优化调整、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",
"private": true,
"version": "26.8.2",
"version": "26.8.3",
"type": "module",
"scripts": {
"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]
name = "thing"
version = "26.8.2"
version = "26.8.3"
description = "A Tauri App"
authors = ["you"]
edition = "2021"
@@ -45,6 +45,8 @@ base64 = "0.22"
image = { version = "0.25", default-features = false, features = ["png"] }
walkdir = "2"
notify = { version = "6", features = [] }
librqbit = "9"
bytes = "1"
[target.'cfg(windows)'.dependencies]
winreg = "0.52"
@@ -67,7 +69,7 @@ windows-sys = { version = "0.52", features = [
"Win32_Storage_Xps",
"Win32_Storage_FileSystem",
] }
# Explorer 蜑榊床逶ョ蠖墓」€豬具シ・ShellWindows COM・会シ壻サ・シ募・逕ィ蛻ー逧・feature・梧而蛻カ郛冶ッ台ス鍋ァ
# Explorer 陷第ヲ雁コ企€カ・ョ陟門「難ス」ツ€雎ャ蜈キ・シ繝サShellWindows COM繝サ莨夲スシ螢サ・サ繝サ・シ蜍溘・騾包スィ陋サ・ー騾ァ繝サfeature繝サ譴ァ閠瑚崕・カ驛帛・・ッ蜿ー・ス骰具スァ・
windows = { version = "0.52", features = [
"Win32_Foundation",
"Win32_System_Com",
@@ -80,3 +82,15 @@ windows = { version = "0.52", features = [
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
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";
/// 任务被删除(浏览器扩展通过 HTTP API 删除任务时发出,前端据此刷新任务列表)
pub const DOWNLOAD_REMOVED: &str = "download-removed";
/// 浏览器扩展通过 HTTP API 新增下载(前端需置前主窗口并跳到下载画面
/// 浏览器扩展通过 HTTP API 新增下载(负载 { id },前端据以为该任务创建专属下载窗口
pub const DOWNLOAD_EXTENSION_ADDED: &str = "download-extension-added";
}
+53 -2
View File
@@ -1,10 +1,11 @@
use std::collections::HashMap;
use tauri::{AppHandle, State};
use tauri::{AppHandle, Manager, State};
use tauri_plugin_opener::OpenerExt;
use super::engine::{CheckUrlResult, DownloadEngine};
use super::task::{DownloadTask, DownloaderSettings};
use super::torrent::TorrentInfo;
/// 获取所有任务
#[tauri::command]
@@ -13,6 +14,20 @@ pub fn downloader_get_tasks(engine: State<'_, DownloadEngine>) -> Vec<DownloadTa
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 重复性并探测文件信息(添加下载前调用)
#[tauri::command]
#[specta::specta]
@@ -57,8 +72,9 @@ pub async fn downloader_add_task(
dir: Option<String>,
headers: Option<HashMap<String, String>>,
auto_rename: Option<bool>,
only_files: Option<Vec<u32>>,
) -> 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)
}
/// 取消任务(置为已取消,清空进度并删除下载文件,但保留记录)
#[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]
#[specta::specta]
@@ -141,3 +171,24 @@ pub fn downloader_open_url(app: AppHandle, url: String) -> Result<(), String> {
.open_url(url, None::<&str>)
.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、文件名)
/// 优先用 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(
&self,
url: &str,
headers: &HashMap<String, String>,
use_proxy: bool,
) -> 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 和获取大小)
let mut req = client
.get(url)
@@ -165,7 +185,36 @@ impl HttpDownloader {
limiter: Arc<RateLimiter>,
use_proxy: bool,
) -> 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();
// 预分配文件(若已知大小)
@@ -191,7 +240,7 @@ impl HttpDownloader {
// 单线程下载(不支持 Range 或文件太小)
let seg = &segments[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?;
return Ok(());
}
+3 -2
View File
@@ -5,10 +5,11 @@ pub mod rate_limit;
pub mod server;
pub mod storage;
pub mod task;
pub mod torrent;
pub use commands::{
downloader_add_task, downloader_check_url, downloader_get_extension_info, downloader_get_settings, downloader_get_tasks,
downloader_open_dir, downloader_open_url, downloader_pause_task, downloader_remove_task,
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_redownload, downloader_remove_task,
downloader_resume_task, downloader_save_settings, downloader_status,
};
pub use engine::DownloadEngine;
+7 -6
View File
@@ -122,13 +122,14 @@ async fn create_download(
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) => {
// 浏览器扩展发起下载:置前主窗口并通知前端跳到下载画面(替代原桌面通知)
crate::tray_menu::focus_main_window(&state.app_handle);
let _ = state
.app_handle
.emit(crate::constants::events::DOWNLOAD_EXTENSION_ADDED, ());
// 浏览器扩展发起下载:不再置前主窗口,改为带 task id 通知前端,
// 由前端为该任务创建一个专属的一次性下载窗口(不打断主界面)
let _ = state.app_handle.emit(
crate::constants::events::DOWNLOAD_EXTENSION_ADDED,
serde_json::json!({ "id": id }),
);
Ok(Json(CreateDownloadResponse { id }))
}
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 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)]
#[serde(rename_all = "lowercase")]
@@ -16,6 +27,8 @@ pub enum TaskStatus {
Complete,
/// 错误
Error,
/// 已取消(用户取消:进度与文件已清除,仅保留记录,只能再次下载)
Cancelled,
}
/// 下载分段(多线程 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)]
#[serde(rename_all = "camelCase")]
pub struct DownloadTask {
/// 任务 ID(自增 hex 字符串)
pub id: String,
/// 下载地址
/// 下载地址HTTP URL 或磁力链接)
pub url: String,
/// 文件名
/// 文件名(HTTP:目标文件名;BT:种子名称)
pub filename: 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,
/// 文件总大小(字节),0=未知
@@ -141,6 +178,18 @@ pub struct DownloaderSettings {
/// 下载是否使用代理:true=尊重系统代理(mihomo 开启系统代理时经其转发),false=强制直连
#[serde(default = "default_true")]
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 {
@@ -180,6 +229,10 @@ impl Default for DownloaderSettings {
delete_files_on_remove: false,
check_duplicate: 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::{
DownloadEngine,
downloader_add_task, downloader_check_url, downloader_get_extension_info, downloader_get_settings, downloader_get_tasks,
downloader_open_dir, downloader_open_url, downloader_pause_task, downloader_remove_task,
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_redownload, downloader_remove_task,
downloader_resume_task, downloader_save_settings, downloader_status,
};
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_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_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,
};
use monitor_kernel::{
@@ -116,7 +116,7 @@ fn export_bindings() {
proxy_close_connection, proxy_confirm_install, proxy_delete_profile, proxy_get_settings,
proxy_get_system_proxy, proxy_import_profile, proxy_kernel_info,
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,
// quickpanel22
quickpanel_get_settings, quickpanel_save_settings, quickpanel_register_shortcut,
@@ -140,8 +140,8 @@ fn export_bindings() {
clipboard_preview_interacted,
// download_engine10,豁免 2
downloader_get_tasks, downloader_check_url, downloader_add_task, downloader_pause_task,
downloader_resume_task, downloader_remove_task, downloader_get_settings,
downloader_save_settings, downloader_open_dir, downloader_open_url,
downloader_resume_task, downloader_cancel_task, downloader_redownload, downloader_remove_task, downloader_get_settings,
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
screenshot_disable_transitions, screenshot_show_overlay, screenshot_register_shortcut,
screenshot_unregister_shortcut, screenshot_register_pin_shortcut,
@@ -200,6 +200,7 @@ pub fn run() {
proxy_status,
proxy_start,
proxy_stop,
proxy_traffic,
proxy_restart,
proxy_version,
proxy_get_proxies,
@@ -243,6 +244,8 @@ pub fn run() {
downloader_check_url,
downloader_pause_task,
downloader_resume_task,
downloader_cancel_task,
downloader_redownload,
downloader_remove_task,
downloader_get_settings,
downloader_save_settings,
@@ -250,6 +253,9 @@ pub fn run() {
downloader_get_extension_info,
downloader_open_dir,
downloader_open_url,
downloader_focus_window,
downloader_inspect,
downloader_select_bt_files,
clipboard_get_history,
clipboard_get_pinned,
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::{
KernelInfo, KernelUpdateInfo, MihomoManager, ProfileMeta, ProxySettings, ProxyStatus,
KernelInfo, KernelUpdateInfo, MihomoManager, ProfileMeta, ProxySettings, ProxyStatus, TrafficSnapshot,
};
use crate::process_manager::{ProcessInfo, ProcessManager, ProcessStatus};
@@ -238,6 +238,14 @@ pub async fn proxy_get_connections(
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]
#[specta::specta]
pub async fn proxy_close_connection(
+63 -4
View File
@@ -17,11 +17,11 @@ mod types;
pub use autoswitch::{pick_best, start_auto_switch_loop};
pub use pseudo::is_pseudo_node;
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::{
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_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_start, proxy_status, proxy_stop, proxy_test_delay, proxy_update_profile, proxy_version,
};
@@ -45,10 +45,19 @@ struct SettingsCacheEntry {
settings: ProxySettings,
}
/// 流量速率差分基线:记录上次采样的会话总量与时刻,用于计算实时速率
struct TrafficBaseline {
download_total: u64,
upload_total: u64,
at: Instant,
}
pub struct MihomoManager {
root: PathBuf,
client: Client,
settings_cache: Mutex<Option<SettingsCacheEntry>>,
/// 流量速率差分基线:记录上次采样总量与时刻,由两次 /connections 总量差异计算实时速率
traffic_baseline: Mutex<Option<TrafficBaseline>>,
/// 内核安装/更新的取消标志(前端「停止下载」置位,下载循环轮询后中止)
kernel_cancel: Arc<AtomicBool>,
/// 取消唤醒通道:让停滞在流式读取(stream.next 最多等 30s)中的下载立即感知取消,
@@ -75,6 +84,7 @@ impl MihomoManager {
.build()
.unwrap_or_else(|_| Client::new()),
settings_cache: Mutex::new(None),
traffic_baseline: Mutex::new(None),
kernel_cancel: Arc::new(AtomicBool::new(false)),
kernel_cancel_tx: tokio::sync::watch::channel(false).0,
install_confirm: std::sync::Mutex::new(None),
@@ -308,7 +318,15 @@ impl MihomoManager {
/// 手动启动与 App 自启共用,保证设置语义一致(mihomo 运行期间自动跟随系统代理)。
pub fn apply_auto_system_proxy(&self) {
let settings = self.load_settings();
if settings.auto_system_proxy && !settings.system_proxy && !system_proxy::get_system_proxy_windows() {
if !settings.auto_system_proxy {
return;
}
// 以注册表实际状态为准判断是否已开启: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));
@@ -318,7 +336,6 @@ impl MihomoManager {
s.system_proxy = true;
let _ = self.save_settings(&s);
}
}
/// 应用启动时检查是否需要自动启动 mihomo 和系统代理
pub fn auto_start_on_launch(&self, app: &AppHandle, pm: &ProcessManager) {
@@ -450,6 +467,48 @@ impl MihomoManager {
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> {
self.api_request(
reqwest::Method::DELETE,
+16
View File
@@ -124,6 +124,22 @@ pub struct ProxyStatus {
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
/// - percent: 0-100(无 total_bytes 时为 0,前端按 downloadedBytes 显示)
+6 -2
View File
@@ -60,7 +60,7 @@ mod win_api {
GetClassNameW, GetCursorPos, GetForegroundWindow, GetWindowLongPtrW,
GetWindowRect, SendMessageW, SetWindowLongPtrW, SetWindowPos,
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,
};
@@ -165,6 +165,10 @@ mod win_api {
} else {
HWND_NOTOPMOST
};
// 注意:不传 SWP_SHOWWINDOW,仅调整 Z 序,绝不改变窗口可见性。
// 否则当 OSD 被 .hide() 隐藏后,任务栏覆盖监视线程在系统 UI 前景切换时
// (点击任务栏/托盘关闭主界面、打开托盘菜单)会重新显示已隐藏的 OSD,
// 表现为"托盘关闭 OSD 无效 / 关闭主界面后 OSD 又出现"。
SetWindowPos(
hwnd,
insert_after,
@@ -172,7 +176,7 @@ mod win_api {
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",
"productName": "thing",
"version": "26.8.2",
"version": "26.8.3",
"identifier": "thing.lfeng.me",
"build": {
"beforeDevCommand": "bun run dev",
+57 -9
View File
@@ -14,8 +14,8 @@ import { useProcessStore } from '@/stores/processStore'
import { TooltipProvider } from '@/components/ui/tooltip'
import { moduleRegistry } from '@/modules/registry'
import type { ModuleMeta } from '@/types/module'
import { pendingNewDownload, pendingShowDownloadTasks } from '@/lib/trayEvents'
import { EVENTS, STORAGE_KEYS } from '@/lib/constants'
import { pendingNewDownload } from '@/lib/trayEvents'
import { EVENTS, STORAGE_KEYS, WINDOWS } from '@/lib/constants'
import { commands } from '@/lib/bindings'
const appStore = useAppStore()
@@ -104,6 +104,56 @@ const handleSearch = (moduleId: string) => {
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 enabledIds = appStore.enabledModules.map(m => m.id)
const fallback = moduleRegistry.getAllMetas().find(
@@ -198,14 +248,12 @@ onMounted(async () => {
}
})
)
// 浏览器扩展新增下载:直接切到下载模块的任务列表页(主窗口已由 Rust 端置前)
// 浏览器扩展新增下载:为该任务创建一个专属的一次性下载窗口(不打断主界面)。
// 主窗口本身无需置前,下载进度/完成事件由独立窗口自行监听。
trayUnlisteners.push(
await listen(EVENTS.downloadExtensionAdded, () => {
const enabledIds = appStore.enabledModules.map(m => m.id)
if (enabledIds.includes('downloader') || moduleRegistry.getConfig('downloader')?.builtin) {
pendingShowDownloadTasks.value = true
handleModuleChange('downloader')
}
await listen<{ id: string }>(EVENTS.downloadExtensionAdded, (e) => {
if (!e.payload?.id) return
void openDownloadWindow(e.payload.id)
})
)
trayUnlisteners.push(
+1 -1
View File
@@ -27,7 +27,7 @@ watch(
<template>
<main
ref="containerRef"
class="flex-1"
class="flex-1 min-w-0"
:style="{ backgroundColor: 'var(--effect-bg)', backdropFilter: 'var(--effect-blur)' }"
>
<ScrollArea data-main-scroll class="h-full w-full">
+10
View File
@@ -1,5 +1,6 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { ref } from "vue"
import { useVModel } from "@vueuse/core"
import { cn } from "@/lib/utils"
@@ -17,10 +18,19 @@ const modelValue = useVModel(props, "modelValue", emits, {
passive: true,
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>
<template>
<input
ref="inputEl"
v-model="modelValue"
data-slot="input"
:class="cn(
+81 -4
View File
@@ -43,6 +43,7 @@ export const commands = {
proxyStart: () => __TAURI_INVOKE<ProcessInfo>("proxy_start"),
proxyStatus: () => __TAURI_INVOKE<ProxyStatus>("proxy_status"),
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 }),
proxyUpdateProfile: (id: string) => __TAURI_INVOKE<ProfileMeta>("proxy_update_profile", { id }),
/** 读取快速面板设置(快捷键等) */
@@ -198,11 +199,15 @@ export const commands = {
/** 检查 URL 重复性并探测文件信息(添加下载前调用) */
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 }),
/** 恢复任务 */
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 }),
/** 获取设置 */
@@ -213,6 +218,17 @@ export const commands = {
downloaderOpenDir: (path: string) => __TAURI_INVOKE<null>("downloader_open_dir", { path }),
/** 用系统默认浏览器打开 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 查找)的显示/隐藏过渡动画,消除覆盖层出现/消失时的缩放动画 */
screenshotDisableTransitions: (label: string) => __TAURI_INVOKE<null>("screenshot_disable_transitions", { label }),
/** 一次 IPC 完成指定窗口的显示与聚焦(截图覆盖层显示关键路径,减少串行往返) */
@@ -280,6 +296,16 @@ export type ArchiveInfo = {
size: number,
};
/** BT 种子内文件条目(多文件任务用;阶段1下载全部文件,但保留列表供 UI 展示) */
export type BtFileInfo = {
/** 文件在种子内的索引 */
index: number,
/** 相对种子根目录的路径(如 "sub/file.mkv" */
path: string,
/** 文件大小(字节) */
size: number,
};
/** 前端可见的捕获数据 */
export type CaptureData = {
pngBase64: string,
@@ -372,12 +398,20 @@ export type DeleteResult = {
export type DownloadTask = {
/** 任务 ID(自增 hex 字符串) */
id: string,
/** 下载地址 */
/** 下载地址HTTP URL 或磁力链接) */
url: string,
/** 文件名 */
/** 文件名(HTTP:目标文件名;BT:种子名称) */
filename: string,
/** 保存目录(绝对路径) */
dir: string,
/** 协议类型 */
protocol?: TaskProtocol,
/** BT 种子 infohash(协议=BitTorrent 时存在) */
infoHash?: string | null,
/** BT 种子内文件列表(协议=BitTorrent 时存在) */
btFiles?: BtFileInfo[],
/** BT 元数据是否已解析就绪(异步添加时:后台解析完成前为 false,调度器跳过) */
btMetadataReady?: boolean,
/** 状态 */
status: TaskStatus,
/** 文件总大小(字节),0=未知 */
@@ -420,6 +454,14 @@ export type DownloaderSettings = {
checkDuplicate?: boolean,
/** 下载是否使用代理:true=尊重系统代理(mihomo 开启系统代理时经其转发),false=强制直连 */
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[],
};
/** 任务下载协议类型 */
export type TaskProtocol =
/** HTTP/HTTPS 直链 */
"http" |
/** BitTorrent(磁力链 / .torrent 文件) */
"bittorrent";
/** 任务状态 */
export type TaskStatus =
/** 排队等待(并发数已满) */
@@ -615,7 +664,35 @@ export type TaskStatus =
/** 已完成 */
"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 中的一个资产 */
export type UpdateAsset = {
+5 -1
View File
@@ -10,6 +10,8 @@ export const WINDOWS = {
osdOverlay: 'osd-overlay',
screenshotOverlay: 'screenshot-overlay',
screenshotPin: 'screenshot-pin',
/** 单文件一次性下载窗口前缀,实际 label = `${downloadWindow}-<taskId>` */
downloadWindow: 'download-window',
} as const
/** Tauri 事件名(前端 emit / listen 与 Rust constants::events 对应) */
@@ -72,7 +74,7 @@ export const EVENTS = {
downloadAdded: 'download-added',
/** 任务被删除(浏览器扩展通过 HTTP API 删除任务时发出,前端据此刷新任务列表) */
downloadRemoved: 'download-removed',
/** 浏览器扩展通过 HTTP API 新增下载(置前主窗口并跳到下载画面 */
/** 浏览器扩展通过 HTTP API 新增下载(负载 { id },前端据以为该任务创建专属下载窗口 */
downloadExtensionAdded: 'download-extension-added',
} as const
@@ -94,6 +96,8 @@ export const STORAGE_KEYS = {
quickpanelDeleteFilterFavs: 'thing_quickpanel_delete_filter_favs',
currencyRates: 'thing_quickpanel_currency_rates',
monitorOsdConfig: 'thing_monitor_osd_config',
/** 关闭"自动启动监控内核"时暂存的 OSD 开关状态(开启自动启动时据此恢复) */
monitorOsdPending: 'thing_monitor_osd_pending',
monitorOverviewCards: 'thing_monitor_overview_cards',
screenshotHistory: 'thing_screenshot_history',
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-editor', '截图编辑器', () => import('./modules/screenshot/ScreenshotEditor.vue')],
['#screenshot-pin', '贴图窗口', () => import('./modules/screenshot/ScreenshotPin.vue')],
['#download-window', '下载窗口', () => import('./modules/downloader/DownloadWindow.vue')],
]
const winHash = window.location.hash
// #screenshot-overlay 带窗口号参数(多屏),按前缀匹配;其余精确匹配
const matched = standaloneWindowApps.find(([hash]) =>
hash === '#screenshot-overlay' ? winHash.startsWith(hash) : winHash === hash
)
// #screenshot-overlay 带窗口号参数(多屏)、#download-window 带 ?task= 参数,按前缀匹配;其余精确匹配
const matched = standaloneWindowApps.find(([hash]) => {
if (hash === '#screenshot-overlay' || hash === '#download-window') return winHash.startsWith(hash)
return winHash === hash
})
if (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>
+439 -23
View File
@@ -5,7 +5,7 @@ import {
Link2, Loader2, FolderOpen, Copy, Puzzle,
CheckCircle2, Clock, Zap, Eye, EyeOff, Search,
ArrowUp, ArrowDown, ChevronLeft, ChevronRight, ExternalLink, Globe,
Info
Info, XCircle, Square
} from '@lucide/vue'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { toast } from 'vue-sonner'
@@ -57,6 +57,118 @@ const addDir = ref('')
const addingTask = 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)
@@ -94,6 +206,7 @@ const STATUS_OPTIONS: { label: string; value: StatusFilter }[] = [
{ label: '等待中', value: 'queued' },
{ label: '已暂停', value: 'paused' },
{ label: '已完成', value: 'complete' },
{ label: '已取消', value: 'cancelled' },
{ label: '错误', value: 'error' }
]
@@ -107,6 +220,17 @@ const SORT_OPTIONS: { label: string; value: SortField }[] = [
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 => {
if (!bytes || isNaN(bytes)) return '0 B'
if (bytes < 1024) return `${bytes} B`
@@ -167,6 +291,8 @@ const getTaskStatusBadge = (task: DownloadTask) => {
return { variant: 'default' as const, text: '已完成', icon: CheckCircle2 }
case 'error':
return { variant: 'destructive' as const, text: '错误', icon: AlertCircle }
case 'cancelled':
return { variant: 'secondary' as const, text: '已取消', icon: XCircle }
default:
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<{
open: boolean
@@ -310,27 +475,54 @@ const handleAddDownload = async () => {
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
try {
const dir = addDir.value.trim() || undefined
// 检查是否启用重复检查
// HTTP 链接走原流程(含重复检查)
if (httpUris.length > 0) {
const checkEnabled = store.settings?.checkDuplicate ?? true
if (checkEnabled) {
// 逐个检查重复
duplicateSuccessCount.value = 0
await processUrlsWithCheck(uris, dir)
processUrlsWithCheck(httpUris, dir || undefined)
} else {
// 直接添加
let successCount = 0
for (const uri of uris) {
for (const uri of httpUris) {
try {
await store.addTask(uri, undefined, dir, undefined, true)
await store.addTask(uri, undefined, dir || undefined, undefined, true)
successCount++
} catch (e) {
logger.error(`添加 ${uri} 失败: ` + e)
}
}
finishAdd(successCount)
if (successCount > 0) 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) {
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 () => {
try {
const selected = await openDialog({ directory: true, multiple: false })
@@ -588,6 +797,8 @@ watch(pendingShowDownloadTasks, (v) => {
onMounted(async () => {
await store.init()
// 注册磁力元数据就绪回调:解析成功后弹文件勾选对话框
store.setBtInspectReadyHandler(onBtInspectReady)
// 消费托盘菜单"新建下载"标志位(挂载前设置的场景,watcher 尚未生效)
if (pendingNewDownload.value) {
pendingNewDownload.value = false
@@ -601,6 +812,7 @@ onMounted(async () => {
})
onUnmounted(() => {
store.setBtInspectReadyHandler(null)
store.stopEventListeners()
})
@@ -610,7 +822,7 @@ const allTasks = computed<DownloadTask[]>(() => store.tasks)
// 状态栏计数:单次遍历统计各状态任务数(替代模板内 4 次 filter 全量扫描)
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]++
return counts
})
@@ -693,8 +905,8 @@ const toggleSortOrder = () => {
</div>
<!-- ===== 下载任务 ===== -->
<TabsContent value="tasks" class="flex-1 mt-4 min-h-0 tab-animate">
<div class="h-full flex flex-col gap-4">
<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 min-w-0">
<!-- 状态栏 -->
<Card class="shrink-0 !py-0 !gap-0">
<CardContent class="pl-4 pr-4 py-3">
@@ -727,6 +939,10 @@ const toggleSortOrder = () => {
<Check class="size-3" />
已完成 {{ statusCounts.complete }}
</Badge>
<Badge v-if="statusCounts.cancelled > 0" variant="secondary" class="gap-1">
<XCircle class="size-3" />
已取消 {{ statusCounts.cancelled }}
</Badge>
</div>
</div>
<div class="flex items-center gap-2">
@@ -849,7 +1065,7 @@ const toggleSortOrder = () => {
</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">
<CheckCircle2 class="size-16 opacity-30" />
<p>暂无符合条件的任务</p>
@@ -863,10 +1079,15 @@ const toggleSortOrder = () => {
<CardContent class="pl-4 pr-4 py-3">
<div class="flex items-start justify-between gap-3 mb-3">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-1">
<span class="font-medium truncate cursor-default">
<div class="flex items-center gap-2 mb-1 min-w-0">
<!-- 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) }}
</span>
<Badge v-if="task.protocol === 'bittorrent'" variant="outline" class="shrink-0 gap-1 py-0 px-1.5 text-[10px]">
BT
</Badge>
<Badge
:variant="getTaskStatusBadge(task).variant"
class="shrink-0"
@@ -895,7 +1116,7 @@ const toggleSortOrder = () => {
</TooltipTrigger>
<TooltipContent class="max-w-[480px] break-words">{{ task.dir }}</TooltipContent>
</Tooltip>
<span v-if="task.error" class="text-destructive">
<span v-if="task.error" class="text-destructive break-all">
{{ task.error }}
</span>
</div>
@@ -927,6 +1148,35 @@ const toggleSortOrder = () => {
</TooltipTrigger>
<TooltipContent>继续</TooltipContent>
</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>
<TooltipTrigger as-child>
<Button
@@ -940,7 +1190,7 @@ const toggleSortOrder = () => {
</TooltipTrigger>
<TooltipContent>详细信息</TooltipContent>
</Tooltip>
<Tooltip v-if="task.dir">
<Tooltip v-if="task.dir && task.status !== 'cancelled'">
<TooltipTrigger as-child>
<Button
size="icon"
@@ -968,8 +1218,9 @@ const toggleSortOrder = () => {
</Tooltip>
</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 v-if="task.status === 'active' && task.speed > 0">
{{ formatEta(getEta(task)) }}
@@ -1153,6 +1404,60 @@ const toggleSortOrder = () => {
</CardContent>
</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 设置 -->
<Card>
<CardHeader>
@@ -1452,6 +1757,68 @@ const toggleSortOrder = () => {
</DialogContent>
</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">
<DialogContent class="max-w-lg">
@@ -1461,7 +1828,7 @@ const toggleSortOrder = () => {
新建下载
</DialogTitle>
<DialogDescription class="text-xs">
支持 HTTP/HTTPS 直链,每行一个 URL。留空下载目录则使用默认目录。
支持 HTTP/HTTPS 直链、磁力链接(magnet:)与 .torrent 种子文件(本地文件或 http(s) 链接),每行一个。留空下载目录则使用默认目录。
</DialogDescription>
</DialogHeader>
@@ -1471,9 +1838,18 @@ const toggleSortOrder = () => {
<Label class="text-xs">下载链接</Label>
<Textarea
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"
/>
<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>
<!-- 下载目录 -->
@@ -1529,7 +1905,7 @@ const toggleSortOrder = () => {
<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 class="font-medium">{{ duplicateDialogState.result.existing.filename }}</span>
<span class="font-medium break-all">{{ duplicateDialogState.result.existing.filename }}</span>
</span>
<span class="block mt-2 text-muted-foreground">
选择"仍然下载"将自动重命名追加序号选择"跳过"将不下载此链接
@@ -1553,7 +1929,9 @@ const toggleSortOrder = () => {
<AlertDialogHeader>
<AlertDialogTitle>删除任务</AlertDialogTitle>
<AlertDialogDescription>
确定要删除任务 "{{ removeDialogState.task ? getFileName(removeDialogState.task) : '' }}"
确定要删除任务
<span class="block font-mono text-xs break-all mt-1">{{ removeDialogState.task ? getFileName(removeDialogState.task) : '' }}</span>
</AlertDialogDescription>
</AlertDialogHeader>
<div class="flex items-center justify-between py-2 px-1">
@@ -1665,6 +2043,44 @@ const toggleSortOrder = () => {
<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="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 配置项变更时自动保存(防抖:滑块拖动期间不逐帧写盘) */
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
saveOsdConfigDebounced(osdConfig.value)
}
@@ -2404,6 +2425,28 @@ watch(() => store.status?.ready, (ready, prev) => {
</DialogContent>
</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 -->
<Dialog v-model:open="osdPickDialogOpen">
<DialogContent class="max-w-lg">
+20
View File
@@ -444,6 +444,23 @@ function scheduleMeasure() {
}, 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(处理原生窗口)
// 仅靠原生 WS_EX_TRANSPARENT 不足:Tauri 窗口包含 webview2 子窗口,需两者都设置才能完全穿透
@@ -537,6 +554,9 @@ onUnmounted(() => {
unlistenFns.forEach(fn => fn())
// 停止监视线程
invoke('osd_stop_watch').catch(() => {})
// 断开内容尺寸监视器
resizeObserver?.disconnect()
resizeObserver = null
})
</script>
+357 -3
View File
@@ -2,7 +2,8 @@
import {
Globe, Play, Square, RotateCw, Power, Zap, Plus, RefreshCw, Trash2,
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'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
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 { appDataDir } from '@tauri-apps/api/path'
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 { useModuleTabsStore } from '@/stores/moduleTabsStore'
import { createLogger } from '@/lib/logger'
@@ -84,6 +85,7 @@ const activeTab = ref('overview')
const tabsStore = useModuleTabsStore()
const tabsListRef = useModuleTabs('proxy', activeTab, [
{ value: 'overview', label: '概览' },
{ value: 'connections', label: '连接' },
{ value: 'proxies', label: '节点' },
{ value: 'profiles', label: '订阅' },
{ value: 'settings', label: '设置' }
@@ -141,9 +143,179 @@ const accordionValue = ref<string>('')
// 进程状态轮询
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)
// ===== 连接页签 =====
/** 当前连接列表(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/流量/套餐等非具体代理节点
const PSEUDO_NODE_KEYWORDS = [
'DIRECT', 'REJECT', 'PASS', 'COMPATIBLE',
@@ -353,6 +525,16 @@ onMounted(() => {
// 同步系统代理真实状态(注册表可能被外部改动,3s 周期足够感知)
await store.refreshSystemProxy()
}, 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)
// 监听后端自动切换节点完成事件(后台执行,不依赖模块激活)
@@ -363,6 +545,8 @@ onMounted(() => {
onUnmounted(() => {
if (statusTimer) clearInterval(statusTimer)
if (trafficTimer) clearInterval(trafficTimer)
if (connTimer) clearInterval(connTimer)
document.removeEventListener('visibilitychange', onVisibilityChange)
autoSwitchUnlisten.forEach(fn => fn())
autoSwitchUnlisten = []
@@ -960,8 +1144,9 @@ tabsStore.registerSave(saveSettingsForm)
<div class="h-full p-6">
<Tabs v-model="activeTab" class="h-full flex flex-col">
<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="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="profiles" class="gap-1.5"><ListChecks 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">
<ScrollArea class="h-full pr-3">
<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>
<CardHeader>
@@ -1436,6 +1657,139 @@ tabsStore.registerSave(saveSettingsForm)
</ScrollArea>
</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">
<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">
+15 -3
View File
@@ -426,13 +426,25 @@ onMounted(async () => {
mq.addEventListener('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) => {
await applyTheme()
Object.assign(state, event.payload)
osdVisible.value = readOsdVisible()
// 显示前重置上次残留的下拉/焦点状态(双保险)
resetMenuState()
// 开始合并窗口:等待完整状态,结束时才显示
menuJustShown = Date.now()
window.clearTimeout(finalizeTimer)
finalizeTimer = window.setTimeout(async () => {
menuJustShown = 0
await measureAndShow()
}, 150)
}))
// 菜单失焦(点击其他位置自动隐藏)时重置下拉框与焦点,避免下次打开时残留
@@ -440,12 +452,12 @@ onMounted(async () => {
if (!focused) resetMenuState()
}))
// 仅更新状态数据,不重新显示窗口。
// - 显示流程的合并窗口内:完整状态补充到达,仅合并数据,让 finalizeTimer 统一显示
// - 动作完成后的状态更新:菜单已隐藏,只更新数据(不重新弹出)
// - 右键后完整状态补充到达(基础状态先行显示,节点数据异步跟上):
// 菜单可见时重新测量调整窗口尺寸(tray_menu_ready 幂等,重新定位+resize,不会重复显示动画)
// - 超时后(mihomo 慢)node 数据补充到达且菜单已可见:重新测量调整尺寸(兜底)
unlistenFns.push(await listen<TrayMenuState>('tray-menu-state-updated', async (event) => {
Object.assign(state, event.payload)
if (menuJustShown) return // 正处于首次显示的合并窗口,等待 finalize 统一显示
try {
const visible = await getCurrentWindow().isVisible()
if (visible) await measureAndShow()
+82 -7
View File
@@ -9,6 +9,7 @@ import type {
DownloadTask as BindDownloadTask,
DownloaderSettings as BindDownloaderSettings,
CheckUrlResult as BindCheckUrlResult,
TorrentInfo as BindTorrentInfo,
TaskStatus,
} from '@/lib/bindings'
@@ -19,11 +20,14 @@ const logger = createLogger('downloader')
export type DownloadTask = Required<BindDownloadTask>
export type DownloaderSettings = Required<BindDownloaderSettings>
export type CheckUrlResult = Required<BindCheckUrlResult>
export type TorrentInfo = Required<BindTorrentInfo>
// re-export:跨端类型统一由 bindings 提供,组件从本 store import 的路径保持不变
export type {
TaskStatus,
TaskProtocol,
Segment,
BtFileInfo,
DuplicateKind,
ExistingTaskInfo,
} from '@/lib/bindings'
@@ -71,6 +75,10 @@ export const useDownloaderStore = defineStore('downloader', () => {
let completeUnlisten: UnlistenFn | null = null
let addedUnlisten: UnlistenFn | null = null
let removedUnlisten: UnlistenFn | null = null
let inspectReadyUnlisten: UnlistenFn | null = null
/** 磁力元数据解析就绪回调(模块注册,用于弹文件勾选对话框) */
let btInspectReadyHandler: ((id: string) => void) | null = null
// ===== 任务列表 =====
const refreshTasks = async () => {
@@ -85,13 +93,26 @@ export const useDownloaderStore = defineStore('downloader', () => {
if (
freshTask.status === 'paused' ||
freshTask.status === 'complete' ||
freshTask.status === 'error'
freshTask.status === 'error' ||
freshTask.status === 'cancelled'
) {
return freshTask
}
const local = tasks.value.find(t => t.id === freshTask.id)
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
} catch (e) {
@@ -105,9 +126,15 @@ export const useDownloaderStore = defineStore('downloader', () => {
const task = tasks.value.find((t) => t.id === payload.id)
if (task) {
// 终态任务忽略迟到的进度事件(下载完成后 in-flight 事件可能把状态/进度回退)
if (task.status === 'complete' || task.status === 'error') return
// 已暂停任务忽略仍携带 active 的迟到事件(暂停瞬间发出的旧事件)
if (task.status === 'paused' && payload.status === 'active') return
if (task.status === 'complete' || task.status === 'error' || task.status === 'cancelled') return
// 已暂停任务忽略"停止瞬间残留的 active 心跳"(无速度且进度未变化的迟到事件)。
// 真正恢复下载后发来的 active(有速度或进度增长)必须放行,否则恢复后列表一直停留在暂停态
if (
task.status === 'paused' &&
payload.status === 'active' &&
payload.speed <= 0 &&
payload.completedSize <= task.completedSize
) return
task.completedSize = payload.completedSize
task.totalSize = payload.totalSize
task.speed = payload.speed
@@ -134,19 +161,32 @@ export const useDownloaderStore = defineStore('downloader', () => {
filename?: string,
dir?: string,
headers?: Record<string, string>,
autoRename = false
autoRename = false,
onlyFiles?: number[]
): Promise<string> => {
const id = await commands.downloaderAddTask(
url,
filename || null,
dir || null,
headers || null,
autoRename
autoRename,
onlyFiles || null
)
await refreshTasks()
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 重复性并探测文件信息 */
const checkUrl = async (
url: string,
@@ -171,6 +211,18 @@ export const useDownloaderStore = defineStore('downloader', () => {
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 () => {
try {
@@ -250,6 +302,15 @@ export const useDownloaderStore = defineStore('downloader', () => {
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 = () => {
@@ -269,6 +330,10 @@ export const useDownloaderStore = defineStore('downloader', () => {
removedUnlisten()
removedUnlisten = null
}
if (inspectReadyUnlisten) {
inspectReadyUnlisten()
inspectReadyUnlisten = null
}
}
// ===== 初始化 =====
@@ -281,6 +346,11 @@ export const useDownloaderStore = defineStore('downloader', () => {
// ===== 工具函数 =====
const openDir = (path: string) => commands.downloaderOpenDir(path)
/** 注册磁力元数据就绪回调(模块传入处理函数,替换式) */
const setBtInspectReadyHandler = (handler: ((id: string) => void) | null) => {
btInspectReadyHandler = handler
}
return {
// state
tasks,
@@ -290,10 +360,14 @@ export const useDownloaderStore = defineStore('downloader', () => {
// tasks
refreshTasks,
addTask,
inspect,
selectBtFiles,
checkUrl,
pauseTask,
resumeTask,
removeTask,
cancelTask,
redownload,
// settings
loadSettings,
saveSettings,
@@ -307,6 +381,7 @@ export const useDownloaderStore = defineStore('downloader', () => {
// init
init,
// utils
setBtInspectReadyHandler,
openDir
}
})
+182 -50
View File
@@ -261,14 +261,14 @@ function defaultOsdConfig(): OsdConfig {
labelLanguage: 'zh',
layout: 'single',
updateIntervalMs: 1000,
// 默认关闭点击穿透:关闭后左键可直接拖动悬浮窗
clickThrough: false,
// 默认开启点击穿透:悬浮窗不拦截鼠标,需拖动时临时关闭
clickThrough: true,
fontColor: '#ffffff',
fontOpacity: 100,
bgColor: 'transparent',
colorThemeEnabled: true,
colorTheme: { ...DEFAULT_COLOR_THEME },
fontStrokeEnabled: false,
fontStrokeEnabled: true,
fontStrokeWidth: 1,
fontStrokeColor: '#000000',
alert: { ...DEFAULT_ALERT_CONFIG, maxValues: { ...DEFAULT_ALERT_CONFIG.maxValues } },
@@ -583,11 +583,41 @@ export const useMonitorStore = defineStore('monitor', () => {
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 开关状态,再关闭 OSD(OSD 依赖内核随应用启动)
* - 关→开:若记录状态为开,则恢复 OSD 显示
*/
async function setAutoStart(enabled: boolean) {
try {
await invoke('monitor_set_auto_start', { 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) {
errorMsg.value = String(e)
logger.error('设置自动启动开关失败: ' + e)
@@ -758,67 +788,172 @@ export const useMonitorStore = defineStore('monitor', () => {
/** 根据显示项估算悬浮窗窗口尺寸(逻辑像素)
* single: 单行分组式,组间用 | 分隔,固定宽度数据列
* group: 分组横排,标题在上 + 数据列在下
* multiline: 多行,每组一行,标题 + 固定宽度数据列 */
* multiline: 多行,每组一行,标题 + 固定宽度数据列
*
* 宽度严格按 OsdWindow.vue 的渲染结构估算:
* - 标签/箭头里的 CJK 按全角宽度(≈fontSize),ASCII 按等宽(≈0.6em
* - 数值用 fmtFixedValue 的固定 pad 宽度、单位用 fmtFixedUnit 的文本
* - 计入各类 gap(组内 3px、single 组间 4px、group 组间 8px、项内 gap、单位 margin
* - 计入 osd-bar 左右 padding4*2
* 这样创建时的窗口宽度与真实内容一致,避免窗口小于内容而截断,
* 也避免因宽度估算偏差导致 computePositionFromPct 的位置百分比偏移。 */
function computeOsdWindowSize(
_itemCount: number,
layout: 'single' | 'group' | 'multiline',
fontSize: number,
_hasNetItem = false,
items?: OsdItem[],
items: OsdItem[] = [],
): { 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 groupMap = new Map<string, OsdItem[]>()
if (items?.length) {
for (const item of items) {
let gkey: string
if (item.special === 'net-up' || item.special === 'net-down') gkey = 'network'
else if (item.groupId.startsWith('gpu')) gkey = 'gpu'
else gkey = item.groupId
if (!groupMap.has(gkey)) groupMap.set(gkey, [])
groupMap.get(gkey)!.push(item)
const isCjk = (ch: string) => {
const c = ch.codePointAt(0)!
return (
(c >= 0x2e80 && c <= 0x9fff) ||
(c >= 0x3000 && c <= 0x303f) ||
(c >= 0xff00 && c <= 0xffef) ||
(c >= 0xf900 && c <= 0xfaff)
)
}
// 字符串像素宽(CJK 全角,ASCII 等宽)
const textPx = (s: string) => {
let w = 0
for (const ch of s) w += isCjk(ch) ? cjkW : charW
return w
}
const groupCount = Math.max(1, groupMap.size)
// 每组数据列宽度:标签(6ch) + 各项(数值+单位+箭头/gap)
const groupWidths: number[] = []
for (const [, groupItems] of groupMap) {
const labelW = 6
const dataW = groupItems.reduce((sum, item) => {
const isNet = item.special === 'net-up' || item.special === 'net-down'
return sum + (isNet ? 11 : 8) + 1
}, 0)
groupWidths.push(labelW + dataW)
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
}
}
// 数值固定宽度(字符数,对应 fmtFixedValue 的 pad 宽度)
const numChars = (it: OsdItem): number => {
if (it.special === 'net-up' || it.special === 'net-down') return 6
switch (it.type) {
case 'load':
case 'level':
case 'temperature': return 3
case 'power':
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') {
// 多行:取最宽行
const maxLineW = groupWidths.length ? Math.max(...groupWidths) : 10
const w = Math.ceil(maxLineW * charW + barHPad)
const lineH = Math.ceil(fontSize + 2)
const h = Math.ceil(groupCount * lineH + 6)
return { w: Math.max(120, w), h: Math.max(28, h) }
// 每行 = 标签(min 4ch) + 组内 gap(4px) + 各项;取最宽行
const labelMinW = 4 * charW
let maxLineW = 0
for (const [gkey, list] of groupEntries) {
const labelW = showLabel ? Math.max(textPx(groupLabelText(gkey)), labelMinW) : 0
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') {
// 分组横排:各组横排 + 标题行
const totalW = groupWidths.reduce((s, w) => s + w + 4, 0) + (groupCount - 1) * 4
const w = Math.ceil(totalW * charW + barHPad)
// 分组横排:各组横排,组间 gap(8px),每组含 padding(4*2)
const totalW = gw.reduce((s, w) => s + w, 0)
+ 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 dataH = Math.ceil(fontSize) + 2
const h = Math.ceil(titleH + dataH + 10)
return { w: Math.max(120, w), h: Math.max(40, h) }
const dataH = lineH
const h = Math.max(40, Math.ceil(titleH + dataH + 3 + 3))
return { w, h }
}
// single:单行分组式,各组横排 + 组间 | 分隔符(1ch)
const sepW = (groupCount - 1) * 1
const totalW = groupWidths.reduce((s, w) => s + w, 0) + sepW
const w = Math.ceil(totalW * charW + barHPad)
const h = Math.ceil(fontSize + 8)
return { w: Math.max(120, w), h: Math.max(28, h) }
// single:单行分组式,组间 | 分隔符 + 组间 gap(4px)
const sepW = (groupCount - 1) * (textPx('|') + 4)
const betweenW = Math.max(0, groupCount - 1) * 4
const w = Math.max(120, Math.ceil(gw.reduce((s, w) => s + w, 0) + sepW + betweenW + barHPad))
const h = Math.max(28, Math.ceil(fontSize + 8))
return { w, h }
}
/** 创建/显示悬浮窗窗口(默认置顶 + NoActivate + 点击穿透) */
@@ -1049,13 +1184,10 @@ export const useMonitorStore = defineStore('monitor', () => {
// stop 句柄存入 osdWatchStopsdispose 时统一释放,避免模块重挂载后重复注册
// OSD 开关变化时创建/隐藏悬浮窗
// 注意:开启 OSD 不再自动开启"应用启动时自动启动监控内核"。
// 二者保持独立(双向联动会导致:关自动启动→关 OSD→再开 OSD→自动启动又被强行打开)。
osdWatchStops.push(watch(() => osdConfig.value.overlayEnabled, (enabled) => {
if (enabled) {
// OSD 显示开启时自动开启"应用启动时自动启动监控内核",
// 使 OSD 持续显示不因重启而中断
if (!autoStart.value) {
setAutoStart(true).catch(e => logger.error('[OSD] 自动开启 autoStart 失败: ' + e))
}
// 开启时若显示项为空则不创建窗口
if (osdConfig.value.overlayItems.length === 0) return
ensureOverlayWindow().catch(e => logger.error('[OSD] 创建悬浮窗失败: ' + e))
+72 -1
View File
@@ -12,7 +12,8 @@ import {
type ProfileMeta,
type KernelInfo,
type KernelUpdateInfo,
type ProxyStatus
type ProxyStatus,
type TrafficSnapshot,
} from '@/lib/bindings'
// Rust 端结构体字段均带 serde(default),返回必完整;用 Required 收窄 bindings 的 optional
@@ -53,6 +54,32 @@ export interface ProxiesResponse {
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 {
version: string
meta?: boolean
@@ -65,6 +92,10 @@ export const useProxyStore = defineStore('proxy', () => {
const proxies = ref<Record<string, ProxyNode>>({})
const settings = ref<FullProxySettings | null>(null)
const systemProxy = ref(false)
/** 实时流量快照(上传/下载速率、会话总量、活跃连接数) */
const traffic = ref<TrafficSnapshot | null>(null)
/** 当前活跃连接列表(仅连接页签需要时拉取) */
const connections = ref<ProxyConnection[] | null>(null)
/** 是否已完成首次加载(避免初始 null/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) => {
await commands.proxySaveSettings(s)
settings.value = s
@@ -487,6 +552,8 @@ export const useProxyStore = defineStore('proxy', () => {
proxies,
settings,
systemProxy,
traffic,
connections,
initialized,
installing,
installProgress,
@@ -516,6 +583,10 @@ export const useProxyStore = defineStore('proxy', () => {
setSystemProxy,
clearSystemProxy,
toggleSystemProxy,
// traffic & connections
refreshTraffic,
refreshConnections,
closeConnection,
// kernel update / install
checkKernelUpdate,
updateKernel,
+14 -2
View File
@@ -65,8 +65,20 @@ export default defineConfig(async () => ({
}
: undefined,
watch: {
// 3. tell Vite to ignore watching `src-tauri`
ignored: ["**/src-tauri/**"],
// 3. Vite 忽略监听这些大目录,避免 Windows 上 chokidar 递归注册监听句柄
// 拖慢 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/**",
],
},
},
}));