下载/主界面优化
This commit is contained in:
+35
-24
@@ -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
@@ -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,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 命令,监听进程状态变更事件
|
||||
|
||||
Generated
+17
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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,19 +578,21 @@ 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) })
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 首次安装内核 =====
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -6,7 +6,7 @@ export type ModuleCategory = 'network' | 'tool' | 'system' | 'media'
|
||||
|
||||
/** 模块进程配置 —— 需要管理外部子进程的模块填写 */
|
||||
export interface ModuleProcessConfig {
|
||||
/** 进程标识符(如 'mihomo'、'aria2') */
|
||||
/** 进程标识符(如 'mihomo') */
|
||||
name: string
|
||||
/** 可执行文件路径(运行时由模块自行确定) */
|
||||
executable: string
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user