性能优化
This commit is contained in:
@@ -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[] => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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::Value,specta 豁免,保留手动类型) */
|
||||
export interface DownloaderStatus {
|
||||
running: boolean
|
||||
}
|
||||
|
||||
/** 扩展信息(downloader_get_extension_info 返回 serde_json::Value,specta 豁免,保留手动类型) */
|
||||
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
@@ -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 温度墙默认 100,GPU 默认 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=false,UI 显示 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
|
||||
}
|
||||
|
||||
/** 模块卸载时调用:仅取消事件订阅,不停止 Kernel(Kernel 由 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 * 100,availW = screenW - windowW
|
||||
// 置 suppressPercentWatch=true 避免百分比变化触发 resetOverlayPosition 循环
|
||||
suppressPercentWatch = true
|
||||
try {
|
||||
const monitor = await currentMonitor()
|
||||
const screenW = (monitor?.size.width ?? 1920) / (monitor?.scaleFactor ?? 1)
|
||||
const screenH = (monitor?.size.height ?? 1080) / (monitor?.scaleFactor ?? 1)
|
||||
const size = await winInstance.outerSize()
|
||||
const scale = monitor?.scaleFactor ?? 1
|
||||
const winW = size.width / scale
|
||||
const winH = size.height / scale
|
||||
const availW = Math.max(1, screenW - winW)
|
||||
const availH = Math.max(1, screenH - winH)
|
||||
osdConfig.value.positionXPct = Math.round((payload.x / availW) * 100)
|
||||
osdConfig.value.positionYPct = Math.round((payload.y / availH) * 100)
|
||||
} catch { /* 忽略百分比反算失败 */ }
|
||||
saveOsdConfig(osdConfig.value)
|
||||
// 下一个微任务后解除抑制(让本次 watch 回调跳过即可)
|
||||
queueMicrotask(() => { suppressPercentWatch = false })
|
||||
})
|
||||
osdEventUnlisteners.push(unlisten)
|
||||
} catch { /* 忽略 */ }
|
||||
})
|
||||
win.once('tauri://error', (e: unknown) => {
|
||||
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 句柄存入 osdWatchStops,dispose 时统一释放,避免模块重挂载后重复注册
|
||||
|
||||
// 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,
|
||||
}
|
||||
|
||||
@@ -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
@@ -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::Value,specta 不导出)的手动类型 =====
|
||||
|
||||
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,这里仅记录日志
|
||||
|
||||
@@ -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 }
|
||||
})
|
||||
@@ -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 URL(JPEG,~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 URL(canvas 缩放至 ~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,
|
||||
|
||||
Reference in New Issue
Block a user