diff --git a/AI_DEV_GUIDE.md b/AI_DEV_GUIDE.md index ceee84e..2214912 100644 --- a/AI_DEV_GUIDE.md +++ b/AI_DEV_GUIDE.md @@ -315,11 +315,11 @@ const status = processStore.getProcessStatus('proxy') | 命令 | 参数 | 返回值 | |------|------|--------| -| `start_process` | `StartProcessParams` | `ProcessInfo` | -| `stop_process` | `id: String` | `()` | -| `get_process_status` | `id: String` | `Option` | -| `get_all_process_status` | - | `Vec` | -| `stop_all_processes` | - | `()` | +| `process_start` | `StartProcessParams` | `ProcessInfo` | +| `process_stop` | `id: String` | `()` | +| `process_status` | `id: String` | `Option` | +| `process_all_status` | - | `Vec` | +| `process_stop_all` | - | `()` | ### 事件 @@ -388,8 +388,8 @@ rusqlite = { version = "0.30", features = ["bundled"] } ### Rust 调试 -- 使用 `println!()` 输出到终端 -- 使用 `dbg!()` 宏调试变量 +- 使用 `crate::logger::log_info / log_warn / log_error`(或 `log_line`)写入统一日志文件,日志页面可过滤级别 +- 使用 `dbg!()` 宏调试变量(仅临时,提交前删除) - 使用 Visual Studio Code 的 Rust 调试插件 ### 构建问题排查 diff --git a/MODULE_DEV_GUIDE.md b/MODULE_DEV_GUIDE.md index 4579455..d6a9a40 100644 --- a/MODULE_DEV_GUIDE.md +++ b/MODULE_DEV_GUIDE.md @@ -397,7 +397,7 @@ thread::spawn(move || { ### 后端:内核/二进制下载用流式 + 事件推送 -`mihomo_manager.rs` 的 `install_kernel` 确立了"下载二进制资源"的标准模式,未来若有其他模块需要下载外部内核时应复用: +`mihomo_manager/` 目录(`kernel.rs`)的 `install_kernel` 确立了"下载二进制资源"的标准模式,未来若有其他模块需要下载外部内核时应复用: - **流式下载**:`reqwest::Response::bytes_stream()` + `futures_util::StreamExt`,避免大文件一次性读入内存 - **进度事件**:通过 `app.emit("xxx-install-progress", progress)` 推送,事件载荷结构参考 `InstallProgress` @@ -492,13 +492,13 @@ TitleBar 的关闭按钮实际是 `hide()` 到托盘。webview 快速隐藏时 ### 前端:浮动标签切换器(TabsList 滚动遮挡时在 TitleBar 显示) -模块详情页内容滚动时,顶部 `TabsList` 会被 `TitleBar` 遮挡,导致用户必须滚回顶部才能切换 Tab。已抽取通用 composable `src/lib/useModuleTabs.ts` 自动处理。 +模块详情页内容滚动时,顶部 `TabsList` 会被 `TitleBar` 遮挡,导致用户必须滚回顶部才能切换 Tab。已抽取通用 composable `src/lib/use-module-tabs.ts` 自动处理。 **接入方式**(任何使用 Tabs 的模块都可用,代理/下载器模块已接入): ```ts // 模块 @@ -245,10 +210,5 @@ onUnmounted(() => { - -
- -
diff --git a/src/components/layout/TitleBar.vue b/src/components/layout/TitleBar.vue index 7c3819e..5980483 100644 --- a/src/components/layout/TitleBar.vue +++ b/src/components/layout/TitleBar.vue @@ -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 }) => { + // 保存 unlisten,onUnmounted 时释放(onFocusChanged 返回 Promise) + 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) }) diff --git a/src/lib/bindings.ts b/src/lib/bindings.ts new file mode 100644 index 0000000..93c6b0a --- /dev/null +++ b/src/lib/bindings.ts @@ -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("proxy_activate_profile", { id }), + proxyCheckKernelUpdate: () => __TAURI_INVOKE("proxy_check_kernel_update"), + proxyClearSystemProxy: () => __TAURI_INVOKE("proxy_clear_system_proxy"), + proxyCloseConnection: (id: string) => __TAURI_INVOKE("proxy_close_connection", { id }), + proxyDeleteProfile: (id: string) => __TAURI_INVOKE("proxy_delete_profile", { id }), + proxyGetSettings: () => __TAURI_INVOKE("proxy_get_settings"), + proxyGetSystemProxy: () => __TAURI_INVOKE("proxy_get_system_proxy"), + proxyImportProfile: (url: string, name: string) => __TAURI_INVOKE("proxy_import_profile", { url, name }), + /** 首次安装内核(与 update_kernel 共用 install_kernel 实现,语义独立便于前端区分场景) */ + proxyInstallKernel: (mirrorPrefix: string | null) => __TAURI_INVOKE("proxy_install_kernel", { mirrorPrefix }), + proxyKernelInfo: () => __TAURI_INVOKE("proxy_kernel_info"), + proxyRestart: () => __TAURI_INVOKE("proxy_restart"), + proxySaveSettings: (settings: ProxySettings) => __TAURI_INVOKE("proxy_save_settings", { settings }), + proxySelectProxy: (group: string, name: string) => __TAURI_INVOKE("proxy_select_proxy", { group, name }), + proxySetSystemProxy: () => __TAURI_INVOKE("proxy_set_system_proxy"), + proxyStart: () => __TAURI_INVOKE("proxy_start"), + proxyStatus: () => __TAURI_INVOKE("proxy_status"), + proxyStop: () => __TAURI_INVOKE("proxy_stop"), + proxyTestDelay: (name: string, url: string | null, timeout: number | null) => __TAURI_INVOKE("proxy_test_delay", { name, url, timeout }), + proxyUpdateKernel: (mirrorPrefix: string | null) => __TAURI_INVOKE("proxy_update_kernel", { mirrorPrefix }), + proxyUpdateProfile: (id: string) => __TAURI_INVOKE("proxy_update_profile", { id }), + /** 读取快速面板设置(快捷键等) */ + quickpanelGetSettings: () => __TAURI_INVOKE("quickpanel_get_settings"), + /** 保存快速面板设置;快捷键变化时自动重新注册 + 预创建窗口 */ + quickpanelSaveSettings: (settings: QuickPanelSettings) => __TAURI_INVOKE("quickpanel_save_settings", { settings }), + /** 注册(或切换)快速面板全局快捷键 */ + quickpanelRegisterShortcut: (shortcut: string) => __TAURI_INVOKE("quickpanel_register_shortcut", { shortcut }), + /** 注销快速面板全局快捷键 */ + quickpanelUnregisterShortcut: () => __TAURI_INVOKE("quickpanel_unregister_shortcut"), + /** 手动触发显示快速面板(供 UI 按钮调用) */ + quickpanelShowPopup: () => __TAURI_INVOKE("quickpanel_show_popup"), + /** 隐藏快速面板 */ + quickpanelHidePopup: () => __TAURI_INVOKE("quickpanel_hide_popup"), + /** 显示已创建的弹窗窗口(前端 onMounted 后调用) */ + quickpanelShowWindow: () => __TAURI_INVOKE("quickpanel_show_window"), + /** 锁定屏幕(Windows: rundll32 user32.dll,LockWorkStation,CREATE_NO_WINDOW 避免黑窗) */ + quickpanelLockScreen: () => __TAURI_INVOKE("quickpanel_lock_screen"), + /** 初始化文件索引数据库(应用启动时调用) */ + quickpanelInitFileIndex: () => __TAURI_INVOKE("quickpanel_init_file_index"), + /** 构建文件索引(全量重建,阻塞操作建议在 spawn_blocking 调用) */ + quickpanelBuildFileIndex: () => __TAURI_INVOKE("quickpanel_build_file_index"), + /** 搜索文件索引(SQLite 查询移出主线程) */ + quickpanelSearchFiles: (query: string, limit: number | null) => __TAURI_INVOKE("quickpanel_search_files", { query, limit }), + /** 获取索引状态 */ + quickpanelFileIndexStats: () => __TAURI_INVOKE("quickpanel_file_index_stats"), + /** 扫描已安装应用(遍历开始菜单/桌面/磁盘,移出主线程) */ + quickpanelScanApps: () => __TAURI_INVOKE("quickpanel_scan_apps"), + /** + * 获取应用图标(data URL)。命中内存/磁盘缓存时零 Windows API 调用。 + * 前端按需为可见项调用,避免一次性加载全部图标。 + * 未命中缓存时 SHGetFileInfoW + 编码 + 落盘为阻塞操作,移出主线程。 + */ + quickpanelGetAppIcon: (path: string) => __TAURI_INVOKE("quickpanel_get_app_icon", { path }), + /** 清理图标缓存(磁盘 + 内存) */ + quickpanelClearAppIconCache: () => __TAURI_INVOKE("quickpanel_clear_app_icon_cache"), + /** 在资源管理器中显示文件(选中) */ + quickpanelRevealInExplorer: (path: string) => __TAURI_INVOKE("quickpanel_reveal_in_explorer", { path }), + /** + * 用系统默认程序打开文件/文件夹。 + * - 目录:explorer.exe 直接打开(修复索引目录点击后未打开的问题) + * - 文件:ShellExecuteW open,无关联应用时自动 fallback 到「打开方式」对话框(verb: openas) + */ + quickpanelOpenFile: (path: string) => __TAURI_INVOKE("quickpanel_open_file", { path }), + /** 获取 Windows 常用快捷位置(hosts、回收站、此电脑、用户目录、系统管理工具等) */ + quickpanelGetSpecialLocations: () => __TAURI_INVOKE("quickpanel_get_special_locations"), + /** 打开快捷位置(kind: file | shell | cmd) */ + quickpanelOpenSpecial: (kind: string, target: string, args: string[]) => __TAURI_INVOKE("quickpanel_open_special", { kind, target, args }), + /** 删除文件(移到回收站,PowerShell 阻塞等待移出主线程) */ + quickpanelDeleteFile: (path: string) => __TAURI_INVOKE("quickpanel_delete_file", { path }), + /** + * 运行自定义命令(执行可执行文件 + 参数) + * .lnk 快捷方式不能直接 spawn(os error 193),需通过 cmd /C 启动 + */ + quickpanelRunCustomCommand: (command: string, args: string[]) => __TAURI_INVOKE("quickpanel_run_custom_command", { command, args }), + /** + * 运行系统命令(不设置 CREATE_NO_WINDOW,使 cmd/powershell/regedit 等显示自身窗口) + * 适用于内置系统工具:regedit、shutdown、cmd、powershell、taskmgr 等。 + */ + quickpanelRunSystemCommand: (command: string, args: string[]) => __TAURI_INVOKE("quickpanel_run_system_command", { command, args }), + clipboardGetHistory: (limit: number | null, offset: number | null, kind: string | null) => __TAURI_INVOKE("clipboard_get_history", { limit, offset, kind }), + clipboardGetPinned: () => __TAURI_INVOKE("clipboard_get_pinned"), + clipboardSearch: (query: string, limit: number | null, offset: number | null) => __TAURI_INVOKE("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("clipboard_set_pinned", { id, pinned }), + clipboardDelete: (id: number) => __TAURI_INVOKE("clipboard_delete", { id }), + clipboardClear: () => __TAURI_INVOKE("clipboard_clear"), + clipboardCopyBack: (id: number) => __TAURI_INVOKE("clipboard_copy_back", { id }), + clipboardCount: () => __TAURI_INVOKE("clipboard_count"), + clipboardGetSettings: () => __TAURI_INVOKE("clipboard_get_settings"), + clipboardSaveSettings: (settings: ClipboardSettings) => __TAURI_INVOKE("clipboard_save_settings", { settings }), + clipboardStatus: () => __TAURI_INVOKE("clipboard_status"), + clipboardStart: () => __TAURI_INVOKE("clipboard_start"), + clipboardStop: () => __TAURI_INVOKE("clipboard_stop"), + /** 注册(或切换)快捷弹窗全局快捷键 */ + clipboardRegisterShortcut: (shortcut: string) => __TAURI_INVOKE("clipboard_register_shortcut", { shortcut }), + /** 注销快捷弹窗全局快捷键 */ + clipboardUnregisterShortcut: () => __TAURI_INVOKE("clipboard_unregister_shortcut"), + /** 手动触发显示快捷弹窗(供 UI 按钮调用) */ + clipboardShowPopup: () => __TAURI_INVOKE("clipboard_show_popup"), + /** 隐藏快捷弹窗 */ + clipboardHidePopup: () => __TAURI_INVOKE("clipboard_hide_popup"), + /** 显示已创建的弹窗窗口(前端 onMounted 后调用) */ + clipboardShowWindow: () => __TAURI_INVOKE("clipboard_show_window"), + /** 隐藏弹窗并模拟 Ctrl+V 粘贴到原窗口 */ + clipboardPasteToTarget: () => __TAURI_INVOKE("clipboard_paste_to_target"), + /** 获取所有任务 */ + downloaderGetTasks: () => __TAURI_INVOKE("downloader_get_tasks"), + /** 检查 URL 重复性并探测文件信息(添加下载前调用) */ + downloaderCheckUrl: (url: string, dir: string | null, headers: { [key in string]: string } | null) => __TAURI_INVOKE("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("downloader_add_task", { url, filename, dir, headers, autoRename }), + /** 暂停任务 */ + downloaderPauseTask: (id: string) => __TAURI_INVOKE("downloader_pause_task", { id }), + /** 恢复任务 */ + downloaderResumeTask: (id: string) => __TAURI_INVOKE("downloader_resume_task", { id }), + /** 移除任务 */ + downloaderRemoveTask: (id: string, deleteFiles: boolean | null) => __TAURI_INVOKE("downloader_remove_task", { id, deleteFiles }), + /** 获取设置 */ + downloaderGetSettings: () => __TAURI_INVOKE("downloader_get_settings"), + /** 保存设置 */ + downloaderSaveSettings: (settings: DownloaderSettings) => __TAURI_INVOKE("downloader_save_settings", { settings }), + /** 用系统资源管理器打开目录 */ + downloaderOpenDir: (path: string) => __TAURI_INVOKE("downloader_open_dir", { path }), + /** 用系统默认浏览器打开 URL */ + downloaderOpenUrl: (url: string) => __TAURI_INVOKE("downloader_open_url", { url }), + /** 禁用指定窗口(按 label 查找)的显示/隐藏过渡动画,消除覆盖层出现/消失时的缩放动画 */ + screenshotDisableTransitions: (label: string) => __TAURI_INVOKE("screenshot_disable_transitions", { label }), + /** 注册(或切换)截图全局快捷键。传入空字符串则禁用快捷键。 */ + screenshotRegisterShortcut: (shortcut: string) => __TAURI_INVOKE("screenshot_register_shortcut", { shortcut }), + /** 注销截图全局快捷键 */ + screenshotUnregisterShortcut: () => __TAURI_INVOKE("screenshot_unregister_shortcut"), + /** 捕获整个虚拟屏(多显示器拼接)存入静态,不做 PNG 编码 */ + screenshotCaptureFullscreen: () => __TAURI_INVOKE("screenshot_capture_fullscreen"), + /** 全屏捕获编码为 PNG base64 并清除(全屏截图直接进编辑器) */ + screenshotFullscreenPng: () => __TAURI_INVOKE("screenshot_fullscreen_png"), + /** 清除静态全屏捕获(覆盖层关闭/取消时释放内存) */ + screenshotClearFullscreen: () => __TAURI_INVOKE("screenshot_clear_fullscreen"), + /** 按物理像素坐标裁剪已存储的全屏捕获 */ + screenshotCropStored: (x: number, y: number, w: number, h: number) => __TAURI_INVOKE("screenshot_crop_stored", { x, y, w, h }), + /** 裁剪已存储的全屏捕获并直接写入剪贴板(一次 IPC 完成"裁剪+复制") */ + screenshotCropCopyStored: (x: number, y: number, w: number, h: number) => __TAURI_INVOKE("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("screenshot_enum_windows"), + /** 按 hwnd 捕获指定窗口 */ + screenshotCaptureWindow: (hwnd: number) => __TAURI_INVOKE("screenshot_capture_window", { hwnd }), + /** 存入编辑器图片(base64 PNG) */ + screenshotSetEditorImage: (pngBase64: string) => __TAURI_INVOKE("screenshot_set_editor_image", { pngBase64 }), + /** 取出编辑器图片(编辑器窗口加载时调用,取出即清除) */ + screenshotGetEditorImage: () => __TAURI_INVOKE("screenshot_get_editor_image"), + /** 将 PNG base64 写入系统剪贴板(转 CF_DIB) */ + screenshotCopyImage: (pngBase64: string) => __TAURI_INVOKE("screenshot_copy_image", { pngBase64 }), + /** 将 PNG base64 写入文件 */ + screenshotSavePng: (pngBase64: string, path: string) => __TAURI_INVOKE("screenshot_save_png", { pngBase64, path }), + /** 将完整 PNG 写入历史缓存目录,返回文件路径 */ + screenshotSaveCache: (pngBase64: string) => __TAURI_INVOKE("screenshot_save_cache", { pngBase64 }), + /** 从历史缓存目录读取 PNG 并返回 base64(点击历史项复制/保存时一次性加载,不常驻内存) */ + screenshotLoadCache: (path: string) => __TAURI_INVOKE("screenshot_load_cache", { path }), + /** 删除历史缓存文件(历史项移除/清空时调用,静默忽略不存在文件) */ + screenshotDeleteCache: (path: string) => __TAURI_INVOKE("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/s(0=不限) */ + 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, +}; + diff --git a/src/lib/calc.test.ts b/src/lib/calc.test.ts new file mode 100644 index 0000000..c9c0941 --- /dev/null +++ b/src/lib/calc.test.ts @@ -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) +}) diff --git a/src/lib/calc.ts b/src/lib/calc.ts new file mode 100644 index 0000000..4bc8f53 --- /dev/null +++ b/src/lib/calc.ts @@ -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 +} diff --git a/src/lib/constants.ts b/src/lib/constants.ts new file mode 100644 index 0000000..32c5492 --- /dev/null +++ b/src/lib/constants.ts @@ -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 diff --git a/src/lib/logger.ts b/src/lib/logger.ts index 1af97ce..b20f2ca 100644 --- a/src/lib/logger.ts +++ b/src/lib/logger.ts @@ -86,19 +86,19 @@ export async function getLogs( level?: LogLevel, limit?: number, ): Promise { - return invoke('get_logs', { module, level, limit }) + return invoke('log_list', { module, level, limit }) } /** * 清空所有日志文件。 */ export async function clearLogs(): Promise { - return invoke('clear_logs') + return invoke('log_clear') } /** * 获取日志系统信息(目录、文件列表、空间占用)。 */ export async function getLogInfo(): Promise { - return invoke('get_log_info') + return invoke('log_info_state') } diff --git a/src/lib/trayEvents.ts b/src/lib/trayEvents.ts index de13811..4b64b66 100644 --- a/src/lib/trayEvents.ts +++ b/src/lib/trayEvents.ts @@ -10,6 +10,3 @@ import { ref } from 'vue' /** 待打开新建下载对话框(由托盘"新建下载"触发) */ export const pendingNewDownload = ref(false) - -/** 待切换到设置模块(由托盘"常规设置"触发) */ -export const pendingOpenSettings = ref(false) diff --git a/src/lib/useModuleTabs.ts b/src/lib/use-module-tabs.ts similarity index 100% rename from src/lib/useModuleTabs.ts rename to src/lib/use-module-tabs.ts diff --git a/src/main.ts b/src/main.ts index c6399eb..9462fa2 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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 { diff --git a/src/modules/clipboard/ClipboardModule.vue b/src/modules/clipboard/ClipboardModule.vue index 7bd432e..d66395c 100644 --- a/src/modules/clipboard/ClipboardModule.vue +++ b/src/modules/clipboard/ClipboardModule.vue @@ -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' diff --git a/src/modules/clipboard/ClipboardPopup.vue b/src/modules/clipboard/ClipboardPopup.vue index c7a1fb2..56c6562 100644 --- a/src/modules/clipboard/ClipboardPopup.vue +++ b/src/modules/clipboard/ClipboardPopup.vue @@ -1,9 +1,11 @@