性能优化

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
+27 -67
View File
@@ -1,7 +1,6 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted, shallowRef, computed, watch, type Component } from 'vue'
import { listen, type UnlistenFn } from '@tauri-apps/api/event'
import { invoke } from '@tauri-apps/api/core'
import { getCurrentWindow } from '@tauri-apps/api/window'
import TitleBar from '@/components/layout/TitleBar.vue'
import Sidebar from '@/components/layout/Sidebar.vue'
@@ -9,13 +8,18 @@ import ModuleContainer from '@/components/layout/ModuleContainer.vue'
import { Toaster } from '@/components/ui/sonner'
import { useAppStore } from '@/stores/appStore'
import { useScreenshotStore } from '@/stores/screenshotStore'
import { useQuickPanelStore } from '@/stores/quickpanelStore'
import { useMonitorStore } from '@/stores/monitorStore'
import { TooltipProvider } from '@/components/ui/tooltip'
import { moduleRegistry } from '@/modules/registry'
import type { ModuleMeta } from '@/types/module'
import { pendingNewDownload, pendingOpenSettings } from '@/lib/trayEvents'
import { pendingNewDownload } from '@/lib/trayEvents'
import { EVENTS, STORAGE_KEYS } from '@/lib/constants'
const appStore = useAppStore()
const screenshotStore = useScreenshotStore()
const quickpanelStore = useQuickPanelStore()
const monitorStore = useMonitorStore()
/** 侧边栏 / 标题栏需要的模块信息(id + name + icon */
interface NavModule {
@@ -32,19 +36,11 @@ const toNavModule = (meta: ModuleMeta): NavModule => ({
})
/** 上次激活的模块 IDlocalStorage 持久化) */
const LAST_MODULE_KEY = 'thing_last_module'
const LAST_MODULE_KEY = STORAGE_KEYS.lastModule
const activeModule = ref('')
const activeComponent = shallowRef<Component | null>(null)
/** 预加载的监控模块组件(用于隐藏预渲染,确保 OSD 在启动时创建) */
const monitorComponent = shallowRef<Component | null>(null)
/** 监控模块是否已启用 */
const monitorEnabled = computed(() =>
appStore.modules.find(m => m.id === 'monitor')?.enabled ?? false
)
/** 当前可显示的模块(内置模块 + 已启用的用户模块),按用户排序显示 */
const availableModules = computed<NavModule[]>(() => {
const enabledIds = appStore.enabledModules.map(m => m.id)
@@ -65,8 +61,13 @@ const availableModules = computed<NavModule[]>(() => {
})
})
/** 模块加载请求序号:快速切换模块时丢弃过期加载结果,避免旧组件覆盖新组件 */
let moduleLoadSeq = 0
const loadModule = async (moduleId: string) => {
const seq = ++moduleLoadSeq
const component = await moduleRegistry.loadComponent(moduleId)
// 过期请求(期间用户又切换了模块)直接丢弃,不覆盖 activeComponent 也不触发钩子
if (seq !== moduleLoadSeq) return
activeComponent.value = component
// 调用模块的 onActivate 生命周期钩子
@@ -108,47 +109,9 @@ watch(() => appStore.enabledModules.length, () => {
loadModule(fallback)
}
// 模块启用/禁用变化时重新同步快速面板命令缓存
syncQuickPanelCommands()
quickpanelStore.syncCommands()
})
/** 同步快速面板命令缓存到 localStorage(供独立窗口读取) */
function syncQuickPanelCommands() {
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('thing_quickpanel_commands', JSON.stringify(commands))
}
/** 同步快速面板设置到 localStorage(供独立窗口的 web provider 读取搜索引擎) */
async function syncQuickPanelSettings() {
try {
const s = await invoke<{ shortcut: string; popupPosition: string; searchEngine: string; indexDirs: string[]; customCommands: unknown[] }>('quickpanel_get_settings')
localStorage.setItem('thing_quickpanel_settings', JSON.stringify(s))
} catch (e) {
console.error('[quickpanel] 同步设置失败:', e)
}
}
/** 计算启动时应打开的默认模块:优先上次记忆,其次排序第一个 */
const resolveDefaultModule = (): string => {
const enabledIds = appStore.enabledModules.map(m => m.id)
@@ -171,20 +134,21 @@ const resolveDefaultModule = (): string => {
onMounted(async () => {
await appStore.init().catch(e => console.error('App init error:', e))
// 预加载监控模块组件,用于隐藏预渲染
// 这样即使启动时默认模块不是监控,MonitorModule 的 onMounted 也会执行
// 从而在应用启动时自动创建 OSD 窗口(如果 OSD 配置已开启)
if (monitorEnabled.value) {
monitorComponent.value = await moduleRegistry.loadComponent('monitor')
}
// 初始化监控 store:订阅后端 monitor-data / monitor-network 等事件,
// 使 OSD 窗口在应用启动后即可接收数据流,不依赖用户手动打开监控模块。
// init() 幂等:MonitorModule 挂载时再次调用不会重复订阅。
monitorStore.init()
// 显式初始化 OSD
// 使 OSD 窗口在应用启动时创建(若配置已开启),且不依赖监控模块挂载/卸载
monitorStore.initOsd()
const defaultModule = resolveDefaultModule()
activeModule.value = defaultModule
loadModule(defaultModule)
// 快速面板:同步命令缓存与设置到 localStorage,供独立窗口读取
syncQuickPanelCommands()
syncQuickPanelSettings()
quickpanelStore.syncCommands()
quickpanelStore.syncSettings()
// 监听快速面板执行命令事件:显示主窗口 + 切换模块
trayUnlisteners.push(
await listen<{ moduleId: string }>('quickpanel-execute-command', async (e) => {
@@ -201,9 +165,9 @@ onMounted(async () => {
)
// 监听托盘菜单事件
// tray:toggle-osd 由 MonitorModule 直接监听(预渲染实例始终挂载
// tray:toggle-osd 由 monitorStore.initOsd() 注册的监听处理(与监控模块生命周期解耦
trayUnlisteners.push(
await listen('tray:new-download', () => {
await listen(EVENTS.trayNewDownload, () => {
// 设置标志位,DownloaderModule 挂载后消费
pendingNewDownload.value = true
// 切换到下载模块(如果未启用则切换到设置)
@@ -214,8 +178,7 @@ onMounted(async () => {
})
)
trayUnlisteners.push(
await listen('tray:open-settings', () => {
pendingOpenSettings.value = true
await listen(EVENTS.trayOpenSettings, () => {
handleModuleChange('settings')
})
)
@@ -228,6 +191,8 @@ const trayUnlisteners: UnlistenFn[] = []
onUnmounted(() => {
trayUnlisteners.forEach(fn => fn())
screenshotStore.destroyExportListener()
// 释放 OSD 事件监听与配置 watcher
monitorStore.disposeOsd()
})
</script>
@@ -245,10 +210,5 @@ onUnmounted(() => {
</div>
</div>
<Toaster position="bottom-right" rich-colors close-button :style="{ zIndex: 99999 }" />
<!-- 预渲染监控模块隐藏确保 OSD 窗口在应用启动时创建不依赖用户切换到监控模块
activeModule === 'monitor' 时不渲染 ModuleContainer 正常渲染避免重复实例 -->
<div v-if="monitorComponent && monitorEnabled && activeModule !== 'monitor'" style="display:none">
<component :is="monitorComponent" />
</div>
</TooltipProvider>
</template>
+14 -5
View File
@@ -6,6 +6,8 @@ import { invoke } from '@tauri-apps/api/core'
import { getCurrentWindow } from '@tauri-apps/api/window'
import { useSearchStore, type SearchItem } from '@/stores/searchStore'
import { useModuleTabsStore } from '@/stores/moduleTabsStore'
// 复用快速面板匹配引擎(支持拼音/子序列模糊匹配)
import { getTextForms, bestScore } from '@/modules/quickpanel/engine'
const tabsStore = useModuleTabsStore()
@@ -23,10 +25,12 @@ const searchStore = useSearchStore()
const filteredModules = computed(() => {
if (!searchQuery.value.trim()) return []
const query = searchQuery.value.toLowerCase()
return props.modules.filter(m =>
m.name.toLowerCase().includes(query) || m.id.toLowerCase().includes(query)
)
const query = searchQuery.value.trim()
return props.modules
.map(m => ({ m, score: Math.max(bestScore(query, getTextForms(m.name)), bestScore(query, getTextForms(m.id))) }))
.filter(e => e.score > 0)
.sort((a, b) => b.score - a.score)
.map(e => e.m)
})
const searchResults = computed(() => {
@@ -68,6 +72,7 @@ const minimize = async () => {
// 窗口最大化状态:切换最大化/还原图标
const isMaximized = ref(false)
let unlistenMaximize: (() => void) | null = null
let unlistenFocus: (() => void) | null = null
const maximize = async () => {
await tauriWindow?.toggleMaximize()
@@ -118,7 +123,8 @@ const close = async () => {
}
if (tauriWindow) {
tauriWindow.onFocusChanged(({ payload: focused }) => {
// 保存 unlistenonUnmounted 时释放(onFocusChanged 返回 Promise<UnlistenFn>
void tauriWindow.onFocusChanged(({ payload: focused }) => {
if (focused) {
hoverSuppressed.value = true
if (document.activeElement instanceof HTMLElement) {
@@ -131,6 +137,8 @@ if (tauriWindow) {
} else {
hoverSuppressed.value = true
}
}).then(fn => {
unlistenFocus = fn
})
}
@@ -202,6 +210,7 @@ onUnmounted(() => {
window.removeEventListener('mousemove', handleFirstMouseMove)
if (restoreHoverTimer) clearTimeout(restoreHoverTimer)
if (unlistenMaximize) unlistenMaximize()
if (unlistenFocus) unlistenFocus()
if (scrollViewport) scrollViewport.removeEventListener('scroll', handleMainScroll)
if (rafId !== null) cancelAnimationFrame(rafId)
})
+484
View File
@@ -0,0 +1,484 @@
// This file has been generated by Tauri Specta. Do not edit this file manually.
import { invoke as __TAURI_INVOKE } from "@tauri-apps/api/core";
/** Commands */
export const commands = {
proxyActivateProfile: (id: string) => __TAURI_INVOKE<null>("proxy_activate_profile", { id }),
proxyCheckKernelUpdate: () => __TAURI_INVOKE<KernelUpdateInfo>("proxy_check_kernel_update"),
proxyClearSystemProxy: () => __TAURI_INVOKE<null>("proxy_clear_system_proxy"),
proxyCloseConnection: (id: string) => __TAURI_INVOKE<null>("proxy_close_connection", { id }),
proxyDeleteProfile: (id: string) => __TAURI_INVOKE<null>("proxy_delete_profile", { id }),
proxyGetSettings: () => __TAURI_INVOKE<ProxySettings>("proxy_get_settings"),
proxyGetSystemProxy: () => __TAURI_INVOKE<boolean>("proxy_get_system_proxy"),
proxyImportProfile: (url: string, name: string) => __TAURI_INVOKE<ProfileMeta>("proxy_import_profile", { url, name }),
/** 首次安装内核(与 update_kernel 共用 install_kernel 实现,语义独立便于前端区分场景) */
proxyInstallKernel: (mirrorPrefix: string | null) => __TAURI_INVOKE<KernelInfo>("proxy_install_kernel", { mirrorPrefix }),
proxyKernelInfo: () => __TAURI_INVOKE<KernelInfo>("proxy_kernel_info"),
proxyRestart: () => __TAURI_INVOKE<ProcessInfo>("proxy_restart"),
proxySaveSettings: (settings: ProxySettings) => __TAURI_INVOKE<null>("proxy_save_settings", { settings }),
proxySelectProxy: (group: string, name: string) => __TAURI_INVOKE<null>("proxy_select_proxy", { group, name }),
proxySetSystemProxy: () => __TAURI_INVOKE<null>("proxy_set_system_proxy"),
proxyStart: () => __TAURI_INVOKE<ProcessInfo>("proxy_start"),
proxyStatus: () => __TAURI_INVOKE<ProxyStatus>("proxy_status"),
proxyStop: () => __TAURI_INVOKE<null>("proxy_stop"),
proxyTestDelay: (name: string, url: string | null, timeout: number | null) => __TAURI_INVOKE<number>("proxy_test_delay", { name, url, timeout }),
proxyUpdateKernel: (mirrorPrefix: string | null) => __TAURI_INVOKE<KernelInfo>("proxy_update_kernel", { mirrorPrefix }),
proxyUpdateProfile: (id: string) => __TAURI_INVOKE<ProfileMeta>("proxy_update_profile", { id }),
/** 读取快速面板设置(快捷键等) */
quickpanelGetSettings: () => __TAURI_INVOKE<QuickPanelSettings>("quickpanel_get_settings"),
/** 保存快速面板设置;快捷键变化时自动重新注册 + 预创建窗口 */
quickpanelSaveSettings: (settings: QuickPanelSettings) => __TAURI_INVOKE<null>("quickpanel_save_settings", { settings }),
/** 注册(或切换)快速面板全局快捷键 */
quickpanelRegisterShortcut: (shortcut: string) => __TAURI_INVOKE<null>("quickpanel_register_shortcut", { shortcut }),
/** 注销快速面板全局快捷键 */
quickpanelUnregisterShortcut: () => __TAURI_INVOKE<null>("quickpanel_unregister_shortcut"),
/** 手动触发显示快速面板(供 UI 按钮调用) */
quickpanelShowPopup: () => __TAURI_INVOKE<null>("quickpanel_show_popup"),
/** 隐藏快速面板 */
quickpanelHidePopup: () => __TAURI_INVOKE<null>("quickpanel_hide_popup"),
/** 显示已创建的弹窗窗口(前端 onMounted 后调用) */
quickpanelShowWindow: () => __TAURI_INVOKE<null>("quickpanel_show_window"),
/** 锁定屏幕(Windows: rundll32 user32.dll,LockWorkStationCREATE_NO_WINDOW 避免黑窗) */
quickpanelLockScreen: () => __TAURI_INVOKE<null>("quickpanel_lock_screen"),
/** 初始化文件索引数据库(应用启动时调用) */
quickpanelInitFileIndex: () => __TAURI_INVOKE<null>("quickpanel_init_file_index"),
/** 构建文件索引(全量重建,阻塞操作建议在 spawn_blocking 调用) */
quickpanelBuildFileIndex: () => __TAURI_INVOKE<number>("quickpanel_build_file_index"),
/** 搜索文件索引(SQLite 查询移出主线程) */
quickpanelSearchFiles: (query: string, limit: number | null) => __TAURI_INVOKE<FileRecord[]>("quickpanel_search_files", { query, limit }),
/** 获取索引状态 */
quickpanelFileIndexStats: () => __TAURI_INVOKE<IndexStats>("quickpanel_file_index_stats"),
/** 扫描已安装应用(遍历开始菜单/桌面/磁盘,移出主线程) */
quickpanelScanApps: () => __TAURI_INVOKE<AppRecord[]>("quickpanel_scan_apps"),
/**
* 获取应用图标(data URL)。命中内存/磁盘缓存时零 Windows API 调用。
* 前端按需为可见项调用,避免一次性加载全部图标。
* 未命中缓存时 SHGetFileInfoW + 编码 + 落盘为阻塞操作,移出主线程。
*/
quickpanelGetAppIcon: (path: string) => __TAURI_INVOKE<string | null>("quickpanel_get_app_icon", { path }),
/** 清理图标缓存(磁盘 + 内存) */
quickpanelClearAppIconCache: () => __TAURI_INVOKE<null>("quickpanel_clear_app_icon_cache"),
/** 在资源管理器中显示文件(选中) */
quickpanelRevealInExplorer: (path: string) => __TAURI_INVOKE<null>("quickpanel_reveal_in_explorer", { path }),
/**
* 用系统默认程序打开文件/文件夹。
* - 目录:explorer.exe 直接打开(修复索引目录点击后未打开的问题)
* - 文件:ShellExecuteW open,无关联应用时自动 fallback 到「打开方式」对话框(verb: openas
*/
quickpanelOpenFile: (path: string) => __TAURI_INVOKE<null>("quickpanel_open_file", { path }),
/** 获取 Windows 常用快捷位置(hosts、回收站、此电脑、用户目录、系统管理工具等) */
quickpanelGetSpecialLocations: () => __TAURI_INVOKE<SpecialLocation[]>("quickpanel_get_special_locations"),
/** 打开快捷位置(kind: file | shell | cmd */
quickpanelOpenSpecial: (kind: string, target: string, args: string[]) => __TAURI_INVOKE<null>("quickpanel_open_special", { kind, target, args }),
/** 删除文件(移到回收站,PowerShell 阻塞等待移出主线程) */
quickpanelDeleteFile: (path: string) => __TAURI_INVOKE<null>("quickpanel_delete_file", { path }),
/**
* 运行自定义命令(执行可执行文件 + 参数)
* .lnk 快捷方式不能直接 spawnos error 193),需通过 cmd /C 启动
*/
quickpanelRunCustomCommand: (command: string, args: string[]) => __TAURI_INVOKE<null>("quickpanel_run_custom_command", { command, args }),
/**
* 运行系统命令(不设置 CREATE_NO_WINDOW,使 cmd/powershell/regedit 等显示自身窗口)
* 适用于内置系统工具:regedit、shutdown、cmd、powershell、taskmgr 等。
*/
quickpanelRunSystemCommand: (command: string, args: string[]) => __TAURI_INVOKE<null>("quickpanel_run_system_command", { command, args }),
clipboardGetHistory: (limit: number | null, offset: number | null, kind: string | null) => __TAURI_INVOKE<HistoryPage>("clipboard_get_history", { limit, offset, kind }),
clipboardGetPinned: () => __TAURI_INVOKE<ClipboardItem[]>("clipboard_get_pinned"),
clipboardSearch: (query: string, limit: number | null, offset: number | null) => __TAURI_INVOKE<HistoryPage>("clipboard_search", { query, limit, offset }),
clipboardGetItem: (id: number) => __TAURI_INVOKE<({
/** 文本内容 / 文件列表 JSON */
content: string | null,
/** 图片 PNG base64(仅 image 类型) */
imageBase64: string | null,
}) & (ClipboardItem) | null>("clipboard_get_item", { id }),
clipboardSetPinned: (id: number, pinned: boolean) => __TAURI_INVOKE<boolean>("clipboard_set_pinned", { id, pinned }),
clipboardDelete: (id: number) => __TAURI_INVOKE<boolean>("clipboard_delete", { id }),
clipboardClear: () => __TAURI_INVOKE<boolean>("clipboard_clear"),
clipboardCopyBack: (id: number) => __TAURI_INVOKE<null>("clipboard_copy_back", { id }),
clipboardCount: () => __TAURI_INVOKE<number>("clipboard_count"),
clipboardGetSettings: () => __TAURI_INVOKE<ClipboardSettings>("clipboard_get_settings"),
clipboardSaveSettings: (settings: ClipboardSettings) => __TAURI_INVOKE<null>("clipboard_save_settings", { settings }),
clipboardStatus: () => __TAURI_INVOKE<ClipboardStatus>("clipboard_status"),
clipboardStart: () => __TAURI_INVOKE<null>("clipboard_start"),
clipboardStop: () => __TAURI_INVOKE<null>("clipboard_stop"),
/** 注册(或切换)快捷弹窗全局快捷键 */
clipboardRegisterShortcut: (shortcut: string) => __TAURI_INVOKE<null>("clipboard_register_shortcut", { shortcut }),
/** 注销快捷弹窗全局快捷键 */
clipboardUnregisterShortcut: () => __TAURI_INVOKE<null>("clipboard_unregister_shortcut"),
/** 手动触发显示快捷弹窗(供 UI 按钮调用) */
clipboardShowPopup: () => __TAURI_INVOKE<null>("clipboard_show_popup"),
/** 隐藏快捷弹窗 */
clipboardHidePopup: () => __TAURI_INVOKE<null>("clipboard_hide_popup"),
/** 显示已创建的弹窗窗口(前端 onMounted 后调用) */
clipboardShowWindow: () => __TAURI_INVOKE<null>("clipboard_show_window"),
/** 隐藏弹窗并模拟 Ctrl+V 粘贴到原窗口 */
clipboardPasteToTarget: () => __TAURI_INVOKE<null>("clipboard_paste_to_target"),
/** 获取所有任务 */
downloaderGetTasks: () => __TAURI_INVOKE<DownloadTask[]>("downloader_get_tasks"),
/** 检查 URL 重复性并探测文件信息(添加下载前调用) */
downloaderCheckUrl: (url: string, dir: string | null, headers: { [key in string]: string } | null) => __TAURI_INVOKE<CheckUrlResult>("downloader_check_url", { url, dir, headers }),
/** 添加下载任务 */
downloaderAddTask: (url: string, filename: string | null, dir: string | null, headers: { [key in string]: string } | null, autoRename: boolean | null) => __TAURI_INVOKE<string>("downloader_add_task", { url, filename, dir, headers, autoRename }),
/** 暂停任务 */
downloaderPauseTask: (id: string) => __TAURI_INVOKE<null>("downloader_pause_task", { id }),
/** 恢复任务 */
downloaderResumeTask: (id: string) => __TAURI_INVOKE<null>("downloader_resume_task", { id }),
/** 移除任务 */
downloaderRemoveTask: (id: string, deleteFiles: boolean | null) => __TAURI_INVOKE<null>("downloader_remove_task", { id, deleteFiles }),
/** 获取设置 */
downloaderGetSettings: () => __TAURI_INVOKE<DownloaderSettings>("downloader_get_settings"),
/** 保存设置 */
downloaderSaveSettings: (settings: DownloaderSettings) => __TAURI_INVOKE<null>("downloader_save_settings", { settings }),
/** 用系统资源管理器打开目录 */
downloaderOpenDir: (path: string) => __TAURI_INVOKE<null>("downloader_open_dir", { path }),
/** 用系统默认浏览器打开 URL */
downloaderOpenUrl: (url: string) => __TAURI_INVOKE<null>("downloader_open_url", { url }),
/** 禁用指定窗口(按 label 查找)的显示/隐藏过渡动画,消除覆盖层出现/消失时的缩放动画 */
screenshotDisableTransitions: (label: string) => __TAURI_INVOKE<null>("screenshot_disable_transitions", { label }),
/** 注册(或切换)截图全局快捷键。传入空字符串则禁用快捷键。 */
screenshotRegisterShortcut: (shortcut: string) => __TAURI_INVOKE<null>("screenshot_register_shortcut", { shortcut }),
/** 注销截图全局快捷键 */
screenshotUnregisterShortcut: () => __TAURI_INVOKE<null>("screenshot_unregister_shortcut"),
/** 捕获整个虚拟屏(多显示器拼接)存入静态,不做 PNG 编码 */
screenshotCaptureFullscreen: () => __TAURI_INVOKE<null>("screenshot_capture_fullscreen"),
/** 全屏捕获编码为 PNG base64 并清除(全屏截图直接进编辑器) */
screenshotFullscreenPng: () => __TAURI_INVOKE<CaptureData>("screenshot_fullscreen_png"),
/** 清除静态全屏捕获(覆盖层关闭/取消时释放内存) */
screenshotClearFullscreen: () => __TAURI_INVOKE<null>("screenshot_clear_fullscreen"),
/** 按物理像素坐标裁剪已存储的全屏捕获 */
screenshotCropStored: (x: number, y: number, w: number, h: number) => __TAURI_INVOKE<CaptureData>("screenshot_crop_stored", { x, y, w, h }),
/** 裁剪已存储的全屏捕获并直接写入剪贴板(一次 IPC 完成"裁剪+复制" */
screenshotCropCopyStored: (x: number, y: number, w: number, h: number) => __TAURI_INVOKE<CaptureData>("screenshot_crop_copy_stored", { x, y, w, h }),
/** 拾取指定物理屏幕坐标下的顶层窗口 */
screenshotWindowFromPoint: (x: number, y: number) => __TAURI_INVOKE<{
hwnd: number,
title: string,
rect: ScreenRect,
/** DWM 扩展边框矩形(视觉边界,去掉最大化窗口的隐形缩放边框),命中测试用 rect,高亮用 visual_rect */
visualRect: ScreenRect | null,
} | null>("screenshot_window_from_point", { x, y }),
/** 获取当前鼠标物理屏幕坐标(覆盖层打开时定位初始悬停窗口) */
screenshotCursorPos: () => __TAURI_INVOKE<[number, number]>("screenshot_cursor_pos"),
/** 枚举所有可见顶层窗口 */
screenshotEnumWindows: () => __TAURI_INVOKE<WindowInfo[]>("screenshot_enum_windows"),
/** 按 hwnd 捕获指定窗口 */
screenshotCaptureWindow: (hwnd: number) => __TAURI_INVOKE<CaptureData>("screenshot_capture_window", { hwnd }),
/** 存入编辑器图片(base64 PNG */
screenshotSetEditorImage: (pngBase64: string) => __TAURI_INVOKE<null>("screenshot_set_editor_image", { pngBase64 }),
/** 取出编辑器图片(编辑器窗口加载时调用,取出即清除) */
screenshotGetEditorImage: () => __TAURI_INVOKE<string | null>("screenshot_get_editor_image"),
/** 将 PNG base64 写入系统剪贴板(转 CF_DIB */
screenshotCopyImage: (pngBase64: string) => __TAURI_INVOKE<null>("screenshot_copy_image", { pngBase64 }),
/** 将 PNG base64 写入文件 */
screenshotSavePng: (pngBase64: string, path: string) => __TAURI_INVOKE<null>("screenshot_save_png", { pngBase64, path }),
/** 将完整 PNG 写入历史缓存目录,返回文件路径 */
screenshotSaveCache: (pngBase64: string) => __TAURI_INVOKE<string>("screenshot_save_cache", { pngBase64 }),
/** 从历史缓存目录读取 PNG 并返回 base64(点击历史项复制/保存时一次性加载,不常驻内存) */
screenshotLoadCache: (path: string) => __TAURI_INVOKE<string>("screenshot_load_cache", { path }),
/** 删除历史缓存文件(历史项移除/清空时调用,静默忽略不存在文件) */
screenshotDeleteCache: (path: string) => __TAURI_INVOKE<null>("screenshot_delete_cache", { path }),
};
/* Types */
export type AppRecord = {
name: string,
path: string,
};
/** 前端可见的捕获数据 */
export type CaptureData = {
pngBase64: string,
width: number,
height: number,
};
/** check_url 命令返回的结果 */
export type CheckUrlResult = {
/** 探测是否成功 */
ok: boolean,
/** 错误信息(探测失败时) */
error: string | null,
/** 文件名(探测成功时) */
filename: string | null,
/** 文件大小(字节) */
totalSize: number | null,
/** 是否支持断点续传 */
supportsResume: boolean,
/** 重复类型 */
duplicate: DuplicateKind,
/** 已存在的任务信息 */
existing: ExistingTaskInfo | null,
};
/** 列表项(不含大字段,用于历史/搜索结果) */
export type ClipboardItem = {
id: number,
kind: string,
preview: string,
size: number,
pinned: boolean,
pinnedOrder: number | null,
createdAt: number,
};
/** 详情(含文本内容或图片 base64) */
export type ClipboardItemDetail = {
/** 文本内容 / 文件列表 JSON */
content: string | null,
/** 图片 PNG base64(仅 image 类型) */
imageBase64: string | null,
} & ClipboardItem;
/** 剪贴板设置(持久化到 clipboard/settings.json */
export type ClipboardSettings = {
/** 监听是否启用 */
enabled?: boolean,
/** 非固定历史最大条数 */
maxItems?: number,
/** 图片大小上限(KB),0 表示不限 */
maxImageKb?: number,
recordText?: boolean,
recordImage?: boolean,
recordFiles?: boolean,
/** 去重(相同内容更新时间而非新增) */
dedup?: boolean,
/** 快捷弹窗全局快捷键(如 "Alt+V",空字符串表示禁用) */
shortcut?: string,
};
export type ClipboardStatus = {
running: boolean,
count: number,
};
/** 自定义命令 */
export type CustomCommand = {
id: string,
title: string,
command: string,
args?: string[],
};
/** 下载任务 */
export type DownloadTask = {
/** 任务 ID(自增 hex 字符串) */
id: string,
/** 下载地址 */
url: string,
/** 文件名 */
filename: string,
/** 保存目录(绝对路径) */
dir: string,
/** 状态 */
status: TaskStatus,
/** 文件总大小(字节),0=未知 */
totalSize: number,
/** 已下载字节 */
completedSize: number,
/** 当前下载速度 bytes/s */
speed: number,
/** 服务器是否支持断点续传 */
supportsResume: boolean,
/** 分段信息 */
segments?: Segment[],
/** 错误信息 */
error?: string | null,
/** 创建时间(Unix 时间戳,毫秒) */
createdAt: number,
/** 自定义请求头(Cookie / Referer 等) */
headers?: { [key in string]: string },
};
/** 下载设置 */
export type DownloaderSettings = {
/** 下载目录 */
downloadDir?: string,
/** 最大同时下载数 */
maxConcurrent?: number,
/** 单任务最大连接数(多线程分段数) */
maxConnections?: number,
/** 断点续传 */
continueDownload?: boolean,
/** 全局速度限制 KB/s0=不限) */
globalSpeedLimit?: number,
/** 扩展 HTTP API 端口 */
extensionPort?: number,
/** 扩展认证密钥(空=不认证) */
extensionSecret?: string,
/** 删除任务时是否同时删除已下载的文件 */
deleteFilesOnRemove?: boolean,
/** 添加下载前检查重复(URL 或文件名重复时询问) */
checkDuplicate?: boolean,
};
/** 重复类型 */
export type DuplicateKind =
/** 无重复 */
"none" |
/** URL 重复(已有相同链接的任务) */
"url" |
/** 文件名重复(已有同名任务下载到同一目录) */
"filename" |
/** 磁盘文件已存在 */
"fileExists";
/** 已存在的任务信息(用于前端展示) */
export type ExistingTaskInfo = {
id: string,
filename: string,
status: TaskStatus,
};
/** 单个文件记录(返回给前端) */
export type FileRecord = {
path: string,
name: string,
ext: string,
size: number,
isDir: boolean,
};
/** 历史查询结果(含总数,用于分页) */
export type HistoryPage = {
items: ClipboardItem[],
total: number,
};
/** 索引状态(返回给前端) */
export type IndexStats = {
total: number,
lastBuiltAt: number,
lastBuiltDirs: string[],
};
export type KernelInfo = {
path: string,
exists: boolean,
version: string | null,
};
export type KernelUpdateInfo = {
currentVersion: string | null,
latestVersion: string,
downloadUrl: string,
hasUpdate: boolean,
};
/** 进程信息(返回给前端) */
export type ProcessInfo = {
id: string,
name: string,
status: ProcessStatus,
pid: number | null,
restartCount: number,
};
/** 进程状态枚举 */
export type ProcessStatus = "running" | "stopped" | "crashed" | "starting";
export type ProfileMeta = {
id?: string,
name?: string,
url?: string,
addedAt?: string,
updatedAt?: string,
size?: number | null,
};
export type 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 URL 前)。
* 空字符串 = 直连 GitHub,其余为镜像站前缀(含尾斜杠)。
*/
kernelMirrors?: string[],
};
export type ProxyStatus = {
running: boolean,
pid: number | null,
restartCount: number,
};
/** 快速面板设置 */
export type QuickPanelSettings = {
/** 全局快捷键(如 "Alt+Space"),空字符串表示不注册。 */
shortcut?: string,
/** 唤起位置:center(鼠标所在显示器中央)| cursor(鼠标位置) */
popupPosition?: string,
/** 默认搜索引擎:google | bing | baidu */
searchEngine?: string,
/** 文件索引目录列表(空列表表示使用默认:桌面/文档/下载) */
indexDirs?: string[],
/** 自定义命令列表 */
customCommands?: CustomCommand[],
};
export type ScreenRect = {
x: number,
y: number,
width: number,
height: number,
};
/** 下载分段(多线程 Range 下载 / 断点续传用) */
export type Segment = {
/** 分段索引 */
index: number,
/** 起始字节(含) */
start: number,
/** 结束字节(含) */
end: number,
/** 已下载字节 */
completed: number,
};
/** 快捷位置条目 */
export type SpecialLocation = {
id: string,
title: string,
subtitle: string,
keywords: string[],
/** file: 真实文件/文件夹路径;shell: explorer 打开的 shell 路径;cmd: 可执行命令 */
kind: string,
target: string,
args: string[],
};
/** 任务状态 */
export type TaskStatus =
/** 排队等待(并发数已满) */
"queued" |
/** 下载中 */
"active" |
/** 已暂停 */
"paused" |
/** 已完成 */
"complete" |
/** 错误 */
"error";
/** 窗口信息(窗口拾取 / 枚举) */
export type WindowInfo = {
hwnd: number,
title: string,
rect: ScreenRect,
/** DWM 扩展边框矩形(视觉边界,去掉最大化窗口的隐形缩放边框),命中测试用 rect,高亮用 visual_rect */
visualRect: ScreenRect | null,
};
+57
View File
@@ -0,0 +1,57 @@
/**
* 表达式求值器单测(Node 内置 test runner)。
*/
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { evaluateExpression } from './calc.ts'
test('四则运算与优先级', () => {
assert.equal(evaluateExpression('1+2*3'), 7)
assert.equal(evaluateExpression('2*(3+4)'), 14)
assert.equal(evaluateExpression('10-2-3'), 5)
})
test('除法与取模', () => {
assert.equal(evaluateExpression('10/4'), 2.5)
assert.equal(evaluateExpression('10%3'), 1)
})
test('小数与边界写法', () => {
assert.equal(evaluateExpression('0.1+0.2'), 0.30000000000000004)
assert.equal(evaluateExpression('.5+.5'), 1)
assert.equal(evaluateExpression('5.'), 5)
})
test('一元正负号', () => {
assert.equal(evaluateExpression('-5+3'), -2)
assert.equal(evaluateExpression('-(2+3)'), -5)
assert.equal(evaluateExpression('2*-3'), -6)
assert.equal(evaluateExpression('+5'), 5)
assert.equal(evaluateExpression('--5'), 5)
})
test('括号嵌套', () => {
assert.equal(evaluateExpression('(1+2)*(3+4)'), 21)
assert.equal(evaluateExpression('((1+2))'), 3)
})
test('空白容忍', () => {
assert.equal(evaluateExpression(' 1 + 2 '), 3)
assert.equal(evaluateExpression(' '), null)
})
test('非法输入返回 null', () => {
assert.equal(evaluateExpression(''), null)
assert.equal(evaluateExpression('abc'), null)
assert.equal(evaluateExpression('1/'), null)
assert.equal(evaluateExpression('((1+2)'), null)
assert.equal(evaluateExpression('1+2)'), null)
assert.equal(evaluateExpression('1 2'), null)
assert.equal(evaluateExpression('%3'), null)
assert.equal(evaluateExpression('1.2.3'), null)
})
test('非有限结果返回 null(除零)', () => {
assert.equal(evaluateExpression('1/0'), null)
assert.equal(evaluateExpression('5%0'), null)
})
+136
View File
@@ -0,0 +1,136 @@
/**
* 表达式求值器(CSP 安全,替代 Function/eval)。
* 支持:十进制小数、+ - * / %、括号、一元正负号。
* 非法输入或结果为非有限值返回 null。
*
* 语义差异说明:原 Function 实现下 `1++2` / `1--2` 属语法错误;
* 此处解析器将连续正负号按一元运算符处理(`1++2` → 3),更宽松且无安全隐患。
*/
type Token =
| { kind: 'num'; value: number }
| { kind: 'op'; value: string }
| { kind: 'end' }
/** 数字 token`12.5` / `12.` / `.5` */
const NUM_RE = /^\d+(\.\d*)?|^\.\d+/
function tokenize(input: string): Token[] | null {
const tokens: Token[] = []
let i = 0
while (i < input.length) {
const ch = input[i]
if (/\s/.test(ch)) {
i++
continue
}
if (/[0-9.]/.test(ch)) {
const m = NUM_RE.exec(input.slice(i))
if (!m) return null
const value = Number(m[0])
if (!Number.isFinite(value)) return null
tokens.push({ kind: 'num', value })
i += m[0].length
continue
}
if ('+-*/%()'.includes(ch)) {
tokens.push({ kind: 'op', value: ch })
i++
continue
}
return null
}
tokens.push({ kind: 'end' })
return tokens
}
/** 递归下降解析器:expr → term → factor(支持优先级与括号) */
class Parser {
private pos = 0
private tokens: Token[]
constructor(tokens: Token[]) {
this.tokens = tokens
}
/** 完整解析:要求消费全部 token 且成功 */
parse(): number | null {
const v = this.parseExpr()
if (v === null) return null
if (this.peek().kind !== 'end') return null
return v
}
private peek(): Token {
return this.tokens[this.pos]
}
private next(): Token {
return this.tokens[this.pos++]
}
/** expr := term (('+' | '-') term)* */
private parseExpr(): number | null {
let left = this.parseTerm()
if (left === null) return null
while (true) {
const tok = this.peek()
if (tok.kind !== 'op' || (tok.value !== '+' && tok.value !== '-')) break
this.next()
const right = this.parseTerm()
if (right === null) return null
left = tok.value === '+' ? left + right : left - right
}
return left
}
/** term := factor (('*' | '/' | '%') factor)* */
private parseTerm(): number | null {
let left = this.parseFactor()
if (left === null) return null
while (true) {
const tok = this.peek()
if (tok.kind !== 'op' || (tok.value !== '*' && tok.value !== '/' && tok.value !== '%')) {
break
}
this.next()
const right = this.parseFactor()
if (right === null) return null
left = tok.value === '*' ? left * right : tok.value === '/' ? left / right : left % right
}
return left
}
/** factor := ('+' | '-') factor | '(' expr ')' | number */
private parseFactor(): number | null {
const tok = this.peek()
if (tok.kind === 'op' && (tok.value === '+' || tok.value === '-')) {
this.next()
const v = this.parseFactor()
if (v === null) return null
return tok.value === '-' ? -v : v
}
if (tok.kind === 'num') {
this.next()
return tok.value
}
if (tok.kind === 'op' && tok.value === '(') {
this.next()
const v = this.parseExpr()
if (v === null) return null
const close = this.next()
if (close.kind !== 'op' || close.value !== ')') return null
return v
}
return null
}
}
/** 求值表达式,非法输入或结果为非有限值返回 null */
export function evaluateExpression(input: string): number | null {
const tokens = tokenize(input)
if (!tokens) return null
const result = new Parser(tokens).parse()
if (result === null || !Number.isFinite(result)) return null
return result
}
+65
View File
@@ -0,0 +1,65 @@
/**
* 全局常量集中定义。
* 窗口 label / Tauri 事件名 / localStorage 存储键,避免魔法字符串散布各处。
* 与 Rust 侧 `src-tauri/src/constants.rs` 保持对应。
*/
/** 窗口 label(对应 Rust constants::windows 与 capabilities/*.json */
export const WINDOWS = {
main: 'main',
osdOverlay: 'osd-overlay',
screenshotOverlay: 'screenshot-overlay',
} as const
/** Tauri 事件名(前端 emit / listen 与 Rust constants::events 对应) */
export const EVENTS = {
// 托盘菜单
trayMenuShow: 'tray-menu-show',
trayMenuStateUpdated: 'tray-menu-state-updated',
trayToggleOsd: 'tray:toggle-osd',
trayNewDownload: 'tray:new-download',
trayOpenSettings: 'tray:open-settings',
// 剪贴板
clipboardChanged: 'clipboard-changed',
clipboardPopupShow: 'clipboard-popup-show',
clipboardPopupHide: 'clipboard-popup-hide',
// 快速面板
quickpanelShow: 'quickpanel-show',
quickpanelHide: 'quickpanel-hide',
quickpanelExecuteCommand: 'quickpanel-execute-command',
// 截图
screenshotBegin: 'screenshot-begin',
screenshotOverlayReady: 'screenshot-overlay-ready',
screenshotShortcut: 'screenshot-shortcut',
screenshotExported: 'screenshot-exported',
// 内核安装进度
kernelInstallProgress: 'kernel-install-progress',
// 监控 OSD
osdStateUpdate: 'osd-state-update',
osdContentSize: 'osd-content-size',
osdSystemUiActive: 'osd-system-ui-active',
osdSystemUiInactive: 'osd-system-ui-inactive',
osdStartDrag: 'osd-start-drag',
osdEndDrag: 'osd-end-drag',
monitorReady: 'monitor-ready',
monitorLoading: 'monitor-loading',
monitorDisconnected: 'monitor-disconnected',
monitorError: 'monitor-error',
monitorData: 'monitor-data',
monitorNetwork: 'monitor-network',
// 其他
processStatusChanged: 'process-status-changed',
downloadAdded: 'download-added',
} as const
/** localStorage 存储键 */
export const STORAGE_KEYS = {
appSettings: 'thing_app_settings',
lastModule: 'thing_last_module',
quickpanelCommands: 'thing_quickpanel_commands',
quickpanelSettings: 'thing_quickpanel_settings',
quickpanelHistory: 'thing_quickpanel_history',
quickpanelHistoryItems: 'thing_quickpanel_history_items',
currencyRates: 'thing_quickpanel_currency_rates',
monitorOsdConfig: 'thing_monitor_osd_config',
} as const
+3 -3
View File
@@ -86,19 +86,19 @@ export async function getLogs(
level?: LogLevel,
limit?: number,
): Promise<LogEntry[]> {
return invoke('get_logs', { module, level, limit })
return invoke('log_list', { module, level, limit })
}
/**
* 清空所有日志文件。
*/
export async function clearLogs(): Promise<void> {
return invoke('clear_logs')
return invoke('log_clear')
}
/**
* 获取日志系统信息(目录、文件列表、空间占用)。
*/
export async function getLogInfo(): Promise<LogInfo> {
return invoke('get_log_info')
return invoke('log_info_state')
}
-3
View File
@@ -10,6 +10,3 @@ import { ref } from 'vue'
/** 待打开新建下载对话框(由托盘"新建下载"触发) */
export const pendingNewDownload = ref(false)
/** 待切换到设置模块(由托盘"常规设置"触发) */
export const pendingOpenSettings = ref(false)
+23 -36
View File
@@ -1,4 +1,4 @@
import { createApp } from 'vue'
import { createApp, type Component } from 'vue'
import { createPinia } from 'pinia'
import './style.css'
import 'vue-sonner/style.css'
@@ -15,44 +15,31 @@ window.addEventListener('unhandledrejection', (event) => {
logger.error(`未处理的Promise拒绝: ${event.reason}`)
})
// ===== OSD 窗口模式检测 =====
// ===== 独立窗口模式 =====
// 通过 URL hash 识别独立窗口:#osd-overlay / #clipboard-popup / #quick-panel / #tray-menu / #screenshot-overlay / #screenshot-editor
// 这些窗口是精简的独立 Vue 应用,不加载主应用的 store 和模块
// 新增独立窗口只需在此表登记一行(hash → 组件)
const standaloneWindowApps: Array<[hash: string, label: string, loader: () => Promise<{ default: Component }>]> = [
['#osd-overlay', 'OSD', () => import('./modules/monitor/OsdWindow.vue')],
['#clipboard-popup', '剪贴板弹窗', () => import('./modules/clipboard/ClipboardPopup.vue')],
['#quick-panel', '快速面板弹窗', () => import('./modules/quickpanel/QuickPanel.vue')],
['#tray-menu', '托盘菜单', () => import('./modules/tray/TrayMenu.vue')],
['#screenshot-overlay', '截图覆盖层', () => import('./modules/screenshot/ScreenshotOverlay.vue')],
['#screenshot-editor', '截图编辑器', () => import('./modules/screenshot/ScreenshotEditor.vue')],
]
const winHash = window.location.hash
if (winHash === '#osd-overlay') {
logger.info(`OSD 窗口启动: ${winHash}`)
void import('./modules/monitor/OsdWindow.vue').then(({ default: OsdWindow }) => {
const app = createApp(OsdWindow)
app.mount('#app')
})
} else if (winHash === '#clipboard-popup') {
logger.info(`剪贴板弹窗窗口启动: ${winHash}`)
void import('./modules/clipboard/ClipboardPopup.vue').then(({ default: ClipboardPopup }) => {
const app = createApp(ClipboardPopup)
app.mount('#app')
})
} else if (winHash === '#quick-panel') {
logger.info(`快速面板弹窗窗口启动: ${winHash}`)
void import('./modules/quickpanel/QuickPanel.vue').then(({ default: QuickPanel }) => {
const app = createApp(QuickPanel)
app.mount('#app')
})
} else if (winHash === '#tray-menu') {
logger.info(`托盘菜单窗口启动: ${winHash}`)
void import('./modules/tray/TrayMenu.vue').then(({ default: TrayMenu }) => {
const app = createApp(TrayMenu)
app.mount('#app')
})
} else if (winHash.startsWith('#screenshot-overlay')) {
logger.info(`截图覆盖层窗口启动: ${winHash}`)
void import('./modules/screenshot/ScreenshotOverlay.vue').then(({ default: ScreenshotOverlay }) => {
const app = createApp(ScreenshotOverlay)
app.mount('#app')
})
} else if (winHash === '#screenshot-editor') {
logger.info(`截图编辑器窗口启动: ${winHash}`)
void import('./modules/screenshot/ScreenshotEditor.vue').then(({ default: ScreenshotEditor }) => {
const app = createApp(ScreenshotEditor)
// #screenshot-overlay 带窗口号参数(多屏),按前缀匹配;其余精确匹配
const matched = standaloneWindowApps.find(([hash]) =>
hash === '#screenshot-overlay' ? winHash.startsWith(hash) : winHash === hash
)
if (matched) {
const [, label, loader] = matched
logger.info(`${label}窗口启动: ${winHash}`)
void loader().then(({ default: Comp }) => {
const app = createApp(Comp)
app.mount('#app')
})
} else {
+5 -5
View File
@@ -6,7 +6,7 @@ import {
} from '@lucide/vue'
import { toast } from 'vue-sonner'
import { useClipboardStore, type ClipboardItem, type ClipboardKind, type ClipboardItemDetail } from '@/stores/clipboardStore'
import { useModuleTabs } from '@/lib/useModuleTabs'
import { useModuleTabs } from '@/lib/use-module-tabs'
import { useModuleTabsStore } from '@/stores/moduleTabsStore'
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
import { ScrollArea } from '@/components/ui/scroll-area'
@@ -249,14 +249,14 @@ const handleClear = async () => {
toast.success('已清空历史')
}
// 显示辅助
const kindIcon = (k: ClipboardKind) => {
// 显示辅助kind 来自 bindings 生成的 string,按字符串比较)
const kindIcon = (k: string) => {
if (k === 'text') return FileText
if (k === 'image') return ImageIcon
return Files
}
const kindLabel = (k: ClipboardKind) => (k === 'text' ? '文本' : k === 'image' ? '图片' : '文件')
const kindBadgeClass = (k: ClipboardKind) =>
const kindLabel = (k: string) => (k === 'text' ? '文本' : k === 'image' ? '图片' : '文件')
const kindBadgeClass = (k: string) =>
k === 'text'
? 'border-blue-500/40 bg-blue-500/10 text-blue-600 dark:text-blue-400'
: k === 'image'
+32 -34
View File
@@ -1,9 +1,11 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, nextTick, watch } from 'vue'
import { invoke } from '@tauri-apps/api/core'
import { listen, type UnlistenFn } from '@tauri-apps/api/event'
import { getCurrentWindow } from '@tauri-apps/api/window'
import { EVENTS, STORAGE_KEYS } from '@/lib/constants'
import { Effect, EffectState } from '@tauri-apps/api/window'
// Rust 端通过 tauri-specta 生成的命令绑定(bindings.ts
import { commands } from '@/lib/bindings'
import {
ClipboardList, Pin, PinOff, Trash2, Search, Image as ImageIcon,
FileText, Files, Loader2,
@@ -14,21 +16,9 @@ import {
Pagination, PaginationContent, PaginationItem, PaginationEllipsis,
} from '@/components/ui/pagination'
// ===== 与 Rust 端对应的数据结构(camelCase =====
type ClipboardKind = 'text' | 'image' | 'files'
interface ClipboardItem {
id: number
kind: ClipboardKind
preview: string
size: number
pinned: boolean
pinnedOrder: number | null
createdAt: number
}
interface HistoryPage {
items: ClipboardItem[]
total: number
}
// ===== 与 Rust 端对应的数据结构(bindings 提供,camelCase =====
// kind 为 bindings 生成的 string,前端按字符串比较即可
import type { ClipboardItem, HistoryPage } from '@/lib/bindings'
// ===== 状态 =====
const items = ref<ClipboardItem[]>([])
@@ -45,27 +35,35 @@ let searchTimer: ReturnType<typeof setTimeout> | null = null
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / PAGE_SIZE)))
// ===== 数据加载 =====
/** 加载请求序号:翻页/搜索快速操作时丢弃过期请求结果,避免旧请求覆盖新结果 */
let loadSeq = 0
async function loadData() {
const seq = ++loadSeq
loading.value = true
try {
const q = searchQuery.value.trim()
const offset = (currentPage.value - 1) * PAGE_SIZE
let res: HistoryPage
if (q) {
res = await invoke<HistoryPage>('clipboard_search', { query: q, limit: PAGE_SIZE, offset })
res = await commands.clipboardSearch(q, PAGE_SIZE, offset)
} else {
res = await invoke<HistoryPage>('clipboard_get_history', { limit: PAGE_SIZE, offset, kind: 'all' })
res = await commands.clipboardGetHistory(PAGE_SIZE, offset, 'all')
}
if (seq !== loadSeq) return // 过期请求丢弃
items.value = res.items
total.value = res.total
selectedIndex.value = 0
} catch (e) {
if (seq !== loadSeq) return
console.error('[clipboard-popup] 加载失败:', e)
} finally {
loading.value = false
// 仅最新请求可结束 loading,避免旧请求提前清除新请求的加载态
if (seq === loadSeq) loading.value = false
}
if (seq === loadSeq) {
await nextTick()
scrollSelectedIntoView()
}
await nextTick()
scrollSelectedIntoView()
}
async function gotoPage(p: number) {
@@ -84,9 +82,9 @@ watch(searchQuery, () => {
/// 选中条目 → 写回剪贴板 → 隐藏窗口 → 模拟 Ctrl+V 粘贴到原窗口
async function selectAndPaste(item: ClipboardItem) {
try {
await invoke('clipboard_copy_back', { id: item.id })
await commands.clipboardCopyBack(item.id)
// paste_to_target 会先隐藏窗口,再延迟模拟 Ctrl+V
await invoke('clipboard_paste_to_target')
await commands.clipboardPasteToTarget()
} catch (e) {
console.error('[clipboard-popup] 粘贴失败:', e)
// 失败时至少隐藏窗口
@@ -97,7 +95,7 @@ async function selectAndPaste(item: ClipboardItem) {
async function togglePin(item: ClipboardItem, ev: Event) {
ev.stopPropagation()
try {
await invoke('clipboard_set_pinned', { id: item.id, pinned: !item.pinned })
await commands.clipboardSetPinned(item.id, !item.pinned)
await loadData()
} catch (e) {
console.error('[clipboard-popup] 固定失败:', e)
@@ -107,7 +105,7 @@ async function togglePin(item: ClipboardItem, ev: Event) {
async function deleteItem(item: ClipboardItem, ev: Event) {
ev.stopPropagation()
try {
await invoke('clipboard_delete', { id: item.id })
await commands.clipboardDelete(item.id)
items.value = items.value.filter((i) => i.id !== item.id)
} catch (e) {
console.error('[clipboard-popup] 删除失败:', e)
@@ -116,7 +114,7 @@ async function deleteItem(item: ClipboardItem, ev: Event) {
async function hideWindow() {
try {
await invoke('clipboard_hide_popup')
await commands.clipboardHidePopup()
} catch {
/* 忽略 */
}
@@ -153,14 +151,14 @@ function scrollSelectedIntoView() {
})
}
// ===== 显示辅助 =====
const kindIcon = (k: ClipboardKind) => {
// ===== 显示辅助kind 为 bindings 生成的 string,按字符串比较) =====
const kindIcon = (k: string) => {
if (k === 'text') return FileText
if (k === 'image') return ImageIcon
return Files
}
const kindLabel = (k: ClipboardKind) => (k === 'text' ? '文本' : k === 'image' ? '图片' : '文件')
const kindBadgeClass = (k: ClipboardKind) =>
const kindLabel = (k: string) => (k === 'text' ? '文本' : k === 'image' ? '图片' : '文件')
const kindBadgeClass = (k: string) =>
k === 'text'
? 'badge-text'
: k === 'image'
@@ -205,7 +203,7 @@ async function onItemHover(idx: number, item: ClipboardItem) {
try {
let src = imageCache.get(item.id)
if (!src) {
const detail = await invoke<{ imageBase64: string | null } | null>('clipboard_get_item', { id: item.id })
const detail = await commands.clipboardGetItem(item.id)
if (detail?.imageBase64) {
src = buildImageDataUrl(detail.imageBase64)
imageCache.set(item.id, src)
@@ -237,7 +235,7 @@ function onItemLeave() {
/** 从 localStorage 读取主应用的主题设置 */
function readMainTheme(): { theme: string; effect: string } {
try {
const raw = localStorage.getItem('thing_app_settings')
const raw = localStorage.getItem(STORAGE_KEYS.appSettings)
if (raw) {
const s = JSON.parse(raw)
return {
@@ -330,7 +328,7 @@ onMounted(async () => {
unlistenFns.push(() => mq.removeEventListener('change', onThemeChange))
// 监听弹窗显示事件:每次显示时重新同步主题 + 刷新数据
unlistenFns.push(await listen('clipboard-popup-show', async () => {
unlistenFns.push(await listen(EVENTS.clipboardPopupShow, async () => {
// 主应用可能切换了主题,每次显示前重新应用
await applyTheme()
searchQuery.value = ''
@@ -351,7 +349,7 @@ onMounted(async () => {
// 主题和数据都就绪后,调用 Rust 端显示窗口
try {
await invoke('clipboard_show_window')
await commands.clipboardShowWindow()
} catch {
/* 忽略 */
}
+56 -40
View File
@@ -9,10 +9,11 @@ import {
} from '@lucide/vue'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { toast } from 'vue-sonner'
import { invoke } from '@tauri-apps/api/core'
import { open as openDialog } from '@tauri-apps/plugin-dialog'
// Rust 端通过 tauri-specta 生成的命令绑定(bindings.ts
import { commands } from '@/lib/bindings'
import { useDownloaderStore, type DownloadTask, type TaskStatus, type CheckUrlResult } from '@/stores/downloaderStore'
import { useModuleTabs } from '@/lib/useModuleTabs'
import { useModuleTabs } from '@/lib/use-module-tabs'
import { useModuleTabsStore } from '@/stores/moduleTabsStore'
import { createLogger } from '@/lib/logger'
import { pendingNewDownload } from '@/lib/trayEvents'
@@ -218,13 +219,21 @@ const onRemoveOpenChange = (open: boolean) => {
}
// ===== 任务详情弹窗 =====
const detailDialogState = ref<{ open: boolean; task: DownloadTask | null }>({
// 仅存任务 id,通过 computed 实时从 store.tasks 取最新对象,
// 保证弹窗内的进度/速度/状态随下载进度事件实时刷新
const detailDialogState = ref<{ open: boolean; taskId: string | null }>({
open: false,
task: null
taskId: null
})
const detailTask = computed<DownloadTask | null>(() => {
const id = detailDialogState.value.taskId
if (!id) return null
return store.tasks.find((t) => t.id === id) ?? null
})
const handleShowDetail = (task: DownloadTask) => {
detailDialogState.value = { open: true, task }
detailDialogState.value = { open: true, taskId: task.id }
}
const handleCopyText = async (text: string, label: string) => {
@@ -498,7 +507,7 @@ const handleDialogSave = async () => {
const EXTENSION_STORE_URL = 'https://chromewebstore.google.com/'
const handleInstallExtensionOnline = async () => {
try {
await invoke('downloader_open_url', { url: EXTENSION_STORE_URL })
await commands.downloaderOpenUrl(EXTENSION_STORE_URL)
} catch (e) {
try {
await navigator.clipboard.writeText(EXTENSION_STORE_URL)
@@ -537,6 +546,13 @@ onUnmounted(() => {
const allTasks = computed<DownloadTask[]>(() => store.tasks)
// 状态栏计数:单次遍历统计各状态任务数(替代模板内 4 次 filter 全量扫描)
const statusCounts = computed(() => {
const counts: Record<TaskStatus, number> = { queued: 0, active: 0, paused: 0, complete: 0, error: 0 }
for (const t of allTasks.value) counts[t.status]++
return counts
})
// 状态筛选
const filteredByStatus = computed<DownloadTask[]>(() => {
if (statusFilter.value === 'all') return allTasks.value
@@ -635,19 +651,19 @@ const toggleSortOrder = () => {
<div v-if="running" class="flex items-center gap-2 text-xs">
<Badge variant="secondary" class="gap-1">
<Download class="h-3 w-3" />
下载中 {{ allTasks.filter(t => t.status === 'active').length }}
下载中 {{ statusCounts.active }}
</Badge>
<Badge variant="secondary" class="gap-1">
<Clock class="h-3 w-3" />
等待 {{ allTasks.filter(t => t.status === 'queued').length }}
等待 {{ statusCounts.queued }}
</Badge>
<Badge v-if="allTasks.filter(t => t.status === 'paused').length > 0" variant="secondary" class="gap-1">
<Badge v-if="statusCounts.paused > 0" variant="secondary" class="gap-1">
<Pause class="h-3 w-3" />
已暂停 {{ allTasks.filter(t => t.status === 'paused').length }}
已暂停 {{ statusCounts.paused }}
</Badge>
<Badge variant="secondary" class="gap-1">
<Check class="h-3 w-3" />
已完成 {{ allTasks.filter(t => t.status === 'complete').length }}
已完成 {{ statusCounts.complete }}
</Badge>
</div>
</div>
@@ -1457,21 +1473,21 @@ const toggleSortOrder = () => {
任务详情
</DialogTitle>
<DialogDescription class="text-xs">
任务 ID{{ detailDialogState.task?.id }}
任务 ID{{ detailTask?.id }}
</DialogDescription>
</DialogHeader>
<ScrollArea class="max-h-[55vh] pr-3">
<div v-if="detailDialogState.task" class="flex flex-col gap-3 py-2 text-sm">
<div v-if="detailTask" class="flex flex-col gap-3 py-2 text-sm">
<!-- 文件名 -->
<div class="flex items-start justify-between gap-2">
<div class="flex flex-col gap-0.5 min-w-0 flex-1">
<span class="text-xs text-muted-foreground">文件名</span>
<span class="font-medium break-all">{{ detailDialogState.task.filename }}</span>
<span class="font-medium break-all">{{ detailTask.filename }}</span>
</div>
<Tooltip>
<TooltipTrigger as-child>
<Button size="icon" variant="ghost" class="h-7 w-7 shrink-0" @click="handleCopyText(detailDialogState.task.filename, '文件名')">
<Button size="icon" variant="ghost" class="h-7 w-7 shrink-0" @click="handleCopyText(detailTask.filename, '文件名')">
<Copy class="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
@@ -1484,9 +1500,9 @@ const toggleSortOrder = () => {
<!-- 状态 -->
<div class="flex items-center justify-between">
<span class="text-xs text-muted-foreground">状态</span>
<Badge :variant="getTaskStatusBadge(detailDialogState.task).variant" class="gap-1">
<component :is="getTaskStatusBadge(detailDialogState.task).icon" class="h-3 w-3" />
{{ getTaskStatusBadge(detailDialogState.task).text }}
<Badge :variant="getTaskStatusBadge(detailTask).variant" class="gap-1">
<component :is="getTaskStatusBadge(detailTask).icon" class="h-3 w-3" />
{{ getTaskStatusBadge(detailTask).text }}
</Badge>
</div>
@@ -1494,11 +1510,11 @@ const toggleSortOrder = () => {
<div class="flex items-start justify-between gap-2">
<div class="flex flex-col gap-0.5 min-w-0 flex-1">
<span class="text-xs text-muted-foreground">下载链接</span>
<span class="font-mono text-xs break-all">{{ detailDialogState.task.url }}</span>
<span class="font-mono text-xs break-all">{{ detailTask.url }}</span>
</div>
<Tooltip>
<TooltipTrigger as-child>
<Button size="icon" variant="ghost" class="h-7 w-7 shrink-0" @click="handleCopyText(detailDialogState.task.url, '下载链接')">
<Button size="icon" variant="ghost" class="h-7 w-7 shrink-0" @click="handleCopyText(detailTask.url, '下载链接')">
<Copy class="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
@@ -1514,15 +1530,15 @@ const toggleSortOrder = () => {
<span class="text-xs text-muted-foreground">保存位置</span>
<Tooltip>
<TooltipTrigger as-child>
<span class="font-mono text-xs break-all cursor-default">{{ detailDialogState.task.dir }}</span>
<span class="font-mono text-xs break-all cursor-default">{{ detailTask.dir }}</span>
</TooltipTrigger>
<TooltipContent class="max-w-[400px] break-all">{{ detailDialogState.task.dir }}</TooltipContent>
<TooltipContent class="max-w-[400px] break-all">{{ detailTask.dir }}</TooltipContent>
</Tooltip>
<span class="font-mono text-xs text-muted-foreground break-all">{{ detailDialogState.task.filename }}</span>
<span class="font-mono text-xs text-muted-foreground break-all">{{ detailTask.filename }}</span>
</div>
<Tooltip>
<TooltipTrigger as-child>
<Button size="icon" variant="ghost" class="h-7 w-7 shrink-0" @click="handleCopyText(detailDialogState.task.dir + '\\' + detailDialogState.task.filename, '完整路径')">
<Button size="icon" variant="ghost" class="h-7 w-7 shrink-0" @click="handleCopyText(detailTask.dir + '\\' + detailTask.filename, '完整路径')">
<Copy class="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
@@ -1536,19 +1552,19 @@ const toggleSortOrder = () => {
<div class="grid grid-cols-2 gap-3">
<div class="flex flex-col gap-0.5">
<span class="text-xs text-muted-foreground">文件总大小</span>
<span>{{ formatSize(detailDialogState.task.totalSize) }}</span>
<span>{{ formatSize(detailTask.totalSize) }}</span>
</div>
<div class="flex flex-col gap-0.5">
<span class="text-xs text-muted-foreground">已下载</span>
<span>{{ formatSize(detailDialogState.task.completedSize) }}</span>
<span>{{ formatSize(detailTask.completedSize) }}</span>
</div>
<div class="flex flex-col gap-0.5">
<span class="text-xs text-muted-foreground">下载进度</span>
<span>{{ getProgress(detailDialogState.task) }}%</span>
<span>{{ getProgress(detailTask) }}%</span>
</div>
<div class="flex flex-col gap-0.5">
<span class="text-xs text-muted-foreground">当前速度</span>
<span v-if="detailDialogState.task.status === 'active'">{{ formatSpeed(detailDialogState.task.speed) }}</span>
<span v-if="detailTask.status === 'active'">{{ formatSpeed(detailTask.speed) }}</span>
<span v-else>-</span>
</div>
</div>
@@ -1559,41 +1575,41 @@ const toggleSortOrder = () => {
<div class="grid grid-cols-2 gap-3">
<div class="flex flex-col gap-0.5">
<span class="text-xs text-muted-foreground">断点续传</span>
<span>{{ detailDialogState.task.supportsResume ? '支持' : '不支持' }}</span>
<span>{{ detailTask.supportsResume ? '支持' : '不支持' }}</span>
</div>
<div class="flex flex-col gap-0.5">
<span class="text-xs text-muted-foreground">连接数 / 分片数</span>
<span>{{ detailDialogState.task.segments.length }}</span>
<span>{{ detailTask.segments.length }}</span>
</div>
<div class="flex flex-col gap-0.5">
<span class="text-xs text-muted-foreground">创建时间</span>
<span>{{ formatTime(detailDialogState.task.createdAt) }}</span>
<span>{{ formatTime(detailTask.createdAt) }}</span>
</div>
<div class="flex flex-col gap-0.5">
<span class="text-xs text-muted-foreground">剩余时间</span>
<span v-if="detailDialogState.task.status === 'active' && detailDialogState.task.speed > 0">
{{ formatEta(getEta(detailDialogState.task)) }}
<span v-if="detailTask.status === 'active' && detailTask.speed > 0">
{{ formatEta(getEta(detailTask)) }}
</span>
<span v-else>-</span>
</div>
</div>
<!-- 错误信息 -->
<template v-if="detailDialogState.task.error">
<template v-if="detailTask.error">
<Separator />
<div class="flex flex-col gap-1">
<span class="text-xs text-muted-foreground">错误信息</span>
<span class="text-sm text-destructive break-all">{{ detailDialogState.task.error }}</span>
<span class="text-sm text-destructive break-all">{{ detailTask.error }}</span>
</div>
</template>
<!-- 自定义请求头 -->
<template v-if="detailDialogState.task.headers && Object.keys(detailDialogState.task.headers).length > 0">
<template v-if="detailTask.headers && Object.keys(detailTask.headers).length > 0">
<Separator />
<div class="flex flex-col gap-1">
<span class="text-xs text-muted-foreground">自定义请求头</span>
<div class="rounded-md bg-muted p-2 text-xs font-mono space-y-0.5">
<div v-for="(value, key) in detailDialogState.task.headers" :key="key" class="flex gap-2">
<div v-for="(value, key) in detailTask.headers" :key="key" class="flex gap-2">
<span class="text-muted-foreground shrink-0">{{ key }}:</span>
<span class="break-all">{{ value }}</span>
</div>
@@ -1602,12 +1618,12 @@ const toggleSortOrder = () => {
</template>
<!-- 分段详情 -->
<template v-if="detailDialogState.task.segments.length > 1">
<template v-if="detailTask.segments.length > 1">
<Separator />
<div class="flex flex-col gap-2">
<span class="text-xs text-muted-foreground">分段详情</span>
<div class="flex flex-col gap-1.5">
<div v-for="(seg, i) in detailDialogState.task.segments" :key="i" class="flex items-center gap-2 text-xs">
<div v-for="(seg, i) in detailTask.segments" :key="i" class="flex items-center gap-2 text-xs">
<span class="w-8 text-muted-foreground shrink-0">#{{ i }}</span>
<div class="flex-1 min-w-0">
<Progress :model-value="segmentProgress(seg)" class="h-1.5" />
@@ -1626,7 +1642,7 @@ const toggleSortOrder = () => {
<DialogClose as-child>
<Button variant="outline">关闭</Button>
</DialogClose>
<Button v-if="detailDialogState.task?.dir" variant="outline" @click="handleOpenDir(detailDialogState.task)">
<Button v-if="detailTask?.dir" variant="outline" @click="handleOpenDir(detailTask)">
<FolderOpen class="h-4 w-4" />
打开目录
</Button>
+2 -2
View File
@@ -8,7 +8,7 @@ import { moduleConfig as screenshot } from './screenshot'
import { moduleConfig as monitor } from './monitor'
import { moduleConfig as downloader } from './downloader'
import { moduleConfig as quickpanel } from './quickpanel'
import { moduleConfig as general } from './general'
import { moduleConfig as settings } from './settings'
const allModules: ModuleConfig[] = [
proxy,
@@ -17,7 +17,7 @@ const allModules: ModuleConfig[] = [
monitor,
downloader,
quickpanel,
general
settings
]
// 启动时注册所有模块
+41 -659
View File
@@ -12,11 +12,19 @@ import { toast } from 'vue-sonner'
import { VueDraggable } from 'vue-draggable-plus'
import { appDataDir } from '@tauri-apps/api/path'
import { revealItemInDir } from '@tauri-apps/plugin-opener'
import { WebviewWindow } from '@tauri-apps/api/webviewWindow'
import { emit, listen, type UnlistenFn } from '@tauri-apps/api/event'
import { currentMonitor, LogicalPosition, LogicalSize } from '@tauri-apps/api/window'
import { useMonitorStore, type SensorEntry, type SensorGroup, type ConnectionState } from '@/stores/monitorStore'
import { useModuleTabs } from '@/lib/useModuleTabs'
import {
useMonitorStore,
type SensorEntry,
type SensorGroup,
type ConnectionState,
type OsdConfig,
type OsdItem,
type ColorTheme,
type AlertConfig,
DEFAULT_COLOR_THEME,
} from '@/stores/monitorStore'
import { useModuleTabs } from '@/lib/use-module-tabs'
import { fmt, tempColor, loadColor, fmtSpeed, typeLabel, groupDisplayName, groupIcon } from './format'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
@@ -185,90 +193,6 @@ const storageDrives = computed<StorageDrive[]>(() => {
const downSpeed = computed(() => fmtSpeed(store.networkSpeed?.downloadBps ?? null))
const upSpeed = computed(() => fmtSpeed(store.networkSpeed?.uploadBps ?? null))
// ===== 工具函数 =====
/** 格式化数值:整数型指标(负载/温度)保留 0 位,浮点型(电压/功率)保留 2 位 */
function fmt(v: number | null, digits = 1): string {
if (v == null || !isFinite(v)) return '--'
return v.toFixed(digits)
}
/** 温度颜色:绿(<50) → 黄(<70) → 橙(<85) → 红(>=85) */
function tempColor(t: number | null): string {
if (t == null) return 'text-muted-foreground'
if (t < 50) return 'text-emerald-500'
if (t < 70) return 'text-yellow-500'
if (t < 85) return 'text-orange-500'
return 'text-red-500'
}
/** 负载颜色:蓝(<50) → 紫(<80) → 红(>=80) */
function loadColor(v: number | null): string {
if (v == null) return 'text-muted-foreground'
if (v < 50) return 'text-sky-500'
if (v < 80) return 'text-violet-500'
return 'text-red-500'
}
/** 格式化网速(bytes/s → 自适应 KB/s 或 MB/s */
function fmtSpeed(bytesPerSec: number | null): { value: string; unit: string } {
if (bytesPerSec == null || !isFinite(bytesPerSec)) return { value: '--', unit: '' }
if (bytesPerSec >= 1_048_576) return { value: (bytesPerSec / 1_048_576).toFixed(2), unit: 'MB/s' }
if (bytesPerSec >= 1024) return { value: (bytesPerSec / 1024).toFixed(1), unit: 'KB/s' }
return { value: bytesPerSec.toFixed(0), unit: 'B/s' }
}
/** 传感器类型 → 中文标签 */
const typeLabels: Record<string, string> = {
temperature: '温度',
load: '负载',
power: '功率',
voltage: '电压',
fan: '风扇',
clock: '时钟',
data: '容量',
smalldata: '容量',
throughput: '吞吐',
level: '等级',
control: '控制',
frequency: '频率',
factor: '因子',
timespan: '时长',
energy: '能量',
noise: '噪声',
conductivity: '电导率',
humidity: '湿度',
flow: '流量',
}
function typeLabel(t: string): string {
return typeLabels[t] ?? t
}
/** 分组 id → 显示名 + 图标组件 */
const groupMeta: Record<string, { name: string; icon: typeof Cpu }> = {
cpu: { name: 'CPU', icon: Cpu },
memory: { name: '内存', icon: MemoryStick },
gpuintel: { name: 'GPU', icon: Gauge },
gpuamd: { name: 'GPU', icon: Gauge },
gpunvidia: { name: 'GPU', icon: Gauge },
storage: { name: '存储', icon: HardDrive },
motherboard: { name: '主板', icon: Activity },
superio: { name: '超级 IO', icon: Activity },
embeddedcontroller: { name: '嵌入式控制器', icon: Activity },
battery: { name: '电池', icon: Activity },
network: { name: '网络', icon: Activity },
psu: { name: '电源', icon: Zap },
}
function groupDisplayName(id: string, fallback: string): string {
return groupMeta[id]?.name ?? fallback
}
function groupIcon(id: string): typeof Cpu {
return groupMeta[id]?.icon ?? Activity
}
// ===== 连接状态徽章 =====
const stateMeta: Record<ConnectionState, { text: string; class: string }> = {
idle: { text: '未启动', class: 'bg-muted text-muted-foreground' },
@@ -281,14 +205,19 @@ const stateMeta: Record<ConnectionState, { text: string; class: string }> = {
// ===== 分组列表(详细页用) =====
const groups = computed<SensorGroup[]>(() => store.snapshot?.groups ?? [])
/** 按 hardwareName 子分组,再按 type 二级分组(详细页用) */
/** 按 hardwareName 子分组,再按 type 二级分组(详细页用)
* 分组结果只依赖传感器的静态元数据(硬件名/类型),与数值变化无关;
* 以传感器数组引用为键缓存(WeakMap),避免每次渲染对数百传感器全量重算 */
const sensorGroupCache = new WeakMap<SensorEntry[], { hardware: string; byType: { type: string; items: SensorEntry[] }[] }[]>()
function groupSensors(sensors: SensorEntry[]): { hardware: string; byType: { type: string; items: SensorEntry[] }[] }[] {
const cached = sensorGroupCache.get(sensors)
if (cached) return cached
const byHw = new Map<string, SensorEntry[]>()
for (const s of sensors) {
if (!byHw.has(s.hardwareName)) byHw.set(s.hardwareName, [])
byHw.get(s.hardwareName)!.push(s)
}
return Array.from(byHw.entries()).map(([hw, items]) => {
const result = Array.from(byHw.entries()).map(([hw, items]) => {
const byType = new Map<string, SensorEntry[]>()
for (const s of items) {
if (!byType.has(s.type)) byType.set(s.type, [])
@@ -299,6 +228,8 @@ function groupSensors(sensors: SensorEntry[]): { hardware: string; byType: { typ
byType: Array.from(byType.entries()).map(([type, list]) => ({ type, items: list })),
}
})
sensorGroupCache.set(sensors, result)
return result
}
// ===== Accordion 折叠状态 =====
@@ -455,203 +386,12 @@ async function handleSaveConfig() {
}
}
// ===== OSD 显示配置 =====
// OSDOn-Screen Display)配置:控制传感器数据在桌面悬浮窗中的显示
// 配置持久化到 localStorage,由独立 OsdWindow.vue 消费。
/** OSD 显示项:从可用传感器中选取并排序 */
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'
}
/** 颜色主题:按硬件/传感器类型着色(类似小飞机风格) */
interface ColorTheme {
/** 按 groupId 着色:cpu/gpu/memory/storage/... */
hardware: Record<string, string>
/** 按 sensor type 着色:temperature/load/power/... */
sensor: Record<string, string>
}
/** 警告色配置:阈值百分比 + 警告/严重颜色 */
interface AlertConfig {
/** 警告色开关 */
enabled: boolean
/** 警告阈值百分比(达到即变警告色,如 80) */
warnThreshold: number
/** 严重阈值百分比(达到即变严重色,如 90) */
criticalThreshold: number
/** 警告色(淡红,hex */
warnColor: string
/** 严重色(大红,hex */
criticalColor: string
/** 各硬件类型的最大值(用于将温度等非百分比值转为百分比)
* CPU 温度墙默认 100GPU 默认 85 */
maxValues: Record<string, number>
}
/** OSD 配置结构 */
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
}
const OSD_STORAGE_KEY = 'thing_monitor_osd_config'
const OSD_CONFIG_VERSION = 11
/** 默认颜色主题(小飞机风格:不同硬件不同颜色,不同传感器不同颜色) */
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;百分比类直接用值 */
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 写入失败 */
}
}
const osdConfig = ref<OsdConfig>(loadOsdConfig())
// ===== OSD 配置(由 monitorStore 统一管理,组件仅做 UI 展示与修改) =====
// 类型/默认值/持久化/窗口管理均在 monitorStoreApp 启动时由 store.initOsd() 显式初始化
const osdConfig = computed<OsdConfig>(() => store.osdConfig)
// 保存调用点保持简洁的薄包装(内部转发到 store 的持久化函数)
const saveOsdConfig = (cfg: OsdConfig) => store.saveOsdConfig(cfg)
const saveOsdConfigDebounced = (cfg: OsdConfig) => store.saveOsdConfigDebounced(cfg)
/** 传感器名称中英文字典(覆盖常见 LHB 传感器名 + 硬件名) */
const SENSOR_NAME_ZH: Record<string, string> = {
@@ -1056,7 +796,7 @@ const availableSensors = computed<AvailableSensor[]>(() => {
for (const g of store.snapshot?.groups ?? []) {
// 悬浮窗不显示存储分组(硬盘容量/温度等已在主界面监控,OSD 场景无需)
if (g.id === 'storage') continue
const groupName = groupMeta[g.id]?.name ?? g.name
const groupName = groupDisplayName(g.id, g.name)
for (const s of g.sensors) {
const key = `${g.id}/${s.hardwareName}/${s.name}/${s.type}`.replace(/\s+/g, '_').toLowerCase()
list.push({
@@ -1188,10 +928,10 @@ function removeOsdItem(key: string) {
}
}
/** OSD 配置项变更时自动保存 */
/** OSD 配置项变更时自动保存(防抖:滑块拖动期间不逐帧写盘) */
function updateOsdConfig(field: keyof OsdConfig, value: unknown) {
;(osdConfig.value as Record<string, unknown>)[field] = value
saveOsdConfig(osdConfig.value)
;(osdConfig.value as unknown as Record<string, unknown>)[field] = value
saveOsdConfigDebounced(osdConfig.value)
}
/** 解析背景色字符串为 hex + alpha0-100 */
@@ -1272,276 +1012,7 @@ function osdItemColor(item: OsdItem): string {
return withOpacity(osdConfig.value.fontColor, opacity)
}
// ===== OSD 窗口管理(实际创建/隐藏 Tauri 窗口并推送数据 =====
const OSD_OVERLAY_LABEL = 'osd-overlay'
/** 抑制百分比 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}`
}
/** 推送当前 OSD 状态到所有 OSD 窗口 */
async function pushOsdState() {
const payload = {
config: osdConfig.value,
snapshot: store.snapshot,
networkSpeed: store.networkSpeed,
}
try {
await emit('osd-state-update', payload)
} catch (e) {
console.error('[OSD] 推送状态失败:', e)
}
}
/** 根据百分比位置计算窗口坐标 */
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) => {
console.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[] = []
async function setupOsdEventListeners() {
// 守卫:避免重复注册(MonitorModule 可能因预渲染多次挂载)
if (osdEventUnlisteners.length) return
const { listen: tauriListen } = await import('@tauri-apps/api/event')
// 监听悬浮窗上报的实际内容尺寸,按内容调整窗口大小(替代不准确的估算)
// 仅当尺寸变化超过 1px 时才 setSize,避免无意义的频繁调用
let lastW = 0
let lastH = 0
const unlisten = await tauriListen<{ 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)
}
// ===== OSD 窗口管理(由 store.initOsd()/ensureOverlayWindow() 等统一管理 =====
// ===== 颜色主题编辑 Dialog =====
const colorThemeDialogOpen = ref(false)
@@ -1570,7 +1041,7 @@ function updateAlertConfig(field: keyof AlertConfig | 'maxValues', value: unknow
if (field === 'maxValues' && maxKey) {
osdConfig.value.alert.maxValues[maxKey] = Number(value)
} else {
;(osdConfig.value.alert as Record<string, unknown>)[field] = value
;(osdConfig.value.alert as unknown as Record<string, unknown>)[field] = value
}
saveOsdConfig(osdConfig.value)
}
@@ -1622,43 +1093,17 @@ onMounted(async () => {
try { appDataPath.value = await appDataDir() } catch { /* 忽略 */ }
store.init()
// 注册 OSD 窗口事件监听
setupOsdEventListeners().catch(e => console.error('[OSD] 事件监听注册失败:', e))
// 初始化悬浮窗(如果开关已开启)
if (osdConfig.value.overlayEnabled) {
ensureOverlayWindow().catch(e => console.error('[OSD] 初始化悬浮窗失败:', e))
}
// 监听托盘菜单"切换 OSD"事件
try {
osdEventUnlisteners.push(
await listen('tray:toggle-osd', () => {
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 => console.error('[OSD] 托盘开启悬浮窗失败:', e))
}
} else {
hideOverlayWindow().catch(e => console.error('[OSD] 托盘关闭悬浮窗失败:', e))
}
})
)
} catch (e) {
console.error('[OSD] 注册 tray:toggle-osd 监听失败:', e)
}
// OSD 配置/窗口/事件监听已迁移至 monitorStore,由 initOsd() 统一初始化
// (幂等:App 启动时已调用过则跳过,模块挂载时再次调用安全)
store.initOsd()
})
onUnmounted(() => {
// 不 dispose storeSSE 订阅保持,确保切走监控模块后 OSD 仍有数据
// store.subscribe() 内部有守卫(if unlistenFns.length return),重复 init 不会重复订阅
// 不关闭 OSD 窗口:切走监控模块时保持 OSD 显示,由模块禁用或应用退出时统一清理
// 仅清理组件级 OSD 事件监听(下次挂载会重新注册setupOsdEventListeners 有守卫
osdEventUnlisteners.forEach(fn => fn())
osdEventUnlisteners = []
// 释放 OSD 事件监听(App 启动或模块重新挂载会重新注册)
store.disposeOsd()
})
// 当 Kernel 从未就绪变成就绪时,主动拉一次快照填充 UI
@@ -1668,71 +1113,8 @@ watch(() => store.status?.ready, (ready, prev) => {
}
})
// ===== OSD 开关变化时创建/隐藏悬浮窗 =====
watch(() => osdConfig.value.overlayEnabled, (enabled) => {
if (enabled) {
// 开启时若显示项为空则不创建窗口
if (osdConfig.value.overlayItems.length === 0) return
ensureOverlayWindow().catch(e => console.error('[OSD] 创建悬浮窗失败:', e))
} else {
hideOverlayWindow().catch(e => console.error('[OSD] 隐藏悬浮窗失败:', e))
}
})
// ===== 显示项变化时:无项则隐藏窗口,有项且开关开则确保窗口存在 =====
watch(() => osdConfig.value.overlayItems.length, (len) => {
if (!osdConfig.value.overlayEnabled) return
if (len === 0) {
hideOverlayWindow().catch(e => console.error('[OSD] 显示项为空,隐藏悬浮窗失败:', e))
} else {
ensureOverlayWindow().catch(e => console.error('[OSD] 显示项恢复,创建悬浮窗失败:', e))
}
})
// ===== 位置百分比变化时重新定位窗口(清除已保存像素位置) =====
// 拖动 OSD 触发的 onMoved 会反算更新百分比,此时 suppressPercentWatch=true 跳过,避免循环
watch(() => [osdConfig.value.positionXPct, osdConfig.value.positionYPct], () => {
if (suppressPercentWatch) return
// 清除保存的像素位置,让窗口使用百分比重新定位
osdConfig.value.overlayX = null
osdConfig.value.overlayY = null
saveOsdConfig(osdConfig.value)
// 如果窗口已存在,重新定位
resetOverlayPosition().catch(() => {})
})
// ===== 数据变化时推送状态到 OSD 窗口 =====
// 快照变化(Kernel SSE 推送)→ 推送到 OSD 窗口
watch(() => store.snapshot, () => {
if (osdConfig.value.overlayEnabled) {
pushOsdState()
}
}, { deep: false })
// 网速变化 → 推送到 OSD 窗口
watch(() => store.networkSpeed, () => {
if (osdConfig.value.overlayEnabled) {
pushOsdState()
}
}, { deep: false })
// OSD 配置变化 → 推送到 OSD 窗口(位置/字体/显示项等)
watch(osdConfig, () => {
if (osdConfig.value.overlayEnabled) {
pushOsdState()
}
}, { deep: true })
// 显示项数量/布局/字号变化 → 更新悬浮窗窗口尺寸(自适应内容)
watch([
() => osdConfig.value.overlayItems.length,
() => osdConfig.value.layout,
() => osdConfig.value.fontSize,
], () => {
if (osdConfig.value.overlayEnabled) {
updateOsdWindowSize().catch(() => {})
}
})
// OSD 相关 watch(开关/显示项/位置/配置/尺寸)已由 store.initOsd() 内部统一注册,
// 与组件生命周期解耦:模块卸载后 OSD 仍能持续刷新,配置变更仍会推送。
</script>
<template>
+7 -6
View File
@@ -3,6 +3,7 @@ import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
import { listen, emit, type UnlistenFn } from '@tauri-apps/api/event'
import { getCurrentWindow } from '@tauri-apps/api/window'
import { invoke } from '@tauri-apps/api/core'
import { EVENTS, WINDOWS } from '@/lib/constants'
// ===== 数据契约(与主窗口 MonitorModule 共享,此处独立声明避免循环依赖) =====
interface OsdItem {
@@ -450,7 +451,7 @@ async function measureAndReportSize() {
const rect = bar.getBoundingClientRect()
if (rect.width === 0 || rect.height === 0) return
// 额外留 1px 余量避免边缘裁切
await emit('osd-content-size', { width: Math.ceil(rect.width) + 1, height: Math.ceil(rect.height) + 1 })
await emit(EVENTS.osdContentSize, { width: Math.ceil(rect.width) + 1, height: Math.ceil(rect.height) + 1 })
}
/** 防抖测量(数据频繁更新时合并) */
@@ -474,7 +475,7 @@ async function applyClickThrough(ignore: boolean) {
}
// 2. Rust 原生:设置 WS_EX_TRANSPARENT 扩展样式(更可靠的原生层穿透)
try {
await invoke('osd_set_click_through', { label: 'osd-overlay', enabled: ignore })
await invoke('osd_set_click_through', { label: WINDOWS.osdOverlay, enabled: ignore })
} catch (e) {
console.error('[OSD] osd_set_click_through 失败:', e)
}
@@ -483,7 +484,7 @@ async function applyClickThrough(ignore: boolean) {
// ===== 应用置顶(使用 Rust 原生命令) =====
async function applyTopmost(topmost: boolean) {
try {
await invoke('osd_set_topmost', { label: 'osd-overlay', topmost })
await invoke('osd_set_topmost', { label: WINDOWS.osdOverlay, topmost })
} catch (e) {
console.error('[OSD] 设置置顶失败:', e)
}
@@ -498,7 +499,7 @@ watch(() => config.value?.clickThrough, (ignore) => {
onMounted(async () => {
// 应用原生样式(NoActivate + ToolWindow,不获取焦点)
try {
await invoke('osd_apply_overlay_style', { label: 'osd-overlay' })
await invoke('osd_apply_overlay_style', { label: WINDOWS.osdOverlay })
} catch (e) {
console.error('[OSD] 应用原生样式失败:', e)
}
@@ -523,11 +524,11 @@ onMounted(async () => {
}))
// 监听系统 UI 覆盖事件
unlistenFns.push(await listen('osd-system-ui-active', async () => {
unlistenFns.push(await listen(EVENTS.osdSystemUiActive, async () => {
await applyTopmost(false)
}))
unlistenFns.push(await listen('osd-system-ui-inactive', async () => {
unlistenFns.push(await listen(EVENTS.osdSystemUiInactive, async () => {
await applyTopmost(true)
}))
})
+87
View File
@@ -0,0 +1,87 @@
/**
* MonitorModule 纯工具函数:数值格式化 / 颜色 / 分组元数据。
* 不依赖组件状态,可独立测试。
*/
import { Activity, Cpu, Gauge, HardDrive, MemoryStick, Zap, type LucideIcon } from '@lucide/vue'
/** 格式化数值:整数型指标(负载/温度)保留 0 位,浮点型(电压/功率)保留 2 位 */
export function fmt(v: number | null, digits = 1): string {
if (v == null || !isFinite(v)) return '--'
return v.toFixed(digits)
}
/** 温度颜色:绿(<50) → 黄(<70) → 橙(<85) → 红(>=85) */
export function tempColor(t: number | null): string {
if (t == null) return 'text-muted-foreground'
if (t < 50) return 'text-emerald-500'
if (t < 70) return 'text-yellow-500'
if (t < 85) return 'text-orange-500'
return 'text-red-500'
}
/** 负载颜色:蓝(<50) → 紫(<80) → 红(>=80) */
export function loadColor(v: number | null): string {
if (v == null) return 'text-muted-foreground'
if (v < 50) return 'text-sky-500'
if (v < 80) return 'text-violet-500'
return 'text-red-500'
}
/** 格式化网速(bytes/s → 自适应 KB/s 或 MB/s */
export function fmtSpeed(bytesPerSec: number | null): { value: string; unit: string } {
if (bytesPerSec == null || !isFinite(bytesPerSec)) return { value: '--', unit: '' }
if (bytesPerSec >= 1_048_576) return { value: (bytesPerSec / 1_048_576).toFixed(2), unit: 'MB/s' }
if (bytesPerSec >= 1024) return { value: (bytesPerSec / 1024).toFixed(1), unit: 'KB/s' }
return { value: bytesPerSec.toFixed(0), unit: 'B/s' }
}
/** 传感器类型 → 中文标签 */
const typeLabels: Record<string, string> = {
temperature: '温度',
load: '负载',
power: '功率',
voltage: '电压',
fan: '风扇',
clock: '时钟',
data: '容量',
smalldata: '容量',
throughput: '吞吐',
level: '等级',
control: '控制',
frequency: '频率',
factor: '因子',
timespan: '时长',
energy: '能量',
noise: '噪声',
conductivity: '电导率',
humidity: '湿度',
flow: '流量',
}
export function typeLabel(t: string): string {
return typeLabels[t] ?? t
}
/** 分组 id → 显示名 + 图标组件 */
const groupMeta: Record<string, { name: string; icon: LucideIcon }> = {
cpu: { name: 'CPU', icon: Cpu },
memory: { name: '内存', icon: MemoryStick },
gpuintel: { name: 'GPU', icon: Gauge },
gpuamd: { name: 'GPU', icon: Gauge },
gpunvidia: { name: 'GPU', icon: Gauge },
storage: { name: '存储', icon: HardDrive },
motherboard: { name: '主板', icon: Activity },
superio: { name: '超级 IO', icon: Activity },
embeddedcontroller: { name: '嵌入式控制器', icon: Activity },
battery: { name: '电池', icon: Activity },
network: { name: '网络', icon: Activity },
psu: { name: '电源', icon: Zap },
}
export function groupDisplayName(id: string, fallback: string): string {
return groupMeta[id]?.name ?? fallback
}
export function groupIcon(id: string): LucideIcon {
return groupMeta[id]?.icon ?? Activity
}
+2 -1
View File
@@ -47,7 +47,8 @@ export const moduleConfig: ModuleConfig = {
// 关闭 OSD 窗口(MonitorModule onUnmounted 不再自动关闭,需在禁用时手动关闭)
try {
const { WebviewWindow } = await import('@tauri-apps/api/webviewWindow')
const osd = await WebviewWindow.getByLabel('osd-overlay')
const { WINDOWS } = await import('@/lib/constants')
const osd = await WebviewWindow.getByLabel(WINDOWS.osdOverlay)
if (osd) await osd.close()
} catch {
/* 忽略 */
+53 -31
View File
@@ -10,7 +10,7 @@ import { invoke } from '@tauri-apps/api/core'
import { appDataDir } from '@tauri-apps/api/path'
import { revealItemInDir } from '@tauri-apps/plugin-opener'
import { useProxyStore, type ProxyNode } from '@/stores/proxyStore'
import { useModuleTabs } from '@/lib/useModuleTabs'
import { useModuleTabs } from '@/lib/use-module-tabs'
import { useModuleTabsStore } from '@/stores/moduleTabsStore'
import { createLogger } from '@/lib/logger'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
@@ -98,6 +98,8 @@ const autoSwitchInterval = ref(5) // 分钟
const autoSwitchTargetGroup = ref('') // 目标代理组
const autoSwitchRegion = ref('') // 空=全部,否则按地区过滤
let autoSwitchTimer: ReturnType<typeof setInterval> | null = null
/** 自动切换执行中标志(防重入:测速超时时上一轮未结束,间隔触发会重叠) */
let autoSwitchRunning = false
// 从 store.settings 同步自动切换设置
const syncAutoSwitchSettings = () => {
@@ -307,30 +309,41 @@ const loadProxiesWithError = async () => {
}
const init = async () => {
// 获取 appData 路径,用于将内核路径替换为 %APPDATA% 形式
try {
appDataPath.value = await appDataDir()
} catch {
/* 忽略 */
}
await Promise.all([store.loadSettings(), store.refreshKernel(), store.refreshStatus()])
// 同步持久化的自动切换设置
syncAutoSwitchSettings()
if (running.value) {
await store.waitForApi()
store.refreshVersion()
loadProxiesWithError()
// 若自动切换已开启,恢复定时器
if (autoSwitchEnabled.value) {
startAutoSwitch()
// 获取 appData 路径,用于将内核路径替换为 %APPDATA% 形式
try {
appDataPath.value = await appDataDir()
} catch {
/* 忽略 */
}
try {
await Promise.all([store.loadSettings(), store.refreshKernel(), store.refreshStatus()])
} catch (e) {
logger.error('代理初始化失败: ' + e)
toast.error('代理模块初始化失败', { description: String(e) })
}
// 同步持久化的自动切换设置
syncAutoSwitchSettings()
if (running.value) {
await store.waitForApi()
store.refreshVersion()
loadProxiesWithError()
// 若自动切换已开启,恢复定时器
if (autoSwitchEnabled.value) {
startAutoSwitch()
}
}
} finally {
// 无论初始化链路是否出错,都结束"加载中"占位,避免模块永久卡死
store.initialized = true
}
store.initialized = true
}
onMounted(() => {
init()
statusTimer = setInterval(async () => {
// 窗口/标签页不可见时暂停状态轮询,恢复可见后下个 tick 自动继续
if (document.hidden) return
await store.refreshStatus()
}, 3000)
})
@@ -345,17 +358,27 @@ watch(running, async (val, old) => {
await store.waitForApi()
await store.refreshVersion()
await loadProxiesWithError()
// 自动切换若已开启,mihomo 启动/重启后恢复定时器
// handleStop 会停掉旧定时器,此处统一接管启动路径,避免开关显示开但功能静默失效)
if (autoSwitchEnabled.value) {
startAutoSwitch()
}
}
})
// 切换到节点 Tab 时,加载节点列表并自动测速
// 切换到节点 Tab 时,加载节点列表并自动测速(10s 节流:快速切换 Tab 时避免重复 IPC 洪峰)
let lastAutoTestAt = 0
watch(activeTab, async (tab) => {
if (tab === 'proxies' && running.value) {
if (!Object.keys(store.proxies).length) {
await loadProxiesWithError()
}
// 自动对所有组测速一次
autoTestAllGroups()
const now = Date.now()
if (now - lastAutoTestAt > 10000) {
lastAutoTestAt = now
// 自动对所有组测速一次
autoTestAllGroups()
}
}
})
@@ -499,13 +522,14 @@ const onAutoSwitchIntervalChange = (val: unknown) => {
}
const runAutoSwitch = async () => {
const groupName = autoSwitchTargetGroup.value || mainGroupName.value
if (!groupName || !running.value) return
const nodes = filteredNodes.value
if (!nodes.length) return
toast.info('正在测试节点延迟...')
if (autoSwitchRunning) return
autoSwitchRunning = true
try {
const groupName = autoSwitchTargetGroup.value || mainGroupName.value
if (!groupName || !running.value) return
const nodes = filteredNodes.value
if (!nodes.length) return
// 使用 testDelayBatch 测速,它会更新 store.proxies[name].history
// 确保 UI 显示的延迟与选优结果一致
await store.testDelayBatch(nodes)
@@ -532,13 +556,11 @@ const runAutoSwitch = async () => {
toast.success('已自动切换到最优节点', {
description: `${best.name} (${best.delay}ms)`
})
} else {
toast.success('当前节点已是最优', {
description: `${best.name} (${best.delay}ms)`
})
}
} catch (e) {
logger.error('自动切换失败: ' + e)
} finally {
autoSwitchRunning = false
}
}
@@ -1465,7 +1487,7 @@ tabsStore.registerSave(saveSettingsForm)
</div>
<p class="text-xs text-muted-foreground truncate">{{ p.url }}</p>
<p class="text-xs text-muted-foreground">
{{ formatSize(p.size) }} · 更新于 {{ p.updatedAt }}
{{ formatSize(p.size ?? 0) }} · 更新于 {{ p.updatedAt }}
</p>
</div>
<div class="flex gap-1 shrink-0">
+5 -4
View File
@@ -1,6 +1,7 @@
import type { ModuleConfig } from '@/types/module'
import type { SearchIndexItem } from '@/stores/searchIndex'
import { invoke } from '@tauri-apps/api/core'
// Rust 端通过 tauri-specta 生成的命令绑定(bindings.ts
import { commands } from '@/lib/bindings'
const searchItems: SearchIndexItem[] = [
{
@@ -47,9 +48,9 @@ export const moduleConfig: ModuleConfig = {
onEnable: async () => {
// 若用户在代理设置中开启了"自动启动",则随模块启用而运行 mihomo
try {
const s = await invoke<{ autoStart?: boolean }>('proxy_get_settings')
const s = await commands.proxyGetSettings()
if (s.autoStart) {
await invoke('proxy_start')
await commands.proxyStart()
}
} catch {
/* 忽略:可能内核未安装 */
@@ -58,7 +59,7 @@ export const moduleConfig: ModuleConfig = {
// 禁用模块时一并关闭系统代理,避免代理已停但系统仍指向导致无法上网
onDisable: async () => {
try {
await invoke('proxy_clear_system_proxy')
await commands.proxyClearSystemProxy()
} catch {
/* 忽略:可能内核未运行 */
}
+23 -12
View File
@@ -1,11 +1,13 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, nextTick, watch } from 'vue'
import { invoke } from '@tauri-apps/api/core'
import { listen, type UnlistenFn } from '@tauri-apps/api/event'
import { getCurrentWindow, Effect, EffectState } from '@tauri-apps/api/window'
// Rust 端通过 tauri-specta 生成的命令绑定(bindings.ts
import { commands } from '@/lib/bindings'
import { Search, Command, Loader2, CornerDownLeft, Calculator, Globe, Lock, ChevronRight, Terminal, History, FolderOpen, Ruler, Trash2 } from '@lucide/vue'
import { Accordion, AccordionItem, AccordionTrigger, AccordionContent } from '@/components/ui/accordion'
import { aggregateSearch, loadAppIconsForResults, recordHistoryItem, getMoreHistoryItems, getMoreHistoryCount, setFileIndexReady, type QPItem, type QPSubAction } from './providers'
import { EVENTS, STORAGE_KEYS } from '@/lib/constants'
// ===== 状态 =====
const query = ref('')
@@ -29,7 +31,7 @@ const moreHistoryItems = ref<QPItem[]>([])
const moreHistoryCount = ref(0)
// ===== 历史频率(localStorage 持久化,用于排序加权) =====
const HISTORY_KEY = 'thing_quickpanel_history'
const HISTORY_KEY = STORAGE_KEYS.quickpanelHistory
function loadHistory(): Record<string, number> {
try {
@@ -61,11 +63,17 @@ function applyHistoryBoost(items: QPItem[]): QPItem[] {
}
// ===== 搜索 =====
/** 搜索请求序号:每次 doSearch 自增,过期请求(序号落后)结果直接丢弃,防止慢请求覆盖新结果 */
let searchSeq = 0
async function doSearch() {
const seq = ++searchSeq
const q = query.value.trim()
if (!q) {
// 空查询:显示命令快捷入口 + 系统操作 + 历史(置顶3条)
results.value = applyHistoryBoost(await aggregateSearch(''))
const items = await aggregateSearch('')
if (seq !== searchSeq) return // 过期请求丢弃
results.value = applyHistoryBoost(items)
selectedIndex.value = 0
// 加载更多历史(Accordion 折叠区,不参与键盘导航)
moreHistoryItems.value = getMoreHistoryItems()
@@ -81,15 +89,18 @@ async function doSearch() {
loading.value = true
try {
const items = await aggregateSearch(q)
if (seq !== searchSeq) return // 过期请求丢弃,不覆盖新结果
results.value = applyHistoryBoost(items)
selectedIndex.value = 0
// 后台加载应用图标(不阻塞结果显示)
void loadAppIconsForResults(results.value)
} catch (e) {
if (seq !== searchSeq) return
console.error('[quickpanel] 搜索失败:', e)
results.value = []
} finally {
loading.value = false
// 仅最新请求可结束 loading,避免旧请求提前清除新请求的加载态
if (seq === searchSeq) loading.value = false
}
}
@@ -103,7 +114,7 @@ watch(query, () => {
// ===== 执行与隐藏 =====
async function hideWindow() {
try {
await invoke('quickpanel_hide_popup')
await commands.quickpanelHidePopup()
} catch {
/* 忽略 */
}
@@ -145,7 +156,7 @@ async function confirmDelete() {
if (!pd) return
pendingDelete.value = null
try {
await invoke('quickpanel_delete_file', { path: pd.path })
await commands.quickpanelDeleteFile(pd.path)
} catch (e) {
console.error('[quickpanel] 删除失败:', e)
}
@@ -294,7 +305,7 @@ const hasResults = () => results.value.length > 0
// ===== 主题应用(与主应用同步,独立窗口需自行设置) =====
function readMainTheme(): { theme: string; effect: string } {
try {
const raw = localStorage.getItem('thing_app_settings')
const raw = localStorage.getItem(STORAGE_KEYS.appSettings)
if (raw) {
const s = JSON.parse(raw)
return {
@@ -372,13 +383,13 @@ onMounted(async () => {
unlistenFns.push(() => mq.removeEventListener('change', onThemeChange))
const onStorage = (e: StorageEvent) => {
if (e.key === 'thing_app_settings') applyTheme()
if (e.key === STORAGE_KEYS.appSettings) applyTheme()
}
window.addEventListener('storage', onStorage)
unlistenFns.push(() => window.removeEventListener('storage', onStorage))
// 监听弹窗显示事件:重新同步主题 + 清空输入 + 加载初始结果
unlistenFns.push(await listen('quickpanel-show', async () => {
unlistenFns.push(await listen(EVENTS.quickpanelShow, async () => {
await applyTheme()
query.value = ''
await doSearch()
@@ -386,7 +397,7 @@ onMounted(async () => {
inputRef.value?.focus()
}))
unlistenFns.push(await listen('quickpanel-hide', () => {
unlistenFns.push(await listen(EVENTS.quickpanelHide, () => {
query.value = ''
results.value = []
}))
@@ -394,7 +405,7 @@ onMounted(async () => {
// 初始加载(空查询显示快捷入口)
// 独立窗口需自行检查文件索引状态(主窗口的 setFileIndexReady 不共享到此上下文)
try {
const stats = await invoke<{ total: number }>('quickpanel_file_index_stats')
const stats = await commands.quickpanelFileIndexStats()
setFileIndexReady((stats?.total ?? 0) > 0)
} catch {
/* 索引未初始化,忽略 */
@@ -404,7 +415,7 @@ onMounted(async () => {
inputRef.value?.focus()
try {
await invoke('quickpanel_show_window')
await commands.quickpanelShowWindow()
} catch {
/* 忽略 */
}
+11 -7
View File
@@ -1,14 +1,16 @@
<script setup lang="ts">
import { ref, reactive, onMounted, onUnmounted, watch, nextTick } from 'vue'
import { invoke } from '@tauri-apps/api/core'
import { open } from '@tauri-apps/plugin-dialog'
import { toast } from 'vue-sonner'
// Rust 端通过 tauri-specta 生成的命令绑定(bindings.ts
import { commands } from '@/lib/bindings'
import { Command, Zap, Keyboard, Globe, Monitor, MousePointer2, FolderTree, RefreshCw, Plus, X, Loader2, Terminal, Pencil, Check } from '@lucide/vue'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { useModuleTabsStore } from '@/stores/moduleTabsStore'
import { setFileIndexReady, invalidateCustomCommandsCache } from './providers'
import { STORAGE_KEYS } from '@/lib/constants'
interface CustomCommand {
id: string
@@ -44,7 +46,7 @@ const building = ref(false)
async function refreshStats() {
try {
indexStats.value = await invoke<IndexStats>('quickpanel_file_index_stats')
indexStats.value = await commands.quickpanelFileIndexStats()
// 索引存在(total > 0)即标记为就绪
setFileIndexReady((indexStats.value?.total ?? 0) > 0)
} catch (e) {
@@ -56,7 +58,7 @@ async function buildIndex() {
if (building.value) return
building.value = true
try {
const count = await invoke<number>('quickpanel_build_file_index')
const count = await commands.quickpanelBuildFileIndex()
toast.success(`索引完成,共 ${count}`)
await refreshStats()
} catch (e) {
@@ -88,7 +90,7 @@ function formatTime(t: number): string {
onMounted(async () => {
try {
const s = await invoke<QuickPanelSettings>('quickpanel_get_settings')
const s = await commands.quickpanelGetSettings()
Object.assign(form, s)
} catch (e) {
console.error('[quickpanel] 读取设置失败:', e)
@@ -99,9 +101,9 @@ onMounted(async () => {
// ===== 保存 =====
async function saveSettings() {
try {
await invoke('quickpanel_save_settings', { settings: { ...form } })
await commands.quickpanelSaveSettings({ ...form })
// 同步到 localStorage 供独立窗口读取
localStorage.setItem('thing_quickpanel_settings', JSON.stringify({ ...form }))
localStorage.setItem(STORAGE_KEYS.quickpanelSettings, JSON.stringify({ ...form }))
// 清除自定义命令缓存,使下次搜索重新加载
invalidateCustomCommandsCache()
toast.success('设置已保存')
@@ -231,12 +233,14 @@ async function clearShortcut() {
onUnmounted(() => {
window.removeEventListener('keydown', onRecordKey, true)
// 注销保存处理函数与标签状态,防止其他模块 activeTab=settings 时误执行本模块 saveSettings
tabsStore.unregisterTabs()
})
// ===== 唤起测试 =====
async function testPopup() {
try {
await invoke('quickpanel_show_popup')
await commands.quickpanelShowPopup()
} catch (e) {
console.error('[quickpanel] 唤起失败:', e)
toast.error('唤起失败')
+88
View File
@@ -0,0 +1,88 @@
/**
* 快速面板匹配引擎单测(Node 内置 test runner,零额外依赖)。
* 运行:npm test
*/
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { fuzzyScore, getTextForms, bestScore, type TextForms } from './engine.ts'
// ===== fuzzyScore 基础匹配 =====
test('空 query 返回 0,空 target 返回 -1', () => {
assert.equal(fuzzyScore('', 'abc'), 0)
assert.equal(fuzzyScore('abc', ''), -1)
})
test('精确匹配最高分 1.5(大小写不敏感)', () => {
assert.equal(fuzzyScore('abc', 'ABC'), 1.5)
assert.equal(fuzzyScore('hongkong', 'HongKong'), 1.5)
})
test('前缀匹配 1.2', () => {
assert.equal(fuzzyScore('ab', 'abc'), 1.2)
assert.equal(fuzzyScore('hk', 'hk-01'), 1.2)
})
test('包含匹配 1.0', () => {
assert.equal(fuzzyScore('bc', 'abc'), 1.0)
assert.equal(fuzzyScore('01', 'hk-01'), 1.0)
})
test('子序列匹配得分在 (0.5, 0.99) 区间', () => {
const s = fuzzyScore('ac', 'abc')
assert.ok(s > 0.5 && s <= 0.99, `子序列得分越界: ${s}`)
})
test('不匹配返回 -1', () => {
assert.equal(fuzzyScore('xyz', 'abc'), -1)
assert.equal(fuzzyScore('zz', 'ab'), -1)
})
test('连续命中加权高于非连续', () => {
const contiguous = fuzzyScore('ab', 'xab')
const sparse = fuzzyScore('ab', 'axb')
assert.ok(contiguous > sparse, `连续 ${contiguous} 应高于非连续 ${sparse}`)
})
test('首字母命中加权:target 开头的 query 得分更高', () => {
const atStart = fuzzyScore('a', 'abc')
const inMiddle = fuzzyScore('a', 'xac')
assert.ok(atStart > inMiddle)
})
// ===== getTextForms 形态生成(含拼音) =====
test('纯英文文本:全拼/首字母回退为原文,多单词首字母独立', () => {
const forms = getTextForms('Visual Studio Code')
assert.deepEqual(forms, ['visual studio code', 'visual studio code', 'visual studio code', 'vsc'])
})
test('中文文本生成拼音全拼与首字母', () => {
const forms = getTextForms('香港')
assert.equal(forms[0], '香港')
assert.equal(forms[1], 'xianggang')
assert.equal(forms[2], 'xg')
})
test('同一文本形态结果按内容缓存', () => {
assert.equal(getTextForms('香港'), getTextForms('香港'))
})
// ===== bestScore 多形态取最高分 =====
test('bestScore 对多形态取最高分(中文拼音可匹配)', () => {
const forms: TextForms = getTextForms('香港')
// 拼音全拼命中(子序列)
const byPinyin = bestScore('xiang', forms)
// 原文命中
const byText = bestScore('香港', forms)
assert.ok(byText >= byPinyin, `原文匹配 ${byText} 应不低于拼音 ${byPinyin}`)
assert.ok(byPinyin > 0, `拼音子序列应能匹配: ${byPinyin}`)
// 完全不匹配
assert.equal(bestScore('zzzz', forms), -1)
})
test('bestScore 支持首字母命中', () => {
const forms: TextForms = getTextForms('香港')
assert.ok(bestScore('xg', forms) > 0, '首字母应能匹配')
})
+12 -6
View File
@@ -6,7 +6,9 @@
* - query 对每种形态做子序列匹配,连续命中 + 首字母命中加权
* - 取最高分作为该 item 的得分
*
* 拼音形态惰性计算并缓存(WeakMap),避免每次输入重算。
* 拼音形态惰性计算并缓存(按文本内容缓存),避免每次输入重算。
* 注:原实现按调用方传入的 host 对象(WeakMap)缓存,但调用方每次新建对象导致缓存永不命中;
* 现改为按 text 内容缓存,同一文本直接复用结果。
*/
import { pinyin } from 'pinyin-pro'
@@ -14,7 +16,9 @@ import { pinyin } from 'pinyin-pro'
/** 一组待匹配的文本形态(原文 / 全拼或原文 / 首字母 / 多单词首字母) */
export type TextForms = readonly [string, string, string, string]
const formsCache = new WeakMap<object, TextForms>()
const formsCache = new Map<string, TextForms>()
/** 缓存上限:超过后整体清空(拼音计算开销小,缓存仅用于避免高频重复计算) */
const FORMS_CACHE_MAX = 2000
/** 判断字符串是否含 CJK 字符(需转拼音) */
function hasCJK(s: string): boolean {
@@ -38,10 +42,10 @@ function extractWordInitials(text: string): string {
/**
* 为文本生成匹配形态:[原文(小写), 拼音全拼(小写连写), 拼音首字母(小写), 多单词首字母(小写)]。
* 非中文文本:全拼与首字母回退为原文,多单词首字母仍独立计算(用于 "Visual Studio Code" → "vsc")。
* 结果按 host 对象缓存,避免重复计算。
* 结果按 text 内容缓存,避免重复计算。
*/
export function getTextForms(text: string, host: object): TextForms {
const cached = formsCache.get(host)
export function getTextForms(text: string): TextForms {
const cached = formsCache.get(text)
if (cached) return cached
const lower = text.toLowerCase()
@@ -59,7 +63,9 @@ export function getTextForms(text: string, host: object): TextForms {
const firstStr = full.map(s => s.charAt(0)).join('').toLowerCase()
forms = [lower, fullStr, firstStr, initials]
}
formsCache.set(host, forms)
formsCache.set(text, forms)
// 防止缓存无限增长(拼音计算本身开销小,超限时整体清空即可)
if (formsCache.size > FORMS_CACHE_MAX) formsCache.clear()
return forms
}
+5 -5
View File
@@ -1,5 +1,7 @@
import type { ModuleConfig } from '@/types/module'
import type { SearchIndexItem } from '@/stores/searchIndex'
// Rust 端通过 tauri-specta 生成的命令绑定(bindings.ts
import { commands } from '@/lib/bindings'
const searchItems: SearchIndexItem[] = [
{
@@ -21,11 +23,10 @@ export const moduleConfig: ModuleConfig = {
lifecycle: {
// 模块启用:读取设置并注册全局快捷键
onEnable: async () => {
const { invoke } = await import('@tauri-apps/api/core')
try {
const settings = await invoke<{ shortcut: string }>('quickpanel_get_settings')
const settings = await commands.quickpanelGetSettings()
if (settings.shortcut) {
await invoke('quickpanel_register_shortcut', { shortcut: settings.shortcut })
await commands.quickpanelRegisterShortcut(settings.shortcut)
}
} catch (e) {
console.error('[quickpanel] onEnable 注册快捷键失败:', e)
@@ -33,9 +34,8 @@ export const moduleConfig: ModuleConfig = {
},
// 模块禁用:注销全局快捷键
onDisable: async () => {
const { invoke } = await import('@tauri-apps/api/core')
try {
await invoke('quickpanel_unregister_shortcut')
await commands.quickpanelUnregisterShortcut()
} catch (e) {
console.error('[quickpanel] onDisable 注销快捷键失败:', e)
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,101 @@
/**
* Provider 注册与聚合搜索。
* 并行调用各 Provider 合并结果、打分排序、应用去重。
*/
import type { QPItem, QPProvider } from './types'
import { appRankFromPath } from './utils'
import { HistoryProvider } from './history'
import { CommandProvider } from './command'
import { CustomCommandProvider } from './customCommand'
import { AppProvider } from './app'
import { FileProvider } from './file'
import { ClipboardProvider } from './clipboard'
import { CalcProvider } from './calc'
import { UnitProvider } from './unit'
import { SpecialProvider } from './special'
import { SystemProvider } from './system'
import { WebProvider } from './web'
let providers: QPProvider[] | null = null
export function getProviders(): QPProvider[] {
if (!providers) {
providers = [
new HistoryProvider(),
new CommandProvider(),
new CustomCommandProvider(),
new AppProvider(),
new FileProvider(),
new ClipboardProvider(),
new CalcProvider(),
new UnitProvider(),
new SpecialProvider(),
new SystemProvider(),
new WebProvider(),
]
}
return providers
}
/**
* 聚合搜索:并行调用各 Provider,合并结果,按 score 降序排序。
* 空查询时返回 command Provider 的快捷入口 + system Provider 的固定项。
*/
export async function aggregateSearch(query: string): Promise<QPItem[]> {
const all = getProviders()
const results = await Promise.all(all.map(p => Promise.resolve(p.search(query))))
const merged: QPItem[] = []
results.forEach((items, idx) => {
items.forEach(item => {
// 未打分的项赋予基础分(按 provider 优先级递减)
if (item.score === undefined) {
item.score = (10 - idx) * 0.01
}
merged.push(item)
})
})
// 去重:所有来源的「应用」(含文件索引中的 .lnk)按名称归并,保留可靠性最高的来源
// 可靠性:开始菜单(appRank 0) > 桌面(1) > 其他位置(2);同可靠性时保留分数更高的
// (如 "TRAE Work CN" 在开始菜单 + 桌面 + 某索引目录都有 .lnk,只留开始菜单那条)
const appKey = (title: string): string => {
let t = title.trim().toLowerCase()
if (t.endsWith('.lnk')) t = t.slice(0, -4).trim()
return t
}
// 应用候选:应用分组,以及文件分组中的 .lnk 快捷方式
const isAppLike = (item: QPItem): boolean => {
if (item.group === '应用') return true
if (item.group === '文件' && item.title && item.title.toLowerCase().endsWith('.lnk')) return true
return false
}
const bestAppByKey = new Map<string, QPItem>()
for (const item of merged) {
if (!isAppLike(item) || !item.title) continue
const key = appKey(item.title)
const prev = bestAppByKey.get(key)
if (!prev) {
bestAppByKey.set(key, item)
continue
}
// 比较可靠性:appRank 越小越可靠;文件分组 .lnk 无 appRank 时按路径推断
const rankOf = (i: QPItem): number => {
if (i.appRank !== undefined) return i.appRank
if (i.group === '文件') return appRankFromPath(i.subtitle ?? '')
return 2
}
const rankA = rankOf(item)
const rankB = rankOf(prev)
if (rankA < rankB || (rankA === rankB && (item.score ?? 0) > (prev.score ?? 0))) {
bestAppByKey.set(key, item)
}
}
const keptAppIds = new Set(Array.from(bestAppByKey.values()).map(i => i.id))
const deduped = merged.filter(item => {
if (!isAppLike(item)) return true
return keptAppIds.has(item.id)
})
deduped.sort((a, b) => (b.score ?? 0) - (a.score ?? 0))
return deduped
}
+113
View File
@@ -0,0 +1,113 @@
/**
* app Provider:扫描开始菜单应用。
* 1 分钟缓存减少重复 IPC;图标按需加载(前端 Map 缓存,避免重复请求)。
*/
import { invoke } from '@tauri-apps/api/core'
import { bestScore } from '../engine'
import type { QPItem, QPProvider } from './types'
import { buildItemForms, makeAppLaunch, makeAppSubActions } from './utils'
interface AppRecord {
name: string
path: string
}
let appCache: AppRecord[] | null = null
let appCacheTime = 0
const APP_CACHE_TTL = 60_000 // 1 分钟缓存
async function loadApps(): Promise<AppRecord[]> {
if (appCache && Date.now() - appCacheTime < APP_CACHE_TTL) {
return appCache
}
try {
const apps = await invoke<AppRecord[]>('quickpanel_scan_apps')
appCache = apps
appCacheTime = Date.now()
return apps
} catch (e) {
console.error('[quickpanel] 扫描应用失败:', e)
return []
}
}
export class AppProvider implements QPProvider {
id = 'app'
label = '应用'
priority = 95
async search(query: string): Promise<QPItem[]> {
const apps = await loadApps()
if (!query.trim()) {
// 空查询:不显示应用(避免列表过长),由命令入口承担
return []
}
const results: Array<{ item: QPItem; score: number }> = []
let idx = 0
for (const app of apps) {
const forms = buildItemForms(app.name)
const score = bestScore(query, forms)
if (score >= 0) {
results.push({
item: {
id: `app-${idx}`,
title: app.name,
subtitle: app.path,
group: '应用',
iconPath: app.path,
action: makeAppLaunch(app.path),
subActions: makeAppSubActions(app.path),
appRank: 0, // 开始菜单:最可靠来源
},
score,
})
}
idx++
}
results.sort((a, b) => b.score - a.score)
return results.slice(0, 15).map(r => ({ ...r.item, score: r.score }))
}
}
// ===== 应用图标按需加载 =====
// 前端缓存(path -> dataUrl)。Rust 侧另有内存 + 磁盘缓存,此处仅避免重复 IPC。
const appIconCache = new Map<string, string>() // path -> dataUrl('' = 无图标)
/** 为搜索结果中带 iconPath 的项(应用、历史中的应用)按需加载图标(data URL),
* 并写入 item.iconUrl 触发响应式更新。
* 命中前端缓存时同步返回;否则异步调用 Rust 命令(命中 Rust 缓存则零开销)。 */
export async function loadAppIconsForResults(items: QPItem[]): Promise<void> {
const toLoad: QPItem[] = []
for (const item of items) {
if (!item.iconPath) continue
if (item.iconUrl !== undefined) continue // 已设置(含加载中)
const cached = appIconCache.get(item.iconPath)
if (cached !== undefined) {
item.iconUrl = cached
} else {
item.iconUrl = '' // 标记加载中,避免重复请求
toLoad.push(item)
}
}
if (!toLoad.length) return
await Promise.all(
toLoad.map(async item => {
const path = item.iconPath!
try {
const url = await invoke<string | null>('quickpanel_get_app_icon', { path })
const u = url ?? ''
appIconCache.set(path, u)
item.iconUrl = u
} catch {
appIconCache.set(path, '')
item.iconUrl = ''
}
}),
)
}
/** 清空前端图标缓存(Rust 端清理命令 quickpanel_clear_app_icon_cache 调用后可一并清空) */
export function invalidateAppIconCache() {
appIconCache.clear()
}
+40
View File
@@ -0,0 +1,40 @@
/**
* calc Provider:输入即算。
* CSP 安全:使用自写递归下降求值器 evaluateExpression(原 Function 构造在启用 CSP 后会被 unsafe-eval 拦截)。
*/
import { evaluateExpression } from '@/lib/calc'
import type { QPItem, QPProvider } from './types'
const CALC_RE = /^[\d\s+\-*/().%]+$/
export class CalcProvider implements QPProvider {
id = 'calc'
label = '计算'
priority = 90
search(query: string): QPItem[] {
const trimmed = query.trim()
if (!trimmed) return []
// 必须至少包含一个运算符和一个数字
if (!CALC_RE.test(trimmed)) return []
if (!/[\d]/.test(trimmed) || !/[+\-*/%]/.test(trimmed)) return []
const result = evaluateExpression(trimmed)
if (result === null) return []
const display = String(result)
return [{
id: 'calc-result',
title: display,
subtitle: `= ${trimmed}`,
group: '计算',
score: 0.95,
action: async () => {
try {
await navigator.clipboard.writeText(display)
} catch {
/* 忽略剪贴板失败 */
}
},
}]
}
}
@@ -0,0 +1,38 @@
/**
* clipboard Provider:复用剪贴板历史。
* 剪贴板模块未启用时静默忽略(invoke 失败返回空列表)。
*/
import type { QPItem, QPProvider } from './types'
// Rust 端通过 tauri-specta 生成的命令绑定(bindings.ts
import { commands } from '@/lib/bindings'
export class ClipboardProvider implements QPProvider {
id = 'clipboard'
label = '剪贴板'
priority = 70
async search(query: string): Promise<QPItem[]> {
if (!query.trim() || query.trim().length < 2) return []
try {
// 返回 HistoryPage{ items, total }),此处取 items
const page = await commands.clipboardSearch(query.trim(), 8, 0)
return page.items.map((c) => ({
id: `clip-${c.id}`,
title: c.preview.slice(0, 80),
subtitle: `${c.kind === 'text' ? '文本' : c.kind === 'image' ? '图片' : '文件'}`,
group: '剪贴板',
score: 0.5,
action: async () => {
try {
await commands.clipboardCopyBack(c.id)
} catch (e) {
console.error('[quickpanel] 复制失败:', e)
}
},
}))
} catch {
// 剪贴板模块可能未启用,静默忽略
return []
}
}
}
@@ -0,0 +1,72 @@
/**
* command Provider:复用主应用模块搜索项。
* 独立窗口约束:不加载主应用 store,从 localStorage 读取主应用写入的命令缓存,
* 执行时 emit `quickpanel-execute-command` 事件通知主窗口切换模块。
*/
import { emit } from '@tauri-apps/api/event'
import { bestScore } from '../engine'
import { EVENTS, STORAGE_KEYS } from '@/lib/constants'
import type { QPItem, QPProvider } from './types'
import { buildItemForms } from './utils'
const COMMANDS_KEY = STORAGE_KEYS.quickpanelCommands
interface CachedCommand {
moduleId: string
moduleName: string
title: string
description?: string
keywords: string[]
}
function loadCommands(): CachedCommand[] {
try {
const raw = localStorage.getItem(COMMANDS_KEY)
if (!raw) return []
return JSON.parse(raw) as CachedCommand[]
} catch {
return []
}
}
export class CommandProvider implements QPProvider {
id = 'command'
label = '命令'
priority = 100
search(query: string): QPItem[] {
const commands = loadCommands()
if (!query.trim() || !commands.length) {
// 无输入时返回前几条命令作为快捷入口
if (!query.trim()) {
return commands.slice(0, 6).map((c, i) => this.toItem(c, i))
}
return []
}
const results: Array<{ item: QPItem; score: number }> = []
commands.forEach((c, idx) => {
const forms = buildItemForms(c.title, c.keywords)
const score = bestScore(query, forms)
if (score >= 0) {
const item = this.toItem(c, idx)
results.push({ item, score })
}
})
results.sort((a, b) => b.score - a.score)
return results.map(r => ({ ...r.item, score: r.score }))
}
private toItem(c: CachedCommand, idx: number): QPItem {
return {
id: `cmd-${c.moduleId}-${idx}`,
title: c.title,
subtitle: c.description || c.moduleName,
group: '命令',
action: async () => {
// 通知主窗口切换到对应模块
await emit(EVENTS.quickpanelExecuteCommand, { moduleId: c.moduleId })
},
}
}
}
@@ -0,0 +1,71 @@
/**
* customCommand Provider:用户自定义命令。
* 设置保存在 Rustquickpanel_get_settings),前端缓存避免重复 IPC;
* 设置页保存后调用 invalidateCustomCommandsCache 清除缓存。
*/
import { bestScore } from '../engine'
import type { QPItem, QPProvider } from './types'
import { buildItemForms } from './utils'
// Rust 端通过 tauri-specta 生成的命令绑定(bindings.ts
import { commands } from '@/lib/bindings'
interface CustomCommandConfig {
id: string
title: string
command: string
args: string[]
}
let customCommandsCache: CustomCommandConfig[] | null = null
async function loadCustomCommands(): Promise<CustomCommandConfig[]> {
if (customCommandsCache) return customCommandsCache
try {
const s = await commands.quickpanelGetSettings()
customCommandsCache = (s.customCommands as CustomCommandConfig[] | undefined) || []
return customCommandsCache
} catch {
return []
}
}
/** 设置页保存后调用,清除缓存使下次搜索重新加载 */
export function invalidateCustomCommandsCache() {
customCommandsCache = null
}
export class CustomCommandProvider implements QPProvider {
id = 'custom'
label = '自定义'
priority = 92
async search(query: string): Promise<QPItem[]> {
const cmds = await loadCustomCommands()
if (!query.trim()) return []
const results: Array<{ item: QPItem; score: number }> = []
for (const cmd of cmds) {
const forms = buildItemForms(cmd.title)
const score = bestScore(query, forms)
if (score >= 0) {
results.push({
item: {
id: `custom-${cmd.id}`,
title: cmd.title,
subtitle: cmd.command,
group: '自定义',
action: async () => {
try {
await commands.quickpanelRunCustomCommand(cmd.command, cmd.args)
} catch (e) {
console.error('[quickpanel] 自定义命令执行失败:', e)
}
},
},
score,
})
}
}
results.sort((a, b) => b.score - a.score)
return results.map(r => ({ ...r.item, score: r.score }))
}
}
+112
View File
@@ -0,0 +1,112 @@
/**
* file Provider:文件索引搜索。
* .lnk 快捷方式按应用处理(带图标、用启动命令),并与开始菜单应用统一去重。
*/
import type { QPItem, QPProvider } from './types'
import { appRankFromPath, makeAppLaunch, makeAppSubActions } from './utils'
// Rust 端通过 tauri-specta 生成的命令绑定(bindings.ts
import { commands } from '@/lib/bindings'
let fileIndexReady = false
export class FileProvider implements QPProvider {
id = 'file'
label = '文件'
priority = 85
async search(query: string): Promise<QPItem[]> {
if (!query.trim() || query.trim().length < 2) return []
if (!fileIndexReady) return []
try {
const files = await commands.quickpanelSearchFiles(query.trim(), 20)
return files.map((f, idx) => {
// .lnk 快捷方式按应用处理:带图标、用启动命令,并与开始菜单应用统一去重
// 注意:Rust 返回的 ext 不带点(如 "lnk"),这里直接按文件名判断最稳妥
const isLnk = !f.isDir && f.name.toLowerCase().endsWith('.lnk')
if (isLnk) {
return {
id: `file-app-${idx}`,
title: f.name,
subtitle: f.path,
group: '应用',
score: 0.55, // 略低于开始菜单应用(0.6+),去重时让位于开始菜单
iconPath: f.path,
action: makeAppLaunch(f.path),
subActions: makeAppSubActions(f.path, true),
deleteInfo: { path: f.path, isDir: false },
appRank: appRankFromPath(f.path),
}
}
const openFile = async () => {
try {
// 目录:Rust 端用 explorer.exe 打开;文件:默认程序打开(无关联时 fallback 打开方式)
await commands.quickpanelOpenFile(f.path)
} catch (e) {
console.error('[quickpanel] 打开文件失败:', e)
}
}
return {
id: `file-${idx}`,
title: f.name,
subtitle: f.path,
group: '文件',
score: 0.6,
action: openFile,
// 目录:打开即导航到该目录,无需再提供「在资源管理器中显示」,避免重复
subActions: [
{
id: 'open',
label: f.isDir ? '打开文件夹' : '打开',
action: openFile,
},
...(f.isDir
? []
: [{
id: 'reveal',
label: '在资源管理器中显示',
action: async () => {
try {
await commands.quickpanelRevealInExplorer(f.path)
} catch (e) {
console.error('[quickpanel] 资源管理器显示失败:', e)
}
},
}]),
{
id: 'copy-path',
label: '复制路径',
action: async () => {
try {
await navigator.clipboard.writeText(f.path)
} catch {
/* 忽略 */
}
},
},
{
id: 'delete',
label: '删除',
action: async () => {
try {
// 移到回收站(PowerShell + Microsoft.VisualBasic
await commands.quickpanelDeleteFile(f.path)
} catch (e) {
console.error('[quickpanel] 删除失败:', e)
}
},
},
],
deleteInfo: { path: f.path, isDir: f.isDir },
}
})
} catch (e) {
console.error('[quickpanel] 文件搜索失败:', e)
return []
}
}
}
/** 由设置页在索引构建完成后调用,启用 file Provider */
export function setFileIndexReady(ready: boolean) {
fileIndexReady = ready
}
+107
View File
@@ -0,0 +1,107 @@
/**
* history Provider:最近交互记录。
* 记录持久化到 localStorage,空查询时置顶展示最近几条;点击历史项时
* 重新聚合搜索恢复原 action。
*/
import { STORAGE_KEYS } from '@/lib/constants'
import type { HistoryEntry, QPItem, QPProvider } from './types'
import { aggregateSearch } from './aggregate'
const HISTORY_ITEMS_KEY = STORAGE_KEYS.quickpanelHistoryItems
const HISTORY_MAX = 50
/** 空查询时默认展示的历史条数(置顶部分) */
export const HISTORY_PREVIEW_COUNT = 3
function loadHistoryEntries(): HistoryEntry[] {
try {
const raw = localStorage.getItem(HISTORY_ITEMS_KEY)
if (!raw) return []
return JSON.parse(raw) as HistoryEntry[]
} catch {
return []
}
}
function saveHistoryEntries(entries: HistoryEntry[]) {
localStorage.setItem(HISTORY_ITEMS_KEY, JSON.stringify(entries.slice(0, HISTORY_MAX)))
}
/** 将一条历史记录转换为可执行的 QPItem */
function buildHistoryItem(e: HistoryEntry): QPItem {
return {
id: `history-${e.id}`,
title: e.title,
subtitle: e.subtitle,
group: '历史',
iconPath: e.iconPath,
historyQuery: e.query,
action: async () => {
// 重新搜索恢复 action 并执行
try {
const results = await aggregateSearch(e.query)
// 按 id 精确匹配原 item
const target = results.find(r => r.id === e.id) ?? results.find(r => r.title === e.title)
if (target) {
await target.action()
}
} catch (err) {
console.error('[quickpanel] 历史项执行失败:', err)
}
},
}
}
/** 记录一次交互。在 QuickPanel.vue 执行 item 时调用。
* query 为执行时的搜索文本(用于后续重建 action)。 */
export function recordHistoryItem(item: QPItem, query: string) {
if (!item.id || item.group === '历史') return // 历史项自身不重复记录
const entries = loadHistoryEntries()
// 去重:同 id 移除旧的,插到头部
const filtered = entries.filter(e => e.id !== item.id)
filtered.unshift({
id: item.id,
title: item.title,
subtitle: item.subtitle,
group: item.group,
iconPath: item.iconPath,
query: query || item.title,
timestamp: Date.now(),
})
saveHistoryEntries(filtered.slice(0, HISTORY_MAX))
}
/** 清空历史记录 */
export function clearHistory() {
localStorage.removeItem(HISTORY_ITEMS_KEY)
}
/** 获取置顶的历史项(最近 N 条),用于空查询时在结果列表顶部显示 */
export function getTopHistoryItems(): QPItem[] {
const entries = loadHistoryEntries()
return entries.slice(0, HISTORY_PREVIEW_COUNT).map(buildHistoryItem)
}
/** 获取置顶历史之后的剩余历史项,用于 Accordion 折叠显示 */
export function getMoreHistoryItems(): QPItem[] {
const entries = loadHistoryEntries()
return entries.slice(HISTORY_PREVIEW_COUNT).map(buildHistoryItem)
}
/** 获取剩余历史数量(用于 Accordion 标题显示) */
export function getMoreHistoryCount(): number {
const entries = loadHistoryEntries()
return Math.max(0, entries.length - HISTORY_PREVIEW_COUNT)
}
export class HistoryProvider implements QPProvider {
id = 'history'
label = '历史'
priority = 99 // 最高优先级,空查询时显示在最前
async search(query: string): Promise<QPItem[]> {
if (query.trim()) return [] // 历史只在空查询时显示
// 只返回置顶3条,剩余由 Accordion 承载
return getTopHistoryItems()
}
}
+23
View File
@@ -0,0 +1,23 @@
/**
* 快速面板 Provider 聚合入口。
*
* 独立窗口约束:不加载主应用 store。
* - command Provider 从 localStorage 读取主应用写入的命令缓存,
* 执行时 emit `quickpanel-execute-command` 事件通知主窗口切换模块。
* - system/web/calc Provider 纯前端 + Rust invoke。
*
* 对外保持公共 API 稳定(目录拆分后导入路径与导出名不变)。
*/
export type { QPItem, QPSubAction, QPProvider } from './types'
export { getProviders, aggregateSearch } from './aggregate'
export { loadAppIconsForResults, invalidateAppIconCache } from './app'
export { setFileIndexReady } from './file'
export { invalidateCustomCommandsCache } from './customCommand'
export {
HISTORY_PREVIEW_COUNT,
recordHistoryItem,
clearHistory,
getTopHistoryItems,
getMoreHistoryItems,
getMoreHistoryCount,
} from './history'
@@ -0,0 +1,93 @@
/**
* special ProviderWindows 常用快捷位置。
* 列表由 Rust 提供(quickpanel_get_special_locations),1 分钟缓存。
*/
import { bestScore } from '../engine'
import type { QPItem, QPProvider } from './types'
import { buildItemForms } from './utils'
// Rust 端通过 tauri-specta 生成的命令绑定与类型(bindings.ts
import { commands, type SpecialLocation } from '@/lib/bindings'
let specialCache: SpecialLocation[] | null = null
let specialCacheTime = 0
const SPECIAL_CACHE_TTL = 60_000 // 1 分钟缓存
async function loadSpecials(): Promise<SpecialLocation[]> {
if (specialCache && Date.now() - specialCacheTime < SPECIAL_CACHE_TTL) {
return specialCache
}
try {
const list = await commands.quickpanelGetSpecialLocations()
specialCache = list
specialCacheTime = Date.now()
return list
} catch (e) {
console.error('[quickpanel] 获取快捷位置失败:', e)
return []
}
}
export class SpecialProvider implements QPProvider {
id = 'special'
label = '快捷'
priority = 60
async search(query: string): Promise<QPItem[]> {
const list = await loadSpecials()
if (!list.length) return []
if (!query.trim()) return [] // 空查询不占用列表,由用户主动搜索
const open = async (s: SpecialLocation) => {
try {
await commands.quickpanelOpenSpecial(s.kind, s.target, s.args)
} catch (e) {
console.error('[quickpanel] 打开快捷位置失败:', e)
}
}
const items: QPItem[] = list.map(s => ({
id: `sp-${s.id}`,
title: s.title,
subtitle: s.subtitle,
group: '快捷',
action: () => open(s),
subActions:
s.kind === 'file'
? [
{ id: 'open', label: '打开', action: () => open(s) },
{
id: 'reveal',
label: '在资源管理器中显示',
action: async () => {
try {
await commands.quickpanelRevealInExplorer(s.target)
} catch (e) {
console.error('[quickpanel] 资源管理器显示失败:', e)
}
},
},
{
id: 'copy-path',
label: '复制路径',
action: async () => {
try {
await navigator.clipboard.writeText(s.target)
} catch {
/* 忽略 */
}
},
},
]
: undefined,
}))
const scored: Array<{ item: QPItem; score: number }> = []
items.forEach((item, idx) => {
const forms = buildItemForms(item.title, list[idx].keywords)
const score = bestScore(query, forms)
if (score >= 0) scored.push({ item, score })
})
scored.sort((a, b) => b.score - a.score)
return scored.slice(0, 8).map(s => ({ ...s.item, score: s.score }))
}
}
+176
View File
@@ -0,0 +1,176 @@
/**
* system Provider:系统操作。
* 内置常用系统命令(regedit / cmd / powershell 等),title 为中文主名,
* keywords 补充英文/别名;拼音全拼与首字母由引擎从 title 的 CJK 部分自动推导。
*/
import { invoke } from '@tauri-apps/api/core'
import { bestScore, type TextForms } from '../engine'
import type { QPItem, QPProvider } from './types'
import { buildItemForms } from './utils'
// Rust 端通过 tauri-specta 生成的命令绑定(bindings.ts
import { commands } from '@/lib/bindings'
interface SystemCommandDef {
id: string
title: string
subtitle: string
/** 额外关键词(英文命令名、中文别名等,用于匹配) */
keywords: string[]
command: string
args: string[]
}
const SYSTEM_COMMANDS: SystemCommandDef[] = [
{
id: 'sys-regedit',
title: '注册表编辑器',
subtitle: 'regedit',
keywords: ['regedit', '注册表', 'registry'],
command: 'regedit',
args: [],
},
{
id: 'sys-cmd',
title: '命令提示符',
subtitle: 'cmd',
keywords: ['cmd', '命令行', '终端', 'command'],
command: 'cmd',
args: [],
},
{
id: 'sys-powershell',
title: 'PowerShell',
subtitle: 'powershell',
keywords: ['powershell', 'pwsh'],
command: 'powershell',
args: [],
},
{
id: 'sys-taskmgr',
title: '任务管理器',
subtitle: 'taskmgr',
keywords: ['taskmgr', '任务管理', '进程'],
command: 'taskmgr',
args: [],
},
{
id: 'sys-explorer',
title: '资源管理器',
subtitle: 'explorer',
keywords: ['explorer', '文件管理器', '资源管理'],
command: 'explorer',
args: [],
},
{
id: 'sys-control',
title: '控制面板',
subtitle: 'control',
keywords: ['control', '控制面板', '设置'],
command: 'control',
args: [],
},
{
id: 'sys-shutdown',
title: '关机',
subtitle: 'shutdown /s /t 0',
keywords: ['shutdown', '关闭计算机', '关闭电脑', 'guanji'],
command: 'shutdown',
args: ['/s', '/t', '0'],
},
{
id: 'sys-restart',
title: '重启',
subtitle: 'shutdown /r /t 0',
keywords: ['restart', 'reboot', '重新启动', '重启电脑', 'chongqi'],
command: 'shutdown',
args: ['/r', '/t', '0'],
},
{
id: 'sys-shutdown-cancel',
title: '取消关机/重启',
subtitle: 'shutdown /a',
keywords: ['cancel', '取消', 'quxiao', 'abort'],
command: 'shutdown',
args: ['/a'],
},
{
id: 'sys-hibernate',
title: '休眠',
subtitle: 'shutdown /h',
keywords: ['hibernate', '睡眠', 'xiu', 'mian'],
command: 'shutdown',
args: ['/h'],
},
]
export class SystemProvider implements QPProvider {
id = 'system'
label = '系统'
priority = 40
private buildItems(): QPItem[] {
const items: QPItem[] = SYSTEM_COMMANDS.map(def => ({
id: def.id,
title: def.title,
subtitle: def.subtitle,
group: '系统',
action: async () => {
try {
await commands.quickpanelRunSystemCommand(def.command, def.args)
} catch (e) {
console.error('[quickpanel] 系统命令失败:', e)
}
},
}))
// 锁屏 + 退出 应用本身
items.push(
{
id: 'sys-lock',
title: '锁定屏幕',
subtitle: '立即锁定计算机',
group: '系统',
action: async () => {
try {
await commands.quickpanelLockScreen()
} catch (e) {
console.error('[quickpanel] 锁屏失败:', e)
}
},
},
{
id: 'sys-quit',
title: '退出 Thing',
subtitle: '关闭应用程序',
group: '系统',
action: async () => {
try {
await invoke('quit_app')
} catch (e) {
console.error('[quickpanel] 退出失败:', e)
}
},
},
)
return items
}
/** 为带 keywords 的 item 构建匹配形态(title + keywords 合并) */
private itemForms(item: QPItem): TextForms {
const def = SYSTEM_COMMANDS.find(d => d.id === item.id)
return buildItemForms(item.title, def?.keywords ?? [])
}
search(query: string): QPItem[] {
const items = this.buildItems()
if (!query.trim()) return items
const scored: Array<{ item: QPItem; score: number }> = []
for (const item of items) {
const forms = this.itemForms(item)
const score = bestScore(query, forms)
if (score >= 0) scored.push({ item, score })
}
scored.sort((a, b) => b.score - a.score)
return scored.map(s => ({ ...s.item, score: s.score }))
}
}
+53
View File
@@ -0,0 +1,53 @@
/**
* 快速面板 Provider 共享类型。
* 各 Provider 实现统一 search(query) 接口,返回带 group 的 QPItem 列表。
*/
/** 子动作(项的右键/展开菜单) */
export interface QPSubAction {
id: string
label: string
action: () => void | Promise<void>
}
export interface QPItem {
id: string
title: string
subtitle?: string
group: string
score?: number
/** 应用图标 data URL'' = 加载中,undefined = 无图标项) */
iconUrl?: string
/** 应用路径(仅 app 项设置,用于按需获取图标) */
iconPath?: string
/** 执行动作(调用方在执行后负责隐藏窗口) */
action: () => void | Promise<void>
/** 子动作菜单(可选)。执行子动作后同样隐藏窗口 */
subActions?: QPSubAction[]
/** 删除确认信息(仅可删除项设置,如文件/文件夹,用于弹窗确认后执行删除) */
deleteInfo?: { path: string; isDir: boolean }
/** 用于历史记录的查询文本(仅历史项设置,点击历史时用此重新搜索恢复 action) */
historyQuery?: string
/** 应用可靠性排序(仅 group='应用' 项设置,越小越可靠:开始菜单 0 / 桌面 1 / 其他 2) */
appRank?: number
}
export interface QPProvider {
id: string
label: string
priority: number
/** 返回当前 query 的候选结果(引擎尚未打分,score 可留空) */
search(query: string): QPItem[] | Promise<QPItem[]>
}
/** 历史记录条目(history Provider 持久化到 localStorage */
export interface HistoryEntry {
id: string
title: string
subtitle?: string
group: string
iconPath?: string
/** 记录时的查询文本,用于点击历史项时重新搜索恢复 action */
query: string
timestamp: number
}
+296
View File
@@ -0,0 +1,296 @@
/**
* unit Provider:单位 / 货币 / 时间 / 温度换算。
* 汇率动态获取(open.er-api.com),带本地缓存与兜底值;温度做仿射换算单独处理。
*/
import { STORAGE_KEYS } from '@/lib/constants'
import type { QPItem, QPProvider } from './types'
interface UnitDef {
/** 可匹配的符号(含中文),小写优先;带 exactCase 的单位只做精确大小写匹配 */
symbols: string[]
label: string
/** 与基准单位的换算系数(基准单位 = 1) */
factor: number
/** 仅精确大小写匹配(如小写 m = 米,避免与 MB 混淆) */
exactCase?: boolean
}
interface UnitCategory {
id: string
name: string
units: UnitDef[]
}
const UNIT_CATEGORIES: UnitCategory[] = [
{
id: 'length',
name: '长度',
units: [
{ symbols: ['m', 'meter', 'meters', '米', '公尺'], label: '米', factor: 1, exactCase: true },
{ symbols: ['km', 'kilometer', 'kilometers', '千米', '公里'], label: '千米', factor: 1000 },
{ symbols: ['cm', 'centimeter', 'centimeters', '厘米'], label: '厘米', factor: 0.01 },
{ symbols: ['mm', 'millimeter', 'millimeters', '毫米'], label: '毫米', factor: 0.001 },
{ symbols: ['in', 'inch', 'inches', '英寸'], label: '英寸', factor: 0.0254 },
{ symbols: ['ft', 'foot', 'feet', '英尺'], label: '英尺', factor: 0.3048 },
{ symbols: ['yd', 'yard', 'yards', '码'], label: '码', factor: 0.9144 },
{ symbols: ['mi', 'mile', 'miles', '英里'], label: '英里', factor: 1609.344 },
{ symbols: ['里', 'li'], label: '里', factor: 500 },
],
},
{
id: 'data',
name: '数据',
units: [
{ symbols: ['b', 'byte', 'bytes', '字节'], label: '字节', factor: 1 },
{ symbols: ['kb', 'kib', 'kilobyte', 'kilobytes', '千字节'], label: 'KB', factor: 1024 },
{ symbols: ['mb', 'mib', 'megabyte', 'megabytes', '兆字节'], label: 'MB', factor: 1024 ** 2 },
{ symbols: ['gb', 'gib', 'gigabyte', 'gigabytes', '吉字节'], label: 'GB', factor: 1024 ** 3 },
{ symbols: ['tb', 'tib', 'terabyte', 'terabytes', '太字节'], label: 'TB', factor: 1024 ** 4 },
{ symbols: ['bit', 'bits', '比特'], label: 'bit', factor: 1 / 8 },
],
},
{
id: 'speed',
name: '网速',
units: [
{ symbols: ['bps', '比特/秒'], label: 'bps', factor: 1 },
{ symbols: ['kbps', '千比特/秒'], label: 'Kbps', factor: 1024 },
{ symbols: ['mbps', '兆比特/秒'], label: 'Mbps', factor: 1024 ** 2 },
{ symbols: ['gbps', '吉比特/秒'], label: 'Gbps', factor: 1024 ** 3 },
{ symbols: ['b/s'], label: 'B/s', factor: 8 },
{ symbols: ['kb/s'], label: 'KB/s', factor: 8 * 1024 },
{ symbols: ['mb/s'], label: 'MB/s', factor: 8 * 1024 ** 2 },
{ symbols: ['gb/s'], label: 'GB/s', factor: 8 * 1024 ** 3 },
],
},
{
id: 'time',
name: '时间',
units: [
{ symbols: ['s', 'sec', 'secs', 'second', 'seconds', '秒'], label: '秒', factor: 1 },
{ symbols: ['min', 'mins', 'minute', 'minutes', '分钟', '分'], label: '分钟', factor: 60 },
{ symbols: ['h', 'hr', 'hrs', 'hour', 'hours', '小时', '时'], label: '小时', factor: 3600 },
{ symbols: ['day', 'days', '天', '日'], label: '天', factor: 86400 },
{ symbols: ['week', 'weeks', '周', '星期'], label: '周', factor: 604800 },
{ symbols: ['year', 'years', '年'], label: '年', factor: 31536000 },
],
},
{
id: 'weight',
name: '重量',
units: [
{ symbols: ['kg', '千克', '公斤'], label: '千克', factor: 1 },
{ symbols: ['g', 'gram', 'grams', '克'], label: '克', factor: 0.001 },
{ symbols: ['mg', 'milligram', '毫克'], label: '毫克', factor: 1e-6 },
{ symbols: ['t', 'ton', 'tons', '吨'], label: '吨', factor: 1000 },
{ symbols: ['lb', 'lbs', 'pound', 'pounds', '磅'], label: '磅', factor: 0.45359237 },
{ symbols: ['oz', 'ounce', 'ounces', '盎司'], label: '盎司', factor: 0.028349523125 },
{ symbols: ['斤', 'jin'], label: '斤', factor: 0.5 },
{ symbols: ['两', 'liang'], label: '两', factor: 0.05 },
],
},
]
// ===== 货币换算(汇率动态获取,带本地缓存与兜底值) =====
const DEFAULT_CURRENCY_RATES: Record<string, number> = {
usd: 1,
cny: 7.2,
eur: 0.92,
gbp: 0.78,
jpy: 156,
hkd: 7.8,
}
const CURRENCY_CACHE_KEY = STORAGE_KEYS.currencyRates
function getCurrencyRates(): Record<string, number> {
try {
const raw = localStorage.getItem(CURRENCY_CACHE_KEY)
if (raw) {
const p = JSON.parse(raw)
if (p?.rates && Date.now() - p.ts < 24 * 3600 * 1000) return p.rates
}
} catch {
/* 忽略损坏缓存 */
}
return DEFAULT_CURRENCY_RATES
}
let currencyRefreshing = false
/** 后台刷新汇率(失败静默,继续用缓存/兜底值),结果写入 localStorage 供下次使用 */
async function refreshCurrencyRates() {
if (currencyRefreshing) return
currencyRefreshing = true
try {
const res = await fetch('https://open.er-api.com/v6/latest/USD')
const data = await res.json()
if (data?.result === 'success' && data.rates) {
const r = data.rates as Record<string, number | undefined>
const rates: Record<string, number> = {
usd: 1,
cny: r.CNY ?? DEFAULT_CURRENCY_RATES.cny,
eur: r.EUR ?? DEFAULT_CURRENCY_RATES.eur,
gbp: r.GBP ?? DEFAULT_CURRENCY_RATES.gbp,
jpy: r.JPY ?? DEFAULT_CURRENCY_RATES.jpy,
hkd: r.HKD ?? DEFAULT_CURRENCY_RATES.hkd,
}
localStorage.setItem(CURRENCY_CACHE_KEY, JSON.stringify({ ts: Date.now(), rates }))
}
} catch {
/* 网络失败,继续使用默认/缓存汇率 */
} finally {
currencyRefreshing = false
}
}
/** 动态构建货币类别(基准 = 美元;factor 为「1 单位该货币 = ? 美元」) */
function getCurrencyCategory(): UnitCategory {
const r = getCurrencyRates()
const perUsd = (v: number) => (v > 0 ? 1 / v : 0)
return {
id: 'currency',
name: '货币',
units: [
{ symbols: ['$', 'usd', '美元', '美金', '美刀'], label: '美元', factor: 1 },
{ symbols: ['¥', '¥', 'rmb', 'cny', '元', '人民币'], label: '人民币', factor: perUsd(r.cny) },
{ symbols: ['€', 'eur', '欧元'], label: '欧元', factor: perUsd(r.eur) },
{ symbols: ['£', 'gbp', '英镑'], label: '英镑', factor: perUsd(r.gbp) },
{ symbols: ['jpy', '日元', '日圆'], label: '日元', factor: perUsd(r.jpy) },
{ symbols: ['hkd', '港币', '港元'], label: '港元', factor: perUsd(r.hkd) },
],
}
}
/** 温度匹配(仿射换算,单独处理) */
function matchTemperature(token: string): 'C' | 'F' | 'K' | null {
const t = token.toLowerCase().replace(/°/g, '')
if (['c', 'celsius', '摄氏度', '摄氏'].includes(t)) return 'C'
if (['f', 'fahrenheit', '华氏度', '华氏'].includes(t)) return 'F'
if (['kelvin', '开尔文'].includes(t)) return 'K'
return null
}
/** 在(普通 + 货币)类别中匹配单位 token */
function matchUnit(
token: string,
categories: UnitCategory[],
): { cat: UnitCategory; unit: UnitDef } | null {
// 第一轮:精确大小写匹配
for (const cat of categories) {
for (const unit of cat.units) {
if (unit.symbols.some(s => s === token)) return { cat, unit }
}
}
// 第二轮:大小写不敏感;exactCase 单位(如 m=米)跳过,避免 "1M" 误判为 1 米
const lower = token.toLowerCase()
for (const cat of categories) {
for (const unit of cat.units) {
if (unit.exactCase) continue
if (unit.symbols.some(s => s.toLowerCase() === lower)) return { cat, unit }
}
}
return null
}
/** 数值格式化(去掉多余的浮点尾巴) */
function formatUnitValue(v: number): string {
if (!isFinite(v)) return ''
if (v === 0) return '0'
const abs = Math.abs(v)
if (abs >= 1e12) return v.toExponential(2)
if (abs >= 1e6) return Number(v.toFixed(0)).toLocaleString('en-US')
if (abs >= 1000) return Number(v.toFixed(1)).toLocaleString('en-US')
if (abs >= 100) return Number(v.toFixed(1)).toString()
if (abs >= 1) return Number(v.toFixed(2)).toString()
if (abs >= 1e-4) return Number(v.toFixed(4)).toString()
return v.toExponential(2)
}
/** 结果展示优先级:整数 > 常见量级(1~1000) > 其他 */
function unitNiceRank(v: number): number {
if (Number.isInteger(v)) return 0
const abs = Math.abs(v)
if (abs >= 1 && abs < 1000) return 1
return 2
}
function buildUnitResultItem(
value: number,
fromLabel: string,
catName: string,
toLabel: string,
toValue: number,
idx: number,
): QPItem {
const text = `${formatUnitValue(toValue)} ${toLabel}`
return {
id: `unit-${catName}-${idx}`,
title: text,
subtitle: `${value} ${fromLabel}${catName}换算)`,
group: '换算',
score: 0.85,
action: async () => {
try {
await navigator.clipboard.writeText(text)
} catch {
/* 忽略 */
}
},
}
}
export class UnitProvider implements QPProvider {
id = 'unit'
label = '换算'
priority = 80
async search(query: string): Promise<QPItem[]> {
const trimmed = query.trim()
if (!trimmed) return []
const m = trimmed.match(/^(\d+(?:\.\d+)?)\s*(.+)$/)
if (!m) return []
const value = parseFloat(m[1])
if (!isFinite(value) || value <= 0) return []
const token = m[2].trim()
if (!token) return []
// 温度(仿射换算)
const tFrom = matchTemperature(token)
if (tFrom) {
const celsius =
tFrom === 'C' ? value : tFrom === 'F' ? ((value - 32) * 5) / 9 : value - 273.15
const convs: Array<{ label: string; v: number }> = [
{ label: '摄氏度', v: celsius },
{ label: '华氏度', v: (celsius * 9) / 5 + 32 },
{ label: '开尔文', v: celsius + 273.15 },
]
return convs
.filter(c => !(tFrom === 'C' && c.label === '摄氏度') && !(tFrom === 'F' && c.label === '华氏度') && !(tFrom === 'K' && c.label === '开尔文'))
.map((c, i) => buildUnitResultItem(value, `${tFrom}°`, '温度', c.label, c.v, i))
}
// 普通单位 / 货币
const currencyCat = getCurrencyCategory()
const categories = [...UNIT_CATEGORIES, currencyCat]
const matched = matchUnit(token, categories)
if (!matched) return []
const { cat, unit } = matched
if (cat.id === 'currency') {
// 命中货币:后台刷新一次汇率,不阻塞本次结果
void refreshCurrencyRates()
}
const base = value * unit.factor
const results: Array<{ item: QPItem; rank: number }> = []
for (const u of cat.units) {
if (u === unit) continue
const v = base / u.factor
results.push({
item: buildUnitResultItem(value, unit.label, cat.name, u.label, v, results.length),
rank: unitNiceRank(v),
})
}
results.sort((a, b) => a.rank - b.rank)
return results.slice(0, 8).map(r => r.item)
}
}
+76
View File
@@ -0,0 +1,76 @@
/**
* 快速面板 Provider 共享工具。
* 匹配形态构建 + 应用启动动作 / 子动作 / 可靠性排序(app 与 file Provider 共用)。
*/
import { getTextForms, type TextForms } from '../engine'
import type { QPSubAction } from './types'
// Rust 端通过 tauri-specta 生成的命令绑定(bindings.ts
import { commands } from '@/lib/bindings'
/** 由 title + keywords 组合出待匹配文本形态(engine 按文本内容缓存,无需外部 host) */
export function buildItemForms(title: string, keywords: string[] = []): TextForms {
return getTextForms([title, ...keywords].join(' '))
}
/** 启动一个应用(.lnk / .exe 等),通过 Rust spawn 子进程 */
export function makeAppLaunch(path: string) {
return async () => {
try {
// .lnk 文件不能用 openUrl 打开,需直接 spawn
await commands.quickpanelRunCustomCommand(path, [])
} catch (e) {
console.error('[quickpanel] 启动应用失败:', e)
}
}
}
/** 应用项的标准子动作:启动 / 在资源管理器中显示 / 复制路径(+ 可选删除) */
export function makeAppSubActions(path: string, includeDelete = false): QPSubAction[] {
const launch = makeAppLaunch(path)
const subs: QPSubAction[] = [
{ id: 'launch', label: '启动', action: launch },
{
id: 'reveal',
label: '在资源管理器中显示',
action: async () => {
try {
await commands.quickpanelRevealInExplorer(path)
} catch (e) {
console.error('[quickpanel] 资源管理器显示失败:', e)
}
},
},
{
id: 'copy-path',
label: '复制路径',
action: async () => {
try {
await navigator.clipboard.writeText(path)
} catch {
/* 忽略 */
}
},
},
]
if (includeDelete) {
subs.push({
id: 'delete',
label: '删除',
action: async () => {
try {
await commands.quickpanelDeleteFile(path)
} catch (e) {
console.error('[quickpanel] 删除失败:', e)
}
},
})
}
return subs
}
/** 根据路径推断应用可靠性排序:桌面 1 / 其他位置 2(开始菜单由调用方直接给 0) */
export function appRankFromPath(path: string): number {
const p = path.toLowerCase()
if (p.includes('\\desktop\\') || p.includes('/desktop/')) return 1
return 2
}
+55
View File
@@ -0,0 +1,55 @@
/**
* web Provider:默认搜索建议。
* 搜索引擎配置来自主应用写入的 quickpanel 设置快照(localStorage)。
*/
import { openUrl } from '@tauri-apps/plugin-opener'
import { STORAGE_KEYS } from '@/lib/constants'
import type { QPItem, QPProvider } from './types'
type SearchEngine = 'google' | 'bing' | 'baidu'
const ENGINE_URL: Record<SearchEngine, string> = {
google: 'https://www.google.com/search?q=',
bing: 'https://www.bing.com/search?q=',
baidu: 'https://www.baidu.com/s?wd=',
}
function getSearchEngine(): SearchEngine {
try {
const raw = localStorage.getItem(STORAGE_KEYS.quickpanelSettings)
if (raw) {
const s = JSON.parse(raw)
if (s.searchEngine && ENGINE_URL[s.searchEngine as SearchEngine]) {
return s.searchEngine
}
}
} catch {
/* 忽略 */
}
return 'bing'
}
export class WebProvider implements QPProvider {
id = 'web'
label = '网页'
priority = 50
search(query: string): QPItem[] {
const trimmed = query.trim()
if (!trimmed) return []
const engine = getSearchEngine()
return [{
id: 'web-search',
title: `搜索「${trimmed}`,
subtitle: `${engine} 中打开`,
group: '网页',
score: 0.3,
action: async () => {
try {
await openUrl(ENGINE_URL[engine] + encodeURIComponent(trimmed))
} catch {
/* 忽略 */
}
},
}]
}
}
+21 -7
View File
@@ -1,9 +1,11 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
import type { Component } from 'vue'
import { invoke } from '@tauri-apps/api/core'
import { getCurrentWindow } from '@tauri-apps/api/window'
import { emit } from '@tauri-apps/api/event'
import { EVENTS } from '@/lib/constants'
// Rust 端通过 tauri-specta 生成的命令绑定(bindings.ts
import { commands } from '@/lib/bindings'
import { save } from '@tauri-apps/plugin-dialog'
import { toast } from 'vue-sonner'
import {
@@ -257,6 +259,17 @@ function onMouseDown(e: MouseEvent) {
redraw()
}
// ===== 画布重绘(rAF 节流) =====
/** 连续鼠标移动时每帧最多重绘一次,避免 mousemove 高频事件(每帧多次)触发多次全量重绘 */
let redrawRaf = 0
function scheduleRedraw() {
if (redrawRaf) return
redrawRaf = requestAnimationFrame(() => {
redrawRaf = 0
redraw()
})
}
function onMouseMove(e: MouseEvent) {
if (!isDrawing.value || !draft.value) return
const p = getPoint(e)
@@ -267,7 +280,7 @@ function onMouseMove(e: MouseEvent) {
d.x2 = p.x
d.y2 = p.y
}
redraw()
scheduleRedraw()
}
function onMouseUp() {
@@ -355,9 +368,9 @@ async function copyToClipboard() {
const pngBase64 = getPngBase64()
if (!canvas || !pngBase64) return
try {
await invoke('screenshot_copy_image', { pngBase64 })
await commands.screenshotCopyImage(pngBase64)
toast.success('已复制到剪贴板')
await emit('screenshot-exported', { pngBase64, width: canvas.width, height: canvas.height })
await emit(EVENTS.screenshotExported, { pngBase64, width: canvas.width, height: canvas.height })
} catch (e) {
toast.error('复制失败')
console.error('[screenshot-editor] 复制失败:', e)
@@ -374,9 +387,10 @@ async function saveToFile() {
filters: [{ name: 'PNG', extensions: ['png'] }],
})
if (!path) return
await invoke('screenshot_save_png', { pngBase64, path })
await commands.screenshotSavePng(pngBase64, path)
toast.success('已保存')
await emit('screenshot-exported', { pngBase64, width: canvas.width, height: canvas.height })
await emit(EVENTS.screenshotExported, { pngBase64, width: canvas.width, height: canvas.height })
await close()
} catch (e) {
toast.error('保存失败')
console.error('[screenshot-editor] 保存失败:', e)
@@ -400,7 +414,7 @@ onMounted(() => {
void (async () => {
try {
const b64 = await invoke<string | null>('screenshot_get_editor_image')
const b64 = await commands.screenshotGetEditorImage()
if (!b64) {
loadError.value = true
return
+19 -11
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch, nextTick } from 'vue'
import { ref, onUnmounted, watch, nextTick } from 'vue'
import {
Keyboard, Settings, FolderOpen, Camera, Copy, Save, Trash2, Timer,
Image as ImageIcon, Loader2,
@@ -7,7 +7,7 @@ import {
import { toast } from 'vue-sonner'
import { open } from '@tauri-apps/plugin-dialog'
import { useScreenshotStore, type RecentCapture } from '@/stores/screenshotStore'
import { useModuleTabs } from '@/lib/useModuleTabs'
import { useModuleTabs } from '@/lib/use-module-tabs'
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
@@ -35,7 +35,7 @@ function formatTime(t: number): string {
}
function thumbSrc(item: RecentCapture): string {
return `data:image/png;base64,${item.pngBase64}`
return item.thumb
}
async function handleCapture() {
@@ -51,16 +51,26 @@ async function chooseSaveDir() {
}
async function handleCopy(item: RecentCapture) {
await store.copyImage(item.pngBase64)
try {
// 完整图从缓存按需加载(历史内存只保留缩略图)
const full = await store.loadFullImage(item)
await store.copyImage(full)
} catch (e) {
toast.error('加载完整图失败: ' + e)
}
}
async function handleSave(item: RecentCapture) {
await store.saveImage(item.pngBase64)
try {
const full = await store.loadFullImage(item)
await store.saveImage(full)
} catch (e) {
toast.error('加载完整图失败: ' + e)
}
}
function handleDelete(item: RecentCapture) {
const idx = store.recent.findIndex(r => r.id === item.id)
if (idx >= 0) store.recent.splice(idx, 1)
void store.removeRecent(item)
toast.success('已从历史移除')
}
@@ -149,12 +159,10 @@ async function clearShortcut() {
toast.success('已禁用截图快捷键')
}
onMounted(() => {
store.initExportListener().catch(e => console.error('[screenshot] 导出监听初始化失败:', e))
})
// 导出监听(screenshot-exported)由应用级注册(main.ts/App.vue),随应用生命周期管理;
// 模块卸载不得销毁该单例监听,否则离开截图模块后全局快捷键截图将不记录历史/不自动保存。
onUnmounted(() => {
store.destroyExportListener()
window.removeEventListener('keydown', onRecordKey, true)
})
</script>
+21 -65
View File
@@ -1,67 +1,23 @@
<script setup lang="ts">
import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue'
import type { Component } from 'vue'
import { invoke } from '@tauri-apps/api/core'
import { getCurrentWindow } from '@tauri-apps/api/window'
import { emit, listen, type UnlistenFn } from '@tauri-apps/api/event'
import { EVENTS, STORAGE_KEYS, WINDOWS } from '@/lib/constants'
// Rust 端通过 tauri-specta 生成的命令绑定(bindings.ts
import { commands } from '@/lib/bindings'
import {
Square, Circle, MoveUpRight, Pencil, Type, Grid3x3, Highlighter, ListOrdered,
Undo2, Redo2, Eraser, Copy, Save,
} from '@lucide/vue'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { Slider } from '@/components/ui/slider'
// ===== 类型 =====
type Phase = 'pick' | 'drawing' | 'selected' | 'editing'
type ToolType = 'rect' | 'ellipse' | 'arrow' | 'pen' | 'text' | 'mosaic' | 'highlight' | 'number'
interface Point { x: number; y: number }
interface Sel { x: number; y: number; w: number; h: number }
interface RectAnno { type: 'rect'; x1: number; y1: number; x2: number; y2: number; color: string; lineWidth: number }
interface EllipseAnno { type: 'ellipse'; x1: number; y1: number; x2: number; y2: number; color: string; lineWidth: number }
interface ArrowAnno { type: 'arrow'; x1: number; y1: number; x2: number; y2: number; color: string; lineWidth: number }
interface PenAnno { type: 'pen'; points: Point[]; color: string; lineWidth: number }
interface TextAnno { type: 'text'; x: number; y: number; text: string; color: string; fontSize: number }
interface MosaicAnno { type: 'mosaic'; x1: number; y1: number; x2: number; y2: number; blockSize: number }
interface HighlightAnno { type: 'highlight'; x1: number; y1: number; x2: number; y2: number; color: string; alpha: number }
interface NumberAnno { type: 'number'; x: number; y: number; n: number; color: string; fontSize: number }
type Annotation = RectAnno | EllipseAnno | ArrowAnno | PenAnno | TextAnno | MosaicAnno | HighlightAnno | NumberAnno
type DrawableAnnotation = RectAnno | EllipseAnno | ArrowAnno | PenAnno | MosaicAnno | HighlightAnno
interface CaptureData {
pngBase64: string
width: number
height: number
}
interface WindowInfo {
hwnd: number
title: string
rect: { x: number; y: number; width: number; height: number }
/** DWM 视觉边界(去掉最大化窗口隐形缩放边框),优先用于高亮框 */
visualRect: { x: number; y: number; width: number; height: number } | null
}
// ===== 工具与选项 =====
const TOOLS: { value: ToolType; icon: Component; label: string }[] = [
{ value: 'rect', icon: Square, label: '矩形' },
{ value: 'ellipse', icon: Circle, label: '椭圆' },
{ value: 'arrow', icon: MoveUpRight, label: '箭头' },
{ value: 'number', icon: ListOrdered, label: '序号' },
{ value: 'pen', icon: Pencil, label: '画笔' },
{ value: 'text', icon: Type, label: '文字' },
{ value: 'mosaic', icon: Grid3x3, label: '马赛克' },
{ value: 'highlight', icon: Highlighter, label: '高亮' },
]
const COLORS = ['#ef4444', '#f97316', '#eab308', '#22c55e', '#3b82f6', '#a855f7', '#000000', '#ffffff']
const BLOCK_SIZES = [8, 10, 14]
const ALPHAS = [0.2, 0.4, 0.6]
const HANDLES = ['nw', 'n', 'ne', 'e', 'se', 's', 'sw', 'w'] as const
type HandleDir = (typeof HANDLES)[number]
const HANDLE_HIT = 10
const DRAG_THRESHOLD = 4
import {
TOOLS, COLORS, BLOCK_SIZES, ALPHAS, HANDLES, HANDLE_HIT, DRAG_THRESHOLD,
type Phase, type ToolType, type Annotation, type DrawableAnnotation,
type Point, type Sel, type CaptureData, type WindowInfo, type HandleDir,
type RectAnno, type EllipseAnno, type ArrowAnno, type PenAnno, type TextAnno,
type MosaicAnno, type HighlightAnno, type NumberAnno,
} from './types'
// ===== 窗口 / 底图 =====
const win = getCurrentWindow()
@@ -1786,7 +1742,7 @@ async function finish() {
if (!out) {
out = await cropFromStored()
if (out) {
await invoke('screenshot_copy_image', { pngBase64: out.b64 }).catch((e) =>
await commands.screenshotCopyImage(out.b64).catch((e) =>
console.error('[screenshot] 复制失败', e)
)
}
@@ -1814,7 +1770,7 @@ async function finish() {
console.error('[screenshot] raw 复制失败,回退 base64 路径', e)
out = await exportBase64()
if (out) {
await invoke('screenshot_copy_image', { pngBase64: out.b64 }).catch((e2) =>
await commands.screenshotCopyImage(out.b64).catch((e2) =>
console.error('[screenshot] 复制失败', e2)
)
}
@@ -1823,7 +1779,7 @@ async function finish() {
}
}
if (!out) return
await emit('screenshot-exported', { pngBase64: out.b64, width: out.w, height: out.h })
await emit(EVENTS.screenshotExported, { pngBase64: out.b64, width: out.w, height: out.h })
} catch (e) {
console.error('[screenshot] 完成失败', e)
} finally {
@@ -1844,8 +1800,8 @@ async function doSave() {
filters: [{ name: 'PNG', extensions: ['png'] }],
})
if (!path) return
await invoke('screenshot_save_png', { pngBase64: out.b64, path })
await emit('screenshot-exported', { pngBase64: out.b64, width: out.w, height: out.h })
await commands.screenshotSavePng(out.b64, path)
await emit(EVENTS.screenshotExported, { pngBase64: out.b64, width: out.w, height: out.h })
await win.hide().catch(() => {})
} catch (e) {
console.error('[screenshot] 保存失败', e)
@@ -1878,7 +1834,7 @@ function applyTheme() {
const root = document.documentElement
let theme = 'system'
try {
const raw = localStorage.getItem('thing_app_settings')
const raw = localStorage.getItem(STORAGE_KEYS.appSettings)
if (raw) {
const s = JSON.parse(raw)
theme = s.theme ?? 'system'
@@ -1895,7 +1851,7 @@ function applyTheme() {
/** 主应用 localStorage 变化(主题切换)时同步主题 */
function onStorageChange(e: StorageEvent) {
if (e.key === 'thing_app_settings') {
if (e.key === STORAGE_KEYS.appSettings) {
applyTheme()
}
}
@@ -1926,12 +1882,12 @@ onMounted(async () => {
// 首次同步窗口尺寸
await refreshWinSize()
// 禁用窗口显示/隐藏过渡动画(消除进入/关闭时的缩放动画),失败静默
invoke('screenshot_disable_transitions', { label: 'screenshot-overlay' }).catch(() => {})
commands.screenshotDisableTransitions(WINDOWS.screenshotOverlay).catch(() => {})
// 先注册 begin 监听再通知 store 就绪,避免首轮事件丢失
beginUnlisten = await listen('screenshot-begin', () => {
beginUnlisten = await listen(EVENTS.screenshotBegin, () => {
void beginCapture()
})
await emit('screenshot-overlay-ready')
await emit(EVENTS.screenshotOverlayReady)
})
/** 响应 store 的 'screenshot-begin':先装载底图(隐藏中),解码完成后再一次性显示窗口 */
@@ -2017,7 +1973,7 @@ onUnmounted(() => {
magGridCanvas = null
if (objectUrl) URL.revokeObjectURL(objectUrl)
// 覆盖层窗口真正销毁(应用退出)时释放 Rust 静态中的全屏原始像素
void invoke('screenshot_clear_fullscreen').catch(() => {})
void commands.screenshotClearFullscreen().catch(() => {})
})
</script>
+60
View File
@@ -0,0 +1,60 @@
/**
* ScreenshotOverlay 共享类型与工具常量。
* 标注数据结构 + 工具栏/手柄/颜色等选项常量,不依赖组件状态。
*/
import type { Component } from 'vue'
import {
Circle, Grid3x3, Highlighter, ListOrdered, MoveUpRight,
Pencil, Square, Type,
} from '@lucide/vue'
export type Phase = 'pick' | 'drawing' | 'selected' | 'editing'
export type ToolType = 'rect' | 'ellipse' | 'arrow' | 'pen' | 'text' | 'mosaic' | 'highlight' | 'number'
export interface Point { x: number; y: number }
export interface Sel { x: number; y: number; w: number; h: number }
export interface RectAnno { type: 'rect'; x1: number; y1: number; x2: number; y2: number; color: string; lineWidth: number }
export interface EllipseAnno { type: 'ellipse'; x1: number; y1: number; x2: number; y2: number; color: string; lineWidth: number }
export interface ArrowAnno { type: 'arrow'; x1: number; y1: number; x2: number; y2: number; color: string; lineWidth: number }
export interface PenAnno { type: 'pen'; points: Point[]; color: string; lineWidth: number }
export interface TextAnno { type: 'text'; x: number; y: number; text: string; color: string; fontSize: number }
export interface MosaicAnno { type: 'mosaic'; x1: number; y1: number; x2: number; y2: number; blockSize: number }
export interface HighlightAnno { type: 'highlight'; x1: number; y1: number; x2: number; y2: number; color: string; alpha: number }
export interface NumberAnno { type: 'number'; x: number; y: number; n: number; color: string; fontSize: number }
export type Annotation = RectAnno | EllipseAnno | ArrowAnno | PenAnno | TextAnno | MosaicAnno | HighlightAnno | NumberAnno
export type DrawableAnnotation = RectAnno | EllipseAnno | ArrowAnno | PenAnno | MosaicAnno | HighlightAnno
export interface CaptureData {
pngBase64: string
width: number
height: number
}
export interface WindowInfo {
hwnd: number
title: string
rect: { x: number; y: number; width: number; height: number }
/** DWM 视觉边界(去掉最大化窗口隐形缩放边框),优先用于高亮框 */
visualRect: { x: number; y: number; width: number; height: number } | null
}
// ===== 工具与选项 =====
export const TOOLS: { value: ToolType; icon: Component; label: string }[] = [
{ value: 'rect', icon: Square, label: '矩形' },
{ value: 'ellipse', icon: Circle, label: '椭圆' },
{ value: 'arrow', icon: MoveUpRight, label: '箭头' },
{ value: 'number', icon: ListOrdered, label: '序号' },
{ value: 'pen', icon: Pencil, label: '画笔' },
{ value: 'text', icon: Type, label: '文字' },
{ value: 'mosaic', icon: Grid3x3, label: '马赛克' },
{ value: 'highlight', icon: Highlighter, label: '高亮' },
]
export const COLORS = ['#ef4444', '#f97316', '#eab308', '#22c55e', '#3b82f6', '#a855f7', '#000000', '#ffffff']
export const BLOCK_SIZES = [8, 10, 14]
export const ALPHAS = [0.2, 0.4, 0.6]
export const HANDLES = ['nw', 'n', 'ne', 'e', 'se', 's', 'sw', 'w'] as const
export type HandleDir = (typeof HANDLES)[number]
export const HANDLE_HIT = 10
export const DRAG_THRESHOLD = 4
+3 -2
View File
@@ -3,6 +3,7 @@ import { ref, reactive, computed, onMounted, onUnmounted, nextTick } from 'vue'
import { invoke } from '@tauri-apps/api/core'
import { listen, type UnlistenFn } from '@tauri-apps/api/event'
import { getCurrentWindow } from '@tauri-apps/api/window'
import { STORAGE_KEYS } from '@/lib/constants'
import { Effect, EffectState } from '@tauri-apps/api/window'
import {
Globe, Power, PowerOff, RefreshCw, Check, Monitor, Download,
@@ -41,7 +42,7 @@ let unlistenFns: UnlistenFn[] = []
function readOsdVisible(): boolean {
try {
const raw = localStorage.getItem('thing_monitor_osd_config')
const raw = localStorage.getItem(STORAGE_KEYS.monitorOsdConfig)
if (raw) {
const parsed = JSON.parse(raw)
return parsed.config?.overlayEnabled ?? false
@@ -337,7 +338,7 @@ async function measureAndShow() {
function readMainTheme(): { theme: string; effect: string } {
try {
const raw = localStorage.getItem('thing_app_settings')
const raw = localStorage.getItem(STORAGE_KEYS.appSettings)
if (raw) {
const s = JSON.parse(raw)
return { theme: s.theme ?? 'system', effect: s.effect ?? 'mica' }
+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,