下载/主界面优化

This commit is contained in:
2026-07-22 22:26:40 +08:00
parent f7d3c13f35
commit 95a69b0017
16 changed files with 295 additions and 106 deletions
+35 -24
View File
@@ -19,7 +19,7 @@
src/modules/<module-name>/
├── index.ts # 模块配置(ModuleConfig
├── ModuleComponent.vue # 前端组件
src-tauri/src/commands/ # Rust 命令(可选
src-tauri/src/ # Rust 后端逻辑(按功能分文件/模块目录
```
#### 模块配置类型
@@ -167,10 +167,10 @@ onUnmounted(() => {
### Rust 命令模板
创建 `src-tauri/src/commands/<name>.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<dyn std::error::Error>> {
```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
```
### 模板文件
+11 -12
View File
@@ -142,7 +142,7 @@ export const moduleIconMap: Record<string, Component> = {
## 进程管理
需要管理外部子进程的模块(如 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(() => {
`<title>` 放完整路径,显示文本用 `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 继续占用端口、系统代理未清除)。新增管理子进程的模块时,检查这两处是否都调用了清理逻辑。
+19 -21
View File
@@ -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 命令,监听进程状态变更事件
+17
View File
@@ -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",
+1
View File
@@ -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"
+2 -1
View File
@@ -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"
]
}
+5 -3
View File
@@ -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(),
+4 -4
View File
@@ -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();
}
+8
View File
@@ -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);
}
+5
View File
@@ -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,
+44 -10
View File
@@ -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 {
+4 -4
View File
@@ -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&#10;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>
+93 -15
View File
@@ -558,6 +558,18 @@ const handleCheckUpdate = async () => {
}
const handleUpdateKernel = async () => {
// 展开更新区块(下载源选择 + 进度),由 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,确认继续?',
@@ -566,20 +578,22 @@ const handleUpdateKernel = async () => {
if (!ok) return
updatingKernel.value = true
try {
// 先停止
if (running.value) {
await store.stop()
}
toast.info('正在下载并安装内核...')
await store.updateKernel()
toast.success('内核更新完成')
kernelUpdateInfo.value = null
} catch (e) {
toast.error('内核更新失败', { description: String(e) })
toast.error('停止 mihomo 失败', { description: String(e) })
updatingKernel.value = false
return
} finally {
updatingKernel.value = false
}
}
toast.info('开始下载更新...')
try {
await store.updateKernel(selectedMirrorPrefix.value)
} catch (e) {
toast.error('内核更新失败', { description: String(e) })
}
}
// ===== 首次安装内核 =====
const installStageText = computed(() => {
@@ -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"
+31 -1
View File
@@ -272,9 +272,39 @@ export const useProxyStore = defineStore('proxy', () => {
return await invoke<KernelUpdateInfo>('proxy_check_kernel_update')
}
const updateKernel = async (mirrorPrefix: string = '') => {
/**
* 更新内核:复用 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
}
}
}
/**
+1 -1
View File
@@ -6,7 +6,7 @@ export type ModuleCategory = 'network' | 'tool' | 'system' | 'media'
/** 模块进程配置 —— 需要管理外部子进程的模块填写 */
export interface ModuleProcessConfig {
/** 进程标识符(如 'mihomo'、'aria2' */
/** 进程标识符(如 'mihomo' */
name: string
/** 可执行文件路径(运行时由模块自行确定) */
executable: string
+5
View File
@@ -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