diff --git a/AI_DEV_GUIDE.md b/AI_DEV_GUIDE.md index cc9ed03..ceee84e 100644 --- a/AI_DEV_GUIDE.md +++ b/AI_DEV_GUIDE.md @@ -19,7 +19,7 @@ src/modules// ├── index.ts # 模块配置(ModuleConfig) ├── ModuleComponent.vue # 前端组件 -src-tauri/src/commands/ # Rust 命令(可选) +src-tauri/src/ # Rust 后端逻辑(按功能分文件/模块目录) ``` #### 模块配置类型 @@ -167,10 +167,10 @@ onUnmounted(() => { ### Rust 命令模板 -创建 `src-tauri/src/commands/.rs`: +在 `src-tauri/src/` 中创建模块文件(如 `src-tauri/src/my_module.rs` 或 `src-tauri/src/my_module/` 目录),并在 `lib.rs` 中注册: ```rust -// commands/module_name.rs +// src-tauri/src/my_module.rs use serde::Serialize; use tauri::State; @@ -230,9 +230,9 @@ fn save_data(data: &str) -> Result<(), Box> { ```rust // lib.rs -mod commands; +mod my_module; -use commands::module_name::{module_get_data, module_set_data}; +use my_module::{module_get_data, module_set_data}; #[tauri::command] fn greet(name: &str) -> String { @@ -254,18 +254,19 @@ pub fn run() { ## 常用 Tauri 插件 -| 插件 | 用途 | 安装命令 | -|------|------|----------| -| `tauri-plugin-autostart` | 开机自启 | `cargo add tauri-plugin-autostart` | -| `tauri-plugin-tray` | 系统托盘 | `cargo add tauri-plugin-tray` | -| `tauri-plugin-log` | 日志系统 | `cargo add tauri-plugin-log` | -| `tauri-plugin-shell` | 执行命令 | `cargo add tauri-plugin-shell` | -| `tauri-plugin-clipboard-manager` | 剪贴板 | `cargo add tauri-plugin-clipboard-manager` | -| `tauri-plugin-window-state` | 窗口状态 | `cargo add tauri-plugin-window-state` | +项目当前使用的 Tauri 插件(见 `Cargo.toml`): + +| 插件 | 用途 | +|------|------| +| `tauri-plugin-autostart` | 开机自启 | +| `tauri-plugin-opener` | 打开文件/目录/URL(绕过 IPC scope 限制) | +| `tauri-plugin-dialog` | 文件/目录选择对话框 | + +> 系统托盘(tray)和窗口效果(mica/acrylic)是 Tauri 2 内置能力,无需额外插件。日志系统为自建(`src-tauri/src/logger.rs`),未使用 `tauri-plugin-log`。 ## 进程管理 -对于需要管理外部进程的模块(如 mihomo、aria2),使用内置的 `ProcessManager`: +对于需要管理外部子进程的模块(如 mihomo),使用内置的 `ProcessManager`。下载器模块使用进程内自建下载引擎(`src-tauri/src/download_engine/`),不涉及外部子进程: ### 架构 @@ -349,21 +350,31 @@ rusqlite = { version = "0.30", features = ["bundled"] } ## 权限配置 -在 `src-tauri/capabilities/default.json` 中配置权限: +在 `src-tauri/capabilities/default.json` 中配置权限(实际项目配置): ```json { - "$schema": "../node_modules/@tauri-apps/cli/schemas/desktop-capability.json", + "$schema": "../gen/schemas/desktop-schema.json", "identifier": "default", - "description": "Default capabilities for the app", + "description": "Capability for the main window", "windows": ["main"], "permissions": [ "core:default", - "shell:allow-execute", - "shell:allow-spawn", - "path:allow-app-data-dir", - "path:allow-read", - "path:allow-write" + "opener:default", + "opener:allow-reveal-item-in-dir", + "opener:allow-open-path", + "dialog:default", + "core:window:allow-minimize", + "core:window:allow-maximize", + "core:window:allow-close", + "core:window:allow-toggle-maximize", + "core:window:allow-hide", + "core:window:allow-show", + "core:window:allow-set-focus", + "core:window:allow-start-dragging", + "core:window:allow-set-effects", + "core:window:allow-set-background-color", + "core:window:allow-set-theme" ] } ``` @@ -425,8 +436,8 @@ let path = Path::new("data").join("config.json"); # 创建前端模块目录 mkdir -p src/modules/new-module -# 创建 Rust 命令文件 -touch src-tauri/src/commands/new_module.rs +# 创建 Rust 模块文件 +touch src-tauri/src/new_module.rs ``` ### 模板文件 diff --git a/MODULE_DEV_GUIDE.md b/MODULE_DEV_GUIDE.md index 88e9829..4579455 100644 --- a/MODULE_DEV_GUIDE.md +++ b/MODULE_DEV_GUIDE.md @@ -142,7 +142,7 @@ export const moduleIconMap: Record = { ## 进程管理 -需要管理外部子进程的模块(如 mihomo、aria2)通过 `ModuleProcessConfig` 声明进程配置。 +需要管理外部子进程的模块(如 mihomo)通过 `ModuleProcessConfig` 声明进程配置。下载器模块使用进程内自建下载引擎,不涉及外部子进程管理。 ### 配置示例 @@ -370,8 +370,7 @@ appStore.toggleModule(moduleId, enabled) - `ProcessManager::start()` 启动 mihomo 等内核 - `ProcessManager::check_and_cleanup()` 崩溃重启 -- 任何调用 `mihomo.exe -v`、`aria2c --version` 等查询版本的场景 -- 未来下载器模块启动 aria2 时同样适用 +- 任何调用 `mihomo.exe -v` 等查询版本的场景 跨平台:非 Windows 平台该函数为空实现,无需条件编译。 @@ -398,7 +397,7 @@ thread::spawn(move || { ### 后端:内核/二进制下载用流式 + 事件推送 -`mihomo_manager.rs` 的 `install_kernel` 确立了"下载二进制资源"的标准模式,未来下载器模块下载 aria2 内核时应复用: +`mihomo_manager.rs` 的 `install_kernel` 确立了"下载二进制资源"的标准模式,未来若有其他模块需要下载外部内核时应复用: - **流式下载**:`reqwest::Response::bytes_stream()` + `futures_util::StreamExt`,避免大文件一次性读入内存 - **进度事件**:通过 `app.emit("xxx-install-progress", progress)` 推送,事件载荷结构参考 `InstallProgress` @@ -550,21 +549,21 @@ const pathDisplay = computed(() => { `` 放完整路径,显示文本用 `pathDisplay`,复制时复制完整路径。 -### 前端:任务历史 localStorage 持久化(下载器模块模式) +### 前端:任务历史持久化(下载器模块模式) -子进程未启动时仍想展示历史数据(如下载任务列表),可在 store 中用 localStorage 保存最近一次的任务快照: +下载引擎在进程内运行,状态通过 `engine_state.json` 持久化到磁盘。前端 store 的做法: -- 每次全量刷新后调用 `persistHistory()` 保存(合并去重,按状态优先级排序,限制条数如 200) -- `onMounted` 时无论子进程是否运行都先调用 `loadHistory()` 填充到 `stoppedTasks` -- 子进程启动后实时数据会覆盖历史快照 +- `onMounted` 时调用 `store.init()`,一次性加载 status/settings/extensionInfo/tasks +- 通过 Tauri 事件 `download-progress`、`download-complete`、`download-added` 实时更新任务状态 +- 引擎在 `Storage::new` 时自动创建数据目录,`save` 时兜底重建父目录,避免路径不存在错误 -此模式适用于任何"子进程不运行时也要展示历史"的场景。 +此模式适用于任何"引擎在进程内运行、状态需持久化"的场景。 ### 前端:轻量设置 Dialog(替代独立 Tab) 模块设置项较多时,传统做法是单独开一个"设置"Tab。但任务页工具栏需要快速调整少量核心设置(如下载目录、并发数),切到设置 Tab 再切回来体验割裂。 -**模式**:在任务工具栏放一个"下载设置"按钮,点击弹出 Dialog,包含核心设置项(与设置页共用 `store.settings`),保存时调用 `handleSaveSettings`(重启子进程应用配置)并自动关闭弹窗。完整设置仍保留在"设置"Tab。 +**模式**:在任务工具栏放一个"下载设置"按钮,点击弹出 Dialog,包含核心设置项(与设置页共用 `store.settings`),保存时调用 `handleSaveSettings`(引擎立即应用新配置,无需重启)并自动关闭弹窗。完整设置仍保留在"设置"Tab。 适用场景:需要在任务页快速调整的少量高频设置;若设置项不多,可完全用 Dialog 替代设置 Tab。 @@ -621,4 +620,4 @@ const formatEta = (seconds: number): string => { 1. `quit_app` Tauri 命令(前端 `appWindow.destroy()` 触发) 2. 系统托盘的退出菜单项 -漏掉任何一个都会导致子进程残留(如 aria2 继续占用端口、mihomo 系统代理未清除)。新增管理子进程的模块时,检查这两处是否都调用了清理逻辑。 +漏掉任何一个都会导致子进程残留(如 mihomo 继续占用端口、系统代理未清除)。新增管理子进程的模块时,检查这两处是否都调用了清理逻辑。 diff --git a/README.md b/README.md index 11a9bd6..1bb76c8 100644 --- a/README.md +++ b/README.md @@ -19,41 +19,39 @@ ``` Thing/ -├── public/ # 静态资源 ├── src/ -│ ├── assets/ # 前端资源 │ ├── components/ # 通用组件 -│ │ ├── layout/ # 布局组件 +│ │ ├── layout/ # 布局组件(TitleBar、Sidebar、ModuleContainer) │ │ └── ui/ # shadcn-vue UI 组件 -│ ├── composables/ # Vue 组合式函数 -│ ├── lib/ # 工具库(cn 等) -│ ├── modules/ # 功能模块 +│ ├── lib/ # 工具库(logger、useModuleTabs、utils) +│ ├── modules/ # 功能模块(每个模块含 index.ts 配置 + Vue 组件) │ │ ├── proxy/ # 代理管理模块 │ │ ├── clipboard/ # 剪贴板增强模块 │ │ ├── screenshot/ # 截图模块 │ │ ├── monitor/ # 硬件监控模块 │ │ ├── downloader/ # 下载器模块 -│ │ └── finder/ # 文件搜索模块 +│ │ ├── finder/ # 文件搜索模块 +│ │ ├── general/ # 通用设置模块 +│ │ ├── icons.ts # 模块图标映射 +│ │ ├── index.ts # 模块聚合入口 +│ │ └── registry.ts # 模块注册表单例 │ ├── stores/ # Pinia 状态管理 │ ├── types/ # TypeScript 类型定义 │ ├── App.vue # 主应用组件 │ ├── main.ts # 入口文件 -│ └── assets/main.css # 全局样式 +│ └── style.css # 全局样式 ├── src-tauri/ +│ ├── binaries/ # 外部内核(mihomo.exe) │ ├── capabilities/ # Tauri 权限配置 │ ├── icons/ # 应用图标 +│ ├── resources/ # 打包资源(浏览器扩展等) │ ├── src/ -│ │ ├── commands/ # Rust 命令 -│ │ │ ├── proxy.rs # 代理相关命令 -│ │ │ ├── clipboard.rs # 剪贴板相关命令 -│ │ │ ├── screenshot.rs # 截图相关命令 -│ │ │ ├── monitor.rs # 监控相关命令 -│ │ │ ├── downloader.rs # 下载器相关命令 -│ │ │ └── finder.rs # 文件搜索相关命令 -│ │ ├── modules/ # Rust 模块逻辑 -│ │ ├── utils/ # Rust 工具函数 +│ │ ├── download_engine/ # 下载引擎(进程内自建,含 engine/storage/http_dl/server 等) +│ │ ├── logger.rs # 日志系统 +│ │ ├── mihomo_manager.rs # mihomo 内核管理(代理模块后端) +│ │ ├── process_manager.rs # 子进程统一管理 │ │ ├── main.rs # Rust 入口 -│ │ └── lib.rs # Rust 库入口 +│ │ └── lib.rs # Rust 库入口(命令注册、应用初始化) │ ├── Cargo.toml # Rust 依赖配置 │ └── tauri.conf.json # Tauri 配置 ├── package.json # 前端依赖配置 @@ -106,8 +104,8 @@ Thing/ - [ ] 网速监控 #### ⬇️ 下载器 -- [x] 下载/内置 aria2 内核集成 -- [x] 接管浏览器下载,创建edge插件(Thing Extension)? +- [x] 自建进程内下载引擎(多线程 HTTP/HTTPS,无需外部内核) +- [x] 接管浏览器下载,浏览器扩展(Thing Extension) - [x] HTTP 下载支持 - [ ] BT/磁力链接支持 - [x] 下载任务管理(历史) @@ -168,7 +166,7 @@ export const moduleConfig: ModuleConfig = { ### 进程管理架构 -需要管理外部子进程的模块(如代理的 mihomo、下载器的 aria2)通过 `ModuleProcessConfig` 声明进程配置,由 Rust 端的 `ProcessManager` 统一管理生命周期: +需要管理外部子进程的模块(如代理的 mihomo)通过 `ModuleProcessConfig` 声明进程配置,由 Rust 端的 `ProcessManager` 统一管理生命周期。下载器模块使用进程内自建下载引擎,无需外部子进程: - **Rust 端** (`src-tauri/src/process_manager.rs`):管理子进程的启动、停止、重启和崩溃检测 - **前端** (`src/stores/processStore.ts`):Pinia store,通过 Tauri IPC 调用 Rust 命令,监听进程状态变更事件 diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 3873b18..35ac851 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -4122,6 +4122,22 @@ dependencies = [ "zbus", ] +[[package]] +name = "tauri-plugin-snap-layout" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f76070bb989b055b9c8a789520c3e2eb70f6cb155fc70598d464925e36a1d78" +dependencies = [ + "raw-window-handle", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.18", + "windows-sys 0.61.2", + "windows-version", +] + [[package]] name = "tauri-runtime" version = "2.11.3" @@ -4261,6 +4277,7 @@ dependencies = [ "tauri-plugin-autostart", "tauri-plugin-dialog", "tauri-plugin-opener", + "tauri-plugin-snap-layout", "tokio", "url", "windows-sys 0.52.0", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 40aec49..8eb5a28 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -21,6 +21,7 @@ tauri-build = { version = "2", features = [] } tauri = { version = "2", features = ["tray-icon"] } tauri-plugin-opener = "2" tauri-plugin-dialog = "2" +tauri-plugin-snap-layout = "1" serde = { version = "1", features = ["derive"] } serde_json = "1" serde_yaml = "0.9" diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index c0dd593..7cf204d 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -19,6 +19,7 @@ "core:window:allow-start-dragging", "core:window:allow-set-effects", "core:window:allow-set-background-color", - "core:window:allow-set-theme" + "core:window:allow-set-theme", + "snap-layout:default" ] } \ No newline at end of file diff --git a/src-tauri/src/download_engine/engine.rs b/src-tauri/src/download_engine/engine.rs index 785bf01..4df709e 100644 --- a/src-tauri/src/download_engine/engine.rs +++ b/src-tauri/src/download_engine/engine.rs @@ -158,9 +158,10 @@ impl DownloadEngine { let id = self.inner.storage.next_task_id(); - // 创建分段 + // 创建分段(受 continue_download 设置控制:关闭时强制单线程、不支持续传) + let resume_enabled = settings.continue_download; let segments = match &probe { - Ok(p) if p.supports_resume && p.total_size.map(|s| s > 0).unwrap_or(false) => { + Ok(p) if resume_enabled && p.supports_resume && p.total_size.map(|s| s > 0).unwrap_or(false) => { split_segments(p.total_size.unwrap(), settings.max_connections) } _ => vec![Segment { @@ -172,7 +173,8 @@ impl DownloadEngine { }; let total_size = probe.as_ref().ok().and_then(|p| p.total_size).unwrap_or(0); - let supports_resume = probe.as_ref().ok().map(|p| p.supports_resume).unwrap_or(false); + let supports_resume = resume_enabled + && probe.as_ref().ok().map(|p| p.supports_resume).unwrap_or(false); let task = DownloadTask { id: id.clone(), diff --git a/src-tauri/src/download_engine/http_dl.rs b/src-tauri/src/download_engine/http_dl.rs index dad7a0e..d14ac95 100644 --- a/src-tauri/src/download_engine/http_dl.rs +++ b/src-tauri/src/download_engine/http_dl.rs @@ -318,13 +318,14 @@ async fn download_segment_with_client( match stream.next().await { Some(Ok(chunk)) => { buf.extend_from_slice(&chunk); + // 接收到数据立即更新进度(避免监控周期内进度无变化导致速度显示为 0) + local_completed += chunk.len() as u64; + progress.store(local_completed, Ordering::Relaxed); // 积累到 64KB 再写入(减少 I/O 次数) if buf.len() >= 64 * 1024 { file.write_all(&buf) .await .map_err(|e| format!("写入文件失败: {}", e))?; - local_completed += buf.len() as u64; - progress.store(local_completed, Ordering::Relaxed); // 限速 limiter.consume(buf.len() as u64).await; buf.clear(); @@ -339,8 +340,7 @@ async fn download_segment_with_client( file.write_all(&buf) .await .map_err(|e| format!("写入文件失败: {}", e))?; - local_completed += buf.len() as u64; - progress.store(local_completed, Ordering::Relaxed); + // local_completed 和 progress 已在接收 chunk 时更新 limiter.consume(buf.len() as u64).await; buf.clear(); } diff --git a/src-tauri/src/download_engine/storage.rs b/src-tauri/src/download_engine/storage.rs index dc89b82..b26ebf0 100644 --- a/src-tauri/src/download_engine/storage.rs +++ b/src-tauri/src/download_engine/storage.rs @@ -38,6 +38,10 @@ pub struct Storage { impl Storage { pub fn new(data_dir: PathBuf) -> Self { + // 确保数据目录存在(首次启动或目录被删除时自动创建) + if let Err(e) = fs::create_dir_all(&data_dir) { + eprintln!("[download_engine] 创建数据目录失败: {} ({})", data_dir.display(), e); + } let state_path = data_dir.join("engine_state.json"); let existing = Self::load_raw(&state_path); let next_id = existing.as_ref().map(|s| s.next_id).unwrap_or(1); @@ -70,6 +74,10 @@ impl Storage { state.next_id = self.id_counter.load(Ordering::SeqCst); match serde_json::to_string_pretty(&state) { Ok(json) => { + // 兜底:若父目录被外部删除则在写入前重建 + if let Some(parent) = self.state_path.parent() { + let _ = fs::create_dir_all(parent); + } if let Err(e) = fs::write(&self.state_path, json) { eprintln!("[download_engine] 保存状态失败: {}", e); } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 4305ba6..1f70b98 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -54,6 +54,11 @@ pub fn run() { .plugin(tauri_plugin_autostart::Builder::new().build()) .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_dialog::init()) + .plugin( + tauri_plugin_snap_layout::init() + .button_id("titlebar-maximize") + .build() + ) .manage(ProcessManager::new()) .invoke_handler(tauri::generate_handler![ greet, diff --git a/src/components/layout/TitleBar.vue b/src/components/layout/TitleBar.vue index 3fdfcfa..bf84938 100644 --- a/src/components/layout/TitleBar.vue +++ b/src/components/layout/TitleBar.vue @@ -1,6 +1,6 @@ <script setup lang="ts"> -import { ref, computed, onUnmounted } from 'vue' -import { Search, Minus, Square, X, Settings, ChevronRight } from '@lucide/vue' +import { ref, computed, onMounted, onUnmounted } from 'vue' +import { Search, Minus, X, Settings, ChevronRight } from '@lucide/vue' import { Input } from '@/components/ui/input' import { getCurrentWindow } from '@tauri-apps/api/window' import { useSearchStore, type SearchItem } from '@/stores/searchStore' @@ -64,6 +64,10 @@ const minimize = async () => { await tauriWindow?.minimize() } +// 窗口最大化状态:切换最大化/还原图标 +const isMaximized = ref(false) +let unlistenMaximize: (() => void) | null = null + const maximize = async () => { await tauriWindow?.toggleMaximize() } @@ -119,9 +123,9 @@ if (tauriWindow) { if (document.activeElement instanceof HTMLElement) { document.activeElement.blur() } - // 200ms 后恢复 hover,足够让浏览器重置伪类状态 - restoreHover(200) - // 同时注册鼠标移动监听作为后备(定时器可能不够,鼠标移动更可靠) + // 不使用定时器恢复 hover:窗口从隐藏恢复显示时,浏览器 :hover 可能被冻结 + // (mouseleave 在窗口隐藏时不触发),定时器恢复会让冻结的 hover 提前暴露 + // 仅依赖鼠标移动恢复——移动时浏览器才会重新评估 :hover 状态 armMouseMoveRestore() } else { hoverSuppressed.value = true @@ -132,9 +136,23 @@ if (tauriWindow) { // 初始时窗口已显示,恢复 hover restoreHover(300) +onMounted(async () => { + if (tauriWindow) { + try { + isMaximized.value = await tauriWindow.isMaximized() + unlistenMaximize = await tauriWindow.onResized(async () => { + isMaximized.value = await tauriWindow!.isMaximized() + }) + } catch { + // 非 Tauri 环境忽略 + } + } +}) + onUnmounted(() => { window.removeEventListener('mousemove', handleFirstMouseMove) if (restoreHoverTimer) clearTimeout(restoreHoverTimer) + if (unlistenMaximize) unlistenMaximize() }) const handleBlur = () => { @@ -241,21 +259,30 @@ const handleBlur = () => { <div class="flex items-center pointer-events-auto" :class="{ 'hover-suppressed': hoverSuppressed }"> <button - class="h-10 w-10 flex items-center justify-center hover:bg-secondary/50 transition-colors rounded-sm" + class="h-10 w-10 flex items-center justify-center hover:bg-foreground/5 transition-colors rounded-sm" @click="minimize" @mousedown.stop > <Minus class="h-4 w-4" /> </button> <button - class="h-10 w-10 flex items-center justify-center hover:bg-secondary/50 transition-colors rounded-sm" + id="titlebar-maximize" + class="h-10 w-10 flex items-center justify-center transition-colors rounded-sm titlebar-maximize-btn" @click="maximize" @mousedown.stop > - <Square class="h-3.5 w-3.5" /> + <!-- 最大化:单圆角矩形 --> + <svg v-if="!isMaximized" class="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> + <rect x="4" y="4" width="16" height="16" rx="2" /> + </svg> + <!-- 还原:单圆角矩形 + 右上角圆角 L形(顶边+右边,圆角连接) --> + <svg v-else class="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> + <rect x="4" y="4" width="16" height="16" rx="2" /> + <path d="M14 4H20V10" /> + </svg> </button> <button - class="h-10 w-10 flex items-center justify-center hover:bg-destructive/20 transition-colors rounded-sm text-destructive" + class="h-10 w-10 flex items-center justify-center hover:bg-red-500 hover:text-white text-foreground transition-colors rounded-sm" @click="close" @mousedown.stop > @@ -267,10 +294,17 @@ const handleBlur = () => { <style scoped> /* 窗口隐藏/重新显示瞬间,抑制按钮 hover 样式,避免冻结的 :hover 残留 */ -.hover-suppressed button:hover { +.hover-suppressed button:hover, +.hover-suppressed button.is-hovered { background-color: transparent !important; } +/* 最大化按钮:snap-layout 插件的原生子窗口遮挡了指针事件, + :hover 无法触发,改用插件注入的 .is-hovered 类(鼠标进入原生子窗口时添加) */ +.titlebar-maximize-btn.is-hovered { + background-color: color-mix(in srgb, var(--foreground) 5%, transparent); +} + /* 浮动标签切换器进出动画:淡入 + 从左侧滑入 */ .floating-tabs-enter-active, .floating-tabs-leave-active { diff --git a/src/modules/downloader/DownloaderModule.vue b/src/modules/downloader/DownloaderModule.vue index bd4ecb3..b102fe1 100644 --- a/src/modules/downloader/DownloaderModule.vue +++ b/src/modules/downloader/DownloaderModule.vue @@ -640,17 +640,17 @@ const toggleSortOrder = () => { </div> <div class="flex items-center gap-1 shrink-0"> <Button - v-if="task.status === 'active'" + v-if="task.status === 'active' || task.status === 'queued'" size="icon" variant="ghost" class="h-8 w-8" - title="暂停" + :title="task.status === 'queued' ? '取消排队' : '暂停'" @click="handlePause(task.id)" > <Pause class="h-4 w-4" /> </Button> <Button - v-if="task.status === 'paused' || task.status === 'queued' || task.status === 'error'" + v-if="task.status === 'paused' || task.status === 'error'" size="icon" variant="ghost" class="h-8 w-8" @@ -1099,7 +1099,7 @@ const toggleSortOrder = () => { <Textarea v-model="addUriText" placeholder="https://example.com/file.zip https://example.com/file2.zip" - class="min-h-[120px] font-mono text-sm" + class="min-h-[120px] font-mono text-sm [field-sizing:fixed] break-all" /> </div> diff --git a/src/modules/proxy/ProxyModule.vue b/src/modules/proxy/ProxyModule.vue index 14d95ee..310f460 100644 --- a/src/modules/proxy/ProxyModule.vue +++ b/src/modules/proxy/ProxyModule.vue @@ -558,26 +558,40 @@ const handleCheckUpdate = async () => { } const handleUpdateKernel = async () => { - const ok = await showConfirm({ - title: '更新内核', - description: '更新内核需要先停止 mihomo,确认继续?', - confirmText: '继续更新' - }) - if (!ok) return - updatingKernel.value = true - try { - // 先停止 - if (running.value) { + // 展开更新区块(下载源选择 + 进度),由 handleStartUpdate 执行实际更新 + updateExpanded.value = true +} + +/** 是否展开"更新内核"区块(下载源 + 进度) */ +const updateExpanded = ref(false) + +/** 开始更新:停止 mihomo → 调用 updateKernel(复用 installProgress 进度机制) */ +const handleStartUpdate = async () => { + if (store.installing) return + // 确认停止 mihomo + if (running.value) { + const ok = await showConfirm({ + title: '更新内核', + description: '更新内核需要先停止 mihomo,确认继续?', + confirmText: '继续更新' + }) + if (!ok) return + updatingKernel.value = true + try { await store.stop() + } catch (e) { + toast.error('停止 mihomo 失败', { description: String(e) }) + updatingKernel.value = false + return + } finally { + updatingKernel.value = false } - toast.info('正在下载并安装内核...') - await store.updateKernel() - toast.success('内核更新完成') - kernelUpdateInfo.value = null + } + toast.info('开始下载更新...') + try { + await store.updateKernel(selectedMirrorPrefix.value) } catch (e) { toast.error('内核更新失败', { description: String(e) }) - } finally { - updatingKernel.value = false } } @@ -653,7 +667,8 @@ const handleInstallKernel = async () => { } } -// 监听安装进度终态,弹 toast 并延时清空进度 +// 监听安装/更新进度终态,弹 toast 并延时清空进度 +// 同时处理更新场景下的 updateExpanded 清理(与 installProgress 同步清除,避免更新区块闪烁) watch( () => store.installProgress?.stage, (stage) => { @@ -661,13 +676,22 @@ watch( toast.success('内核安装完成', { description: store.installProgress?.message }) - // 2 秒后清空进度,让用户看到 100% 终态 - setTimeout(() => store.clearInstallProgress(), 2000) + if (updateExpanded.value) { + kernelUpdateInfo.value = null + } + // 2 秒后同时清空进度和更新区块,让用户看到 100% 终态 + setTimeout(() => { + store.clearInstallProgress() + updateExpanded.value = false + }, 2000) } else if (stage === 'error') { toast.error('内核安装失败', { description: store.installProgress?.message }) - setTimeout(() => store.clearInstallProgress(), 5000) + setTimeout(() => { + store.clearInstallProgress() + // 错误时不清除 updateExpanded,让用户可以重新选择下载源重试 + }, 5000) } } ) @@ -912,11 +936,10 @@ const saveSettingsForm = async () => { v-if="kernelUpdateInfo.hasUpdate" key="btn-update" size="xs" variant="default" - :disabled="updatingKernel" + :disabled="store.installing || updateExpanded" @click="handleUpdateKernel" > - <Loader2 v-if="updatingKernel" class="size-3 animate-spin" /> - <Download v-else key="icon-download" class="size-3" />更新 + <Download key="icon-download" class="size-3" />更新 </Button> <Check v-else key="icon-updated" class="size-3 text-emerald-500" /> </span> @@ -941,6 +964,61 @@ const saveSettingsForm = async () => { </div> </div> + <!-- 更新内核区块:检查到更新且用户点击"更新"后展开(复用首次安装的下载源选择 UI) --> + <div + v-if="updateExpanded && !store.installProgress" + class="space-y-2 pt-2 border-t" + > + <div class="space-y-1.5"> + <Label class="text-xs text-muted-foreground">下载源</Label> + <Select v-model="mirrorChoice"> + <SelectTrigger size="sm" class="w-full"> + <SelectValue placeholder="选择下载源" /> + </SelectTrigger> + <SelectContent> + <SelectItem + v-for="m in MIRROR_PRESETS" + :key="m.value" + :value="m.value" + > + {{ m.label }} + </SelectItem> + </SelectContent> + </Select> + <p class="text-xs text-muted-foreground"> + {{ MIRROR_PRESETS.find(m => m.value === mirrorChoice)?.hint }} + </p> + </div> + <div v-if="mirrorChoice === '__custom'" class="space-y-1.5"> + <Label class="text-xs text-muted-foreground">镜像站前缀</Label> + <Input + v-model="customMirror" + placeholder="如 https://ghproxy.net/" + class="h-8 text-xs" + /> + <p class="text-xs text-muted-foreground"> + 前缀会拼接到 GitHub 下载链接前,需以 / 结尾(自动补全) + </p> + </div> + <div class="flex gap-2"> + <Button + size="sm" variant="outline" class="flex-1" + :disabled="store.installing || updatingKernel" + @click="updateExpanded = false" + > + 取消 + </Button> + <Button + size="sm" variant="default" class="flex-1" + :disabled="store.installing || updatingKernel || (mirrorChoice === '__custom' && !customMirror.trim())" + @click="handleStartUpdate" + > + <Loader2 v-if="store.installing || updatingKernel" class="size-4 animate-spin" /> + <DownloadCloud v-else class="size-4" />开始更新 + </Button> + </div> + </div> + <!-- 首次安装区块:仅在内核未安装且不在安装中且已初始化时显示 --> <div v-if="store.initialized && !store.kernel?.exists && !store.installProgress" diff --git a/src/stores/proxyStore.ts b/src/stores/proxyStore.ts index 5aced68..ee92fd3 100644 --- a/src/stores/proxyStore.ts +++ b/src/stores/proxyStore.ts @@ -272,9 +272,39 @@ export const useProxyStore = defineStore('proxy', () => { return await invoke<KernelUpdateInfo>('proxy_check_kernel_update') } - const updateKernel = async (mirrorPrefix: string = '') => { - await invoke('proxy_update_kernel', { mirrorPrefix }) - await refreshKernel() + /** + * 更新内核:复用 installKernel 的完整进度事件机制(installing/installProgress/事件监听) + * 与 installKernel 的区别仅在于后端会先 stop mihomo(由调用方在前端控制), + * 后端 proxy_update_kernel 与 proxy_install_kernel 共用 install_kernel 实现 + */ + const updateKernel = async (mirrorPrefix: string = ''): Promise<void> => { + if (installing.value) return + installing.value = true + installProgress.value = { + stage: 'downloading', + percent: 0, + downloadedBytes: 0, + totalBytes: null, + message: '准备开始下载...' + } + if (!progressUnlisten) { + progressUnlisten = await listen<InstallProgress>('kernel-install-progress', (e) => { + installProgress.value = e.payload + }) + } + try { + await invoke('proxy_update_kernel', { mirrorPrefix }) + await refreshKernel() + } catch (e) { + logger.error('内核更新失败: ' + e) + throw e + } finally { + installing.value = false + if (progressUnlisten) { + progressUnlisten() + progressUnlisten = null + } + } } /** diff --git a/src/types/module.ts b/src/types/module.ts index 9e44b0e..7707483 100644 --- a/src/types/module.ts +++ b/src/types/module.ts @@ -6,7 +6,7 @@ export type ModuleCategory = 'network' | 'tool' | 'system' | 'media' /** 模块进程配置 —— 需要管理外部子进程的模块填写 */ export interface ModuleProcessConfig { - /** 进程标识符(如 'mihomo'、'aria2') */ + /** 进程标识符(如 'mihomo') */ name: string /** 可执行文件路径(运行时由模块自行确定) */ executable: string diff --git a/vite.config.ts b/vite.config.ts index db9053f..93038a3 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -26,6 +26,11 @@ export default defineConfig(async () => ({ }, }, + // 防止 Vite 缓存 tauri-plugin-snap-layout,否则注入脚本中的占位符无法被替换 + optimizeDeps: { + exclude: ["tauri-plugin-snap-layout"], + }, + // Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build` // // 1. prevent Vite from obscuring rust errors