Files
Thing/src/modules/monitor/OsdWindow.vue
T
2026-09-12 11:05:26 +08:00

801 lines
25 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
import { listen, emit, type UnlistenFn } from '@tauri-apps/api/event'
import { getCurrentWindow } from '@tauri-apps/api/window'
import { invoke } from '@tauri-apps/api/core'
import { EVENTS, WINDOWS } from '@/lib/constants'
// ===== 数据契约(与主窗口 MonitorModule 共享,此处独立声明避免循环依赖) =====
interface OsdItem {
key: string
groupId: string
sensorName: string
hardwareName: string
type: string
unit: string
special?: 'net-up' | 'net-down'
}
interface ColorTheme {
hardware: Record<string, string>
sensor: Record<string, string>
}
/** 警告色配置:阈值百分比 + 警告/严重颜色 */
interface AlertConfig {
/** 警告色开关 */
enabled: boolean
/** 警告阈值百分比(达到即变警告色,如 80) */
warnThreshold: number
/** 严重阈值百分比(达到即变严重色,如 90) */
criticalThreshold: number
/** 警告色(淡红,hex */
warnColor: string
/** 严重色(大红,hex */
criticalColor: string
/** 各硬件类型的最大值(用于将温度等非百分比值转为百分比)
* CPU 温度墙默认 100GPU 默认 85 */
maxValues: Record<string, number>
}
interface OsdConfig {
overlayEnabled: boolean
overlayItems: OsdItem[]
positionXPct: number
positionYPct: number
overlayOpacity: number
fontSize: number
showUnit: boolean
showLabel: boolean
labelLanguage: 'zh' | 'en'
/** 布局:'single' 单行分组式(组间用 | 分隔,固定宽度),
* 'group' 分组横排(标题在上+数据列在下),'multiline' 多行(每组一行,左对齐,类小飞机) */
layout: 'single' | 'group' | 'multiline'
updateIntervalMs: number
/** 鼠标穿透:true 时窗口不接收鼠标事件(需关闭穿透才能拖动) */
clickThrough: boolean
/** 默认文字颜色(hex),颜色主题关闭时使用 */
fontColor: string
/** 字体不透明度 0-100(独立于悬浮窗透明度) */
fontOpacity: number
/** 悬浮窗背景色(CSS 颜色字符串,如 rgba(0,0,0,0.55) */
bgColor: string
/** 启用颜色主题(按硬件/传感器类型着色) */
colorThemeEnabled: boolean
/** 颜色主题配置 */
colorTheme: ColorTheme
/** 字体描边开关(默认关闭) */
fontStrokeEnabled: boolean
/** 字体描边厚度(px,默认 1) */
fontStrokeWidth: number
/** 字体描边颜色(hex,默认 #000000 */
fontStrokeColor: string
/** 警告色配置 */
alert: AlertConfig
/** 悬浮窗窗口保存位置(null=使用百分比计算默认位置) */
overlayX?: number | null
overlayY?: number | null
}
interface NetworkSpeed {
downloadBps: number
uploadBps: number
}
/** 配置通道载荷(低频:配置变化时推送) */
interface OsdStatePayload {
config: OsdConfig
}
/** 数据通道载荷(高频:仅显示项 key→value 映射 + 网速) */
interface OsdDataPayload {
data: Record<string, number | null>
networkSpeed: NetworkSpeed | null
}
// ===== 默认颜色主题(小飞机风格) =====
const DEFAULT_COLOR_THEME: ColorTheme = {
hardware: {
cpu: '#4A9EFF',
gpuintel: '#9D4EFF',
gpuamd: '#9D4EFF',
gpunvidia: '#9D4EFF',
memory: '#FF9F4A',
storage: '#4AFF9F',
motherboard: '#FFD700',
superio: '#B0B0B0',
embeddedcontroller: '#B0B0B0',
battery: '#FF4A9F',
network: '#4AFFFF',
psu: '#FF4A4A',
},
sensor: {
temperature: '#FF6B6B',
load: '#4A9EFF',
power: '#FFD700',
voltage: '#9D4EFF',
fan: '#B0B0B0',
clock: '#4AFF9F',
data: '#FF9F4A',
smalldata: '#FF9F4A',
throughput: '#4AFFFF',
level: '#FF4A9F',
control: '#FFA500',
frequency: '#4AFF9F',
factor: '#FF4A4A',
timespan: '#B0B0B0',
energy: '#FFD700',
noise: '#B0B0B0',
conductivity: '#4AFFFF',
humidity: '#4A9EFF',
flow: '#4AFFFF',
},
}
/** 默认警告色配置:CPU 温度墙 100°C,GPU 85°C;百分比类直接用值 */
const DEFAULT_ALERT_CONFIG: AlertConfig = {
enabled: false,
warnThreshold: 80,
criticalThreshold: 90,
warnColor: '#FF6B6B',
criticalColor: '#FF0000',
maxValues: {
cpu: 100,
gpu: 85,
gpuintel: 85,
gpuamd: 85,
gpunvidia: 85,
},
}
/** 计算某项的警告百分比(0-100)
* 百分比类传感器(load/level)直接用值;温度等需除以该硬件类型的最大值 */
function itemAlertPct(item: OsdItem, value: number): number {
if (value == null || !isFinite(value)) return 0
// 百分比类传感器直接用值
if (item.type === 'load' || item.type === 'level') {
return value
}
// 温度等需除以该硬件类型的最大值
if (item.type === 'temperature') {
const alert = config.value?.alert ?? DEFAULT_ALERT_CONFIG
// GPU 系列合并查找
let gkey = item.groupId
if (gkey.startsWith('gpu')) gkey = 'gpu'
const max = alert.maxValues[gkey] ?? alert.maxValues[item.groupId] ?? 100
return (value / max) * 100
}
return 0
}
/** 获取项的警告色(若达到阈值),否则返回 null */
function itemAlertColor(item: OsdItem): string | null {
const alert = config.value?.alert
if (!alert?.enabled) return null
const value = getOsdItemValue(item)
if (value == null || !isFinite(value)) return null
const pct = itemAlertPct(item, value)
if (pct >= alert.criticalThreshold) return alert.criticalColor
if (pct >= alert.warnThreshold) return alert.warnColor
return null
}
// ===== 标签生成(根据 labelLanguage 返回中文通俗标题或英文传感器名) =====
/** 是否英文模式 */
function isEn(): boolean {
return config.value?.labelLanguage === 'en'
}
// ===== 分组(group / multiline / single 布局用) =====
interface OsdGroup {
/** 分组 keycpu / gpu / memory / storage / network / ... */
key: string
/** 分组标题:CPU / GPU / 内存 / 网络 ... */
label: string
items: OsdItem[]
}
/** 将显示项按硬件类型分组(GPU 系列合并为 gpu,网速特殊项归入 network */
function groupOsdItems(items: OsdItem[]): OsdGroup[] {
const groups: OsdGroup[] = []
const map = new Map<string, OsdGroup>()
for (const item of items) {
let gkey: string
if (item.special === 'net-up' || item.special === 'net-down') {
gkey = 'network'
} else if (item.groupId.startsWith('gpu')) {
gkey = 'gpu'
} else {
gkey = item.groupId
}
let g = map.get(gkey)
if (!g) {
g = { key: gkey, label: groupLabel(gkey), items: [] }
map.set(gkey, g)
groups.push(g)
}
g.items.push(item)
}
return groups
}
/** 分组标题 */
function groupLabel(gkey: string): string {
if (isEn()) {
switch (gkey) {
case 'cpu': return 'CPU'
case 'gpu': return 'GPU'
case 'memory': return 'RAM'
case 'storage': return 'DISK'
case 'network': return 'NET'
case 'motherboard': return 'MB'
case 'battery': return 'BAT'
case 'psu': return 'PSU'
default: return gkey.toUpperCase().slice(0, 6)
}
}
switch (gkey) {
case 'cpu': return 'CPU'
case 'gpu': return 'GPU'
case 'memory': return '内存'
case 'storage': return '存储'
case 'network': return '网络'
case 'motherboard': return '主板'
case 'battery': return '电池'
case 'psu': return '电源'
default: return gkey
}
}
/** 网络方向箭头 */
function netArrow(item: OsdItem): string {
if (item.special === 'net-up') return '↑'
if (item.special === 'net-down') return '↓'
return ''
}
// ===== 固定宽度格式化(group / multiline 布局用,避免数据变动导致宽度跳动) =====
/** 右对齐补空格到指定宽度 */
function padNum(s: string, width: number): string {
return s.length >= width ? s : ' '.repeat(width - s.length) + s
}
/** 固定宽度数值(不含单位),按传感器类型决定小数位和宽度 */
function fmtFixedValue(v: number | null, item: OsdItem): string {
if (v == null || !isFinite(v)) return '--'
if (item.special === 'net-up' || item.special === 'net-down') {
// 分割显示的数值部分:6 字符宽度
if (v >= 1_048_576) return padNum((v / 1_048_576).toFixed(2), 6)
if (v >= 1024) return padNum((v / 1024).toFixed(1), 6)
return padNum(v.toFixed(0), 6)
}
switch (item.type) {
case 'load':
case 'level':
return padNum(Math.round(v).toString(), 3) // 0-100 → 3 字符
case 'temperature':
return padNum(Math.round(v).toString(), 3) // 0-150 → 3 字符
case 'power':
return padNum(v.toFixed(2), 6) // 12.34 / 123.45 → 6 字符(支持三位数功耗)
case 'voltage':
return padNum(v.toFixed(2), 5) // 12.34 → 5 字符
case 'clock':
case 'frequency':
return padNum(Math.round(v).toString(), 4) // 4500 → 4 字符
case 'fan':
return padNum(Math.round(v).toString(), 4) // 3500 → 4 字符
case 'data':
case 'smalldata':
return padNum(v.toFixed(1), 5) // 1024.0 → 5 字符
default:
return padNum(v.toFixed(1), 5)
}
}
/** 固定宽度单位(网速分割显示时返回 4 字符单位) */
function fmtFixedUnit(item: OsdItem): string {
if (item.special === 'net-up' || item.special === 'net-down') {
const v = getOsdItemValue(item)
if (v == null || !isFinite(v)) return ' '
if (v >= 1_048_576) return 'MB/s'
if (v >= 1024) return 'KB/s'
return ' B/s'
}
if (!config.value?.showUnit) return ''
switch (item.type) {
case 'temperature': return '°C'
case 'load': return '%'
case 'power': return 'W'
case 'voltage': return 'V'
case 'fan': return 'RPM'
case 'clock':
case 'frequency': return 'MHz'
case 'data':
case 'smalldata': return 'GB'
case 'level': return '%'
default: return item.unit || ''
}
}
// ===== 状态 =====
const config = ref<OsdConfig | null>(null)
/** 数据通道:显示项 key→value 映射(由主窗口每秒推送,替代全量快照) */
const dataMap = ref<Record<string, number | null>>({})
const networkSpeed = ref<NetworkSpeed | null>(null)
let unlistenFns: UnlistenFn[] = []
// ===== 数据查询 =====
function getOsdItemValue(item: OsdItem): number | null {
if (item.special === 'net-up') return networkSpeed.value?.uploadBps ?? null
if (item.special === 'net-down') return networkSpeed.value?.downloadBps ?? null
return dataMap.value[item.key] ?? null
}
// ===== 颜色主题 =====
/** 将 hex 颜色 + 不透明度(0-100) 转为 rgba 字符串 */
function withOpacity(hex: string, opacityPct: number): string {
const a = Math.max(0, Math.min(100, opacityPct)) / 100
// 解析 #RGB / #RRGGBB / #RRGGBBAA
let h = hex.replace('#', '').trim()
if (h.length === 3) h = h.split('').map(c => c + c).join('')
if (h.length === 8) {
// 已含 alpha,先解析再用新 alpha 覆盖
h = h.slice(0, 6)
}
if (h.length !== 6 || /[^0-9a-fA-F]/.test(h)) return hex // 解析失败,原样返回
const r = parseInt(h.slice(0, 2), 16)
const g = parseInt(h.slice(2, 4), 16)
const b = parseInt(h.slice(4, 6), 16)
return `rgba(${r}, ${g}, ${b}, ${a})`
}
function itemColor(item: OsdItem): string {
const opacity = config.value?.fontOpacity ?? 100
if (!config.value?.colorThemeEnabled) return withOpacity(config.value?.fontColor ?? '#ffffff', opacity)
const theme = config.value.colorTheme ?? DEFAULT_COLOR_THEME
// 优先按硬件类型着色
const hwColor = theme.hardware[item.groupId]
if (hwColor) return withOpacity(hwColor, opacity)
// 其次按传感器类型着色
const sensorColor = theme.sensor[item.type]
if (sensorColor) return withOpacity(sensorColor, opacity)
return withOpacity(config.value?.fontColor ?? '#ffffff', opacity)
}
/** 项颜色(含警告色优先:达到阈值时覆盖为警告/严重色) */
function itemColorWithAlert(item: OsdItem): string {
const opacity = config.value?.fontOpacity ?? 100
const alertCol = itemAlertColor(item)
if (alertCol) return withOpacity(alertCol, opacity)
return itemColor(item)
}
/** 分组标题警告色:组内任一项达到严重阈值则标题变严重色,达到警告阈值则变警告色,否则用默认色 */
function groupLabelColor(g: OsdGroup): string {
const opacity = config.value?.fontOpacity ?? 100
const alert = config.value?.alert
if (!alert?.enabled) return withOpacity(config.value?.fontColor ?? '#ffffff', opacity)
let maxPct = 0
for (const item of g.items) {
const v = getOsdItemValue(item)
if (v == null || !isFinite(v)) continue
const pct = itemAlertPct(item, v)
if (pct > maxPct) maxPct = pct
}
if (maxPct >= alert.criticalThreshold) return withOpacity(alert.criticalColor, opacity)
if (maxPct >= alert.warnThreshold) return withOpacity(alert.warnColor, opacity)
return withOpacity(config.value?.fontColor ?? '#ffffff', opacity)
}
const fontColor = computed(() => withOpacity(config.value?.fontColor ?? '#ffffff', config.value?.fontOpacity ?? 100))
/** 字体描边样式(通过 8 方向 text-shadow 实现描边效果) */
const fontStrokeStyle = computed(() => {
if (!config.value?.fontStrokeEnabled) return ''
const w = config.value?.fontStrokeWidth ?? 1
const c = config.value?.fontStrokeColor ?? '#000000'
// 8 方向阴影构成描边
const s = `${w}px 0 0 ${c}, -${w}px 0 0 ${c}, 0 ${w}px 0 ${c}, 0 -${w}px 0 ${c}, ${w}px ${w}px 0 ${c}, -${w}px -${w}px 0 ${c}, ${w}px -${w}px 0 ${c}, -${w}px ${w}px 0 ${c}`
return `text-shadow: ${s}`
})
/** 分组后的显示项(group / multiline 布局用) */
const osdGroups = computed<OsdGroup[]>(() => {
if (!config.value?.overlayItems?.length) return []
return groupOsdItems(config.value.overlayItems)
})
// ===== 窗口拖动(右键长按触发,由 Rust 后端检测并发事件) =====
async function startNativeDrag() {
try {
await getCurrentWindow().startDragging()
} catch {
/* 忽略 */
}
}
// ===== 实际内容尺寸测量与上报(替代不准确的估算) =====
const osdRootEl = ref<HTMLElement | null>(null)
let measureTimer: ReturnType<typeof setTimeout> | null = null
/** 测量 osd-bar 实际渲染尺寸,上报给主窗口调整窗口大小 */
async function measureAndReportSize() {
await nextTick()
const root = osdRootEl.value
if (!root) return
const bar = root.querySelector<HTMLElement>('.osd-bar')
if (!bar) return
// 用 getBoundingClientRect 取实际尺寸(逻辑像素)
const rect = bar.getBoundingClientRect()
if (rect.width === 0 || rect.height === 0) return
// 额外留 1px 余量避免边缘裁切
await emit(EVENTS.osdContentSize, { width: Math.ceil(rect.width) + 1, height: Math.ceil(rect.height) + 1 })
}
/** 防抖测量(数据频繁更新时合并) */
function scheduleMeasure() {
if (measureTimer) clearTimeout(measureTimer)
measureTimer = setTimeout(() => {
measureTimer = null
void measureAndReportSize()
}, 50)
}
/** 内容尺寸监视器:内核未启动时显示占位符(如 '--' 很短),数据就绪后实际值更长,
* 导致 osd-bar 变宽。仅靠配置变化触发测量不够——需监听 osd-bar 尺寸变化,
* 任何内容变宽/变高(数据加载、配置变更)都自动重测上报,驱动主窗口放大。 */
let resizeObserver: ResizeObserver | null = null
function observeBarSize() {
resizeObserver?.disconnect()
resizeObserver = null
const root = osdRootEl.value
const bar = root?.querySelector<HTMLElement>('.osd-bar')
if (!bar) return
resizeObserver = new ResizeObserver(() => scheduleMeasure())
resizeObserver.observe(bar)
}
// 根容器就绪后(config 到达、v-if 挂载)开始监视 osd-bar 尺寸变化
watch(osdRootEl, () => { void nextTick(observeBarSize) })
// ===== 应用鼠标穿透 =====
// 同时调用 Tauri setIgnoreCursorEvents(处理 webview2 子窗口)和 Rust WS_EX_TRANSPARENT(处理原生窗口)
// 仅靠原生 WS_EX_TRANSPARENT 不足:Tauri 窗口包含 webview2 子窗口,需两者都设置才能完全穿透
async function applyClickThrough(ignore: boolean) {
// 1. Tauri API:处理 webview2 层的鼠标事件穿透
try {
await getCurrentWindow().setIgnoreCursorEvents(ignore)
} catch (e) {
console.error('[OSD] setIgnoreCursorEvents 失败:', e)
}
// 2. Rust 原生:设置 WS_EX_TRANSPARENT 扩展样式(更可靠的原生层穿透)
try {
await invoke('osd_set_click_through', { label: WINDOWS.osdOverlay, enabled: ignore })
} catch (e) {
console.error('[OSD] osd_set_click_through 失败:', e)
}
}
// ===== 应用置顶(使用 Rust 原生命令) =====
async function applyTopmost(topmost: boolean) {
try {
await invoke('osd_set_topmost', { label: WINDOWS.osdOverlay, topmost })
} catch (e) {
console.error('[OSD] 设置置顶失败:', e)
}
}
// 监听配置变化:应用点击穿透
watch(() => config.value?.clickThrough, (ignore) => {
if (ignore === undefined) return
applyClickThrough(Boolean(ignore))
})
onMounted(async () => {
// 应用原生样式(NoActivate + ToolWindow,不获取焦点)
try {
await invoke('osd_apply_overlay_style', { label: WINDOWS.osdOverlay })
} catch (e) {
console.error('[OSD] 应用原生样式失败:', e)
}
// 默认置顶
await applyTopmost(true)
// 启动任务栏覆盖监视(系统 UI 出现时暂时取消置顶)
try {
await invoke('osd_start_topmost_watch')
} catch (e) {
console.error('[OSD] 启动置顶监视失败:', e)
}
// 启动游戏全屏监视(前台全屏应用时通知主窗口隐藏 OSD,避免游戏掉帧)
try {
await invoke('osd_start_game_watch')
} catch (e) {
console.error('[OSD] 启动游戏全屏监视失败:', e)
}
// 监听主窗口推送的 OSD 配置(低频通道)
unlistenFns.push(await listen<OsdStatePayload>(EVENTS.osdStateUpdate, (e) => {
config.value = e.payload.config
// 配置变化(字号/布局/显示项)后重新测量尺寸
scheduleMeasure()
}))
// 监听主窗口推送的 OSD 数据(高频通道:key→value 映射 + 网速)
unlistenFns.push(await listen<OsdDataPayload>(EVENTS.osdDataUpdate, (e) => {
dataMap.value = e.payload.data
networkSpeed.value = e.payload.networkSpeed
}))
// 监听系统 UI 覆盖事件
unlistenFns.push(await listen(EVENTS.osdSystemUiActive, async () => {
await applyTopmost(false)
}))
unlistenFns.push(await listen(EVENTS.osdSystemUiInactive, async () => {
await applyTopmost(true)
}))
// 监听注册完成后,主动请求主窗口补发配置+数据
// (数据通道不含配置;若窗口加载慢错过创建时的首推,需主动请求,否则会一直空白)
await emit(EVENTS.osdConfigRequest)
})
/** 鼠标按下:仅在关闭穿透时响应左键拖动 */
async function onMouseDown(e: MouseEvent) {
if (e.button !== 0) return // 仅左键
if (config.value?.clickThrough) return // 穿透模式下不响应
e.preventDefault()
// 确保穿透已关闭(双保险:防止穿透状态不同步导致 startDragging 无效)
await applyClickThrough(false)
// 使用 Tauri 官方 startDragging API(比 SendMessageW(WM_NCLBUTTONDOWN) 从其他线程调用更可靠)
await startNativeDrag()
}
onUnmounted(() => {
unlistenFns.forEach(fn => fn())
// 停止监视线程
invoke('osd_stop_watch').catch(() => {})
// 断开内容尺寸监视器
resizeObserver?.disconnect()
resizeObserver = null
})
</script>
<template>
<div
v-if="config"
ref="osdRootEl"
class="osd-root select-none"
:style="`font-size: ${config.fontSize}px; ${fontStrokeStyle}`"
@mousedown="onMouseDown"
>
<!-- ===== 单行布局分组式组间用 | 分隔固定宽度 ===== -->
<div
v-if="config.overlayItems.length && config.layout === 'single'"
class="osd-bar osd-bar-single"
:style="{ background: config.bgColor }"
>
<template v-for="(g, gi) in osdGroups" :key="g.key">
<span v-if="gi > 0" class="osd-sg-sep" :style="{ color: fontColor }">|</span>
<span class="osd-sg-group">
<span v-if="config.showLabel" class="osd-sg-label" :style="{ color: groupLabelColor(g) }">{{ g.label }}</span>
<span
v-for="item in g.items"
:key="item.key"
class="osd-sg-item"
:style="{ color: itemColorWithAlert(item) }"
>
<span v-if="netArrow(item)" class="osd-net-arrow">{{ netArrow(item) }}</span>
<span class="osd-fixed-num">{{ fmtFixedValue(getOsdItemValue(item), item) }}</span>
<span class="osd-fixed-unit">{{ fmtFixedUnit(item) }}</span>
</span>
</span>
</template>
</div>
<!-- ===== 分组横排布局每组标题在上 + 数据列在下固定宽度 ===== -->
<div
v-else-if="config.overlayItems.length && config.layout === 'group'"
class="osd-bar osd-bar-group"
:style="{ background: config.bgColor }"
>
<div v-for="g in osdGroups" :key="g.key" class="osd-group">
<div class="osd-group-header" :style="{ color: groupLabelColor(g) }">{{ g.label }}</div>
<div class="osd-group-data">
<span
v-for="item in g.items"
:key="item.key"
class="osd-group-item"
:style="{ color: itemColorWithAlert(item) }"
>
<span v-if="netArrow(item)" class="osd-net-arrow">{{ netArrow(item) }}</span>
<span class="osd-fixed-num">{{ fmtFixedValue(getOsdItemValue(item), item) }}</span>
<span class="osd-fixed-unit">{{ fmtFixedUnit(item) }}</span>
</span>
</div>
</div>
</div>
<!-- ===== 多行布局每组一行左对齐类小飞机 ===== -->
<div
v-else-if="config.overlayItems.length && config.layout === 'multiline'"
class="osd-bar osd-bar-multiline"
:style="{ background: config.bgColor }"
>
<div v-for="g in osdGroups" :key="g.key" class="osd-line">
<span class="osd-line-label" :style="{ color: groupLabelColor(g) }">{{ g.label }}</span>
<span
v-for="item in g.items"
:key="item.key"
class="osd-line-item"
:style="{ color: itemColorWithAlert(item) }"
>
<span v-if="netArrow(item)" class="osd-net-arrow">{{ netArrow(item) }}</span>
<span class="osd-fixed-num">{{ fmtFixedValue(getOsdItemValue(item), item) }}</span>
<span class="osd-fixed-unit">{{ fmtFixedUnit(item) }}</span>
</span>
</div>
</div>
<!-- 无显示项占位 -->
<div v-else class="osd-empty">未配置显示项</div>
</div>
</template>
<style scoped>
/* 根容器:填满窗口 */
.osd-root {
font-family: 'Cascadia Code', 'Consolas', 'Microsoft YaHei', monospace;
font-variant-numeric: tabular-nums;
background: transparent;
width: 100vw;
height: 100vh;
overflow: hidden;
display: flex;
align-items: flex-start;
justify-content: flex-start;
padding: 0;
}
/* OSD 容器:inline-flex 让宽度自适应内容,每项紧凑排列 */
.osd-bar {
display: inline-flex;
flex-wrap: nowrap;
align-items: stretch;
/* 文本永不换行:配置变更(如中英文切换)到窗口 resize 之间存在异步窗口期,
若允许换行,中文标签(内存/网络)会在旧窗口宽度内竖排;禁止后仅临时溢出,
随内容测量上报触发 setSize 立即恢复 */
white-space: nowrap;
/* 不被 osd-root100vw 旧窗口宽度)压缩:否则 getBoundingClientRect 测到的是
被旧窗口钳制的宽度而非真实内容宽度,上报后 setSize 不变,窗口永远无法变宽 */
flex-shrink: 0;
backdrop-filter: blur(8px);
padding: 3px 4px;
gap: 0;
}
/* ===== 单行布局:分组式,组间用 | 分隔 ===== */
.osd-bar-single {
align-items: baseline;
gap: 4px;
}
.osd-sg-sep {
opacity: 0.4;
font-weight: 400;
}
.osd-sg-group {
display: inline-flex;
align-items: baseline;
gap: 3px;
}
.osd-sg-label {
font-weight: 400;
opacity: 0.7;
margin-right: 1px;
}
.osd-sg-item {
display: inline-flex;
align-items: baseline;
gap: 1px;
}
/* ===== 分组横排布局:每组标题在上 + 数据列在下 ===== */
.osd-bar-group {
display: inline-flex;
flex-wrap: nowrap;
align-items: stretch;
gap: 8px;
}
.osd-group {
display: flex;
flex-direction: column;
gap: 2px;
padding: 0 4px;
}
.osd-group-header {
font-size: 0.85em;
font-weight: 400;
text-align: center;
opacity: 0.7;
border-bottom: 1px solid rgba(255, 255, 255, 0.15);
padding-bottom: 1px;
white-space: nowrap;
}
.osd-group-data {
display: flex;
align-items: baseline;
gap: 4px;
}
.osd-group-item {
display: inline-flex;
align-items: baseline;
gap: 1px;
}
/* ===== 多行布局:每组一行,左对齐,类小飞机 ===== */
.osd-bar-multiline {
display: inline-flex;
flex-direction: column;
gap: 1px;
}
.osd-line {
display: flex;
align-items: baseline;
gap: 4px;
white-space: pre;
}
.osd-line-label {
font-weight: 400;
opacity: 0.7;
min-width: 4ch;
text-align: left;
}
.osd-line-item {
display: inline-flex;
align-items: baseline;
gap: 1px;
}
/* ===== 固定宽度文本(group / multiline 布局用,white-space: pre 保留 padNum 补的空格) ===== */
.osd-fixed-num {
font-weight: 600;
white-space: pre;
}
.osd-fixed-unit {
opacity: 0.7;
white-space: pre;
margin-left: 1px;
}
.osd-net-arrow {
margin-right: 1px;
}
.osd-empty {
color: rgba(255, 255, 255, 0.5);
font-size: 12px;
padding: 8px 12px;
backdrop-filter: blur(8px);
border-radius: 4px;
}
</style>