优化调整、BT下载(有bug)

This commit is contained in:
zhongluofeng
2026-08-27 18:25:45 +08:00
parent 4dd60f42a1
commit d21649c60e
34 changed files with 4991 additions and 198 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "thing", "name": "thing",
"private": true, "private": true,
"version": "26.8.2", "version": "26.8.3",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+1355 -24
View File
File diff suppressed because it is too large Load Diff
+16 -2
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "thing" name = "thing"
version = "26.8.2" version = "26.8.3"
description = "A Tauri App" description = "A Tauri App"
authors = ["you"] authors = ["you"]
edition = "2021" edition = "2021"
@@ -45,6 +45,8 @@ base64 = "0.22"
image = { version = "0.25", default-features = false, features = ["png"] } image = { version = "0.25", default-features = false, features = ["png"] }
walkdir = "2" walkdir = "2"
notify = { version = "6", features = [] } notify = { version = "6", features = [] }
librqbit = "9"
bytes = "1"
[target.'cfg(windows)'.dependencies] [target.'cfg(windows)'.dependencies]
winreg = "0.52" winreg = "0.52"
@@ -67,7 +69,7 @@ windows-sys = { version = "0.52", features = [
"Win32_Storage_Xps", "Win32_Storage_Xps",
"Win32_Storage_FileSystem", "Win32_Storage_FileSystem",
] } ] }
# Explorer 蜑榊床逶ョ蠖墓」€豬具シ・ShellWindows COM・会シ壻サ・シ募・逕ィ蛻ー逧・feature・梧而蛻カ郛冶ッ台ス鍋ァ # Explorer 陷第ヲ雁コ企€カ・ョ陟門「難ス」ツ€雎ャ蜈キ・シ繝サShellWindows COM繝サ莨夲スシ螢サ・サ繝サ・シ蜍溘・騾包スィ陋サ・ー騾ァ繝サfeature繝サ譴ァ閠瑚崕・カ驛帛・・ッ蜿ー・ス骰具スァ・
windows = { version = "0.52", features = [ windows = { version = "0.52", features = [
"Win32_Foundation", "Win32_Foundation",
"Win32_System_Com", "Win32_System_Com",
@@ -80,3 +82,15 @@ windows = { version = "0.52", features = [
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies] [target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
tauri-plugin-autostart = "2" tauri-plugin-autostart = "2"
# ===== 构建性能 =====
# dev 增量编译加速:仅保留行号级调试信息(panic 仍显示 文件:行号),
# 显著减少本 crate 的代码生成量与链接体积,加快 `tauri dev` 的 Rust 重编。
[profile.dev]
incremental = true
debug = "line-tables-only"
# release 构建瘦身:去符号 + LTO,减小安装包体积
[profile.release]
strip = true
lto = true
@@ -0,0 +1,26 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "download-window",
"description": "Capability for the per-download one-time window",
"windows": ["download-window-*"],
"permissions": [
"core:default",
"core:window:allow-hide",
"core:window:allow-show",
"core:window:allow-set-focus",
"core:window:allow-minimize",
"core:window:allow-unminimize",
"core:window:allow-set-title",
"core:window:allow-set-size",
"core:window:allow-start-dragging",
"core:window:allow-set-always-on-top",
"core:window:allow-set-skip-taskbar",
"core:window:allow-set-theme",
"core:window:allow-set-effects",
"core:window:allow-set-background-color",
"core:window:allow-close",
"core:event:allow-emit",
"core:event:allow-listen",
"snap-layout:default"
]
}
+1 -1
View File
@@ -60,6 +60,6 @@ pub mod events {
pub const DOWNLOAD_ADDED: &str = "download-added"; pub const DOWNLOAD_ADDED: &str = "download-added";
/// 任务被删除(浏览器扩展通过 HTTP API 删除任务时发出,前端据此刷新任务列表) /// 任务被删除(浏览器扩展通过 HTTP API 删除任务时发出,前端据此刷新任务列表)
pub const DOWNLOAD_REMOVED: &str = "download-removed"; pub const DOWNLOAD_REMOVED: &str = "download-removed";
/// 浏览器扩展通过 HTTP API 新增下载(前端需置前主窗口并跳到下载画面 /// 浏览器扩展通过 HTTP API 新增下载(负载 { id },前端据以为该任务创建专属下载窗口
pub const DOWNLOAD_EXTENSION_ADDED: &str = "download-extension-added"; pub const DOWNLOAD_EXTENSION_ADDED: &str = "download-extension-added";
} }
+53 -2
View File
@@ -1,10 +1,11 @@
use std::collections::HashMap; use std::collections::HashMap;
use tauri::{AppHandle, State}; use tauri::{AppHandle, Manager, State};
use tauri_plugin_opener::OpenerExt; use tauri_plugin_opener::OpenerExt;
use super::engine::{CheckUrlResult, DownloadEngine}; use super::engine::{CheckUrlResult, DownloadEngine};
use super::task::{DownloadTask, DownloaderSettings}; use super::task::{DownloadTask, DownloaderSettings};
use super::torrent::TorrentInfo;
/// 获取所有任务 /// 获取所有任务
#[tauri::command] #[tauri::command]
@@ -13,6 +14,20 @@ pub fn downloader_get_tasks(engine: State<'_, DownloadEngine>) -> Vec<DownloadTa
engine.get_tasks() engine.get_tasks()
} }
/// 解析磁力链 / .torrent 文件,返回种子信息(名称 / infohash / 文件列表),供前端做文件勾选
#[tauri::command]
#[specta::specta]
pub async fn downloader_inspect(engine: State<'_, DownloadEngine>, input: String) -> Result<TorrentInfo, String> {
engine.inspect(&input).await
}
/// 磁力任务:元数据解析成功后,用户勾选文件并确认开始下载
#[tauri::command]
#[specta::specta]
pub async fn downloader_select_bt_files(engine: State<'_, DownloadEngine>, id: String, only_files: Vec<u32>) -> Result<(), String> {
engine.select_bt_files(&id, only_files).await
}
/// 检查 URL 重复性并探测文件信息(添加下载前调用) /// 检查 URL 重复性并探测文件信息(添加下载前调用)
#[tauri::command] #[tauri::command]
#[specta::specta] #[specta::specta]
@@ -57,8 +72,9 @@ pub async fn downloader_add_task(
dir: Option<String>, dir: Option<String>,
headers: Option<HashMap<String, String>>, headers: Option<HashMap<String, String>>,
auto_rename: Option<bool>, auto_rename: Option<bool>,
only_files: Option<Vec<u32>>,
) -> Result<String, String> { ) -> Result<String, String> {
engine.add_task(url, filename, dir, headers.unwrap_or_default(), auto_rename.unwrap_or(false)).await engine.add_task(url, filename, dir, headers.unwrap_or_default(), auto_rename.unwrap_or(false), only_files).await
} }
/// 暂停任务 /// 暂停任务
@@ -75,6 +91,20 @@ pub fn downloader_resume_task(engine: State<'_, DownloadEngine>, id: String) ->
engine.resume_task(&id) engine.resume_task(&id)
} }
/// 取消任务(置为已取消,清空进度并删除下载文件,但保留记录)
#[tauri::command]
#[specta::specta]
pub fn downloader_cancel_task(engine: State<'_, DownloadEngine>, id: String) -> Result<(), String> {
engine.cancel_task(&id)
}
/// 重新下载已取消/出错的任务
#[tauri::command]
#[specta::specta]
pub async fn downloader_redownload(engine: State<'_, DownloadEngine>, id: String) -> Result<(), String> {
engine.redownload(&id).await
}
/// 移除任务 /// 移除任务
#[tauri::command] #[tauri::command]
#[specta::specta] #[specta::specta]
@@ -141,3 +171,24 @@ pub fn downloader_open_url(app: AppHandle, url: String) -> Result<(), String> {
.open_url(url, None::<&str>) .open_url(url, None::<&str>)
.map_err(|e| format!("打开链接失败: {}", e)) .map_err(|e| format!("打开链接失败: {}", e))
} }
/// 将指定 label 的下载窗口显示并强制置为前台。
/// Tauri 的 set_focus 在 Windows 上受前台锁定限制(尤其下载窗口由后台进程创建、
/// 或创建到非主显示器时更明显),改用原生 SetForegroundWindow + BringWindowToTop
/// (模拟 Alt 键重置前台锁定),保证开始/完成下载时窗口能正确定位到前台。
#[tauri::command]
#[specta::specta]
pub fn downloader_focus_window(app: AppHandle, label: String) -> Result<(), String> {
let Some(window) = app.get_webview_window(&label) else {
return Ok(()); // 窗口已关闭则忽略
};
window.show().map_err(|e| e.to_string())?;
window.unminimize().map_err(|e| e.to_string())?;
match window.hwnd() {
Ok(hwnd) => crate::win32_util::force_foreground(hwnd.0 as isize),
Err(_) => {
window.set_focus().ok();
}
}
Ok(())
}
File diff suppressed because it is too large Load Diff
+53 -4
View File
@@ -53,14 +53,34 @@ impl HttpDownloader {
} }
/// 探测下载资源信息(大小、是否支持 Range、文件名) /// 探测下载资源信息(大小、是否支持 Range、文件名)
/// 优先用 GET + Range: bytes=0-0(返回 206 + Content-Range),回退到 HEAD /// 优先用 GET + Range: bytes=0-0(返回 206 + Content-Range),回退到 HEAD
/// 代理降级:use_proxy=true 时先走系统代理,失败则回退 no_proxy 直连重试一次
pub async fn probe( pub async fn probe(
&self, &self,
url: &str, url: &str,
headers: &HashMap<String, String>, headers: &HashMap<String, String>,
use_proxy: bool, use_proxy: bool,
) -> Result<ProbeResult, String> { ) -> Result<ProbeResult, String> {
let client = self.client(use_proxy); let first = self.client(use_proxy);
match self.probe_with_client(first, url, headers).await {
Ok(r) => return Ok(r),
Err(e) if use_proxy => {
let direct = self.client(false);
self.probe_with_client(direct, url, headers)
.await
.map_err(|e2| format!("代理探测失败({}),直连重试也失败({}", e, e2))
}
Err(e) => Err(e),
}
}
/// 用指定 client 执行探测(GET Range → 回退 HEAD),供代理降级复用
async fn probe_with_client(
&self,
client: &Client,
url: &str,
headers: &HashMap<String, String>,
) -> Result<ProbeResult, String> {
// 先尝试 Range 请求(能同时判断 Accept-Ranges 和获取大小) // 先尝试 Range 请求(能同时判断 Accept-Ranges 和获取大小)
let mut req = client let mut req = client
.get(url) .get(url)
@@ -165,7 +185,36 @@ impl HttpDownloader {
limiter: Arc<RateLimiter>, limiter: Arc<RateLimiter>,
use_proxy: bool, use_proxy: bool,
) -> Result<(), String> { ) -> Result<(), String> {
let client = self.client(use_proxy); // 代理降级:仅当 use_proxy=true 才有"走代理→失败回退直连"的意义。
// use_proxy=false 直接用直连客户端,无需回退。
// 注意:用户主动暂停/取消(返回"已取消")必须原样透传,不能触发代理回退,
// 否则会把"已取消"包装成"代理失败",导致引擎将其误判为错误而非暂停。
let first = self.client(use_proxy);
match self.download_with_client(first, url, headers, segments, file_path, cancel.clone(), progress, &limiter).await {
Ok(()) => return Ok(()),
Err(e) if use_proxy && e != "已取消" => {
// 回退直连重试(不继承 use_proxy,保证用 no_proxy 客户端)
let direct = self.client(false);
self.download_with_client(direct, url, headers, segments, file_path, cancel, progress, &limiter)
.await
.map_err(|e2| format!("代理下载失败({}),直连重试也失败({}", e, e2))
}
Err(e) => Err(e),
}
}
/// 用指定 client 执行下载(支持单线程与多线程分段),供代理降级复用
async fn download_with_client(
&self,
client: &Client,
url: &str,
headers: &HashMap<String, String>,
segments: &[Segment],
file_path: &Path,
cancel: Arc<AtomicBool>,
progress: &[Arc<AtomicU64>],
limiter: &Arc<RateLimiter>,
) -> Result<(), String> {
let total_size = segments.iter().map(|s| s.len()).sum(); let total_size = segments.iter().map(|s| s.len()).sum();
// 预分配文件(若已知大小) // 预分配文件(若已知大小)
@@ -191,7 +240,7 @@ impl HttpDownloader {
// 单线程下载(不支持 Range 或文件太小) // 单线程下载(不支持 Range 或文件太小)
let seg = &segments[0]; let seg = &segments[0];
let prog = &progress[0]; let prog = &progress[0];
self.download_segment(url, headers, seg, file_path, cancel.clone(), prog.clone(), limiter, client) self.download_segment(url, headers, seg, file_path, cancel.clone(), prog.clone(), limiter.clone(), client)
.await?; .await?;
return Ok(()); return Ok(());
} }
+3 -2
View File
@@ -5,10 +5,11 @@ pub mod rate_limit;
pub mod server; pub mod server;
pub mod storage; pub mod storage;
pub mod task; pub mod task;
pub mod torrent;
pub use commands::{ pub use commands::{
downloader_add_task, downloader_check_url, downloader_get_extension_info, downloader_get_settings, downloader_get_tasks, downloader_add_task, downloader_cancel_task, downloader_check_url, downloader_focus_window, downloader_get_extension_info, downloader_get_settings, downloader_get_tasks, downloader_inspect, downloader_select_bt_files,
downloader_open_dir, downloader_open_url, downloader_pause_task, downloader_remove_task, downloader_open_dir, downloader_open_url, downloader_pause_task, downloader_redownload, downloader_remove_task,
downloader_resume_task, downloader_save_settings, downloader_status, downloader_resume_task, downloader_save_settings, downloader_status,
}; };
pub use engine::DownloadEngine; pub use engine::DownloadEngine;
+7 -6
View File
@@ -122,13 +122,14 @@ async fn create_download(
return Ok(Json(CreateDownloadResponse { id: existing.id })); return Ok(Json(CreateDownloadResponse { id: existing.id }));
} }
match state.engine.add_task(req.url, req.filename, req.dir, req.headers, true).await { match state.engine.add_task(req.url, req.filename, req.dir, req.headers, true, None).await {
Ok(id) => { Ok(id) => {
// 浏览器扩展发起下载:置前主窗口并通知前端跳到下载画面(替代原桌面通知) // 浏览器扩展发起下载:不再置前主窗口,改为带 task id 通知前端,
crate::tray_menu::focus_main_window(&state.app_handle); // 由前端为该任务创建一个专属的一次性下载窗口(不打断主界面)
let _ = state let _ = state.app_handle.emit(
.app_handle crate::constants::events::DOWNLOAD_EXTENSION_ADDED,
.emit(crate::constants::events::DOWNLOAD_EXTENSION_ADDED, ()); serde_json::json!({ "id": id }),
);
Ok(Json(CreateDownloadResponse { id })) Ok(Json(CreateDownloadResponse { id }))
} }
Err(e) => Err((StatusCode::BAD_REQUEST, Json(ErrorResponse { error: e }))), Err(e) => Err((StatusCode::BAD_REQUEST, Json(ErrorResponse { error: e }))),
+55 -2
View File
@@ -2,6 +2,17 @@ use serde::{Deserialize, Serialize};
use specta::Type; use specta::Type;
use std::collections::HashMap; use std::collections::HashMap;
/// 任务下载协议类型
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Type, Default)]
#[serde(rename_all = "lowercase")]
pub enum TaskProtocol {
/// HTTP/HTTPS 直链
#[default]
Http,
/// BitTorrent(磁力链 / .torrent 文件)
BitTorrent,
}
/// 任务状态 /// 任务状态
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Type)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Type)]
#[serde(rename_all = "lowercase")] #[serde(rename_all = "lowercase")]
@@ -16,6 +27,8 @@ pub enum TaskStatus {
Complete, Complete,
/// 错误 /// 错误
Error, Error,
/// 已取消(用户取消:进度与文件已清除,仅保留记录,只能再次下载)
Cancelled,
} }
/// 下载分段(多线程 Range 下载 / 断点续传用) /// 下载分段(多线程 Range 下载 / 断点续传用)
@@ -52,18 +65,42 @@ impl Segment {
} }
} }
/// BT 种子内文件条目(多文件任务用;阶段1下载全部文件,但保留列表供 UI 展示)
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct BtFileInfo {
/// 文件在种子内的索引
pub index: u32,
/// 相对种子根目录的路径(如 "sub/file.mkv"
pub path: String,
/// 文件大小(字节)
pub size: u64,
}
/// 下载任务 /// 下载任务
#[derive(Debug, Clone, Serialize, Deserialize, Type)] #[derive(Debug, Clone, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct DownloadTask { pub struct DownloadTask {
/// 任务 ID(自增 hex 字符串) /// 任务 ID(自增 hex 字符串)
pub id: String, pub id: String,
/// 下载地址 /// 下载地址HTTP URL 或磁力链接)
pub url: String, pub url: String,
/// 文件名 /// 文件名(HTTP:目标文件名;BT:种子名称)
pub filename: String, pub filename: String,
/// 保存目录(绝对路径) /// 保存目录(绝对路径)
pub dir: String, pub dir: String,
/// 协议类型
#[serde(default)]
pub protocol: TaskProtocol,
/// BT 种子 infohash(协议=BitTorrent 时存在)
#[serde(default)]
pub info_hash: Option<String>,
/// BT 种子内文件列表(协议=BitTorrent 时存在)
#[serde(default)]
pub bt_files: Vec<BtFileInfo>,
/// BT 元数据是否已解析就绪(异步添加时:后台解析完成前为 false,调度器跳过)
#[serde(default)]
pub bt_metadata_ready: bool,
/// 状态 /// 状态
pub status: TaskStatus, pub status: TaskStatus,
/// 文件总大小(字节),0=未知 /// 文件总大小(字节),0=未知
@@ -141,6 +178,18 @@ pub struct DownloaderSettings {
/// 下载是否使用代理:true=尊重系统代理(mihomo 开启系统代理时经其转发),false=强制直连 /// 下载是否使用代理:true=尊重系统代理(mihomo 开启系统代理时经其转发),false=强制直连
#[serde(default = "default_true")] #[serde(default = "default_true")]
pub use_proxy: bool, pub use_proxy: bool,
/// BitTorrent 上传限速 KB/s0=不限)
#[serde(default)]
pub bt_upload_limit_kb: u64,
/// BitTorrent 下载完成后是否继续做种上传(false=下载完即停止上传)
#[serde(default)]
pub bt_seed_after_download: bool,
/// BitTorrent 监听端口(0=自动选择)
#[serde(default)]
pub bt_listen_port: u16,
/// BitTorrent 使用代理下载:开启后自动使用代理模块(mihomo)的 SOCKS5 端口;代理不可用时降级直连
#[serde(default)]
pub bt_use_proxy: bool,
} }
fn default_max_concurrent() -> u32 { fn default_max_concurrent() -> u32 {
@@ -180,6 +229,10 @@ impl Default for DownloaderSettings {
delete_files_on_remove: false, delete_files_on_remove: false,
check_duplicate: true, check_duplicate: true,
use_proxy: true, use_proxy: true,
bt_upload_limit_kb: 0,
bt_seed_after_download: false,
bt_listen_port: 0,
bt_use_proxy: false,
} }
} }
} }
+469
View File
@@ -0,0 +1,469 @@
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex, RwLock};
// 会话字段专用 tokio 异步互斥(与 std 互斥区分开):
// 创建 Session 是 async 操作,必须持锁跨 await,否则"双重检查"在 await 期间失效,
// 并发首次添加 BT 任务可能各建一个全局会话,产生孤儿会话与跨会话无效句柄。
use tokio::sync::Mutex as AsyncMutex;
use librqbit::api::TorrentIdOrHash;
use librqbit::{AddTorrent, AddTorrentOptions, AddTorrentResponse, ManagedTorrent, Session};
use super::task::BtFileInfo;
/// 磁力元数据解析超时(秒):依赖 DHT/tracker 拉取,过短易误报,过长体验差
pub(crate) const INSPECT_TIMEOUT_SECS: u64 = 60;
/// 每日 tracker 同步源(XIU2/TrackersListCollection,官方新域名 cf.trackerslist.com)。
/// 注意:GitHub 仓库 raw 路径(raw.githubusercontent.com/...trackers_best.txt)已随项目
/// 迁移失效(404),不要再用 GitHub 代理镜像。以下为官方 Cloudflare 分发地址,
/// 国内直连通常可达,按顺序尝试、首个成功即用。
const TRACKER_SYNC_SOURCES: &[&str] = &[
"https://cf.trackerslist.com/best.txt",
"https://trackerslist.com/best.txt",
"https://cf.trackerslist.com/all.txt",
];
/// 动态 tracker 持久化文件与同步元数据
const TRACKERS_FILE: &str = "bt_trackers.txt";
const TRACKERS_META_FILE: &str = "bt_trackers_meta.json";
/// 常用公共 tracker:磁力链接本身可能只带很少 tracker,追加这些可提升解析成功率。
/// 混合 UDP/HTTP(S)/WebSocket,覆盖 UDP 被屏蔽但 HTTP 可用的网络环境。
pub const PUBLIC_TRACKERS: &[&str] = &[
// UDP
"udp://tracker.opentrackr.org:1337/announce",
"udp://open.tracker.cl:1337/announce",
"udp://tracker.openbittorrent.com:6969/announce",
"udp://tracker.torrent.eu.org:451/announce",
"udp://open.stealth.si:80/announce",
"udp://exodus.desync.com:6969/announce",
"udp://tracker.tiny-vps.com:6969/announce",
"udp://open.demonii.com:1337/announce",
"udp://tracker.moeking.me:6969/announce",
"udp://ipv4.tracker.harry.lu:80/announce",
"udp://explodie.org:6969/announce",
"udp://tracker.birkenfeld.ru:7496/announce",
"udp://tracker.pomf.se:80/announce",
"udp://tracker.tamersunion.org:6969/announce",
"udp://retracker.lanta-net.ru:2710/announce",
// HTTP(S)
"http://tracker.opentrackr.org:1337/announce",
"http://tracker.openbittorrent.com:80/announce",
"http://tracker1.itzmx.com:8080/announce",
"http://tracker4.itzmx.com:2710/announce",
"https://tracker.gbitt.info:443/announce",
"https://tracker.nanoha.org:443/announce",
"http://tracker.bt4g.com:2095/announce",
"http://tracker.gbitt.info:80/announce",
];
/// 种子信息(inspect 解析结果,供命令返回给前端做文件勾选)
#[derive(Debug, Clone, serde::Serialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct TorrentInfo {
/// 种子名称
pub name: String,
/// infohashhex 小写字符串)
pub info_hash: String,
/// 种子内全部文件总大小(字节)
pub total_size: u64,
/// 种子内文件列表
pub files: Vec<BtFileInfo>,
}
/// BitTorrent 下载器:封装 librqbit 全局会话(多任务共享 DHT / 监听端口 / tracker 缓存)。
/// 会话懒创建:只有真正添加 BT 任务时才初始化,避免引擎启动即拉起 BT 内核。
#[derive(Clone)]
pub struct TorrentDownloader {
/// 全局会话(懒创建;tokio 异步互斥保证并发首次添加只创建一个会话)
session: Arc<AsyncMutex<Option<Arc<Session>>>>,
/// 会话默认输出目录(每个任务用 AddTorrentOptions.output_folder 覆盖)
base_dir: PathBuf,
/// 上传限速 bytes/s0=不限),per-torrent 应用
upload_limit_bps: Arc<std::sync::atomic::AtomicU64>,
/// 监听端口(0=自动)
listen_port: Arc<std::sync::atomic::AtomicU16>,
/// SOCKS5 代理地址(None=直连;来自代理模块,不可用时降级直连)
proxy_addr: Arc<Mutex<Option<String>>>,
/// 动态公共 tracker(每日从外部列表同步,叠加到内置列表)
dynamic_trackers: Arc<RwLock<Vec<String>>>,
}
impl TorrentDownloader {
pub fn new(base_dir: PathBuf) -> Self {
Self {
session: Arc::new(AsyncMutex::new(None)),
base_dir,
upload_limit_bps: Arc::new(std::sync::atomic::AtomicU64::new(0)),
listen_port: Arc::new(std::sync::atomic::AtomicU16::new(0)),
proxy_addr: Arc::new(Mutex::new(None)),
dynamic_trackers: Arc::new(RwLock::new(Vec::new())),
}
}
/// 更新 BT 专属设置(上传限速 KB/s、监听端口、SOCKS5 代理地址)— 会话创建时生效
pub fn set_settings(&self, upload_limit_kb: u64, listen_port: u16, proxy_addr: Option<String>) {
self.upload_limit_bps
.store(upload_limit_kb.saturating_mul(1024), std::sync::atomic::Ordering::SeqCst);
self.listen_port
.store(listen_port, std::sync::atomic::Ordering::SeqCst);
*self.proxy_addr.lock().unwrap_or_else(|e| e.into_inner()) = proxy_addr;
}
/// 设置动态 tracker(合并去重)
pub fn add_dynamic_trackers(&self, list: Vec<String>) {
if list.is_empty() {
return;
}
let mut g = self.dynamic_trackers.write().unwrap_or_else(|e| e.into_inner());
for t in list {
let t = t.trim().to_string();
if !t.is_empty() && !g.contains(&t) {
g.push(t);
}
}
}
/// 当前动态 tracker 数量
pub fn dynamic_count(&self) -> usize {
self.dynamic_trackers.read().unwrap_or_else(|e| e.into_inner()).len()
}
/// 合并内置 + 动态 tracker(去重),供 inspect / add_async 共用
fn build_trackers(&self) -> Vec<String> {
let dyn_trackers = self.dynamic_trackers.read().unwrap_or_else(|e| e.into_inner()).clone();
let mut trackers: Vec<String> = PUBLIC_TRACKERS.iter().map(|s| s.to_string()).collect();
for t in dyn_trackers {
if !trackers.contains(&t) {
trackers.push(t);
}
}
trackers
}
/// 每日同步动态 tracker:读取本地缓存 → 判断今天是否已同步 → 未同步则拉取更新。
/// 失败静默降级:保留上次成功的列表。
pub async fn sync_dynamic_trackers(&self, data_dir: &Path) {
fn parse_list(text: &str) -> Vec<String> {
text.lines()
.map(|l| l.trim())
.filter(|l| l.starts_with("http://") || l.starts_with("https://") || l.starts_with("udp://") || l.starts_with("ws://") || l.starts_with("wss://"))
.map(|l| l.to_string())
.collect()
}
let trackers_path = data_dir.join(TRACKERS_FILE);
let meta_path = data_dir.join(TRACKERS_META_FILE);
let today = chrono::Local::now().format("%Y-%m-%d").to_string();
// 1. 先加载上次成功保存的列表到内存(覆盖"重启后内存被清空")
if let Ok(s) = std::fs::read_to_string(&trackers_path) {
self.add_dynamic_trackers(parse_list(&s));
}
// 2. 今天已同步过则不再下载
if let Ok(meta) = std::fs::read_to_string(&meta_path) {
if let Ok(v) = serde_json::from_str::<serde_json::Value>(&meta) {
if v.get("date").and_then(|d| d.as_str()) == Some(&today) {
return;
}
}
}
// 3. 依次尝试各镜像源(记录每个源的失败原因,便于判断是 DNS 还是连接超时)
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(10))
.build()
.ok();
for url in TRACKER_SYNC_SOURCES {
let Some(c) = &client else { break };
match c.get(*url).send().await {
Err(e) => {
crate::logger::log_line("download", crate::logger::LogLevel::Debug, &format!("动态 tracker 源不可达 {}: {}", url, e));
continue;
}
Ok(resp) => {
let text = match resp.text().await {
Ok(t) => t,
Err(e) => {
crate::logger::log_line("download", crate::logger::LogLevel::Debug, &format!("动态 tracker 源读取失败 {}: {}", url, e));
continue;
}
};
let list = parse_list(&text);
if list.is_empty() {
crate::logger::log_line("download", crate::logger::LogLevel::Debug, &format!("动态 tracker 源返回空列表 {}", url));
continue;
}
// 保存列表与同步元数据
let _ = std::fs::write(&trackers_path, format!("{}\n", list.join("\n")));
let _ = std::fs::write(&meta_path, serde_json::json!({ "date": today }).to_string());
self.add_dynamic_trackers(list);
crate::logger::log_info("download", &format!("已同步动态 tracker(共 {} 条)", self.dynamic_count()));
return;
}
}
}
crate::logger::log_warn("download", "动态 tracker 全部同步源不可达,使用上次成功列表或内置列表(不影响磁力解析,公共 tracker 仍会生效)");
}
/// 会话级公共 trackerHashSet<Url>,供 SessionOptions.trackers 使用)。
/// 注意:librqbit 对磁力链接只采用 magnet URL 自带的 tr= 参数,完全忽略
/// AddTorrentOptions.trackers(见 librqbit session.rs 的 magnet 分支),导致
/// 纯磁力(无自带 tr)解析元数据时 "trackers list is empty"。而会话级
/// SessionOptions.trackers 会在 make_peer_rx 中合并进每个种子(含磁力),
/// 因此必须放到这里才能让磁力链接真正带上公共 tracker。
fn session_trackers(&self) -> std::collections::HashSet<url::Url> {
self.build_trackers()
.into_iter()
.filter_map(|t| url::Url::parse(&t).ok())
.collect()
}
/// 获取或创建全局会话。
/// 持 tokio 互斥锁覆盖整个构造过程:并发调用在此串行化,
/// 先进入者创建并写入,后续者锁内二次检查直接复用(避免多会话竞态)。
/// 开启代理时先尝试用代理初始化;失败则降级为直连(记录下来供 add 重试判断)。
async fn get_session(&self) -> Result<Arc<Session>, String> {
let mut guard = self.session.lock().await;
if let Some(s) = guard.as_ref() {
return Ok(s.clone());
}
let _ = std::fs::create_dir_all(&self.base_dir);
let port = self.listen_port.load(std::sync::atomic::Ordering::SeqCst);
let proxy = self.proxy_addr.lock().unwrap_or_else(|e| e.into_inner()).clone();
let connect_opt = proxy.as_ref().map(|p| librqbit::ConnectionOptions { proxy_url: Some(p.clone()), ..Default::default() });
let had_proxy = connect_opt.is_some();
// 构造带代理(若启用)+ 监听端口 + 会话级公共 tracker 的会话选项
let mut opts = lib_session_options();
opts.trackers = self.session_trackers();
if connect_opt.is_some() {
opts.connect = connect_opt;
}
if port > 0 {
if let Ok(addr) = format!("0.0.0.0:{}", port).parse::<std::net::SocketAddr>() {
opts.listen = Some(librqbit::ListenerOptions {
listen_addr: addr,
..Default::default()
});
} else {
crate::logger::log_error("download", &format!("BT 监听端口 {} 无效,使用自动端口", port));
}
}
// 尝试创建会话:先按配置(可能带代理),失败且有代理则降级直连重试
let s = match Session::new_with_opts(self.base_dir.clone(), opts).await {
Ok(s) => s,
Err(e) => {
if had_proxy {
crate::logger::log_error("download", &format!("BT 代理会话初始化失败({}),降级为直连", e));
let mut m = lib_session_options();
m.trackers = self.session_trackers();
if port > 0 {
if let Ok(addr) = format!("0.0.0.0:{}", port).parse::<std::net::SocketAddr>() {
m.listen = Some(librqbit::ListenerOptions { listen_addr: addr, ..Default::default() });
}
}
Session::new_with_opts(self.base_dir.clone(), m).await.map_err(|e2| format!("初始化 BitTorrent 会话失败: {}", e2))?
} else {
return Err(format!("初始化 BitTorrent 会话失败: {}", e));
}
}
};
*guard = Some(s.clone());
Ok(s)
}
/// 解析磁力链 / 本地 .torrent 文件,返回种子信息(不开始下载)。
/// list_only 模式:仅获取元数据,不加入会话,因此不影响后续真正添加。
/// 磁力元数据依赖 DHT/tracker 拉取,无超时会永久等待,故加超时。
pub async fn inspect(&self, input: &str) -> Result<TorrentInfo, String> {
let session = self.get_session().await?;
let trackers = self.build_trackers();
// 磁力:需从网络拉取元数据,可能较慢(取决于种子热度与网络连通性);
// 本地 .torrent / http(s) .torrent URL:元数据在文件内,from_cli_argument 会读取/下载并解析
let add = AddTorrent::from_cli_argument(input).map_err(|e| format!("无效的种子输入: {}", e))?;
let add_fut = session.add_torrent(
add,
Some(AddTorrentOptions {
list_only: true,
trackers: Some(trackers),
..Default::default()
}),
);
let resp = tokio::time::timeout(std::time::Duration::from_secs(INSPECT_TIMEOUT_SECS), add_fut)
.await
.map_err(|_| "解析磁力元数据超时:未能从 DHT/Tracker 获取种子信息,请确认种子有做种源或网络可直连 BT".to_string())?
.map_err(|e| format!("解析种子失败: {}", e))?;
let AddTorrentResponse::ListOnly(lo) = resp else {
return Err("该链接未返回有效的种子元数据".to_string());
};
let info = lo.info.info();
let name = info
.name
.as_ref()
.map(|b| String::from_utf8_lossy(b.as_ref()).into_owned())
.or_else(|| lo.info.name().map(|c| c.to_string()))
.unwrap_or_else(|| "未命名种子".to_string());
let mut files = Vec::new();
let mut total_size = 0u64;
if let Some(fs) = &info.files {
for (i, f) in fs.iter().enumerate() {
let path = f
.path
.iter()
.map(|p| String::from_utf8_lossy(p.as_ref()).into_owned())
.collect::<Vec<_>>()
.join("/");
files.push(BtFileInfo {
index: i as u32,
path,
size: f.length,
});
total_size += f.length;
}
} else if let Some(len) = info.length {
// 单文件种子
files.push(BtFileInfo {
index: 0,
path: name.clone(),
size: len,
});
total_size = len;
}
Ok(TorrentInfo {
name,
info_hash: hex_encode(&lo.info_hash.0),
total_size,
files,
})
}
/// 异步添加种子:立即返回句柄(不等待元数据),由调用方后台解析。
/// 附加公共 tracker 提升磁力元数据解析成功率;上传限速 per-torrent 应用。
pub async fn add_async(
&self,
input: &str,
output_dir: &str,
) -> Result<(usize, Arc<ManagedTorrent>), String> {
let session = self.get_session().await?;
std::fs::create_dir_all(output_dir).map_err(|e| format!("创建下载目录失败: {}", e))?;
let up = self.upload_limit_bps.load(std::sync::atomic::Ordering::SeqCst);
let upload_bps = std::num::NonZeroU32::new(up.min(u32::MAX as u64) as u32);
let trackers = self.build_trackers();
let opts = AddTorrentOptions {
paused: true,
output_folder: Some(output_dir.to_string()),
overwrite: true,
trackers: Some(trackers),
ratelimits: librqbit::limits::LimitsConfig {
upload_bps,
download_bps: None,
},
..Default::default()
};
let add = AddTorrent::from_cli_argument(input).map_err(|e| format!("无效的种子输入: {}", e))?;
let resp = session
.add_torrent(add, Some(opts))
.await
.map_err(|e| format!("添加种子失败: {}", e))?;
match resp {
AddTorrentResponse::Added(id, handle) => Ok((id, handle)),
AddTorrentResponse::AlreadyManaged(id, handle) => Ok((id, handle)),
_ => Err("种子已存在或元数据无效".to_string()),
}
}
/// 等待种子元数据初始化就绪(磁力需要从网络拉取,带超时)
pub async fn wait_initialized(&self, handle: &Arc<ManagedTorrent>) -> Result<(), String> {
tokio::time::timeout(
std::time::Duration::from_secs(INSPECT_TIMEOUT_SECS),
handle.wait_until_initialized(),
)
.await
.map_err(|_| "解析磁力元数据超时:未能从 DHT/Tracker 获取种子信息,请确认种子有做种源或网络可直连 BT".to_string())?
.map_err(|e| format!("获取种子元数据失败: {}", e))
}
/// 设置要下载的种子文件子集(用户文件勾选;传全部索引则全选)
pub async fn set_only_files(&self, handle: &Arc<ManagedTorrent>, files: &[u32]) -> Result<(), String> {
let session = self.get_session().await?;
let set: std::collections::HashSet<usize> = files.iter().map(|&i| i as usize).collect();
session
.update_only_files(handle, &set)
.await
.map_err(|e| format!("设置下载文件失败: {}", e))
}
/// 读取句柄当前进度:返回 (已下载字节, 总字节, 每文件已下载字节)
/// `file_progress` 与种子文件一一对应,供前端详情页展示单文件进度。
pub fn progress_full(handle: &Arc<ManagedTorrent>) -> (u64, u64, Vec<u64>) {
let s = handle.stats();
(s.progress_bytes, s.total_bytes, s.file_progress.clone())
}
/// 暂停种子(下载中的连接会停止,已下载片段保留,可继续)
pub async fn pause(&self, handle: &Arc<ManagedTorrent>) -> Result<(), String> {
let session = self.get_session().await?;
session
.pause(handle)
.await
.map_err(|e| format!("暂停种子失败: {}", e))
}
/// 继续种子
pub async fn unpause(&self, handle: &Arc<ManagedTorrent>) -> Result<(), String> {
let session = self.get_session().await?;
session
.unpause(handle)
.await
.map_err(|e| format!("继续种子失败: {}", e))
}
/// 删除种子(info_hash 为 hex 字符串;delete_files 是否同时删除已下载文件)
pub async fn delete(&self, info_hash: &str, delete_files: bool) -> Result<(), String> {
let session = self.get_session().await?;
let id = TorrentIdOrHash::parse(info_hash)
.map_err(|_| format!("无效的 infohash: {}", info_hash))?;
session
.delete(id, delete_files)
.await
.map_err(|e| format!("删除种子失败: {}", e))
}
/// 读取句柄当前下载进度 (已下载字节, 总字节)
pub fn progress(handle: &Arc<ManagedTorrent>) -> (u64, u64) {
let s = handle.stats();
(s.progress_bytes, s.total_bytes)
}
/// 停止全局会话(应用退出时调用)
pub async fn stop(&self) {
let session = {
let mut guard = self.session.lock().await;
guard.take()
};
if let Some(s) = session {
let _ = s.stop().await;
}
}
}
/// 字节数组转小写十六进制字符串(librqbit 的 Id 未实现 Display
pub(crate) fn hex_encode(bytes: &[u8]) -> String {
const HEX: &[u8; 16] = b"0123456789abcdef";
let mut out = String::with_capacity(bytes.len() * 2);
for &b in bytes {
out.push(HEX[(b >> 4) as usize] as char);
out.push(HEX[(b & 0x0f) as usize] as char);
}
out
}
/// 默认会话选项(后续如需新增 DHT/缓存等可在此统一配置)
fn lib_session_options() -> librqbit::SessionOptions {
librqbit::SessionOptions::default()
}
+12 -6
View File
@@ -20,8 +20,8 @@ mod win32_util;
use download_engine::{ use download_engine::{
DownloadEngine, DownloadEngine,
downloader_add_task, downloader_check_url, downloader_get_extension_info, downloader_get_settings, downloader_get_tasks, downloader_add_task, downloader_cancel_task, downloader_check_url, downloader_focus_window, downloader_get_extension_info, downloader_get_settings, downloader_get_tasks, downloader_inspect, downloader_select_bt_files,
downloader_open_dir, downloader_open_url, downloader_pause_task, downloader_remove_task, downloader_open_dir, downloader_open_url, downloader_pause_task, downloader_redownload, downloader_remove_task,
downloader_resume_task, downloader_save_settings, downloader_status, downloader_resume_task, downloader_save_settings, downloader_status,
}; };
use logger::{ use logger::{
@@ -31,7 +31,7 @@ use mihomo_manager::{
proxy_activate_profile, proxy_apply_kernel_update, proxy_cancel_kernel_install, proxy_check_kernel_update, proxy_clear_system_proxy, proxy_activate_profile, proxy_apply_kernel_update, proxy_cancel_kernel_install, proxy_check_kernel_update, proxy_clear_system_proxy,
proxy_close_connection, proxy_confirm_install, proxy_delete_profile, proxy_get_connections, proxy_get_proxies, proxy_get_settings, proxy_get_system_proxy, proxy_close_connection, proxy_confirm_install, proxy_delete_profile, proxy_get_connections, proxy_get_proxies, proxy_get_settings, proxy_get_system_proxy,
proxy_import_profile, proxy_kernel_info, proxy_patch_configs, proxy_restart, proxy_save_settings, proxy_import_profile, proxy_kernel_info, proxy_patch_configs, proxy_restart, proxy_save_settings,
proxy_select_proxy, proxy_set_system_proxy, proxy_start, proxy_status, proxy_stop, proxy_select_proxy, proxy_set_system_proxy, proxy_start, proxy_status, proxy_stop, proxy_traffic,
proxy_test_delay, proxy_update_profile, proxy_version, MihomoManager, proxy_test_delay, proxy_update_profile, proxy_version, MihomoManager,
}; };
use monitor_kernel::{ use monitor_kernel::{
@@ -116,7 +116,7 @@ fn export_bindings() {
proxy_close_connection, proxy_confirm_install, proxy_delete_profile, proxy_get_settings, proxy_close_connection, proxy_confirm_install, proxy_delete_profile, proxy_get_settings,
proxy_get_system_proxy, proxy_import_profile, proxy_kernel_info, proxy_get_system_proxy, proxy_import_profile, proxy_kernel_info,
proxy_restart, proxy_save_settings, proxy_restart, proxy_save_settings,
proxy_select_proxy, proxy_set_system_proxy, proxy_start, proxy_status, proxy_stop, proxy_select_proxy, proxy_set_system_proxy, proxy_start, proxy_status, proxy_stop, proxy_traffic,
proxy_test_delay, proxy_update_profile, proxy_test_delay, proxy_update_profile,
// quickpanel22 // quickpanel22
quickpanel_get_settings, quickpanel_save_settings, quickpanel_register_shortcut, quickpanel_get_settings, quickpanel_save_settings, quickpanel_register_shortcut,
@@ -140,8 +140,8 @@ fn export_bindings() {
clipboard_preview_interacted, clipboard_preview_interacted,
// download_engine10,豁免 2 // download_engine10,豁免 2
downloader_get_tasks, downloader_check_url, downloader_add_task, downloader_pause_task, downloader_get_tasks, downloader_check_url, downloader_add_task, downloader_pause_task,
downloader_resume_task, downloader_remove_task, downloader_get_settings, downloader_resume_task, downloader_cancel_task, downloader_redownload, downloader_remove_task, downloader_get_settings,
downloader_save_settings, downloader_open_dir, downloader_open_url, downloader_save_settings, downloader_open_dir, downloader_open_url, downloader_focus_window, downloader_inspect, downloader_select_bt_files,
// screenshot22,豁免 3get_fullscreen_bmp / load_cache_raw 返回 ipc::Response、compose_copy 接收 ipc::Request // screenshot22,豁免 3get_fullscreen_bmp / load_cache_raw 返回 ipc::Response、compose_copy 接收 ipc::Request
screenshot_disable_transitions, screenshot_show_overlay, screenshot_register_shortcut, screenshot_disable_transitions, screenshot_show_overlay, screenshot_register_shortcut,
screenshot_unregister_shortcut, screenshot_register_pin_shortcut, screenshot_unregister_shortcut, screenshot_register_pin_shortcut,
@@ -200,6 +200,7 @@ pub fn run() {
proxy_status, proxy_status,
proxy_start, proxy_start,
proxy_stop, proxy_stop,
proxy_traffic,
proxy_restart, proxy_restart,
proxy_version, proxy_version,
proxy_get_proxies, proxy_get_proxies,
@@ -243,6 +244,8 @@ pub fn run() {
downloader_check_url, downloader_check_url,
downloader_pause_task, downloader_pause_task,
downloader_resume_task, downloader_resume_task,
downloader_cancel_task,
downloader_redownload,
downloader_remove_task, downloader_remove_task,
downloader_get_settings, downloader_get_settings,
downloader_save_settings, downloader_save_settings,
@@ -250,6 +253,9 @@ pub fn run() {
downloader_get_extension_info, downloader_get_extension_info,
downloader_open_dir, downloader_open_dir,
downloader_open_url, downloader_open_url,
downloader_focus_window,
downloader_inspect,
downloader_select_bt_files,
clipboard_get_history, clipboard_get_history,
clipboard_get_pinned, clipboard_get_pinned,
clipboard_search, clipboard_search,
+9 -1
View File
@@ -4,7 +4,7 @@ use tauri::{AppHandle, State};
use super::system_proxy::{clear_system_proxy_windows, get_system_proxy_windows, set_system_proxy_windows}; use super::system_proxy::{clear_system_proxy_windows, get_system_proxy_windows, set_system_proxy_windows};
use super::{ use super::{
KernelInfo, KernelUpdateInfo, MihomoManager, ProfileMeta, ProxySettings, ProxyStatus, KernelInfo, KernelUpdateInfo, MihomoManager, ProfileMeta, ProxySettings, ProxyStatus, TrafficSnapshot,
}; };
use crate::process_manager::{ProcessInfo, ProcessManager, ProcessStatus}; use crate::process_manager::{ProcessInfo, ProcessManager, ProcessStatus};
@@ -238,6 +238,14 @@ pub async fn proxy_get_connections(
state.get_connections().await state.get_connections().await
} }
#[tauri::command]
#[specta::specta]
pub async fn proxy_traffic(
state: State<'_, MihomoManager>,
) -> Result<TrafficSnapshot, String> {
state.traffic_snapshot().await
}
#[tauri::command] #[tauri::command]
#[specta::specta] #[specta::specta]
pub async fn proxy_close_connection( pub async fn proxy_close_connection(
+70 -11
View File
@@ -17,11 +17,11 @@ mod types;
pub use autoswitch::{pick_best, start_auto_switch_loop}; pub use autoswitch::{pick_best, start_auto_switch_loop};
pub use pseudo::is_pseudo_node; pub use pseudo::is_pseudo_node;
pub use system_proxy::get_system_proxy_windows; pub use system_proxy::get_system_proxy_windows;
pub use types::{InstallProgress, KernelInfo, KernelUpdateInfo, ProfileMeta, ProxySettings, ProxyStatus}; pub use types::{InstallProgress, KernelInfo, KernelUpdateInfo, ProfileMeta, ProxySettings, ProxyStatus, TrafficSnapshot};
pub use commands::{ pub use commands::{
proxy_activate_profile, proxy_apply_kernel_update, proxy_cancel_kernel_install, proxy_check_kernel_update, proxy_clear_system_proxy, proxy_activate_profile, proxy_apply_kernel_update, proxy_cancel_kernel_install, proxy_check_kernel_update, proxy_clear_system_proxy,
proxy_close_connection, proxy_confirm_install, proxy_delete_profile, proxy_get_connections, proxy_get_proxies, proxy_close_connection, proxy_confirm_install, proxy_delete_profile, proxy_get_connections, proxy_get_proxies,
proxy_get_settings, proxy_get_system_proxy, proxy_import_profile, proxy_kernel_info, proxy_get_settings, proxy_get_system_proxy, proxy_import_profile, proxy_kernel_info, proxy_traffic,
proxy_patch_configs, proxy_restart, proxy_save_settings, proxy_select_proxy, proxy_set_system_proxy, proxy_patch_configs, proxy_restart, proxy_save_settings, proxy_select_proxy, proxy_set_system_proxy,
proxy_start, proxy_status, proxy_stop, proxy_test_delay, proxy_update_profile, proxy_version, proxy_start, proxy_status, proxy_stop, proxy_test_delay, proxy_update_profile, proxy_version,
}; };
@@ -45,10 +45,19 @@ struct SettingsCacheEntry {
settings: ProxySettings, settings: ProxySettings,
} }
/// 流量速率差分基线:记录上次采样的会话总量与时刻,用于计算实时速率
struct TrafficBaseline {
download_total: u64,
upload_total: u64,
at: Instant,
}
pub struct MihomoManager { pub struct MihomoManager {
root: PathBuf, root: PathBuf,
client: Client, client: Client,
settings_cache: Mutex<Option<SettingsCacheEntry>>, settings_cache: Mutex<Option<SettingsCacheEntry>>,
/// 流量速率差分基线:记录上次采样总量与时刻,由两次 /connections 总量差异计算实时速率
traffic_baseline: Mutex<Option<TrafficBaseline>>,
/// 内核安装/更新的取消标志(前端「停止下载」置位,下载循环轮询后中止) /// 内核安装/更新的取消标志(前端「停止下载」置位,下载循环轮询后中止)
kernel_cancel: Arc<AtomicBool>, kernel_cancel: Arc<AtomicBool>,
/// 取消唤醒通道:让停滞在流式读取(stream.next 最多等 30s)中的下载立即感知取消, /// 取消唤醒通道:让停滞在流式读取(stream.next 最多等 30s)中的下载立即感知取消,
@@ -75,6 +84,7 @@ impl MihomoManager {
.build() .build()
.unwrap_or_else(|_| Client::new()), .unwrap_or_else(|_| Client::new()),
settings_cache: Mutex::new(None), settings_cache: Mutex::new(None),
traffic_baseline: Mutex::new(None),
kernel_cancel: Arc::new(AtomicBool::new(false)), kernel_cancel: Arc::new(AtomicBool::new(false)),
kernel_cancel_tx: tokio::sync::watch::channel(false).0, kernel_cancel_tx: tokio::sync::watch::channel(false).0,
install_confirm: std::sync::Mutex::new(None), install_confirm: std::sync::Mutex::new(None),
@@ -308,16 +318,23 @@ impl MihomoManager {
/// 手动启动与 App 自启共用,保证设置语义一致(mihomo 运行期间自动跟随系统代理)。 /// 手动启动与 App 自启共用,保证设置语义一致(mihomo 运行期间自动跟随系统代理)。
pub fn apply_auto_system_proxy(&self) { pub fn apply_auto_system_proxy(&self) {
let settings = self.load_settings(); let settings = self.load_settings();
if settings.auto_system_proxy && !settings.system_proxy && !system_proxy::get_system_proxy_windows() { if !settings.auto_system_proxy {
let addr = format!("127.0.0.1:{}", settings.mixed_port); return;
if let Err(e) = system_proxy::set_system_proxy_windows(&addr) {
crate::logger::log_warn("mihomo", &format!("自动开启系统代理失败: {}", e));
return;
}
let mut s = settings;
s.system_proxy = true;
let _ = self.save_settings(&s);
} }
// 以注册表实际状态为准判断是否已开启:settings.system_proxy 是会话内标志,
// 上次退出 cleanup_on_exit 只清注册表不会回写该标志,重启后会残留 true,
// 若用它做守卫会导致「启动时自动开启系统代理」永远被短路而不生效。
if system_proxy::get_system_proxy_windows() {
return;
}
let addr = format!("127.0.0.1:{}", settings.mixed_port);
if let Err(e) = system_proxy::set_system_proxy_windows(&addr) {
crate::logger::log_warn("mihomo", &format!("自动开启系统代理失败: {}", e));
return;
}
let mut s = settings;
s.system_proxy = true;
let _ = self.save_settings(&s);
} }
/// 应用启动时检查是否需要自动启动 mihomo 和系统代理 /// 应用启动时检查是否需要自动启动 mihomo 和系统代理
@@ -450,6 +467,48 @@ impl MihomoManager {
self.api_get("/connections").await self.api_get("/connections").await
} }
/// 拉取 /connections 并计算实时流量快照。
/// 速率由两次采样的会话总量差分得出;mihomo 重启导致总量回退时自动重置基线。
pub async fn traffic_snapshot(&self) -> Result<TrafficSnapshot, String> {
let conns = self.get_connections().await?;
let upload_total = conns["uploadTotal"].as_u64().unwrap_or(0);
let download_total = conns["downloadTotal"].as_u64().unwrap_or(0);
let active_connections = conns["connections"].as_array().map(|a| a.len()).unwrap_or(0);
let (upload_speed, download_speed) = {
let base = self.traffic_baseline.lock().unwrap();
match base.as_ref() {
// 正常差分:总量单调递增才计算速率
Some(b) if upload_total >= b.upload_total && download_total >= b.download_total => {
let dt = b.at.elapsed().as_secs_f64();
if dt > 0.0 {
let up = ((upload_total - b.upload_total) as f64 / dt).max(0.0) as u64;
let down = ((download_total - b.download_total) as f64 / dt).max(0.0) as u64;
(up, down)
} else {
(0, 0)
}
}
// 无基线或总量回退(mihomo 重启):本帧速率为 0,下方重置基线
_ => (0, 0),
}
};
*self.traffic_baseline.lock().unwrap() = Some(TrafficBaseline {
download_total,
upload_total,
at: Instant::now(),
});
Ok(TrafficSnapshot {
download_total,
upload_total,
download_speed,
upload_speed,
active_connections,
})
}
pub async fn close_connection(&self, id: &str) -> Result<(), String> { pub async fn close_connection(&self, id: &str) -> Result<(), String> {
self.api_request( self.api_request(
reqwest::Method::DELETE, reqwest::Method::DELETE,
+16
View File
@@ -124,6 +124,22 @@ pub struct ProxyStatus {
pub restart_count: u32, pub restart_count: u32,
} }
/// 实时流量快照(由 /connections 的会话总量差分得出实时速率)
#[derive(Serialize, Clone, Type)]
#[serde(rename_all = "camelCase")]
pub struct TrafficSnapshot {
/// 本次会话累计下载字节数
pub download_total: u64,
/// 本次会话累计上传字节数
pub upload_total: u64,
/// 实时下载速率(字节/秒)
pub download_speed: u64,
/// 实时上传速率(字节/秒)
pub upload_speed: u64,
/// 当前活跃连接数
pub active_connections: usize,
}
/// 内核安装进度事件载荷 /// 内核安装进度事件载荷
/// - stage: downloading | extracting | replacing | done | error /// - stage: downloading | extracting | replacing | done | error
/// - percent: 0-100(无 total_bytes 时为 0,前端按 downloadedBytes 显示) /// - percent: 0-100(无 total_bytes 时为 0,前端按 downloadedBytes 显示)
+6 -2
View File
@@ -60,7 +60,7 @@ mod win_api {
GetClassNameW, GetCursorPos, GetForegroundWindow, GetWindowLongPtrW, GetClassNameW, GetCursorPos, GetForegroundWindow, GetWindowLongPtrW,
GetWindowRect, SendMessageW, SetWindowLongPtrW, SetWindowPos, GetWindowRect, SendMessageW, SetWindowLongPtrW, SetWindowPos,
GWL_EXSTYLE, HTCAPTION, HWND_NOTOPMOST, HWND_TOPMOST, SWP_NOACTIVATE, SWP_NOMOVE, GWL_EXSTYLE, HTCAPTION, HWND_NOTOPMOST, HWND_TOPMOST, SWP_NOACTIVATE, SWP_NOMOVE,
SWP_NOSIZE, SWP_NOZORDER, SWP_SHOWWINDOW, WM_NCLBUTTONDOWN, WS_EX_NOACTIVATE, SWP_NOSIZE, SWP_NOZORDER, WM_NCLBUTTONDOWN, WS_EX_NOACTIVATE,
WS_EX_TOOLWINDOW, WS_EX_TRANSPARENT, WS_EX_TOOLWINDOW, WS_EX_TRANSPARENT,
}; };
@@ -165,6 +165,10 @@ mod win_api {
} else { } else {
HWND_NOTOPMOST HWND_NOTOPMOST
}; };
// 注意:不传 SWP_SHOWWINDOW,仅调整 Z 序,绝不改变窗口可见性。
// 否则当 OSD 被 .hide() 隐藏后,任务栏覆盖监视线程在系统 UI 前景切换时
// (点击任务栏/托盘关闭主界面、打开托盘菜单)会重新显示已隐藏的 OSD,
// 表现为"托盘关闭 OSD 无效 / 关闭主界面后 OSD 又出现"。
SetWindowPos( SetWindowPos(
hwnd, hwnd,
insert_after, insert_after,
@@ -172,7 +176,7 @@ mod win_api {
0, 0,
0, 0,
0, 0,
SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE | SWP_SHOWWINDOW, SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE,
); );
} }
} }
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "thing", "productName": "thing",
"version": "26.8.2", "version": "26.8.3",
"identifier": "thing.lfeng.me", "identifier": "thing.lfeng.me",
"build": { "build": {
"beforeDevCommand": "bun run dev", "beforeDevCommand": "bun run dev",
+57 -9
View File
@@ -14,8 +14,8 @@ import { useProcessStore } from '@/stores/processStore'
import { TooltipProvider } from '@/components/ui/tooltip' import { TooltipProvider } from '@/components/ui/tooltip'
import { moduleRegistry } from '@/modules/registry' import { moduleRegistry } from '@/modules/registry'
import type { ModuleMeta } from '@/types/module' import type { ModuleMeta } from '@/types/module'
import { pendingNewDownload, pendingShowDownloadTasks } from '@/lib/trayEvents' import { pendingNewDownload } from '@/lib/trayEvents'
import { EVENTS, STORAGE_KEYS } from '@/lib/constants' import { EVENTS, STORAGE_KEYS, WINDOWS } from '@/lib/constants'
import { commands } from '@/lib/bindings' import { commands } from '@/lib/bindings'
const appStore = useAppStore() const appStore = useAppStore()
@@ -104,6 +104,56 @@ const handleSearch = (moduleId: string) => {
handleModuleChange(moduleId) handleModuleChange(moduleId)
} }
// 为单个下载任务创建专属的一次性下载窗口(浏览器扩展发起)。
// label 带 task id 保证同时多个下载时各占一个窗口;对应 capabilities/download-window.json 的 glob "download-window-*"
async function openDownloadWindow(taskId: string) {
const { WebviewWindow } = await import('@tauri-apps/api/webviewWindow')
const { currentMonitor } = await import('@tauri-apps/api/window')
const label = `${WINDOWS.downloadWindow}-${taskId}`
try {
const existing = await WebviewWindow.getByLabel(label)
if (existing) {
// 已存在:用 Rust 端强制置前(绕过前台锁定,双屏/后台创建也能到前台)
await commands.downloaderFocusWindow(label)
return
}
// 定位到主窗口当前所在显示器的中央偏上
const monitor = await currentMonitor()
const scale = monitor?.scaleFactor ?? 1
const w = 420
const h = 176
const x = Math.round(((monitor?.size.width ?? 1920) / scale - w) / 2)
const y = Math.round(((monitor?.size.height ?? 1080) / scale - h) / 2 * 0.8)
const win = new WebviewWindow(label, {
url: `index.html#download-window?task=${encodeURIComponent(taskId)}`,
title: '下载',
width: w,
height: h,
x,
y,
decorations: false,
transparent: true,
resizable: false,
maximizable: false,
minimizable: true,
shadow: true,
visible: false,
focus: false,
// 默认不置顶、放入任务栏(可最小化,任务栏图标唤出);下载完成时窗口置前提醒。
// 隐藏创建:由 DownloadWindow 贴合内容高度后一次性 show,避免显示后再 resize 闪烁
})
win.once('tauri://error', (e) => console.error('创建下载窗口失败:', e))
// 窗口改为隐藏创建:由 DownloadWindow 在 onMounted 贴合内容高度后一次性 show,
// 避免"先以 176 高度显示、再 resize 到内容高度"造成的闪烁。
// 此处仅保留异常兜底:WebView 加载异常导致 DownloadWindow 未 reveal 时,强制显示。
win.once('tauri://created', () => {
window.setTimeout(() => { void commands.downloaderFocusWindow(label) }, 2500)
})
} catch (e) {
console.error('创建下载窗口失败:', e)
}
}
const getFallbackModule = () => { const getFallbackModule = () => {
const enabledIds = appStore.enabledModules.map(m => m.id) const enabledIds = appStore.enabledModules.map(m => m.id)
const fallback = moduleRegistry.getAllMetas().find( const fallback = moduleRegistry.getAllMetas().find(
@@ -198,14 +248,12 @@ onMounted(async () => {
} }
}) })
) )
// 浏览器扩展新增下载:直接切到下载模块的任务列表页(主窗口已由 Rust 端置前) // 浏览器扩展新增下载:为该任务创建一个专属的一次性下载窗口(不打断主界面)。
// 主窗口本身无需置前,下载进度/完成事件由独立窗口自行监听。
trayUnlisteners.push( trayUnlisteners.push(
await listen(EVENTS.downloadExtensionAdded, () => { await listen<{ id: string }>(EVENTS.downloadExtensionAdded, (e) => {
const enabledIds = appStore.enabledModules.map(m => m.id) if (!e.payload?.id) return
if (enabledIds.includes('downloader') || moduleRegistry.getConfig('downloader')?.builtin) { void openDownloadWindow(e.payload.id)
pendingShowDownloadTasks.value = true
handleModuleChange('downloader')
}
}) })
) )
trayUnlisteners.push( trayUnlisteners.push(
+1 -1
View File
@@ -27,7 +27,7 @@ watch(
<template> <template>
<main <main
ref="containerRef" ref="containerRef"
class="flex-1" class="flex-1 min-w-0"
:style="{ backgroundColor: 'var(--effect-bg)', backdropFilter: 'var(--effect-blur)' }" :style="{ backgroundColor: 'var(--effect-bg)', backdropFilter: 'var(--effect-blur)' }"
> >
<ScrollArea data-main-scroll class="h-full w-full"> <ScrollArea data-main-scroll class="h-full w-full">
+10
View File
@@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import type { HTMLAttributes } from "vue" import type { HTMLAttributes } from "vue"
import { ref } from "vue"
import { useVModel } from "@vueuse/core" import { useVModel } from "@vueuse/core"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
@@ -17,10 +18,19 @@ const modelValue = useVModel(props, "modelValue", emits, {
passive: true, passive: true,
defaultValue: props.defaultValue, defaultValue: props.defaultValue,
}) })
// 暴露原生 input 元素与 focus,供父级 `ref="xx"` 后调用 xx.focus()
// (组件默认不转发,ref 拿到的是组件实例,调用 .focus() 会报 "focus is not a function"
const inputEl = ref<HTMLInputElement | null>(null)
defineExpose({
focus: () => inputEl.value?.focus(),
element: () => inputEl.value,
})
</script> </script>
<template> <template>
<input <input
ref="inputEl"
v-model="modelValue" v-model="modelValue"
data-slot="input" data-slot="input"
:class="cn( :class="cn(
+81 -4
View File
@@ -43,6 +43,7 @@ export const commands = {
proxyStart: () => __TAURI_INVOKE<ProcessInfo>("proxy_start"), proxyStart: () => __TAURI_INVOKE<ProcessInfo>("proxy_start"),
proxyStatus: () => __TAURI_INVOKE<ProxyStatus>("proxy_status"), proxyStatus: () => __TAURI_INVOKE<ProxyStatus>("proxy_status"),
proxyStop: () => __TAURI_INVOKE<null>("proxy_stop"), proxyStop: () => __TAURI_INVOKE<null>("proxy_stop"),
proxyTraffic: () => __TAURI_INVOKE<TrafficSnapshot>("proxy_traffic"),
proxyTestDelay: (name: string, url: string | null, timeout: number | null) => __TAURI_INVOKE<number>("proxy_test_delay", { name, url, timeout }), proxyTestDelay: (name: string, url: string | null, timeout: number | null) => __TAURI_INVOKE<number>("proxy_test_delay", { name, url, timeout }),
proxyUpdateProfile: (id: string) => __TAURI_INVOKE<ProfileMeta>("proxy_update_profile", { id }), proxyUpdateProfile: (id: string) => __TAURI_INVOKE<ProfileMeta>("proxy_update_profile", { id }),
/** 读取快速面板设置(快捷键等) */ /** 读取快速面板设置(快捷键等) */
@@ -198,11 +199,15 @@ export const commands = {
/** 检查 URL 重复性并探测文件信息(添加下载前调用) */ /** 检查 URL 重复性并探测文件信息(添加下载前调用) */
downloaderCheckUrl: (url: string, dir: string | null, headers: { [key in string]: string } | null) => __TAURI_INVOKE<CheckUrlResult>("downloader_check_url", { url, dir, headers }), downloaderCheckUrl: (url: string, dir: string | null, headers: { [key in string]: string } | null) => __TAURI_INVOKE<CheckUrlResult>("downloader_check_url", { url, dir, headers }),
/** 添加下载任务 */ /** 添加下载任务 */
downloaderAddTask: (url: string, filename: string | null, dir: string | null, headers: { [key in string]: string } | null, autoRename: boolean | null) => __TAURI_INVOKE<string>("downloader_add_task", { url, filename, dir, headers, autoRename }), downloaderAddTask: (url: string, filename: string | null, dir: string | null, headers: { [key in string]: string } | null, autoRename: boolean | null, onlyFiles: number[] | null) => __TAURI_INVOKE<string>("downloader_add_task", { url, filename, dir, headers, autoRename, onlyFiles }),
/** 暂停任务 */ /** 暂停任务 */
downloaderPauseTask: (id: string) => __TAURI_INVOKE<null>("downloader_pause_task", { id }), downloaderPauseTask: (id: string) => __TAURI_INVOKE<null>("downloader_pause_task", { id }),
/** 恢复任务 */ /** 恢复任务 */
downloaderResumeTask: (id: string) => __TAURI_INVOKE<null>("downloader_resume_task", { id }), downloaderResumeTask: (id: string) => __TAURI_INVOKE<null>("downloader_resume_task", { id }),
/** 取消任务(置为已取消,清空进度并删除下载文件,但保留记录) */
downloaderCancelTask: (id: string) => __TAURI_INVOKE<null>("downloader_cancel_task", { id }),
/** 重新下载已取消/出错的任务 */
downloaderRedownload: (id: string) => __TAURI_INVOKE<null>("downloader_redownload", { id }),
/** 移除任务 */ /** 移除任务 */
downloaderRemoveTask: (id: string, deleteFiles: boolean | null) => __TAURI_INVOKE<null>("downloader_remove_task", { id, deleteFiles }), downloaderRemoveTask: (id: string, deleteFiles: boolean | null) => __TAURI_INVOKE<null>("downloader_remove_task", { id, deleteFiles }),
/** 获取设置 */ /** 获取设置 */
@@ -213,6 +218,17 @@ export const commands = {
downloaderOpenDir: (path: string) => __TAURI_INVOKE<null>("downloader_open_dir", { path }), downloaderOpenDir: (path: string) => __TAURI_INVOKE<null>("downloader_open_dir", { path }),
/** 用系统默认浏览器打开 URL */ /** 用系统默认浏览器打开 URL */
downloaderOpenUrl: (url: string) => __TAURI_INVOKE<null>("downloader_open_url", { url }), downloaderOpenUrl: (url: string) => __TAURI_INVOKE<null>("downloader_open_url", { url }),
/**
* 将指定 label 的下载窗口显示并强制置为前台。
* Tauri 的 set_focus 在 Windows 上受前台锁定限制(尤其下载窗口由后台进程创建、
* 或创建到非主显示器时更明显),改用原生 SetForegroundWindow + BringWindowToTop
* (模拟 Alt 键重置前台锁定),保证开始/完成下载时窗口能正确定位到前台。
*/
downloaderFocusWindow: (label: string) => __TAURI_INVOKE<null>("downloader_focus_window", { label }),
/** 解析磁力链 / .torrent 文件,返回种子信息(名称 / infohash / 文件列表),供前端做文件勾选 */
downloaderInspect: (input: string) => __TAURI_INVOKE<TorrentInfo>("downloader_inspect", { input }),
/** 磁力任务:元数据解析成功后,用户勾选文件并确认开始下载 */
downloaderSelectBtFiles: (id: string, onlyFiles: number[]) => __TAURI_INVOKE<null>("downloader_select_bt_files", { id, onlyFiles }),
/** 禁用指定窗口(按 label 查找)的显示/隐藏过渡动画,消除覆盖层出现/消失时的缩放动画 */ /** 禁用指定窗口(按 label 查找)的显示/隐藏过渡动画,消除覆盖层出现/消失时的缩放动画 */
screenshotDisableTransitions: (label: string) => __TAURI_INVOKE<null>("screenshot_disable_transitions", { label }), screenshotDisableTransitions: (label: string) => __TAURI_INVOKE<null>("screenshot_disable_transitions", { label }),
/** 一次 IPC 完成指定窗口的显示与聚焦(截图覆盖层显示关键路径,减少串行往返) */ /** 一次 IPC 完成指定窗口的显示与聚焦(截图覆盖层显示关键路径,减少串行往返) */
@@ -280,6 +296,16 @@ export type ArchiveInfo = {
size: number, size: number,
}; };
/** BT 种子内文件条目(多文件任务用;阶段1下载全部文件,但保留列表供 UI 展示) */
export type BtFileInfo = {
/** 文件在种子内的索引 */
index: number,
/** 相对种子根目录的路径(如 "sub/file.mkv" */
path: string,
/** 文件大小(字节) */
size: number,
};
/** 前端可见的捕获数据 */ /** 前端可见的捕获数据 */
export type CaptureData = { export type CaptureData = {
pngBase64: string, pngBase64: string,
@@ -372,12 +398,20 @@ export type DeleteResult = {
export type DownloadTask = { export type DownloadTask = {
/** 任务 ID(自增 hex 字符串) */ /** 任务 ID(自增 hex 字符串) */
id: string, id: string,
/** 下载地址 */ /** 下载地址HTTP URL 或磁力链接) */
url: string, url: string,
/** 文件名 */ /** 文件名(HTTP:目标文件名;BT:种子名称) */
filename: string, filename: string,
/** 保存目录(绝对路径) */ /** 保存目录(绝对路径) */
dir: string, dir: string,
/** 协议类型 */
protocol?: TaskProtocol,
/** BT 种子 infohash(协议=BitTorrent 时存在) */
infoHash?: string | null,
/** BT 种子内文件列表(协议=BitTorrent 时存在) */
btFiles?: BtFileInfo[],
/** BT 元数据是否已解析就绪(异步添加时:后台解析完成前为 false,调度器跳过) */
btMetadataReady?: boolean,
/** 状态 */ /** 状态 */
status: TaskStatus, status: TaskStatus,
/** 文件总大小(字节),0=未知 */ /** 文件总大小(字节),0=未知 */
@@ -420,6 +454,14 @@ export type DownloaderSettings = {
checkDuplicate?: boolean, checkDuplicate?: boolean,
/** 下载是否使用代理:true=尊重系统代理(mihomo 开启系统代理时经其转发),false=强制直连 */ /** 下载是否使用代理:true=尊重系统代理(mihomo 开启系统代理时经其转发),false=强制直连 */
useProxy?: boolean, useProxy?: boolean,
/** BitTorrent 上传限速 KB/s0=不限) */
btUploadLimitKb?: number,
/** BitTorrent 下载完成后是否继续做种上传(false=下载完即停止上传) */
btSeedAfterDownload?: boolean,
/** BitTorrent 监听端口(0=自动选择) */
btListenPort?: number,
/** BitTorrent 使用代理下载:开启后自动使用代理模块(mihomo)的 SOCKS5 端口;代理不可用时降级直连 */
btUseProxy?: boolean,
}; };
/** 重复类型 */ /** 重复类型 */
@@ -604,6 +646,13 @@ export type SpecialLocation = {
args: string[], args: string[],
}; };
/** 任务下载协议类型 */
export type TaskProtocol =
/** HTTP/HTTPS 直链 */
"http" |
/** BitTorrent(磁力链 / .torrent 文件) */
"bittorrent";
/** 任务状态 */ /** 任务状态 */
export type TaskStatus = export type TaskStatus =
/** 排队等待(并发数已满) */ /** 排队等待(并发数已满) */
@@ -615,7 +664,35 @@ export type TaskStatus =
/** 已完成 */ /** 已完成 */
"complete" | "complete" |
/** 错误 */ /** 错误 */
"error"; "error" |
/** 已取消(用户取消:进度与文件已清除,仅保留记录,只能再次下载) */
"cancelled";
/** 种子信息(inspect 解析结果,供命令返回给前端做文件勾选) */
export type TorrentInfo = {
/** 种子名称 */
name: string,
/** infohashhex 小写字符串) */
infoHash: string,
/** 种子内全部文件总大小(字节) */
totalSize: number,
/** 种子内文件列表 */
files: BtFileInfo[],
};
/** 实时流量快照(由 /connections 的会话总量差分得出实时速率) */
export type TrafficSnapshot = {
/** 本次会话累计下载字节数 */
downloadTotal: number,
/** 本次会话累计上传字节数 */
uploadTotal: number,
/** 实时下载速率(字节/秒) */
downloadSpeed: number,
/** 实时上传速率(字节/秒) */
uploadSpeed: number,
/** 当前活跃连接数 */
activeConnections: number,
};
/** release 中的一个资产 */ /** release 中的一个资产 */
export type UpdateAsset = { export type UpdateAsset = {
+5 -1
View File
@@ -10,6 +10,8 @@ export const WINDOWS = {
osdOverlay: 'osd-overlay', osdOverlay: 'osd-overlay',
screenshotOverlay: 'screenshot-overlay', screenshotOverlay: 'screenshot-overlay',
screenshotPin: 'screenshot-pin', screenshotPin: 'screenshot-pin',
/** 单文件一次性下载窗口前缀,实际 label = `${downloadWindow}-<taskId>` */
downloadWindow: 'download-window',
} as const } as const
/** Tauri 事件名(前端 emit / listen 与 Rust constants::events 对应) */ /** Tauri 事件名(前端 emit / listen 与 Rust constants::events 对应) */
@@ -72,7 +74,7 @@ export const EVENTS = {
downloadAdded: 'download-added', downloadAdded: 'download-added',
/** 任务被删除(浏览器扩展通过 HTTP API 删除任务时发出,前端据此刷新任务列表) */ /** 任务被删除(浏览器扩展通过 HTTP API 删除任务时发出,前端据此刷新任务列表) */
downloadRemoved: 'download-removed', downloadRemoved: 'download-removed',
/** 浏览器扩展通过 HTTP API 新增下载(置前主窗口并跳到下载画面 */ /** 浏览器扩展通过 HTTP API 新增下载(负载 { id },前端据以为该任务创建专属下载窗口 */
downloadExtensionAdded: 'download-extension-added', downloadExtensionAdded: 'download-extension-added',
} as const } as const
@@ -94,6 +96,8 @@ export const STORAGE_KEYS = {
quickpanelDeleteFilterFavs: 'thing_quickpanel_delete_filter_favs', quickpanelDeleteFilterFavs: 'thing_quickpanel_delete_filter_favs',
currencyRates: 'thing_quickpanel_currency_rates', currencyRates: 'thing_quickpanel_currency_rates',
monitorOsdConfig: 'thing_monitor_osd_config', monitorOsdConfig: 'thing_monitor_osd_config',
/** 关闭"自动启动监控内核"时暂存的 OSD 开关状态(开启自动启动时据此恢复) */
monitorOsdPending: 'thing_monitor_osd_pending',
monitorOverviewCards: 'thing_monitor_overview_cards', monitorOverviewCards: 'thing_monitor_overview_cards',
screenshotHistory: 'thing_screenshot_history', screenshotHistory: 'thing_screenshot_history',
screenshotPinIndex: 'thing_screenshot_pin_index', screenshotPinIndex: 'thing_screenshot_pin_index',
+6 -4
View File
@@ -31,14 +31,16 @@ const standaloneWindowApps: Array<[hash: string, label: string, loader: () => Pr
['#screenshot-overlay', '截图覆盖层', () => import('./modules/screenshot/ScreenshotOverlay.vue')], ['#screenshot-overlay', '截图覆盖层', () => import('./modules/screenshot/ScreenshotOverlay.vue')],
['#screenshot-editor', '截图编辑器', () => import('./modules/screenshot/ScreenshotEditor.vue')], ['#screenshot-editor', '截图编辑器', () => import('./modules/screenshot/ScreenshotEditor.vue')],
['#screenshot-pin', '贴图窗口', () => import('./modules/screenshot/ScreenshotPin.vue')], ['#screenshot-pin', '贴图窗口', () => import('./modules/screenshot/ScreenshotPin.vue')],
['#download-window', '下载窗口', () => import('./modules/downloader/DownloadWindow.vue')],
] ]
const winHash = window.location.hash const winHash = window.location.hash
// #screenshot-overlay 带窗口号参数(多屏),按前缀匹配;其余精确匹配 // #screenshot-overlay 带窗口号参数(多屏)、#download-window 带 ?task= 参数,按前缀匹配;其余精确匹配
const matched = standaloneWindowApps.find(([hash]) => const matched = standaloneWindowApps.find(([hash]) => {
hash === '#screenshot-overlay' ? winHash.startsWith(hash) : winHash === hash if (hash === '#screenshot-overlay' || hash === '#download-window') return winHash.startsWith(hash)
) return winHash === hash
})
if (matched) { if (matched) {
const [, label, loader] = matched const [, label, loader] = matched
+495
View File
@@ -0,0 +1,495 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { getCurrentWindow, Effect, EffectState } from '@tauri-apps/api/window'
import { LogicalSize } from '@tauri-apps/api/dpi'
import { listen, type UnlistenFn } from '@tauri-apps/api/event'
import { Pause, Play, X, Minus, FolderOpen, AlertCircle, Loader2, XCircle } from '@lucide/vue'
import { commands, type DownloadTask, type Segment, type TaskStatus } from '@/lib/bindings'
import { STORAGE_KEYS } from '@/lib/constants'
import { createLogger } from '@/lib/logger'
const logger = createLogger('download-window')
// ===== 从 URL hash 解析任务 id#download-window?task=<id> =====
const hash = window.location.hash
const taskId = new URLSearchParams(hash.split('?')[1] ?? '').get('task') ?? ''
if (!taskId) logger.warn('下载窗口缺少 task 参数')
const win = getCurrentWindow()
const task = ref<DownloadTask | null>(null)
const loading = ref(true)
const actionError = ref('')
// ===== 进度事件载荷(与 Rust 端 ProgressPayload 对应) =====
interface ProgressPayload {
id: string
completedSize: number
totalSize: number
speed: number
status: TaskStatus
segments: number[]
}
const loadTask = async () => {
try {
const all = await commands.downloaderGetTasks()
task.value = all.find(t => t.id === taskId) ?? null
} catch (e) {
logger.error('获取任务失败: ' + e)
} finally {
loading.value = false
}
// 任务已不存在(被模块/扩展删除):本窗口无存在意义,直接关闭
if (!task.value) {
await win.close()
return
}
// 文件名显示到窗口标题(无边框窗口下仍影响任务栏悬浮标题)
try { await win.setTitle(task.value.filename) } catch { /* 忽略 */ }
}
// ===== 状态派生 =====
const totalSize = computed(() => task.value?.totalSize ?? 0)
const completedSize = computed(() => task.value?.completedSize ?? 0)
const speed = computed(() => task.value?.speed ?? 0)
const status = computed<TaskStatus>(() => task.value?.status ?? 'active')
const progress = computed(() => {
if (!totalSize.value || totalSize.value === 0) return 0
return Math.min(100, Math.round((completedSize.value / totalSize.value) * 100))
})
const segments = computed(() => task.value?.segments ?? [])
const showSegments = computed(() => segments.value.length > 1)
const statusText = computed(() => {
switch (status.value) {
case 'active': return '下载中'
case 'queued': return '等待中'
case 'paused': return '已暂停'
case 'complete': return '已完成'
case 'error': return '出错'
case 'cancelled': return '已取消'
}
})
const statusTone = computed(() => {
if (status.value === 'complete') return 'success'
if (status.value === 'error') return 'destructive'
if (status.value === 'paused' || status.value === 'cancelled') return 'muted'
return 'active'
})
// ===== 格式化 =====
const formatByte = (b: number) => {
if (!b || isNaN(b)) return '0 B'
if (b < 1024) return `${b} B`
if (b < 1024 * 1024) return `${(b / 1024).toFixed(1)} KB`
if (b < 1024 * 1024 * 1024) return `${(b / 1024 / 1024).toFixed(2)} MB`
return `${(b / 1024 / 1024 / 1024).toFixed(2)} GB`
}
const formatTotal = (b: number) => (!b || b === 0 ? '未知' : formatByte(b))
// ===== 分段进度(横向分段条:每段宽度按其长度占整个文件的比例) =====
const segLen = (s: Segment) => s.end - s.start + 1
const totalSpan = computed(() => segments.value.reduce((a, s) => a + segLen(s), 0))
const segPercent = (s: Segment) => {
if (segLen(s) <= 0) return 0
return Math.min(100, Math.round((s.completed / segLen(s)) * 100))
}
// ===== 操作 =====
const errorTip = (e: unknown) => {
actionError.value = typeof e === 'string' ? e : '操作失败'
}
const doPause = async () => {
try { await commands.downloaderPauseTask(taskId) } catch (e) { return errorTip(e) }
await loadTask().catch(() => {})
}
const doResume = async () => {
try { await commands.downloaderResumeTask(taskId) } catch (e) { return errorTip(e) }
await loadTask().catch(() => {})
}
// 取消:二次确认(3 秒内再点一次才真正删除)
const armedCancel = ref(false)
let cancelArmTimer = 0
const doCancel = async () => {
if (!armedCancel.value) {
armedCancel.value = true
window.clearTimeout(cancelArmTimer)
cancelArmTimer = window.setTimeout(() => (armedCancel.value = false), 3000)
return
}
window.clearTimeout(cancelArmTimer)
armedCancel.value = false
try { await commands.downloaderCancelTask(taskId) } catch (e) { return errorTip(e) }
await win.close()
}
const doOpenFolder = async () => {
if (!task.value?.dir) return
try { await commands.downloaderOpenDir(task.value.dir) } catch (e) { errorTip(e) }
}
const doMinimize = () => win.minimize()
const doClose = () => win.close()
// 标题文字按下拖动窗口:data-tauri-drag-region 对文字子元素可能不生效
// (文字层拦截 pointer 事件),改用显式 startDragging(仅左键)
const startDrag = () => {
win.startDragging().catch(() => {})
}
// 只读提示:错误等瞬态信息 3 秒后清除
const clearErrorSoon = () => {
if (!actionError.value) return
window.setTimeout(() => { if (!actionError.value) return; actionError.value = '' }, 3000)
}
// ===== 主题与窗口效果(与主应用同步:主题 + mica/acrylic 效果) =====
function readMainTheme(): { theme: string; effect: string } {
try {
const raw = localStorage.getItem(STORAGE_KEYS.appSettings)
if (raw) {
const s = JSON.parse(raw)
return { theme: s.theme ?? 'system', effect: s.effect ?? 'mica' }
}
} catch { /* 忽略 */ }
return { theme: 'system', effect: 'mica' }
}
/** 判断当前是否应为深色主题(弹窗独立窗口,system 模式用 matchMedia 可靠) */
function resolveIsDark(theme: string): boolean {
if (theme === 'dark') return true
if (theme === 'light') return false
return window.matchMedia('(prefers-color-scheme: dark)').matches
}
async function applyTheme() {
const root = document.documentElement
const { theme, effect } = readMainTheme()
// 1. 设置窗口原生主题(system → null 跟随系统)
try {
if (theme === 'system') await win.setTheme(null)
else await win.setTheme(theme as 'dark' | 'light')
} catch { /* 忽略 */ }
// 2. 用 matchMedia 判断深浅(不依赖主应用状态)
const isDark = resolveIsDark(theme)
// 3. 设置 DOM classeffect 类供 style.css 变量联动)
root.classList.remove('dark', 'effect-mica', 'effect-acrylic', 'effect-normal')
root.classList.add(`effect-${effect}`)
if (isDark) root.classList.add('dark')
// 4. 设置窗口效果:mica/acrylic 下卡片透明,普通模式用不透明背景
try {
await win.clearEffects()
if (effect === 'mica') {
await win.setEffects({
effects: [Effect.Mica],
state: EffectState.FollowsWindowActiveState,
color: isDark ? [30, 41, 59, 0] : [248, 250, 252, 0],
})
await win.setBackgroundColor('#00000000')
root.style.setProperty('--popup-bg', 'transparent')
} else if (effect === 'acrylic') {
await win.setEffects({
effects: [Effect.Acrylic],
state: EffectState.FollowsWindowActiveState,
color: isDark ? [30, 41, 59, 0] : [248, 250, 252, 0],
})
await win.setBackgroundColor('#00000000')
root.style.setProperty('--popup-bg', 'transparent')
} else {
await win.setBackgroundColor(isDark ? '#0f172a' : '#ffffff')
root.style.setProperty('--popup-bg', isDark ? '#0f172a' : '#ffffff')
}
} catch { /* 忽略 */ }
}
// ===== 自动贴合内容高度:消除卡片下方留白 =====
const rootRef = ref<HTMLElement | null>(null)
let lastHeight = 0
let autoResizeObserver: ResizeObserver | null = null
// 仅兜底防窗口过小;正常状态下窗口严格贴合内容高度(既不高出导致底部露白,
// 也不低于 titlebar/loading 所需),由 getBoundingClientRect().height 决定
const MIN_H = 52
const WINDOW_W = 420
const applyAutoHeight = async () => {
if (!rootRef.value) return
try {
if (await win.isMinimized()) return
} catch { /* 忽略 */ }
const h = Math.max(MIN_H, Math.ceil(rootRef.value.getBoundingClientRect().height))
if (Math.abs(h - lastHeight) > 4) {
lastHeight = h
win.setSize(new LogicalSize(WINDOW_W, h)).catch(() => {})
}
}
// ===== 事件监听 =====
let progressUnlisten: UnlistenFn | null = null
let completeUnlisten: UnlistenFn | null = null
let onThemeChange: (() => void) | null = null
// 完成时置前提醒:恢复最小化 + 显示 + 聚焦(窗口从任务栏/后台唤出到最前)。
// 用 Rust 端 downloaderFocusWindow 强制置前,绕过 Windows 前台锁定(纯 setFocus 会被忽略)
const handleComplete = async () => {
await loadTask().catch(() => {})
if (status.value === 'complete') {
try { await commands.downloaderFocusWindow(win.label) } catch { /* 忽略 */ }
}
}
onMounted(async () => {
await applyTheme()
await loadTask()
progressUnlisten = await listen<ProgressPayload>('download-progress', (e) => {
if (e.payload.id !== taskId || !task.value) return
const t = task.value
t.completedSize = e.payload.completedSize
t.totalSize = e.payload.totalSize
t.speed = e.payload.speed
t.status = e.payload.status
if (Array.isArray(e.payload.segments) && t.segments) {
t.segments.forEach((seg, i) => {
const v = e.payload.segments[i]
if (v !== undefined) seg.completed = v
})
}
})
completeUnlisten = await listen<{ id: string }>('download-complete', (e) => {
if (e.payload?.id !== taskId) return
handleComplete()
})
const mq = window.matchMedia('(prefers-color-scheme: dark)')
onThemeChange = () => applyTheme()
mq.addEventListener('change', onThemeChange)
// 内容变化时自动贴合窗口高度(始终保留,覆盖后续加载→数据等状态下的高度变化)
lastHeight = 0
autoResizeObserver = new ResizeObserver(applyAutoHeight)
if (rootRef.value) autoResizeObserver.observe(rootRef.value)
// 首次显示:窗口由 App.vue 隐藏创建,此处先在隐藏态贴合到内容高度,
// 再延迟一小段等待 setSize 生效后一次性 show + 置前,
// 避免"先以初始高度显示、再 resize"造成的尺寸跳变闪烁。
void applyAutoHeight()
window.setTimeout(async () => {
// 隐藏态下多贴合一次,确保尺寸已对内容就位
await applyAutoHeight()
try { await win.show() } catch { /* 忽略 */ }
try { await win.unminimize() } catch { /* 忽略 */ }
void commands.downloaderFocusWindow(win.label).catch(() => {})
}, 120)
})
onUnmounted(() => {
progressUnlisten?.()
completeUnlisten?.()
autoResizeObserver?.disconnect()
if (onThemeChange) window.matchMedia('(prefers-color-scheme: dark)').removeEventListener('change', onThemeChange)
window.clearTimeout(cancelArmTimer)
})
</script>
<template>
<div ref="rootRef" class="dlw-root">
<!-- 顶部标题栏可拖动文件名作为标题右侧放操作按钮 + 最小化 + 关闭 -->
<div class="dlw-titlebar" data-tauri-drag-region>
<div class="dlw-title-left" data-tauri-drag-region>
<Loader2 v-if="status === 'active'" class="size-3 shrink-0 animate-spin text-primary" />
<XCircle v-else-if="status === 'error'" class="size-3 shrink-0 text-red-500" />
<Pause v-else class="size-3 shrink-0 text-muted-foreground" />
<span v-if="loading" class="text-xs font-medium text-muted-foreground">下载</span>
<span v-else-if="task" class="dlw-title-name" :title="task.filename"
@mousedown.left.prevent="startDrag">{{ task.filename }}</span>
</div>
<div class="dlw-title-right">
<button v-if="status === 'active'" class="dlw-btn" @click="doPause">
<Pause class="size-3" /> 暂停
</button>
<button v-else-if="status === 'paused'" class="dlw-btn" @click="doResume">
<Play class="size-3" /> 继续
</button>
<button v-if="status !== 'complete' && status !== 'error'" class="dlw-btn dlw-btn-danger" @click="doCancel">
{{ armedCancel ? '再点一次取消' : '取消' }}
</button>
<button v-if="status === 'complete'" class="dlw-btn dlw-btn-primary" @click="doOpenFolder">
<FolderOpen class="size-3" /> 打开文件夹
</button>
<button v-else-if="status === 'error' && task?.dir" class="dlw-btn" @click="doOpenFolder">
<FolderOpen class="size-3" /> 打开目录
</button>
<button class="dlw-icon-btn" title="最小化" @click="doMinimize">
<Minus class="size-3.5" />
</button>
<button class="dlw-icon-btn" title="关闭(下载在后台继续)" @click="doClose">
<X class="size-3.5" />
</button>
</div>
</div>
<!-- 主体状态 + 总进度 + 分段条 -->
<div class="dlw-body">
<div v-if="loading" class="dlw-loading">
<Loader2 class="size-5 animate-spin text-muted-foreground" />
</div>
<template v-else-if="task">
<div class="flex items-center justify-between gap-2 text-xs">
<span class="dlw-status-badge" :class="`tone-${statusTone}`">{{ statusText }}</span>
<span class="truncate text-muted-foreground">
{{ formatByte(completedSize) }} / {{ formatTotal(totalSize) }} · {{ progress }}%
<template v-if="status === 'active' && speed > 0"> · {{ formatByte(speed) }}/s</template>
</span>
</div>
<div class="dlw-bar mt-1.5">
<div class="dlw-bar-fill" :style="{ width: progress + '%' }" />
</div>
<!-- 分段进度横向分段条 -->
<div v-if="showSegments" class="mt-2">
<span class="text-[10px] text-muted-foreground">分段 ({{ segments.length }} 线程)</span>
<div class="dlw-seg-strip mt-1">
<div
v-for="(seg, i) in segments"
:key="i"
class="dlw-seg"
:style="{ width: ((segLen(seg) / totalSpan) * 100).toFixed(2) + '%' }"
:title="`#${i + 1} ${segPercent(seg)}%`"
>
<div class="dlw-seg-fill" :style="{ width: segPercent(seg) + '%' }" />
</div>
</div>
</div>
<div v-if="actionError" class="mt-1.5 truncate text-right text-[10px] text-red-500" @click="clearErrorSoon">
{{ actionError }}
</div>
</template>
<div v-else class="dlw-loading">
<span class="flex items-center text-xs text-muted-foreground">
<AlertCircle class="size-4 mr-1.5" /> 任务已不存在
</span>
</div>
</div>
</div>
</template>
<style scoped>
.dlw-root {
display: flex;
flex-direction: column;
gap: 4px;
padding: 4px 6px 6px;
overflow: hidden;
}
/* 标题栏 */
.dlw-titlebar {
display: flex;
align-items: center;
justify-content: space-between;
height: 34px;
padding: 0 2px 0 8px;
gap: 6px;
user-select: none;
flex-shrink: 0;
}
.dlw-title-left {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
}
.dlw-title-name {
font-size: 12px;
font-weight: 600;
line-height: 1.3;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.dlw-title-right {
display: flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
min-width: 0;
}
/* 图标按钮(最小化/关闭) */
.dlw-icon-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: 5px;
color: var(--muted-foreground);
}
.dlw-icon-btn:hover { background: var(--accent); color: var(--accent-foreground); }
/* 主体卡片:背景色由 --popup-bg 控制(mica/acrylic 下透明,普通模式不透明)。
flex:1 撑满 titlebar 下方的剩余高度,避免内容(尤其无分段时)比窗口矮导致底部露出
白色/透明的窗口背景区空白。 */
.dlw-body {
display: flex;
flex-direction: column;
flex: 1 1 auto;
min-height: 0;
border: 1px solid var(--border);
border-radius: 9px;
background: var(--popup-bg, transparent);
padding: 9px 11px 9px;
box-shadow: 0 8px 30px rgb(0 0 0 / 0.18);
}
/* 加载/任务缺失等仅有一行的场景,内容垂直居中于撑满的卡片内 */
.dlw-body > .dlw-loading { flex: 1 1 auto; margin: auto; }
.dlw-loading {
display: flex;
align-items: center;
justify-content: center;
min-height: 56px;
}
.dlw-status-badge {
flex-shrink: 0;
font-size: 10px;
line-height: 1.6;
padding: 0 8px;
border-radius: 999px;
white-space: nowrap;
}
.dlw-status-badge.tone-active { background: var(--primary); color: var(--primary-foreground); }
.dlw-status-badge.tone-success { background: #10b981; color: #fff; }
.dlw-status-badge.tone-destructive { background: #ef4444; color: #fff; }
.dlw-status-badge.tone-muted { background: var(--muted); color: var(--muted-foreground); }
.dlw-bar { height: 7px; border-radius: 999px; background: var(--muted); overflow: hidden; }
.dlw-bar-fill { height: 100%; border-radius: 999px; background: var(--primary); transition: width 0.2s linear; }
.dlw-seg-strip { display: flex; gap: 2px; height: 7px; }
.dlw-seg { height: 100%; border-radius: 3px; background: var(--muted); overflow: hidden; }
.dlw-seg-fill { height: 100%; background: var(--primary); opacity: 0.75; transition: width 0.2s linear; }
/* 操作按钮(紧凑) */
.dlw-btn {
display: inline-flex;
align-items: center;
gap: 4px;
height: 24px;
padding: 0 8px;
font-size: 11px;
border-radius: 6px;
border: 1px solid var(--border);
background: transparent;
color: var(--foreground);
cursor: pointer;
white-space: nowrap;
}
.dlw-btn:hover { background: var(--accent); color: var(--accent-foreground); }
.dlw-btn-danger { color: #ef4444; }
.dlw-btn-danger:hover { background: rgb(239 68 68 / 0.12); color: #dc2626; }
.dlw-btn-primary { background: var(--primary); color: var(--primary-foreground); border-color: transparent; }
/* hover 时显式保持前景色,避免被上面的 .dlw-btn:hover 覆盖成深色导致黑字黑底不可见 */
.dlw-btn-primary:hover { background: var(--primary); color: var(--primary-foreground); filter: brightness(1.05); }
</style>
+448 -32
View File
@@ -5,7 +5,7 @@ import {
Link2, Loader2, FolderOpen, Copy, Puzzle, Link2, Loader2, FolderOpen, Copy, Puzzle,
CheckCircle2, Clock, Zap, Eye, EyeOff, Search, CheckCircle2, Clock, Zap, Eye, EyeOff, Search,
ArrowUp, ArrowDown, ChevronLeft, ChevronRight, ExternalLink, Globe, ArrowUp, ArrowDown, ChevronLeft, ChevronRight, ExternalLink, Globe,
Info Info, XCircle, Square
} from '@lucide/vue' } from '@lucide/vue'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue' import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { toast } from 'vue-sonner' import { toast } from 'vue-sonner'
@@ -57,6 +57,118 @@ const addDir = ref('')
const addingTask = ref(false) const addingTask = ref(false)
const addDialogOpen = ref(false) const addDialogOpen = ref(false)
// ===== BT 种子文件勾选对话框(异步添加:元数据解析成功后由事件驱动弹出) =====
interface BtSelectEntry {
taskId: string
/** 已勾选的文件索引(默认全选) */
selected: number[]
}
/** 待勾选文件的任务队列(可能多个磁力同时解析完成) */
const btSelectQueue = ref<BtSelectEntry[]>([])
const btSelectOpen = ref(false)
/** 当前展示的勾选入口 */
const btSelectEntry = computed<BtSelectEntry | null>(() => btSelectQueue.value[0] ?? null)
/** 当前勾选入口对应的任务(含文件列表) */
const btSelectTask = computed<DownloadTask | null>(() => {
const e = btSelectEntry.value
if (!e) return null
return store.tasks.find((t) => t.id === e.taskId) ?? null
})
/** 判断单个输入是否为 BT 链接 */
const isBtInput = (input: string): boolean => {
const s = input.trim()
return s.toLowerCase().startsWith('magnet:') || /\.torrent($|\?)/i.test(s)
}
/** 切换某个文件勾选 */
const btnToggleFile = (idx: number) => {
const e = btSelectEntry.value
if (!e) return
const i = e.selected.indexOf(idx)
if (i >= 0) e.selected.splice(i, 1)
else e.selected.push(idx)
}
/** 全选 / 全不选 */
const btnSetAll = (all: boolean) => {
const e = btSelectEntry.value
const task = btSelectTask.value
if (!e || !task) return
e.selected = all ? task.btFiles.map((f) => f.index) : []
}
/** 当前任务是否已全选 */
const btnIsAll = (): boolean => {
const task = btSelectTask.value
const e = btSelectEntry.value
if (!task || !e) return false
return e.selected.length === task.btFiles.length
}
/** 当前已勾选文件总大小 */
const btnSelectedSize = (): number => {
const task = btSelectTask.value
const e = btSelectEntry.value
if (!task || !e) return 0
return task.btFiles
.filter((f) => e.selected.includes(f.index))
.reduce((sum, f) => sum + f.size, 0)
}
/** 收起当前入口,展示队列中的下一个(或关闭) */
const nextBtSelect = () => {
btSelectQueue.value.shift()
btSelectOpen.value = btSelectQueue.value.length > 0
}
/** 磁力元数据就绪回调:刷新任务后把该任务加入勾选队列并弹出 */
const onBtInspectReady = (taskId: string) => {
const task = store.tasks.find((t) => t.id === taskId)
if (!task) return
// 单文件种子无勾选必要:直接全选并开始下载,跳过勾选对话框
if (task.btFiles.length === 1) {
store.selectBtFiles(taskId, [task.btFiles[0].index]).catch((err) => {
toast.error('开始下载失败: ' + err)
})
return
}
btSelectQueue.value.push({
taskId,
selected: task.btFiles.map((f) => f.index) // 默认全选
})
btSelectOpen.value = true
}
/** 确认:设置勾选文件并开始下载,然后处理下一个 */
const onBtFileConfirm = async () => {
const e = btSelectEntry.value
if (!e) return
try {
await store.selectBtFiles(e.taskId, e.selected)
} catch (err) {
toast.error('设置下载文件失败: ' + err)
return
}
nextBtSelect()
}
/** 取消:不下载该任务的勾选,跳过到下一个 */
const onBtFileCancel = async () => {
const e = btSelectEntry.value
if (e) {
// 元数据已就绪且任务仍为 Queued,若不改状态会被下一次 schedule()
// 以"全选默认"自动开始下载;置为 Paused 才真正保持冻结,
// 用户想继续下载时点"继续"即可(恢复为全部文件)。
try {
await store.pauseTask(e.taskId)
} catch (err) {
logger.error('暂停未确认的磁力任务失败: ' + err)
}
}
nextBtSelect()
}
// 扩展密钥显示 // 扩展密钥显示
const showSecret = ref(false) const showSecret = ref(false)
@@ -94,6 +206,7 @@ const STATUS_OPTIONS: { label: string; value: StatusFilter }[] = [
{ label: '等待中', value: 'queued' }, { label: '等待中', value: 'queued' },
{ label: '已暂停', value: 'paused' }, { label: '已暂停', value: 'paused' },
{ label: '已完成', value: 'complete' }, { label: '已完成', value: 'complete' },
{ label: '已取消', value: 'cancelled' },
{ label: '错误', value: 'error' } { label: '错误', value: 'error' }
] ]
@@ -107,6 +220,17 @@ const SORT_OPTIONS: { label: string; value: SortField }[] = [
const running = computed(() => store.status.running) const running = computed(() => store.status.running)
// ===== 工具函数 ===== // ===== 工具函数 =====
/** BT 第 i 个文件已下载字节(progress 事件把每文件进度写入 segments[i].completed */
const btFileDownloaded = (task: DownloadTask, i: number): number =>
task.segments?.[i]?.completed ?? 0
/** BT 第 i 个文件下载百分比 */
const btFileProgress = (task: DownloadTask, i: number): number => {
const f = task.btFiles?.[i]
if (!f || !f.size) return 0
return Math.min(100, Math.round((btFileDownloaded(task, i) / f.size) * 100))
}
const formatSize = (bytes: number): string => { const formatSize = (bytes: number): string => {
if (!bytes || isNaN(bytes)) return '0 B' if (!bytes || isNaN(bytes)) return '0 B'
if (bytes < 1024) return `${bytes} B` if (bytes < 1024) return `${bytes} B`
@@ -167,6 +291,8 @@ const getTaskStatusBadge = (task: DownloadTask) => {
return { variant: 'default' as const, text: '已完成', icon: CheckCircle2 } return { variant: 'default' as const, text: '已完成', icon: CheckCircle2 }
case 'error': case 'error':
return { variant: 'destructive' as const, text: '错误', icon: AlertCircle } return { variant: 'destructive' as const, text: '错误', icon: AlertCircle }
case 'cancelled':
return { variant: 'secondary' as const, text: '已取消', icon: XCircle }
default: default:
return { variant: 'outline' as const, text: task.status, icon: AlertCircle } return { variant: 'outline' as const, text: task.status, icon: AlertCircle }
} }
@@ -199,6 +325,45 @@ const handleResume = async (id: string) => {
} }
} }
// ===== 取消下载(参考下载窗口:二次点击确认,3 秒内未二次点击则取消)=====
const cancelArmed = ref<Record<string, boolean>>({})
let cancelArmTimers: Record<string, number> = {}
const handleCancel = (id: string) => {
if (cancelArmed.value[id]) {
// 二次点击:执行取消
window.clearTimeout(cancelArmTimers[id])
delete cancelArmTimers[id]
cancelArmed.value[id] = false
doCancel(id)
} else {
// 首次点击:进入待确认状态
cancelArmed.value[id] = true
window.clearTimeout(cancelArmTimers[id])
cancelArmTimers[id] = window.setTimeout(() => {
cancelArmed.value[id] = false
delete cancelArmTimers[id]
}, 3000)
}
}
const doCancel = async (id: string) => {
try {
await store.cancelTask(id)
toast.success('已取消,下载文件已删除')
} catch (e) {
toast.error('取消失败: ' + e)
}
}
// ===== 重新下载(已取消/出错任务) =====
const handleRedownload = async (id: string) => {
try {
await store.redownload(id)
toast.success('已重新下载')
} catch (e) {
toast.error('重新下载失败: ' + e)
}
}
// ===== 删除任务专用对话框(带"同时删除文件"开关) ===== // ===== 删除任务专用对话框(带"同时删除文件"开关) =====
const removeDialogState = ref<{ const removeDialogState = ref<{
open: boolean open: boolean
@@ -310,27 +475,54 @@ const handleAddDownload = async () => {
return return
} }
// 分离 BT 链接(磁力/种子)与 HTTP 链接
const httpUris: string[] = []
const btUris: string[] = []
for (const uri of uris) {
if (isBtInput(uri)) btUris.push(uri)
else httpUris.push(uri)
}
const dir = addDir.value.trim()
addingTask.value = true addingTask.value = true
try { try {
const dir = addDir.value.trim() || undefined // HTTP 链接走原流程(含重复检查)
// 检查是否启用重复检查 if (httpUris.length > 0) {
const checkEnabled = store.settings?.checkDuplicate ?? true const checkEnabled = store.settings?.checkDuplicate ?? true
if (checkEnabled) { if (checkEnabled) {
// 逐个检查重复 duplicateSuccessCount.value = 0
duplicateSuccessCount.value = 0 processUrlsWithCheck(httpUris, dir || undefined)
await processUrlsWithCheck(uris, dir) } else {
} else { let successCount = 0
// 直接添加 for (const uri of httpUris) {
let successCount = 0 try {
for (const uri of uris) { await store.addTask(uri, undefined, dir || undefined, undefined, true)
try { successCount++
await store.addTask(uri, undefined, dir, undefined, true) } catch (e) {
successCount++ logger.error(`添加 ${uri} 失败: ` + e)
} catch (e) { }
logger.error(`添加 ${uri} 失败: ` + e)
} }
if (successCount > 0) finishAdd(successCount)
} }
finishAdd(successCount) }
// BT 链接:异步添加(立即返回,不卡在元数据解析)。后台解析,成功后弹文件勾选。
let btCount = 0
for (const uri of btUris) {
try {
await store.addTask(uri, undefined, dir || undefined, undefined, true)
btCount++
} catch (e) {
toast.error(`添加磁力任务失败:${e}`)
}
}
if (btCount > 0) {
toast.info(`已添加 ${btCount} 个磁力任务,正在后台解析元数据…`)
// 关闭新建下载对话框(任务已出现在列表,解析成功后自动弹文件勾选)
addDialogOpen.value = false
addUriText.value = ''
addDir.value = ''
activeTab.value = 'tasks'
} }
} catch (e) { } catch (e) {
toast.error('添加失败: ' + e) toast.error('添加失败: ' + e)
@@ -498,6 +690,23 @@ const handleSelectDir = async () => {
} }
} }
/** 选择本地 .torrent 种子文件,追加到下载链接输入框(支持一次选多个,每行一个) */
const handleSelectTorrentFile = async () => {
try {
const selected = await openDialog({
multiple: true,
filters: [{ name: '种子文件', extensions: ['torrent'] }]
})
const paths = Array.isArray(selected) ? selected : selected ? [selected] : []
if (paths.length === 0) return
const existing = addUriText.value.trim()
const next = existing ? existing.trimEnd() + '\n' + paths.join('\n') : paths.join('\n')
addUriText.value = next
} catch (e) {
logger.error('选择种子文件失败: ' + e)
}
}
const handleSelectSettingsDir = async () => { const handleSelectSettingsDir = async () => {
try { try {
const selected = await openDialog({ directory: true, multiple: false }) const selected = await openDialog({ directory: true, multiple: false })
@@ -588,6 +797,8 @@ watch(pendingShowDownloadTasks, (v) => {
onMounted(async () => { onMounted(async () => {
await store.init() await store.init()
// 注册磁力元数据就绪回调:解析成功后弹文件勾选对话框
store.setBtInspectReadyHandler(onBtInspectReady)
// 消费托盘菜单"新建下载"标志位(挂载前设置的场景,watcher 尚未生效) // 消费托盘菜单"新建下载"标志位(挂载前设置的场景,watcher 尚未生效)
if (pendingNewDownload.value) { if (pendingNewDownload.value) {
pendingNewDownload.value = false pendingNewDownload.value = false
@@ -601,6 +812,7 @@ onMounted(async () => {
}) })
onUnmounted(() => { onUnmounted(() => {
store.setBtInspectReadyHandler(null)
store.stopEventListeners() store.stopEventListeners()
}) })
@@ -610,7 +822,7 @@ const allTasks = computed<DownloadTask[]>(() => store.tasks)
// 状态栏计数:单次遍历统计各状态任务数(替代模板内 4 次 filter 全量扫描) // 状态栏计数:单次遍历统计各状态任务数(替代模板内 4 次 filter 全量扫描)
const statusCounts = computed(() => { const statusCounts = computed(() => {
const counts: Record<TaskStatus, number> = { queued: 0, active: 0, paused: 0, complete: 0, error: 0 } const counts: Record<TaskStatus, number> = { queued: 0, active: 0, paused: 0, complete: 0, error: 0, cancelled: 0 }
for (const t of allTasks.value) counts[t.status]++ for (const t of allTasks.value) counts[t.status]++
return counts return counts
}) })
@@ -693,8 +905,8 @@ const toggleSortOrder = () => {
</div> </div>
<!-- ===== 下载任务 ===== --> <!-- ===== 下载任务 ===== -->
<TabsContent value="tasks" class="flex-1 mt-4 min-h-0 tab-animate"> <TabsContent value="tasks" class="flex-1 mt-4 min-h-0 min-w-0 tab-animate">
<div class="h-full flex flex-col gap-4"> <div class="h-full flex flex-col gap-4 min-w-0">
<!-- 状态栏 --> <!-- 状态栏 -->
<Card class="shrink-0 !py-0 !gap-0"> <Card class="shrink-0 !py-0 !gap-0">
<CardContent class="pl-4 pr-4 py-3"> <CardContent class="pl-4 pr-4 py-3">
@@ -727,6 +939,10 @@ const toggleSortOrder = () => {
<Check class="size-3" /> <Check class="size-3" />
已完成 {{ statusCounts.complete }} 已完成 {{ statusCounts.complete }}
</Badge> </Badge>
<Badge v-if="statusCounts.cancelled > 0" variant="secondary" class="gap-1">
<XCircle class="size-3" />
已取消 {{ statusCounts.cancelled }}
</Badge>
</div> </div>
</div> </div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
@@ -849,7 +1065,7 @@ const toggleSortOrder = () => {
</div> </div>
<!-- 任务列表 --> <!-- 任务列表 -->
<ScrollArea class="flex-1 min-h-0"> <ScrollArea class="flex-1 min-h-0 min-w-0">
<div v-if="pagedTasks.length === 0" key="empty" class="h-full flex flex-col items-center justify-center text-muted-foreground gap-2 pt-10"> <div v-if="pagedTasks.length === 0" key="empty" class="h-full flex flex-col items-center justify-center text-muted-foreground gap-2 pt-10">
<CheckCircle2 class="size-16 opacity-30" /> <CheckCircle2 class="size-16 opacity-30" />
<p>暂无符合条件的任务</p> <p>暂无符合条件的任务</p>
@@ -863,10 +1079,15 @@ const toggleSortOrder = () => {
<CardContent class="pl-4 pr-4 py-3"> <CardContent class="pl-4 pr-4 py-3">
<div class="flex items-start justify-between gap-3 mb-3"> <div class="flex items-start justify-between gap-3 mb-3">
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-1"> <div class="flex items-center gap-2 mb-1 min-w-0">
<span class="font-medium truncate cursor-default"> <!-- flex-1 + min-w-0 + truncateflex 子项默认 min-width:auto 不收缩
长文件名会把整行撑宽flex-1 让名字占满可用宽度并省略号截断 -->
<span class="min-w-0 flex-1 font-medium truncate cursor-default" :title="getFileName(task)">
{{ getFileName(task) }} {{ getFileName(task) }}
</span> </span>
<Badge v-if="task.protocol === 'bittorrent'" variant="outline" class="shrink-0 gap-1 py-0 px-1.5 text-[10px]">
BT
</Badge>
<Badge <Badge
:variant="getTaskStatusBadge(task).variant" :variant="getTaskStatusBadge(task).variant"
class="shrink-0" class="shrink-0"
@@ -895,7 +1116,7 @@ const toggleSortOrder = () => {
</TooltipTrigger> </TooltipTrigger>
<TooltipContent class="max-w-[480px] break-words">{{ task.dir }}</TooltipContent> <TooltipContent class="max-w-[480px] break-words">{{ task.dir }}</TooltipContent>
</Tooltip> </Tooltip>
<span v-if="task.error" class="text-destructive"> <span v-if="task.error" class="text-destructive break-all">
{{ task.error }} {{ task.error }}
</span> </span>
</div> </div>
@@ -927,6 +1148,35 @@ const toggleSortOrder = () => {
</TooltipTrigger> </TooltipTrigger>
<TooltipContent>继续</TooltipContent> <TooltipContent>继续</TooltipContent>
</Tooltip> </Tooltip>
<!-- 已取消任务只能再次下载 -->
<Tooltip v-if="task.status === 'cancelled'">
<TooltipTrigger as-child>
<Button
size="icon"
variant="outline"
class="size-8 text-primary"
@click="handleRedownload(task.id)"
>
<RefreshCw class="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>再次下载</TooltipContent>
</Tooltip>
<!-- 取消下载二次点击确认 -->
<Tooltip v-if="task.status !== 'complete' && task.status !== 'cancelled'">
<TooltipTrigger as-child>
<Button
size="icon"
variant="ghost"
class="size-8 text-destructive hover:text-destructive"
:class="{ 'font-bold': cancelArmed[task.id] }"
@click="handleCancel(task.id)"
>
<XCircle class="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{{ cancelArmed[task.id] ? '再点一次确认取消' : '取消(再点一次确认)' }}</TooltipContent>
</Tooltip>
<Tooltip> <Tooltip>
<TooltipTrigger as-child> <TooltipTrigger as-child>
<Button <Button
@@ -940,7 +1190,7 @@ const toggleSortOrder = () => {
</TooltipTrigger> </TooltipTrigger>
<TooltipContent>详细信息</TooltipContent> <TooltipContent>详细信息</TooltipContent>
</Tooltip> </Tooltip>
<Tooltip v-if="task.dir"> <Tooltip v-if="task.dir && task.status !== 'cancelled'">
<TooltipTrigger as-child> <TooltipTrigger as-child>
<Button <Button
size="icon" size="icon"
@@ -968,8 +1218,9 @@ const toggleSortOrder = () => {
</Tooltip> </Tooltip>
</div> </div>
</div> </div>
<Progress :model-value="getProgress(task)" class="h-1.5" /> <!-- 已取消任务不展示进度条与进度进度与文件均已清除 -->
<div class="flex justify-between mt-1 text-xs text-muted-foreground"> <Progress v-if="task.status !== 'cancelled'" :model-value="getProgress(task)" class="h-1.5" />
<div v-if="task.status !== 'cancelled'" class="flex justify-between mt-1 text-xs text-muted-foreground">
<span>{{ getProgress(task) }}%</span> <span>{{ getProgress(task) }}%</span>
<span v-if="task.status === 'active' && task.speed > 0"> <span v-if="task.status === 'active' && task.speed > 0">
{{ formatEta(getEta(task)) }} {{ formatEta(getEta(task)) }}
@@ -1153,6 +1404,60 @@ const toggleSortOrder = () => {
</CardContent> </CardContent>
</Card> </Card>
<!-- BT 专属设置 -->
<Card>
<CardHeader>
<CardTitle class="flex items-center gap-2 text-base">
<Zap class="size-4 text-primary" />
BitTorrent 设置
</CardTitle>
</CardHeader>
<CardContent class="flex flex-col gap-3">
<div class="flex flex-col gap-1.5">
<Label class="text-xs">上传限速 KB/s0=不限)</Label>
<Input
:model-value="settingsDraft?.btUploadLimitKb ?? 0"
type="number"
min="0"
@update:model-value="(v: string | number) => settingsDraft && (settingsDraft.btUploadLimitKb = parseInt(String(v)) || 0)"
/>
</div>
<div class="flex flex-col gap-1.5">
<Label class="text-xs">监听端口(0=自动选择)</Label>
<Input
:model-value="settingsDraft?.btListenPort ?? 0"
type="number"
min="0"
max="65535"
@update:model-value="(v: string | number) => settingsDraft && (settingsDraft.btListenPort = parseInt(String(v)) || 0)"
/>
<p class="text-xs text-muted-foreground">下载完成后继续上传做种需要对外开放连接端口。修改端口后重启应用生效。</p>
</div>
<div class="flex items-center justify-between">
<div class="flex flex-col gap-0.5">
<Label for="bt-proxy" class="cursor-pointer">使用代理下载</Label>
<span class="text-xs text-muted-foreground">开启后自动使用代理模块(mihomo)的 SOCKS5 端口;代理不可用时自动降级为直连</span>
</div>
<Switch
id="bt-proxy"
:model-value="settingsDraft?.btUseProxy ?? false"
@update:model-value="(v: boolean) => settingsDraft && (settingsDraft.btUseProxy = v)"
/>
</div>
<div class="flex items-center justify-between">
<div class="flex flex-col gap-0.5">
<Label for="bt-seed" class="cursor-pointer">下载完成后继续做种上传</Label>
<span class="text-xs text-muted-foreground">关闭则下载完成后立即停止上传(节省流量,推荐)</span>
</div>
<Switch
id="bt-seed"
:model-value="settingsDraft?.btSeedAfterDownload ?? false"
@update:model-value="(v: boolean) => settingsDraft && (settingsDraft.btSeedAfterDownload = v)"
/>
</div>
</CardContent>
</Card>
<!-- 扩展 API 设置 --> <!-- 扩展 API 设置 -->
<Card> <Card>
<CardHeader> <CardHeader>
@@ -1452,6 +1757,68 @@ const toggleSortOrder = () => {
</DialogContent> </DialogContent>
</Dialog> </Dialog>
<!-- ===== BT 种子文件勾选弹窗(元数据解析成功后弹出) ===== -->
<Dialog v-model:open="btSelectOpen">
<DialogContent class="max-w-xl max-h-[80vh] flex flex-col">
<DialogHeader>
<DialogTitle class="flex items-center gap-2">
<ListChecks class="size-4 text-primary" />
选择要下载的文件
</DialogTitle>
<DialogDescription class="text-xs">
<span class="block break-all font-medium">"{{ btSelectTask?.filename }}"</span>
<span class="block mt-0.5">已解析完成,默认全选,可取消不需要的文件。</span>
<span v-if="btSelectQueue.length > 1">(还有 {{ btSelectQueue.length - 1 }} 个任务待选择)</span>
</DialogDescription>
</DialogHeader>
<ScrollArea class="flex-1 min-h-0 pr-3">
<div v-if="btSelectTask && btSelectTask.btFiles.length" class="flex flex-col gap-2 py-2">
<div class="flex items-center justify-between gap-2">
<span class="text-xs text-muted-foreground truncate">{{ btSelectTask.btFiles.length }} 个文件</span>
<Button
size="sm"
variant="outline"
class="h-7 px-2 text-xs shrink-0"
@click="btnSetAll(!btnIsAll())"
>
<Check v-if="btnIsAll()" class="size-3" />
<Square v-else class="size-3" />
{{ btnIsAll() ? '全不选' : '全选' }}
</Button>
</div>
<div class="flex flex-col gap-1 max-h-64 overflow-y-auto">
<label
v-for="f in btSelectTask.btFiles"
:key="f.index"
class="flex items-center gap-2 text-xs rounded px-1.5 py-1 hover:bg-muted cursor-pointer"
>
<input
type="checkbox"
class="accent-primary size-3.5"
:checked="btSelectEntry?.selected.includes(f.index)"
@change="btnToggleFile(f.index)"
/>
<span class="truncate flex-1">{{ f.path }}</span>
<span class="text-muted-foreground shrink-0">{{ formatSize(f.size) }}</span>
</label>
</div>
<div class="text-xs text-muted-foreground flex justify-end">
已选 {{ btSelectEntry?.selected.length ?? 0 }}/{{ btSelectTask.btFiles.length }} 文件 · {{ formatSize(btnSelectedSize()) }}
</div>
</div>
</ScrollArea>
<DialogFooter>
<Button variant="outline" @click="onBtFileCancel">取消</Button>
<Button @click="onBtFileConfirm">
<Download class="size-4" />
开始下载
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<!-- ===== 新建下载弹窗 ===== --> <!-- ===== 新建下载弹窗 ===== -->
<Dialog v-model:open="addDialogOpen"> <Dialog v-model:open="addDialogOpen">
<DialogContent class="max-w-lg"> <DialogContent class="max-w-lg">
@@ -1461,7 +1828,7 @@ const toggleSortOrder = () => {
新建下载 新建下载
</DialogTitle> </DialogTitle>
<DialogDescription class="text-xs"> <DialogDescription class="text-xs">
支持 HTTP/HTTPS 直链,每行一个 URL。留空下载目录则使用默认目录。 支持 HTTP/HTTPS 直链、磁力链接(magnet:)与 .torrent 种子文件(本地文件或 http(s) 链接),每行一个。留空下载目录则使用默认目录。
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
@@ -1471,9 +1838,18 @@ const toggleSortOrder = () => {
<Label class="text-xs">下载链接</Label> <Label class="text-xs">下载链接</Label>
<Textarea <Textarea
v-model="addUriText" v-model="addUriText"
placeholder="https://example.com/file.zip&#10;https://example.com/file2.zip" placeholder="https://example.com/file.zip&#10;magnet:?xt=urn:btih:...&#10;C:\path\to\file.torrent"
class="min-h-[120px] font-mono text-sm [field-sizing:fixed] break-all" class="min-h-[120px] font-mono text-sm [field-sizing:fixed] break-all"
/> />
<Button
size="sm"
variant="outline"
class="h-7 gap-1 px-2 text-xs bg-transparent self-start"
@click="handleSelectTorrentFile"
>
<FolderOpen class="size-3" />
选择本地种子文件
</Button>
</div> </div>
<!-- 下载目录 --> <!-- 下载目录 -->
@@ -1529,7 +1905,7 @@ const toggleSortOrder = () => {
<span class="block font-mono text-xs break-all">{{ duplicateDialogState.url }}</span> <span class="block font-mono text-xs break-all">{{ duplicateDialogState.url }}</span>
<span v-if="duplicateDialogState.result?.existing" class="block mt-2 text-xs"> <span v-if="duplicateDialogState.result?.existing" class="block mt-2 text-xs">
已存在 已存在
<span class="font-medium">{{ duplicateDialogState.result.existing.filename }}</span> <span class="font-medium break-all">{{ duplicateDialogState.result.existing.filename }}</span>
</span> </span>
<span class="block mt-2 text-muted-foreground"> <span class="block mt-2 text-muted-foreground">
选择"仍然下载"将自动重命名追加序号选择"跳过"将不下载此链接 选择"仍然下载"将自动重命名追加序号选择"跳过"将不下载此链接
@@ -1553,7 +1929,9 @@ const toggleSortOrder = () => {
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>删除任务</AlertDialogTitle> <AlertDialogTitle>删除任务</AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>
确定要删除任务 "{{ removeDialogState.task ? getFileName(removeDialogState.task) : '' }}" 确定要删除任务
<span class="block font-mono text-xs break-all mt-1">{{ removeDialogState.task ? getFileName(removeDialogState.task) : '' }}</span>
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<div class="flex items-center justify-between py-2 px-1"> <div class="flex items-center justify-between py-2 px-1">
@@ -1665,6 +2043,44 @@ const toggleSortOrder = () => {
<Separator /> <Separator />
<!-- BT 种子信息(磁力/种子任务) -->
<template v-if="detailTask.protocol === 'bittorrent'">
<div class="flex flex-col gap-2">
<span class="text-xs text-muted-foreground">BT 种子信息</span>
<div class="flex flex-col gap-1 text-xs">
<div class="flex items-start justify-between gap-2">
<span class="text-muted-foreground shrink-0">Infohash</span>
<span class="font-mono break-all">{{ detailTask.infoHash }}</span>
</div>
<div class="flex justify-between">
<span class="text-muted-foreground">文件数</span>
<span>{{ detailTask.btFiles.length }}</span>
</div>
</div>
<!-- 文件列表 + 单文件进度(progress 事件经 segments 下发每文件已下载字节) -->
<div v-if="detailTask.btFiles.length > 0" class="flex flex-col gap-1.5">
<div
v-for="(f, i) in detailTask.btFiles"
:key="f.index"
class="flex flex-col gap-0.5"
>
<div class="flex items-center justify-between gap-2 text-xs">
<span class="truncate">{{ f.path }}</span>
<span class="text-muted-foreground shrink-0">{{ btFileProgress(detailTask, i) }}%</span>
</div>
<div class="flex items-center gap-2">
<Progress :model-value="btFileProgress(detailTask, i)" class="h-1" />
<span class="text-muted-foreground text-[10px] shrink-0">
{{ formatSize(btFileDownloaded(detailTask, i)) }} / {{ formatSize(f.size) }}
</span>
</div>
</div>
</div>
</div>
</template>
<Separator />
<!-- 文件大小信息 --> <!-- 文件大小信息 -->
<div class="grid grid-cols-2 gap-3"> <div class="grid grid-cols-2 gap-3">
<div class="flex flex-col gap-0.5"> <div class="flex flex-col gap-0.5">
+43
View File
@@ -1109,8 +1109,29 @@ function removeOsdItem(key: string) {
} }
} }
/** 开启 OSD 但监控内核未运行时的提示对话框开关 */
const osdNeedsKernelOpen = ref(false)
/** 从对话框启动内核:先直接开启 OSD(立即显示),内核在后台自行启动 */
async function startKernelForOsd() {
osdNeedsKernelOpen.value = false
// 直接开启 OSD(绕过内联内核检查),store 的 overlayEnabled watch 立即创建/显示悬浮窗
osdConfig.value.overlayEnabled = true
saveOsdConfigDebounced(osdConfig.value)
// 后台启动内核(不 await,不阻塞 OSD 显示;内核就绪后 OSD 自动填充数据)
store.start().catch(() => {})
}
/** OSD 配置项变更时自动保存(防抖:滑块拖动期间不逐帧写盘) */ /** OSD 配置项变更时自动保存(防抖:滑块拖动期间不逐帧写盘) */
function updateOsdConfig(field: keyof OsdConfig, value: unknown) { function updateOsdConfig(field: keyof OsdConfig, value: unknown) {
// 开启 OSD 时,若监控内核未运行,弹出提示并取消本次开启(OSD 依赖内核提供数据)
if (field === 'overlayEnabled' && value === true) {
const kernelRunning = store.status?.running === true
if (!kernelRunning) {
osdNeedsKernelOpen.value = true
return
}
}
;(osdConfig.value as unknown as Record<string, unknown>)[field] = value ;(osdConfig.value as unknown as Record<string, unknown>)[field] = value
saveOsdConfigDebounced(osdConfig.value) saveOsdConfigDebounced(osdConfig.value)
} }
@@ -2404,6 +2425,28 @@ watch(() => store.status?.ready, (ready, prev) => {
</DialogContent> </DialogContent>
</Dialog> </Dialog>
<!-- 开启 OSD 但监控内核未运行提示 -->
<Dialog v-model:open="osdNeedsKernelOpen">
<DialogContent class="max-w-md">
<DialogHeader>
<DialogTitle class="flex items-center gap-2">
<TriangleAlert class="size-4 text-amber-500" />
需先启动监控内核
</DialogTitle>
<DialogDescription>
OSD 悬浮窗依赖监控内核提供数据。当前内核未运行,请先启动内核后再开启 OSD 显示。
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" @click="osdNeedsKernelOpen = false">取消</Button>
<Button @click="startKernelForOsd">
<Play class="size-3.5" />
启动内核
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<!-- OSD 显示项选择 Dialog --> <!-- OSD 显示项选择 Dialog -->
<Dialog v-model:open="osdPickDialogOpen"> <Dialog v-model:open="osdPickDialogOpen">
<DialogContent class="max-w-lg"> <DialogContent class="max-w-lg">
+20
View File
@@ -444,6 +444,23 @@ function scheduleMeasure() {
}, 50) }, 50)
} }
/** 内容尺寸监视器:内核未启动时显示占位符(如 '--' 很短),数据就绪后实际值更长,
* 导致 osd-bar 变宽。仅靠配置变化触发测量不够——需监听 osd-bar 尺寸变化,
* 任何内容变宽/变高(数据加载、配置变更)都自动重测上报,驱动主窗口放大。 */
let resizeObserver: ResizeObserver | null = null
function observeBarSize() {
resizeObserver?.disconnect()
resizeObserver = null
const root = osdRootEl.value
const bar = root?.querySelector<HTMLElement>('.osd-bar')
if (!bar) return
resizeObserver = new ResizeObserver(() => scheduleMeasure())
resizeObserver.observe(bar)
}
// 根容器就绪后(config 到达、v-if 挂载)开始监视 osd-bar 尺寸变化
watch(osdRootEl, () => { void nextTick(observeBarSize) })
// ===== 应用鼠标穿透 ===== // ===== 应用鼠标穿透 =====
// 同时调用 Tauri setIgnoreCursorEvents(处理 webview2 子窗口)和 Rust WS_EX_TRANSPARENT(处理原生窗口) // 同时调用 Tauri setIgnoreCursorEvents(处理 webview2 子窗口)和 Rust WS_EX_TRANSPARENT(处理原生窗口)
// 仅靠原生 WS_EX_TRANSPARENT 不足:Tauri 窗口包含 webview2 子窗口,需两者都设置才能完全穿透 // 仅靠原生 WS_EX_TRANSPARENT 不足:Tauri 窗口包含 webview2 子窗口,需两者都设置才能完全穿透
@@ -537,6 +554,9 @@ onUnmounted(() => {
unlistenFns.forEach(fn => fn()) unlistenFns.forEach(fn => fn())
// 停止监视线程 // 停止监视线程
invoke('osd_stop_watch').catch(() => {}) invoke('osd_stop_watch').catch(() => {})
// 断开内容尺寸监视器
resizeObserver?.disconnect()
resizeObserver = null
}) })
</script> </script>
+357 -3
View File
@@ -2,7 +2,8 @@
import { import {
Globe, Play, Square, RotateCw, Power, Zap, Plus, RefreshCw, Trash2, Globe, Play, Square, RotateCw, Power, Zap, Plus, RefreshCw, Trash2,
Check, AlertCircle, Server, Settings as SettingsIcon, ListChecks, Check, AlertCircle, Server, Settings as SettingsIcon, ListChecks,
Upload, Link2, Loader2, Download, Timer, Target, FolderOpen, Copy, DownloadCloud Upload, Link2, Loader2, Download, Timer, Target, FolderOpen, Copy, DownloadCloud,
Waypoints, ArrowDown, ArrowUp, Activity, X
} from '@lucide/vue' } from '@lucide/vue'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue' import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { toast } from 'vue-sonner' import { toast } from 'vue-sonner'
@@ -10,7 +11,7 @@ import { invoke } from '@tauri-apps/api/core'
import { listen, type UnlistenFn } from '@tauri-apps/api/event' import { listen, type UnlistenFn } from '@tauri-apps/api/event'
import { appDataDir } from '@tauri-apps/api/path' import { appDataDir } from '@tauri-apps/api/path'
import { revealItemInDir } from '@tauri-apps/plugin-opener' import { revealItemInDir } from '@tauri-apps/plugin-opener'
import { useProxyStore, type ProxyNode } from '@/stores/proxyStore' import { useProxyStore, type ProxyNode, type ProxyConnection } from '@/stores/proxyStore'
import { useModuleTabs } from '@/lib/use-module-tabs' import { useModuleTabs } from '@/lib/use-module-tabs'
import { useModuleTabsStore } from '@/stores/moduleTabsStore' import { useModuleTabsStore } from '@/stores/moduleTabsStore'
import { createLogger } from '@/lib/logger' import { createLogger } from '@/lib/logger'
@@ -84,6 +85,7 @@ const activeTab = ref('overview')
const tabsStore = useModuleTabsStore() const tabsStore = useModuleTabsStore()
const tabsListRef = useModuleTabs('proxy', activeTab, [ const tabsListRef = useModuleTabs('proxy', activeTab, [
{ value: 'overview', label: '概览' }, { value: 'overview', label: '概览' },
{ value: 'connections', label: '连接' },
{ value: 'proxies', label: '节点' }, { value: 'proxies', label: '节点' },
{ value: 'profiles', label: '订阅' }, { value: 'profiles', label: '订阅' },
{ value: 'settings', label: '设置' } { value: 'settings', label: '设置' }
@@ -141,9 +143,179 @@ const accordionValue = ref<string>('')
// //
let statusTimer: ReturnType<typeof setInterval> | null = null let statusTimer: ReturnType<typeof setInterval> | null = null
let trafficTimer: ReturnType<typeof setInterval> | null = null
let connTimer: ReturnType<typeof setInterval> | null = null
/** 字节 → 人类可读大小(B / KB / MB / GB / TB */
function fmtBytes(v: number): string {
if (!v && v !== 0) return '--'
if (v < 1024) return v + ' B'
const units = ['KB', 'MB', 'GB', 'TB']
let n = v / 1024
let u = 0
while (n >= 1024 && u < units.length - 1) {
n /= 1024
u++
}
return (n >= 100 ? n.toFixed(0) : n >= 10 ? n.toFixed(1) : n.toFixed(2)) + ' ' + units[u]
}
/** 速率显示(字节/秒 → /s) */
function fmtSpeed(v: number): string {
return fmtBytes(v) + '/s'
}
const running = computed(() => store.status.running) const running = computed(() => store.status.running)
// ===== =====
/** 当前连接列表(store.connections 可能为 null → 视为空) */
const connList = computed(() => store.connections ?? [])
const connFilter = ref('')
/** 内网/国内/国外 一键过滤('all' = 全部) */
const connScopeFilter = ref<'all' | ConnScope>('all')
/** 一键过滤选项 */
const scopeFilterOptions: { value: 'all' | ConnScope; label: string }[] = [
{ value: 'all', label: '全部' },
{ value: 'direct', label: '国内' },
{ value: 'proxy', label: '国外' }
]
/** 顶部下载/上传速率(复用实时流量快照的整体速率) */
const connTotalDownloadSec = computed(() => store.traffic?.downloadSpeed ?? 0)
const connTotalUploadSec = computed(() => store.traffic?.uploadSpeed ?? 0)
/** 命中规则总数 = 活跃连接数(每条连接命中一条规则) */
const ruleHitCount = computed(() => connList.value.length)
/** 按规则聚合当前连接,便于观察哪些规则被频繁命中 */
const ruleHits = computed(() => {
const map = new Map<string, number>()
for (const c of connList.value) {
const r = c.rule || 'DIRECT'
map.set(r, (map.get(r) ?? 0) + 1)
}
return [...map.entries()]
.map(([rule, count]) => ({ rule, count }))
.sort((a, b) => b.count - a.count)
})
/** 按内网/国内/国外、通用关键词过滤后的连接 */
const filteredConnections = computed(() => {
const q = connFilter.value.trim().toLowerCase()
return connList.value.filter((c) => {
if (connScopeFilter.value !== 'all' && connScopeOf(c) !== connScopeFilter.value) return false
if (!q) return true
const process = (c.metadata?.process ?? '').toLowerCase()
const host = (c.metadata?.host ?? '').toLowerCase()
const rule = (c.rule ?? '').toLowerCase()
return process.includes(q) || host.includes(q) || rule.includes(q)
})
})
/** 连接进程显示名 */
const connProcess = (c: ProxyConnection) => c.metadata?.process || '未知'
/** 连接源地址显示(IP:端口) */
const connSource = (c: ProxyConnection) => {
const ip = c.metadata?.sourceIP
const port = c.metadata?.sourcePort
return ip ? `${ip}${port ? ':' + port : ''}` : '--'
}
/** 连接目标显示:优先 host,否则用 IP:端口 */
const connHost = (c: ProxyConnection) => {
const h = c.metadata?.host
if (h) return h
const ip = c.metadata?.destinationIP
const port = c.metadata?.destinationPort
return ip ? `${ip}${port ? ':' + port : ''}` : '--'
}
// ===== / =====
/** 规则类型归一化:忽略大小写与 "-" "_" 空格(订阅里可能写成 DomainSuffix / DOMAIN-SUFFIX */
const normRuleType = (s: string) => s.trim().replace(/[-_\s]/g, '').toLowerCase()
const RULE_CN: Record<string, string> = {
// /
match: '兜底', final: '兜底', ruleset: '规则集', direct: '直连', reject: '拒绝',
//
domain: '域名', domainsuffix: '域名后缀', domainkeyword: '域名关键字', domainregex: '域名正则',
// /
geoip: '地区', geosite: '域名组', ipasn: 'ASN',
//
ipcidr: 'IP段', ipcidr6: 'IP段(v6)', srcipcidr: '源IP段', srcipcidr6: '源IP段(v6)',
dstnet: '目标地址', srcnet: '源地址', network: '网络类型',
//
srcport: '源端口', dstport: '目标端口', srcportrange: '源端口范围', dstportrange: '目标端口范围',
// /
process: '进程', processname: '进程名', processpath: '进程路径', processpathregex: '进程路径正则', uid: '用户ID',
//
intype: '入站类型', inuser: '入站用户', inname: '入站名称', inport: '入站端口',
//
rulesetipcidr: '规则集IP', rulesetipcidr6: '规则集IP(v6)', rulesetdomainsuffix: '规则集域名后缀',
rulesetdomainkeyword: '规则集域名关键字', rulesetdomainregex: '规则集域名正则', rulesetgeoip: '规则集地区',
//
and: '与', not: '非', or: '或', subrule: '子规则'
}
/** 将 mihomo 规则翻译为中文类型名(仅替换类型关键字,保留匹配内容) */
const translateRule = (rule: string): string => {
const parts = rule.split(',')
const mapped = RULE_CN[normRuleType(parts[0])]
if (!mapped) return rule
return [mapped, ...parts.slice(1)].join(',')
}
/**
* 连接走向分类只区分国内/国外
* - direct 国内未走代理内网/局域网因代理过滤也已直连归入国内
* - proxy 国外已走代理节点代理多用于访问境外故视为国外
* 判定依据链路最后一跳是否为 DIRECT
*/
type ConnScope = 'direct' | 'proxy'
const connScopeOf = (c: ProxyConnection): ConnScope => {
const chain = c.chains
if (chain && chain.length) {
return chain[chain.length - 1] === 'DIRECT' ? 'direct' : 'proxy'
}
// 退
return 'direct'
}
/** 各规则的简短释义(供「规则命中」展示),仅为便于理解,非精确语义 */
const RULE_DESC: Record<string, string> = {
// /
match: '未匹配任何规则时的兜底', final: '未匹配任何规则时的兜底', direct: '直连', reject: '拒绝访问',
//
domain: '完全匹配该域名', domainsuffix: '匹配该域名及其子域名', domainkeyword: '域名包含该关键词', domainregex: '域名按正则匹配',
// /
geoip: '按 IP 所属国家/地区', geosite: '按域名所属站点类别', ipasn: '按 IP 所属 ASN 自治域',
//
ipcidr: '匹配该 IP 网段', ipcidr6: '匹配该 IPv6 网段', srcipcidr: '按源 IP 网段', srcipcidr6: '按源 IPv6 网段',
dstnet: '按目标 IP/域名', srcnet: '按源 IP/域名', network: '按网络类型(TCP/UDP',
//
srcport: '按源端口', dstport: '按目标端口', srcportrange: '按源端口范围', dstportrange: '按目标端口范围',
// /
process: '按进程', processname: '按进程名', processpath: '按进程可执行路径', processpathregex: '按进程路径正则',
uid: '按 Linux 用户 ID',
//
intype: '按入站类型', inuser: '按入站用户', inname: '按入站名称', inport: '按入站端口',
//
ruleset: '按规则集内容匹配',
rulesetipcidr: '匹配规则集中任一 IP 网段', rulesetipcidr6: '匹配规则集中任一 IPv6 网段',
rulesetdomainsuffix: '匹配规则集中任一域名后缀', rulesetdomainkeyword: '匹配规则集中任一域名关键字',
rulesetdomainregex: '匹配规则集正则', rulesetgeoip: '匹配规则集中任一地区',
//
and: '多个条件同时满足(与)', or: '任一条件满足(或)', not: '取反(非)', subrule: '子规则分发'
}
/** 取了某条规则的类型释义;未知类型返回空串 */
const ruleDesc = (rule: string): string => {
return RULE_DESC[normRuleType(rule.split(',')[0])] ?? ''
}
/** 断开全部连接 */
const closeAllConnections = async () => {
const ok = await showConfirm({
title: '断开全部连接',
description: `确定断开当前 ${connList.value.length} 条活跃连接吗?`,
confirmText: '断开',
destructive: true
})
if (!ok) return
for (const c of [...connList.value]) {
await store.closeConnection(c.id).catch(() => {})
}
}
// DIRECT/REJECT// // DIRECT/REJECT//
const PSEUDO_NODE_KEYWORDS = [ const PSEUDO_NODE_KEYWORDS = [
'DIRECT', 'REJECT', 'PASS', 'COMPATIBLE', 'DIRECT', 'REJECT', 'PASS', 'COMPATIBLE',
@@ -353,6 +525,16 @@ onMounted(() => {
// 3s // 3s
await store.refreshSystemProxy() await store.refreshSystemProxy()
}, 3000) }, 3000)
// /
trafficTimer = setInterval(async () => {
if (document.hidden) return
if (running.value) await store.refreshTraffic()
}, 1000)
//
connTimer = setInterval(async () => {
if (document.hidden) return
if (activeTab.value === 'connections' && running.value) await store.refreshConnections()
}, 3000)
// / // /
document.addEventListener('visibilitychange', onVisibilityChange) document.addEventListener('visibilitychange', onVisibilityChange)
// //
@@ -363,6 +545,8 @@ onMounted(() => {
onUnmounted(() => { onUnmounted(() => {
if (statusTimer) clearInterval(statusTimer) if (statusTimer) clearInterval(statusTimer)
if (trafficTimer) clearInterval(trafficTimer)
if (connTimer) clearInterval(connTimer)
document.removeEventListener('visibilitychange', onVisibilityChange) document.removeEventListener('visibilitychange', onVisibilityChange)
autoSwitchUnlisten.forEach(fn => fn()) autoSwitchUnlisten.forEach(fn => fn())
autoSwitchUnlisten = [] autoSwitchUnlisten = []
@@ -960,8 +1144,9 @@ tabsStore.registerSave(saveSettingsForm)
<div class="h-full p-6"> <div class="h-full p-6">
<Tabs v-model="activeTab" class="h-full flex flex-col"> <Tabs v-model="activeTab" class="h-full flex flex-col">
<div ref="tabsListRef"> <div ref="tabsListRef">
<TabsList class="grid w-full grid-cols-4 max-w-md !bg-transparent !p-0 !shadow-none"> <TabsList class="grid w-full grid-cols-5 max-w-md !bg-transparent !p-0 !shadow-none">
<TabsTrigger value="overview" class="gap-1.5"><Globe class="size-3.5" />概览</TabsTrigger> <TabsTrigger value="overview" class="gap-1.5"><Globe class="size-3.5" />概览</TabsTrigger>
<TabsTrigger value="connections" class="gap-1.5"><Waypoints class="size-3.5" />连接</TabsTrigger>
<TabsTrigger value="proxies" class="gap-1.5"><Server class="size-3.5" />节点</TabsTrigger> <TabsTrigger value="proxies" class="gap-1.5"><Server class="size-3.5" />节点</TabsTrigger>
<TabsTrigger value="profiles" class="gap-1.5"><ListChecks class="size-3.5" />订阅</TabsTrigger> <TabsTrigger value="profiles" class="gap-1.5"><ListChecks class="size-3.5" />订阅</TabsTrigger>
<TabsTrigger value="settings" class="gap-1.5"><SettingsIcon class="size-3.5" />设置</TabsTrigger> <TabsTrigger value="settings" class="gap-1.5"><SettingsIcon class="size-3.5" />设置</TabsTrigger>
@@ -972,6 +1157,42 @@ tabsStore.registerSave(saveSettingsForm)
<TabsContent value="overview" class="flex-1 mt-4 tab-animate"> <TabsContent value="overview" class="flex-1 mt-4 tab-animate">
<ScrollArea class="h-full pr-3"> <ScrollArea class="h-full pr-3">
<div class="columns-1 md:columns-2 gap-4 [&>*]:mb-4 [&>*]:break-inside-avoid"> <div class="columns-1 md:columns-2 gap-4 [&>*]:mb-4 [&>*]:break-inside-avoid">
<!-- 实时流量 -->
<Card>
<CardHeader>
<CardTitle class="flex items-center justify-between text-base">
<span class="flex items-center gap-2"><Activity class="size-4 text-primary" />实时流量</span>
<Badge v-if="running" variant="outline" class="gap-1 text-xs">
<span class="size-1.5 rounded-full bg-emerald-500" />实时更新
</Badge>
</CardTitle>
</CardHeader>
<CardContent class="space-y-4 text-sm">
<div class="grid grid-cols-2 gap-4">
<div>
<div class="flex items-center gap-1 text-muted-foreground text-xs mb-1">
<ArrowDown class="size-3.5 text-emerald-500" />下载
</div>
<p class="text-lg font-semibold tabular-nums">{{ fmtSpeed(store.traffic?.downloadSpeed ?? 0) }}</p>
<p class="text-xs text-muted-foreground tabular-nums">累计 {{ store.traffic ? fmtBytes(store.traffic.downloadTotal) : '--' }}</p>
</div>
<div>
<div class="flex items-center gap-1 text-muted-foreground text-xs mb-1">
<ArrowUp class="size-3.5 text-rose-500" />上传
</div>
<p class="text-lg font-semibold tabular-nums">{{ fmtSpeed(store.traffic?.uploadSpeed ?? 0) }}</p>
<p class="text-xs text-muted-foreground tabular-nums">累计 {{ store.traffic ? fmtBytes(store.traffic.uploadTotal) : '--' }}</p>
</div>
</div>
<Separator />
<div class="flex items-center justify-between">
<span class="flex items-center gap-1.5 text-muted-foreground">
<Waypoints class="size-3.5" />活跃连接
</span>
<span class="font-semibold tabular-nums">{{ store.traffic?.activeConnections ?? '--' }}</span>
</div>
</CardContent>
</Card>
<!-- 内核状态 --> <!-- 内核状态 -->
<Card> <Card>
<CardHeader> <CardHeader>
@@ -1436,6 +1657,139 @@ tabsStore.registerSave(saveSettingsForm)
</ScrollArea> </ScrollArea>
</TabsContent> </TabsContent>
<!-- 连接 -->
<TabsContent value="connections" class="flex-1 mt-4 tab-animate">
<div v-if="!running" key="conn-not-running" class="h-full flex flex-col items-center justify-center text-muted-foreground gap-2 pt-10 pr-10">
<Waypoints class="size-12 opacity-30" />
<p class="text-sm">mihomo 未运行请先在概览页启动</p>
</div>
<div v-else class="h-full flex flex-col gap-4 pr-3">
<!-- 顶部统计 -->
<div class="grid grid-cols-2 md:grid-cols-4 gap-2">
<Card>
<CardContent class="py-3">
<div class="flex items-center gap-1.5 text-muted-foreground text-xs mb-1"><Waypoints class="size-3.5" />活跃连接</div>
<p class="text-xl font-semibold tabular-nums">{{ connList.length }}</p>
</CardContent>
</Card>
<Card>
<CardContent class="py-3">
<div class="flex items-center gap-1.5 text-muted-foreground text-xs mb-1"><ArrowDown class="size-3.5 text-emerald-500" />下载速率</div>
<p class="text-xl font-semibold tabular-nums">{{ fmtSpeed(connTotalDownloadSec) }}</p>
</CardContent>
</Card>
<Card>
<CardContent class="py-3">
<div class="flex items-center gap-1.5 text-muted-foreground text-xs mb-1"><ArrowUp class="size-3.5 text-rose-500" />上传速率</div>
<p class="text-xl font-semibold tabular-nums">{{ fmtSpeed(connTotalUploadSec) }}</p>
</CardContent>
</Card>
<Card>
<CardContent class="py-3">
<div class="flex items-center gap-1.5 text-muted-foreground text-xs mb-1"><Target class="size-3.5" />命中规则</div>
<p class="text-xl font-semibold tabular-nums">{{ ruleHitCount }}</p>
</CardContent>
</Card>
</div>
<!-- 规则命中分布 -->
<Card>
<CardHeader class="pb-2">
<CardTitle class="text-sm flex items-center gap-2"><Target class="size-3.5 text-primary" />规则命中</CardTitle>
</CardHeader>
<CardContent class="pt-0">
<div v-if="!ruleHits.length" class="text-xs text-muted-foreground py-2">暂无连接</div>
<div v-else class="space-y-1.5">
<div v-for="r in ruleHits.slice(0, 6)" :key="r.rule" class="flex items-baseline gap-2 text-xs">
<span class="w-1.5 h-1.5 rounded-full bg-primary shrink-0 self-center" />
<span class="font-medium shrink-0">{{ translateRule(r.rule) }}</span>
<span class="flex-1 min-w-0 truncate text-muted-foreground">
<template v-if="ruleDesc(r.rule)">{{ ruleDesc(r.rule) }}</template>
</span>
<span class="shrink-0 tabular-nums">×{{ r.count }}</span>
</div>
</div>
</CardContent>
</Card>
<!-- 连接列表 -->
<Card class="flex-1 min-h-0 flex flex-col">
<CardHeader class="pb-2 space-y-2">
<div class="flex items-center justify-between gap-3">
<CardTitle class="text-sm flex items-center gap-2"><Waypoints class="size-3.5 text-primary" />当前连接</CardTitle>
<div class="flex items-center gap-2">
<Input v-model="connFilter" placeholder="按进程/域名/规则过滤" class="h-8 w-56" />
<Button size="xs" variant="outline" :disabled="!connList.length" @click="closeAllConnections">
<Square class="size-3" />断开全部
</Button>
</div>
</div>
<div class="flex flex-wrap items-center gap-1.5">
<span class="text-xs text-muted-foreground">走向</span>
<Button
v-for="s in scopeFilterOptions"
:key="s.value"
size="xs"
:variant="connScopeFilter === s.value ? 'default' : 'outline'"
@click="connScopeFilter = s.value"
>{{ s.label }}</Button>
</div>
</CardHeader>
<CardContent class="flex-1 min-h-0 overflow-hidden pt-0">
<ScrollArea class="h-full">
<table class="w-full text-xs">
<thead class="sticky top-0 z-10 bg-card text-muted-foreground">
<tr class="border-b">
<th class="text-left font-medium py-2 px-2">进程 / 源地址</th>
<th class="text-left font-medium py-2 px-2">目标</th>
<th class="text-left font-medium py-2 px-2">规则</th>
<th class="text-right font-medium py-2 px-2">下载</th>
<th class="text-right font-medium py-2 px-2">上传</th>
<th class="text-center font-medium py-2 px-2"></th>
</tr>
</thead>
<tbody>
<tr v-for="c in filteredConnections" :key="c.id" class="border-b last:border-0 hover:bg-muted/40">
<td class="py-2 px-2 align-baseline">
<span class="font-medium truncate block max-w-[140px]">{{ connProcess(c) }}</span>
<span class="text-muted-foreground">{{ connSource(c) }}</span>
</td>
<td class="py-2 px-2 align-baseline">
<div class="flex items-center gap-1.5">
<Badge
v-if="connScopeOf(c) === 'direct'"
variant="outline" class="h-4 px-1.5 text-[10px] shrink-0 border-emerald-500 text-emerald-500"
>国内</Badge>
<Badge
v-else
variant="outline" class="h-4 px-1.5 text-[10px] shrink-0 border-sky-500 text-sky-500"
>国外</Badge>
<span class="truncate block max-w-[140px]">{{ connHost(c) }}</span>
</div>
<span class="text-muted-foreground">{{ c.metadata?.network }} / {{ c.metadata?.type }}</span>
</td>
<td class="py-2 px-2 align-baseline text-muted-foreground">
<span class="truncate block max-w-[160px]">{{ translateRule(c.rule || 'DIRECT') }}</span>
</td>
<td class="py-2 px-2 text-right tabular-nums align-baseline">{{ fmtBytes(c.download) }}</td>
<td class="py-2 px-2 text-right tabular-nums align-baseline">{{ fmtBytes(c.upload) }}</td>
<td class="py-2 px-2 text-center align-baseline">
<Button size="icon" variant="ghost" class="size-6" title="断开连接" @click="store.closeConnection(c.id)">
<X class="size-3.5" />
</Button>
</td>
</tr>
<tr v-if="!connList.length">
<td colspan="6" class="text-center text-muted-foreground py-8">暂无活跃连接</td>
</tr>
</tbody>
</table>
</ScrollArea>
</CardContent>
</Card>
</div>
</TabsContent>
<!-- 节点 --> <!-- 节点 -->
<TabsContent value="proxies" class="flex-1 mt-4 tab-animate"> <TabsContent value="proxies" class="flex-1 mt-4 tab-animate">
<div v-if="!running" key="not-running" class="h-full flex flex-col items-center justify-center text-muted-foreground gap-2 pt-10 pr-10"> <div v-if="!running" key="not-running" class="h-full flex flex-col items-center justify-center text-muted-foreground gap-2 pt-10 pr-10">
+16 -4
View File
@@ -426,13 +426,25 @@ onMounted(async () => {
mq.addEventListener('change', onThemeChange) mq.addEventListener('change', onThemeChange)
unlistenFns.push(() => mq.removeEventListener('change', onThemeChange)) unlistenFns.push(() => mq.removeEventListener('change', onThemeChange))
// /线 tray-menu-show
// 150ms
// " resize"
let menuJustShown = 0 // ""
let finalizeTimer = 0
unlistenFns.push(await listen<TrayMenuState>('tray-menu-show', async (event) => { unlistenFns.push(await listen<TrayMenuState>('tray-menu-show', async (event) => {
await applyTheme() await applyTheme()
Object.assign(state, event.payload) Object.assign(state, event.payload)
osdVisible.value = readOsdVisible() osdVisible.value = readOsdVisible()
// / // /
resetMenuState() resetMenuState()
await measureAndShow() //
menuJustShown = Date.now()
window.clearTimeout(finalizeTimer)
finalizeTimer = window.setTimeout(async () => {
menuJustShown = 0
await measureAndShow()
}, 150)
})) }))
// //
@@ -440,12 +452,12 @@ onMounted(async () => {
if (!focused) resetMenuState() if (!focused) resetMenuState()
})) }))
// // - finalizeTimer
// - // -
// - // - mihomo node
// tray_menu_ready +resize
unlistenFns.push(await listen<TrayMenuState>('tray-menu-state-updated', async (event) => { unlistenFns.push(await listen<TrayMenuState>('tray-menu-state-updated', async (event) => {
Object.assign(state, event.payload) Object.assign(state, event.payload)
if (menuJustShown) return // finalize
try { try {
const visible = await getCurrentWindow().isVisible() const visible = await getCurrentWindow().isVisible()
if (visible) await measureAndShow() if (visible) await measureAndShow()
+82 -7
View File
@@ -9,6 +9,7 @@ import type {
DownloadTask as BindDownloadTask, DownloadTask as BindDownloadTask,
DownloaderSettings as BindDownloaderSettings, DownloaderSettings as BindDownloaderSettings,
CheckUrlResult as BindCheckUrlResult, CheckUrlResult as BindCheckUrlResult,
TorrentInfo as BindTorrentInfo,
TaskStatus, TaskStatus,
} from '@/lib/bindings' } from '@/lib/bindings'
@@ -19,11 +20,14 @@ const logger = createLogger('downloader')
export type DownloadTask = Required<BindDownloadTask> export type DownloadTask = Required<BindDownloadTask>
export type DownloaderSettings = Required<BindDownloaderSettings> export type DownloaderSettings = Required<BindDownloaderSettings>
export type CheckUrlResult = Required<BindCheckUrlResult> export type CheckUrlResult = Required<BindCheckUrlResult>
export type TorrentInfo = Required<BindTorrentInfo>
// re-export:跨端类型统一由 bindings 提供,组件从本 store import 的路径保持不变 // re-export:跨端类型统一由 bindings 提供,组件从本 store import 的路径保持不变
export type { export type {
TaskStatus, TaskStatus,
TaskProtocol,
Segment, Segment,
BtFileInfo,
DuplicateKind, DuplicateKind,
ExistingTaskInfo, ExistingTaskInfo,
} from '@/lib/bindings' } from '@/lib/bindings'
@@ -71,6 +75,10 @@ export const useDownloaderStore = defineStore('downloader', () => {
let completeUnlisten: UnlistenFn | null = null let completeUnlisten: UnlistenFn | null = null
let addedUnlisten: UnlistenFn | null = null let addedUnlisten: UnlistenFn | null = null
let removedUnlisten: UnlistenFn | null = null let removedUnlisten: UnlistenFn | null = null
let inspectReadyUnlisten: UnlistenFn | null = null
/** 磁力元数据解析就绪回调(模块注册,用于弹文件勾选对话框) */
let btInspectReadyHandler: ((id: string) => void) | null = null
// ===== 任务列表 ===== // ===== 任务列表 =====
const refreshTasks = async () => { const refreshTasks = async () => {
@@ -85,13 +93,26 @@ export const useDownloaderStore = defineStore('downloader', () => {
if ( if (
freshTask.status === 'paused' || freshTask.status === 'paused' ||
freshTask.status === 'complete' || freshTask.status === 'complete' ||
freshTask.status === 'error' freshTask.status === 'error' ||
freshTask.status === 'cancelled'
) { ) {
return freshTask return freshTask
} }
const local = tasks.value.find(t => t.id === freshTask.id) const local = tasks.value.find(t => t.id === freshTask.id)
if (!local) return freshTask if (!local) return freshTask
return { ...local, status: freshTask.status, error: freshTask.error } // 进度/速度沿用本地实时值;但元数据字段(文件名/文件列表/总大小/infohash
// 必须以后端为准 —— 这些只在后台解析完成后才就绪,本地快照在添加时是占位空值,
// 若沿用会导致"元数据已解析但勾选对话框仍无文件列表"(旧快照覆盖新元数据)
return {
...local,
status: freshTask.status,
error: freshTask.error,
filename: freshTask.filename,
btFiles: freshTask.btFiles,
btMetadataReady: freshTask.btMetadataReady,
infoHash: freshTask.infoHash,
totalSize: freshTask.totalSize,
}
}) })
tasks.value = merged tasks.value = merged
} catch (e) { } catch (e) {
@@ -105,9 +126,15 @@ export const useDownloaderStore = defineStore('downloader', () => {
const task = tasks.value.find((t) => t.id === payload.id) const task = tasks.value.find((t) => t.id === payload.id)
if (task) { if (task) {
// 终态任务忽略迟到的进度事件(下载完成后 in-flight 事件可能把状态/进度回退) // 终态任务忽略迟到的进度事件(下载完成后 in-flight 事件可能把状态/进度回退)
if (task.status === 'complete' || task.status === 'error') return if (task.status === 'complete' || task.status === 'error' || task.status === 'cancelled') return
// 已暂停任务忽略仍携带 active 的迟到事件(暂停瞬间发出的旧事件) // 已暂停任务忽略"停止瞬间残留的 active 心跳"(无速度且进度未变化的迟到事件)。
if (task.status === 'paused' && payload.status === 'active') return // 真正恢复下载后发来的 active(有速度或进度增长)必须放行,否则恢复后列表一直停留在暂停态
if (
task.status === 'paused' &&
payload.status === 'active' &&
payload.speed <= 0 &&
payload.completedSize <= task.completedSize
) return
task.completedSize = payload.completedSize task.completedSize = payload.completedSize
task.totalSize = payload.totalSize task.totalSize = payload.totalSize
task.speed = payload.speed task.speed = payload.speed
@@ -134,19 +161,32 @@ export const useDownloaderStore = defineStore('downloader', () => {
filename?: string, filename?: string,
dir?: string, dir?: string,
headers?: Record<string, string>, headers?: Record<string, string>,
autoRename = false autoRename = false,
onlyFiles?: number[]
): Promise<string> => { ): Promise<string> => {
const id = await commands.downloaderAddTask( const id = await commands.downloaderAddTask(
url, url,
filename || null, filename || null,
dir || null, dir || null,
headers || null, headers || null,
autoRename autoRename,
onlyFiles || null
) )
await refreshTasks() await refreshTasks()
return id return id
} }
/** 解析磁力链 / .torrent,返回种子信息(文件勾选用) */
const inspect = async (input: string): Promise<TorrentInfo> => {
return await commands.downloaderInspect(input)
}
/** 磁力任务元数据解析成功后:设置勾选文件并开始下载 */
const selectBtFiles = async (id: string, onlyFiles: number[]) => {
await commands.downloaderSelectBtFiles(id, onlyFiles)
await refreshTasks()
}
/** 检查 URL 重复性并探测文件信息 */ /** 检查 URL 重复性并探测文件信息 */
const checkUrl = async ( const checkUrl = async (
url: string, url: string,
@@ -171,6 +211,18 @@ export const useDownloaderStore = defineStore('downloader', () => {
await refreshTasks() await refreshTasks()
} }
/** 取消下载:置为已取消、清空进度并删除下载文件,但保留记录 */
const cancelTask = async (id: string) => {
await commands.downloaderCancelTask(id)
await refreshTasks()
}
/** 重新下载已取消/出错的任务 */
const redownload = async (id: string) => {
await commands.downloaderRedownload(id)
await refreshTasks()
}
// ===== 设置 ===== // ===== 设置 =====
const loadSettings = async () => { const loadSettings = async () => {
try { try {
@@ -250,6 +302,15 @@ export const useDownloaderStore = defineStore('downloader', () => {
refreshTasks() refreshTasks()
}) })
} }
if (!inspectReadyUnlisten) {
inspectReadyUnlisten = await listen<{ id: string }>('download-inspect-ready', async (e) => {
// 磁力元数据解析成功:先刷新任务(拿到文件列表),再通知模块弹文件勾选对话框。
// 必须 await —— 否则回调读取的 store.tasks 仍是旧快照(btFiles 为空),
// 导致勾选对话框无条目、默认选中空数组,进而在后端被 librqbit 秒判为"已完成 0%"
await refreshTasks()
btInspectReadyHandler?.(e.payload.id)
})
}
} }
const stopEventListeners = () => { const stopEventListeners = () => {
@@ -269,6 +330,10 @@ export const useDownloaderStore = defineStore('downloader', () => {
removedUnlisten() removedUnlisten()
removedUnlisten = null removedUnlisten = null
} }
if (inspectReadyUnlisten) {
inspectReadyUnlisten()
inspectReadyUnlisten = null
}
} }
// ===== 初始化 ===== // ===== 初始化 =====
@@ -281,6 +346,11 @@ export const useDownloaderStore = defineStore('downloader', () => {
// ===== 工具函数 ===== // ===== 工具函数 =====
const openDir = (path: string) => commands.downloaderOpenDir(path) const openDir = (path: string) => commands.downloaderOpenDir(path)
/** 注册磁力元数据就绪回调(模块传入处理函数,替换式) */
const setBtInspectReadyHandler = (handler: ((id: string) => void) | null) => {
btInspectReadyHandler = handler
}
return { return {
// state // state
tasks, tasks,
@@ -290,10 +360,14 @@ export const useDownloaderStore = defineStore('downloader', () => {
// tasks // tasks
refreshTasks, refreshTasks,
addTask, addTask,
inspect,
selectBtFiles,
checkUrl, checkUrl,
pauseTask, pauseTask,
resumeTask, resumeTask,
removeTask, removeTask,
cancelTask,
redownload,
// settings // settings
loadSettings, loadSettings,
saveSettings, saveSettings,
@@ -307,6 +381,7 @@ export const useDownloaderStore = defineStore('downloader', () => {
// init // init
init, init,
// utils // utils
setBtInspectReadyHandler,
openDir openDir
} }
}) })
+182 -50
View File
@@ -261,14 +261,14 @@ function defaultOsdConfig(): OsdConfig {
labelLanguage: 'zh', labelLanguage: 'zh',
layout: 'single', layout: 'single',
updateIntervalMs: 1000, updateIntervalMs: 1000,
// 默认关闭点击穿透:关闭后左键可直接拖动悬浮窗 // 默认开启点击穿透:悬浮窗不拦截鼠标,需拖动时临时关闭
clickThrough: false, clickThrough: true,
fontColor: '#ffffff', fontColor: '#ffffff',
fontOpacity: 100, fontOpacity: 100,
bgColor: 'transparent', bgColor: 'transparent',
colorThemeEnabled: true, colorThemeEnabled: true,
colorTheme: { ...DEFAULT_COLOR_THEME }, colorTheme: { ...DEFAULT_COLOR_THEME },
fontStrokeEnabled: false, fontStrokeEnabled: true,
fontStrokeWidth: 1, fontStrokeWidth: 1,
fontStrokeColor: '#000000', fontStrokeColor: '#000000',
alert: { ...DEFAULT_ALERT_CONFIG, maxValues: { ...DEFAULT_ALERT_CONFIG.maxValues } }, alert: { ...DEFAULT_ALERT_CONFIG, maxValues: { ...DEFAULT_ALERT_CONFIG.maxValues } },
@@ -583,11 +583,41 @@ export const useMonitorStore = defineStore('monitor', () => {
return autoStart.value return autoStart.value
} }
/** 设置"自动启动监控内核"开关 */ /** 关闭"自动启动监控内核"时暂存的 OSD 开关状态(开启自动启动时据此恢复) */
const OSD_PENDING_KEY = STORAGE_KEYS.monitorOsdPending
/** 待恢复的 OSD 开关状态:关闭自动启动时记录、开启时按记录恢复。持久化于 localStorage,跨重启有效。 */
let pendingOsdEnabled: boolean | null = null
try {
pendingOsdEnabled = JSON.parse(localStorage.getItem(OSD_PENDING_KEY) ?? 'null')
} catch { /* 忽略非法缓存,视为无恢复记录 */ }
/**
* "自动启动监控内核"
* OSD OSD
* - OSD OSDOSD
* - OSD
*/
async function setAutoStart(enabled: boolean) { async function setAutoStart(enabled: boolean) {
try { try {
await invoke('monitor_set_auto_start', { enabled }) await invoke('monitor_set_auto_start', { enabled })
autoStart.value = enabled autoStart.value = enabled
if (!enabled) {
// 关闭时记录当前 OSD 状态,随后由 overlayEnabled watch 统一隐藏悬浮窗
pendingOsdEnabled = osdConfig.value.overlayEnabled
try { localStorage.setItem(OSD_PENDING_KEY, JSON.stringify(pendingOsdEnabled)) } catch { /* 忽略 */ }
osdConfig.value.overlayEnabled = false
saveOsdConfig(osdConfig.value)
} else {
// 开启时若有记录且曾为开,恢复 OSD 显示
if (pendingOsdEnabled === true) {
osdConfig.value.overlayEnabled = true
saveOsdConfig(osdConfig.value)
}
// 消费记录,避免下次开启再次恢复
pendingOsdEnabled = null
try { localStorage.removeItem(OSD_PENDING_KEY) } catch { /* 忽略 */ }
}
} catch (e) { } catch (e) {
errorMsg.value = String(e) errorMsg.value = String(e)
logger.error('设置自动启动开关失败: ' + e) logger.error('设置自动启动开关失败: ' + e)
@@ -758,67 +788,172 @@ export const useMonitorStore = defineStore('monitor', () => {
/** /**
* single: 单行分组式 | * single: 单行分组式 |
* group: 分组横排 + * group: 分组横排 +
* multiline: 多行 + */ * multiline: 多行 +
*
* OsdWindow.vue
* - / CJK fontSizeASCII 0.6em
* - fmtFixedValue pad fmtFixedUnit
* - gap 3pxsingle 4pxgroup 8px gap margin
* - osd-bar padding4*2
*
* computePositionFromPct */
function computeOsdWindowSize( function computeOsdWindowSize(
_itemCount: number, _itemCount: number,
layout: 'single' | 'group' | 'multiline', layout: 'single' | 'group' | 'multiline',
fontSize: number, fontSize: number,
_hasNetItem = false, _hasNetItem = false,
items?: OsdItem[], items: OsdItem[] = [],
): { w: number; h: number } { ): { w: number; h: number } {
const charW = fontSize * 0.62 const charW = fontSize * 0.6 // 等宽 ASCII 字符宽(Cascadia/Consolas ≈0.6em
const cjkW = fontSize // CJK 全角字符宽
const barHPad = 8 // osd-bar 左右 padding 4*2 const barHPad = 8 // osd-bar 左右 padding 4*2
// 按硬件类型分组(与渲染逻辑一致) const isCjk = (ch: string) => {
const groupMap = new Map<string, OsdItem[]>() const c = ch.codePointAt(0)!
if (items?.length) { return (
for (const item of items) { (c >= 0x2e80 && c <= 0x9fff) ||
let gkey: string (c >= 0x3000 && c <= 0x303f) ||
if (item.special === 'net-up' || item.special === 'net-down') gkey = 'network' (c >= 0xff00 && c <= 0xffef) ||
else if (item.groupId.startsWith('gpu')) gkey = 'gpu' (c >= 0xf900 && c <= 0xfaff)
else gkey = item.groupId )
if (!groupMap.has(gkey)) groupMap.set(gkey, []) }
groupMap.get(gkey)!.push(item) // 字符串像素宽(CJK 全角,ASCII 等宽)
const textPx = (s: string) => {
let w = 0
for (const ch of s) w += isCjk(ch) ? cjkW : charW
return w
}
const showLabel = osdConfig.value?.showLabel !== false
const en = osdConfig.value?.labelLanguage === 'en'
const groupLabelText = (gkey: string): string => {
if (en) {
switch (gkey) {
case 'cpu': return 'CPU'
case 'gpu': return 'GPU'
case 'memory': return 'RAM'
case 'storage': return 'DISK'
case 'network': return 'NET'
case 'motherboard': return 'MB'
case 'battery': return 'BAT'
case 'psu': return 'PSU'
default: return gkey.toUpperCase().slice(0, 6)
}
}
switch (gkey) {
case 'cpu': return 'CPU'
case 'gpu': return 'GPU'
case 'memory': return '内存'
case 'storage': return '存储'
case 'network': return '网络'
case 'motherboard': return '主板'
case 'battery': return '电池'
case 'psu': return '电源'
default: return gkey
} }
} }
const groupCount = Math.max(1, groupMap.size)
// 每组数据列宽度:标签(6ch) + 各项(数值+单位+箭头/gap) // 数值固定宽度(字符数,对应 fmtFixedValue 的 pad 宽度)
const groupWidths: number[] = [] const numChars = (it: OsdItem): number => {
for (const [, groupItems] of groupMap) { if (it.special === 'net-up' || it.special === 'net-down') return 6
const labelW = 6 switch (it.type) {
const dataW = groupItems.reduce((sum, item) => { case 'load':
const isNet = item.special === 'net-up' || item.special === 'net-down' case 'level':
return sum + (isNet ? 11 : 8) + 1 case 'temperature': return 3
}, 0) case 'power':
groupWidths.push(labelW + dataW) case 'voltage': return 5
case 'clock':
case 'frequency': return 4
case 'fan': return 4
case 'data':
case 'smalldata': return 5
default: return 5
}
}
// 单位文本(对应 fmtFixedUnit;网速取最宽单位 MB/s 估算)
const showUnit = osdConfig.value?.showUnit !== false
const unitText = (it: OsdItem): string => {
if (it.special === 'net-up' || it.special === 'net-down') return showUnit ? 'MB/s' : ''
if (!showUnit) return ''
switch (it.type) {
case 'temperature': return '°C'
case 'load': return '%'
case 'power': return 'W'
case 'voltage': return 'V'
case 'fan': return 'RPM'
case 'clock':
case 'frequency': return 'MHz'
case 'data':
case 'smalldata': return 'GB'
case 'level': return '%'
default: return it.unit || ''
}
}
// 单一项像素宽:箭头 + 数值 + 单位 + 项内 gap(1px) + 单位 margin(1px)
const itemPx = (it: OsdItem): number => {
let w = it.special ? charW : 0 // 箭头
if (it.special) w += 1 // 箭头与数值 gap
w += numChars(it) * charW
const unit = unitText(it)
if (unit) w += textPx(unit) + 1 + 1 // 数值-单位 gap + 单位 left margin
return w
}
// 一个分组的像素宽:组内各子项 gap(3px) + 标签 + 各项
const groupWidthPx = (gkey: string, list: OsdItem[]): number => {
const labelW = showLabel ? textPx(groupLabelText(gkey)) : 0
const itemsW = list.reduce((s, it) => s + itemPx(it), 0)
const gapCount = list.length + (showLabel ? 1 : 0) - 1
return Math.ceil(labelW + itemsW + Math.max(0, gapCount) * 3)
} }
// 构建分组(归一化 key,与渲染逻辑一致)
const groupMap = new Map<string, OsdItem[]>()
for (const it of items) {
let gkey = it.groupId
if (it.special === 'net-up' || it.special === 'net-down') gkey = 'network'
else if (it.groupId.startsWith('gpu')) gkey = 'gpu'
if (!groupMap.has(gkey)) groupMap.set(gkey, [])
groupMap.get(gkey)!.push(it)
}
const groupEntries = [...groupMap.entries()]
const groupCount = Math.max(1, groupEntries.length)
const gw = groupEntries.map(([k, list]) => groupWidthPx(k, list))
const lineH = Math.ceil(fontSize + 2)
if (layout === 'multiline') { if (layout === 'multiline') {
// 多行:取最宽行 // 每行 = 标签(min 4ch) + 组内 gap(4px) + 各项;取最宽行
const maxLineW = groupWidths.length ? Math.max(...groupWidths) : 10 const labelMinW = 4 * charW
const w = Math.ceil(maxLineW * charW + barHPad) let maxLineW = 0
const lineH = Math.ceil(fontSize + 2) for (const [gkey, list] of groupEntries) {
const h = Math.ceil(groupCount * lineH + 6) const labelW = showLabel ? Math.max(textPx(groupLabelText(gkey)), labelMinW) : 0
return { w: Math.max(120, w), h: Math.max(28, h) } const itemsW = list.reduce((s, it) => s + itemPx(it), 0)
const gapCount = list.length + (showLabel ? 1 : 0) - 1
maxLineW = Math.max(maxLineW, labelW + itemsW + Math.max(0, gapCount) * 4)
}
const w = Math.max(120, Math.ceil(maxLineW + barHPad))
const h = Math.max(28, Math.ceil(groupCount * lineH + 6))
return { w, h }
} }
if (layout === 'group') { if (layout === 'group') {
// 分组横排:各组横排 + 标题行 // 分组横排:各组横排,组间 gap(8px),每组含 padding(4*2)
const totalW = groupWidths.reduce((s, w) => s + w + 4, 0) + (groupCount - 1) * 4 const totalW = gw.reduce((s, w) => s + w, 0)
const w = Math.ceil(totalW * charW + barHPad) + groupCount * 8 // 每组左右 padding 4*2
+ Math.max(0, groupCount - 1) * 8 // 组间 gap
const w = Math.max(120, Math.ceil(totalW + barHPad))
const titleH = Math.ceil(fontSize * 0.85) + 2 const titleH = Math.ceil(fontSize * 0.85) + 2
const dataH = Math.ceil(fontSize) + 2 const dataH = lineH
const h = Math.ceil(titleH + dataH + 10) const h = Math.max(40, Math.ceil(titleH + dataH + 3 + 3))
return { w: Math.max(120, w), h: Math.max(40, h) } return { w, h }
} }
// single:单行分组式,各组横排 + 组间 | 分隔符(1ch) // single:单行分组式,组间 | 分隔符 + 组间 gap(4px)
const sepW = (groupCount - 1) * 1 const sepW = (groupCount - 1) * (textPx('|') + 4)
const totalW = groupWidths.reduce((s, w) => s + w, 0) + sepW const betweenW = Math.max(0, groupCount - 1) * 4
const w = Math.ceil(totalW * charW + barHPad) const w = Math.max(120, Math.ceil(gw.reduce((s, w) => s + w, 0) + sepW + betweenW + barHPad))
const h = Math.ceil(fontSize + 8) const h = Math.max(28, Math.ceil(fontSize + 8))
return { w: Math.max(120, w), h: Math.max(28, h) } return { w, h }
} }
/** 创建/显示悬浮窗窗口(默认置顶 + NoActivate + 点击穿透) */ /** 创建/显示悬浮窗窗口(默认置顶 + NoActivate + 点击穿透) */
@@ -1049,13 +1184,10 @@ export const useMonitorStore = defineStore('monitor', () => {
// stop 句柄存入 osdWatchStopsdispose 时统一释放,避免模块重挂载后重复注册 // stop 句柄存入 osdWatchStopsdispose 时统一释放,避免模块重挂载后重复注册
// OSD 开关变化时创建/隐藏悬浮窗 // OSD 开关变化时创建/隐藏悬浮窗
// 注意:开启 OSD 不再自动开启"应用启动时自动启动监控内核"。
// 二者保持独立(双向联动会导致:关自动启动→关 OSD→再开 OSD→自动启动又被强行打开)。
osdWatchStops.push(watch(() => osdConfig.value.overlayEnabled, (enabled) => { osdWatchStops.push(watch(() => osdConfig.value.overlayEnabled, (enabled) => {
if (enabled) { if (enabled) {
// OSD 显示开启时自动开启"应用启动时自动启动监控内核",
// 使 OSD 持续显示不因重启而中断
if (!autoStart.value) {
setAutoStart(true).catch(e => logger.error('[OSD] 自动开启 autoStart 失败: ' + e))
}
// 开启时若显示项为空则不创建窗口 // 开启时若显示项为空则不创建窗口
if (osdConfig.value.overlayItems.length === 0) return if (osdConfig.value.overlayItems.length === 0) return
ensureOverlayWindow().catch(e => logger.error('[OSD] 创建悬浮窗失败: ' + e)) ensureOverlayWindow().catch(e => logger.error('[OSD] 创建悬浮窗失败: ' + e))
+72 -1
View File
@@ -12,7 +12,8 @@ import {
type ProfileMeta, type ProfileMeta,
type KernelInfo, type KernelInfo,
type KernelUpdateInfo, type KernelUpdateInfo,
type ProxyStatus type ProxyStatus,
type TrafficSnapshot,
} from '@/lib/bindings' } from '@/lib/bindings'
// Rust 端结构体字段均带 serde(default),返回必完整;用 Required 收窄 bindings 的 optional // Rust 端结构体字段均带 serde(default),返回必完整;用 Required 收窄 bindings 的 optional
@@ -53,6 +54,32 @@ export interface ProxiesResponse {
proxies: Record<string, ProxyNode> proxies: Record<string, ProxyNode>
} }
/** mihomo /connections 单条连接(完整字段以原始 JSON 为准,仅取前端用到的部分) */
export interface ProxyConnection {
id: string
chains?: string[]
rule?: string
rulePayload?: string
upload: number
download: number
start: string
metadata?: {
network?: string
type?: string
process?: string
host?: string
sourceIP?: string
sourcePort?: number
destinationIP?: string
destinationPort?: number
}
}
/** mihomo /connections 响应(原始 JSON,字段可能缺失) */
export interface ConnectionsResponse {
connections?: ProxyConnection[]
}
export interface MihomoVersion { export interface MihomoVersion {
version: string version: string
meta?: boolean meta?: boolean
@@ -65,6 +92,10 @@ export const useProxyStore = defineStore('proxy', () => {
const proxies = ref<Record<string, ProxyNode>>({}) const proxies = ref<Record<string, ProxyNode>>({})
const settings = ref<FullProxySettings | null>(null) const settings = ref<FullProxySettings | null>(null)
const systemProxy = ref(false) const systemProxy = ref(false)
/** 实时流量快照(上传/下载速率、会话总量、活跃连接数) */
const traffic = ref<TrafficSnapshot | null>(null)
/** 当前活跃连接列表(仅连接页签需要时拉取) */
const connections = ref<ProxyConnection[] | null>(null)
/** 是否已完成首次加载(避免初始 null/false 导致闪烁误导状态) */ /** 是否已完成首次加载(避免初始 null/false 导致闪烁误导状态) */
const initialized = ref(false) const initialized = ref(false)
@@ -209,6 +240,40 @@ export const useProxyStore = defineStore('proxy', () => {
} }
} }
// ---------- 流量 / 连接 ----------
/** 刷新实时流量快照(速率 + 会话总量 + 活跃连接数)。失败时保留上一次数据,避免抖动。 */
const refreshTraffic = async () => {
try {
traffic.value = await commands.proxyTraffic()
} catch {
/* mihomo 瞬时不可用(如重启)时保留上一次数据 */
}
}
/** 刷新当前活跃连接列表(原始 /connections)。供「连接」页签低频拉取。 */
const refreshConnections = async () => {
try {
const resp = await invoke<ConnectionsResponse>('proxy_get_connections')
connections.value = resp.connections ?? []
} catch {
/* mihomo 瞬时不可用(如重启)时保留上一次数据 */
}
}
/** 关闭指定连接 */
const closeConnection = async (id: string) => {
try {
await commands.proxyCloseConnection(id)
// 本地立即移除,无需等下一轮轮询
if (connections.value) {
connections.value = connections.value.filter((c) => c.id !== id)
}
} catch (e) {
logger.error('关闭连接失败: ' + e)
throw e
}
}
const saveSettings = async (s: FullProxySettings) => { const saveSettings = async (s: FullProxySettings) => {
await commands.proxySaveSettings(s) await commands.proxySaveSettings(s)
settings.value = s settings.value = s
@@ -487,6 +552,8 @@ export const useProxyStore = defineStore('proxy', () => {
proxies, proxies,
settings, settings,
systemProxy, systemProxy,
traffic,
connections,
initialized, initialized,
installing, installing,
installProgress, installProgress,
@@ -516,6 +583,10 @@ export const useProxyStore = defineStore('proxy', () => {
setSystemProxy, setSystemProxy,
clearSystemProxy, clearSystemProxy,
toggleSystemProxy, toggleSystemProxy,
// traffic & connections
refreshTraffic,
refreshConnections,
closeConnection,
// kernel update / install // kernel update / install
checkKernelUpdate, checkKernelUpdate,
updateKernel, updateKernel,
+14 -2
View File
@@ -65,8 +65,20 @@ export default defineConfig(async () => ({
} }
: undefined, : undefined,
watch: { watch: {
// 3. tell Vite to ignore watching `src-tauri` // 3. Vite 忽略监听这些大目录,避免 Windows 上 chokidar 递归注册监听句柄
ignored: ["**/src-tauri/**"], // 拖慢 dev 冷启动。src-tauri/target 是 Rust 构建产物(本仓库可达 5 万+ 文件、
// 数十 GB),默认仅忽略 node_modules/.git/.vite,若被递归遍历,WebView 首屏
// 加载会被阻塞几十秒(详见 juejin.cn/post/7657865700393451554)。
ignored: [
"**/src-tauri/**",
"**/node_modules/**",
"**/dist/**",
"**/release_stage/**",
"**/ThingHK/**",
"**/.git/**",
"**/.idea/**",
"**/.vite/**",
],
}, },
}, },
})); }));