Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
28e0c4664a | ||
|
|
d21649c60e |
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "thing",
|
||||
"private": true,
|
||||
"version": "26.8.2",
|
||||
"version": "26.8.4",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
Generated
+1355
-24
File diff suppressed because it is too large
Load Diff
+16
-2
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "thing"
|
||||
version = "26.8.2"
|
||||
version = "26.8.4"
|
||||
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 构建启用增量编译 + 行号级调试(提速本地迭代),仅影响 `tauri dev`
|
||||
|
||||
[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"
|
||||
]
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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,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(());
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 }))),
|
||||
|
||||
@@ -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/s(0=不限)
|
||||
#[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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
/// infohash(hex 小写字符串)
|
||||
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/s(0=不限),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 仍会生效)");
|
||||
}
|
||||
|
||||
/// 会话级公共 tracker(HashSet<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
@@ -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,
|
||||
// quickpanel(22)
|
||||
quickpanel_get_settings, quickpanel_save_settings, quickpanel_register_shortcut,
|
||||
@@ -140,8 +140,8 @@ fn export_bindings() {
|
||||
clipboard_preview_interacted,
|
||||
// download_engine(10,豁免 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,
|
||||
// screenshot(22,豁免 3:get_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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 显示)
|
||||
|
||||
@@ -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,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "thing",
|
||||
"version": "26.8.2",
|
||||
"version": "26.8.4",
|
||||
"identifier": "thing.lfeng.me",
|
||||
"build": {
|
||||
"beforeDevCommand": "bun run dev",
|
||||
|
||||
+57
-9
@@ -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(
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
@@ -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/s(0=不限) */
|
||||
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,
|
||||
/** infohash(hex 小写字符串) */
|
||||
infoHash: string,
|
||||
/** 种子内全部文件总大小(字节) */
|
||||
totalSize: number,
|
||||
/** 种子内文件列表 */
|
||||
files: BtFileInfo[],
|
||||
};
|
||||
|
||||
/** 实时流量快照(由 /connections 的会话总量差分得出实时速率) */
|
||||
export type TrafficSnapshot = {
|
||||
/** 本次会话累计下载字节数 */
|
||||
downloadTotal: number,
|
||||
/** 本次会话累计上传字节数 */
|
||||
uploadTotal: number,
|
||||
/** 实时下载速率(字节/秒) */
|
||||
downloadSpeed: number,
|
||||
/** 实时上传速率(字节/秒) */
|
||||
uploadSpeed: number,
|
||||
/** 当前活跃连接数 */
|
||||
activeConnections: number,
|
||||
};
|
||||
|
||||
/** release 中的一个资产 */
|
||||
export type UpdateAsset = {
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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 class(effect 类供 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>
|
||||
@@ -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 + truncate:flex 子项默认 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/s(0=不限)</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 https://example.com/file2.zip"
|
||||
placeholder="https://example.com/file.zip magnet:?xt=urn:btih:... 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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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 左右 padding(4*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 句柄存入 osdWatchStops,dispose 时统一释放,避免模块重挂载后重复注册
|
||||
|
||||
// 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))
|
||||
|
||||
@@ -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
@@ -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/**",
|
||||
],
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user