Files
Thing/src/modules/monitor/MonitorModule.vue
T
2026-08-14 17:47:06 +08:00

2554 lines
117 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 {
Activity, Play, Square, RefreshCw, Loader2, Cpu, MemoryStick,
Gauge, HardDrive, Settings as SettingsIcon, AlertTriangle,
ShieldCheck, ShieldOff, Zap, Thermometer, Clock, ChevronDown,
ArrowDown, ArrowUp, Wifi, FolderOpen, Copy, ListChecks,
Monitor as MonitorIcon, GripVertical, SlidersHorizontal,
Eye, EyeOff, MousePointerClick,
} from '@lucide/vue'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
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 {
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'
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Accordion, AccordionItem, AccordionTrigger, AccordionContent } from '@/components/ui/accordion'
import { Progress } from '@/components/ui/progress'
import { Separator } from '@/components/ui/separator'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from '@/components/ui/dialog'
import { Checkbox } from '@/components/ui/checkbox'
import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'
import { Switch } from '@/components/ui/switch'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Label } from '@/components/ui/label'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
const store = useMonitorStore()
// ===== Tab 配置(注册到 TitleBar 浮动切换器) =====
const activeTab = ref('overview')
const tabsListRef = useModuleTabs('monitor', activeTab, [
{ value: 'overview', label: '概览' },
{ value: 'details', label: '详细' },
{ value: 'osd', label: 'OSD 显示' },
{ value: 'settings', label: '设置' },
])
// ===== 关键指标 computed =====
/** GPU 分组 id(兼容 intel/amd/nvidia */
const gpuGroupId = computed(() => {
if (store.groupById['gpuintel']) return 'gpuintel'
if (store.groupById['gpuamd']) return 'gpuamd'
if (store.groupById['gpunvidia']) return 'gpunvidia'
return null
})
const cpuModel = computed(() => store.groupById['cpu']?.sensors[0]?.hardwareName ?? null)
/** 取首个 >0 的传感器值:用于过滤 AMD Ryzen 上 LHB 返回 0 的无效读数(温度/功耗/时钟为 0 不可信) */
function findPositiveValue(groupId: string, matchers: Array<{ name?: string; hardwareName?: string; type?: string }>): number | null {
for (const m of matchers) {
const v = store.findSensorValue(groupId, m)
if (v != null && v > 0 && isFinite(v)) return v
}
return null
}
// CPU 温度:Intel 命名 "CPU Package"AMD Ryzen 命名 "Core (Tctl/Tdie)"
// LHB 0.9.5 对 Ryzen 9000 系列支持不全,可能返回 0,用 >0 守卫过滤。
const cpuTemp = computed(() =>
findPositiveValue('cpu', [
{ name: 'CPU Package', type: 'temperature' }, // Intel
{ name: 'Core (Tctl/Tdie)', type: 'temperature' }, // AMD Ryzen
{ type: 'temperature' }, // 兜底:任意温度
])
)
const cpuLoad = computed(() => store.findSensorValue('cpu', { name: 'CPU Total', type: 'load' }))
// CPU 功耗:Intel "CPU Package"AMD Ryzen "Package"
const cpuPower = computed(() =>
findPositiveValue('cpu', [
{ name: 'CPU Package', type: 'power' }, // Intel
{ name: 'Package', type: 'power' }, // AMD Ryzen
{ type: 'power' }, // 兜底:任意功耗
])
)
const gpuModel = computed(() => {
const gid = gpuGroupId.value
return gid ? store.groupById[gid]?.sensors[0]?.hardwareName ?? null : null
})
const gpuTemp = computed(() => {
const gid = gpuGroupId.value
if (!gid) return null
// GPU 温度传感器命名因厂商而异,按优先级匹配
return store.findSensorValue(gid, { name: 'GPU Core', type: 'temperature' })
?? store.findSensorValue(gid, { name: 'GPU Temperature', type: 'temperature' })
?? store.findSensorValue(gid, { type: 'temperature' })
})
const gpuLoad = computed(() => {
const gid = gpuGroupId.value
if (!gid) return null
return store.findSensorValue(gid, { name: 'D3D 3D', type: 'load' })
?? store.findSensorValue(gid, { name: 'GPU Core', type: 'load' })
?? store.findSensorValue(gid, { type: 'load' })
})
const gpuPower = computed(() => {
const gid = gpuGroupId.value
if (!gid) return null
return store.findSensorValue(gid, { name: 'GPU Power', type: 'power' })
?? store.findSensorValue(gid, { type: 'power' })
})
const memLoad = computed(() => store.findSensorValue('memory', { name: 'Memory', hardwareName: 'Total Memory', type: 'load' }))
const memUsedGB = computed(() => store.findSensorValue('memory', { hardwareName: 'Total Memory', name: 'Memory Used', type: 'data' }))
const memAvailGB = computed(() => store.findSensorValue('memory', { hardwareName: 'Total Memory', name: 'Memory Available', type: 'data' }))
const memTotalGB = computed(() => {
const used = memUsedGB.value
const avail = memAvailGB.value
if (used != null && avail != null) return used + avail
return null
})
/** 物理内存条型号列表(排除 Virtual Memory / Total Memory 虚拟分组) */
const memModuleModels = computed(() => {
const g = store.groupById['memory']
if (!g) return []
const models = new Set<string>()
for (const s of g.sensors) {
if (s.hardwareName !== 'Virtual Memory' && s.hardwareName !== 'Total Memory') {
models.add(s.hardwareName)
}
}
return Array.from(models)
})
/** 存储硬盘列表(按 hardwareName 分组,提取温度/使用率/容量)
* LHB 0.9.5 已知 bugMBR + XINT13 Extended 分区布局下,Free Space 可能返回
* ≈ 2^64/1e9 GB(无符号回绕),Used Space load 也可能为巨大负值。
* 这里对 LHB 原始值做有效性校验,异常时降级为 null(UI 显示 "--")。 */
interface StorageDrive {
name: string
temp: number | null
usedPct: number | null
totalGB: number | null
usedGB: number | null
}
const storageDrives = computed<StorageDrive[]>(() => {
const g = store.groupById['storage']
if (!g) return []
const byHw = new Map<string, SensorEntry[]>()
for (const s of g.sensors) {
if (!byHw.has(s.hardwareName)) byHw.set(s.hardwareName, [])
byHw.get(s.hardwareName)!.push(s)
}
return Array.from(byHw.entries()).map(([hw, sensors]) => {
const temp = sensors.find(s => s.type === 'temperature')?.value ?? null
// Used Space 是 load 百分比,理论上 [0,100]LHB 在 MBR+Extended 布局下可能返回 -922153000% 等
const rawUsedPct = sensors.find(s => s.name === 'Used Space' && s.type === 'load')?.value ?? null
const usedPct = rawUsedPct != null && isFinite(rawUsedPct) && rawUsedPct >= 0 && rawUsedPct <= 100
? rawUsedPct
: null
// Total Space 在 LHB 中通常正确(即便 Free Space 异常)
const rawTotal = sensors.find(s => s.name === 'Total Space' && s.type === 'data')?.value ?? null
const totalGB = rawTotal != null && isFinite(rawTotal) && rawTotal > 0 && rawTotal < 1e6
? rawTotal
: null
// Free Space 在 MBR+Extended 布局下可能返回 ≈ 1.8446744e10 GBUINT64_MAX / 1e9
const rawFree = sensors.find(s => s.name === 'Free Space' && s.type === 'data')?.value ?? null
const freeGB = rawFree != null && isFinite(rawFree) && rawFree >= 0 && totalGB != null && rawFree <= totalGB
? rawFree
: null
// 优先用 total - free 计算 usedGBfree 异常时回退用 usedPct * total / 100
let usedGB: number | null = null
if (totalGB != null) {
if (freeGB != null) {
usedGB = totalGB - freeGB
} else if (usedPct != null) {
usedGB = totalGB * usedPct / 100
}
}
return { name: hw, temp, usedPct, totalGB, usedGB }
})
})
// 网速格式化(computed 避免模板中重复调用)
const downSpeed = computed(() => fmtSpeed(store.networkSpeed?.downloadBps ?? null))
const upSpeed = computed(() => fmtSpeed(store.networkSpeed?.uploadBps ?? null))
// ===== 连接状态徽章 =====
const stateMeta: Record<ConnectionState, { text: string; class: string }> = {
idle: { text: '未启动', class: 'bg-muted text-muted-foreground' },
loading: { text: '启动中', class: 'bg-yellow-500/15 text-yellow-600 dark:text-yellow-400' },
connected: { text: '已连接', class: 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-400' },
disconnected: { text: '已断开', class: 'bg-orange-500/15 text-orange-600 dark:text-orange-400' },
error: { text: '错误', class: 'bg-red-500/15 text-red-600 dark:text-red-400' },
}
// ===== 分组列表(详细页用) =====
const groups = computed<SensorGroup[]>(() => store.snapshot?.groups ?? [])
/** 按 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)
}
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, [])
byType.get(s.type)!.push(s)
}
return {
hardware: hw,
byType: Array.from(byType.entries()).map(([type, list]) => ({ type, items: list })),
}
})
sensorGroupCache.set(sensors, result)
return result
}
// ===== Accordion 折叠状态 =====
const accordionValue = ref<string[]>([])
// ===== 控制操作 =====
async function handleStart() {
await store.start()
if (store.connState === 'connected') toast.success('Kernel 已启动')
else if (store.errorMsg) toast.error('启动失败', { description: store.errorMsg })
}
async function handleStop() {
await store.stop()
toast.success('Kernel 已停止')
}
async function handleRefresh() {
await store.refreshStatus()
if (store.status?.ready) {
await store.fetchSnapshot()
toast.success('已刷新')
}
}
/** 提权:以管理员权限重启 Thing 自身,并持久化标志使后续启动自动提权。
* Thing 以管理员权限运行时,ThingHK 子进程继承权限,ProcessManager 可直接管控,
* ThingHK 崩溃会自动重启,避免数据停止后 Thing 不感知。
* 非管理员时进程退出;已是管理员时仅设置标志并返回。
*/
async function handleElevateSelf() {
await store.elevateSelf()
// 非管理员时进程已退出,不会走到这里
if (store.errorMsg) {
toast.error('提权失败', { description: store.errorMsg })
} else if (store.elevateOnLaunch) {
toast.success('已启用提权', { description: '后续启动将自动以管理员权限运行' })
}
}
/** 取消提权:清除标志,下次启动不再触发 UAC(当前会话权限不变) */
async function handleCancelElevation() {
await store.cancelElevation()
if (!store.errorMsg) {
toast.success('已取消提权', { description: '下次启动将以普通权限运行' })
}
}
// ===== Kernel 路径显示 + 复制 + 打开文件夹(参考代理模块 mihomo 信息板块) =====
const appDataPath = ref('')
/** 路径缩略显示:只显示最后 3 段 */
const pathShort = computed(() => {
const p = store.kernelInfo?.path
if (!p) return '—'
const parts = p.replace(/\\/g, '/').split('/')
if (parts.length <= 4) return p
return '.../' + parts.slice(-3).join('/')
})
/** 路径完整显示:将 appData 部分替换为 %APPDATA% 便于在文件管理器地址栏打开 */
const pathDisplay = computed(() => {
const p = store.kernelInfo?.path
if (!p) return ''
if (appDataPath.value && p.toLowerCase().startsWith(appDataPath.value.toLowerCase())) {
return '%APPDATA%' + p.slice(appDataPath.value.length)
}
return p
})
async function copyPath() {
const p = store.kernelInfo?.path
if (!p) return
try {
await navigator.clipboard.writeText(p)
toast.success('路径已复制', { description: pathDisplay.value })
} catch {
toast.error('复制失败')
}
}
async function openFolder() {
const p = store.kernelInfo?.path
if (!p) return
try {
await revealItemInDir(p)
} catch (e) {
toast.error('打开文件夹失败', { description: String(e) })
}
}
// ===== 硬件监控配置 Dialog =====
const configDialogOpen = ref(false)
/** Dialog 内编辑中的硬件开关(key → bool) */
const editingHardware = ref<Record<string, boolean>>({})
/** Dialog 内编辑中的传感器类型开关(key → bool) */
const editingSensorTypes = ref<Record<string, boolean>>({})
const savingConfig = ref(false)
async function openConfigDialog() {
await store.fetchHardwareConfig()
if (store.hardwareConfig) {
// 深拷贝当前配置到编辑状态
editingHardware.value = { ...store.hardwareConfig.config.hardware }
editingSensorTypes.value = { ...store.hardwareConfig.config.sensorTypes }
}
configDialogOpen.value = true
}
async function handleSaveConfig() {
savingConfig.value = true
try {
const resp = await store.saveHardwareConfig(editingHardware.value, editingSensorTypes.value)
if (resp?.success) {
if (resp.restartRequired) {
// 检测是否启用了需要管理员权限的硬件但当前非管理员
const needsAdmin = store.hardwareConfig?.availableHardware.some(
hw => editingHardware.value[hw.key] && hw.requiresAdmin
) ?? false
const isElevated = store.status?.elevated || store.status?.thingElevated
configDialogOpen.value = false
if (needsAdmin && !isElevated) {
toast.warning('部分硬件需要管理员权限', {
description: '主板/存储等硬件需提权才能读取完整数据,建议提权',
duration: 6000,
})
} else {
toast.success('配置已保存', { description: '正在重启 Kernel...' })
}
// 自动重启 Kernel 以应用硬件开关变更
await store.stop()
await new Promise(r => setTimeout(r, 800))
await store.start()
// 等待 Kernel 就绪后拉取快照(cold start 可能需要数秒)
for (let i = 0; i < 20; i++) {
await new Promise(r => setTimeout(r, 500))
await store.refreshStatus()
if (store.status?.ready) {
await store.fetchSnapshot()
break
}
}
} else {
toast.success('配置已保存', { description: '传感器类型过滤已热生效' })
configDialogOpen.value = false
// 热更新后立即拉取新快照以反映传感器类型过滤变化
await store.fetchSnapshot()
}
} else if (store.errorMsg) {
toast.error('保存失败', { description: store.errorMsg })
}
} finally {
savingConfig.value = false
}
}
// ===== OSD 配置(由 monitorStore 统一管理,组件仅做 UI 展示与修改) =====
// 类型/默认值/持久化/窗口管理均在 monitorStoreApp 启动时由 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> = {
// CPU 通用(Intel 命名)
'CPU Total': '总使用率',
'CPU Core Average': '平均温度',
'CPU Core Max': '最大使用率',
'CPU Graphics': '核显',
'CPU DRAM': '内存功耗',
'CPU Cores': '核心功耗',
'CPU Bus': '总线功耗',
'Bus Speed': '总线频率',
'Distance to TjMax': 'TjMax 距离',
// AMD Ryzen
'Core (Tctl/Tdie)': '封装温度',
'Cores (Average)': '平均频率',
'Cores (Average Effective)': '平均有效频率',
// GPU
'GPU Core': 'GPU 核心',
'GPU Memory': 'GPU 显存',
'GPU Memory Controller': 'GPU 显存控制器',
'GPU Video Engine': 'GPU 视频引擎',
'GPU Power': 'GPU 功耗',
'GPU Fan': 'GPU 风扇',
'GPU Temperature': 'GPU 温度',
'GPU PCIe': 'GPU PCIe',
'GPU Memory Total': '总显存',
'GPU Memory Used': '已用显存',
'D3D 3D': '3D 引擎',
'D3D Copy': '拷贝引擎',
'D3D VideoDecode': '视频解码',
'D3D VideoProcessing': '视频处理',
// 内存
'Memory Used': '已用',
'Memory Available': '可用',
'Virtual Memory': '虚拟内存使用率',
'Virtual Memory Used': '虚拟已用',
'Virtual Memory Available': '虚拟可用',
// 存储
'Used Space': '已用空间',
'Free Space': '可用空间',
'Total Space': '总空间',
'Read Speed': '读取速度',
'Write Speed': '写入速度',
'Read Rate': '读取速率',
'Write Rate': '写入速率',
// 主板/SuperIO
'CPU Fan': 'CPU 风扇',
'System Fan': '系统风扇',
'Motherboard': '主板',
'Motherboard Temperature': '主板温度',
'CPU Socket': 'CPU 插槽温度',
// 电池
'Battery Level': '电量',
'Battery Charge': '充电功率',
'Battery Discharge': '放电功率',
'Battery Voltage': '电池电压',
'Battery Capacity': '电池容量',
'Battery Wear Level': '电池损耗',
}
/** 同名传感器按 type 消歧的字典(key 格式:sensorName|type)。
* 用于解决 Intel/AMD 同一传感器名在不同 type 下含义不同的问题。
* 例如 'CPU Package' 在 temperature 是封装温度,在 power 是封装功耗;
* 'Memory' 在 load 是使用率,在 data 是内存容量。 */
const SENSOR_NAME_ZH_TYPED: Record<string, string> = {
'CPU Package|temperature': '封装温度',
'CPU Package|power': '封装功耗',
'Package|power': '封装功耗',
'Package|temperature': '封装温度',
'Memory|load': '内存使用率',
'Memory|data': '内存',
'Memory|smalldata': '内存',
'Cores (Average)|clock': '平均频率',
'Cores (Average)|power': '核心平均功耗',
'Cores (Average Effective)|clock': '平均有效频率',
}
/** 硬件名中英文(用于分组标题等) */
const HW_NAME_ZH: Record<string, string> = {
'Total Memory': '总内存',
'Virtual Memory': '虚拟内存',
}
/** 网速特殊项的中文/英文名 */
const SPECIAL_SENSOR_META: Record<string, { zh: string; en: string; unit: string; type: string }> = {
'net-up': { zh: '上传速度', en: 'Upload', unit: '', type: 'throughput' },
'net-down': { zh: '下载速度', en: 'Download', unit: '', type: 'throughput' },
}
/** 通俗类型后缀(OSD 双行标题用),如 温度 / 使用率 / 功耗 */
function colloquialTypeLabel(item: OsdItem): string {
if (item.special === 'net-up') return '上传速度'
if (item.special === 'net-down') return '下载速度'
switch (item.type) {
case 'temperature': return '温度'
case 'load': return '使用率'
case 'power': return '功耗'
case 'voltage': return '电压'
case 'fan': return '风扇'
case 'clock': return '频率'
case 'data':
case 'smalldata': return '容量'
case 'throughput': return '速率'
case 'level': return '等级'
case 'frequency': return '频率'
case 'control': return '控制'
case 'factor': return '因子'
case 'timespan': return '时长'
case 'energy': return '能量'
default: return ''
}
}
/** 硬件前缀(简洁),如 CPU / GPU / 内存 / 主板 */
function shortHardwareLabel(item: OsdItem): string {
if (item.special === 'net-up') return '上传'
if (item.special === 'net-down') return '下载'
switch (item.groupId) {
case 'cpu': return 'CPU'
case 'gpuintel':
case 'gpuamd':
case 'gpunvidia': return 'GPU'
case 'memory': return '内存'
case 'motherboard': return '主板'
case 'battery': return '电池'
case 'network': return '网络'
case 'psu': return '电源'
default: return ''
}
}
function truncateHwName(name: string, maxLen = 10): string {
return name.length > maxLen ? name.slice(0, maxLen) + '…' : name
}
/** 完整通俗标题(双行模式第一行 + 设置页显示),如 "CPU温度"、"GPU功耗"、"内存使用率" */
function fullColloquialLabel(item: OsdItem): string {
if (item.special === 'net-up') return '上传速度'
if (item.special === 'net-down') return '下载速度'
if (osdConfig.value.labelLanguage === 'en') return item.sensorName
const type = colloquialTypeLabel(item)
// 存储类用硬件名(硬盘型号)+ 类型
if (item.groupId === 'storage') {
return `${truncateHwName(item.hardwareName)}${type}`
}
const hw = shortHardwareLabel(item)
if (hw && type) return `${hw}${type}`
return hw || type || (SENSOR_NAME_ZH[item.sensorName] ?? item.sensorName)
}
/** 传感器显示名翻译:根据 labelLanguage 返回中文或英文
* 中文优先智能"按词对应"翻译,找不到时回退到通俗组合 */
function sensorLabel(item: OsdItem): string {
// 特殊项
if (item.special) {
const meta = SPECIAL_SENSOR_META[item.special]
if (!meta) return item.sensorName
return osdConfig.value.labelLanguage === 'zh' ? meta.zh : meta.en
}
if (osdConfig.value.labelLanguage === 'en') return item.sensorName
// 优先:智能精准翻译(与 Dialog 保持一致)
const smart = smartSensorLabelZh(item.sensorName, item.type)
if (smart) return smart
// 回退:通俗描述
return fullColloquialLabel(item)
}
/** 硬件名翻译(标签辅助显示) */
function hwLabel(item: OsdItem): string {
if (osdConfig.value.labelLanguage === 'en') return item.hardwareName
return HW_NAME_ZH[item.hardwareName] ?? item.hardwareName
}
/** AvailableSensor 的传感器名翻译(用于选择 Dialog)
* 优先智能"按词对应"翻译(精准且唯一),找不到时回退到通俗组合。 */
function sensorLabelAvail(s: AvailableSensor): string {
if (s.special) {
const meta = SPECIAL_SENSOR_META[s.special]
if (!meta) return s.sensorName
return osdConfig.value.labelLanguage === 'zh' ? meta.zh : meta.en
}
if (osdConfig.value.labelLanguage === 'en') return s.sensorName
// 优先:智能精准翻译(typed 字典 + 字典 + 模式匹配)
const smart = smartSensorLabelZh(s.sensorName, s.type)
if (smart) return smart
// 容量类(data/smalldata):用字典翻译 + 硬件名区分(已用内存/可用内存/已用空间/可用空间)
if (s.type === 'data' || s.type === 'smalldata') {
const dictName = SENSOR_NAME_ZH[s.sensorName]
if (dictName) {
const hw = shortHardwareLabelAvail(s)
const prefix = s.groupId === 'storage' ? truncateHwName(s.hardwareName) : hw
return prefix ? `${prefix}${dictName}` : dictName
}
return SENSOR_NAME_ZH[s.sensorName] ?? s.sensorName
}
// 回退:通俗类型组合(短前缀 + 类型后缀)
const type = colloquialTypeLabel({ type: s.type } as OsdItem)
const hw = shortHardwareLabelAvail(s)
if (s.groupId === 'storage') {
return `${truncateHwName(s.hardwareName)}${type}`
}
if (hw && type) return `${hw}${type}`
return SENSOR_NAME_ZH[s.sensorName] ?? s.sensorName
}
/** AvailableSensor 的硬件短名(用于 Dialog 显示) */
function shortHardwareLabelAvail(s: AvailableSensor): string {
if (s.special === 'net-up') return '上传'
if (s.special === 'net-down') return '下载'
switch (s.groupId) {
case 'cpu': return 'CPU'
case 'gpuintel':
case 'gpuamd':
case 'gpunvidia': return 'GPU'
case 'memory': return '内存'
case 'motherboard': return '主板'
case 'battery': return '电池'
case 'network': return '网络'
case 'psu': return '电源'
default: return ''
}
}
/** AvailableSensor 的硬件名翻译(用于选择 Dialog) */
function hwLabelAvail(s: AvailableSensor): string {
if (osdConfig.value.labelLanguage === 'en') return s.hardwareName
return HW_NAME_ZH[s.hardwareName] ?? s.hardwareName
}
/** 类型后缀(中文,用于组合"核心 N 频率"、"封装温度"等精准翻译) */
function typeSuffixZh(type: string): string {
switch (type) {
case 'temperature': return '温度'
case 'load': return '使用率'
case 'power': return '功耗'
case 'clock': return '频率'
case 'voltage': return '电压'
case 'fan': return '风扇'
case 'throughput': return '速率'
case 'level': return '等级'
case 'frequency': return '频率'
case 'control': return '控制'
case 'factor': return '因子'
case 'timespan': return '时长'
case 'energy': return '能量'
default: return ''
}
}
/** 智能"按词对应"翻译:根据 sensorName + type 生成精准中文标签
* 优先级:① typed 字典(同名不同义,如 CPU Package temperature vs power
* ② 普通字典(固定短语)
* ③ 模式匹配(带核心序号、CCD、D3D 引擎等)
* ④ 返回 null 交由调用方回退
* 设计目标:每个传感器产出唯一中文标签,避免"CPU功耗 ×8"这种重复 */
function smartSensorLabelZh(name: string, type: string): string | null {
// ① typed 字典:同名 + type 消歧
const typedKey = `${name}|${type}`
if (SENSOR_NAME_ZH_TYPED[typedKey]) return SENSOR_NAME_ZH_TYPED[typedKey]
// ② 普通字典
if (SENSOR_NAME_ZH[name]) return SENSOR_NAME_ZH[name]
// ③ 模式匹配
let m: RegExpMatchArray | null
// CPU Core #N / Core #N → 核心 N + 类型后缀(load→使用率, clock→频率, power→功耗...
if ((m = name.match(/^(?:CPU )?Core #(\d+)$/))) {
return `核心 ${m[1]}${typeSuffixZh(type)}`
}
// CPU Core #N (Effective) / Core #N (Effective) → 核心 N 有效频率
if ((m = name.match(/^(?:CPU )?Core #(\d+) \(Effective\)$/))) {
return `核心 ${m[1]} 有效${typeSuffixZh(type)}`
}
// Core #N (SMU) → 核心 N (SMU) 功耗
if ((m = name.match(/^Core #(\d+) \(SMU\)$/))) {
return `核心 ${m[1]} (SMU)${typeSuffixZh(type)}`
}
// CCD1 (Tdie) → CCD1 (Tdie) 温度
if ((m = name.match(/^CCD(\d+) \(Tdie\)$/))) {
return `CCD${m[1]} (Tdie) 温度`
}
// CCD#N (Tccd#N) → CCD#N (Tccd#N) 温度
if ((m = name.match(/^CCD(\d+) \(Tccd\d+\)$/))) {
return `CCD${m[1]} 温度`
}
// D3D 引擎族:D3D 3D / D3D Copy / D3D VideoDecode / D3D VideoProcessing
if ((m = name.match(/^D3D\s+(.+)$/))) {
const d3dMap: Record<string, string> = {
'3D': '3D 引擎',
'Copy': '拷贝引擎',
'VideoDecode': '视频解码',
'VideoProcessing': '视频处理',
}
if (d3dMap[m[1]]) return d3dMap[m[1]]
}
return null
}
/**
* 判断传感器是否为"常用项"。
* 规则:基于 name + type + hardwareName 模式匹配,挑选日常监控最关注的指标。
* 其余归入"详细项"(如 CPU 分核负载、各路电压、各时钟等)。
*/
function isCommon(s: { sensorName: string; hardwareName: string; type: string; special?: string }): boolean {
// 特殊项(网速)算常用
if (s.special) return true
const name = s.sensorName
const type = s.type
const hw = s.hardwareName.toLowerCase()
const nameLc = name.toLowerCase()
// CPU 常用:封装温度 / Total 负载 / 封装功耗 / Graphics 核显 / Core Average 温度
// 兼容 Intel ("CPU Package") 与 AMD Ryzen ("Core (Tctl/Tdie)" / "Package") 命名
// 其余(分核负载、分核温度、时钟、总线、各路功耗)归详细
if (hw.includes('cpu') || nameLc.startsWith('cpu') || hw.includes('amd ryzen')) {
if (type === 'temperature' && (name === 'CPU Package' || name === 'Core (Tctl/Tdie)' || name === 'CPU Core Average')) return true
if (name === 'CPU Total' && type === 'load') return true
if (type === 'power' && (name === 'CPU Package' || name === 'Package')) return true
if (name === 'CPU Graphics' && (type === 'load' || type === 'temperature')) return true
return false
}
// GPU 常用:核心温度 / 核心负载 / 功耗 / 风扇 / 显存负载
if (hw.includes('gpu') || nameLc.startsWith('gpu') || name === 'D3D 3D') {
if (type === 'temperature' && (name === 'GPU Core' || name === 'GPU Temperature')) return true
if (type === 'load' && (name === 'GPU Core' || name === 'D3D 3D' || name === '3D')) return true
if (type === 'power' && name === 'GPU Power') return true
if (type === 'fan' && name === 'GPU Fan') return true
if (type === 'load' && name === 'GPU Memory') return true
if (type === 'smalldata' && (name === 'GPU Memory Total' || name === 'GPU Memory Used')) return true
return false
}
// 内存:Total Memory 常用 / Virtual Memory 详细
if (s.hardwareName === 'Total Memory') {
if (name === 'Memory' && type === 'load') return true
if (name === 'Memory Used' && type === 'data') return true
if (name === 'Memory Available' && type === 'data') return true
return false
}
if (s.hardwareName === 'Virtual Memory') return false
// 存储:使用率 + 温度 常用;其余(Total/Free/Read/Write/Throughput)详细
if (type === 'load' && name === 'Used Space') return true
if (type === 'temperature' && (nameLc.includes('temperature') || nameLc.includes('temp'))) return true
// 风扇类(所有 fan 类型,覆盖主板/SuperIO/GPU 多风扇)
if (type === 'fan') return true
// 电池
if (name === 'Battery Level' && type === 'level') return true
if (name === 'Battery Charge' && type === 'power') return true
if (name === 'Battery Discharge' && type === 'power') return true
// 主板温度
if (type === 'temperature' && (nameLc.includes('motherboard') || nameLc.includes('cpu socket'))) return true
// 电源
if (type === 'level' && nameLc.includes('psu')) return true
return false
}
/** 当前快照中所有可用传感器(供"显示项"选择),按分组聚合 */
interface AvailableSensor {
key: string
groupId: string
groupName: string
sensorName: string
hardwareName: string
type: string
unit: string
special?: 'net-up' | 'net-down'
}
const availableSensors = computed<AvailableSensor[]>(() => {
const list: AvailableSensor[] = []
// 网络特殊项:始终前置(即使 Kernel 未就绪也可选)
list.push({
key: 'special/net-up',
groupId: 'network',
groupName: '网络',
sensorName: 'Upload',
hardwareName: 'Network',
type: 'throughput',
unit: '',
special: 'net-up',
})
list.push({
key: 'special/net-down',
groupId: 'network',
groupName: '网络',
sensorName: 'Download',
hardwareName: 'Network',
type: 'throughput',
unit: '',
special: 'net-down',
})
for (const g of store.snapshot?.groups ?? []) {
// 悬浮窗不显示存储分组(硬盘容量/温度等已在主界面监控,OSD 场景无需)
if (g.id === 'storage') continue
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({
key,
groupId: g.id,
groupName,
sensorName: s.name,
hardwareName: s.hardwareName,
type: s.type,
unit: s.unit,
})
}
}
return list
})
/** 按分组聚合 + 常用/详细分类的可用传感器(用于 OSD 选择 Dialog 展示) */
interface GroupedSensors {
groupId: string
groupName: string
common: AvailableSensor[]
detailed: AvailableSensor[]
}
const groupedAvailableSensors = computed<GroupedSensors[]>(() => {
const map = new Map<string, GroupedSensors>()
for (const s of availableSensors.value) {
if (!map.has(s.groupId)) {
map.set(s.groupId, {
groupId: s.groupId,
groupName: s.groupName,
common: [],
detailed: [],
})
}
const grp = map.get(s.groupId)!
if (isCommon(s)) {
grp.common.push(s)
} else {
grp.detailed.push(s)
}
}
// 按默认顺序排序:CPU → GPU → 内存 → 网络 → 存储 → 其余
const groupOrder = (id: string): number => {
if (id === 'cpu') return 0
if (id.startsWith('gpu')) return 1
if (id === 'memory') return 2
if (id === 'network') return 3
if (id === 'storage') return 4
return 9
}
// 过滤掉空分组(理论上不会出现)
return Array.from(map.values())
.filter(g => g.common.length || g.detailed.length)
.sort((a, b) => groupOrder(a.groupId) - groupOrder(b.groupId))
})
/** Dialog 中各分组"详细项"折叠状态:groupId → 是否展开 */
const detailedExpanded = ref<Record<string, boolean>>({})
/** 显示项选择 Dialog(仅悬浮窗) */
const osdPickDialogOpen = ref(false)
/** Dialog 中勾选状态(key → 是否选中) */
const osdPickSelected = ref<Record<string, boolean>>({})
function openOsdPickDialog() {
const items = osdConfig.value.overlayItems
const selected: Record<string, boolean> = {}
// 存储分组已从 OSD 选择中移除,已选的存储项不预选(确认时自动清理)
for (const it of items) {
if (it.groupId === 'storage') continue
selected[it.key] = true
}
osdPickSelected.value = selected
osdPickDialogOpen.value = true
}
function confirmOsdPick() {
// 收集所有勾选项(保留原有项对象,新增项从 availableSensors 构造)
const oldItems = osdConfig.value.overlayItems
const newItems: OsdItem[] = []
// 保留原有项(存储项不再支持,直接丢弃)
for (const old of oldItems) {
if (old.groupId === 'storage') continue
if (osdPickSelected.value[old.key]) {
newItems.push(old)
}
}
// 追加新增项
for (const s of availableSensors.value) {
if (osdPickSelected.value[s.key] && !newItems.some(it => it.key === s.key)) {
newItems.push({
key: s.key,
groupId: s.groupId,
sensorName: s.sensorName,
hardwareName: s.hardwareName,
type: s.type,
unit: s.unit,
special: s.special,
})
}
}
// 按默认顺序排序:CPU → GPU → 内存 → 网络 → 其余
const groupOrder = (id: string): number => {
if (id === 'cpu') return 0
if (id.startsWith('gpu')) return 1
if (id === 'memory') return 2
if (id === 'network') return 3
return 9
}
newItems.sort((a, b) => groupOrder(a.groupId) - groupOrder(b.groupId))
osdConfig.value.overlayItems = newItems
saveOsdConfig(osdConfig.value)
osdPickDialogOpen.value = false
toast.success('悬浮窗显示项已更新')
}
/** 拖动排序结束回调 */
function onOsdDragEnd() {
saveOsdConfig(osdConfig.value)
}
/** 移除单个显示项 */
function removeOsdItem(key: string) {
const items = osdConfig.value.overlayItems
const idx = items.findIndex(it => it.key === key)
if (idx >= 0) {
items.splice(idx, 1)
saveOsdConfig(osdConfig.value)
}
}
/** OSD 配置项变更时自动保存(防抖:滑块拖动期间不逐帧写盘) */
function updateOsdConfig(field: keyof OsdConfig, value: unknown) {
;(osdConfig.value as unknown as Record<string, unknown>)[field] = value
saveOsdConfigDebounced(osdConfig.value)
}
/** 解析背景色字符串为 hex + alpha0-100 */
function parseBgColor(bg: string): { hex: string; alpha: number } {
// rgba(r,g,b,a) 或 #RRGGBBAA
const rgbaMatch = bg.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*(?:,\s*([\d.]+))?\s*\)/i)
if (rgbaMatch) {
const r = parseInt(rgbaMatch[1])
const g = parseInt(rgbaMatch[2])
const b = parseInt(rgbaMatch[3])
const a = rgbaMatch[4] != null ? parseFloat(rgbaMatch[4]) : 1
const hex = '#' + [r, g, b].map(n => n.toString(16).padStart(2, '0')).join('')
return { hex, alpha: Math.round(a * 100) }
}
let h = bg.replace('#', '').trim()
if (h.length === 3) h = h.split('').map(c => c + c).join('')
if (h.length === 8) {
return { hex: '#' + h.slice(0, 6), alpha: Math.round(parseInt(h.slice(6, 8), 16) / 255 * 100) }
}
if (h.length === 6) {
return { hex: '#' + h, alpha: 100 }
}
return { hex: '#000000', alpha: 55 }
}
/** 背景色 hex 部分(用于 color picker 绑定) */
const osdBgHex = computed(() => parseBgColor(osdConfig.value.bgColor).hex)
/** 背景色 alpha 部分(0-100,用于透明度滑块) */
const osdBgAlpha = computed(() => parseBgColor(osdConfig.value.bgColor).alpha)
/** 背景颜色选择器变化:保持原 alpha,更新 hex */
function onBgColorInput(hex: string) {
const { alpha } = parseBgColor(osdConfig.value.bgColor)
const a = (alpha / 100).toFixed(2)
// 解析 hex 为 rgb
let h = hex.replace('#', '')
if (h.length === 3) h = h.split('').map(c => c + c).join('')
const r = parseInt(h.slice(0, 2), 16)
const g = parseInt(h.slice(2, 4), 16)
const b = parseInt(h.slice(4, 6), 16)
updateOsdConfig('bgColor', `rgba(${r}, ${g}, ${b}, ${a})`)
}
/** 背景透明度滑块变化:保持原 hex,更新 alpha */
function onBgAlphaInput(alpha: number) {
const { hex } = parseBgColor(osdConfig.value.bgColor)
const a = (alpha / 100).toFixed(2)
let h = hex.replace('#', '')
if (h.length === 3) h = h.split('').map(c => c + c).join('')
const r = parseInt(h.slice(0, 2), 16)
const g = parseInt(h.slice(2, 4), 16)
const b = parseInt(h.slice(4, 6), 16)
updateOsdConfig('bgColor', `rgba(${r}, ${g}, ${b}, ${a})`)
}
/** 将 hex 颜色 + 不透明度(0-100) 转为 rgba 字符串 */
function withOpacity(hex: string, opacityPct: number): string {
const a = Math.max(0, Math.min(100, opacityPct)) / 100
let h = hex.replace('#', '').trim()
if (h.length === 3) h = h.split('').map(c => c + c).join('')
if (h.length === 8) 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})`
}
/** 获取 OSD 项颜色(按颜色主题着色,应用字体透明度) */
function osdItemColor(item: OsdItem): string {
const opacity = osdConfig.value.fontOpacity ?? 100
if (!osdConfig.value.colorThemeEnabled) return withOpacity(osdConfig.value.fontColor, opacity)
const theme = osdConfig.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(osdConfig.value.fontColor, opacity)
}
// ===== OSD 窗口管理(由 store.initOsd()/ensureOverlayWindow() 等统一管理) =====
// ===== 颜色主题编辑 Dialog =====
const colorThemeDialogOpen = ref(false)
/** 编辑中的颜色主题(深拷贝) */
const editingColorTheme = ref<ColorTheme>({ hardware: {}, sensor: {} })
function openColorThemeDialog() {
editingColorTheme.value = JSON.parse(JSON.stringify(osdConfig.value.colorTheme ?? DEFAULT_COLOR_THEME))
colorThemeDialogOpen.value = true
}
function saveColorTheme() {
osdConfig.value.colorTheme = editingColorTheme.value
saveOsdConfig(osdConfig.value)
colorThemeDialogOpen.value = false
toast.success('颜色主题已保存')
}
function resetColorTheme() {
editingColorTheme.value = JSON.parse(JSON.stringify(DEFAULT_COLOR_THEME))
}
// ===== 警告色配置更新辅助 =====
/** 更新 alert 配置字段(支持嵌套 maxValues */
function updateAlertConfig(field: keyof AlertConfig | 'maxValues', value: unknown, maxKey?: string) {
if (field === 'maxValues' && maxKey) {
osdConfig.value.alert.maxValues[maxKey] = Number(value)
} else {
;(osdConfig.value.alert as unknown as Record<string, unknown>)[field] = value
}
saveOsdConfig(osdConfig.value)
}
/** 警告色配置中需配置最大值的硬件类型列表(温度墙) */
const ALERT_MAX_VALUE_LIST: { key: string; name: string }[] = [
{ key: 'cpu', name: 'CPU' },
{ key: 'gpu', name: 'GPU' },
]
/** 颜色主题中硬件类型列表(含中文名) */
const COLOR_THEME_HARDWARE_LIST: { key: string; name: string }[] = [
{ key: 'cpu', name: 'CPU' },
{ key: 'gpuintel', name: 'GPU (Intel)' },
{ key: 'gpuamd', name: 'GPU (AMD)' },
{ key: 'gpunvidia', name: 'GPU (NVIDIA)' },
{ key: 'memory', name: '内存' },
{ key: 'storage', name: '存储' },
{ key: 'motherboard', name: '主板' },
{ key: 'superio', name: '超级 IO' },
{ key: 'embeddedcontroller', name: '嵌入式控制器' },
{ key: 'battery', name: '电池' },
{ key: 'network', name: '网络' },
{ key: 'psu', name: '电源' },
]
/** 颜色主题中传感器类型列表(含中文名) */
const COLOR_THEME_SENSOR_LIST: { key: string; name: string }[] = [
{ key: 'temperature', name: '温度' },
{ key: 'load', name: '使用率' },
{ key: 'power', name: '功耗' },
{ key: 'voltage', name: '电压' },
{ key: 'fan', name: '风扇' },
{ key: 'clock', name: '时钟' },
{ key: 'frequency', name: '频率' },
{ key: 'data', name: '容量' },
{ key: 'smalldata', name: '小容量' },
{ key: 'throughput', name: '吞吐' },
{ key: 'level', name: '等级' },
{ key: 'control', name: '控制' },
{ key: 'factor', name: '因子' },
{ key: 'timespan', name: '时长' },
{ key: 'energy', name: '能量' },
]
// 生命周期
onMounted(async () => {
// 获取 appData 路径,用于将 Kernel 路径替换为 %APPDATA% 形式
try { appDataPath.value = await appDataDir() } catch { /* 忽略 */ }
store.init()
// OSD 配置/窗口/事件监听已迁移至 monitorStore,由 initOsd() 统一初始化
// (幂等:App 启动时已调用过则跳过,模块挂载时再次调用安全)
store.initOsd()
})
onUnmounted(() => {
// 不 dispose storeSSE 订阅保持,确保切走监控模块后 OSD 仍有数据
// store.subscribe() 内部有守卫(if unlistenFns.length return),重复 init 不会重复订阅
// 不关闭 OSD 窗口:切走监控模块时保持 OSD 显示,由模块禁用或应用退出时统一清理
// 不调用 store.disposeOsd()tray:toggle-osd 监听与 OSD 配置 watcher 由 App.vue 的
// initOsd() 注册,属应用级常驻(与模块生命周期解耦);若在此释放,切走监控模块后
// 托盘菜单的 OSD 开关会失效。OSD 事件监听仅在 App 卸载(应用退出)时统一释放。
})
// 当 Kernel 从未就绪变成就绪时,主动拉一次快照填充 UI
watch(() => store.status?.ready, (ready, prev) => {
if (ready && !prev) {
store.fetchSnapshot()
}
})
// OSD 相关 watch(开关/显示项/位置/配置/尺寸)已由 store.initOsd() 内部统一注册,
// 与组件生命周期解耦:模块卸载后 OSD 仍能持续刷新,配置变更仍会推送。
</script>
<template>
<div class="h-full p-6">
<Tabs v-model="activeTab" class="h-full flex flex-col">
<div ref="tabsListRef">
<TabsList class="grid w-full max-w-md grid-cols-4 !bg-transparent !p-0 !shadow-none">
<TabsTrigger value="overview" class="gap-1.5"><Activity class="size-3.5" />概览</TabsTrigger>
<TabsTrigger value="details" class="gap-1.5"><Gauge class="size-3.5" />详细</TabsTrigger>
<TabsTrigger value="osd" class="gap-1.5"><MonitorIcon class="size-3.5" />OSD</TabsTrigger>
<TabsTrigger value="settings" class="gap-1.5"><SettingsIcon class="size-3.5" />设置</TabsTrigger>
</TabsList>
</div>
<!-- ===== 概览 ===== -->
<TabsContent value="overview" class="flex-1 mt-4 tab-animate">
<ScrollArea class="h-full pr-3">
<!-- 加载中占位仅首次初始化前显示 -->
<div v-if="!store.initialized" key="initializing" class="h-full flex flex-col items-center justify-center text-muted-foreground gap-2 pt-10">
<Loader2 class="size-8 animate-spin" />
<p class="text-sm">正在加载...</p>
</div>
<!-- 始终显示卡片网格未启动时数据以占位符显示保持画面完整 -->
<div v-else key="content" class="grid grid-cols-1 md:grid-cols-3 gap-2.5">
<!-- CPU温度 + 功耗 + 频率未读数据以 -- 占位 -->
<Card class="py-0 gap-0">
<CardHeader class="pb-1.5 px-3.5 pt-2.5">
<CardTitle class="flex items-center justify-between text-sm">
<span class="flex items-center gap-1.5"><Cpu class="size-4 text-primary" />CPU</span>
<Tooltip>
<TooltipTrigger as-child>
<span class="text-xs text-muted-foreground font-normal truncate ml-2">{{ cpuModel ?? '--' }}</span>
</TooltipTrigger>
<TooltipContent>{{ cpuModel ?? '' }}</TooltipContent>
</Tooltip>
</CardTitle>
</CardHeader>
<CardContent class="px-3.5 pb-2.5 space-y-1.5">
<!-- 温度 + 功耗 -->
<div class="flex items-end justify-between gap-2">
<div>
<div class="text-xs text-muted-foreground flex items-center gap-1"><Thermometer class="size-3" />封装温度</div>
<div :class="['text-2xl font-bold tabular-nums leading-tight', tempColor(cpuTemp)]">
{{ fmt(cpuTemp, 0) }}<span class="text-sm font-normal">°C</span>
</div>
</div>
<div class="text-right">
<div class="text-xs text-muted-foreground flex items-center gap-1 justify-end"><Zap class="size-3" />功耗</div>
<div class="text-base font-medium tabular-nums">{{ fmt(cpuPower, 1) }}<span class="text-xs text-muted-foreground ml-0.5">W</span></div>
</div>
</div>
<!-- 负载 -->
<div>
<div class="flex items-center justify-between text-xs mb-0.5">
<span class="text-muted-foreground flex items-center gap-1"><Gauge class="size-3" />总负载</span>
<span :class="['font-medium tabular-nums', loadColor(cpuLoad)]">{{ fmt(cpuLoad, 0) }}%</span>
</div>
<Progress :model-value="cpuLoad ?? 0" class="h-1.5" />
</div>
</CardContent>
</Card>
<!-- GPU结构与 CPU 一致温度 + 功耗未读数据以 -- 占位 -->
<Card class="py-0 gap-0">
<CardHeader class="pb-1.5 px-3.5 pt-2.5">
<CardTitle class="flex items-center justify-between text-sm">
<span class="flex items-center gap-1.5"><Gauge class="size-4 text-primary" />GPU</span>
<Tooltip>
<TooltipTrigger as-child>
<span class="text-xs text-muted-foreground font-normal truncate ml-2">{{ gpuModel ?? '--' }}</span>
</TooltipTrigger>
<TooltipContent>{{ gpuModel ?? '' }}</TooltipContent>
</Tooltip>
</CardTitle>
</CardHeader>
<CardContent class="px-3.5 pb-2.5 space-y-1.5">
<!-- 温度 + 功耗 CPU 卡片结构一致 -->
<div class="flex items-end justify-between gap-2">
<div>
<div class="text-xs text-muted-foreground flex items-center gap-1"><Thermometer class="size-3" />核心温度</div>
<div :class="['text-2xl font-bold tabular-nums leading-tight', tempColor(gpuTemp)]">
{{ fmt(gpuTemp, 0) }}<span class="text-sm font-normal">°C</span>
</div>
</div>
<div class="text-right">
<div class="text-xs text-muted-foreground flex items-center gap-1 justify-end"><Zap class="size-3" />功耗</div>
<div class="text-base font-medium tabular-nums">{{ fmt(gpuPower, 1) }}<span class="text-xs text-muted-foreground ml-0.5">W</span></div>
</div>
</div>
<!-- 负载 -->
<div>
<div class="flex items-center justify-between text-xs mb-0.5">
<span class="text-muted-foreground flex items-center gap-1"><Gauge class="size-3" />3D 负载</span>
<span :class="['font-medium tabular-nums', loadColor(gpuLoad)]">{{ fmt(gpuLoad, 0) }}%</span>
</div>
<Progress :model-value="gpuLoad ?? 0" class="h-1.5" />
</div>
</CardContent>
</Card>
<!-- 内存主指标大字 + 进度条未读数据以 -- 占位 -->
<Card class="py-0 gap-0">
<CardHeader class="pb-1.5 px-3.5 pt-2.5">
<CardTitle class="flex items-center justify-between text-sm">
<span class="flex items-center gap-1.5"><MemoryStick class="size-4 text-primary" />内存</span>
<Tooltip>
<TooltipTrigger as-child>
<span class="text-xs text-muted-foreground font-normal truncate ml-2">
{{ memTotalGB != null ? fmt(memTotalGB, 0) + ' GB' : '--' }}
</span>
</TooltipTrigger>
<TooltipContent>{{ memModuleModels.join(', ') }}</TooltipContent>
</Tooltip>
</CardTitle>
</CardHeader>
<CardContent class="px-3.5 pb-2.5 space-y-1.5">
<!-- 已使用 / 总容量主指标 CPU 温度对齐 -->
<div class="flex items-end justify-between gap-2">
<div>
<div class="text-xs text-muted-foreground flex items-center gap-1"><MemoryStick class="size-3" />已使用</div>
<div class="text-2xl font-bold tabular-nums leading-tight">
{{ fmt(memUsedGB, 1) }}<span class="text-sm font-normal text-muted-foreground"> / {{ fmt(memTotalGB, 1) }} GB</span>
</div>
</div>
</div>
<!-- 负载进度条 CPU 负载对齐 -->
<div>
<div class="flex items-center justify-between text-xs mb-0.5">
<span class="text-muted-foreground flex items-center gap-1"><Gauge class="size-3" />负载</span>
<span :class="['font-medium tabular-nums', loadColor(memLoad)]">{{ fmt(memLoad, 0) }}%</span>
</div>
<Progress :model-value="memLoad ?? 0" class="h-1.5" />
</div>
</CardContent>
</Card>
<!-- 网络跨3列下载/上传速率独立于 Kernel Tauri 后台推送 -->
<Card class="md:col-span-3 py-0 gap-0">
<CardHeader class="pb-1.5 px-3.5 pt-2.5">
<CardTitle class="flex items-center gap-1.5 text-sm">
<Wifi class="size-4 text-primary" />网络
</CardTitle>
</CardHeader>
<CardContent class="px-3.5 pb-2.5">
<div class="grid grid-cols-2 gap-4">
<!-- 下载 -->
<div>
<div class="text-xs text-muted-foreground flex items-center gap-1 mb-0.5"><ArrowDown class="size-3 text-sky-500" />下载</div>
<div class="text-2xl font-bold tabular-nums leading-tight text-sky-500">
{{ downSpeed.value }}<span class="text-sm font-normal text-muted-foreground ml-0.5">{{ downSpeed.unit }}</span>
</div>
</div>
<!-- 上传 -->
<div>
<div class="text-xs text-muted-foreground flex items-center gap-1 mb-0.5"><ArrowUp class="size-3 text-violet-500" />上传</div>
<div class="text-2xl font-bold tabular-nums leading-tight text-violet-500">
{{ upSpeed.value }}<span class="text-sm font-normal text-muted-foreground ml-0.5">{{ upSpeed.unit }}</span>
</div>
</div>
</div>
</CardContent>
</Card>
<!-- 存储跨3列):列出各硬盘温度/容量/使用率未读到以占位符显示 -->
<Card class="md:col-span-3 py-0 gap-0">
<CardHeader class="pb-1.5 px-3.5 pt-2.5">
<CardTitle class="flex items-center gap-1.5 text-sm">
<HardDrive class="size-4 text-primary" />存储
</CardTitle>
</CardHeader>
<CardContent class="px-3.5 pb-2.5">
<div v-if="storageDrives.length" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2">
<div v-for="drive in storageDrives" :key="drive.name" class="border rounded-md p-2 space-y-1">
<div class="flex items-center justify-between gap-2">
<Tooltip>
<TooltipTrigger as-child>
<span class="text-xs font-medium truncate">{{ drive.name }}</span>
</TooltipTrigger>
<TooltipContent>{{ drive.name }}</TooltipContent>
</Tooltip>
<span :class="['text-xs font-mono tabular-nums shrink-0', tempColor(drive.temp)]">{{ fmt(drive.temp, 0) }}°C</span>
</div>
<div>
<div class="flex items-center justify-between text-xs mb-0.5">
<span class="text-muted-foreground">使用率</span>
<span class="font-mono tabular-nums">{{ fmt(drive.usedPct, 0) }}%</span>
</div>
<Progress :model-value="drive.usedPct ?? 0" class="h-1" />
</div>
<div class="flex items-center justify-between text-xs text-muted-foreground">
<span>容量</span>
<span class="font-mono tabular-nums">{{ fmt(drive.usedGB, 1) }} / {{ fmt(drive.totalGB, 1) }} GB</span>
</div>
</div>
</div>
<!-- 无数据占位保持卡片结构完整 -->
<div v-else class="text-xs text-muted-foreground py-2 text-center">暂无存储数据</div>
</CardContent>
</Card>
<!-- Kernel 状态卡片跨3列含启动/停止/刷新/提权按钮 -->
<Card class="md:col-span-3 py-0 gap-0">
<CardHeader class="pb-1.5 px-3.5 pt-2.5">
<CardTitle class="flex items-center justify-between text-sm">
<span class="flex items-center gap-1.5"><Activity class="size-4 text-primary" />Kernel 状态</span>
<div class="flex items-center gap-1.5">
<span :class="['text-xs px-2 py-0.5 rounded-full', stateMeta[store.connState].class]">
{{ stateMeta[store.connState].text }}
</span>
<Badge :variant="store.snapshot?.isAdmin ? 'default' : 'outline'" :class="store.snapshot?.isAdmin ? 'bg-emerald-500 hover:bg-emerald-500' : ''">
{{ store.snapshot?.isAdmin ? '管理员' : '普通' }}
</Badge>
<Badge v-if="store.status?.thingElevated" variant="outline" class="text-xs border-emerald-500/50 text-emerald-600 dark:text-emerald-400">
<ShieldCheck class="size-2.5 mr-0.5" />提权
</Badge>
</div>
</CardTitle>
</CardHeader>
<CardContent class="px-3.5 pb-2.5">
<div class="flex flex-wrap items-center gap-x-4 gap-y-1.5 text-xs">
<span class="text-muted-foreground">PID: <span class="font-mono text-foreground">{{ store.status?.pid ?? '--' }}</span></span>
<span class="text-muted-foreground">传感器: <span class="font-mono text-foreground">{{ store.status?.sensorCount ?? '--' }}</span></span>
<span class="text-muted-foreground">重启: <span class="font-mono text-foreground">{{ store.status?.restartCount ?? 0 }}</span></span>
<span class="text-muted-foreground">事件: <span class="font-mono text-foreground">{{ store.eventCount }}</span></span>
<div class="flex items-center gap-1.5 ml-auto">
<!-- 启动中 loadingstarting=true 但状态还没变为 loading 时显示 -->
<Button v-if="store.starting && store.connState === 'idle'" size="xs" variant="outline" disabled>
<Loader2 class="size-3 animate-spin" />启动中
</Button>
<!-- 启动按钮未运行且非启动中时显示 -->
<Button v-if="store.connState === 'idle' && !store.starting" size="xs" :disabled="store.starting" @click="handleStart">
<Play class="size-3" />启动
</Button>
<!-- 提权按钮标志未启用时显示设置标志 + 以管理员权限重启 Thing -->
<Tooltip v-if="!store.elevateOnLaunch">
<TooltipTrigger as-child>
<Button size="xs" variant="outline" class="gap-1 text-emerald-600 dark:text-emerald-400 border-emerald-500/40 hover:bg-emerald-500/10" :disabled="store.starting" @click="handleElevateSelf">
<Loader2 v-if="store.starting" class="size-3 animate-spin" />
<ShieldCheck v-else class="size-3" />提权
</Button>
</TooltipTrigger>
<TooltipContent class="max-w-[480px] break-words">以管理员权限重启 Thing UACThingHK 子进程继承权限后续启动自动提权崩溃自动重启</TooltipContent>
</Tooltip>
<!-- 取消提权按钮标志已启用时显示清除标志下次启动不触发 UAC -->
<Tooltip v-else>
<TooltipTrigger as-child>
<Button size="xs" variant="outline" class="gap-1 text-muted-foreground hover:text-foreground" @click="handleCancelElevation">
<ShieldOff class="size-3" />取消提权
</Button>
</TooltipTrigger>
<TooltipContent class="max-w-[480px] break-words">取消提权下次启动将以普通权限运行不影响当前会话</TooltipContent>
</Tooltip>
<Button size="xs" variant="outline" :disabled="store.starting || store.stopping || store.connState === 'idle'" @click="handleRefresh">
<RefreshCw class="size-3" />刷新
</Button>
<Button size="xs" variant="outline" :disabled="store.starting || store.stopping || store.connState === 'idle'" @click="handleStop">
<Square class="size-3" />停止
</Button>
</div>
</div>
</CardContent>
</Card>
<!-- 断线提示 -->
<Card v-if="store.connState === 'disconnected'" class="md:col-span-3 border-orange-500/40 py-0 gap-0">
<CardContent class="pt-3 flex items-start gap-2 text-sm">
<AlertTriangle class="size-4 text-orange-500 mt-0.5 shrink-0" />
<div>
<div class="font-medium text-orange-600 dark:text-orange-400">SSE 连接断开</div>
<div class="text-xs text-muted-foreground mt-0.5">Kernel 可能正在重启等待自动重连...</div>
</div>
</CardContent>
</Card>
<!-- 错误提示 -->
<Card v-if="store.errorMsg" class="md:col-span-3 border-red-500/40 py-0 gap-0">
<CardContent class="pt-3 flex items-start gap-2 text-sm">
<AlertTriangle class="size-4 text-red-500 mt-0.5 shrink-0" />
<div>
<div class="font-medium text-red-600 dark:text-red-400">错误</div>
<div class="text-xs font-mono mt-0.5 break-all">{{ store.errorMsg }}</div>
</div>
</CardContent>
</Card>
</div>
</ScrollArea>
</TabsContent>
<!-- ===== 详细 ===== -->
<TabsContent value="details" class="flex-1 mt-4 tab-animate">
<div v-if="!store.snapshot" key="no-snapshot" class="h-full flex flex-col items-center justify-center text-muted-foreground gap-2 pt-10">
<Gauge class="size-12 opacity-30" />
<p class="text-sm">无快照数据</p>
<p v-if="store.connState === 'idle'" class="text-xs">请先在概览页启动 Kernel</p>
<Button v-else size="xs" variant="outline" :disabled="!store.status?.ready" @click="store.fetchSnapshot()">
<RefreshCw class="size-3" />拉取快照
</Button>
</div>
<ScrollArea v-else key="details-list" class="h-full pr-3">
<div class="flex items-center justify-between mb-3">
<p class="text-sm text-muted-foreground">{{ groups.length }} 个分组 / {{ store.status?.sensorCount ?? 0 }} 个传感器</p>
<Button size="xs" variant="outline" @click="store.fetchSnapshot()">
<RefreshCw class="size-3" />刷新
</Button>
</div>
<Accordion v-model="accordionValue" type="multiple" class="w-full space-y-2 pb-4">
<Card v-for="g in groups" :key="g.id" class="overflow-hidden py-0">
<AccordionItem :value="g.id" class="border-b-0">
<AccordionTrigger class="px-4 py-2.5 hover:no-underline">
<div class="flex items-center justify-between w-full pr-2">
<span class="flex items-center gap-2">
<component :is="groupIcon(g.id)" class="size-4 text-primary" />
<span class="font-medium">{{ groupDisplayName(g.id, g.name) }}</span>
<Badge variant="outline" class="font-normal">{{ g.sensors.length }}</Badge>
</span>
<span class="text-xs text-muted-foreground">{{ g.id }}</span>
</div>
</AccordionTrigger>
<AccordionContent class="px-4 pb-3 pt-0">
<!-- 无传感器数据占位硬件已启用但 LHB 未检测到传感器通常因权限不足或硬件不支持 -->
<div v-if="!g.sensors.length" class="py-3 flex items-center gap-2 text-xs text-muted-foreground">
<AlertTriangle class="size-3.5 shrink-0 text-amber-500" />
<span>未检测到传感器数据可能需要管理员权限或硬件不支持</span>
</div>
<div v-for="hwGroup in groupSensors(g.sensors)" :key="hwGroup.hardware" class="py-2 border-t first:border-t-0">
<div class="text-xs font-medium text-muted-foreground mb-1.5 flex items-center gap-1">
<ChevronDown class="size-3" />{{ hwGroup.hardware }}
</div>
<div v-for="typeGroup in hwGroup.byType" :key="typeGroup.type" class="mb-2">
<div class="text-[11px] uppercase tracking-wide text-muted-foreground/70 mb-1">{{ typeLabel(typeGroup.type) }}</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-0.5 text-xs">
<div v-for="s in typeGroup.items" :key="s.id" class="flex justify-between items-center py-0.5">
<Tooltip>
<TooltipTrigger as-child>
<span class="text-muted-foreground truncate pr-2">{{ s.name }}</span>
</TooltipTrigger>
<TooltipContent>{{ s.name }}</TooltipContent>
</Tooltip>
<span class="font-mono tabular-nums shrink-0" :class="{ 'text-muted-foreground/50': s.value == null }">
{{ s.value == null ? 'N/A' : s.value.toFixed(s.type === 'voltage' || s.type === 'power' ? 2 : 1) }}
<span class="text-muted-foreground ml-0.5">{{ s.unit }}</span>
</span>
</div>
</div>
</div>
</div>
</AccordionContent>
</AccordionItem>
</Card>
</Accordion>
</ScrollArea>
</TabsContent>
<!-- ===== OSD 显示 ===== -->
<TabsContent value="osd" class="flex-1 mt-4 tab-animate">
<ScrollArea class="h-full pr-3">
<div class="max-w-2xl space-y-4 pb-4">
<!-- 悬浮窗开关 -->
<Card>
<CardHeader>
<CardTitle class="text-base flex items-center justify-between">
<span class="flex items-center gap-2">
<MonitorIcon class="size-4 text-primary" />桌面悬浮窗
</span>
<!-- 标题语言切换 -->
<div class="flex items-center gap-1.5 text-xs">
<span class="text-muted-foreground">标题语言</span>
<div class="flex rounded-md border overflow-hidden">
<button
type="button"
class="px-2 py-0.5 transition-colors"
:class="osdConfig.labelLanguage === 'zh' ? 'bg-primary text-primary-foreground' : 'hover:bg-muted'"
@click="updateOsdConfig('labelLanguage', 'zh')"
></button>
<button
type="button"
class="px-2 py-0.5 transition-colors"
:class="osdConfig.labelLanguage === 'en' ? 'bg-primary text-primary-foreground' : 'hover:bg-muted'"
@click="updateOsdConfig('labelLanguage', 'en')"
>EN</button>
</div>
</div>
</CardTitle>
</CardHeader>
<CardContent class="space-y-3">
<div class="flex items-center justify-between gap-3 rounded-md border p-3">
<div class="flex items-center gap-3 min-w-0">
<div class="flex size-9 items-center justify-center rounded-md bg-muted shrink-0">
<MonitorIcon class="size-4 text-primary" />
</div>
<div class="min-w-0">
<div class="text-sm font-medium flex items-center gap-1.5">
悬浮窗
<Badge v-if="osdConfig.overlayEnabled" variant="outline" class="text-[10px]">{{ osdConfig.overlayItems.length }} </Badge>
</div>
<div class="text-xs text-muted-foreground truncate">桌面自定义显示监控悬浮窗</div>
</div>
</div>
<Switch
:model-value="osdConfig.overlayEnabled"
@update:model-value="updateOsdConfig('overlayEnabled', Boolean($event))"
/>
</div>
<div v-if="!store.snapshot?.ready" class="flex items-start gap-2 text-xs text-amber-600 dark:text-amber-400 rounded-md border border-amber-500/30 bg-amber-500/5 p-2.5">
<AlertTriangle class="size-3.5 mt-0.5 shrink-0" />
<span>Kernel 未就绪无法获取可用传感器列表请先在概览页启动 Kernel</span>
</div>
</CardContent>
</Card>
<!-- 悬浮窗显示项 -->
<Card v-if="osdConfig.overlayEnabled">
<CardHeader>
<CardTitle class="text-base flex items-center justify-between">
<span class="flex items-center gap-2"><MonitorIcon class="size-4 text-primary" />显示项</span>
<Button size="xs" variant="outline" :disabled="!store.snapshot?.ready" @click="openOsdPickDialog()">
<ListChecks class="size-3" />选择项
</Button>
</CardTitle>
</CardHeader>
<CardContent>
<div v-if="!osdConfig.overlayItems.length" class="text-sm text-muted-foreground py-3 text-center">
暂无显示项点击"选择项"添加
</div>
<VueDraggable
v-else
v-model="osdConfig.overlayItems"
:animation="200"
:force-fallback="true"
handle=".osd-drag-handle"
ghost-class="opacity-40"
chosen-class="drag-chosen"
class="space-y-1"
@end="onOsdDragEnd()"
>
<div v-for="item in osdConfig.overlayItems" :key="item.key" class="flex items-center gap-2 rounded-md border px-2.5 py-2 text-sm">
<div class="osd-drag-handle cursor-grab active:cursor-grabbing text-muted-foreground/40 hover:text-muted-foreground transition-colors no-native-drag">
<GripVertical class="size-4" />
</div>
<div class="flex items-center gap-1.5 min-w-0 flex-1">
<span class="size-2.5 rounded-full shrink-0" :style="{ backgroundColor: osdItemColor(item) }" />
<span class="truncate">{{ sensorLabel(item) }}</span>
<span class="text-xs text-muted-foreground truncate">/ {{ hwLabel(item) }}</span>
</div>
<Button size="icon-xs" variant="ghost" class="text-muted-foreground hover:text-destructive shrink-0" @click="removeOsdItem(item.key)">
<span class="text-lg leading-none">×</span>
</Button>
</div>
</VueDraggable>
<p class="text-xs text-muted-foreground mt-2 flex items-center gap-1">
<GripVertical class="size-3" />拖动图标可调整显示顺序
</p>
</CardContent>
</Card>
<!-- 显示设置 -->
<Card v-if="osdConfig.overlayEnabled">
<CardHeader>
<CardTitle class="text-base flex items-center gap-2">
<SlidersHorizontal class="size-4 text-primary" />显示设置
</CardTitle>
</CardHeader>
<CardContent class="space-y-4">
<!-- 悬浮窗位置百分比 -->
<div class="space-y-1.5">
<div class="flex items-center justify-between">
<Label class="text-xs text-muted-foreground">悬浮窗位置屏幕百分比</Label>
<Button size="xs" variant="ghost" class="h-6 text-xs" @click="updateOsdConfig('positionXPct', 50); updateOsdConfig('positionYPct', 0)">
重置为顶部居中
</Button>
</div>
<div class="grid grid-cols-2 gap-3">
<div class="space-y-1">
<div class="flex items-center justify-between text-xs">
<span class="text-muted-foreground">X水平</span>
<span class="font-mono tabular-nums">{{ osdConfig.positionXPct }}%</span>
</div>
<input
type="range"
min="0"
max="100"
step="1"
:value="osdConfig.positionXPct"
class="w-full h-1.5 cursor-pointer"
@input="updateOsdConfig('positionXPct', Number(($event.target as HTMLInputElement).value))"
/>
</div>
<div class="space-y-1">
<div class="flex items-center justify-between text-xs">
<span class="text-muted-foreground">Y垂直</span>
<span class="font-mono tabular-nums">{{ osdConfig.positionYPct }}%</span>
</div>
<input
type="range"
min="0"
max="100"
step="1"
:value="osdConfig.positionYPct"
class="w-full h-1.5 cursor-pointer"
@input="updateOsdConfig('positionYPct', Number(($event.target as HTMLInputElement).value))"
/>
</div>
</div>
<p class="text-[11px] text-muted-foreground">X=50% Y=0% 表示水平居中垂直顶部调整百分比会重置到对应位置</p>
</div>
<Separator />
<!-- 布局单行/分组横排/多行 -->
<div class="space-y-1.5">
<Label class="text-xs text-muted-foreground">布局</Label>
<div class="grid grid-cols-3 gap-2">
<button
type="button"
class="rounded-md border p-2.5 text-left transition-colors"
:class="osdConfig.layout === 'single' ? 'border-primary bg-primary/5' : 'hover:bg-muted'"
@click="updateOsdConfig('layout', 'single')"
>
<div class="text-xs font-medium">单行</div>
<div class="text-[11px] text-muted-foreground mt-0.5 font-mono">CPU 50% 30°C | GPU 60°C</div>
</button>
<button
type="button"
class="rounded-md border p-2.5 text-left transition-colors"
:class="osdConfig.layout === 'group' ? 'border-primary bg-primary/5' : 'hover:bg-muted'"
@click="updateOsdConfig('layout', 'group')"
>
<div class="text-xs font-medium">分组横排</div>
<div class="text-[11px] text-muted-foreground mt-0.5 font-mono">
<div>CPU&nbsp;&nbsp;GPU&nbsp;&nbsp;网络</div>
<div>50%&nbsp;60°C&nbsp;1KB/s</div>
</div>
</button>
<button
type="button"
class="rounded-md border p-2.5 text-left transition-colors"
:class="osdConfig.layout === 'multiline' ? 'border-primary bg-primary/5' : 'hover:bg-muted'"
@click="updateOsdConfig('layout', 'multiline')"
>
<div class="text-xs font-medium">多行</div>
<div class="text-[11px] text-muted-foreground mt-0.5 font-mono">
<div>CPU&nbsp;&nbsp;50% 60°C</div>
<div>GPU&nbsp;&nbsp;20% 40°C</div>
</div>
</button>
</div>
</div>
<Separator />
<!-- 颜色主题 -->
<div class="space-y-2">
<div class="flex items-center justify-between">
<div class="flex flex-col gap-0.5">
<Label class="text-sm flex items-center gap-1.5 cursor-pointer">
<span class="size-3 rounded-full" :style="{ background: 'conic-gradient(#4A9EFF, #9D4EFF, #FF9F4A, #4AFF9F, #FFD700, #FF6B6B, #4A9EFF)' }" />
颜色主题
</Label>
<span class="text-[11px] text-muted-foreground">不同硬件/传感器不同颜色</span>
</div>
<Switch
:model-value="osdConfig.colorThemeEnabled"
@update:model-value="updateOsdConfig('colorThemeEnabled', Boolean($event))"
/>
</div>
<Button v-if="osdConfig.colorThemeEnabled" size="xs" variant="outline" @click="openColorThemeDialog">
<SlidersHorizontal class="size-3" />自定义颜色
</Button>
</div>
<!-- 警告色 -->
<div class="space-y-2">
<div class="flex items-center justify-between">
<div class="flex flex-col gap-0.5">
<Label class="text-sm flex items-center gap-1.5 cursor-pointer">
<span class="size-3 rounded-full" :style="{ background: 'linear-gradient(90deg, #FF6B6B 60%, #FF0000 100%)' }" />
警告色
</Label>
<span class="text-[11px] text-muted-foreground">达到阈值时数值/标题变色</span>
</div>
<Switch
:model-value="osdConfig.alert.enabled"
@update:model-value="updateAlertConfig('enabled', Boolean($event))"
/>
</div>
<div v-if="osdConfig.alert.enabled" class="space-y-2.5 pl-1">
<!-- 阈值 -->
<div class="flex items-center justify-between">
<Label class="text-xs text-muted-foreground">警告阈值</Label>
<div class="flex items-center gap-2 w-40">
<span class="text-xs font-mono tabular-nums">{{ osdConfig.alert.warnThreshold }}%</span>
<input
type="range"
min="50"
max="95"
step="1"
:value="osdConfig.alert.warnThreshold"
class="flex-1 h-1.5 cursor-pointer"
@input="updateAlertConfig('warnThreshold', Number(($event.target as HTMLInputElement).value))"
/>
</div>
</div>
<div class="flex items-center justify-between">
<Label class="text-xs text-muted-foreground">严重阈值</Label>
<div class="flex items-center gap-2 w-40">
<span class="text-xs font-mono tabular-nums">{{ osdConfig.alert.criticalThreshold }}%</span>
<input
type="range"
min="55"
max="100"
step="1"
:value="osdConfig.alert.criticalThreshold"
class="flex-1 h-1.5 cursor-pointer"
@input="updateAlertConfig('criticalThreshold', Number(($event.target as HTMLInputElement).value))"
/>
</div>
</div>
<!-- 警告色 -->
<div class="flex items-center justify-between">
<Label class="text-xs text-muted-foreground">警告色</Label>
<Popover>
<PopoverTrigger as-child>
<button
type="button"
class="flex items-center gap-2 rounded-md border px-2.5 py-1 text-xs hover:bg-muted transition-colors"
>
<span class="size-4 rounded border" :style="{ backgroundColor: osdConfig.alert.warnColor }" />
<span class="font-mono">{{ osdConfig.alert.warnColor }}</span>
</button>
</PopoverTrigger>
<PopoverContent class="w-auto p-3" align="end">
<div class="flex flex-col gap-2">
<input
type="color"
:value="osdConfig.alert.warnColor"
class="size-32 cursor-pointer rounded border-0 bg-transparent p-0"
@input="updateAlertConfig('warnColor', ($event.target as HTMLInputElement).value)"
/>
<div class="flex items-center gap-1.5">
<input
type="text"
:value="osdConfig.alert.warnColor"
class="h-7 w-24 rounded border bg-transparent px-2 text-xs font-mono"
@change="updateAlertConfig('warnColor', ($event.target as HTMLInputElement).value)"
/>
<Button size="xs" variant="outline" @click="updateAlertConfig('warnColor', '#FF6B6B')">重置</Button>
</div>
</div>
</PopoverContent>
</Popover>
</div>
<!-- 严重色 -->
<div class="flex items-center justify-between">
<Label class="text-xs text-muted-foreground">严重色</Label>
<Popover>
<PopoverTrigger as-child>
<button
type="button"
class="flex items-center gap-2 rounded-md border px-2.5 py-1 text-xs hover:bg-muted transition-colors"
>
<span class="size-4 rounded border" :style="{ backgroundColor: osdConfig.alert.criticalColor }" />
<span class="font-mono">{{ osdConfig.alert.criticalColor }}</span>
</button>
</PopoverTrigger>
<PopoverContent class="w-auto p-3" align="end">
<div class="flex flex-col gap-2">
<input
type="color"
:value="osdConfig.alert.criticalColor"
class="size-32 cursor-pointer rounded border-0 bg-transparent p-0"
@input="updateAlertConfig('criticalColor', ($event.target as HTMLInputElement).value)"
/>
<div class="flex items-center gap-1.5">
<input
type="text"
:value="osdConfig.alert.criticalColor"
class="h-7 w-24 rounded border bg-transparent px-2 text-xs font-mono"
@change="updateAlertConfig('criticalColor', ($event.target as HTMLInputElement).value)"
/>
<Button size="xs" variant="outline" @click="updateAlertConfig('criticalColor', '#FF0000')">重置</Button>
</div>
</div>
</PopoverContent>
</Popover>
</div>
<!-- 温度墙 -->
<div class="space-y-1.5">
<Label class="text-xs text-muted-foreground">温度墙用于温度换算百分比</Label>
<div v-for="hw in ALERT_MAX_VALUE_LIST" :key="hw.key" class="flex items-center justify-between">
<Label class="text-xs">{{ hw.name }}</Label>
<div class="flex items-center gap-2 w-40">
<input
type="number"
min="50"
max="150"
:value="osdConfig.alert.maxValues[hw.key] ?? 100"
class="h-7 w-16 rounded border bg-transparent px-2 text-xs font-mono text-right"
@change="updateAlertConfig('maxValues', Number(($event.target as HTMLInputElement).value), hw.key)"
/>
<span class="text-xs text-muted-foreground">°C</span>
</div>
</div>
</div>
</div>
</div>
<Separator />
<!-- 悬浮窗背景色 -->
<div class="flex items-center justify-between">
<Label class="text-xs text-muted-foreground">悬浮窗背景色</Label>
<Popover>
<PopoverTrigger as-child>
<button
type="button"
class="flex items-center gap-2 rounded-md border px-2.5 py-1 text-xs hover:bg-muted transition-colors"
>
<span class="size-4 rounded border" :style="{ backgroundColor: osdConfig.bgColor }" />
<span class="font-mono text-[10px]">{{ osdConfig.bgColor }}</span>
</button>
</PopoverTrigger>
<PopoverContent class="w-auto p-3" align="end">
<div class="flex flex-col gap-2">
<div class="flex items-center gap-2">
<input
type="color"
:value="osdBgHex"
class="size-20 cursor-pointer rounded border-0 bg-transparent p-0"
@input="onBgColorInput(($event.target as HTMLInputElement).value)"
/>
<div class="flex flex-col gap-1.5">
<Label class="text-[10px] text-muted-foreground">背景透明度</Label>
<input
type="range"
min="0"
max="100"
:value="osdBgAlpha"
class="w-28 h-1.5 cursor-pointer"
@input="onBgAlphaInput(Number(($event.target as HTMLInputElement).value))"
/>
</div>
</div>
<div class="flex items-center gap-1.5">
<input
type="text"
:value="osdConfig.bgColor"
class="h-7 flex-1 rounded border bg-transparent px-2 text-xs font-mono"
@change="updateOsdConfig('bgColor', ($event.target as HTMLInputElement).value)"
/>
<Button size="xs" variant="outline" @click="updateOsdConfig('bgColor', 'rgba(0, 0, 0, 0.55)')">重置</Button>
</div>
</div>
</PopoverContent>
</Popover>
</div>
<!-- 字体大小 -->
<div class="space-y-1.5">
<div class="flex items-center justify-between">
<Label class="text-xs text-muted-foreground">字体大小</Label>
<span class="text-xs font-mono tabular-nums">{{ osdConfig.fontSize }}px</span>
</div>
<input
type="range"
min="10"
max="20"
:value="osdConfig.fontSize"
class="w-full h-1.5 cursor-pointer"
@input="updateOsdConfig('fontSize', Number(($event.target as HTMLInputElement).value))"
/>
</div>
<!-- 字体透明度独立于悬浮窗透明度 -->
<div class="space-y-1.5">
<div class="flex items-center justify-between">
<Label class="text-xs text-muted-foreground">字体透明度</Label>
<span class="text-xs font-mono tabular-nums">{{ osdConfig.fontOpacity }}%</span>
</div>
<input
type="range"
min="0"
max="100"
:value="osdConfig.fontOpacity"
class="w-full h-1.5 cursor-pointer"
@input="updateOsdConfig('fontOpacity', Number(($event.target as HTMLInputElement).value))"
/>
</div>
<!-- 文字颜色颜色主题关闭时使用 -->
<div v-if="!osdConfig.colorThemeEnabled" class="flex items-center justify-between">
<Label class="text-xs text-muted-foreground">文字颜色</Label>
<Popover>
<PopoverTrigger as-child>
<button
type="button"
class="flex items-center gap-2 rounded-md border px-2.5 py-1 text-xs hover:bg-muted transition-colors"
>
<span class="size-4 rounded border" :style="{ backgroundColor: osdConfig.fontColor }" />
<span class="font-mono">{{ osdConfig.fontColor }}</span>
</button>
</PopoverTrigger>
<PopoverContent class="w-auto p-3" align="end">
<div class="flex flex-col gap-2">
<input
type="color"
:value="osdConfig.fontColor"
class="size-32 cursor-pointer rounded border-0 bg-transparent p-0"
@input="updateOsdConfig('fontColor', ($event.target as HTMLInputElement).value)"
/>
<div class="flex items-center gap-1.5">
<input
type="text"
:value="osdConfig.fontColor"
class="h-7 w-24 rounded border bg-transparent px-2 text-xs font-mono"
@change="updateOsdConfig('fontColor', ($event.target as HTMLInputElement).value)"
/>
<Button size="xs" variant="outline" @click="updateOsdConfig('fontColor', '#ffffff')">重置</Button>
</div>
</div>
</PopoverContent>
</Popover>
</div>
<!-- 字体描边 -->
<div class="space-y-2">
<div class="flex items-center justify-between">
<Label class="text-sm flex items-center gap-1.5 cursor-pointer">
字体描边
</Label>
<Switch
:model-value="osdConfig.fontStrokeEnabled"
@update:model-value="updateOsdConfig('fontStrokeEnabled', Boolean($event))"
/>
</div>
<div v-if="osdConfig.fontStrokeEnabled" class="space-y-2 pl-1">
<!-- 厚度 -->
<div class="flex items-center justify-between">
<Label class="text-xs text-muted-foreground">厚度</Label>
<div class="flex items-center gap-2 w-40">
<span class="text-xs font-mono tabular-nums">{{ osdConfig.fontStrokeWidth }}px</span>
<input
type="range"
min="1"
max="5"
step="1"
:value="osdConfig.fontStrokeWidth"
class="flex-1 h-1.5 cursor-pointer"
@input="updateOsdConfig('fontStrokeWidth', Number(($event.target as HTMLInputElement).value))"
/>
</div>
</div>
<!-- 颜色 -->
<div class="flex items-center justify-between">
<Label class="text-xs text-muted-foreground">颜色</Label>
<Popover>
<PopoverTrigger as-child>
<button
type="button"
class="flex items-center gap-2 rounded-md border px-2.5 py-1 text-xs hover:bg-muted transition-colors"
>
<span class="size-4 rounded border" :style="{ backgroundColor: osdConfig.fontStrokeColor }" />
<span class="font-mono">{{ osdConfig.fontStrokeColor }}</span>
</button>
</PopoverTrigger>
<PopoverContent class="w-auto p-3" align="end">
<div class="flex flex-col gap-2">
<input
type="color"
:value="osdConfig.fontStrokeColor"
class="size-32 cursor-pointer rounded border-0 bg-transparent p-0"
@input="updateOsdConfig('fontStrokeColor', ($event.target as HTMLInputElement).value)"
/>
<div class="flex items-center gap-1.5">
<input
type="text"
:value="osdConfig.fontStrokeColor"
class="h-7 w-24 rounded border bg-transparent px-2 text-xs font-mono"
@change="updateOsdConfig('fontStrokeColor', ($event.target as HTMLInputElement).value)"
/>
<Button size="xs" variant="outline" @click="updateOsdConfig('fontStrokeColor', '#000000')">重置</Button>
</div>
</div>
</PopoverContent>
</Popover>
</div>
</div>
</div>
<Separator />
<!-- 显示选项 -->
<div class="space-y-2.5">
<div class="flex items-center justify-between">
<Label class="text-sm flex items-center gap-1.5 cursor-pointer">
<Eye v-if="osdConfig.showLabel" class="size-3.5 text-muted-foreground" />
<EyeOff v-else class="size-3.5 text-muted-foreground" />
显示标签
</Label>
<Switch
:model-value="osdConfig.showLabel"
@update:model-value="updateOsdConfig('showLabel', Boolean($event))"
/>
</div>
<div class="flex items-center justify-between">
<Label class="text-sm flex items-center gap-1.5 cursor-pointer">
<Eye v-if="osdConfig.showUnit" class="size-3.5 text-muted-foreground" />
<EyeOff v-else class="size-3.5 text-muted-foreground" />
显示单位
</Label>
<Switch
:model-value="osdConfig.showUnit"
@update:model-value="updateOsdConfig('showUnit', Boolean($event))"
/>
</div>
<!-- 鼠标穿透 -->
<div class="flex items-center justify-between">
<div class="flex flex-col gap-0.5">
<Label class="text-sm flex items-center gap-1.5 cursor-pointer">
<MousePointerClick class="size-3.5 text-muted-foreground" />
鼠标穿透
</Label>
<span class="text-[11px] text-muted-foreground">开启后鼠标穿透悬浮窗关闭后左键可拖动悬浮窗</span>
</div>
<Switch
:model-value="osdConfig.clickThrough"
@update:model-value="updateOsdConfig('clickThrough', Boolean($event))"
/>
</div>
</div>
<Separator />
<!-- 更新间隔 -->
<div class="space-y-1.5">
<div class="flex items-center justify-between">
<Label class="text-xs text-muted-foreground">更新间隔</Label>
<span class="text-xs font-mono tabular-nums">{{ osdConfig.updateIntervalMs }}ms</span>
</div>
<Select
:model-value="String(osdConfig.updateIntervalMs)"
@update:model-value="updateOsdConfig('updateIntervalMs', Number($event))"
>
<SelectTrigger class="h-8 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="500">500ms</SelectItem>
<SelectItem value="1000">1</SelectItem>
<SelectItem value="2000">2</SelectItem>
<SelectItem value="5000">5</SelectItem>
</SelectContent>
</Select>
</div>
</CardContent>
</Card>
<!-- 预览卡片已移除用户可在桌面悬浮窗直接看实际效果 -->
</div>
</ScrollArea>
</TabsContent>
<!-- ===== 设置 ===== -->
<TabsContent value="settings" class="flex-1 mt-4 tab-animate">
<ScrollArea class="h-full pr-3">
<div class="max-w-2xl space-y-4 pb-4">
<!-- 控制 -->
<Card>
<CardHeader>
<CardTitle class="text-base flex items-center gap-2">
<Activity class="size-4 text-primary" />Kernel 控制
</CardTitle>
</CardHeader>
<CardContent class="space-y-3 text-sm">
<div class="flex items-center gap-2 flex-wrap">
<Button size="sm" :disabled="store.starting || store.stopping || store.connState !== 'idle'" @click="handleStart">
<Loader2 v-if="store.starting" class="size-3.5 animate-spin" />
<Play v-else class="size-3.5" />启动
</Button>
<Button size="sm" variant="destructive" :disabled="store.starting || store.stopping || store.connState === 'idle'" @click="handleStop">
<Loader2 v-if="store.stopping" class="size-3.5 animate-spin" />
<Square v-else class="size-3.5" />停止
</Button>
<Button size="sm" variant="outline" :disabled="!store.status?.ready" @click="handleRefresh">
<RefreshCw class="size-3.5" />刷新状态
</Button>
<Button size="sm" variant="outline" :disabled="!store.status?.ready" @click="openConfigDialog">
<ListChecks class="size-3.5" />监控项
</Button>
</div>
<div class="text-xs text-muted-foreground">
Kernel ProcessManager 统一管理崩溃自动重启
</div>
<!-- 应用启动时自动启动 toggle参照代理模块 -->
<div class="flex items-center justify-between rounded-md border p-3">
<div>
<p class="text-sm">应用启动时自动启动监控内核</p>
<p class="text-xs text-muted-foreground">软件启动时自动运行 ThingHK 内核</p>
</div>
<Switch
:model-value="store.autoStart"
@update:model-value="(v: boolean) => store.setAutoStart(v)"
/>
</div>
</CardContent>
</Card>
<!-- Kernel 信息 -->
<Card v-if="store.kernelInfo">
<CardHeader>
<CardTitle class="text-base flex items-center gap-2">
<SettingsIcon class="size-4 text-primary" />Kernel 信息
</CardTitle>
</CardHeader>
<CardContent class="space-y-2 text-sm">
<div class="flex justify-between">
<span class="text-muted-foreground">端口</span>
<span class="font-mono">{{ store.kernelInfo.port }}</span>
</div>
<div class="flex justify-between">
<span class="text-muted-foreground">已安装</span>
<Badge :variant="store.kernelInfo.exists ? 'default' : 'destructive'" :class="store.kernelInfo.exists ? 'bg-emerald-500 hover:bg-emerald-500' : ''">
{{ store.kernelInfo.exists ? '是' : '否' }}
</Badge>
</div>
<div class="flex items-center justify-between gap-3">
<span class="text-muted-foreground shrink-0">路径</span>
<div class="flex items-center gap-1.5 min-w-0">
<Tooltip>
<TooltipTrigger as-child>
<span
v-if="store.kernelInfo.exists"
class="font-mono text-xs text-right truncate cursor-default"
>{{ pathDisplay || pathShort }}</span>
<span v-else class="font-mono text-xs text-right cursor-default">{{ pathShort }}</span>
</TooltipTrigger>
<TooltipContent class="max-w-[480px] break-words">{{ store.kernelInfo.path ?? '' }}</TooltipContent>
</Tooltip>
<template v-if="store.kernelInfo.exists">
<Tooltip>
<TooltipTrigger as-child>
<Button size="icon-xs" variant="ghost" @click="copyPath">
<Copy class="size-3" />
</Button>
</TooltipTrigger>
<TooltipContent>复制路径</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger as-child>
<Button size="icon-xs" variant="ghost" @click="openFolder">
<FolderOpen class="size-3" />
</Button>
</TooltipTrigger>
<TooltipContent>在文件夹中显示</TooltipContent>
</Tooltip>
</template>
</div>
</div>
</CardContent>
</Card>
<!-- 权限与降级说明 -->
<Card>
<CardHeader>
<CardTitle class="text-base flex items-center gap-2">
<ShieldCheck class="size-4 text-primary" />权限与降级
</CardTitle>
</CardHeader>
<CardContent class="space-y-3 text-sm text-muted-foreground">
<div class="flex items-center justify-between">
<span>当前权限</span>
<div class="flex items-center gap-1.5">
<Badge :variant="store.snapshot?.isAdmin ? 'default' : 'outline'" :class="store.snapshot?.isAdmin ? 'bg-emerald-500 hover:bg-emerald-500' : ''">
{{ store.snapshot?.isAdmin ? '管理员' : '普通' }}
</Badge>
<Badge v-if="store.status?.thingElevated" variant="outline" class="border-emerald-500/50 text-emerald-600 dark:text-emerald-400">
<ShieldCheck class="size-2.5 mr-0.5" />提权
</Badge>
</div>
</div>
<Separator />
<p class="text-xs leading-relaxed">
LibreHardwareMonitor 访问 CPU MSR(温度/时钟)、存储 SMART、SMBus、EC 传感器需要管理员权限。
普通权限下可读:CPU 负载/功率、内存、GPU 负载。不可读:CPU 温度/时钟、存储、主板/电压。
</p>
<!-- 提权说明 -->
<div class="space-y-2">
<div class="flex items-start gap-2">
<ShieldCheck class="size-3.5 mt-0.5 shrink-0 text-emerald-500" />
<div class="text-xs">
<span class="font-medium text-foreground">提权</span>:以管理员权限重启 Thing 自身,ThingHK 子进程继承权限。标志持久化,后续每次启动自动触发 UAC;可在概览页点击"取消提权"关闭。ThingHK 崩溃由 ProcessManager 自动重启,避免数据停止后 Thing 不感知。
</div>
</div>
</div>
<p v-if="store.status?.thingElevated" class="text-xs leading-relaxed text-emerald-600 dark:text-emerald-400 flex items-start gap-1.5">
<ShieldCheck class="size-3.5 mt-0.5 shrink-0" />
<span>提权模式:Thing 与 ThingHK 均以管理员权限运行,ProcessManager 可直接 kill,崩溃自动重启可用。</span>
</p>
</CardContent>
</Card>
<!-- 关于 -->
<Card>
<CardHeader>
<CardTitle class="text-base flex items-center gap-2">
<Clock class="size-4 text-primary" />关于
</CardTitle>
</CardHeader>
<CardContent class="text-sm space-y-2 text-muted-foreground">
<p class="text-xs leading-relaxed">
数据源:LibreHardwareMonitorLib 0.9.5Native AOT 编译,独立进程)
</p>
<p class="text-xs leading-relaxed">
通信:HTTP + SSE(本地 loopback),schemaVersion=1 数据契约
</p>
<p v-if="store.snapshot?.coldStartMs" class="text-xs leading-relaxed">
冷启动耗时:{{ (store.snapshot.coldStartMs / 1000).toFixed(1) }} 秒
</p>
</CardContent>
</Card>
</div>
</ScrollArea>
</TabsContent>
</Tabs>
</div>
<!-- 硬件监控配置 Dialog -->
<Dialog v-model:open="configDialogOpen">
<DialogContent class="max-w-lg">
<DialogHeader>
<DialogTitle class="flex items-center gap-2">
<ListChecks class="size-4 text-primary" />监控项配置
</DialogTitle>
<DialogDescription>
勾选需要监控的硬件和传感器类型。硬件开关变更需重启 Kernel,传感器类型过滤立即生效。
</DialogDescription>
</DialogHeader>
<ScrollArea class="max-h-[60vh] pr-4">
<div class="space-y-4">
<!-- 硬件分组 -->
<div class="space-y-2">
<h4 class="text-sm font-medium text-muted-foreground">硬件分组</h4>
<div class="space-y-1.5">
<div
v-for="hw in store.hardwareConfig?.availableHardware ?? []"
:key="hw.key"
class="flex items-center justify-between gap-2 rounded-md px-2 py-1.5 hover:bg-muted/50"
>
<div class="flex items-center gap-2 min-w-0">
<Checkbox
:id="'hw-' + hw.key"
:model-value="editingHardware[hw.key] ?? false"
@update:model-value="editingHardware[hw.key] = Boolean($event)"
/>
<label :for="'hw-' + hw.key" class="text-sm cursor-pointer truncate">{{ hw.name }}</label>
<span v-if="hw.key === 'motherboard'" class="text-[10px] text-muted-foreground/60">含 SuperIO/EC(自动联动)</span>
</div>
<Badge v-if="hw.requiresAdmin" variant="outline" class="text-[10px] shrink-0 text-amber-600 border-amber-500/40">
需管理员
</Badge>
</div>
</div>
</div>
<Separator />
<!-- 传感器类型 -->
<div class="space-y-2">
<h4 class="text-sm font-medium text-muted-foreground">传感器类型</h4>
<div class="grid grid-cols-2 gap-1.5">
<div
v-for="st in store.hardwareConfig?.availableSensorTypes ?? []"
:key="st.key"
class="flex items-center gap-2 rounded-md px-2 py-1.5 hover:bg-muted/50"
>
<Checkbox
:id="'st-' + st.key"
:model-value="editingSensorTypes[st.key] ?? false"
@update:model-value="editingSensorTypes[st.key] = Boolean($event)"
/>
<label :for="'st-' + st.key" class="text-sm cursor-pointer">{{ st.name }}</label>
</div>
</div>
</div>
</div>
</ScrollArea>
<DialogFooter>
<Button variant="outline" @click="configDialogOpen = false">取消</Button>
<Button :disabled="savingConfig" @click="handleSaveConfig">
<Loader2 v-if="savingConfig" class="size-3.5 animate-spin" />
保存
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<!-- OSD 显示项选择 Dialog -->
<Dialog v-model:open="osdPickDialogOpen">
<DialogContent class="max-w-lg">
<DialogHeader>
<DialogTitle class="flex items-center gap-2">
<ListChecks class="size-4 text-primary" />
悬浮窗显示项
</DialogTitle>
<DialogDescription>
常用项直接显示,详细项(如 CPU 分核、电压等)折叠于各分组内。
</DialogDescription>
</DialogHeader>
<ScrollArea class="max-h-[60vh] pr-4">
<div v-if="!availableSensors.length" class="text-sm text-muted-foreground py-6 text-center">
暂无可用传感器,请确认 Kernel 已就绪且有传感器数据
</div>
<div v-else class="space-y-3">
<!-- 按分组聚合:常用项直接展示,详细项折叠 -->
<div v-for="grp in groupedAvailableSensors" :key="grp.groupId">
<div class="text-xs font-medium text-muted-foreground mb-1.5 flex items-center gap-1.5">
<component :is="groupIcon(grp.groupId)" class="size-3" />
{{ grp.groupName }}
<Badge variant="outline" class="text-[10px]">{{ grp.common.length + grp.detailed.length }}</Badge>
</div>
<!-- 常用项 -->
<div v-if="grp.common.length" class="space-y-1">
<div
v-for="s in grp.common"
:key="s.key"
class="flex items-center gap-2 rounded-md px-2 py-1.5 hover:bg-muted/50"
>
<Checkbox
:id="'osd-' + s.key"
:model-value="osdPickSelected[s.key] ?? false"
@update:model-value="osdPickSelected[s.key] = Boolean($event)"
/>
<label :for="'osd-' + s.key" class="text-sm cursor-pointer flex-1 min-w-0 truncate">
{{ sensorLabelAvail(s) }}
<span class="text-xs text-muted-foreground">/ {{ hwLabelAvail(s) }}</span>
</label>
<Badge variant="outline" class="text-[10px] shrink-0">{{ typeLabel(s.type) }}</Badge>
</div>
</div>
<!-- 详细项折叠合集 -->
<button
v-if="grp.detailed.length"
type="button"
class="mt-1 flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors px-2 py-1 rounded-md hover:bg-muted/40 w-full"
@click="detailedExpanded[grp.groupId] = !detailedExpanded[grp.groupId]"
>
<ChevronDown
class="size-3 transition-transform"
:class="{ 'rotate-[-90deg]': !detailedExpanded[grp.groupId] }"
/>
<span>详细项({{ grp.detailed.length }}</span>
</button>
<div v-if="grp.detailed.length && detailedExpanded[grp.groupId]" class="space-y-1 mt-1">
<div
v-for="s in grp.detailed"
:key="s.key"
class="flex items-center gap-2 rounded-md px-2 py-1.5 hover:bg-muted/50"
>
<Checkbox
:id="'osd-' + s.key"
:model-value="osdPickSelected[s.key] ?? false"
@update:model-value="osdPickSelected[s.key] = Boolean($event)"
/>
<label :for="'osd-' + s.key" class="text-sm cursor-pointer flex-1 min-w-0 truncate">
{{ sensorLabelAvail(s) }}
<span class="text-xs text-muted-foreground">/ {{ hwLabelAvail(s) }}</span>
</label>
<Badge variant="outline" class="text-[10px] shrink-0">{{ typeLabel(s.type) }}</Badge>
</div>
</div>
</div>
</div>
</ScrollArea>
<DialogFooter>
<div class="text-xs text-muted-foreground mr-auto">
已选 {{ Object.values(osdPickSelected).filter(Boolean).length }} 项
</div>
<Button variant="outline" @click="osdPickDialogOpen = false">取消</Button>
<Button @click="confirmOsdPick">确定</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<!-- 颜色主题编辑 Dialog -->
<Dialog v-model:open="colorThemeDialogOpen">
<DialogContent class="max-w-2xl">
<DialogHeader>
<DialogTitle class="flex items-center gap-2">
<SlidersHorizontal class="size-4 text-primary" />
颜色主题
</DialogTitle>
<DialogDescription>
按硬件类型与传感器类型分别着色,类似小飞机风格。优先使用硬件颜色,其次传感器颜色。
</DialogDescription>
</DialogHeader>
<ScrollArea class="max-h-[60vh] pr-4">
<div class="space-y-4">
<!-- 硬件类型颜色 -->
<div class="space-y-2">
<div class="text-xs font-medium text-muted-foreground">硬件类型</div>
<div class="grid grid-cols-2 gap-2">
<div
v-for="hw in COLOR_THEME_HARDWARE_LIST"
:key="hw.key"
class="flex items-center gap-2 rounded-md border px-2.5 py-1.5"
>
<input
type="color"
:value="editingColorTheme.hardware[hw.key] ?? '#ffffff'"
class="size-6 cursor-pointer rounded border-0 bg-transparent p-0"
@input="editingColorTheme.hardware[hw.key] = ($event.target as HTMLInputElement).value"
/>
<span class="text-sm flex-1 truncate">{{ hw.name }}</span>
<span class="font-mono text-[10px] text-muted-foreground">{{ editingColorTheme.hardware[hw.key] ?? '--' }}</span>
</div>
</div>
</div>
<!-- 传感器类型颜色 -->
<div class="space-y-2">
<div class="text-xs font-medium text-muted-foreground">传感器类型</div>
<div class="grid grid-cols-2 gap-2">
<div
v-for="sen in COLOR_THEME_SENSOR_LIST"
:key="sen.key"
class="flex items-center gap-2 rounded-md border px-2.5 py-1.5"
>
<input
type="color"
:value="editingColorTheme.sensor[sen.key] ?? '#ffffff'"
class="size-6 cursor-pointer rounded border-0 bg-transparent p-0"
@input="editingColorTheme.sensor[sen.key] = ($event.target as HTMLInputElement).value"
/>
<span class="text-sm flex-1 truncate">{{ sen.name }}</span>
<span class="font-mono text-[10px] text-muted-foreground">{{ editingColorTheme.sensor[sen.key] ?? '--' }}</span>
</div>
</div>
</div>
</div>
</ScrollArea>
<DialogFooter>
<Button variant="outline" @click="resetColorTheme">重置默认</Button>
<Button variant="outline" @click="colorThemeDialogOpen = false">取消</Button>
<Button @click="saveColorTheme">保存</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</template>
<style scoped>
.no-native-drag {
-webkit-user-drag: none;
user-select: none;
}
/* OSD 预览:单行分组式 */
.osd-preview-single {
display: inline-flex;
align-items: baseline;
gap: 4px;
padding: 4px 6px;
border-radius: 4px;
white-space: nowrap;
}
.osd-preview-sg-sep {
opacity: 0.4;
}
.osd-preview-sg-group {
display: inline-flex;
align-items: baseline;
gap: 3px;
}
.osd-preview-sg-label {
opacity: 0.7;
margin-right: 1px;
}
/* OSD 预览:分组横排 */
.osd-preview-group {
display: inline-flex;
flex-wrap: wrap;
gap: 8px;
padding: 4px 6px;
border-radius: 4px;
}
.osd-preview-group-item {
display: flex;
flex-direction: column;
gap: 2px;
padding: 0 4px;
}
.osd-preview-group-header {
font-size: 0.85em;
opacity: 0.7;
text-align: center;
border-bottom: 1px solid rgba(255, 255, 255, 0.15);
padding-bottom: 1px;
}
.osd-preview-group-data {
display: flex;
gap: 4px;
}
.osd-preview-group-val {
white-space: nowrap;
}
/* OSD 预览:多行 */
.osd-preview-multiline {
display: inline-flex;
flex-direction: column;
gap: 1px;
padding: 4px 6px;
border-radius: 4px;
}
.osd-preview-line {
display: flex;
gap: 4px;
white-space: nowrap;
}
.osd-preview-line-label {
opacity: 0.7;
min-width: 3ch;
}
</style>