2554 lines
117 KiB
Vue
2554 lines
117 KiB
Vue
<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 已知 bug:MBR + 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 GB(UINT64_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 计算 usedGB;free 异常时回退用 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 展示与修改) =====
|
||
// 类型/默认值/持久化/窗口管理均在 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> = {
|
||
// 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 + alpha(0-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 store:SSE 订阅保持,确保切走监控模块后 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">
|
||
<!-- 启动中 loading(starting=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(弹 UAC,ThingHK 子进程继承权限,后续启动自动提权,崩溃自动重启)</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 GPU 网络</div>
|
||
<div>50% 60°C ↑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 50% 60°C</div>
|
||
<div>GPU 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.5(Native 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>
|