性能优化

This commit is contained in:
zhongluofeng
2026-08-06 10:33:16 +08:00
parent c7578a2e6b
commit e66c53e66d
105 changed files with 7273 additions and 5002 deletions
+2 -1
View File
@@ -7,6 +7,7 @@ import { useSearchStore } from '@/stores/searchStore'
import { useProcessStore } from '@/stores/processStore'
import { toast } from 'vue-sonner'
import { createLogger } from '@/lib/logger'
import { STORAGE_KEYS } from '@/lib/constants'
import type { ModuleCategory } from '@/types/module'
const logger = createLogger('app')
@@ -27,7 +28,7 @@ export interface ModuleInfo {
/** localStorage 版本号 —— 结构变更时递增,自动清除旧数据 */
const SETTINGS_VERSION = 4
const STORAGE_KEY = 'thing_app_settings'
const STORAGE_KEY = STORAGE_KEYS.appSettings
/** 从模块注册表初始化模块元信息 */
const initModulesFromRegistry = (): ModuleInfo[] => {
+38 -68
View File
@@ -1,52 +1,30 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { invoke } from '@tauri-apps/api/core'
import { listen, type UnlistenFn } from '@tauri-apps/api/event'
import { createLogger } from '@/lib/logger'
import { EVENTS } from '@/lib/constants'
// Rust 端通过 tauri-specta 生成的类型与命令绑定(bindings.ts
import { commands } from '@/lib/bindings'
import type {
ClipboardItem,
ClipboardSettings,
ClipboardStatus,
} from '@/lib/bindings'
// re-export:跨端类型统一由 bindings 提供,组件从本 store import 的路径保持不变
export type {
ClipboardItem,
ClipboardItemDetail,
ClipboardSettings,
ClipboardStatus,
HistoryPage,
} from '@/lib/bindings'
const logger = createLogger('clipboard')
// ===== 与 Rust 端对应的数据结构(camelCase =====
/** 剪贴板内容类型(bindings 的 kind 为 string,此联合为前端业务约束) */
export type ClipboardKind = 'text' | 'image' | 'files'
export interface ClipboardItem {
id: number
kind: ClipboardKind
preview: string
size: number
pinned: boolean
pinnedOrder: number | null
createdAt: number
}
/** 历史分页结果(与 Rust 端 HistoryPage 对应) */
export interface HistoryPage {
items: ClipboardItem[]
total: number
}
export interface ClipboardItemDetail extends ClipboardItem {
content: string | null
imageBase64: string | null
}
export interface ClipboardSettings {
enabled: boolean
maxItems: number
maxImageKb: number
recordText: boolean
recordImage: boolean
recordFiles: boolean
dedup: boolean
shortcut: string
}
export interface ClipboardStatus {
running: boolean
count: number
}
const DEFAULT_SETTINGS: ClipboardSettings = {
enabled: true,
maxItems: 500,
@@ -74,8 +52,8 @@ export const useClipboardStore = defineStore('clipboard', () => {
const init = async () => {
try {
const [s, st] = await Promise.all([
invoke<ClipboardSettings>('clipboard_get_settings'),
invoke<ClipboardStatus>('clipboard_status'),
commands.clipboardGetSettings(),
commands.clipboardStatus(),
])
settings.value = { ...DEFAULT_SETTINGS, ...s }
status.value = st
@@ -83,7 +61,7 @@ export const useClipboardStore = defineStore('clipboard', () => {
logger.error('初始化失败: ' + e)
}
if (!changedUnlisten) {
changedUnlisten = await listen('clipboard-changed', () => {
changedUnlisten = await listen(EVENTS.clipboardChanged, () => {
// 防抖:短时间内多次复制只刷新一次
if (debounceTimer) clearTimeout(debounceTimer)
debounceTimer = setTimeout(() => {
@@ -117,11 +95,7 @@ export const useClipboardStore = defineStore('clipboard', () => {
const page = Math.max(1, opts.page ?? 1)
const offset = (page - 1) * pageSize
try {
const res = await invoke<HistoryPage>('clipboard_get_history', {
limit: pageSize,
offset,
kind,
})
const res = await commands.clipboardGetHistory(pageSize, offset, kind)
history.value = res.items
historyTotal.value = res.total
} catch (e) {
@@ -135,7 +109,7 @@ export const useClipboardStore = defineStore('clipboard', () => {
const refreshPinned = async () => {
try {
pinned.value = await invoke<ClipboardItem[]>('clipboard_get_pinned')
pinned.value = await commands.clipboardGetPinned()
} catch (e) {
logger.error('获取固定条目失败: ' + e)
}
@@ -148,11 +122,7 @@ export const useClipboardStore = defineStore('clipboard', () => {
return fetchHistoryPage({ page, pageSize })
}
try {
const res = await invoke<HistoryPage>('clipboard_search', {
query,
limit: pageSize,
offset: (page - 1) * pageSize,
})
const res = await commands.clipboardSearch(query, pageSize, (page - 1) * pageSize)
history.value = res.items
historyTotal.value = res.total
} catch (e) {
@@ -166,7 +136,7 @@ export const useClipboardStore = defineStore('clipboard', () => {
const getItem = async (id: number) => {
try {
return await invoke<ClipboardItemDetail | null>('clipboard_get_item', { id })
return await commands.clipboardGetItem(id)
} catch (e) {
logger.error('获取详情失败: ' + e)
return null
@@ -175,7 +145,7 @@ export const useClipboardStore = defineStore('clipboard', () => {
const refreshStatus = async () => {
try {
status.value = await invoke<ClipboardStatus>('clipboard_status')
status.value = await commands.clipboardStatus()
} catch (e) {
logger.error('获取状态失败: ' + e)
}
@@ -184,7 +154,7 @@ export const useClipboardStore = defineStore('clipboard', () => {
// ===== 操作 =====
const setPinned = async (id: number, pinned: boolean) => {
try {
await invoke('clipboard_set_pinned', { id, pinned })
await commands.clipboardSetPinned(id, pinned)
// 固定/取消后刷新两个列表
await Promise.all([refreshHistory(), refreshPinned()])
} catch (e) {
@@ -194,7 +164,7 @@ export const useClipboardStore = defineStore('clipboard', () => {
const remove = async (id: number) => {
try {
await invoke('clipboard_delete', { id })
await commands.clipboardDelete(id)
history.value = history.value.filter((i) => i.id !== id)
pinned.value = pinned.value.filter((i) => i.id !== id)
status.value.count = Math.max(0, status.value.count - 1)
@@ -205,7 +175,7 @@ export const useClipboardStore = defineStore('clipboard', () => {
const clear = async () => {
try {
await invoke('clipboard_clear')
await commands.clipboardClear()
history.value = []
await refreshStatus()
} catch (e) {
@@ -214,13 +184,13 @@ export const useClipboardStore = defineStore('clipboard', () => {
}
const copyBack = async (id: number) => {
await invoke('clipboard_copy_back', { id })
await commands.clipboardCopyBack(id)
// copy_back 会触发 suppress,不会产生 clipboard-changed 事件
}
const saveSettings = async (s: ClipboardSettings) => {
try {
await invoke('clipboard_save_settings', { settings: s })
await commands.clipboardSaveSettings(s)
settings.value = { ...s }
await refreshStatus()
} catch (e) {
@@ -230,35 +200,35 @@ export const useClipboardStore = defineStore('clipboard', () => {
}
const start = async () => {
await invoke('clipboard_start')
await commands.clipboardStart()
await refreshStatus()
}
const stop = async () => {
await invoke('clipboard_stop')
await commands.clipboardStop()
await refreshStatus()
}
// ===== 快捷弹窗 =====
const showPopup = async () => {
await invoke('clipboard_show_popup')
await commands.clipboardShowPopup()
}
const hidePopup = async () => {
await invoke('clipboard_hide_popup')
await commands.clipboardHidePopup()
}
/// 隐藏弹窗并模拟 Ctrl+V 粘贴到原窗口
const pasteToTarget = async () => {
await invoke('clipboard_paste_to_target')
await commands.clipboardPasteToTarget()
}
const registerShortcut = async (shortcut: string) => {
await invoke('clipboard_register_shortcut', { shortcut })
await commands.clipboardRegisterShortcut(shortcut)
}
const unregisterShortcut = async () => {
await invoke('clipboard_unregister_shortcut')
await commands.clipboardUnregisterShortcut()
}
return {
+77 -78
View File
@@ -3,70 +3,37 @@ import { ref } from 'vue'
import { invoke } from '@tauri-apps/api/core'
import { listen, type UnlistenFn } from '@tauri-apps/api/event'
import { createLogger } from '@/lib/logger'
// Rust 端通过 tauri-specta 生成的类型与命令绑定(bindings.ts
import { commands } from '@/lib/bindings'
import type {
DownloadTask as BindDownloadTask,
DownloaderSettings as BindDownloaderSettings,
CheckUrlResult as BindCheckUrlResult,
TaskStatus,
} from '@/lib/bindings'
const logger = createLogger('downloader')
// ===== 与 Rust 端对应的数据结构(camelCase =====
// Rust 端字段均带 serde(default),序列化总是完整输出;Required 收窄 bindings 的 optional
// 组件访问 task.segments / settings.downloadDir 等字段无需判空
export type DownloadTask = Required<BindDownloadTask>
export type DownloaderSettings = Required<BindDownloaderSettings>
export type CheckUrlResult = Required<BindCheckUrlResult>
export type TaskStatus = 'queued' | 'active' | 'paused' | 'complete' | 'error'
export interface Segment {
index: number
start: number
end: number
completed: number
}
export interface DownloadTask {
id: string
url: string
filename: string
dir: string
status: TaskStatus
totalSize: number
completedSize: number
speed: number
supportsResume: boolean
segments: Segment[]
error: string | null
createdAt: number
headers: Record<string, string>
}
export interface DownloaderSettings {
downloadDir: string
maxConcurrent: number
maxConnections: number
continueDownload: boolean
globalSpeedLimit: number
extensionPort: number
extensionSecret: string
deleteFilesOnRemove: boolean
checkDuplicate: boolean
}
/** 重复类型 */
export type DuplicateKind = 'none' | 'url' | 'filename' | 'fileExists'
/** check_url 返回的结果 */
export interface CheckUrlResult {
ok: boolean
error: string | null
filename: string | null
totalSize: number | null
supportsResume: boolean
duplicate: DuplicateKind
existing: {
id: string
filename: string
status: TaskStatus
} | null
}
// re-export:跨端类型统一由 bindings 提供,组件从本 store import 的路径保持不变
export type {
TaskStatus,
Segment,
DuplicateKind,
ExistingTaskInfo,
} from '@/lib/bindings'
/** 下载器运行状态(downloader_status 返回 serde_json::Valuespecta 豁免,保留手动类型) */
export interface DownloaderStatus {
running: boolean
}
/** 扩展信息(downloader_get_extension_info 返回 serde_json::Valuespecta 豁免,保留手动类型) */
export interface ExtensionInfo {
url: string
port: number
@@ -74,7 +41,7 @@ export interface ExtensionInfo {
hasSecret: boolean
}
/** 下载进度事件载荷 */
/** 下载进度事件载荷(事件监听传递,specta 不导出,保留手动定义) */
interface ProgressPayload {
id: string
completedSize: number
@@ -105,7 +72,15 @@ export const useDownloaderStore = defineStore('downloader', () => {
// ===== 任务列表 =====
const refreshTasks = async () => {
try {
tasks.value = await invoke<DownloadTask[]>('downloader_get_tasks')
// Rust 端序列化保证字段完整,断言为 Required 收窄后的类型
const fresh = (await commands.downloaderGetTasks()) as DownloadTask[]
// merge 化:保留本地仍在更新的任务对象(进度事件可能刚修改过它),
// 避免整体替换导致进行中任务的实时进度/速度被快照回退
const merged = fresh.map(freshTask => {
const local = tasks.value.find(t => t.id === freshTask.id)
return local ?? freshTask
})
tasks.value = merged
} catch (e) {
logger.error('获取任务列表失败: ' + e)
}
@@ -137,13 +112,13 @@ export const useDownloaderStore = defineStore('downloader', () => {
headers?: Record<string, string>,
autoRename = false
): Promise<string> => {
const id = await invoke<string>('downloader_add_task', {
const id = await commands.downloaderAddTask(
url,
filename: filename || null,
dir: dir || null,
headers: headers || null,
filename || null,
dir || null,
headers || null,
autoRename
})
)
await refreshTasks()
return id
}
@@ -154,40 +129,40 @@ export const useDownloaderStore = defineStore('downloader', () => {
dir?: string,
headers?: Record<string, string>
): Promise<CheckUrlResult> => {
return await invoke<CheckUrlResult>('downloader_check_url', {
url,
dir: dir || null,
headers: headers || null
})
return (await commands.downloaderCheckUrl(url, dir || null, headers || null)) as CheckUrlResult
}
const pauseTask = async (id: string) => {
await invoke('downloader_pause_task', { id })
await commands.downloaderPauseTask(id)
await refreshTasks()
}
const resumeTask = async (id: string) => {
await invoke('downloader_resume_task', { id })
await commands.downloaderResumeTask(id)
await refreshTasks()
}
const removeTask = async (id: string, deleteFiles = false) => {
await invoke('downloader_remove_task', { id, deleteFiles })
await commands.downloaderRemoveTask(id, deleteFiles)
await refreshTasks()
}
// ===== 设置 =====
const loadSettings = async () => {
settings.value = await invoke<DownloaderSettings>('downloader_get_settings')
try {
settings.value = (await commands.downloaderGetSettings()) as DownloaderSettings
} catch (e) {
logger.error('加载设置失败: ' + e)
}
return settings.value
}
const saveSettings = async (s: DownloaderSettings) => {
await invoke('downloader_save_settings', { settings: s })
await commands.downloaderSaveSettings(s)
settings.value = s
}
// ===== 状态 =====
// ===== 状态(specta 豁免命令,保留原生 invoke) =====
const refreshStatus = async () => {
try {
status.value = await invoke<DownloaderStatus>('downloader_status')
@@ -197,18 +172,41 @@ export const useDownloaderStore = defineStore('downloader', () => {
return status.value
}
// ===== 扩展信息 =====
// ===== 扩展信息(specta 豁免命令,保留原生 invoke) =====
const loadExtensionInfo = async () => {
extensionInfo.value = await invoke<ExtensionInfo>('downloader_get_extension_info')
try {
extensionInfo.value = await invoke<ExtensionInfo>('downloader_get_extension_info')
} catch (e) {
logger.error('获取扩展信息失败: ' + e)
}
return extensionInfo.value
}
// ===== 事件监听 =====
/** 进度事件 rAF 合并:多任务并发时进度事件 20-100ms 一个,
* 先并入待处理表,每帧(requestAnimationFrame)批量应用一次,
* 避免每个事件触发一次 Vue 渲染 */
const pendingProgress = new Map<string, ProgressPayload>()
let progressRaf = 0
const flushProgress = () => {
progressRaf = 0
for (const payload of pendingProgress.values()) {
updateTaskProgress(payload)
}
pendingProgress.clear()
}
const scheduleProgressFlush = () => {
if (progressRaf) return
progressRaf = requestAnimationFrame(flushProgress)
}
const startEventListeners = async () => {
if (progressUnlisten && completeUnlisten && addedUnlisten) return
if (!progressUnlisten) {
progressUnlisten = await listen<ProgressPayload>('download-progress', (e) => {
updateTaskProgress(e.payload)
// 同名任务只保留最新进度,合并后由 rAF 统一应用
pendingProgress.set(e.payload.id, e.payload)
scheduleProgressFlush()
})
}
if (!completeUnlisten) {
@@ -241,12 +239,13 @@ export const useDownloaderStore = defineStore('downloader', () => {
// ===== 初始化 =====
const init = async () => {
await Promise.all([refreshStatus(), loadSettings(), loadExtensionInfo(), refreshTasks()])
// 任一子调用失败不阻断事件订阅注册:否则一个命令失败会导致进度/完成事件全部缺失
await Promise.allSettled([refreshStatus(), loadSettings(), loadExtensionInfo(), refreshTasks()])
await startEventListeners()
}
// ===== 工具函数 =====
const openDir = (path: string) => invoke<void>('downloader_open_dir', { path })
const openDir = (path: string) => commands.downloaderOpenDir(path)
return {
// state
+705 -30
View File
@@ -1,8 +1,12 @@
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import { computed, ref, watch } from 'vue'
import { invoke } from '@tauri-apps/api/core'
import { listen, type UnlistenFn } from '@tauri-apps/api/event'
import { emit, listen, type UnlistenFn } from '@tauri-apps/api/event'
import { WebviewWindow } from '@tauri-apps/api/webviewWindow'
import { currentMonitor, LogicalPosition, LogicalSize } from '@tauri-apps/api/window'
import { toast } from 'vue-sonner'
import { createLogger } from '@/lib/logger'
import { EVENTS, STORAGE_KEYS, WINDOWS } from '@/lib/constants'
const logger = createLogger('monitor')
@@ -86,7 +90,7 @@ export interface HardwareConfigUpdateResponse {
configPath: string | null
}
/** 网速数据(由 Tauri network_monitor 模块推送,独立于 ThingHK Kernel */
/** 网速数据(由 Tauri network 模块推送,独立于 ThingHK Kernel */
export interface NetworkSpeed {
/** 下载速率(bytes/s */
downloadBps: number
@@ -102,6 +106,202 @@ export type ConnectionState = 'idle' | 'loading' | 'connected' | 'disconnected'
/** 5 秒未收到 monitor-data 事件视为掉线(与后端心跳节奏一致) */
const STALE_TIMEOUT_MS = 5000
// ===== OSD 配置结构 =====
export interface OsdItem {
/** 唯一 key{groupId}/{hardwareName}/{sensorName}/{type} 小写化,或 special 项的固定 key */
key: string
groupId: string
sensorName: string
hardwareName: string
type: string
unit: string
/** 特殊项标记:非 Kernel 传感器,由前端直接计算(如网速) */
special?: 'net-up' | 'net-down'
}
/** 颜色主题:按硬件/传感器类型着色(类似小飞机风格) */
export interface ColorTheme {
/** 按 groupId 着色:cpu/gpu/memory/storage/... */
hardware: Record<string, string>
/** 按 sensor type 着色:temperature/load/power/... */
sensor: Record<string, string>
}
/** 警告色配置:阈值百分比 + 警告/严重颜色 */
export interface AlertConfig {
/** 警告色开关 */
enabled: boolean
/** 警告阈值百分比(达到即变警告色,如 80) */
warnThreshold: number
/** 严重阈值百分比(达到即变严重色,如 90) */
criticalThreshold: number
/** 警告色(淡红,hex */
warnColor: string
/** 严重色(大红,hex */
criticalColor: string
/** 各硬件类型的最大值(用于将温度等非百分比值转为百分比)
* CPU 温度墙默认 100GPU 默认 85 */
maxValues: Record<string, number>
}
/** OSD 配置结构 */
export interface OsdConfig {
overlayEnabled: boolean
overlayItems: OsdItem[]
/** 悬浮窗位置 X 百分比(0=最左,50=居中,100=最右) */
positionXPct: number
/** 悬浮窗位置 Y 百分比(0=最上,50=居中,100=最下) */
positionYPct: number
fontSize: number
showUnit: boolean
showLabel: boolean
/** 标题语言:'zh' 中文 / 'en' 英文(原始传感器名) */
labelLanguage: 'zh' | 'en'
/** 布局:'single' 单行分组式(组间用 | 分隔,固定宽度),
* 'group' 分组横排(标题在上+数据列在下),'multiline' 多行(每组一行,左对齐,类小飞机) */
layout: 'single' | 'group' | 'multiline'
updateIntervalMs: number
/** 鼠标穿透:true 时窗口不接收鼠标事件(需关闭穿透才能左键拖动) */
clickThrough: boolean
/** 默认文字颜色(hex),颜色主题关闭时使用 */
fontColor: string
/** 字体不透明度 0-100 */
fontOpacity: number
/** 悬浮窗背景色(CSS 颜色字符串,如 rgba(0,0,0,0.55) */
bgColor: string
/** 启用颜色主题(按硬件/传感器类型着色) */
colorThemeEnabled: boolean
/** 颜色主题配置 */
colorTheme: ColorTheme
/** 字体描边开关(默认关闭) */
fontStrokeEnabled: boolean
/** 字体描边厚度(px,默认 1) */
fontStrokeWidth: number
/** 字体描边颜色(hex,默认 #000000 */
fontStrokeColor: string
/** 警告色配置 */
alert: AlertConfig
/** 悬浮窗窗口保存位置(null=使用百分比计算默认位置) */
overlayX?: number | null
overlayY?: number | null
}
/** OSD 悬浮窗窗口 label(与 Tauri 窗口创建对应,见 constants::WINDOWS */
const OSD_OVERLAY_LABEL = WINDOWS.osdOverlay
const OSD_STORAGE_KEY = STORAGE_KEYS.monitorOsdConfig
const OSD_CONFIG_VERSION = 11
/** 默认颜色主题(小飞机风格:不同硬件不同颜色,不同传感器不同颜色) */
export const DEFAULT_COLOR_THEME: ColorTheme = {
hardware: {
cpu: '#4A9EFF',
gpuintel: '#9D4EFF',
gpuamd: '#9D4EFF',
gpunvidia: '#9D4EFF',
memory: '#FF9F4A',
storage: '#4AFF9F',
motherboard: '#FFD700',
superio: '#B0B0B0',
embeddedcontroller: '#B0B0B0',
battery: '#FF4A9F',
network: '#4AFFFF',
psu: '#FF4A4A',
},
sensor: {
temperature: '#FF6B6B',
load: '#4A9EFF',
power: '#FFD700',
voltage: '#9D4EFF',
fan: '#B0B0B0',
clock: '#4AFF9F',
data: '#FF9F4A',
smalldata: '#FF9F4A',
throughput: '#4AFFFF',
level: '#FF4A9F',
control: '#FFA500',
frequency: '#4AFF9F',
factor: '#FF4A4A',
timespan: '#B0B0B0',
energy: '#FFD700',
noise: '#B0B0B0',
conductivity: '#4AFFFF',
humidity: '#4A9EFF',
flow: '#4AFFFF',
},
}
/** 默认警告色配置:CPU 温度墙 100°C,GPU 85°C;百分比类直接用值 */
export const DEFAULT_ALERT_CONFIG: AlertConfig = {
enabled: true,
warnThreshold: 80,
criticalThreshold: 90,
warnColor: '#FF6B6B',
criticalColor: '#FF0000',
maxValues: {
cpu: 100,
gpu: 85,
gpuintel: 85,
gpuamd: 85,
gpunvidia: 85,
},
}
function defaultOsdConfig(): OsdConfig {
return {
overlayEnabled: false,
overlayItems: [],
// 默认顶部居中(top 0):水平 50%,垂直 0%
positionXPct: 50,
positionYPct: 0,
fontSize: 14,
showUnit: true,
showLabel: true,
labelLanguage: 'zh',
layout: 'single',
updateIntervalMs: 1000,
// 默认关闭点击穿透:关闭后左键可直接拖动悬浮窗
clickThrough: false,
fontColor: '#ffffff',
fontOpacity: 100,
bgColor: 'transparent',
colorThemeEnabled: true,
colorTheme: { ...DEFAULT_COLOR_THEME },
fontStrokeEnabled: false,
fontStrokeWidth: 1,
fontStrokeColor: '#000000',
alert: { ...DEFAULT_ALERT_CONFIG, maxValues: { ...DEFAULT_ALERT_CONFIG.maxValues } },
overlayX: null,
overlayY: null,
}
}
function loadOsdConfig(): OsdConfig {
try {
const saved = localStorage.getItem(OSD_STORAGE_KEY)
if (!saved) return defaultOsdConfig()
const parsed = JSON.parse(saved)
if (parsed.version !== OSD_CONFIG_VERSION) return defaultOsdConfig()
// 合并默认值,确保新增字段有默认值
const def = defaultOsdConfig()
return { ...def, ...parsed.config }
} catch {
return defaultOsdConfig()
}
}
function saveOsdConfig(cfg: OsdConfig) {
try {
localStorage.setItem(OSD_STORAGE_KEY, JSON.stringify({
version: OSD_CONFIG_VERSION,
config: cfg,
}))
} catch {
/* 忽略 localStorage 写入失败 */
}
}
export const useMonitorStore = defineStore('monitor', () => {
// ===== state =====
const status = ref<MonitorStatus | null>(null)
@@ -116,7 +316,7 @@ export const useMonitorStore = defineStore('monitor', () => {
const starting = ref(false)
const stopping = ref(false)
/** 网速数据(由 network_monitor 后台任务推送,独立于 Kernel) */
/** 网速数据(由 network 后台任务推送,独立于 Kernel) */
const networkSpeed = ref<NetworkSpeed | null>(null)
/** 是否已完成首次加载(避免初始 null/false 导致 UI 闪烁误导状态) */
@@ -130,6 +330,76 @@ export const useMonitorStore = defineStore('monitor', () => {
let unlistenFns: UnlistenFn[] = []
/**
* OSD 配置(store 级统一管理,App 启动时 initOsd 显式初始化)。
* 快照/网速事件到达时若 OSD 开启则 store 统一推送 osd-state-update
* 使 OSD 数据流不依赖组件生命周期(模块卸载后 OSD 窗口仍能持续刷新)。
*/
const osdConfig = ref<OsdConfig>(loadOsdConfig())
/** OSD 配置防抖保存:滑块/输入连续变化时合并为一次 localStorage 写入(避免每帧全量序列化) */
let osdSaveTimer: ReturnType<typeof setTimeout> | null = null
/** OSD 配置防抖推送定时器(initOsd 内注册的 deep watch 使用,dispose 时需清理) */
let osdPushTimer: ReturnType<typeof setTimeout> | null = null
function saveOsdConfigDebounced(cfg: OsdConfig) {
if (osdSaveTimer) clearTimeout(osdSaveTimer)
osdSaveTimer = setTimeout(() => {
osdSaveTimer = null
saveOsdConfig(cfg)
}, 200)
}
/** 推送 OSD 状态到所有 OSD 窗口(仅 OSD 开启时生效) */
async function pushOsdState() {
if (!osdConfig.value.overlayEnabled) return
try {
await emit(EVENTS.osdStateUpdate, {
config: osdConfig.value,
snapshot: snapshot.value,
networkSpeed: networkSpeed.value,
})
} catch (e) {
logger.error('[OSD] 推送状态失败: ' + e)
}
}
/**
* OSD 单通道合并推送:monitor-data 与 monitor-network 事件可能同帧先后到达,
* 直接各推一次会触发两次 IPC + OSD 窗口两次重排重绘。
* 合并为每帧最多一次 emit,载荷始终为帧末最新快照+网速。
*/
let osdPushRaf = 0
const scheduleOsdPush = () => {
if (osdPushRaf) return
osdPushRaf = requestAnimationFrame(() => {
osdPushRaf = 0
void pushOsdState()
})
}
// ===== 心跳计时器 =====
// connState 的"断线判定"依赖时间流逝,但 computed 只随响应式依赖重算,
// 直接用 Date.now() 会导致 disconnected 状态永远不触发(SSE 断开后依赖不再变化)。
// 用 1s 心跳递增 nowTick,使断线判定可被驱动。
const nowTick = ref(0)
let heartbeatTimer: ReturnType<typeof setInterval> | null = null
function startHeartbeat() {
if (heartbeatTimer) return
heartbeatTimer = setInterval(() => {
// 窗口/标签页不可见时暂停心跳,恢复可见后下个 tick 自动继续
if (document.hidden) return
nowTick.value = Date.now()
}, 1000)
}
function stopHeartbeat() {
if (heartbeatTimer) {
clearInterval(heartbeatTimer)
heartbeatTimer = null
}
}
// ===== getters =====
/** 当前连接状态(基于 status + 最近事件时间推断) */
@@ -139,7 +409,7 @@ export const useMonitorStore = defineStore('monitor', () => {
if (!status.value.running) return 'idle'
if (!status.value.ready) return 'loading'
// Kernel 已 ready 即视为已连接(避免 SSE 首事件延迟导致一直显示"启动中")
if (Date.now() - lastEventTime.value > STALE_TIMEOUT_MS && eventCount.value > 0) return 'disconnected'
if (nowTick.value - lastEventTime.value > STALE_TIMEOUT_MS && eventCount.value > 0) return 'disconnected'
return 'connected'
})
@@ -315,7 +585,7 @@ export const useMonitorStore = defineStore('monitor', () => {
/** 订阅 Tauri 事件:monitor-data / monitor-ready / monitor-loading / monitor-disconnected / monitor-error */
async function subscribe() {
if (unlistenFns.length) return
unlistenFns.push(await listen<SensorSnapshot>('monitor-data', (e) => {
unlistenFns.push(await listen<SensorSnapshot>(EVENTS.monitorData, (e) => {
// schemaVersion 守卫:仅接受 v1,未来版本需在此处显式升级
if (e.payload?.schemaVersion !== 1) {
logger.warn('收到未知 schemaVersion: ' + e.payload?.schemaVersion)
@@ -324,27 +594,31 @@ export const useMonitorStore = defineStore('monitor', () => {
snapshot.value = e.payload
eventCount.value++
lastEventTime.value = Date.now()
// OSD 开启时同步推送(合并到每帧一次,避免与网速事件重复推送)
if (osdConfig.value.overlayEnabled) scheduleOsdPush()
}))
unlistenFns.push(await listen('monitor-ready', () => {
unlistenFns.push(await listen(EVENTS.monitorReady, () => {
refreshStatus()
// Kernel 就绪后主动拉取一次快照,避免等待 SSE 首事件导致 UI 空白
fetchSnapshot()
}))
unlistenFns.push(await listen('monitor-loading', () => {
unlistenFns.push(await listen(EVENTS.monitorLoading, () => {
// 后端正在等待 Kernel ready,刷新状态以反映 running=true
refreshStatus()
}))
unlistenFns.push(await listen('monitor-disconnected', () => {
unlistenFns.push(await listen(EVENTS.monitorDisconnected, () => {
logger.warn('SSE 断开,等待自动重连')
refreshStatus()
}))
unlistenFns.push(await listen<{ message?: string }>('monitor-error', (e) => {
unlistenFns.push(await listen<{ message?: string }>(EVENTS.monitorError, (e) => {
errorMsg.value = e.payload?.message ?? 'Kernel 错误'
logger.error('Kernel 错误: ' + JSON.stringify(e.payload))
}))
// 网速监控事件(独立于 Kernel,应用启动即推送)
unlistenFns.push(await listen<NetworkSpeed>('monitor-network', (e) => {
unlistenFns.push(await listen<NetworkSpeed>(EVENTS.monitorNetwork, (e) => {
networkSpeed.value = e.payload
// OSD 开启时同步推送(合并到每帧一次,避免与快照事件重复推送)
if (osdConfig.value.overlayEnabled) scheduleOsdPush()
}))
}
@@ -353,35 +627,425 @@ export const useMonitorStore = defineStore('monitor', () => {
unlistenFns = []
}
/** 模块挂载时调用:刷新状态 + 订阅事件 + 拉取一次快照
/** init 幂等守卫:保证完整初始化逻辑只执行一次,重复调用返回同一 promise
* App 启动时与 MonitorModule 挂载时都会调用 init(),守卫避免重复订阅/刷新。 */
let initPromise: Promise<void> | null = null
/** 初始化监控 store:刷新状态 + 订阅事件 + 拉取一次快照。
* 幂等:多次调用只执行一次完整初始化逻辑,重复调用返回同一 promise。
* 首次加载期间 initialized=falseUI 显示 loading 占位(隐藏启动按钮等),
* 避免与后端 setup 异步自动启动竞态导致按钮误显示。 */
async function init() {
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
if (initPromise) return initPromise
initPromise = (async () => {
try {
// 启动心跳,驱动 connState 的断线判定(SSE 断开后依赖不再变化,须有心跳触发重算)
startHeartbeat()
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
}
} finally {
initialized.value = true
}
})()
return initPromise
}
/** 模块卸载时调用:仅取消事件订阅,不停止 KernelKernel 由 ProcessManager 全局管理) */
function dispose() {
unsubscribe()
stopHeartbeat()
// 取消尚未执行的合并推送
if (osdPushRaf) {
cancelAnimationFrame(osdPushRaf)
osdPushRaf = 0
}
// 重置 init 守卫,允许重新初始化
initPromise = null
}
// ===== OSD 窗口管理(由 initOsd/disposeOsd 管理生命周期) =====
/** 抑制百分比 watch 的程序定位标志:拖动 onMoved 更新百分比时置 true,避免触发 resetOverlayPosition 循环 */
let suppressPercentWatch = false
/** 构建用于 OSD 窗口的 URL(基于当前页面 URL 替换 hash) */
function osdUrl(hash: string): string {
const base = window.location.href.split('#')[0]
return `${base}#${hash}`
}
/** 根据百分比位置计算窗口坐标 */
function computePositionFromPct(screenW: number, screenH: number, w: number, h: number, xPct: number, yPct: number): { x: number; y: number } {
// 百分比基于可用空间(屏幕尺寸 - 窗口尺寸),确保窗口不会被定位到屏幕外
const availW = Math.max(0, screenW - w)
const availH = Math.max(0, screenH - h)
return {
x: Math.round((availW * xPct) / 100),
y: Math.round((availH * yPct) / 100),
}
}
/** 根据显示项估算悬浮窗窗口尺寸(逻辑像素)
* single: 单行分组式,组间用 | 分隔,固定宽度数据列
* group: 分组横排,标题在上 + 数据列在下
* multiline: 多行,每组一行,标题 + 固定宽度数据列 */
function computeOsdWindowSize(
_itemCount: number,
layout: 'single' | 'group' | 'multiline',
fontSize: number,
_hasNetItem = false,
items?: OsdItem[],
): { w: number; h: number } {
const charW = fontSize * 0.62
const barHPad = 8 // osd-bar 左右 padding 4*2
// 按硬件类型分组(与渲染逻辑一致)
const groupMap = new Map<string, OsdItem[]>()
if (items?.length) {
for (const item of items) {
let gkey: string
if (item.special === 'net-up' || item.special === 'net-down') gkey = 'network'
else if (item.groupId.startsWith('gpu')) gkey = 'gpu'
else gkey = item.groupId
if (!groupMap.has(gkey)) groupMap.set(gkey, [])
groupMap.get(gkey)!.push(item)
}
}
const groupCount = Math.max(1, groupMap.size)
// 每组数据列宽度:标签(6ch) + 各项(数值+单位+箭头/gap)
const groupWidths: number[] = []
for (const [, groupItems] of groupMap) {
const labelW = 6
const dataW = groupItems.reduce((sum, item) => {
const isNet = item.special === 'net-up' || item.special === 'net-down'
return sum + (isNet ? 11 : 8) + 1
}, 0)
groupWidths.push(labelW + dataW)
}
if (layout === 'multiline') {
// 多行:取最宽行
const maxLineW = groupWidths.length ? Math.max(...groupWidths) : 10
const w = Math.ceil(maxLineW * charW + barHPad)
const lineH = Math.ceil(fontSize + 2)
const h = Math.ceil(groupCount * lineH + 6)
return { w: Math.max(120, w), h: Math.max(28, h) }
}
if (layout === 'group') {
// 分组横排:各组横排 + 标题行
const totalW = groupWidths.reduce((s, w) => s + w + 4, 0) + (groupCount - 1) * 4
const w = Math.ceil(totalW * charW + barHPad)
const titleH = Math.ceil(fontSize * 0.85) + 2
const dataH = Math.ceil(fontSize) + 2
const h = Math.ceil(titleH + dataH + 10)
return { w: Math.max(120, w), h: Math.max(40, h) }
}
// single:单行分组式,各组横排 + 组间 | 分隔符(1ch)
const sepW = (groupCount - 1) * 1
const totalW = groupWidths.reduce((s, w) => s + w, 0) + sepW
const w = Math.ceil(totalW * charW + barHPad)
const h = Math.ceil(fontSize + 8)
return { w: Math.max(120, w), h: Math.max(28, h) }
}
/** 创建/显示悬浮窗窗口(默认置顶 + NoActivate + 点击穿透) */
async function ensureOverlayWindow() {
const existing = await WebviewWindow.getByLabel(OSD_OVERLAY_LABEL)
if (existing) {
// 窗口已存在,仅显示并推送最新状态
await existing.show()
await updateOsdWindowSize()
await pushOsdState()
return
}
// 获取屏幕尺寸用于定位
const monitor = await currentMonitor()
const screenW = monitor?.size.width ?? 1920
const screenH = monitor?.size.height ?? 1080
const scale = monitor?.scaleFactor ?? 1
const logicalW = screenW / scale
const logicalH = screenH / scale
const hasNetItem = osdConfig.value.overlayItems.some(i => i.special === 'net-up' || i.special === 'net-down')
const { w, h } = computeOsdWindowSize(
osdConfig.value.overlayItems.length,
osdConfig.value.layout,
osdConfig.value.fontSize,
hasNetItem,
osdConfig.value.overlayItems,
)
// 优先使用保存的像素位置;否则根据百分比计算默认位置
let x: number, y: number
if (osdConfig.value.overlayX != null && osdConfig.value.overlayY != null) {
x = osdConfig.value.overlayX
y = osdConfig.value.overlayY
} else {
const pos = computePositionFromPct(logicalW, logicalH, w, h, osdConfig.value.positionXPct, osdConfig.value.positionYPct)
x = pos.x
y = pos.y
}
const win = new WebviewWindow(OSD_OVERLAY_LABEL, {
url: osdUrl('osd-overlay'),
title: 'OSD 悬浮窗',
width: w,
height: h,
x,
y,
decorations: false,
transparent: true,
// 关闭窗口阴影:Win11 默认会画一圈阴影光晕,透明窗口上表现为可见的"外部框"
shadow: false,
alwaysOnTop: true,
skipTaskbar: true,
// 禁用调整大小:移除 Windows 隐形 resize 边框(该边框会拦截鼠标事件导致穿透/拖动失效)
resizable: false,
visible: true,
// 不获取焦点(NoActivate 由 Rust 后端 osd_apply_overlay_style 进一步保证)
focus: false,
})
win.once('tauri://created', async () => {
// 等待 webview 加载后推送初始状态
setTimeout(() => pushOsdState(), 300)
// 监听窗口移动,保存像素位置并同步更新百分比(拖动结束后触发)
try {
const winInstance = await win
const unlisten = await winInstance.onMoved(async ({ payload }) => {
osdConfig.value.overlayX = payload.x
osdConfig.value.overlayY = payload.y
// 反算百分比:xPct = x / availW * 100availW = screenW - windowW
// 置 suppressPercentWatch=true 避免百分比变化触发 resetOverlayPosition 循环
suppressPercentWatch = true
try {
const monitor = await currentMonitor()
const screenW = (monitor?.size.width ?? 1920) / (monitor?.scaleFactor ?? 1)
const screenH = (monitor?.size.height ?? 1080) / (monitor?.scaleFactor ?? 1)
const size = await winInstance.outerSize()
const scale = monitor?.scaleFactor ?? 1
const winW = size.width / scale
const winH = size.height / scale
const availW = Math.max(1, screenW - winW)
const availH = Math.max(1, screenH - winH)
osdConfig.value.positionXPct = Math.round((payload.x / availW) * 100)
osdConfig.value.positionYPct = Math.round((payload.y / availH) * 100)
} catch { /* 忽略百分比反算失败 */ }
saveOsdConfig(osdConfig.value)
// 下一个微任务后解除抑制(让本次 watch 回调跳过即可)
queueMicrotask(() => { suppressPercentWatch = false })
})
osdEventUnlisteners.push(unlisten)
} catch { /* 忽略 */ }
})
win.once('tauri://error', (e: unknown) => {
logger.error('[OSD] 悬浮窗创建失败: ' + e)
toast.error('悬浮窗创建失败')
})
}
/** 隐藏悬浮窗 */
async function hideOverlayWindow() {
const existing = await WebviewWindow.getByLabel(OSD_OVERLAY_LABEL)
if (existing) {
await existing.hide()
}
}
/** 根据当前配置更新悬浮窗窗口尺寸(显示项数量/布局/字号变化时调用) */
async function updateOsdWindowSize() {
try {
const existing = await WebviewWindow.getByLabel(OSD_OVERLAY_LABEL)
if (!existing) return
const hasNetItem = osdConfig.value.overlayItems.some(i => i.special === 'net-up' || i.special === 'net-down')
const { w, h } = computeOsdWindowSize(
osdConfig.value.overlayItems.length,
osdConfig.value.layout,
osdConfig.value.fontSize,
hasNetItem,
osdConfig.value.overlayItems,
)
await existing.setSize(new LogicalSize(w, h))
} catch { /* 忽略 */ }
}
/** 重置悬浮窗位置到默认(百分比位置),清除保存的像素位置
* 仅重新定位,不改变尺寸——尺寸由悬浮窗内容实际测量上报维持 */
async function resetOverlayPosition() {
osdConfig.value.overlayX = null
osdConfig.value.overlayY = null
saveOsdConfig(osdConfig.value)
// 重新定位窗口
try {
const existing = await WebviewWindow.getByLabel(OSD_OVERLAY_LABEL)
if (existing) {
const monitor = await currentMonitor()
const screenW = (monitor?.size.width ?? 1920) / (monitor?.scaleFactor ?? 1)
const screenH = (monitor?.size.height ?? 1080) / (monitor?.scaleFactor ?? 1)
// 读取窗口当前实际尺寸用于定位计算,不调用 setSize(避免覆盖实际测量值)
const size = await existing.outerSize()
const scale = monitor?.scaleFactor ?? 1
const w = size.width / scale
const h = size.height / scale
const pos = computePositionFromPct(screenW, screenH, w, h, osdConfig.value.positionXPct, osdConfig.value.positionYPct)
await existing.setPosition(new LogicalPosition(pos.x, pos.y))
}
} catch { /* 忽略 */ }
}
// ===== OSD 窗口事件监听 =====
let osdEventUnlisteners: UnlistenFn[] = []
/** initOsd 注册的 watch stop 句柄(dispose 时统一释放,避免模块重挂载后重复注册) */
let osdWatchStops: (() => void)[] = []
async function setupOsdEventListeners() {
// 守卫:避免重复注册(App 启动 initOsd 与模块挂载均会调用)
if (osdEventUnlisteners.length) return
// 监听悬浮窗上报的实际内容尺寸,按内容调整窗口大小(替代不准确的估算)
// 仅当尺寸变化超过 1px 时才 setSize,避免无意义的频繁调用
let lastW = 0
let lastH = 0
const unlisten = await listen<{ width: number; height: number }>('osd-content-size', async (e) => {
const { width, height } = e.payload
if (Math.abs(width - lastW) < 1 && Math.abs(height - lastH) < 1) return
lastW = width
lastH = height
try {
const w = await WebviewWindow.getByLabel(OSD_OVERLAY_LABEL)
if (w) await w.setSize(new LogicalSize(width, height))
} catch { /* 忽略 */ }
})
osdEventUnlisteners.push(unlisten)
}
/** initOsd 幂等守卫:监听/配置 watcher 只注册一次(App 启动 + 模块挂载均会调用) */
let osdInitialized = false
/**
* 显式初始化 OSD。
* 加载配置、注册悬浮窗事件监听与配置 watcher、创建悬浮窗(若已开启)。
* 幂等:重复调用仅刷新窗口状态,不重复注册监听。
*/
function initOsd() {
if (osdInitialized) {
// 已初始化过:窗口状态刷新(显示 + 推送最新配置)
if (osdConfig.value.overlayEnabled) {
ensureOverlayWindow().catch(e => logger.error('[OSD] 刷新悬浮窗失败: ' + e))
}
return
}
osdInitialized = true
// 注册 OSD 窗口事件监听
setupOsdEventListeners().catch(e => logger.error('[OSD] 事件监听注册失败: ' + e))
// 监听托盘菜单"切换 OSD"事件(应用级常驻,不随模块挂载/卸载变化)
listen(EVENTS.trayToggleOsd, () => {
osdConfig.value.overlayEnabled = !osdConfig.value.overlayEnabled
saveOsdConfig(osdConfig.value)
if (osdConfig.value.overlayEnabled) {
if (osdConfig.value.overlayItems.length === 0) {
toast.warning('OSD 显示项为空,已开启但未创建窗口')
} else {
ensureOverlayWindow().catch(e => logger.error('[OSD] 托盘开启悬浮窗失败: ' + e))
}
} else {
hideOverlayWindow().catch(e => logger.error('[OSD] 托盘关闭悬浮窗失败: ' + e))
}
}).then(unlisten => { osdEventUnlisteners.push(unlisten) })
.catch(e => logger.error('[OSD] 注册 tray:toggle-osd 监听失败: ' + e))
// ===== OSD 配置行为 watcher(store 级常驻,与组件生命周期解耦) =====
// stop 句柄存入 osdWatchStopsdispose 时统一释放,避免模块重挂载后重复注册
// OSD 开关变化时创建/隐藏悬浮窗
osdWatchStops.push(watch(() => osdConfig.value.overlayEnabled, (enabled) => {
if (enabled) {
// 开启时若显示项为空则不创建窗口
if (osdConfig.value.overlayItems.length === 0) return
ensureOverlayWindow().catch(e => logger.error('[OSD] 创建悬浮窗失败: ' + e))
} else {
hideOverlayWindow().catch(e => logger.error('[OSD] 隐藏悬浮窗失败: ' + e))
}
}))
// 显示项变化时:无项则隐藏窗口,有项且开关开则确保窗口存在
osdWatchStops.push(watch(() => osdConfig.value.overlayItems.length, (len) => {
if (!osdConfig.value.overlayEnabled) return
if (len === 0) {
hideOverlayWindow().catch(e => logger.error('[OSD] 显示项为空,隐藏悬浮窗失败: ' + e))
} else {
ensureOverlayWindow().catch(e => logger.error('[OSD] 显示项恢复,创建悬浮窗失败: ' + e))
}
}))
// 位置百分比变化时重新定位窗口(清除已保存像素位置)
// 拖动 OSD 触发的 onMoved 会反算更新百分比,此时 suppressPercentWatch=true 跳过,避免循环
osdWatchStops.push(watch(() => [osdConfig.value.positionXPct, osdConfig.value.positionYPct], () => {
if (suppressPercentWatch) return
// 清除保存的像素位置,让窗口使用百分比重新定位
osdConfig.value.overlayX = null
osdConfig.value.overlayY = null
saveOsdConfig(osdConfig.value)
// 如果窗口已存在,重新定位
resetOverlayPosition().catch(() => {})
}))
// OSD 配置变化 → 推送到 OSD 窗口(位置/字体/显示项等)
// 防抖 200ms 合并:滑块拖动期间只推送最终状态,避免每帧 emit 整份快照
osdWatchStops.push(watch(osdConfig, () => {
if (osdPushTimer) clearTimeout(osdPushTimer)
osdPushTimer = setTimeout(() => {
osdPushTimer = null
if (osdConfig.value.overlayEnabled) {
pushOsdState()
}
}, 200)
}, { deep: true }))
// 显示项数量/布局/字号变化 → 更新悬浮窗窗口尺寸(自适应内容)
osdWatchStops.push(watch([
() => osdConfig.value.overlayItems.length,
() => osdConfig.value.layout,
() => osdConfig.value.fontSize,
], () => {
if (osdConfig.value.overlayEnabled) {
updateOsdWindowSize().catch(() => {})
}
}))
// 初始化悬浮窗(如果开关已开启)
if (osdConfig.value.overlayEnabled) {
ensureOverlayWindow().catch(e => logger.error('[OSD] 初始化悬浮窗失败: ' + e))
}
}
/** 应用退出时调用:释放 OSD 事件监听与配置 watcher(不关闭窗口,窗口随应用退出销毁) */
function disposeOsd() {
osdEventUnlisteners.forEach(fn => fn())
osdEventUnlisteners = []
osdWatchStops.forEach(stop => stop())
osdWatchStops = []
if (osdSaveTimer) { clearTimeout(osdSaveTimer); osdSaveTimer = null }
if (osdPushTimer) { clearTimeout(osdPushTimer); osdPushTimer = null }
osdInitialized = false
}
return {
@@ -414,6 +1078,17 @@ export const useMonitorStore = defineStore('monitor', () => {
saveHardwareConfig,
stop,
fetchSnapshot,
// OSD 配置与窗口管理(store 级,initOsd 显式初始化)
osdConfig,
saveOsdConfig,
saveOsdConfigDebounced,
pushOsdState,
ensureOverlayWindow,
hideOverlayWindow,
updateOsdWindowSize,
resetOverlayPosition,
initOsd,
disposeOsd,
init,
dispose,
}
+5 -5
View File
@@ -59,20 +59,20 @@ export const useProcessStore = defineStore('process', () => {
maxRestarts: pc.maxRestarts
}
const info = await invoke<ProcessInfo>('start_process', { params })
const info = await invoke<ProcessInfo>('process_start', { params })
processes.value.set(moduleId, info)
return info
}
/** 通过模块 ID 停止进程 */
const stopByModule = async (moduleId: string): Promise<void> => {
await invoke('stop_process', { id: moduleId })
await invoke('process_stop', { id: moduleId })
processes.value.delete(moduleId)
}
/** 获取单个进程状态(从 Rust 端查询最新值) */
const refreshStatus = async (moduleId: string): Promise<ProcessInfo | null> => {
const info = await invoke<ProcessInfo | null>('get_process_status', { id: moduleId })
const info = await invoke<ProcessInfo | null>('process_status', { id: moduleId })
if (info) {
processes.value.set(moduleId, info)
} else {
@@ -83,7 +83,7 @@ export const useProcessStore = defineStore('process', () => {
/** 刷新所有进程状态 */
const refreshAll = async (): Promise<void> => {
const all = await invoke<ProcessInfo[]>('get_all_process_status')
const all = await invoke<ProcessInfo[]>('process_all_status')
processes.value.clear()
all.forEach((info) => {
processes.value.set(info.id, info)
@@ -97,7 +97,7 @@ export const useProcessStore = defineStore('process', () => {
/** 停止所有进程 */
const stopAll = async (): Promise<void> => {
await invoke('stop_all_processes')
await invoke('process_stop_all')
processes.value.clear()
}
+57 -79
View File
@@ -3,32 +3,27 @@ import { ref } from 'vue'
import { invoke } from '@tauri-apps/api/core'
import { listen, type UnlistenFn } from '@tauri-apps/api/event'
import { createLogger } from '@/lib/logger'
import { EVENTS } from '@/lib/constants'
// Rust 端通过 tauri-specta 生成的类型与命令绑定(bindings.ts
import {
commands,
type ProxySettings,
type ProfileMeta,
type KernelInfo,
type KernelUpdateInfo,
type ProxyStatus
} from '@/lib/bindings'
// Rust 端结构体字段均带 serde(default),返回必完整;用 Required 收窄 bindings 的 optional
// 避免组件侧对每个字段做 undefined 判空(保留 null 联合,如 currentProfile/size
export type FullProfileMeta = Required<ProfileMeta>
export type FullProxySettings = Required<ProxySettings> & { profiles: FullProfileMeta[] }
const logger = createLogger('proxy')
// ===== Rust 端对应的数据结构(camelCase =====
// ===== Rust 端未收录命令(返回 serde_json::Valuespecta 不导出)的手动类型 =====
export interface ProxySettings {
mixedPort: number
externalController: string
secret: string
mode: string
logLevel: string
allowLan: boolean
systemProxy: boolean
autoStart: boolean
autoSystemProxy: boolean
currentProfile: string | null
profiles: ProfileMeta[]
autoSwitchEnabled: boolean
autoSwitchInterval: number
autoSwitchGroup: string
autoSwitchRegion: string
/** 内核下载镜像源前缀列表(空串=直连 GitHub) */
kernelMirrors: string[]
}
/** 内核安装进度事件载荷,对应 Rust 端 InstallProgress */
/** 内核安装进度事件载荷(经事件监听传递,specta 不导出,保留手动定义) */
export interface InstallProgress {
/** downloading | extracting | replacing | done | error */
stage: string
@@ -38,34 +33,6 @@ export interface InstallProgress {
message: string
}
export interface ProfileMeta {
id: string
name: string
url: string
addedAt: string
updatedAt: string
size: number
}
export interface KernelInfo {
path: string
exists: boolean
version: string | null
}
export interface KernelUpdateInfo {
currentVersion: string | null
latestVersion: string
downloadUrl: string
hasUpdate: boolean
}
export interface ProxyStatus {
running: boolean
pid: number | null
restartCount: number
}
export interface ProxyHistory {
time: string
delay: number
@@ -95,7 +62,7 @@ export const useProxyStore = defineStore('proxy', () => {
const status = ref<ProxyStatus>({ running: false, pid: null, restartCount: 0 })
const version = ref<string>('')
const proxies = ref<Record<string, ProxyNode>>({})
const settings = ref<ProxySettings | null>(null)
const settings = ref<FullProxySettings | null>(null)
const systemProxy = ref(false)
/** 是否已完成首次加载(避免初始 null/false 导致闪烁误导状态) */
@@ -109,7 +76,7 @@ export const useProxyStore = defineStore('proxy', () => {
/** 内核信息(同时尝试从 resource 提取到 cores/ */
const refreshKernel = async () => {
try {
kernel.value = await invoke<KernelInfo>('proxy_kernel_info')
kernel.value = await commands.proxyKernelInfo()
} catch (e) {
logger.error('获取内核信息失败: ' + e)
}
@@ -119,7 +86,7 @@ export const useProxyStore = defineStore('proxy', () => {
/** 刷新进程状态 */
const refreshStatus = async () => {
try {
status.value = await invoke<ProxyStatus>('proxy_status')
status.value = await commands.proxyStatus()
} catch (e) {
logger.error('获取进程状态失败: ' + e)
}
@@ -127,17 +94,17 @@ export const useProxyStore = defineStore('proxy', () => {
}
const start = async () => {
await invoke('proxy_start')
await commands.proxyStart()
await refreshStatus()
}
const stop = async () => {
await invoke('proxy_stop')
await commands.proxyStop()
await refreshStatus()
}
const restart = async () => {
await invoke('proxy_restart')
await commands.proxyRestart()
await refreshStatus()
}
@@ -174,7 +141,7 @@ export const useProxyStore = defineStore('proxy', () => {
/** 选择节点 */
const selectProxy = async (group: string, name: string) => {
await invoke('proxy_select_proxy', { group, name })
await commands.proxySelectProxy(group, name)
// 更新本地状态
if (proxies.value[group]) {
proxies.value[group].now = name
@@ -183,13 +150,18 @@ export const useProxyStore = defineStore('proxy', () => {
/** 测速,返回延迟 ms(失败抛错) */
const testDelay = async (name: string): Promise<number> => {
return await invoke<number>('proxy_test_delay', { name })
return await commands.proxyTestDelay(name, null, null)
}
/** 批量测速:对一组节点测速,更新 history */
/** 批量测速:对一组节点测速,更新 history
* 限并发(默认 8)执行,避免数百个 invoke 同时触发造成 IPC 洪峰 + mihomo 限流 → 大量假超时 */
const testDelayBatch = async (names: string[]) => {
await Promise.all(
names.map(async (name) => {
const CONCURRENCY = 8
const targets = [...names]
let idx = 0
const worker = async () => {
while (idx < targets.length) {
const name = targets[idx++]
try {
const delay = await testDelay(name)
const node = proxies.value[name]
@@ -202,48 +174,54 @@ export const useProxyStore = defineStore('proxy', () => {
node.history = [{ time: new Date().toISOString(), delay: 0 }, ...(node.history ?? [])].slice(0, 5)
}
}
})
)
}
}
const workerCount = Math.min(CONCURRENCY, targets.length)
await Promise.all(Array.from({ length: workerCount }, () => worker()))
}
// ---------- 设置 ----------
const loadSettings = async () => {
settings.value = await invoke<ProxySettings>('proxy_get_settings')
systemProxy.value = await invoke<boolean>('proxy_get_system_proxy')
try {
settings.value = (await commands.proxyGetSettings()) as FullProxySettings
systemProxy.value = await commands.proxyGetSystemProxy()
} catch (e) {
console.error('[proxy] 加载设置失败:', e)
}
return settings.value
}
const saveSettings = async (s: ProxySettings) => {
await invoke('proxy_save_settings', { settings: s })
const saveSettings = async (s: FullProxySettings) => {
await commands.proxySaveSettings(s)
settings.value = s
}
// ---------- 订阅 ----------
const importProfile = async (url: string, name: string) => {
const meta = await invoke<ProfileMeta>('proxy_import_profile', { url, name })
const meta = await commands.proxyImportProfile(url, name)
await loadSettings()
return meta
}
const updateProfile = async (id: string) => {
const meta = await invoke<ProfileMeta>('proxy_update_profile', { id })
const meta = await commands.proxyUpdateProfile(id)
await loadSettings()
return meta
}
const deleteProfile = async (id: string) => {
await invoke('proxy_delete_profile', { id })
await commands.proxyDeleteProfile(id)
await loadSettings()
}
const activateProfile = async (id: string) => {
await invoke('proxy_activate_profile', { id })
await commands.proxyActivateProfile(id)
await loadSettings()
}
// ---------- 系统代理 ----------
const setSystemProxy = async () => {
await invoke('proxy_set_system_proxy')
await commands.proxySetSystemProxy()
systemProxy.value = true
if (settings.value) {
settings.value.systemProxy = true
@@ -251,7 +229,7 @@ export const useProxyStore = defineStore('proxy', () => {
}
const clearSystemProxy = async () => {
await invoke('proxy_clear_system_proxy')
await commands.proxyClearSystemProxy()
systemProxy.value = false
if (settings.value) {
settings.value.systemProxy = false
@@ -269,7 +247,7 @@ export const useProxyStore = defineStore('proxy', () => {
// ---------- 内核更新 / 安装 ----------
const checkKernelUpdate = async (): Promise<KernelUpdateInfo> => {
return await invoke<KernelUpdateInfo>('proxy_check_kernel_update')
return await commands.proxyCheckKernelUpdate()
}
/**
@@ -288,12 +266,12 @@ export const useProxyStore = defineStore('proxy', () => {
message: '准备开始下载...'
}
if (!progressUnlisten) {
progressUnlisten = await listen<InstallProgress>('kernel-install-progress', (e) => {
progressUnlisten = await listen<InstallProgress>(EVENTS.kernelInstallProgress, (e) => {
installProgress.value = e.payload
})
}
try {
await invoke('proxy_update_kernel', { mirrorPrefix })
await commands.proxyUpdateKernel(mirrorPrefix)
await refreshKernel()
} catch (e) {
logger.error('内核更新失败: ' + e)
@@ -308,7 +286,7 @@ export const useProxyStore = defineStore('proxy', () => {
}
/**
* 首次安装内核:调用后端 install_kernel,监听 kernel-install-progress 事件更新进度
* 首次安装内核:调用后端 install_kernel,监听内核安装进度事件更新进度
* @param mirrorPrefix 镜像源前缀(空串=GitHub 直连)
* 完成或出错后自动取消监听并清空进度(由调用方控制何时隐藏 UI)
*/
@@ -324,12 +302,12 @@ export const useProxyStore = defineStore('proxy', () => {
}
// 注册进度事件监听
if (!progressUnlisten) {
progressUnlisten = await listen<InstallProgress>('kernel-install-progress', (e) => {
progressUnlisten = await listen<InstallProgress>(EVENTS.kernelInstallProgress, (e) => {
installProgress.value = e.payload
})
}
try {
await invoke('proxy_install_kernel', { mirrorPrefix })
await commands.proxyInstallKernel(mirrorPrefix)
await refreshKernel()
} catch (e) {
// 错误事件已由后端 emit,这里仅记录日志
+50
View File
@@ -0,0 +1,50 @@
import { defineStore } from 'pinia'
import { moduleRegistry } from '@/modules/registry'
import { useAppStore } from '@/stores/appStore'
import { STORAGE_KEYS } from '@/lib/constants'
// Rust 端通过 tauri-specta 生成的命令绑定(bindings.ts
import { commands } from '@/lib/bindings'
/** 快速面板状态:命令缓存与设置的跨窗口同步(写入 localStorage 供独立弹窗读取) */
export const useQuickPanelStore = defineStore('quickpanel', () => {
/** 将已启用模块的命令缓存写入 localStorage(供独立弹窗窗口读取) */
const syncCommands = () => {
const appStore = useAppStore()
const enabledIds = appStore.enabledModules.map(m => m.id)
const all = moduleRegistry.getAllSearchItems()
const commands: Array<{
moduleId: string
moduleName: string
title: string
description?: string
keywords: string[]
}> = []
for (const { moduleId, items } of all) {
const config = moduleRegistry.getConfig(moduleId)
// 内置模块或已启用模块的搜索项才收录
if (!config?.builtin && !enabledIds.includes(moduleId)) continue
for (const item of items) {
commands.push({
moduleId,
moduleName: config?.name ?? moduleId,
title: item.title,
description: item.description,
keywords: item.keywords,
})
}
}
localStorage.setItem(STORAGE_KEYS.quickpanelCommands, JSON.stringify(commands))
}
/** 将快速面板设置写入 localStorage(供独立窗口的 web provider 读取搜索引擎) */
const syncSettings = async () => {
try {
const s = await commands.quickpanelGetSettings()
localStorage.setItem(STORAGE_KEYS.quickpanelSettings, JSON.stringify(s))
} catch (e) {
console.error('[quickpanel] 同步设置失败:', e)
}
}
return { syncCommands, syncSettings }
})
+86 -26
View File
@@ -1,15 +1,20 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { invoke } from '@tauri-apps/api/core'
import { WebviewWindow } from '@tauri-apps/api/webviewWindow'
import { PhysicalPosition, PhysicalSize } from '@tauri-apps/api/dpi'
import { availableMonitors } from '@tauri-apps/api/window'
import { emit, listen, type UnlistenFn } from '@tauri-apps/api/event'
import { toast } from 'vue-sonner'
import { EVENTS, WINDOWS } from '@/lib/constants'
// Rust 端通过 tauri-specta 生成的命令绑定(bindings.ts
import { commands } from '@/lib/bindings'
export interface RecentCapture {
id: string
pngBase64: string
/** 缩略图 data URLJPEG~256px 宽,常驻内存的只有它,完整图已落盘缓存) */
thumb: string
/** 完整 PNG 缓存文件路径(按需通过 screenshot_load_cache 加载) */
filePath: string
width: number
height: number
time: number
@@ -47,7 +52,7 @@ export const useScreenshotStore = defineStore('screenshot', () => {
let exportUnlisten: UnlistenFn | null = null
let shortcutUnlisten: UnlistenFn | null = null
/** 常驻截图覆盖层窗口(启动时创建,之后每次截图复用,避免重复 WebView 初始化) */
const OVERLAY_LABEL = 'screenshot-overlay'
const OVERLAY_LABEL = WINDOWS.screenshotOverlay
let overlayWin: WebviewWindow | null = null
let overlayReadyResolve: (() => void) | null = null
let overlayReadyPromise: Promise<void> | null = null
@@ -122,17 +127,17 @@ export const useScreenshotStore = defineStore('screenshot', () => {
}
}
// 捕获虚拟屏(覆盖层隐藏 → 不会出现在截图中),仅存原始像素,不做 PNG 编码
await invoke('screenshot_capture_fullscreen')
await commands.screenshotCaptureFullscreen()
// 用物理像素把覆盖层对齐到虚拟屏(多显示器/混合 DPI 下保证底图 1:1 与坐标一致)
const rect = await computeVirtualPhysicalRect()
await overlayWin?.setPosition(new PhysicalPosition(rect.x, rect.y))
await overlayWin?.setSize(new PhysicalSize(rect.width, rect.height))
await waitOverlayReady()
await emit('screenshot-begin')
await emit(EVENTS.screenshotBegin)
} catch (e) {
console.error('[screenshot] 捕获失败', e)
toast.error('截图启动失败:' + (e as Error).message)
await invoke('screenshot_clear_fullscreen').catch(() => {})
await commands.screenshotClearFullscreen().catch(() => {})
} finally {
capturing.value = false
}
@@ -188,7 +193,7 @@ export const useScreenshotStore = defineStore('screenshot', () => {
if (readyListenerInit) return
readyListenerInit = true
resetOverlayReady()
await listen('screenshot-overlay-ready', () => {
await listen(EVENTS.screenshotOverlayReady, () => {
overlayReadyResolve?.()
})
await ensureOverlay()
@@ -214,25 +219,71 @@ export const useScreenshotStore = defineStore('screenshot', () => {
}
// ===== 历史 / 导出 =====
function addRecent(pngBase64: string, width: number, height: number, mode: string) {
recent.value.unshift({
id: crypto.randomUUID(),
pngBase64,
width,
height,
time: Date.now(),
mode,
/** 由完整 PNG base64 生成缩略图 data URLcanvas 缩放至 ~256px 宽,JPEG 压缩) */
function makeThumb(pngBase64: string, width: number, height: number): Promise<string> {
return new Promise((resolve, reject) => {
const img = new Image()
img.onload = () => {
try {
const targetW = 256
const scale = targetW / width
const targetH = Math.max(1, Math.round(height * scale))
const canvas = document.createElement('canvas')
canvas.width = targetW
canvas.height = targetH
const ctx = canvas.getContext('2d')
if (!ctx) {
reject(new Error('无法创建画布上下文'))
return
}
ctx.drawImage(img, 0, 0, targetW, targetH)
resolve(canvas.toDataURL('image/jpeg', 0.7))
} catch (e) {
reject(e)
}
}
img.onerror = () => reject(new Error('缩略图解码失败'))
img.src = `data:image/png;base64,${pngBase64}`
})
const limit = Math.max(1, settings.value.historyLimit)
if (recent.value.length > limit) recent.value.length = limit
}
function clearHistory() {
/** 追加历史项:删除超出上限的最旧缓存文件 */
function addRecent(thumb: string, filePath: string, width: number, height: number, mode: string) {
recent.value.unshift({ id: crypto.randomUUID(), thumb, filePath, width, height, time: Date.now(), mode })
const limit = Math.max(1, settings.value.historyLimit)
if (recent.value.length > limit) {
const removed = recent.value.splice(limit)
for (const item of removed) {
void commands.screenshotDeleteCache(item.filePath)
}
}
}
/** 移除单个历史项(同时删除缓存文件) */
async function removeRecent(item: RecentCapture) {
const idx = recent.value.findIndex((r) => r.id === item.id)
if (idx >= 0) recent.value.splice(idx, 1)
try {
await commands.screenshotDeleteCache(item.filePath)
} catch (e) {
console.error('[screenshot] 删除缓存失败', e)
}
}
async function clearHistory() {
for (const item of recent.value) {
void commands.screenshotDeleteCache(item.filePath)
}
recent.value = []
}
/** 从历史缓存加载完整 PNG base64(一次性,不常驻) */
async function loadFullImage(item: RecentCapture): Promise<string> {
return await commands.screenshotLoadCache(item.filePath)
}
async function copyImage(pngBase64: string) {
await invoke('screenshot_copy_image', { pngBase64 })
await commands.screenshotCopyImage(pngBase64)
toast.success('已复制到剪贴板')
}
@@ -247,14 +298,21 @@ export const useScreenshotStore = defineStore('screenshot', () => {
filters: [{ name: 'PNG', extensions: ['png'] }],
})
if (!path) return
await invoke('screenshot_save_png', { pngBase64, path })
await commands.screenshotSavePng(pngBase64, path)
toast.success('已保存到文件')
}
/** 覆盖层/编辑器导出处理:记录历史 + 按设置自动保存 */
/** 覆盖层/编辑器导出处理:记录历史(缩略图 + 缓存落盘)+ 按设置自动保存 */
async function handleExport(payload: { pngBase64: string; width: number; height: number }) {
const { pngBase64, width, height } = payload
addRecent(pngBase64, width, height, 'capture')
try {
// 完整图落盘缓存目录,历史只保留缩略图,避免完整 base64 常驻内存
const filePath = await commands.screenshotSaveCache(pngBase64)
const thumb = await makeThumb(pngBase64, width, height)
addRecent(thumb, filePath, width, height, 'capture')
} catch (e) {
console.error('[screenshot] 历史缓存失败', e)
}
// 自动保存到指定目录
if (settings.value.autoSave && settings.value.saveDir) {
const ts = new Date()
@@ -263,7 +321,7 @@ export const useScreenshotStore = defineStore('screenshot', () => {
.slice(0, 19)
const path = `${settings.value.saveDir.replace(/\\$/, '')}\\screenshot_${ts}.png`
try {
await invoke('screenshot_save_png', { pngBase64, path })
await commands.screenshotSavePng(pngBase64, path)
} catch (e) {
console.error('[screenshot] 自动保存失败', e)
}
@@ -286,7 +344,7 @@ export const useScreenshotStore = defineStore('screenshot', () => {
/** 监听 Rust 侧 emit 的 'screenshot-shortcut' 事件(快捷键按下时触发) */
async function initShortcutListener() {
if (shortcutUnlisten) return
shortcutUnlisten = await listen('screenshot-shortcut', () => {
shortcutUnlisten = await listen(EVENTS.screenshotShortcut, () => {
void startCapture()
})
}
@@ -294,7 +352,7 @@ export const useScreenshotStore = defineStore('screenshot', () => {
/** 应用启动时按已保存的快捷键注册全局热键(支持自定义,默认 Ctrl+Alt+A */
async function initShortcutRegistration() {
try {
await invoke('screenshot_register_shortcut', { shortcut: settings.value.shortcut })
await commands.screenshotRegisterShortcut(settings.value.shortcut)
} catch (e) {
console.error('[screenshot] 快捷键注册失败', e)
}
@@ -305,7 +363,7 @@ export const useScreenshotStore = defineStore('screenshot', () => {
const next = shortcut.trim()
setSettings({ shortcut: next })
try {
await invoke('screenshot_register_shortcut', { shortcut: next })
await commands.screenshotRegisterShortcut(next)
return true
} catch (e) {
console.error('[screenshot] 快捷键注册失败', e)
@@ -339,6 +397,8 @@ export const useScreenshotStore = defineStore('screenshot', () => {
copyImage,
saveImage,
handleExport,
removeRecent,
loadFullImage,
loadSettings,
setSettings,
setShortcut,