性能优化
This commit is contained in:
@@ -12,11 +12,19 @@ import { toast } from 'vue-sonner'
|
||||
import { VueDraggable } from 'vue-draggable-plus'
|
||||
import { appDataDir } from '@tauri-apps/api/path'
|
||||
import { revealItemInDir } from '@tauri-apps/plugin-opener'
|
||||
import { WebviewWindow } from '@tauri-apps/api/webviewWindow'
|
||||
import { emit, listen, type UnlistenFn } from '@tauri-apps/api/event'
|
||||
import { currentMonitor, LogicalPosition, LogicalSize } from '@tauri-apps/api/window'
|
||||
import { useMonitorStore, type SensorEntry, type SensorGroup, type ConnectionState } from '@/stores/monitorStore'
|
||||
import { useModuleTabs } from '@/lib/useModuleTabs'
|
||||
import {
|
||||
useMonitorStore,
|
||||
type SensorEntry,
|
||||
type SensorGroup,
|
||||
type ConnectionState,
|
||||
type OsdConfig,
|
||||
type OsdItem,
|
||||
type ColorTheme,
|
||||
type AlertConfig,
|
||||
DEFAULT_COLOR_THEME,
|
||||
} from '@/stores/monitorStore'
|
||||
import { useModuleTabs } from '@/lib/use-module-tabs'
|
||||
import { fmt, tempColor, loadColor, fmtSpeed, typeLabel, groupDisplayName, groupIcon } from './format'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
@@ -185,90 +193,6 @@ const storageDrives = computed<StorageDrive[]>(() => {
|
||||
const downSpeed = computed(() => fmtSpeed(store.networkSpeed?.downloadBps ?? null))
|
||||
const upSpeed = computed(() => fmtSpeed(store.networkSpeed?.uploadBps ?? null))
|
||||
|
||||
// ===== 工具函数 =====
|
||||
|
||||
/** 格式化数值:整数型指标(负载/温度)保留 0 位,浮点型(电压/功率)保留 2 位 */
|
||||
function fmt(v: number | null, digits = 1): string {
|
||||
if (v == null || !isFinite(v)) return '--'
|
||||
return v.toFixed(digits)
|
||||
}
|
||||
|
||||
/** 温度颜色:绿(<50) → 黄(<70) → 橙(<85) → 红(>=85) */
|
||||
function tempColor(t: number | null): string {
|
||||
if (t == null) return 'text-muted-foreground'
|
||||
if (t < 50) return 'text-emerald-500'
|
||||
if (t < 70) return 'text-yellow-500'
|
||||
if (t < 85) return 'text-orange-500'
|
||||
return 'text-red-500'
|
||||
}
|
||||
|
||||
/** 负载颜色:蓝(<50) → 紫(<80) → 红(>=80) */
|
||||
function loadColor(v: number | null): string {
|
||||
if (v == null) return 'text-muted-foreground'
|
||||
if (v < 50) return 'text-sky-500'
|
||||
if (v < 80) return 'text-violet-500'
|
||||
return 'text-red-500'
|
||||
}
|
||||
|
||||
/** 格式化网速(bytes/s → 自适应 KB/s 或 MB/s) */
|
||||
function fmtSpeed(bytesPerSec: number | null): { value: string; unit: string } {
|
||||
if (bytesPerSec == null || !isFinite(bytesPerSec)) return { value: '--', unit: '' }
|
||||
if (bytesPerSec >= 1_048_576) return { value: (bytesPerSec / 1_048_576).toFixed(2), unit: 'MB/s' }
|
||||
if (bytesPerSec >= 1024) return { value: (bytesPerSec / 1024).toFixed(1), unit: 'KB/s' }
|
||||
return { value: bytesPerSec.toFixed(0), unit: 'B/s' }
|
||||
}
|
||||
|
||||
/** 传感器类型 → 中文标签 */
|
||||
const typeLabels: Record<string, string> = {
|
||||
temperature: '温度',
|
||||
load: '负载',
|
||||
power: '功率',
|
||||
voltage: '电压',
|
||||
fan: '风扇',
|
||||
clock: '时钟',
|
||||
data: '容量',
|
||||
smalldata: '容量',
|
||||
throughput: '吞吐',
|
||||
level: '等级',
|
||||
control: '控制',
|
||||
frequency: '频率',
|
||||
factor: '因子',
|
||||
timespan: '时长',
|
||||
energy: '能量',
|
||||
noise: '噪声',
|
||||
conductivity: '电导率',
|
||||
humidity: '湿度',
|
||||
flow: '流量',
|
||||
}
|
||||
|
||||
function typeLabel(t: string): string {
|
||||
return typeLabels[t] ?? t
|
||||
}
|
||||
|
||||
/** 分组 id → 显示名 + 图标组件 */
|
||||
const groupMeta: Record<string, { name: string; icon: typeof Cpu }> = {
|
||||
cpu: { name: 'CPU', icon: Cpu },
|
||||
memory: { name: '内存', icon: MemoryStick },
|
||||
gpuintel: { name: 'GPU', icon: Gauge },
|
||||
gpuamd: { name: 'GPU', icon: Gauge },
|
||||
gpunvidia: { name: 'GPU', icon: Gauge },
|
||||
storage: { name: '存储', icon: HardDrive },
|
||||
motherboard: { name: '主板', icon: Activity },
|
||||
superio: { name: '超级 IO', icon: Activity },
|
||||
embeddedcontroller: { name: '嵌入式控制器', icon: Activity },
|
||||
battery: { name: '电池', icon: Activity },
|
||||
network: { name: '网络', icon: Activity },
|
||||
psu: { name: '电源', icon: Zap },
|
||||
}
|
||||
|
||||
function groupDisplayName(id: string, fallback: string): string {
|
||||
return groupMeta[id]?.name ?? fallback
|
||||
}
|
||||
|
||||
function groupIcon(id: string): typeof Cpu {
|
||||
return groupMeta[id]?.icon ?? Activity
|
||||
}
|
||||
|
||||
// ===== 连接状态徽章 =====
|
||||
const stateMeta: Record<ConnectionState, { text: string; class: string }> = {
|
||||
idle: { text: '未启动', class: 'bg-muted text-muted-foreground' },
|
||||
@@ -281,14 +205,19 @@ const stateMeta: Record<ConnectionState, { text: string; class: string }> = {
|
||||
// ===== 分组列表(详细页用) =====
|
||||
const groups = computed<SensorGroup[]>(() => store.snapshot?.groups ?? [])
|
||||
|
||||
/** 按 hardwareName 子分组,再按 type 二级分组(详细页用) */
|
||||
/** 按 hardwareName 子分组,再按 type 二级分组(详细页用)。
|
||||
* 分组结果只依赖传感器的静态元数据(硬件名/类型),与数值变化无关;
|
||||
* 以传感器数组引用为键缓存(WeakMap),避免每次渲染对数百传感器全量重算 */
|
||||
const sensorGroupCache = new WeakMap<SensorEntry[], { hardware: string; byType: { type: string; items: SensorEntry[] }[] }[]>()
|
||||
function groupSensors(sensors: SensorEntry[]): { hardware: string; byType: { type: string; items: SensorEntry[] }[] }[] {
|
||||
const cached = sensorGroupCache.get(sensors)
|
||||
if (cached) return cached
|
||||
const byHw = new Map<string, SensorEntry[]>()
|
||||
for (const s of sensors) {
|
||||
if (!byHw.has(s.hardwareName)) byHw.set(s.hardwareName, [])
|
||||
byHw.get(s.hardwareName)!.push(s)
|
||||
}
|
||||
return Array.from(byHw.entries()).map(([hw, items]) => {
|
||||
const result = Array.from(byHw.entries()).map(([hw, items]) => {
|
||||
const byType = new Map<string, SensorEntry[]>()
|
||||
for (const s of items) {
|
||||
if (!byType.has(s.type)) byType.set(s.type, [])
|
||||
@@ -299,6 +228,8 @@ function groupSensors(sensors: SensorEntry[]): { hardware: string; byType: { typ
|
||||
byType: Array.from(byType.entries()).map(([type, list]) => ({ type, items: list })),
|
||||
}
|
||||
})
|
||||
sensorGroupCache.set(sensors, result)
|
||||
return result
|
||||
}
|
||||
|
||||
// ===== Accordion 折叠状态 =====
|
||||
@@ -455,203 +386,12 @@ async function handleSaveConfig() {
|
||||
}
|
||||
}
|
||||
|
||||
// ===== OSD 显示配置 =====
|
||||
// OSD(On-Screen Display)配置:控制传感器数据在桌面悬浮窗中的显示。
|
||||
// 配置持久化到 localStorage,由独立 OsdWindow.vue 消费。
|
||||
|
||||
/** OSD 显示项:从可用传感器中选取并排序 */
|
||||
interface OsdItem {
|
||||
/** 唯一 key:{groupId}/{hardwareName}/{sensorName}/{type} 小写化,或 special 项的固定 key */
|
||||
key: string
|
||||
groupId: string
|
||||
sensorName: string
|
||||
hardwareName: string
|
||||
type: string
|
||||
unit: string
|
||||
/** 特殊项标记:非 Kernel 传感器,由前端直接计算(如网速) */
|
||||
special?: 'net-up' | 'net-down'
|
||||
}
|
||||
|
||||
/** 颜色主题:按硬件/传感器类型着色(类似小飞机风格) */
|
||||
interface ColorTheme {
|
||||
/** 按 groupId 着色:cpu/gpu/memory/storage/... */
|
||||
hardware: Record<string, string>
|
||||
/** 按 sensor type 着色:temperature/load/power/... */
|
||||
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>
|
||||
}
|
||||
|
||||
/** OSD 配置结构 */
|
||||
interface OsdConfig {
|
||||
overlayEnabled: boolean
|
||||
overlayItems: OsdItem[]
|
||||
/** 悬浮窗位置 X 百分比(0=最左,50=居中,100=最右) */
|
||||
positionXPct: number
|
||||
/** 悬浮窗位置 Y 百分比(0=最上,50=居中,100=最下) */
|
||||
positionYPct: number
|
||||
fontSize: number
|
||||
showUnit: boolean
|
||||
showLabel: boolean
|
||||
/** 标题语言:'zh' 中文 / 'en' 英文(原始传感器名) */
|
||||
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
|
||||
}
|
||||
|
||||
const OSD_STORAGE_KEY = 'thing_monitor_osd_config'
|
||||
const OSD_CONFIG_VERSION = 11
|
||||
|
||||
/** 默认颜色主题(小飞机风格:不同硬件不同颜色,不同传感器不同颜色) */
|
||||
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: true,
|
||||
warnThreshold: 80,
|
||||
criticalThreshold: 90,
|
||||
warnColor: '#FF6B6B',
|
||||
criticalColor: '#FF0000',
|
||||
maxValues: {
|
||||
cpu: 100,
|
||||
gpu: 85,
|
||||
gpuintel: 85,
|
||||
gpuamd: 85,
|
||||
gpunvidia: 85,
|
||||
},
|
||||
}
|
||||
|
||||
function defaultOsdConfig(): OsdConfig {
|
||||
return {
|
||||
overlayEnabled: false,
|
||||
overlayItems: [],
|
||||
// 默认顶部居中(top 0):水平 50%,垂直 0%
|
||||
positionXPct: 50,
|
||||
positionYPct: 0,
|
||||
fontSize: 14,
|
||||
showUnit: true,
|
||||
showLabel: true,
|
||||
labelLanguage: 'zh',
|
||||
layout: 'single',
|
||||
updateIntervalMs: 1000,
|
||||
// 默认关闭点击穿透:关闭后左键可直接拖动悬浮窗
|
||||
clickThrough: false,
|
||||
fontColor: '#ffffff',
|
||||
fontOpacity: 100,
|
||||
bgColor: 'transparent',
|
||||
colorThemeEnabled: true,
|
||||
colorTheme: { ...DEFAULT_COLOR_THEME },
|
||||
fontStrokeEnabled: false,
|
||||
fontStrokeWidth: 1,
|
||||
fontStrokeColor: '#000000',
|
||||
alert: { ...DEFAULT_ALERT_CONFIG, maxValues: { ...DEFAULT_ALERT_CONFIG.maxValues } },
|
||||
overlayX: null,
|
||||
overlayY: null,
|
||||
}
|
||||
}
|
||||
|
||||
function loadOsdConfig(): OsdConfig {
|
||||
try {
|
||||
const saved = localStorage.getItem(OSD_STORAGE_KEY)
|
||||
if (!saved) return defaultOsdConfig()
|
||||
const parsed = JSON.parse(saved)
|
||||
if (parsed.version !== OSD_CONFIG_VERSION) return defaultOsdConfig()
|
||||
// 合并默认值,确保新增字段有默认值
|
||||
const def = defaultOsdConfig()
|
||||
return { ...def, ...parsed.config }
|
||||
} catch {
|
||||
return defaultOsdConfig()
|
||||
}
|
||||
}
|
||||
|
||||
function saveOsdConfig(cfg: OsdConfig) {
|
||||
try {
|
||||
localStorage.setItem(OSD_STORAGE_KEY, JSON.stringify({
|
||||
version: OSD_CONFIG_VERSION,
|
||||
config: cfg,
|
||||
}))
|
||||
} catch {
|
||||
/* 忽略 localStorage 写入失败 */
|
||||
}
|
||||
}
|
||||
|
||||
const osdConfig = ref<OsdConfig>(loadOsdConfig())
|
||||
// ===== OSD 配置(由 monitorStore 统一管理,组件仅做 UI 展示与修改) =====
|
||||
// 类型/默认值/持久化/窗口管理均在 monitorStore;App 启动时由 store.initOsd() 显式初始化。
|
||||
const osdConfig = computed<OsdConfig>(() => store.osdConfig)
|
||||
// 保存调用点保持简洁的薄包装(内部转发到 store 的持久化函数)
|
||||
const saveOsdConfig = (cfg: OsdConfig) => store.saveOsdConfig(cfg)
|
||||
const saveOsdConfigDebounced = (cfg: OsdConfig) => store.saveOsdConfigDebounced(cfg)
|
||||
|
||||
/** 传感器名称中英文字典(覆盖常见 LHB 传感器名 + 硬件名) */
|
||||
const SENSOR_NAME_ZH: Record<string, string> = {
|
||||
@@ -1056,7 +796,7 @@ const availableSensors = computed<AvailableSensor[]>(() => {
|
||||
for (const g of store.snapshot?.groups ?? []) {
|
||||
// 悬浮窗不显示存储分组(硬盘容量/温度等已在主界面监控,OSD 场景无需)
|
||||
if (g.id === 'storage') continue
|
||||
const groupName = groupMeta[g.id]?.name ?? g.name
|
||||
const groupName = groupDisplayName(g.id, g.name)
|
||||
for (const s of g.sensors) {
|
||||
const key = `${g.id}/${s.hardwareName}/${s.name}/${s.type}`.replace(/\s+/g, '_').toLowerCase()
|
||||
list.push({
|
||||
@@ -1188,10 +928,10 @@ function removeOsdItem(key: string) {
|
||||
}
|
||||
}
|
||||
|
||||
/** OSD 配置项变更时自动保存 */
|
||||
/** OSD 配置项变更时自动保存(防抖:滑块拖动期间不逐帧写盘) */
|
||||
function updateOsdConfig(field: keyof OsdConfig, value: unknown) {
|
||||
;(osdConfig.value as Record<string, unknown>)[field] = value
|
||||
saveOsdConfig(osdConfig.value)
|
||||
;(osdConfig.value as unknown as Record<string, unknown>)[field] = value
|
||||
saveOsdConfigDebounced(osdConfig.value)
|
||||
}
|
||||
|
||||
/** 解析背景色字符串为 hex + alpha(0-100) */
|
||||
@@ -1272,276 +1012,7 @@ function osdItemColor(item: OsdItem): string {
|
||||
return withOpacity(osdConfig.value.fontColor, opacity)
|
||||
}
|
||||
|
||||
// ===== OSD 窗口管理(实际创建/隐藏 Tauri 窗口并推送数据) =====
|
||||
const OSD_OVERLAY_LABEL = 'osd-overlay'
|
||||
/** 抑制百分比 watch 的程序定位标志:拖动 onMoved 更新百分比时置 true,避免触发 resetOverlayPosition 循环 */
|
||||
let suppressPercentWatch = false
|
||||
|
||||
/** 构建用于 OSD 窗口的 URL(基于当前页面 URL 替换 hash) */
|
||||
function osdUrl(hash: string): string {
|
||||
const base = window.location.href.split('#')[0]
|
||||
return `${base}#${hash}`
|
||||
}
|
||||
|
||||
/** 推送当前 OSD 状态到所有 OSD 窗口 */
|
||||
async function pushOsdState() {
|
||||
const payload = {
|
||||
config: osdConfig.value,
|
||||
snapshot: store.snapshot,
|
||||
networkSpeed: store.networkSpeed,
|
||||
}
|
||||
try {
|
||||
await emit('osd-state-update', payload)
|
||||
} catch (e) {
|
||||
console.error('[OSD] 推送状态失败:', e)
|
||||
}
|
||||
}
|
||||
|
||||
/** 根据百分比位置计算窗口坐标 */
|
||||
function computePositionFromPct(screenW: number, screenH: number, w: number, h: number, xPct: number, yPct: number): { x: number; y: number } {
|
||||
// 百分比基于可用空间(屏幕尺寸 - 窗口尺寸),确保窗口不会被定位到屏幕外
|
||||
const availW = Math.max(0, screenW - w)
|
||||
const availH = Math.max(0, screenH - h)
|
||||
return {
|
||||
x: Math.round((availW * xPct) / 100),
|
||||
y: Math.round((availH * yPct) / 100),
|
||||
}
|
||||
}
|
||||
|
||||
/** 根据显示项估算悬浮窗窗口尺寸(逻辑像素)
|
||||
* single: 单行分组式,组间用 | 分隔,固定宽度数据列
|
||||
* group: 分组横排,标题在上 + 数据列在下
|
||||
* multiline: 多行,每组一行,标题 + 固定宽度数据列 */
|
||||
function computeOsdWindowSize(
|
||||
_itemCount: number,
|
||||
layout: 'single' | 'group' | 'multiline',
|
||||
fontSize: number,
|
||||
_hasNetItem = false,
|
||||
items?: OsdItem[],
|
||||
): { w: number; h: number } {
|
||||
const charW = fontSize * 0.62
|
||||
const barHPad = 8 // osd-bar 左右 padding 4*2
|
||||
|
||||
// 按硬件类型分组(与渲染逻辑一致)
|
||||
const groupMap = new Map<string, OsdItem[]>()
|
||||
if (items?.length) {
|
||||
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
|
||||
if (!groupMap.has(gkey)) groupMap.set(gkey, [])
|
||||
groupMap.get(gkey)!.push(item)
|
||||
}
|
||||
}
|
||||
const groupCount = Math.max(1, groupMap.size)
|
||||
|
||||
// 每组数据列宽度:标签(6ch) + 各项(数值+单位+箭头/gap)
|
||||
const groupWidths: number[] = []
|
||||
for (const [, groupItems] of groupMap) {
|
||||
const labelW = 6
|
||||
const dataW = groupItems.reduce((sum, item) => {
|
||||
const isNet = item.special === 'net-up' || item.special === 'net-down'
|
||||
return sum + (isNet ? 11 : 8) + 1
|
||||
}, 0)
|
||||
groupWidths.push(labelW + dataW)
|
||||
}
|
||||
|
||||
if (layout === 'multiline') {
|
||||
// 多行:取最宽行
|
||||
const maxLineW = groupWidths.length ? Math.max(...groupWidths) : 10
|
||||
const w = Math.ceil(maxLineW * charW + barHPad)
|
||||
const lineH = Math.ceil(fontSize + 2)
|
||||
const h = Math.ceil(groupCount * lineH + 6)
|
||||
return { w: Math.max(120, w), h: Math.max(28, h) }
|
||||
}
|
||||
|
||||
if (layout === 'group') {
|
||||
// 分组横排:各组横排 + 标题行
|
||||
const totalW = groupWidths.reduce((s, w) => s + w + 4, 0) + (groupCount - 1) * 4
|
||||
const w = Math.ceil(totalW * charW + barHPad)
|
||||
const titleH = Math.ceil(fontSize * 0.85) + 2
|
||||
const dataH = Math.ceil(fontSize) + 2
|
||||
const h = Math.ceil(titleH + dataH + 10)
|
||||
return { w: Math.max(120, w), h: Math.max(40, h) }
|
||||
}
|
||||
|
||||
// single:单行分组式,各组横排 + 组间 | 分隔符(1ch)
|
||||
const sepW = (groupCount - 1) * 1
|
||||
const totalW = groupWidths.reduce((s, w) => s + w, 0) + sepW
|
||||
const w = Math.ceil(totalW * charW + barHPad)
|
||||
const h = Math.ceil(fontSize + 8)
|
||||
return { w: Math.max(120, w), h: Math.max(28, h) }
|
||||
}
|
||||
|
||||
/** 创建/显示悬浮窗窗口(默认置顶 + NoActivate + 点击穿透) */
|
||||
async function ensureOverlayWindow() {
|
||||
const existing = await WebviewWindow.getByLabel(OSD_OVERLAY_LABEL)
|
||||
if (existing) {
|
||||
// 窗口已存在,仅显示并推送最新状态
|
||||
await existing.show()
|
||||
await updateOsdWindowSize()
|
||||
await pushOsdState()
|
||||
return
|
||||
}
|
||||
|
||||
// 获取屏幕尺寸用于定位
|
||||
const monitor = await currentMonitor()
|
||||
const screenW = monitor?.size.width ?? 1920
|
||||
const screenH = monitor?.size.height ?? 1080
|
||||
const scale = monitor?.scaleFactor ?? 1
|
||||
const logicalW = screenW / scale
|
||||
const logicalH = screenH / scale
|
||||
const hasNetItem = osdConfig.value.overlayItems.some(i => i.special === 'net-up' || i.special === 'net-down')
|
||||
const { w, h } = computeOsdWindowSize(
|
||||
osdConfig.value.overlayItems.length,
|
||||
osdConfig.value.layout,
|
||||
osdConfig.value.fontSize,
|
||||
hasNetItem,
|
||||
osdConfig.value.overlayItems,
|
||||
)
|
||||
|
||||
// 优先使用保存的像素位置;否则根据百分比计算默认位置
|
||||
let x: number, y: number
|
||||
if (osdConfig.value.overlayX != null && osdConfig.value.overlayY != null) {
|
||||
x = osdConfig.value.overlayX
|
||||
y = osdConfig.value.overlayY
|
||||
} else {
|
||||
const pos = computePositionFromPct(logicalW, logicalH, w, h, osdConfig.value.positionXPct, osdConfig.value.positionYPct)
|
||||
x = pos.x
|
||||
y = pos.y
|
||||
}
|
||||
|
||||
const win = new WebviewWindow(OSD_OVERLAY_LABEL, {
|
||||
url: osdUrl('osd-overlay'),
|
||||
title: 'OSD 悬浮窗',
|
||||
width: w,
|
||||
height: h,
|
||||
x,
|
||||
y,
|
||||
decorations: false,
|
||||
transparent: true,
|
||||
// 关闭窗口阴影:Win11 默认会画一圈阴影光晕,透明窗口上表现为可见的"外部框"
|
||||
shadow: false,
|
||||
alwaysOnTop: true,
|
||||
skipTaskbar: true,
|
||||
// 禁用调整大小:移除 Windows 隐形 resize 边框(该边框会拦截鼠标事件导致穿透/拖动失效)
|
||||
resizable: false,
|
||||
visible: true,
|
||||
// 不获取焦点(NoActivate 由 Rust 后端 osd_apply_overlay_style 进一步保证)
|
||||
focus: false,
|
||||
})
|
||||
|
||||
win.once('tauri://created', async () => {
|
||||
// 等待 webview 加载后推送初始状态
|
||||
setTimeout(() => pushOsdState(), 300)
|
||||
// 监听窗口移动,保存像素位置并同步更新百分比(拖动结束后触发)
|
||||
try {
|
||||
const winInstance = await win
|
||||
const unlisten = await winInstance.onMoved(async ({ payload }) => {
|
||||
osdConfig.value.overlayX = payload.x
|
||||
osdConfig.value.overlayY = payload.y
|
||||
// 反算百分比:xPct = x / availW * 100,availW = screenW - windowW
|
||||
// 置 suppressPercentWatch=true 避免百分比变化触发 resetOverlayPosition 循环
|
||||
suppressPercentWatch = true
|
||||
try {
|
||||
const monitor = await currentMonitor()
|
||||
const screenW = (monitor?.size.width ?? 1920) / (monitor?.scaleFactor ?? 1)
|
||||
const screenH = (monitor?.size.height ?? 1080) / (monitor?.scaleFactor ?? 1)
|
||||
const size = await winInstance.outerSize()
|
||||
const scale = monitor?.scaleFactor ?? 1
|
||||
const winW = size.width / scale
|
||||
const winH = size.height / scale
|
||||
const availW = Math.max(1, screenW - winW)
|
||||
const availH = Math.max(1, screenH - winH)
|
||||
osdConfig.value.positionXPct = Math.round((payload.x / availW) * 100)
|
||||
osdConfig.value.positionYPct = Math.round((payload.y / availH) * 100)
|
||||
} catch { /* 忽略百分比反算失败 */ }
|
||||
saveOsdConfig(osdConfig.value)
|
||||
// 下一个微任务后解除抑制(让本次 watch 回调跳过即可)
|
||||
queueMicrotask(() => { suppressPercentWatch = false })
|
||||
})
|
||||
osdEventUnlisteners.push(unlisten)
|
||||
} catch { /* 忽略 */ }
|
||||
})
|
||||
win.once('tauri://error', (e: unknown) => {
|
||||
console.error('[OSD] 悬浮窗创建失败:', e)
|
||||
toast.error('悬浮窗创建失败')
|
||||
})
|
||||
}
|
||||
|
||||
/** 隐藏悬浮窗 */
|
||||
async function hideOverlayWindow() {
|
||||
const existing = await WebviewWindow.getByLabel(OSD_OVERLAY_LABEL)
|
||||
if (existing) {
|
||||
await existing.hide()
|
||||
}
|
||||
}
|
||||
|
||||
/** 根据当前配置更新悬浮窗窗口尺寸(显示项数量/布局/字号变化时调用) */
|
||||
async function updateOsdWindowSize() {
|
||||
try {
|
||||
const existing = await WebviewWindow.getByLabel(OSD_OVERLAY_LABEL)
|
||||
if (!existing) return
|
||||
const hasNetItem = osdConfig.value.overlayItems.some(i => i.special === 'net-up' || i.special === 'net-down')
|
||||
const { w, h } = computeOsdWindowSize(
|
||||
osdConfig.value.overlayItems.length,
|
||||
osdConfig.value.layout,
|
||||
osdConfig.value.fontSize,
|
||||
hasNetItem,
|
||||
osdConfig.value.overlayItems,
|
||||
)
|
||||
await existing.setSize(new LogicalSize(w, h))
|
||||
} catch { /* 忽略 */ }
|
||||
}
|
||||
|
||||
/** 重置悬浮窗位置到默认(百分比位置),清除保存的像素位置
|
||||
* 仅重新定位,不改变尺寸——尺寸由悬浮窗内容实际测量上报维持 */
|
||||
async function resetOverlayPosition() {
|
||||
osdConfig.value.overlayX = null
|
||||
osdConfig.value.overlayY = null
|
||||
saveOsdConfig(osdConfig.value)
|
||||
// 重新定位窗口
|
||||
try {
|
||||
const existing = await WebviewWindow.getByLabel(OSD_OVERLAY_LABEL)
|
||||
if (existing) {
|
||||
const monitor = await currentMonitor()
|
||||
const screenW = (monitor?.size.width ?? 1920) / (monitor?.scaleFactor ?? 1)
|
||||
const screenH = (monitor?.size.height ?? 1080) / (monitor?.scaleFactor ?? 1)
|
||||
// 读取窗口当前实际尺寸用于定位计算,不调用 setSize(避免覆盖实际测量值)
|
||||
const size = await existing.outerSize()
|
||||
const scale = monitor?.scaleFactor ?? 1
|
||||
const w = size.width / scale
|
||||
const h = size.height / scale
|
||||
const pos = computePositionFromPct(screenW, screenH, w, h, osdConfig.value.positionXPct, osdConfig.value.positionYPct)
|
||||
await existing.setPosition(new LogicalPosition(pos.x, pos.y))
|
||||
}
|
||||
} catch { /* 忽略 */ }
|
||||
}
|
||||
|
||||
// ===== OSD 窗口事件监听 =====
|
||||
let osdEventUnlisteners: UnlistenFn[] = []
|
||||
|
||||
async function setupOsdEventListeners() {
|
||||
// 守卫:避免重复注册(MonitorModule 可能因预渲染多次挂载)
|
||||
if (osdEventUnlisteners.length) return
|
||||
const { listen: tauriListen } = await import('@tauri-apps/api/event')
|
||||
// 监听悬浮窗上报的实际内容尺寸,按内容调整窗口大小(替代不准确的估算)
|
||||
// 仅当尺寸变化超过 1px 时才 setSize,避免无意义的频繁调用
|
||||
let lastW = 0
|
||||
let lastH = 0
|
||||
const unlisten = await tauriListen<{ width: number; height: number }>('osd-content-size', async (e) => {
|
||||
const { width, height } = e.payload
|
||||
if (Math.abs(width - lastW) < 1 && Math.abs(height - lastH) < 1) return
|
||||
lastW = width
|
||||
lastH = height
|
||||
try {
|
||||
const w = await WebviewWindow.getByLabel(OSD_OVERLAY_LABEL)
|
||||
if (w) await w.setSize(new LogicalSize(width, height))
|
||||
} catch { /* 忽略 */ }
|
||||
})
|
||||
osdEventUnlisteners.push(unlisten)
|
||||
}
|
||||
// ===== OSD 窗口管理(由 store.initOsd()/ensureOverlayWindow() 等统一管理) =====
|
||||
|
||||
// ===== 颜色主题编辑 Dialog =====
|
||||
const colorThemeDialogOpen = ref(false)
|
||||
@@ -1570,7 +1041,7 @@ function updateAlertConfig(field: keyof AlertConfig | 'maxValues', value: unknow
|
||||
if (field === 'maxValues' && maxKey) {
|
||||
osdConfig.value.alert.maxValues[maxKey] = Number(value)
|
||||
} else {
|
||||
;(osdConfig.value.alert as Record<string, unknown>)[field] = value
|
||||
;(osdConfig.value.alert as unknown as Record<string, unknown>)[field] = value
|
||||
}
|
||||
saveOsdConfig(osdConfig.value)
|
||||
}
|
||||
@@ -1622,43 +1093,17 @@ onMounted(async () => {
|
||||
try { appDataPath.value = await appDataDir() } catch { /* 忽略 */ }
|
||||
store.init()
|
||||
|
||||
// 注册 OSD 窗口事件监听
|
||||
setupOsdEventListeners().catch(e => console.error('[OSD] 事件监听注册失败:', e))
|
||||
|
||||
// 初始化悬浮窗(如果开关已开启)
|
||||
if (osdConfig.value.overlayEnabled) {
|
||||
ensureOverlayWindow().catch(e => console.error('[OSD] 初始化悬浮窗失败:', e))
|
||||
}
|
||||
|
||||
// 监听托盘菜单"切换 OSD"事件
|
||||
try {
|
||||
osdEventUnlisteners.push(
|
||||
await listen('tray:toggle-osd', () => {
|
||||
osdConfig.value.overlayEnabled = !osdConfig.value.overlayEnabled
|
||||
saveOsdConfig(osdConfig.value)
|
||||
if (osdConfig.value.overlayEnabled) {
|
||||
if (osdConfig.value.overlayItems.length === 0) {
|
||||
toast.warning('OSD 显示项为空,已开启但未创建窗口')
|
||||
} else {
|
||||
ensureOverlayWindow().catch(e => console.error('[OSD] 托盘开启悬浮窗失败:', e))
|
||||
}
|
||||
} else {
|
||||
hideOverlayWindow().catch(e => console.error('[OSD] 托盘关闭悬浮窗失败:', e))
|
||||
}
|
||||
})
|
||||
)
|
||||
} catch (e) {
|
||||
console.error('[OSD] 注册 tray:toggle-osd 监听失败:', e)
|
||||
}
|
||||
// OSD 配置/窗口/事件监听已迁移至 monitorStore,由 initOsd() 统一初始化
|
||||
// (幂等:App 启动时已调用过则跳过,模块挂载时再次调用安全)
|
||||
store.initOsd()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
// 不 dispose store:SSE 订阅保持,确保切走监控模块后 OSD 仍有数据
|
||||
// store.subscribe() 内部有守卫(if unlistenFns.length return),重复 init 不会重复订阅
|
||||
// 不关闭 OSD 窗口:切走监控模块时保持 OSD 显示,由模块禁用或应用退出时统一清理
|
||||
// 仅清理组件级 OSD 事件监听(下次挂载会重新注册,setupOsdEventListeners 有守卫)
|
||||
osdEventUnlisteners.forEach(fn => fn())
|
||||
osdEventUnlisteners = []
|
||||
// 释放 OSD 事件监听(App 启动或模块重新挂载时会重新注册)
|
||||
store.disposeOsd()
|
||||
})
|
||||
|
||||
// 当 Kernel 从未就绪变成就绪时,主动拉一次快照填充 UI
|
||||
@@ -1668,71 +1113,8 @@ watch(() => store.status?.ready, (ready, prev) => {
|
||||
}
|
||||
})
|
||||
|
||||
// ===== OSD 开关变化时创建/隐藏悬浮窗 =====
|
||||
watch(() => osdConfig.value.overlayEnabled, (enabled) => {
|
||||
if (enabled) {
|
||||
// 开启时若显示项为空则不创建窗口
|
||||
if (osdConfig.value.overlayItems.length === 0) return
|
||||
ensureOverlayWindow().catch(e => console.error('[OSD] 创建悬浮窗失败:', e))
|
||||
} else {
|
||||
hideOverlayWindow().catch(e => console.error('[OSD] 隐藏悬浮窗失败:', e))
|
||||
}
|
||||
})
|
||||
|
||||
// ===== 显示项变化时:无项则隐藏窗口,有项且开关开则确保窗口存在 =====
|
||||
watch(() => osdConfig.value.overlayItems.length, (len) => {
|
||||
if (!osdConfig.value.overlayEnabled) return
|
||||
if (len === 0) {
|
||||
hideOverlayWindow().catch(e => console.error('[OSD] 显示项为空,隐藏悬浮窗失败:', e))
|
||||
} else {
|
||||
ensureOverlayWindow().catch(e => console.error('[OSD] 显示项恢复,创建悬浮窗失败:', e))
|
||||
}
|
||||
})
|
||||
|
||||
// ===== 位置百分比变化时重新定位窗口(清除已保存像素位置) =====
|
||||
// 拖动 OSD 触发的 onMoved 会反算更新百分比,此时 suppressPercentWatch=true 跳过,避免循环
|
||||
watch(() => [osdConfig.value.positionXPct, osdConfig.value.positionYPct], () => {
|
||||
if (suppressPercentWatch) return
|
||||
// 清除保存的像素位置,让窗口使用百分比重新定位
|
||||
osdConfig.value.overlayX = null
|
||||
osdConfig.value.overlayY = null
|
||||
saveOsdConfig(osdConfig.value)
|
||||
// 如果窗口已存在,重新定位
|
||||
resetOverlayPosition().catch(() => {})
|
||||
})
|
||||
|
||||
// ===== 数据变化时推送状态到 OSD 窗口 =====
|
||||
// 快照变化(Kernel SSE 推送)→ 推送到 OSD 窗口
|
||||
watch(() => store.snapshot, () => {
|
||||
if (osdConfig.value.overlayEnabled) {
|
||||
pushOsdState()
|
||||
}
|
||||
}, { deep: false })
|
||||
|
||||
// 网速变化 → 推送到 OSD 窗口
|
||||
watch(() => store.networkSpeed, () => {
|
||||
if (osdConfig.value.overlayEnabled) {
|
||||
pushOsdState()
|
||||
}
|
||||
}, { deep: false })
|
||||
|
||||
// OSD 配置变化 → 推送到 OSD 窗口(位置/字体/显示项等)
|
||||
watch(osdConfig, () => {
|
||||
if (osdConfig.value.overlayEnabled) {
|
||||
pushOsdState()
|
||||
}
|
||||
}, { deep: true })
|
||||
|
||||
// 显示项数量/布局/字号变化 → 更新悬浮窗窗口尺寸(自适应内容)
|
||||
watch([
|
||||
() => osdConfig.value.overlayItems.length,
|
||||
() => osdConfig.value.layout,
|
||||
() => osdConfig.value.fontSize,
|
||||
], () => {
|
||||
if (osdConfig.value.overlayEnabled) {
|
||||
updateOsdWindowSize().catch(() => {})
|
||||
}
|
||||
})
|
||||
// OSD 相关 watch(开关/显示项/位置/配置/尺寸)已由 store.initOsd() 内部统一注册,
|
||||
// 与组件生命周期解耦:模块卸载后 OSD 仍能持续刷新,配置变更仍会推送。
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
Reference in New Issue
Block a user