801 lines
25 KiB
Vue
801 lines
25 KiB
Vue
<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 温度墙默认 100,GPU 默认 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 {
|
||
/** 分组 key:cpu / 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-root(100vw 旧窗口宽度)压缩:否则 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>
|