监控模块 优化

This commit is contained in:
zhongluofeng
2026-07-24 18:16:34 +08:00
parent e8267c244f
commit 7e6149355e
15 changed files with 2148 additions and 234 deletions
+610 -160
View File
@@ -2,10 +2,13 @@
import {
Activity, Play, Square, RefreshCw, Loader2, Cpu, MemoryStick,
Gauge, HardDrive, Settings as SettingsIcon, AlertTriangle,
ShieldCheck, ShieldAlert, Zap, Thermometer, Clock, ChevronDown,
ShieldCheck, ShieldOff, Zap, Thermometer, Clock, ChevronDown,
KeyRound, ArrowDown, ArrowUp, Wifi, FolderOpen, Copy, ListChecks,
} from '@lucide/vue'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { toast } from 'vue-sonner'
import { appDataDir } from '@tauri-apps/api/path'
import { revealItemInDir } from '@tauri-apps/plugin-opener'
import { useMonitorStore, type SensorEntry, type SensorGroup, type ConnectionState } from '@/stores/monitorStore'
import { useModuleTabs } from '@/lib/useModuleTabs'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
@@ -16,6 +19,9 @@ 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'
const store = useMonitorStore()
@@ -31,9 +37,9 @@ const tabsListRef = useModuleTabs(activeTab, [
// 容量 30 点,约对应 30 秒(fast 通道 1s 推送一次)
const HISTORY_LEN = 30
const cpuTempHistory = ref<number[]>([])
const cpuLoadHistory = ref<number[]>([])
const memLoadHistory = ref<number[]>([])
const gpuLoadHistory = ref<number[]>([])
const gpuTempHistory = ref<number[]>([])
const netDownHistory = ref<number[]>([])
const netUpHistory = ref<number[]>([])
function pushHistory(buf: typeof cpuTempHistory, v: number | null) {
if (v == null || !isFinite(v)) return
@@ -41,31 +47,112 @@ function pushHistory(buf: typeof cpuTempHistory, v: number | null) {
if (buf.value.length > HISTORY_LEN) buf.value.shift()
}
// 监听快照变化,提取关键指标入历史
watch(() => store.snapshot, (snap) => {
if (!snap) return
pushHistory(cpuTempHistory, store.findSensorValue('cpu', { name: 'CPU Package', type: 'temperature' }))
pushHistory(cpuLoadHistory, store.findSensorValue('cpu', { name: 'CPU Total', type: 'load' }))
// Memory 分组有两条同名 "Memory" 负载传感器(Virtual Memory + Total Memory),优先取 Total Memory
pushHistory(memLoadHistory, store.findSensorValue('memory', { name: 'Memory', hardwareName: 'Total Memory', type: 'load' }))
// Intel GPU 3D 负载传感器名为 "D3D 3D";部分 GPU 为 "GPU Core"
pushHistory(gpuLoadHistory,
store.findSensorValue('gpuintel', { name: 'D3D 3D', type: 'load' }) ??
store.findSensorValue('gpuintel', { name: 'GPU Core', type: 'load' }) ??
store.findSensorValue('gpuintel', { type: 'load' }),
)
}, { deep: false })
// ===== 关键指标 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)
const cpuTemp = computed(() => store.findSensorValue('cpu', { name: 'CPU Package', type: 'temperature' }))
const cpuLoad = computed(() => store.findSensorValue('cpu', { name: 'CPU Total', type: 'load' }))
const cpuPower = computed(() => store.findSensorValue('cpu', { name: 'CPU Package', 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 gpuLoad = computed(() =>
store.findSensorValue('gpuintel', { name: 'D3D 3D', type: 'load' }) ??
store.findSensorValue('gpuintel', { name: 'GPU Core', type: 'load' }) ??
store.findSensorValue('gpuintel', { type: 'load' }),
)
const storageTemp = computed(() => store.findSensorValue('storage', { type: 'temperature' }))
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 分组,提取温度/使用率/容量) */
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
const usedPct = sensors.find(s => s.name === 'Used Space')?.value ?? null
const totalGB = sensors.find(s => s.name === 'Total Space')?.value ?? null
const freeGB = sensors.find(s => s.name === 'Free Space')?.value ?? null
const usedGB = totalGB != null && freeGB != null ? totalGB - freeGB : null
return { name: hw, temp, usedPct, totalGB, usedGB }
})
})
// 监听快照变化,提取温度历史(用于 CPU/GPU 温度 sparkline
watch(() => store.snapshot, () => {
pushHistory(cpuTempHistory, cpuTemp.value)
pushHistory(gpuTempHistory, gpuTemp.value)
}, { deep: false })
// 监听网速事件,记录历史(用于网速 sparkline)
watch(() => store.networkSpeed, (v) => {
if (v) {
pushHistory(netDownHistory, v.downloadBps)
pushHistory(netUpHistory, v.uploadBps)
}
}, { deep: false })
// 网速格式化(computed 避免模板中重复调用)
const downSpeed = computed(() => fmtSpeed(store.networkSpeed?.downloadBps ?? null))
const upSpeed = computed(() => fmtSpeed(store.networkSpeed?.uploadBps ?? null))
// ===== 工具函数 =====
@@ -92,6 +179,14 @@ function loadColor(v: number | null): string {
return 'text-red-500'
}
/** 格式化网速(bytes/s → 自适应 KB/s 或 MB/s */
function fmtSpeed(bytesPerSec: number | null): { value: string; unit: string } {
if (bytesPerSec == null || !isFinite(bytesPerSec)) return { value: '--', unit: '' }
if (bytesPerSec >= 1_048_576) return { value: (bytesPerSec / 1_048_576).toFixed(2), unit: 'MB/s' }
if (bytesPerSec >= 1024) return { value: (bytesPerSec / 1024).toFixed(1), unit: 'KB/s' }
return { value: bytesPerSec.toFixed(0), unit: 'B/s' }
}
/** 传感器类型 → 中文标签 */
const typeLabels: Record<string, string> = {
temperature: '温度',
@@ -214,8 +309,151 @@ async function handleRefresh() {
}
}
/** 提权启动 Kernel(弹 UAC)。
* 普通权限下 CPU 温度/时钟/存储等传感器不可读,提权后可获取完整数据。
*/
async function handleStartElevated() {
await store.startElevated()
if (store.status?.elevated) {
toast.success('Kernel 已提权运行')
} else if (store.errorMsg) {
toast.error('提权失败', { description: store.errorMsg })
}
}
/** 永久提权:以管理员权限重启 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
}
}
// 生命周期
onMounted(() => {
onMounted(async () => {
// 获取 appData 路径,用于将 Kernel 路径替换为 %APPDATA% 形式
try { appDataPath.value = await appDataDir() } catch { /* 忽略 */ }
store.init()
})
@@ -245,105 +483,42 @@ watch(() => store.status?.ready, (ready, prev) => {
<!-- ===== 概览 ===== -->
<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-if="store.connState === 'idle'" key="idle" class="h-full flex flex-col items-center justify-center text-muted-foreground gap-3 pt-10">
<Activity class="size-12 opacity-30" />
<p class="text-sm">Kernel 未启动</p>
<Button size="sm" :disabled="store.starting" @click="handleStart">
<Loader2 v-if="store.starting" class="size-3.5 animate-spin" />
<Play v-else class="size-3.5" />启动 Kernel
</Button>
</div>
<div v-else key="content" class="columns-1 md:columns-2 gap-4 [&>*]:mb-4 [&>*]:break-inside-avoid">
<!-- 状态卡片 -->
<Card>
<CardHeader class="pb-3">
<CardTitle class="flex items-center justify-between text-base">
<span class="flex items-center gap-2"><Activity class="size-4 text-primary" />Kernel 状态</span>
<span :class="['text-xs px-2 py-0.5 rounded-full', stateMeta[store.connState].class]">
{{ stateMeta[store.connState].text }}
</span>
<!-- 始终显示卡片网格未启动时数据以占位符显示保持画面完整 -->
<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>
<span class="text-xs text-muted-foreground font-normal truncate ml-2" :title="cpuModel ?? ''">{{ cpuModel ?? '--' }}</span>
</CardTitle>
</CardHeader>
<CardContent class="space-y-2 text-sm">
<div class="grid grid-cols-2 gap-x-4 gap-y-1.5">
<div class="flex justify-between">
<span class="text-muted-foreground">进程</span>
<span>{{ store.status?.running ? '运行中' : '已停止' }}</span>
</div>
<div class="flex justify-between">
<span class="text-muted-foreground">PID</span>
<span class="font-mono">{{ store.status?.pid ?? '--' }}</span>
</div>
<div class="flex justify-between">
<span class="text-muted-foreground">就绪</span>
<span>{{ store.status?.ready ? '是' : '否' }}</span>
</div>
<div class="flex justify-between">
<span class="text-muted-foreground">传感器数</span>
<span>{{ store.status?.sensorCount ?? '--' }}</span>
</div>
<div class="flex justify-between">
<span class="text-muted-foreground">重启次数</span>
<span>{{ store.status?.restartCount ?? 0 }}</span>
</div>
<div class="flex justify-between">
<span class="text-muted-foreground">事件数</span>
<span>{{ store.eventCount }}</span>
</div>
</div>
<Separator />
<div class="flex items-center justify-between">
<span class="flex items-center gap-1.5 text-muted-foreground">
<component :is="store.snapshot?.isAdmin ? ShieldCheck : ShieldAlert" class="size-3.5" />
权限
</span>
<Badge :variant="store.snapshot?.isAdmin ? 'default' : 'outline'" :class="store.snapshot?.isAdmin ? 'bg-emerald-500 hover:bg-emerald-500' : ''">
{{ store.snapshot?.isAdmin ? '管理员' : '普通' }}
</Badge>
</div>
<div v-if="!store.snapshot?.isAdmin" class="text-xs text-orange-600 dark:text-orange-400 flex items-start gap-1.5 pt-1">
<AlertTriangle class="size-3.5 mt-0.5 shrink-0" />
<span>非管理员运行部分传感器电压/主板/SMBus可能不可读可在设置页查看降级说明</span>
</div>
<div class="flex items-center gap-2 pt-1">
<Button size="xs" variant="outline" :disabled="store.starting || store.stopping" @click="handleRefresh">
<RefreshCw class="size-3" />刷新
</Button>
<Button size="xs" variant="outline" :disabled="store.starting || store.stopping" @click="handleStop">
<Square class="size-3" />停止
</Button>
</div>
</CardContent>
</Card>
<!-- CPU 关键指标 -->
<Card>
<CardHeader class="pb-3">
<CardTitle class="flex items-center gap-2 text-base">
<Cpu class="size-4 text-primary" />CPU
</CardTitle>
</CardHeader>
<CardContent class="space-y-3">
<div class="flex items-end justify-between">
<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', tempColor(cpuTemp)]">
<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>
<svg v-if="cpuTempHistory.length >= 2" :width="100" :height="28" class="opacity-80">
<path :d="sparklinePath(cpuTempHistory)" :stroke="'currentColor'" :class="tempColor(cpuTemp)" fill="none" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
<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>
<svg v-if="cpuTempHistory.length >= 2" :width="56" :height="24" class="opacity-70 shrink-0">
<path :d="sparklinePath(cpuTempHistory, { w: 56, h: 24 })" stroke="currentColor" :class="tempColor(cpuTemp)" fill="none" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</div>
<!-- 负载 -->
<div>
<div class="flex items-center justify-between text-xs mb-1">
<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>
@@ -352,64 +527,203 @@ watch(() => store.status?.ready, (ready, prev) => {
</CardContent>
</Card>
<!-- 内存 -->
<Card>
<CardHeader class="pb-3">
<CardTitle class="flex items-center gap-2 text-base">
<MemoryStick class="size-4 text-primary" />内存
<!-- 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>
<span class="text-xs text-muted-foreground font-normal truncate ml-2" :title="gpuModel ?? ''">{{ gpuModel ?? '--' }}</span>
</CardTitle>
</CardHeader>
<CardContent class="space-y-3">
<div>
<div class="flex items-center justify-between text-xs mb-1">
<span class="text-muted-foreground">内存负载</span>
<span :class="['font-medium tabular-nums', loadColor(memLoad)]">{{ fmt(memLoad, 0) }}%</span>
<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>
<Progress :model-value="memLoad ?? 0" class="h-1.5" />
<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>
<svg v-if="gpuTempHistory.length >= 2" :width="56" :height="24" class="opacity-70 shrink-0">
<path :d="sparklinePath(gpuTempHistory, { w: 56, h: 24 })" stroke="currentColor" :class="tempColor(gpuTemp)" fill="none" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</div>
<svg v-if="memLoadHistory.length >= 2" :width="'100%'" :height="32" class="opacity-80" preserveAspectRatio="none" viewBox="0 0 100 32">
<path :d="sparklinePath(memLoadHistory, { min: 0, max: 100, w: 100, h: 32 })" stroke="currentColor" class="text-violet-500" fill="none" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</CardContent>
</Card>
<!-- GPU -->
<Card>
<CardHeader class="pb-3">
<CardTitle class="flex items-center gap-2 text-base">
<Gauge class="size-4 text-primary" />GPU
</CardTitle>
</CardHeader>
<CardContent class="space-y-3">
<div v-if="gpuLoad != null">
<div class="flex items-center justify-between text-xs mb-1">
<span class="text-muted-foreground">3D 负载</span>
<!-- 负载 -->
<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>
<div v-else class="text-xs text-muted-foreground py-2 text-center">暂无 GPU 数据</div>
</CardContent>
</Card>
<!-- 存储 -->
<Card v-if="storageTemp != null">
<CardHeader class="pb-3">
<CardTitle class="flex items-center gap-2 text-base">
<!-- 内存主指标大字 + 进度条未读数据以 -- 占位 -->
<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>
<span class="text-xs text-muted-foreground font-normal truncate ml-2" :title="memModuleModels.join(', ')">
{{ memTotalGB != null ? fmt(memTotalGB, 0) + ' GB' : '--' }}
</span>
</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="flex items-end gap-2">
<div class="text-2xl font-bold tabular-nums leading-tight text-sky-500 shrink-0">
{{ downSpeed.value }}<span class="text-sm font-normal text-muted-foreground ml-0.5">{{ downSpeed.unit }}</span>
</div>
<svg v-if="netDownHistory.length >= 2" class="flex-1 h-7 opacity-70" viewBox="0 0 80 28" preserveAspectRatio="none">
<path :d="sparklinePath(netDownHistory, { w: 80, h: 28 })" stroke="currentColor" class="text-sky-500" fill="none" stroke-width="1.5" vector-effect="non-scaling-stroke" />
</svg>
</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="flex items-end gap-2">
<div class="text-2xl font-bold tabular-nums leading-tight text-violet-500 shrink-0">
{{ upSpeed.value }}<span class="text-sm font-normal text-muted-foreground ml-0.5">{{ upSpeed.unit }}</span>
</div>
<svg v-if="netUpHistory.length >= 2" class="flex-1 h-7 opacity-70" viewBox="0 0 80 28" preserveAspectRatio="none">
<path :d="sparklinePath(netUpHistory, { w: 80, h: 28 })" stroke="currentColor" class="text-violet-500" fill="none" stroke-width="1.5" vector-effect="non-scaling-stroke" />
</svg>
</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>
<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', tempColor(storageTemp)]">
{{ fmt(storageTemp, 0) }}<span class="text-sm font-normal">°C</span>
<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">
<span class="text-xs font-medium truncate" :title="drive.name">{{ drive.name }}</span>
<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>
<Badge v-else-if="store.status?.elevated" variant="outline" class="text-xs border-violet-500/50 text-violet-600 dark:text-violet-400">
<KeyRound 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">
<!-- 启动按钮未运行时显示 -->
<Button v-if="store.connState === 'idle'" size="xs" :disabled="store.starting" @click="handleStart">
<Loader2 v-if="store.starting" class="size-3 animate-spin" />
<Play v-else class="size-3" />启动
</Button>
<!-- 普通提权按钮仅提权 ThingHKThing 非管理员且 ThingHK 未提权时显示 -->
<Button v-if="!store.snapshot?.isAdmin && !store.status?.elevated && !store.status?.thingElevated" size="xs" variant="outline" class="gap-1 text-orange-600 dark:text-orange-400 border-orange-500/40 hover:bg-orange-500/10" :disabled="store.starting" title="以管理员权限启动 Kernel(弹 UAC,仅提权 ThingHK" @click="handleStartElevated">
<Loader2 v-if="store.starting" class="size-3 animate-spin" />
<KeyRound v-else class="size-3" />提权
</Button>
<!-- 永久提权按钮标志未启用时显示设置标志 + 以管理员权限重启 Thing -->
<Button v-if="!store.elevateOnLaunch" 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" title="以管理员权限重启 Thing(弹 UAC,ThingHK 子进程继承权限,后续启动自动提权)" @click="handleElevateSelf">
<Loader2 v-if="store.starting" class="size-3 animate-spin" />
<ShieldCheck v-else class="size-3" />永久提权
</Button>
<!-- 取消永久提权按钮标志已启用时显示清除标志下次启动不触发 UAC -->
<Button v-else size="xs" variant="outline" class="gap-1 text-muted-foreground hover:text-foreground" title="取消永久提权,下次启动将以普通权限运行(不影响当前会话)" @click="handleCancelElevation">
<ShieldOff class="size-3" />取消提权
</Button>
<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="border-orange-500/40">
<CardContent class="pt-4 flex items-start gap-2 text-sm">
<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>
@@ -419,8 +733,8 @@ watch(() => store.status?.ready, (ready, prev) => {
</Card>
<!-- 错误提示 -->
<Card v-if="store.errorMsg" class="border-red-500/40">
<CardContent class="pt-4 flex items-start gap-2 text-sm">
<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>
@@ -513,6 +827,9 @@ watch(() => store.status?.ready, (ready, prev) => {
<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 统一管理应用启动时自动拉起崩溃自动重启
@@ -538,9 +855,38 @@ watch(() => store.status?.ready, (ready, prev) => {
{{ store.kernelInfo.exists ? '是' : '否' }}
</Badge>
</div>
<div class="flex justify-between items-start gap-2">
<div class="flex items-center justify-between gap-3">
<span class="text-muted-foreground shrink-0">路径</span>
<span class="font-mono text-xs text-right break-all" :title="store.kernelInfo.path">{{ store.kernelInfo.path }}</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-[400px] break-all">{{ 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>
@@ -552,16 +898,48 @@ watch(() => store.status?.ready, (ready, prev) => {
<ShieldCheck class="size-4 text-primary" />权限与降级
</CardTitle>
</CardHeader>
<CardContent class="space-y-2 text-sm text-muted-foreground">
<p>当前权限<Badge :variant="store.snapshot?.isAdmin ? 'default' : 'outline'" :class="store.snapshot?.isAdmin ? 'bg-emerald-500 hover:bg-emerald-500 ml-1' : 'ml-1'">{{ store.snapshot?.isAdmin ? '管理员' : '普通' }}</Badge></p>
<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>
<Badge v-else-if="store.status?.elevated" variant="outline" class="border-violet-500/50 text-violet-600 dark:text-violet-400">
<KeyRound class="size-2.5 mr-0.5" />提权模式
</Badge>
</div>
</div>
<Separator />
<p class="text-xs leading-relaxed">
LibreHardwareMonitor 访问 SMBus部分 EC 传感器某些 GPU 传感器需要管理员权限
默认非提权运行覆盖大部分 CPU/GPU 温度通过 OHM RPC/WMI 仍可读牺牲部分主板/电压传感器
LibreHardwareMonitor 访问 CPU MSR温度/时钟存储 SMARTSMBusEC 传感器需要管理员权限
普通权限下可读CPU 负载/功率内存GPU 负载不可读CPU 温度/时钟存储主板/电压
</p>
<p class="text-xs leading-relaxed text-orange-600 dark:text-orange-400 flex items-start gap-1.5">
<AlertTriangle class="size-3.5 mt-0.5 shrink-0" />
<span>提权策略任务计划程序免 UAC 启动将在阶段五实现当前以普通权限运行</span>
<!-- 两种提权模式说明 -->
<div class="space-y-2">
<div class="flex items-start gap-2">
<KeyRound class="size-3.5 mt-0.5 shrink-0 text-orange-500" />
<div class="text-xs">
<span class="font-medium text-foreground">提权</span>仅以管理员权限启动 ThingHK UACThingHK 崩溃不自动重启停止走 /shutdown 接口
</div>
</div>
<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可在概览页点击"取消提权"关闭
</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>
<p v-else-if="store.status?.elevated" class="text-xs leading-relaxed text-violet-600 dark:text-violet-400 flex items-start gap-1.5">
<KeyRound class="size-3.5 mt-0.5 shrink-0" />
<span>提权模式运行中 ThingHK 管理员权限进程不归 ProcessManager 管控崩溃不自动重启停止通过 /shutdown 接口优雅退出</span>
</p>
</CardContent>
</Card>
@@ -590,4 +968,76 @@ watch(() => store.status?.ready, (ready, prev) => {
</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>
</template>
+182 -9
View File
@@ -42,6 +42,10 @@ export interface MonitorStatus {
ready: boolean
sensorCount: number
restartCount: number
/** 是否为提权模式(通过 UAC 以管理员权限启动) */
elevated: boolean
/** Thing 自身是否以管理员权限运行(永久提权模式) */
thingElevated: boolean
}
export interface MonitorKernelInfo {
@@ -50,6 +54,48 @@ export interface MonitorKernelInfo {
port: number
}
// ===== 硬件监控配置(与 ThingHK HardwareConfig.cs 对应) =====
export interface HardwareTypeInfo {
key: string
name: string
enabled: boolean
requiresAdmin: boolean
}
export interface SensorTypeInfo {
key: string
name: string
enabled: boolean
}
export interface HardwareConfig {
hardware: Record<string, boolean>
sensorTypes: Record<string, boolean>
}
export interface HardwareConfigResponse {
config: HardwareConfig
availableHardware: HardwareTypeInfo[]
availableSensorTypes: SensorTypeInfo[]
}
export interface HardwareConfigUpdateResponse {
success: boolean
restartRequired: boolean
configPath: string | null
}
/** 网速数据(由 Tauri network_monitor 模块推送,独立于 ThingHK Kernel */
export interface NetworkSpeed {
/** 下载速率(bytes/s */
downloadBps: number
/** 上传速率(bytes/s */
uploadBps: number
/** 时间戳(ms */
timestamp: number
}
/** 连接状态机:与后端事件一一对应 */
export type ConnectionState = 'idle' | 'loading' | 'connected' | 'disconnected' | 'error'
@@ -70,9 +116,18 @@ export const useMonitorStore = defineStore('monitor', () => {
const starting = ref(false)
const stopping = ref(false)
/** 网速数据(由 network_monitor 后台任务推送,独立于 Kernel) */
const networkSpeed = ref<NetworkSpeed | null>(null)
/** 是否已完成首次加载(避免初始 null/false 导致 UI 闪烁误导状态) */
const initialized = ref(false)
/** 永久提权标志是否已启用(后续启动自动触发 UAC) */
const elevateOnLaunch = ref(false)
/** 硬件监控配置(可用硬件 + 传感器类型清单 + 当前启用状态) */
const hardwareConfig = ref<HardwareConfigResponse | null>(null)
let unlistenFns: UnlistenFn[] = []
// ===== getters =====
@@ -122,8 +177,6 @@ export const useMonitorStore = defineStore('monitor', () => {
errorMsg.value = null
} catch (e) {
logger.error('获取状态失败: ' + e)
} finally {
initialized.value = true
}
return status.value
}
@@ -169,6 +222,96 @@ export const useMonitorStore = defineStore('monitor', () => {
}
}
/** 以管理员权限重启 Kernel(弹 UAC)。
* 停止当前 Kernel → ShellExecute "runas" → 等待 ready → 重新订阅 SSE。
* 提权后进程不归 ProcessManager 管,停止走 /shutdown 接口。
*/
async function startElevated() {
if (starting.value) return
starting.value = true
errorMsg.value = null
try {
await invoke('monitor_start_elevated')
await refreshStatus()
} catch (e) {
errorMsg.value = String(e)
logger.error('提权启动失败: ' + e)
} finally {
starting.value = false
}
}
/** 永久提权:以管理员权限重启 Thing 自身,ThingHK 子进程会继承管理员权限。
* 非管理员时进程退出;已是管理员时仅设置标志并返回。 */
async function elevateSelf() {
if (starting.value) return
starting.value = true
errorMsg.value = null
try {
await invoke('monitor_elevate_self')
// 命令返回说明已是管理员(非管理员时进程已 exit,不会走到这里)
await refreshElevateOnLaunch()
} catch (e) {
errorMsg.value = String(e)
logger.error('永久提权失败: ' + e)
} finally {
starting.value = false
}
}
/** 刷新永久提权标志状态 */
async function refreshElevateOnLaunch() {
try {
elevateOnLaunch.value = await invoke<boolean>('monitor_get_elevate_on_launch')
} catch (e) {
logger.error('获取提权标志失败: ' + e)
}
return elevateOnLaunch.value
}
/** 取消永久提权:清除标志,下次启动不再触发 UAC(当前会话权限不变) */
async function cancelElevation() {
try {
await invoke('monitor_set_elevate_on_launch', { enabled: false })
await refreshElevateOnLaunch()
} catch (e) {
errorMsg.value = String(e)
logger.error('取消提权失败: ' + e)
}
}
/** 拉取硬件监控配置(可用硬件 + 传感器类型清单 + 当前启用状态) */
async function fetchHardwareConfig() {
try {
hardwareConfig.value = await invoke<HardwareConfigResponse>('monitor_get_hardware_config')
} catch (e) {
logger.error('获取硬件配置失败: ' + e)
}
return hardwareConfig.value
}
/** 保存硬件监控配置。
* 传感器类型过滤热生效;硬件开关变化返回 restartRequired=true。
* 调用方负责根据 restartRequired 决定是否重启 Kernel。 */
async function saveHardwareConfig(
hardware: Record<string, boolean>,
sensorTypes: Record<string, boolean>,
): Promise<HardwareConfigUpdateResponse | null> {
try {
const resp = await invoke<HardwareConfigUpdateResponse>('monitor_set_hardware_config', {
hardware,
sensorTypes,
})
// 刷新本地缓存的配置
await fetchHardwareConfig()
return resp
} catch (e) {
errorMsg.value = String(e)
logger.error('保存硬件配置失败: ' + e)
return null
}
}
/** 主动拉取一次性快照(切回 tab 时立即填充,不等下一个 SSE tick) */
async function fetchSnapshot() {
try {
@@ -196,7 +339,8 @@ export const useMonitorStore = defineStore('monitor', () => {
refreshStatus()
}))
unlistenFns.push(await listen('monitor-loading', () => {
// 状态由 status 轮询反映
// 后端正在等待 Kernel ready,刷新状态以反映 running=true
refreshStatus()
}))
unlistenFns.push(await listen('monitor-disconnected', () => {
logger.warn('SSE 断开,等待自动重连')
@@ -206,6 +350,10 @@ export const useMonitorStore = defineStore('monitor', () => {
errorMsg.value = e.payload?.message ?? 'Kernel 错误'
logger.error('Kernel 错误: ' + JSON.stringify(e.payload))
}))
// 网速监控事件(独立于 Kernel,应用启动即推送)
unlistenFns.push(await listen<NetworkSpeed>('monitor-network', (e) => {
networkSpeed.value = e.payload
}))
}
function unsubscribe() {
@@ -213,13 +361,29 @@ export const useMonitorStore = defineStore('monitor', () => {
unlistenFns = []
}
/** 模块挂载时调用:刷新状态 + 订阅事件 + 拉取一次快照 */
/** 模块挂载时调用:刷新状态 + 订阅事件 + 拉取一次快照
* 首次加载期间 initialized=falseUI 显示 loading 占位(隐藏启动按钮等),
* 避免与后端 setup 异步自动启动竞态导致按钮误显示。 */
async function init() {
await Promise.all([refreshStatus(), refreshKernelInfo()])
await subscribe()
// 若 Kernel 已就绪,立即拉一次快照避免 UI 空白
if (status.value?.ready) {
await fetchSnapshot()
try {
await Promise.all([refreshStatus(), refreshKernelInfo(), refreshElevateOnLaunch()])
await subscribe()
// 若 Kernel 已就绪,立即拉一次快照避免 UI 空白
if (status.value?.ready) {
await fetchSnapshot()
}
// 自动启动竞态修复:setup 中 start_with_subscription 是异步 spawn
// 首次 refreshStatus 可能返回 running=false(进程还未拉起)。
// 若状态仍为 idle,短暂重试以等待自动启动生效。
if (!status.value?.running) {
for (let i = 0; i < 5; i++) {
await new Promise(r => setTimeout(r, 500))
await refreshStatus()
if (status.value?.running) break
}
}
} finally {
initialized.value = true
}
}
@@ -239,6 +403,9 @@ export const useMonitorStore = defineStore('monitor', () => {
starting,
stopping,
initialized,
networkSpeed,
elevateOnLaunch,
hardwareConfig,
// getters
connState,
isLive,
@@ -247,7 +414,13 @@ export const useMonitorStore = defineStore('monitor', () => {
findSensorValue,
refreshStatus,
refreshKernelInfo,
refreshElevateOnLaunch,
start,
startElevated,
elevateSelf,
cancelElevation,
fetchHardwareConfig,
saveHardwareConfig,
stop,
fetchSnapshot,
init,