Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6b9f71da08 | ||
|
|
28e0c4664a | ||
|
|
d21649c60e |
@@ -111,7 +111,7 @@ Thing/
|
|||||||
- [x] 自建进程内下载引擎(多线程 HTTP/HTTPS,无需外部内核)
|
- [x] 自建进程内下载引擎(多线程 HTTP/HTTPS,无需外部内核)
|
||||||
- [x] 接管浏览器下载,浏览器扩展(Thing Extension)
|
- [x] 接管浏览器下载,浏览器扩展(Thing Extension)
|
||||||
- [x] HTTP 下载支持
|
- [x] HTTP 下载支持
|
||||||
- [ ] BT/磁力链接支持(后续支持)
|
- [x] BT/磁力链接支持(后续支持)
|
||||||
- [x] 下载任务管理(历史)
|
- [x] 下载任务管理(历史)
|
||||||
- [x] 速度限制
|
- [x] 速度限制
|
||||||
- [x] 断点续传
|
- [x] 断点续传
|
||||||
@@ -124,8 +124,8 @@ Thing/
|
|||||||
|
|
||||||
### 第三阶段:优化与完善
|
### 第三阶段:优化与完善
|
||||||
|
|
||||||
- [x] 性能优化(P1/P2:轮询随窗口可见性暂停、批量测速限并发、渲染 memo 化等,见 MODULE_REVIEW.md)
|
- [x] 性能优化(见 MODULE_REVIEW.md)
|
||||||
- [x] 错误处理与日志完善(B5 进程级全局日志器、异常兜底)
|
- [x] 错误处理与日志完善(全局日志器、异常兜底)
|
||||||
- [x] 用户体验优化(混合 DPI 定位、rAF 节流、UI 细节)
|
- [x] 用户体验优化(混合 DPI 定位、rAF 节流、UI 细节)
|
||||||
- [x] 自动更新机制
|
- [x] 自动更新机制
|
||||||
- [x] 打包发布
|
- [x] 打包发布
|
||||||
|
|||||||
@@ -66,6 +66,8 @@ internal sealed class KernelStatus
|
|||||||
{
|
{
|
||||||
public bool Ready { get; set; }
|
public bool Ready { get; set; }
|
||||||
public bool IsAdmin { get; set; }
|
public bool IsAdmin { get; set; }
|
||||||
|
/// <summary>PawnIO 驱动是否已安装(ring0 传感器读取依赖它或 WinRing0,缺失时温度/频率通常无法读取)</summary>
|
||||||
|
public bool PawnIoInstalled { get; set; }
|
||||||
public double UptimeMs { get; set; }
|
public double UptimeMs { get; set; }
|
||||||
public int GroupCount { get; set; }
|
public int GroupCount { get; set; }
|
||||||
public int SensorCount { get; set; }
|
public int SensorCount { get; set; }
|
||||||
|
|||||||
@@ -277,7 +277,7 @@ internal sealed class HardwareManager : IDisposable
|
|||||||
_ => "",
|
_ => "",
|
||||||
};
|
};
|
||||||
|
|
||||||
private static bool IsRunningAsAdmin()
|
internal static bool IsRunningAsAdmin()
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ internal static class HttpEndpoints
|
|||||||
{
|
{
|
||||||
Ready = hw?.Ready ?? false,
|
Ready = hw?.Ready ?? false,
|
||||||
IsAdmin = hw?.IsAdmin ?? false,
|
IsAdmin = hw?.IsAdmin ?? false,
|
||||||
|
PawnIoInstalled = PawnIoSupport.IsServiceInstalled(),
|
||||||
UptimeMs = kernel.Uptime.Elapsed.TotalMilliseconds,
|
UptimeMs = kernel.Uptime.Elapsed.TotalMilliseconds,
|
||||||
GroupCount = snap?.Groups.Count ?? 0,
|
GroupCount = snap?.Groups.Count ?? 0,
|
||||||
SensorCount = kernel.Scheduler.Cache.SensorCount,
|
SensorCount = kernel.Scheduler.Cache.SensorCount,
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
|
using Microsoft.Win32;
|
||||||
|
|
||||||
|
namespace ThingHK;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PawnIO 驱动支持:检测 + 静默安装。
|
||||||
|
///
|
||||||
|
/// 背景:LHM 读取 CPU 温度/频率等 ring0 数据依赖内核驱动,回退用的 WinRing0 被
|
||||||
|
/// 微软"易受攻击的驱动程序阻止列表"和部分杀软(如火绒)拦截,导致传感器缺失。
|
||||||
|
/// PawnIO 是正规签名的替代驱动(不在阻止列表、兼容 HVCI/安全启动),
|
||||||
|
/// LHM 0.9.5+ 检测到已安装时优先使用,无需任何代码开关。
|
||||||
|
///
|
||||||
|
/// 安装器约定:PawnIO_setup.exe 与 ThingHK.exe 同目录
|
||||||
|
/// (由 Tauri 侧 prepare_kernel 从资源目录随内核一起复制到 {app_data}/monitor/cores/)。
|
||||||
|
///
|
||||||
|
/// 静默参数:-install -silent(官方 CLI 参数,见 namazso/PawnIO.Setup)。
|
||||||
|
/// 退出码:0=成功;3010=成功但需重启(ERROR_SUCCESS_REBOOT_REQUIRED)。
|
||||||
|
///
|
||||||
|
/// 策略:仅在内核已提权时安装。两种提权模式(Thing 提权继承 / 仅提权 ThingHK)
|
||||||
|
/// 都只有一次 UAC,内核拿到权限后自行静默安装,避免二次弹窗。
|
||||||
|
/// serve 模式调用;scan 诊断模式不安装,保持被动。
|
||||||
|
/// </summary>
|
||||||
|
internal static class PawnIoSupport
|
||||||
|
{
|
||||||
|
/// <summary>驱动服务注册表键:存在即认为已安装</summary>
|
||||||
|
private const string ServiceKeyName = @"SYSTEM\CurrentControlSet\Services\PawnIO";
|
||||||
|
|
||||||
|
private const string SetupFileName = "PawnIO_setup.exe";
|
||||||
|
|
||||||
|
/// <summary>3010 = ERROR_SUCCESS_REBOOT_REQUIRED(安装成功但需重启生效)</summary>
|
||||||
|
private const int ExitCodeRebootRequired = 3010;
|
||||||
|
|
||||||
|
/// <summary>驱动安装通常数秒内完成,留足余量防止卡死启动流程</summary>
|
||||||
|
private const int InstallTimeoutMs = 90_000;
|
||||||
|
|
||||||
|
/// <summary>检测 PawnIO 驱动服务是否已注册</summary>
|
||||||
|
public static bool IsServiceInstalled()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var key = Registry.LocalMachine.OpenSubKey(ServiceKeyName);
|
||||||
|
return key != null;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 确保 PawnIO 就绪:已安装直接返回;未安装且当前已提权时静默安装。
|
||||||
|
/// 返回描述性结果(写入 stderr 日志 + /status 诊断)。
|
||||||
|
/// </summary>
|
||||||
|
public static string EnsureInstalled()
|
||||||
|
{
|
||||||
|
if (IsServiceInstalled())
|
||||||
|
return "already-installed";
|
||||||
|
|
||||||
|
if (!HardwareManager.IsRunningAsAdmin())
|
||||||
|
return "skipped: not elevated (温度/频率等传感器需要提权运行)";
|
||||||
|
|
||||||
|
string setupPath = Path.Combine(AppContext.BaseDirectory, SetupFileName);
|
||||||
|
if (!File.Exists(setupPath))
|
||||||
|
return $"skipped: {SetupFileName} 未找到(应随内核一起部署,见 prepare_kernel)";
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var process = Process.Start(new ProcessStartInfo
|
||||||
|
{
|
||||||
|
FileName = setupPath,
|
||||||
|
Arguments = "-install -silent",
|
||||||
|
UseShellExecute = false,
|
||||||
|
CreateNoWindow = true,
|
||||||
|
});
|
||||||
|
if (process == null)
|
||||||
|
return "failed: Process.Start 返回 null";
|
||||||
|
|
||||||
|
if (!process.WaitForExit(InstallTimeoutMs))
|
||||||
|
{
|
||||||
|
try { process.Kill(); } catch { /* 超时后进程可能已自行退出 */ }
|
||||||
|
return "failed: 安装超时";
|
||||||
|
}
|
||||||
|
|
||||||
|
int code = process.ExitCode;
|
||||||
|
if (code == ExitCodeRebootRequired)
|
||||||
|
return "installed: 需重启后生效";
|
||||||
|
|
||||||
|
if (code != 0)
|
||||||
|
return $"failed: 安装器退出码 {code}";
|
||||||
|
|
||||||
|
return IsServiceInstalled()
|
||||||
|
? "installed"
|
||||||
|
: "failed: 安装器返回 0 但服务未注册";
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return $"failed: {ex.Message}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -87,6 +87,11 @@ internal static class Program
|
|||||||
|
|
||||||
Console.Error.WriteLine($"[ThingHK] serve 模式: port={port} config={configPath ?? "(默认)"} fast={fastMs}ms slow={slowMs}ms");
|
Console.Error.WriteLine($"[ThingHK] serve 模式: port={port} config={configPath ?? "(默认)"} fast={fastMs}ms slow={slowMs}ms");
|
||||||
|
|
||||||
|
// PawnIO:ring0 传感器读取的首选驱动(未安装且已提权时静默安装,
|
||||||
|
// 避开 WinRing0 被系统阻止列表/杀软拦截导致的温度/频率缺失)
|
||||||
|
string pawnIoResult = PawnIoSupport.EnsureInstalled();
|
||||||
|
Console.Error.WriteLine($"[ThingHK] PawnIO: {pawnIoResult}");
|
||||||
|
|
||||||
using var kernel = new KernelHost();
|
using var kernel = new KernelHost();
|
||||||
await kernel.StartAsync(configPath, fastMs, slowMs);
|
await kernel.StartAsync(configPath, fastMs, slowMs);
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,7 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="LibreHardwareMonitorLib" Version="0.9.7-pre716" />
|
<PackageReference Include="LibreHardwareMonitorLib" Version="0.9.7-pre729" />
|
||||||
<PackageReference Include="System.CommandLine" Version="2.0.0-beta4.22272.1" />
|
<PackageReference Include="System.CommandLine" Version="2.0.0-beta4.22272.1" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "thing",
|
"name": "thing",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "26.8.2",
|
"version": "26.8.4",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
Generated
+1355
-24
File diff suppressed because it is too large
Load Diff
+16
-2
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "thing"
|
name = "thing"
|
||||||
version = "26.8.2"
|
version = "26.8.4"
|
||||||
description = "A Tauri App"
|
description = "A Tauri App"
|
||||||
authors = ["you"]
|
authors = ["you"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
@@ -45,6 +45,8 @@ base64 = "0.22"
|
|||||||
image = { version = "0.25", default-features = false, features = ["png"] }
|
image = { version = "0.25", default-features = false, features = ["png"] }
|
||||||
walkdir = "2"
|
walkdir = "2"
|
||||||
notify = { version = "6", features = [] }
|
notify = { version = "6", features = [] }
|
||||||
|
librqbit = "9"
|
||||||
|
bytes = "1"
|
||||||
|
|
||||||
[target.'cfg(windows)'.dependencies]
|
[target.'cfg(windows)'.dependencies]
|
||||||
winreg = "0.52"
|
winreg = "0.52"
|
||||||
@@ -67,7 +69,7 @@ windows-sys = { version = "0.52", features = [
|
|||||||
"Win32_Storage_Xps",
|
"Win32_Storage_Xps",
|
||||||
"Win32_Storage_FileSystem",
|
"Win32_Storage_FileSystem",
|
||||||
] }
|
] }
|
||||||
# Explorer 蜑榊床逶ョ蠖墓」豬具シ・ShellWindows COM・会シ壻サ・シ募・逕ィ蛻ー逧・feature・梧而蛻カ郛冶ッ台ス鍋ァッ
|
# Explorer 鬯ョ・ッ繝サ・キ鬮ォ・ィ繝サ・ャ郢晢スサ繝サ・ヲ鬯ョ・ョ郢晢スサ繝サ・ス繝サ・コ髣費スィ郢晢スサ・つ郢晢スサ繝サ・カ驛「譎「・ス・サ郢晢スサ繝サ・ョ鬯ョ・ッ雋翫・譚溽ケ晢スサ繝サ・「鬯ョ・ョ繝サ・」郢晢スサ繝サ・ス郢晢スサ繝サ・」郢晢スサ邵コ・、・つ鬯ョ・ョ髮懶ス」繝サ・ス繝サ・ャ鬮ッ・キ髣鯉スィ繝サ・ス繝サ・キ驛「譎「・ス・サ郢晢スサ繝サ・シ鬩幢ス「隴趣ス「繝サ・ス繝サ・サShellWindows COM鬩幢ス「隴趣ス「繝サ・ス繝サ・サ鬮」雋サ・ス・ィ髯樊サゑスス・イ郢晢スサ繝サ・ス郢晢スサ繝サ・シ鬮ッ讖ク・ス・「郢晢スサ繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・サ鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・シ鬮ッ・キ髢ァ・エ繝サ・コ陋滂ス・郢晢スサ鬯ッ・ィ繝サ・セ髯具スケ郢晢スサ繝サ・ス繝サ・ス郢晢スサ繝サ・ィ鬯ョ・ッ陷茨スキ繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・ー鬯ッ・ィ繝サ・セ郢晢スサ繝サ・ァ鬩幢ス「隴趣ス「繝サ・ス繝サ・サfeature鬩幢ス「隴趣ス「繝サ・ス繝サ・サ鬮ォ・エ繝サ・エ郢晢スサ繝サ・ァ鬯ョ・「繝サ・ー髴托スケ陞「・シ繝サ・エ髴域鱒繝サ郢晢スサ繝サ・カ鬯ッ・ゥ陝カ蟷「・ス・ク陝カ蜷カ繝サ驛「譎「・ス・サ郢晢スサ繝サ・ッ鬮ッ・キ繝サ・ソ郢晢スサ繝サ・ー驛「譎「・ス・サ郢晢スサ繝サ・ス鬯ッ・ェ繝サ・ー髯キ闌ィ・ス・キ郢晢スサ繝サ・ス郢晢スサ繝サ・ァ驛「譎「・ス・サ郢晢スサ繝サ・ッ
|
||||||
windows = { version = "0.52", features = [
|
windows = { version = "0.52", features = [
|
||||||
"Win32_Foundation",
|
"Win32_Foundation",
|
||||||
"Win32_System_Com",
|
"Win32_System_Com",
|
||||||
@@ -80,3 +82,15 @@ windows = { version = "0.52", features = [
|
|||||||
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
|
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
|
||||||
tauri-plugin-autostart = "2"
|
tauri-plugin-autostart = "2"
|
||||||
|
|
||||||
|
# ===== 驛帛・・ッ蜿ー・シ莨懷密 =====
|
||||||
|
# dev 隴ォ繝サ・サ・コ陷キ・ッ騾包スィ陟・ィ」纃シ驛帛・・ッ繝サ+ 髯ヲ謔滓差驛、・ァ髫ケ繝サ・ッ蛹・スシ蝓溽スイ鬨セ貊捺た陜ィ・ー髴托スュ闔会ス」繝サ莨夲スシ蠕。・サ繝サ・ス・ア陷ゥ繝サ`tauri dev`
|
||||||
|
|
||||||
|
[profile.dev]
|
||||||
|
incremental = true
|
||||||
|
debug = "line-tables-only"
|
||||||
|
|
||||||
|
# release 髫エ・ォ郢晢スサ繝サ・サ繝サ・コ鬨セ蜴・スス・ヲ鬮エ繝サ・ス・ォ郢晢スサ陞「・シ隰碑崟蝨キ繝サ・ヲ髯キ・ソ繝サ・キ + LTO郢晢スサ隰疲コ倩ゥ宣劑繝サ・ク讖ク・ス・ョ髣・スス繝サ・」郢晢スサ隴ッ竏ャ謚・ェー蜈キ・ス・ァ繝サ・ッ
|
||||||
|
[profile.release]
|
||||||
|
strip = true
|
||||||
|
lto = true
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -44,6 +44,8 @@ pub mod events {
|
|||||||
// OSD 窗口
|
// OSD 窗口
|
||||||
pub const OSD_SYSTEM_UI_ACTIVE: &str = "osd-system-ui-active";
|
pub const OSD_SYSTEM_UI_ACTIVE: &str = "osd-system-ui-active";
|
||||||
pub const OSD_SYSTEM_UI_INACTIVE: &str = "osd-system-ui-inactive";
|
pub const OSD_SYSTEM_UI_INACTIVE: &str = "osd-system-ui-inactive";
|
||||||
|
pub const OSD_GAME_ACTIVE: &str = "osd-game-active";
|
||||||
|
pub const OSD_GAME_INACTIVE: &str = "osd-game-inactive";
|
||||||
pub const OSD_START_DRAG: &str = "osd-start-drag";
|
pub const OSD_START_DRAG: &str = "osd-start-drag";
|
||||||
pub const OSD_END_DRAG: &str = "osd-end-drag";
|
pub const OSD_END_DRAG: &str = "osd-end-drag";
|
||||||
// 截图
|
// 截图
|
||||||
@@ -60,6 +62,6 @@ pub mod events {
|
|||||||
pub const DOWNLOAD_ADDED: &str = "download-added";
|
pub const DOWNLOAD_ADDED: &str = "download-added";
|
||||||
/// 任务被删除(浏览器扩展通过 HTTP API 删除任务时发出,前端据此刷新任务列表)
|
/// 任务被删除(浏览器扩展通过 HTTP API 删除任务时发出,前端据此刷新任务列表)
|
||||||
pub const DOWNLOAD_REMOVED: &str = "download-removed";
|
pub const DOWNLOAD_REMOVED: &str = "download-removed";
|
||||||
/// 浏览器扩展通过 HTTP API 新增下载(前端需置前主窗口并跳到下载画面)
|
/// 浏览器扩展通过 HTTP API 新增下载(负载 { id },前端据以为该任务创建专属下载窗口)
|
||||||
pub const DOWNLOAD_EXTENSION_ADDED: &str = "download-extension-added";
|
pub const DOWNLOAD_EXTENSION_ADDED: &str = "download-extension-added";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
use tauri::{AppHandle, State};
|
use tauri::{AppHandle, Manager, State};
|
||||||
use tauri_plugin_opener::OpenerExt;
|
use tauri_plugin_opener::OpenerExt;
|
||||||
|
|
||||||
use super::engine::{CheckUrlResult, DownloadEngine};
|
use super::engine::{CheckUrlResult, DownloadEngine};
|
||||||
use super::task::{DownloadTask, DownloaderSettings};
|
use super::task::{DownloadTask, DownloaderSettings};
|
||||||
|
use super::torrent::TorrentInfo;
|
||||||
|
|
||||||
/// 获取所有任务
|
/// 获取所有任务
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@@ -13,6 +14,20 @@ pub fn downloader_get_tasks(engine: State<'_, DownloadEngine>) -> Vec<DownloadTa
|
|||||||
engine.get_tasks()
|
engine.get_tasks()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 解析磁力链 / .torrent 文件,返回种子信息(名称 / infohash / 文件列表),供前端做文件勾选
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn downloader_inspect(engine: State<'_, DownloadEngine>, input: String) -> Result<TorrentInfo, String> {
|
||||||
|
engine.inspect(&input).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 磁力任务:元数据解析成功后,用户勾选文件并确认开始下载
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn downloader_select_bt_files(engine: State<'_, DownloadEngine>, id: String, only_files: Vec<u32>) -> Result<(), String> {
|
||||||
|
engine.select_bt_files(&id, only_files).await
|
||||||
|
}
|
||||||
|
|
||||||
/// 检查 URL 重复性并探测文件信息(添加下载前调用)
|
/// 检查 URL 重复性并探测文件信息(添加下载前调用)
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
#[specta::specta]
|
#[specta::specta]
|
||||||
@@ -57,8 +72,9 @@ pub async fn downloader_add_task(
|
|||||||
dir: Option<String>,
|
dir: Option<String>,
|
||||||
headers: Option<HashMap<String, String>>,
|
headers: Option<HashMap<String, String>>,
|
||||||
auto_rename: Option<bool>,
|
auto_rename: Option<bool>,
|
||||||
|
only_files: Option<Vec<u32>>,
|
||||||
) -> Result<String, String> {
|
) -> Result<String, String> {
|
||||||
engine.add_task(url, filename, dir, headers.unwrap_or_default(), auto_rename.unwrap_or(false)).await
|
engine.add_task(url, filename, dir, headers.unwrap_or_default(), auto_rename.unwrap_or(false), only_files).await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 暂停任务
|
/// 暂停任务
|
||||||
@@ -75,6 +91,20 @@ pub fn downloader_resume_task(engine: State<'_, DownloadEngine>, id: String) ->
|
|||||||
engine.resume_task(&id)
|
engine.resume_task(&id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 取消任务(置为已取消,清空进度并删除下载文件,但保留记录)
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub fn downloader_cancel_task(engine: State<'_, DownloadEngine>, id: String) -> Result<(), String> {
|
||||||
|
engine.cancel_task(&id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 重新下载已取消/出错的任务
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn downloader_redownload(engine: State<'_, DownloadEngine>, id: String) -> Result<(), String> {
|
||||||
|
engine.redownload(&id).await
|
||||||
|
}
|
||||||
|
|
||||||
/// 移除任务
|
/// 移除任务
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
#[specta::specta]
|
#[specta::specta]
|
||||||
@@ -141,3 +171,24 @@ pub fn downloader_open_url(app: AppHandle, url: String) -> Result<(), String> {
|
|||||||
.open_url(url, None::<&str>)
|
.open_url(url, None::<&str>)
|
||||||
.map_err(|e| format!("打开链接失败: {}", e))
|
.map_err(|e| format!("打开链接失败: {}", e))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 将指定 label 的下载窗口显示并强制置为前台。
|
||||||
|
/// Tauri 的 set_focus 在 Windows 上受前台锁定限制(尤其下载窗口由后台进程创建、
|
||||||
|
/// 或创建到非主显示器时更明显),改用原生 SetForegroundWindow + BringWindowToTop
|
||||||
|
/// (模拟 Alt 键重置前台锁定),保证开始/完成下载时窗口能正确定位到前台。
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub fn downloader_focus_window(app: AppHandle, label: String) -> Result<(), String> {
|
||||||
|
let Some(window) = app.get_webview_window(&label) else {
|
||||||
|
return Ok(()); // 窗口已关闭则忽略
|
||||||
|
};
|
||||||
|
window.show().map_err(|e| e.to_string())?;
|
||||||
|
window.unminimize().map_err(|e| e.to_string())?;
|
||||||
|
match window.hwnd() {
|
||||||
|
Ok(hwnd) => crate::win32_util::force_foreground(hwnd.0 as isize),
|
||||||
|
Err(_) => {
|
||||||
|
window.set_focus().ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -53,14 +53,34 @@ impl HttpDownloader {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 探测下载资源信息(大小、是否支持 Range、文件名)
|
/// 探测下载资源信息(大小、是否支持 Range、文件名)
|
||||||
/// 优先用 GET + Range: bytes=0-0(返回 206 + Content-Range),回退到 HEAD
|
/// 优先用 GET + Range: bytes=0-0(返回 206 + Content-Range),回退到 HEAD。
|
||||||
|
/// 代理降级:use_proxy=true 时先走系统代理,失败则回退 no_proxy 直连重试一次
|
||||||
pub async fn probe(
|
pub async fn probe(
|
||||||
&self,
|
&self,
|
||||||
url: &str,
|
url: &str,
|
||||||
headers: &HashMap<String, String>,
|
headers: &HashMap<String, String>,
|
||||||
use_proxy: bool,
|
use_proxy: bool,
|
||||||
) -> Result<ProbeResult, String> {
|
) -> Result<ProbeResult, String> {
|
||||||
let client = self.client(use_proxy);
|
let first = self.client(use_proxy);
|
||||||
|
match self.probe_with_client(first, url, headers).await {
|
||||||
|
Ok(r) => return Ok(r),
|
||||||
|
Err(e) if use_proxy => {
|
||||||
|
let direct = self.client(false);
|
||||||
|
self.probe_with_client(direct, url, headers)
|
||||||
|
.await
|
||||||
|
.map_err(|e2| format!("代理探测失败({}),直连重试也失败({})", e, e2))
|
||||||
|
}
|
||||||
|
Err(e) => Err(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 用指定 client 执行探测(GET Range → 回退 HEAD),供代理降级复用
|
||||||
|
async fn probe_with_client(
|
||||||
|
&self,
|
||||||
|
client: &Client,
|
||||||
|
url: &str,
|
||||||
|
headers: &HashMap<String, String>,
|
||||||
|
) -> Result<ProbeResult, String> {
|
||||||
// 先尝试 Range 请求(能同时判断 Accept-Ranges 和获取大小)
|
// 先尝试 Range 请求(能同时判断 Accept-Ranges 和获取大小)
|
||||||
let mut req = client
|
let mut req = client
|
||||||
.get(url)
|
.get(url)
|
||||||
@@ -165,7 +185,36 @@ impl HttpDownloader {
|
|||||||
limiter: Arc<RateLimiter>,
|
limiter: Arc<RateLimiter>,
|
||||||
use_proxy: bool,
|
use_proxy: bool,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let client = self.client(use_proxy);
|
// 代理降级:仅当 use_proxy=true 才有"走代理→失败回退直连"的意义。
|
||||||
|
// use_proxy=false 直接用直连客户端,无需回退。
|
||||||
|
// 注意:用户主动暂停/取消(返回"已取消")必须原样透传,不能触发代理回退,
|
||||||
|
// 否则会把"已取消"包装成"代理失败",导致引擎将其误判为错误而非暂停。
|
||||||
|
let first = self.client(use_proxy);
|
||||||
|
match self.download_with_client(first, url, headers, segments, file_path, cancel.clone(), progress, &limiter).await {
|
||||||
|
Ok(()) => return Ok(()),
|
||||||
|
Err(e) if use_proxy && e != "已取消" => {
|
||||||
|
// 回退直连重试(不继承 use_proxy,保证用 no_proxy 客户端)
|
||||||
|
let direct = self.client(false);
|
||||||
|
self.download_with_client(direct, url, headers, segments, file_path, cancel, progress, &limiter)
|
||||||
|
.await
|
||||||
|
.map_err(|e2| format!("代理下载失败({}),直连重试也失败({})", e, e2))
|
||||||
|
}
|
||||||
|
Err(e) => Err(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 用指定 client 执行下载(支持单线程与多线程分段),供代理降级复用
|
||||||
|
async fn download_with_client(
|
||||||
|
&self,
|
||||||
|
client: &Client,
|
||||||
|
url: &str,
|
||||||
|
headers: &HashMap<String, String>,
|
||||||
|
segments: &[Segment],
|
||||||
|
file_path: &Path,
|
||||||
|
cancel: Arc<AtomicBool>,
|
||||||
|
progress: &[Arc<AtomicU64>],
|
||||||
|
limiter: &Arc<RateLimiter>,
|
||||||
|
) -> Result<(), String> {
|
||||||
let total_size = segments.iter().map(|s| s.len()).sum();
|
let total_size = segments.iter().map(|s| s.len()).sum();
|
||||||
|
|
||||||
// 预分配文件(若已知大小)
|
// 预分配文件(若已知大小)
|
||||||
@@ -191,7 +240,7 @@ impl HttpDownloader {
|
|||||||
// 单线程下载(不支持 Range 或文件太小)
|
// 单线程下载(不支持 Range 或文件太小)
|
||||||
let seg = &segments[0];
|
let seg = &segments[0];
|
||||||
let prog = &progress[0];
|
let prog = &progress[0];
|
||||||
self.download_segment(url, headers, seg, file_path, cancel.clone(), prog.clone(), limiter, client)
|
self.download_segment(url, headers, seg, file_path, cancel.clone(), prog.clone(), limiter.clone(), client)
|
||||||
.await?;
|
.await?;
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,10 +5,11 @@ pub mod rate_limit;
|
|||||||
pub mod server;
|
pub mod server;
|
||||||
pub mod storage;
|
pub mod storage;
|
||||||
pub mod task;
|
pub mod task;
|
||||||
|
pub mod torrent;
|
||||||
|
|
||||||
pub use commands::{
|
pub use commands::{
|
||||||
downloader_add_task, downloader_check_url, downloader_get_extension_info, downloader_get_settings, downloader_get_tasks,
|
downloader_add_task, downloader_cancel_task, downloader_check_url, downloader_focus_window, downloader_get_extension_info, downloader_get_settings, downloader_get_tasks, downloader_inspect, downloader_select_bt_files,
|
||||||
downloader_open_dir, downloader_open_url, downloader_pause_task, downloader_remove_task,
|
downloader_open_dir, downloader_open_url, downloader_pause_task, downloader_redownload, downloader_remove_task,
|
||||||
downloader_resume_task, downloader_save_settings, downloader_status,
|
downloader_resume_task, downloader_save_settings, downloader_status,
|
||||||
};
|
};
|
||||||
pub use engine::DownloadEngine;
|
pub use engine::DownloadEngine;
|
||||||
|
|||||||
@@ -122,13 +122,14 @@ async fn create_download(
|
|||||||
return Ok(Json(CreateDownloadResponse { id: existing.id }));
|
return Ok(Json(CreateDownloadResponse { id: existing.id }));
|
||||||
}
|
}
|
||||||
|
|
||||||
match state.engine.add_task(req.url, req.filename, req.dir, req.headers, true).await {
|
match state.engine.add_task(req.url, req.filename, req.dir, req.headers, true, None).await {
|
||||||
Ok(id) => {
|
Ok(id) => {
|
||||||
// 浏览器扩展发起下载:置前主窗口并通知前端跳到下载画面(替代原桌面通知)
|
// 浏览器扩展发起下载:不再置前主窗口,改为带 task id 通知前端,
|
||||||
crate::tray_menu::focus_main_window(&state.app_handle);
|
// 由前端为该任务创建一个专属的一次性下载窗口(不打断主界面)
|
||||||
let _ = state
|
let _ = state.app_handle.emit(
|
||||||
.app_handle
|
crate::constants::events::DOWNLOAD_EXTENSION_ADDED,
|
||||||
.emit(crate::constants::events::DOWNLOAD_EXTENSION_ADDED, ());
|
serde_json::json!({ "id": id }),
|
||||||
|
);
|
||||||
Ok(Json(CreateDownloadResponse { id }))
|
Ok(Json(CreateDownloadResponse { id }))
|
||||||
}
|
}
|
||||||
Err(e) => Err((StatusCode::BAD_REQUEST, Json(ErrorResponse { error: e }))),
|
Err(e) => Err((StatusCode::BAD_REQUEST, Json(ErrorResponse { error: e }))),
|
||||||
|
|||||||
@@ -2,6 +2,17 @@ use serde::{Deserialize, Serialize};
|
|||||||
use specta::Type;
|
use specta::Type;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
/// 任务下载协议类型
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Type, Default)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum TaskProtocol {
|
||||||
|
/// HTTP/HTTPS 直链
|
||||||
|
#[default]
|
||||||
|
Http,
|
||||||
|
/// BitTorrent(磁力链 / .torrent 文件)
|
||||||
|
BitTorrent,
|
||||||
|
}
|
||||||
|
|
||||||
/// 任务状态
|
/// 任务状态
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Type)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Type)]
|
||||||
#[serde(rename_all = "lowercase")]
|
#[serde(rename_all = "lowercase")]
|
||||||
@@ -16,6 +27,8 @@ pub enum TaskStatus {
|
|||||||
Complete,
|
Complete,
|
||||||
/// 错误
|
/// 错误
|
||||||
Error,
|
Error,
|
||||||
|
/// 已取消(用户取消:进度与文件已清除,仅保留记录,只能再次下载)
|
||||||
|
Cancelled,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 下载分段(多线程 Range 下载 / 断点续传用)
|
/// 下载分段(多线程 Range 下载 / 断点续传用)
|
||||||
@@ -52,18 +65,42 @@ impl Segment {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// BT 种子内文件条目(多文件任务用;阶段1下载全部文件,但保留列表供 UI 展示)
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct BtFileInfo {
|
||||||
|
/// 文件在种子内的索引
|
||||||
|
pub index: u32,
|
||||||
|
/// 相对种子根目录的路径(如 "sub/file.mkv")
|
||||||
|
pub path: String,
|
||||||
|
/// 文件大小(字节)
|
||||||
|
pub size: u64,
|
||||||
|
}
|
||||||
|
|
||||||
/// 下载任务
|
/// 下载任务
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
|
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct DownloadTask {
|
pub struct DownloadTask {
|
||||||
/// 任务 ID(自增 hex 字符串)
|
/// 任务 ID(自增 hex 字符串)
|
||||||
pub id: String,
|
pub id: String,
|
||||||
/// 下载地址
|
/// 下载地址(HTTP URL 或磁力链接)
|
||||||
pub url: String,
|
pub url: String,
|
||||||
/// 文件名
|
/// 文件名(HTTP:目标文件名;BT:种子名称)
|
||||||
pub filename: String,
|
pub filename: String,
|
||||||
/// 保存目录(绝对路径)
|
/// 保存目录(绝对路径)
|
||||||
pub dir: String,
|
pub dir: String,
|
||||||
|
/// 协议类型
|
||||||
|
#[serde(default)]
|
||||||
|
pub protocol: TaskProtocol,
|
||||||
|
/// BT 种子 infohash(协议=BitTorrent 时存在)
|
||||||
|
#[serde(default)]
|
||||||
|
pub info_hash: Option<String>,
|
||||||
|
/// BT 种子内文件列表(协议=BitTorrent 时存在)
|
||||||
|
#[serde(default)]
|
||||||
|
pub bt_files: Vec<BtFileInfo>,
|
||||||
|
/// BT 元数据是否已解析就绪(异步添加时:后台解析完成前为 false,调度器跳过)
|
||||||
|
#[serde(default)]
|
||||||
|
pub bt_metadata_ready: bool,
|
||||||
/// 状态
|
/// 状态
|
||||||
pub status: TaskStatus,
|
pub status: TaskStatus,
|
||||||
/// 文件总大小(字节),0=未知
|
/// 文件总大小(字节),0=未知
|
||||||
@@ -141,6 +178,18 @@ pub struct DownloaderSettings {
|
|||||||
/// 下载是否使用代理:true=尊重系统代理(mihomo 开启系统代理时经其转发),false=强制直连
|
/// 下载是否使用代理:true=尊重系统代理(mihomo 开启系统代理时经其转发),false=强制直连
|
||||||
#[serde(default = "default_true")]
|
#[serde(default = "default_true")]
|
||||||
pub use_proxy: bool,
|
pub use_proxy: bool,
|
||||||
|
/// BitTorrent 上传限速 KB/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 {
|
fn default_max_concurrent() -> u32 {
|
||||||
@@ -180,6 +229,10 @@ impl Default for DownloaderSettings {
|
|||||||
delete_files_on_remove: false,
|
delete_files_on_remove: false,
|
||||||
check_duplicate: true,
|
check_duplicate: true,
|
||||||
use_proxy: true,
|
use_proxy: true,
|
||||||
|
bt_upload_limit_kb: 0,
|
||||||
|
bt_seed_after_download: false,
|
||||||
|
bt_listen_port: 0,
|
||||||
|
bt_use_proxy: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,477 @@
|
|||||||
|
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))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 继续种子(幂等):恢复已暂停的种子。
|
||||||
|
/// 若种子本已处于 Live(例如 select_bt_files 的 set_only_files 内部已恢复过、
|
||||||
|
/// 或暂停期间被其他逻辑恢复),直接视为成功,避免 librqbit 报 "torrent is already live"
|
||||||
|
/// 而导致 pause→resume 链路被误判为失败。
|
||||||
|
pub async fn unpause(&self, handle: &Arc<ManagedTorrent>) -> Result<(), String> {
|
||||||
|
let session = self.get_session().await?;
|
||||||
|
match handle.stats().state {
|
||||||
|
librqbit::TorrentStatsState::Live => Ok(()),
|
||||||
|
librqbit::TorrentStatsState::Error => Err("种子处于错误状态,无法继续".to_string()),
|
||||||
|
librqbit::TorrentStatsState::Paused
|
||||||
|
| librqbit::TorrentStatsState::Initializing { .. } => 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()
|
||||||
|
}
|
||||||
+26
-11
@@ -20,8 +20,8 @@ mod win32_util;
|
|||||||
|
|
||||||
use download_engine::{
|
use download_engine::{
|
||||||
DownloadEngine,
|
DownloadEngine,
|
||||||
downloader_add_task, downloader_check_url, downloader_get_extension_info, downloader_get_settings, downloader_get_tasks,
|
downloader_add_task, downloader_cancel_task, downloader_check_url, downloader_focus_window, downloader_get_extension_info, downloader_get_settings, downloader_get_tasks, downloader_inspect, downloader_select_bt_files,
|
||||||
downloader_open_dir, downloader_open_url, downloader_pause_task, downloader_remove_task,
|
downloader_open_dir, downloader_open_url, downloader_pause_task, downloader_redownload, downloader_remove_task,
|
||||||
downloader_resume_task, downloader_save_settings, downloader_status,
|
downloader_resume_task, downloader_save_settings, downloader_status,
|
||||||
};
|
};
|
||||||
use logger::{
|
use logger::{
|
||||||
@@ -31,7 +31,7 @@ use mihomo_manager::{
|
|||||||
proxy_activate_profile, proxy_apply_kernel_update, proxy_cancel_kernel_install, proxy_check_kernel_update, proxy_clear_system_proxy,
|
proxy_activate_profile, proxy_apply_kernel_update, proxy_cancel_kernel_install, proxy_check_kernel_update, proxy_clear_system_proxy,
|
||||||
proxy_close_connection, proxy_confirm_install, proxy_delete_profile, proxy_get_connections, proxy_get_proxies, proxy_get_settings, proxy_get_system_proxy,
|
proxy_close_connection, proxy_confirm_install, proxy_delete_profile, proxy_get_connections, proxy_get_proxies, proxy_get_settings, proxy_get_system_proxy,
|
||||||
proxy_import_profile, proxy_kernel_info, proxy_patch_configs, proxy_restart, proxy_save_settings,
|
proxy_import_profile, proxy_kernel_info, proxy_patch_configs, proxy_restart, proxy_save_settings,
|
||||||
proxy_select_proxy, proxy_set_system_proxy, proxy_start, proxy_status, proxy_stop,
|
proxy_select_proxy, proxy_set_system_proxy, proxy_start, proxy_status, proxy_stop, proxy_traffic,
|
||||||
proxy_test_delay, proxy_update_profile, proxy_version, MihomoManager,
|
proxy_test_delay, proxy_update_profile, proxy_version, MihomoManager,
|
||||||
};
|
};
|
||||||
use monitor_kernel::{
|
use monitor_kernel::{
|
||||||
@@ -43,7 +43,8 @@ use monitor_kernel::{
|
|||||||
use network_monitor::network_status;
|
use network_monitor::network_status;
|
||||||
use osd_window::{
|
use osd_window::{
|
||||||
osd_apply_overlay_style, osd_begin_drag, osd_set_bounds, osd_set_click_through,
|
osd_apply_overlay_style, osd_begin_drag, osd_set_bounds, osd_set_click_through,
|
||||||
osd_set_topmost, osd_start_drag_watch, osd_start_topmost_watch, osd_stop_watch,
|
osd_set_topmost, osd_start_drag_watch, osd_start_game_watch, osd_start_topmost_watch,
|
||||||
|
osd_stop_watch,
|
||||||
};
|
};
|
||||||
use process_manager::{
|
use process_manager::{
|
||||||
process_all_status, process_start, process_status,
|
process_all_status, process_start, process_status,
|
||||||
@@ -84,7 +85,10 @@ use quickpanel::{
|
|||||||
quickpanel_show_window, quickpanel_unregister_shortcut, quickpanel_focus_main_window,
|
quickpanel_show_window, quickpanel_unregister_shortcut, quickpanel_focus_main_window,
|
||||||
};
|
};
|
||||||
use tray_menu::{tray_menu_action, tray_menu_hide, tray_menu_ready};
|
use tray_menu::{tray_menu_action, tray_menu_hide, tray_menu_ready};
|
||||||
use updater::{app_version, update_check, update_install, update_thinghk};
|
use updater::{
|
||||||
|
app_version, update_check, update_install, update_thinghk_apply, update_thinghk_cancel,
|
||||||
|
update_thinghk_confirm, ThinghkUpdateState,
|
||||||
|
};
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
fn quit_app(app: tauri::AppHandle) {
|
fn quit_app(app: tauri::AppHandle) {
|
||||||
@@ -109,14 +113,15 @@ fn export_bindings() {
|
|||||||
// 生成命令失败时直接 throw,与原生 invoke 一致,前端无需解包 helper
|
// 生成命令失败时直接 throw,与原生 invoke 一致,前端无需解包 helper
|
||||||
.error_handling(ErrorHandlingMode::Throw)
|
.error_handling(ErrorHandlingMode::Throw)
|
||||||
.commands(collect_commands![
|
.commands(collect_commands![
|
||||||
// 应用更新(4)
|
// 应用更新(6)
|
||||||
app_version, update_check, update_install, update_thinghk,
|
app_version, update_check, update_install, update_thinghk_apply,
|
||||||
|
update_thinghk_confirm, update_thinghk_cancel,
|
||||||
// proxy(20)
|
// proxy(20)
|
||||||
proxy_activate_profile, proxy_apply_kernel_update, proxy_cancel_kernel_install, proxy_check_kernel_update, proxy_clear_system_proxy,
|
proxy_activate_profile, proxy_apply_kernel_update, proxy_cancel_kernel_install, proxy_check_kernel_update, proxy_clear_system_proxy,
|
||||||
proxy_close_connection, proxy_confirm_install, proxy_delete_profile, proxy_get_settings,
|
proxy_close_connection, proxy_confirm_install, proxy_delete_profile, proxy_get_settings,
|
||||||
proxy_get_system_proxy, proxy_import_profile, proxy_kernel_info,
|
proxy_get_system_proxy, proxy_import_profile, proxy_kernel_info,
|
||||||
proxy_restart, proxy_save_settings,
|
proxy_restart, proxy_save_settings,
|
||||||
proxy_select_proxy, proxy_set_system_proxy, proxy_start, proxy_status, proxy_stop,
|
proxy_select_proxy, proxy_set_system_proxy, proxy_start, proxy_status, proxy_stop, proxy_traffic,
|
||||||
proxy_test_delay, proxy_update_profile,
|
proxy_test_delay, proxy_update_profile,
|
||||||
// quickpanel(22)
|
// quickpanel(22)
|
||||||
quickpanel_get_settings, quickpanel_save_settings, quickpanel_register_shortcut,
|
quickpanel_get_settings, quickpanel_save_settings, quickpanel_register_shortcut,
|
||||||
@@ -140,8 +145,8 @@ fn export_bindings() {
|
|||||||
clipboard_preview_interacted,
|
clipboard_preview_interacted,
|
||||||
// download_engine(10,豁免 2)
|
// download_engine(10,豁免 2)
|
||||||
downloader_get_tasks, downloader_check_url, downloader_add_task, downloader_pause_task,
|
downloader_get_tasks, downloader_check_url, downloader_add_task, downloader_pause_task,
|
||||||
downloader_resume_task, downloader_remove_task, downloader_get_settings,
|
downloader_resume_task, downloader_cancel_task, downloader_redownload, downloader_remove_task, downloader_get_settings,
|
||||||
downloader_save_settings, downloader_open_dir, downloader_open_url,
|
downloader_save_settings, downloader_open_dir, downloader_open_url, downloader_focus_window, downloader_inspect, downloader_select_bt_files,
|
||||||
// screenshot(22,豁免 3:get_fullscreen_bmp / load_cache_raw 返回 ipc::Response、compose_copy 接收 ipc::Request)
|
// 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_disable_transitions, screenshot_show_overlay, screenshot_register_shortcut,
|
||||||
screenshot_unregister_shortcut, screenshot_register_pin_shortcut,
|
screenshot_unregister_shortcut, screenshot_register_pin_shortcut,
|
||||||
@@ -175,12 +180,15 @@ pub fn run() {
|
|||||||
.build()
|
.build()
|
||||||
)
|
)
|
||||||
.manage(ProcessManager::new())
|
.manage(ProcessManager::new())
|
||||||
|
.manage(ThinghkUpdateState::new())
|
||||||
.invoke_handler(tauri::generate_handler![
|
.invoke_handler(tauri::generate_handler![
|
||||||
quit_app,
|
quit_app,
|
||||||
app_version,
|
app_version,
|
||||||
update_check,
|
update_check,
|
||||||
update_install,
|
update_install,
|
||||||
update_thinghk,
|
update_thinghk_apply,
|
||||||
|
update_thinghk_confirm,
|
||||||
|
update_thinghk_cancel,
|
||||||
process_start,
|
process_start,
|
||||||
process_stop,
|
process_stop,
|
||||||
process_status,
|
process_status,
|
||||||
@@ -200,6 +208,7 @@ pub fn run() {
|
|||||||
proxy_status,
|
proxy_status,
|
||||||
proxy_start,
|
proxy_start,
|
||||||
proxy_stop,
|
proxy_stop,
|
||||||
|
proxy_traffic,
|
||||||
proxy_restart,
|
proxy_restart,
|
||||||
proxy_version,
|
proxy_version,
|
||||||
proxy_get_proxies,
|
proxy_get_proxies,
|
||||||
@@ -237,12 +246,15 @@ pub fn run() {
|
|||||||
osd_set_topmost,
|
osd_set_topmost,
|
||||||
osd_start_drag_watch,
|
osd_start_drag_watch,
|
||||||
osd_start_topmost_watch,
|
osd_start_topmost_watch,
|
||||||
|
osd_start_game_watch,
|
||||||
osd_stop_watch,
|
osd_stop_watch,
|
||||||
downloader_get_tasks,
|
downloader_get_tasks,
|
||||||
downloader_add_task,
|
downloader_add_task,
|
||||||
downloader_check_url,
|
downloader_check_url,
|
||||||
downloader_pause_task,
|
downloader_pause_task,
|
||||||
downloader_resume_task,
|
downloader_resume_task,
|
||||||
|
downloader_cancel_task,
|
||||||
|
downloader_redownload,
|
||||||
downloader_remove_task,
|
downloader_remove_task,
|
||||||
downloader_get_settings,
|
downloader_get_settings,
|
||||||
downloader_save_settings,
|
downloader_save_settings,
|
||||||
@@ -250,6 +262,9 @@ pub fn run() {
|
|||||||
downloader_get_extension_info,
|
downloader_get_extension_info,
|
||||||
downloader_open_dir,
|
downloader_open_dir,
|
||||||
downloader_open_url,
|
downloader_open_url,
|
||||||
|
downloader_focus_window,
|
||||||
|
downloader_inspect,
|
||||||
|
downloader_select_bt_files,
|
||||||
clipboard_get_history,
|
clipboard_get_history,
|
||||||
clipboard_get_pinned,
|
clipboard_get_pinned,
|
||||||
clipboard_search,
|
clipboard_search,
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ use tauri::{AppHandle, State};
|
|||||||
|
|
||||||
use super::system_proxy::{clear_system_proxy_windows, get_system_proxy_windows, set_system_proxy_windows};
|
use super::system_proxy::{clear_system_proxy_windows, get_system_proxy_windows, set_system_proxy_windows};
|
||||||
use super::{
|
use super::{
|
||||||
KernelInfo, KernelUpdateInfo, MihomoManager, ProfileMeta, ProxySettings, ProxyStatus,
|
KernelInfo, KernelUpdateInfo, MihomoManager, ProfileMeta, ProxySettings, ProxyStatus, TrafficSnapshot,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::process_manager::{ProcessInfo, ProcessManager, ProcessStatus};
|
use crate::process_manager::{ProcessInfo, ProcessManager, ProcessStatus};
|
||||||
@@ -238,6 +238,14 @@ pub async fn proxy_get_connections(
|
|||||||
state.get_connections().await
|
state.get_connections().await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn proxy_traffic(
|
||||||
|
state: State<'_, MihomoManager>,
|
||||||
|
) -> Result<TrafficSnapshot, String> {
|
||||||
|
state.traffic_snapshot().await
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
#[specta::specta]
|
#[specta::specta]
|
||||||
pub async fn proxy_close_connection(
|
pub async fn proxy_close_connection(
|
||||||
|
|||||||
@@ -17,11 +17,11 @@ mod types;
|
|||||||
pub use autoswitch::{pick_best, start_auto_switch_loop};
|
pub use autoswitch::{pick_best, start_auto_switch_loop};
|
||||||
pub use pseudo::is_pseudo_node;
|
pub use pseudo::is_pseudo_node;
|
||||||
pub use system_proxy::get_system_proxy_windows;
|
pub use system_proxy::get_system_proxy_windows;
|
||||||
pub use types::{InstallProgress, KernelInfo, KernelUpdateInfo, ProfileMeta, ProxySettings, ProxyStatus};
|
pub use types::{InstallProgress, KernelInfo, KernelUpdateInfo, ProfileMeta, ProxySettings, ProxyStatus, TrafficSnapshot};
|
||||||
pub use commands::{
|
pub use commands::{
|
||||||
proxy_activate_profile, proxy_apply_kernel_update, proxy_cancel_kernel_install, proxy_check_kernel_update, proxy_clear_system_proxy,
|
proxy_activate_profile, proxy_apply_kernel_update, proxy_cancel_kernel_install, proxy_check_kernel_update, proxy_clear_system_proxy,
|
||||||
proxy_close_connection, proxy_confirm_install, proxy_delete_profile, proxy_get_connections, proxy_get_proxies,
|
proxy_close_connection, proxy_confirm_install, proxy_delete_profile, proxy_get_connections, proxy_get_proxies,
|
||||||
proxy_get_settings, proxy_get_system_proxy, proxy_import_profile, proxy_kernel_info,
|
proxy_get_settings, proxy_get_system_proxy, proxy_import_profile, proxy_kernel_info, proxy_traffic,
|
||||||
proxy_patch_configs, proxy_restart, proxy_save_settings, proxy_select_proxy, proxy_set_system_proxy,
|
proxy_patch_configs, proxy_restart, proxy_save_settings, proxy_select_proxy, proxy_set_system_proxy,
|
||||||
proxy_start, proxy_status, proxy_stop, proxy_test_delay, proxy_update_profile, proxy_version,
|
proxy_start, proxy_status, proxy_stop, proxy_test_delay, proxy_update_profile, proxy_version,
|
||||||
};
|
};
|
||||||
@@ -45,10 +45,19 @@ struct SettingsCacheEntry {
|
|||||||
settings: ProxySettings,
|
settings: ProxySettings,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 流量速率差分基线:记录上次采样的会话总量与时刻,用于计算实时速率
|
||||||
|
struct TrafficBaseline {
|
||||||
|
download_total: u64,
|
||||||
|
upload_total: u64,
|
||||||
|
at: Instant,
|
||||||
|
}
|
||||||
|
|
||||||
pub struct MihomoManager {
|
pub struct MihomoManager {
|
||||||
root: PathBuf,
|
root: PathBuf,
|
||||||
client: Client,
|
client: Client,
|
||||||
settings_cache: Mutex<Option<SettingsCacheEntry>>,
|
settings_cache: Mutex<Option<SettingsCacheEntry>>,
|
||||||
|
/// 流量速率差分基线:记录上次采样总量与时刻,由两次 /connections 总量差异计算实时速率
|
||||||
|
traffic_baseline: Mutex<Option<TrafficBaseline>>,
|
||||||
/// 内核安装/更新的取消标志(前端「停止下载」置位,下载循环轮询后中止)
|
/// 内核安装/更新的取消标志(前端「停止下载」置位,下载循环轮询后中止)
|
||||||
kernel_cancel: Arc<AtomicBool>,
|
kernel_cancel: Arc<AtomicBool>,
|
||||||
/// 取消唤醒通道:让停滞在流式读取(stream.next 最多等 30s)中的下载立即感知取消,
|
/// 取消唤醒通道:让停滞在流式读取(stream.next 最多等 30s)中的下载立即感知取消,
|
||||||
@@ -75,6 +84,7 @@ impl MihomoManager {
|
|||||||
.build()
|
.build()
|
||||||
.unwrap_or_else(|_| Client::new()),
|
.unwrap_or_else(|_| Client::new()),
|
||||||
settings_cache: Mutex::new(None),
|
settings_cache: Mutex::new(None),
|
||||||
|
traffic_baseline: Mutex::new(None),
|
||||||
kernel_cancel: Arc::new(AtomicBool::new(false)),
|
kernel_cancel: Arc::new(AtomicBool::new(false)),
|
||||||
kernel_cancel_tx: tokio::sync::watch::channel(false).0,
|
kernel_cancel_tx: tokio::sync::watch::channel(false).0,
|
||||||
install_confirm: std::sync::Mutex::new(None),
|
install_confirm: std::sync::Mutex::new(None),
|
||||||
@@ -308,7 +318,15 @@ impl MihomoManager {
|
|||||||
/// 手动启动与 App 自启共用,保证设置语义一致(mihomo 运行期间自动跟随系统代理)。
|
/// 手动启动与 App 自启共用,保证设置语义一致(mihomo 运行期间自动跟随系统代理)。
|
||||||
pub fn apply_auto_system_proxy(&self) {
|
pub fn apply_auto_system_proxy(&self) {
|
||||||
let settings = self.load_settings();
|
let settings = self.load_settings();
|
||||||
if settings.auto_system_proxy && !settings.system_proxy && !system_proxy::get_system_proxy_windows() {
|
if !settings.auto_system_proxy {
|
||||||
|
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);
|
let addr = format!("127.0.0.1:{}", settings.mixed_port);
|
||||||
if let Err(e) = system_proxy::set_system_proxy_windows(&addr) {
|
if let Err(e) = system_proxy::set_system_proxy_windows(&addr) {
|
||||||
crate::logger::log_warn("mihomo", &format!("自动开启系统代理失败: {}", e));
|
crate::logger::log_warn("mihomo", &format!("自动开启系统代理失败: {}", e));
|
||||||
@@ -318,7 +336,6 @@ impl MihomoManager {
|
|||||||
s.system_proxy = true;
|
s.system_proxy = true;
|
||||||
let _ = self.save_settings(&s);
|
let _ = self.save_settings(&s);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/// 应用启动时检查是否需要自动启动 mihomo 和系统代理
|
/// 应用启动时检查是否需要自动启动 mihomo 和系统代理
|
||||||
pub fn auto_start_on_launch(&self, app: &AppHandle, pm: &ProcessManager) {
|
pub fn auto_start_on_launch(&self, app: &AppHandle, pm: &ProcessManager) {
|
||||||
@@ -450,6 +467,48 @@ impl MihomoManager {
|
|||||||
self.api_get("/connections").await
|
self.api_get("/connections").await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 拉取 /connections 并计算实时流量快照。
|
||||||
|
/// 速率由两次采样的会话总量差分得出;mihomo 重启导致总量回退时自动重置基线。
|
||||||
|
pub async fn traffic_snapshot(&self) -> Result<TrafficSnapshot, String> {
|
||||||
|
let conns = self.get_connections().await?;
|
||||||
|
let upload_total = conns["uploadTotal"].as_u64().unwrap_or(0);
|
||||||
|
let download_total = conns["downloadTotal"].as_u64().unwrap_or(0);
|
||||||
|
let active_connections = conns["connections"].as_array().map(|a| a.len()).unwrap_or(0);
|
||||||
|
|
||||||
|
let (upload_speed, download_speed) = {
|
||||||
|
let base = self.traffic_baseline.lock().unwrap();
|
||||||
|
match base.as_ref() {
|
||||||
|
// 正常差分:总量单调递增才计算速率
|
||||||
|
Some(b) if upload_total >= b.upload_total && download_total >= b.download_total => {
|
||||||
|
let dt = b.at.elapsed().as_secs_f64();
|
||||||
|
if dt > 0.0 {
|
||||||
|
let up = ((upload_total - b.upload_total) as f64 / dt).max(0.0) as u64;
|
||||||
|
let down = ((download_total - b.download_total) as f64 / dt).max(0.0) as u64;
|
||||||
|
(up, down)
|
||||||
|
} else {
|
||||||
|
(0, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 无基线或总量回退(mihomo 重启):本帧速率为 0,下方重置基线
|
||||||
|
_ => (0, 0),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
*self.traffic_baseline.lock().unwrap() = Some(TrafficBaseline {
|
||||||
|
download_total,
|
||||||
|
upload_total,
|
||||||
|
at: Instant::now(),
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(TrafficSnapshot {
|
||||||
|
download_total,
|
||||||
|
upload_total,
|
||||||
|
download_speed,
|
||||||
|
upload_speed,
|
||||||
|
active_connections,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn close_connection(&self, id: &str) -> Result<(), String> {
|
pub async fn close_connection(&self, id: &str) -> Result<(), String> {
|
||||||
self.api_request(
|
self.api_request(
|
||||||
reqwest::Method::DELETE,
|
reqwest::Method::DELETE,
|
||||||
|
|||||||
@@ -124,6 +124,22 @@ pub struct ProxyStatus {
|
|||||||
pub restart_count: u32,
|
pub restart_count: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 实时流量快照(由 /connections 的会话总量差分得出实时速率)
|
||||||
|
#[derive(Serialize, Clone, Type)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct TrafficSnapshot {
|
||||||
|
/// 本次会话累计下载字节数
|
||||||
|
pub download_total: u64,
|
||||||
|
/// 本次会话累计上传字节数
|
||||||
|
pub upload_total: u64,
|
||||||
|
/// 实时下载速率(字节/秒)
|
||||||
|
pub download_speed: u64,
|
||||||
|
/// 实时上传速率(字节/秒)
|
||||||
|
pub upload_speed: u64,
|
||||||
|
/// 当前活跃连接数
|
||||||
|
pub active_connections: usize,
|
||||||
|
}
|
||||||
|
|
||||||
/// 内核安装进度事件载荷
|
/// 内核安装进度事件载荷
|
||||||
/// - stage: downloading | extracting | replacing | done | error
|
/// - stage: downloading | extracting | replacing | done | error
|
||||||
/// - percent: 0-100(无 total_bytes 时为 0,前端按 downloadedBytes 显示)
|
/// - percent: 0-100(无 total_bytes 时为 0,前端按 downloadedBytes 显示)
|
||||||
|
|||||||
@@ -228,6 +228,8 @@ pub fn check_and_relaunch_if_needed(app_data_dir: &std::path::Path) -> bool {
|
|||||||
pub struct KernelStatus {
|
pub struct KernelStatus {
|
||||||
pub ready: bool,
|
pub ready: bool,
|
||||||
pub is_admin: bool,
|
pub is_admin: bool,
|
||||||
|
/// PawnIO 驱动是否已安装;旧版内核无此字段,Option 兼容
|
||||||
|
pub pawn_io_installed: Option<bool>,
|
||||||
pub uptime_ms: f64,
|
pub uptime_ms: f64,
|
||||||
pub group_count: u32,
|
pub group_count: u32,
|
||||||
pub sensor_count: u32,
|
pub sensor_count: u32,
|
||||||
@@ -370,7 +372,9 @@ impl MonitorKernel {
|
|||||||
self.root.join("hardware-config.json")
|
self.root.join("hardware-config.json")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 确保内核就位:若 cores/ 无内核或版本过期(源文件较新),从资源目录复制
|
/// 确保内核就位:若 cores/ 无内核或版本过期(源文件较新),从资源目录复制。
|
||||||
|
/// 同时把 PawnIO_setup.exe(可选资源)复制过去——内核提权启动时会静默安装它,
|
||||||
|
/// 作为 WinRing0 被系统/杀软拦截时读取温度/频率的替代驱动。
|
||||||
pub fn prepare_kernel(&self, app: &AppHandle) -> Result<MonitorKernelInfo, String> {
|
pub fn prepare_kernel(&self, app: &AppHandle) -> Result<MonitorKernelInfo, String> {
|
||||||
let kernel = self.kernel_path();
|
let kernel = self.kernel_path();
|
||||||
if let Ok(src) = app.path().resolve("binaries/ThingHK.exe", BaseDirectory::Resource) {
|
if let Ok(src) = app.path().resolve("binaries/ThingHK.exe", BaseDirectory::Resource) {
|
||||||
@@ -385,6 +389,24 @@ impl MonitorKernel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PawnIO 安装器:可选资源,缺失时仅影响自动安装能力(不影响内核运行)
|
||||||
|
if let Ok(setup_src) = app.path().resolve("binaries/PawnIO_setup.exe", BaseDirectory::Resource) {
|
||||||
|
if setup_src.exists() {
|
||||||
|
let setup_dest = self.cores_dir().join("PawnIO_setup.exe");
|
||||||
|
let need_copy = !setup_dest.exists()
|
||||||
|
|| fs::metadata(&setup_src)
|
||||||
|
.and_then(|s| fs::metadata(&setup_dest).map(|d| s.len() != d.len()))
|
||||||
|
.unwrap_or(true);
|
||||||
|
if need_copy {
|
||||||
|
fs::create_dir_all(self.cores_dir()).ok();
|
||||||
|
if let Err(e) = fs::copy(&setup_src, &setup_dest) {
|
||||||
|
crate::logger::log_warn("monitor", &format!("复制 PawnIO_setup.exe 失败: {}", e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Ok(MonitorKernelInfo {
|
Ok(MonitorKernelInfo {
|
||||||
path: kernel.to_string_lossy().to_string(),
|
path: kernel.to_string_lossy().to_string(),
|
||||||
exists: kernel.exists(),
|
exists: kernel.exists(),
|
||||||
@@ -489,12 +511,20 @@ impl MonitorKernel {
|
|||||||
Ok(resp) if resp.status().is_success() => {
|
Ok(resp) if resp.status().is_success() => {
|
||||||
match resp.json::<KernelStatus>().await {
|
match resp.json::<KernelStatus>().await {
|
||||||
Ok(s) if s.ready => {
|
Ok(s) if s.ready => {
|
||||||
|
// PawnIO 诊断:已提权但驱动缺失时,温度/频率等 ring0 传感器大概率无法读取
|
||||||
|
if s.is_admin && s.pawn_io_installed == Some(false) {
|
||||||
|
crate::logger::log_warn(
|
||||||
|
"monitor",
|
||||||
|
"Kernel 已提权但 PawnIO 驱动未安装,CPU 温度/频率可能无法读取(检查 cores/PawnIO_setup.exe 是否随包部署)",
|
||||||
|
);
|
||||||
|
}
|
||||||
let _ = app.emit(
|
let _ = app.emit(
|
||||||
crate::constants::events::MONITOR_READY,
|
crate::constants::events::MONITOR_READY,
|
||||||
serde_json::json!({
|
serde_json::json!({
|
||||||
"isAdmin": s.is_admin,
|
"isAdmin": s.is_admin,
|
||||||
"sensorCount": s.sensor_count,
|
"sensorCount": s.sensor_count,
|
||||||
"providers": s.providers,
|
"providers": s.providers,
|
||||||
|
"pawnIoInstalled": s.pawn_io_installed,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
return Ok(());
|
return Ok(());
|
||||||
|
|||||||
+155
-5
@@ -15,9 +15,12 @@ use tauri::{AppHandle, Emitter};
|
|||||||
static DRAG_STOP: OnceLock<Arc<AtomicBool>> = OnceLock::new();
|
static DRAG_STOP: OnceLock<Arc<AtomicBool>> = OnceLock::new();
|
||||||
/// 任务栏覆盖监视线程停止标志
|
/// 任务栏覆盖监视线程停止标志
|
||||||
static TOPMOST_STOP: OnceLock<Arc<AtomicBool>> = OnceLock::new();
|
static TOPMOST_STOP: OnceLock<Arc<AtomicBool>> = OnceLock::new();
|
||||||
|
/// 游戏全屏监视线程停止标志
|
||||||
|
static GAME_STOP: OnceLock<Arc<AtomicBool>> = OnceLock::new();
|
||||||
/// 监视线程句柄(用于停止时 join,避免 sleep 猜测式等待 + 线程泄漏)
|
/// 监视线程句柄(用于停止时 join,避免 sleep 猜测式等待 + 线程泄漏)
|
||||||
static DRAG_HANDLE: Mutex<Option<JoinHandle<()>>> = Mutex::new(None);
|
static DRAG_HANDLE: Mutex<Option<JoinHandle<()>>> = Mutex::new(None);
|
||||||
static TOPMOST_HANDLE: Mutex<Option<JoinHandle<()>>> = Mutex::new(None);
|
static TOPMOST_HANDLE: Mutex<Option<JoinHandle<()>>> = Mutex::new(None);
|
||||||
|
static GAME_HANDLE: Mutex<Option<JoinHandle<()>>> = Mutex::new(None);
|
||||||
|
|
||||||
fn drag_stop() -> &'static Arc<AtomicBool> {
|
fn drag_stop() -> &'static Arc<AtomicBool> {
|
||||||
DRAG_STOP.get_or_init(|| Arc::new(AtomicBool::new(true)))
|
DRAG_STOP.get_or_init(|| Arc::new(AtomicBool::new(true)))
|
||||||
@@ -27,6 +30,10 @@ fn topmost_stop() -> &'static Arc<AtomicBool> {
|
|||||||
TOPMOST_STOP.get_or_init(|| Arc::new(AtomicBool::new(true)))
|
TOPMOST_STOP.get_or_init(|| Arc::new(AtomicBool::new(true)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn game_stop() -> &'static Arc<AtomicBool> {
|
||||||
|
GAME_STOP.get_or_init(|| Arc::new(AtomicBool::new(true)))
|
||||||
|
}
|
||||||
|
|
||||||
/// 停止右键拖动监视线程并等待其退出(标志置位后线程最迟一个轮询周期退出)
|
/// 停止右键拖动监视线程并等待其退出(标志置位后线程最迟一个轮询周期退出)
|
||||||
fn stop_drag_thread() {
|
fn stop_drag_thread() {
|
||||||
drag_stop().store(true, Ordering::SeqCst);
|
drag_stop().store(true, Ordering::SeqCst);
|
||||||
@@ -51,18 +58,35 @@ fn stop_topmost_thread() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 停止游戏全屏监视线程并等待其退出
|
||||||
|
fn stop_game_thread() {
|
||||||
|
game_stop().store(true, Ordering::SeqCst);
|
||||||
|
if let Some(h) = GAME_HANDLE
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(|e| e.into_inner())
|
||||||
|
.take()
|
||||||
|
{
|
||||||
|
let _ = h.join();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
mod win_api {
|
mod win_api {
|
||||||
use tauri::{AppHandle, Manager};
|
use tauri::{AppHandle, Manager};
|
||||||
use windows_sys::Win32::Foundation::{POINT, RECT};
|
use windows_sys::Win32::Foundation::{POINT, RECT};
|
||||||
|
use windows_sys::Win32::Graphics::Gdi::{
|
||||||
|
GetMonitorInfoW, MonitorFromWindow, MONITORINFO, MONITOR_DEFAULTTONEAREST,
|
||||||
|
};
|
||||||
use windows_sys::Win32::UI::Input::KeyboardAndMouse::{GetAsyncKeyState, VK_RBUTTON};
|
use windows_sys::Win32::UI::Input::KeyboardAndMouse::{GetAsyncKeyState, VK_RBUTTON};
|
||||||
use windows_sys::Win32::UI::WindowsAndMessaging::{
|
use windows_sys::Win32::UI::WindowsAndMessaging::{
|
||||||
GetClassNameW, GetCursorPos, GetForegroundWindow, GetWindowLongPtrW,
|
GetClassNameW, GetCursorPos, GetForegroundWindow, GetWindowLongPtrW, GetWindowLongW,
|
||||||
GetWindowRect, SendMessageW, SetWindowLongPtrW, SetWindowPos,
|
GetWindowRect, GetWindowThreadProcessId, SendMessageW, SetWindowLongPtrW, SetWindowPos,
|
||||||
GWL_EXSTYLE, HTCAPTION, HWND_NOTOPMOST, HWND_TOPMOST, SWP_NOACTIVATE, SWP_NOMOVE,
|
GWL_EXSTYLE, GWL_STYLE, HTCAPTION, HWND_NOTOPMOST, HWND_TOPMOST, SWP_NOACTIVATE,
|
||||||
SWP_NOSIZE, SWP_NOZORDER, SWP_SHOWWINDOW, WM_NCLBUTTONDOWN, WS_EX_NOACTIVATE,
|
SWP_NOMOVE, SWP_NOSIZE, SWP_NOZORDER, WM_NCLBUTTONDOWN, WS_EX_NOACTIVATE,
|
||||||
WS_EX_TOOLWINDOW, WS_EX_TRANSPARENT,
|
WS_EX_TOOLWINDOW, WS_EX_TRANSPARENT,
|
||||||
};
|
};
|
||||||
|
/// 供模块外全屏判定使用的窗口样式常量(pub re-export)
|
||||||
|
pub use windows_sys::Win32::UI::WindowsAndMessaging::WS_CAPTION;
|
||||||
|
|
||||||
/// windows-sys 的 HWND 类型别名(isize)
|
/// windows-sys 的 HWND 类型别名(isize)
|
||||||
pub type Hwnd = isize;
|
pub type Hwnd = isize;
|
||||||
@@ -165,6 +189,10 @@ mod win_api {
|
|||||||
} else {
|
} else {
|
||||||
HWND_NOTOPMOST
|
HWND_NOTOPMOST
|
||||||
};
|
};
|
||||||
|
// 注意:不传 SWP_SHOWWINDOW,仅调整 Z 序,绝不改变窗口可见性。
|
||||||
|
// 否则当 OSD 被 .hide() 隐藏后,任务栏覆盖监视线程在系统 UI 前景切换时
|
||||||
|
// (点击任务栏/托盘关闭主界面、打开托盘菜单)会重新显示已隐藏的 OSD,
|
||||||
|
// 表现为"托盘关闭 OSD 无效 / 关闭主界面后 OSD 又出现"。
|
||||||
SetWindowPos(
|
SetWindowPos(
|
||||||
hwnd,
|
hwnd,
|
||||||
insert_after,
|
insert_after,
|
||||||
@@ -172,7 +200,7 @@ mod win_api {
|
|||||||
0,
|
0,
|
||||||
0,
|
0,
|
||||||
0,
|
0,
|
||||||
SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE | SWP_SHOWWINDOW,
|
SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -221,6 +249,41 @@ mod win_api {
|
|||||||
| "Windows.UI.Shell.ShellFlyoutWindow" // Win11 Shell 弹出
|
| "Windows.UI.Shell.ShellFlyoutWindow" // Win11 Shell 弹出
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 获取窗口样式(GWL_STYLE)
|
||||||
|
pub fn get_window_style(hwnd: Hwnd) -> isize {
|
||||||
|
unsafe { GetWindowLongW(hwnd, GWL_STYLE) as isize }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 判断窗口是否属于本进程(Thing 自身窗口不参与全屏判定)
|
||||||
|
pub fn is_own_process(hwnd: Hwnd) -> bool {
|
||||||
|
let mut pid: u32 = 0;
|
||||||
|
unsafe {
|
||||||
|
GetWindowThreadProcessId(hwnd, &mut pid);
|
||||||
|
}
|
||||||
|
pid != 0 && pid == std::process::id()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取窗口所在显示器(最近匹配)的矩形(物理像素)
|
||||||
|
pub fn get_monitor_rect(hwnd: Hwnd) -> Option<RECT> {
|
||||||
|
let monitor = unsafe { MonitorFromWindow(hwnd, MONITOR_DEFAULTTONEAREST) };
|
||||||
|
if monitor == 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let mut info = MONITORINFO {
|
||||||
|
cbSize: std::mem::size_of::<MONITORINFO>() as u32,
|
||||||
|
rcMonitor: RECT { left: 0, top: 0, right: 0, bottom: 0 },
|
||||||
|
rcWork: RECT { left: 0, top: 0, right: 0, bottom: 0 },
|
||||||
|
dwFlags: 0,
|
||||||
|
};
|
||||||
|
unsafe {
|
||||||
|
if GetMonitorInfoW(monitor, &mut info) != 0 {
|
||||||
|
Some(info.rcMonitor)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 应用 OSD 悬浮窗的原生样式(NoActivate + ToolWindow)
|
/// 应用 OSD 悬浮窗的原生样式(NoActivate + ToolWindow)
|
||||||
@@ -365,11 +428,98 @@ pub fn osd_start_topmost_watch(app: AppHandle) -> Result<(), String> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 判断窗口是否为全屏应用(无边框/独占全屏游戏)
|
||||||
|
///
|
||||||
|
/// 判定条件(全部满足):
|
||||||
|
/// 1. 无 WS_CAPTION 样式 —— 排除普通应用的"最大化"(即使系统任务栏设为自动隐藏,
|
||||||
|
/// 最大化窗口覆盖率也接近 100%,但它们带标题栏,靠样式即可区分)
|
||||||
|
/// 2. 非本进程窗口(Thing 主窗口/悬浮窗自身)
|
||||||
|
/// 3. 窗口矩形与所在显示器矩形的交集覆盖率 ≥ 95%(兼容缩放/1px 误差)
|
||||||
|
#[cfg(windows)]
|
||||||
|
fn is_fullscreen_game_window(hwnd: isize) -> bool {
|
||||||
|
if win_api::get_window_style(hwnd) & (win_api::WS_CAPTION as isize) != 0 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if win_api::is_own_process(hwnd) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let (Some(win_rect), Some(mon_rect)) = (
|
||||||
|
win_api::get_window_rect(hwnd),
|
||||||
|
win_api::get_monitor_rect(hwnd),
|
||||||
|
) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let iw = (win_rect.right.min(mon_rect.right) - win_rect.left.max(mon_rect.left)).max(0) as i64;
|
||||||
|
let ih = (win_rect.bottom.min(mon_rect.bottom) - win_rect.top.max(mon_rect.top)).max(0) as i64;
|
||||||
|
let mw = (mon_rect.right - mon_rect.left).max(0) as i64;
|
||||||
|
let mh = (mon_rect.bottom - mon_rect.top).max(0) as i64;
|
||||||
|
let mon_area = (mw * mh).max(1);
|
||||||
|
iw * ih * 100 >= mon_area * 95
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 启动游戏全屏监视
|
||||||
|
///
|
||||||
|
/// 轮询检测前景窗口是否为全屏应用(无边框/独占全屏游戏),
|
||||||
|
/// 状态变化时发出 `osd-game-active` / `osd-game-inactive` 事件。
|
||||||
|
/// 前端据此隐藏/恢复 OSD:透明置顶 WebView 悬浮窗会占用 DWM 合成路径,
|
||||||
|
/// 禁用游戏的独立翻转(MPO),是游戏中掉帧的根源;隐藏悬浮窗即可排除影响。
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn osd_start_game_watch(app: AppHandle) -> Result<(), String> {
|
||||||
|
// 停止旧线程,等待其退出后再启动新线程
|
||||||
|
stop_game_thread();
|
||||||
|
let stop_flag = game_stop().clone();
|
||||||
|
stop_flag.store(false, Ordering::SeqCst);
|
||||||
|
|
||||||
|
let app_handle = app.clone();
|
||||||
|
|
||||||
|
let handle = thread::spawn(move || {
|
||||||
|
let mut fullscreen_active = false;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
if stop_flag.load(Ordering::SeqCst) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
{
|
||||||
|
let fg = win_api::get_foreground_window();
|
||||||
|
let fullscreen = fg != 0
|
||||||
|
&& win_api::get_class_name(fg)
|
||||||
|
.map_or(false, |c| !win_api::is_system_ui_class(&c))
|
||||||
|
&& is_fullscreen_game_window(fg);
|
||||||
|
|
||||||
|
if fullscreen != fullscreen_active {
|
||||||
|
fullscreen_active = fullscreen;
|
||||||
|
let event = if fullscreen {
|
||||||
|
crate::constants::events::OSD_GAME_ACTIVE
|
||||||
|
} else {
|
||||||
|
crate::constants::events::OSD_GAME_INACTIVE
|
||||||
|
};
|
||||||
|
let _ = app_handle.emit(event, ());
|
||||||
|
crate::logger::log_info(
|
||||||
|
"osd",
|
||||||
|
&format!("全屏应用前台: {}", if fullscreen { "是 → 隐藏 OSD" } else { "否 → 恢复 OSD" }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
thread::sleep(Duration::from_millis(1000));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if let Ok(mut guard) = GAME_HANDLE.lock() {
|
||||||
|
*guard = Some(handle);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// 停止所有 OSD 监视线程
|
/// 停止所有 OSD 监视线程
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn osd_stop_watch() {
|
pub fn osd_stop_watch() {
|
||||||
stop_drag_thread();
|
stop_drag_thread();
|
||||||
stop_topmost_thread();
|
stop_topmost_thread();
|
||||||
|
stop_game_thread();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 设置点击穿透(Rust 侧原生 WS_EX_TRANSPARENT,比 JS setIgnoreCursorEvents 更可靠)
|
/// 设置点击穿透(Rust 侧原生 WS_EX_TRANSPARENT,比 JS setIgnoreCursorEvents 更可靠)
|
||||||
|
|||||||
@@ -11,6 +11,11 @@ use tauri::{AppHandle, Emitter, Manager};
|
|||||||
// CREATE_NO_WINDOW = 0x08000000,阻止子进程创建新的控制台窗口
|
// CREATE_NO_WINDOW = 0x08000000,阻止子进程创建新的控制台窗口
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
pub const CREATE_NO_WINDOW: u32 = 0x08000000;
|
pub const CREATE_NO_WINDOW: u32 = 0x08000000;
|
||||||
|
// CREATE_NEW_CONSOLE = 0x00000010,强制为控制台类子进程新开一个可见控制台窗口。
|
||||||
|
// 从 GUI 宿主(无控制台)直接 spawn cmd/powershell 等控制台程序时若不设置,
|
||||||
|
// 子进程会挂到隐藏控制台/不显示窗口,表现为"点击没反应"。
|
||||||
|
#[cfg(windows)]
|
||||||
|
pub const CREATE_NEW_CONSOLE: u32 = 0x00000010;
|
||||||
|
|
||||||
// Windows Job Object 相关常量,用于异常退出时自动清理子进程
|
// Windows Job Object 相关常量,用于异常退出时自动清理子进程
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
|
|||||||
@@ -505,11 +505,35 @@ pub fn quickpanel_run_custom_command(command: String, args: Vec<String>) -> Resu
|
|||||||
|
|
||||||
/// 运行系统命令(不设置 CREATE_NO_WINDOW,使 cmd/powershell/regedit 等显示自身窗口)
|
/// 运行系统命令(不设置 CREATE_NO_WINDOW,使 cmd/powershell/regedit 等显示自身窗口)
|
||||||
/// 适用于内置系统工具:regedit、shutdown、cmd、powershell、taskmgr 等。
|
/// 适用于内置系统工具:regedit、shutdown、cmd、powershell、taskmgr 等。
|
||||||
|
/// - 控制台类交互程序(cmd/powershell/pwsh)额外设置 CREATE_NEW_CONSOLE,
|
||||||
|
/// 否则从 GUI 宿主启动时无可见控制台窗口(表现为"点击没反应")。
|
||||||
|
/// - .msc 控制台文件(如 devmgmt.msc)不可被 CreateProcess 直接执行,
|
||||||
|
/// 改由 mmc 打开(路径解析到 System32,不受当前工作目录影响)。
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
#[specta::specta]
|
#[specta::specta]
|
||||||
pub fn quickpanel_run_system_command(command: String, args: Vec<String>) -> Result<(), String> {
|
pub fn quickpanel_run_system_command(command: String, args: Vec<String>) -> Result<(), String> {
|
||||||
|
let lower = command.to_lowercase();
|
||||||
|
if lower.ends_with(".msc") {
|
||||||
|
// 控制台文件:通过 mmc 打开(GUI 程序,无需新控制台)
|
||||||
|
let system_root = std::env::var("SystemRoot").unwrap_or_else(|_| "C:\\Windows".into());
|
||||||
|
let path = format!("{}\\System32\\{}", system_root, command);
|
||||||
|
let mut c = std::process::Command::new("mmc");
|
||||||
|
c.arg(&path);
|
||||||
|
return c
|
||||||
|
.spawn()
|
||||||
|
.map(|_| ())
|
||||||
|
.map_err(|e| format!("运行系统命令失败: {}", e));
|
||||||
|
}
|
||||||
let mut cmd = std::process::Command::new(&command);
|
let mut cmd = std::process::Command::new(&command);
|
||||||
cmd.args(&args);
|
cmd.args(&args);
|
||||||
|
#[cfg(windows)]
|
||||||
|
{
|
||||||
|
use std::os::windows::process::CommandExt;
|
||||||
|
let c = lower;
|
||||||
|
if c == "cmd" || c == "powershell" || c == "pwsh" {
|
||||||
|
cmd.creation_flags(crate::process_manager::CREATE_NEW_CONSOLE);
|
||||||
|
}
|
||||||
|
}
|
||||||
cmd.spawn().map_err(|e| format!("运行系统命令失败: {}", e))?;
|
cmd.spawn().map_err(|e| format!("运行系统命令失败: {}", e))?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
+118
-91
@@ -2,18 +2,65 @@
|
|||||||
//! 更新源为自建 Gitea:`https://gitea.atie.fun/LFeng/Thing` 的 release 资产。
|
//! 更新源为自建 Gitea:`https://gitea.atie.fun/LFeng/Thing` 的 release 资产。
|
||||||
//! - 便携版(无 unins000.exe 且不在 Program Files):下载新 thing.exe → update.bat 覆盖重启
|
//! - 便携版(无 unins000.exe 且不在 Program Files):下载新 thing.exe → update.bat 覆盖重启
|
||||||
//! - 安装版(NSIS):下载新 setup.exe → 提权静默安装 /S
|
//! - 安装版(NSIS):下载新 setup.exe → 提权静默安装 /S
|
||||||
//! - ThingHK 内核:下载 thing-hk_{v}.zip → 停止监控内核 → 覆盖 {app_data}/monitor/cores/ThingHK.exe
|
//! - ThingHK 内核:下载由前端下载模块完成 → apply 命令 need_stop 等待确认 → 解压覆盖
|
||||||
//! mihomo 内核更新继续复用代理模块已有的 GitHub 下载机制,不在此模块处理。
|
//! {app_data}/monitor/cores/ThingHK.exe(与代理模块 mihomo 内核更新同模式)
|
||||||
use futures_util::StreamExt;
|
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use specta::Type;
|
use specta::Type;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::io::{Read, Write};
|
use std::io::{Read, Write};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::{atomic::{AtomicBool, Ordering}, Mutex};
|
||||||
use tauri::{AppHandle, Emitter, Manager};
|
use tauri::{AppHandle, Emitter, Manager};
|
||||||
|
use tokio::sync::{oneshot, watch};
|
||||||
|
|
||||||
use crate::constants::events::UPDATE_PROGRESS;
|
use crate::constants::events::UPDATE_PROGRESS;
|
||||||
|
|
||||||
|
/// 用户主动取消 ThingHK 更新的标记错误信息(前端据此静默处理,不弹错误 toast)
|
||||||
|
const THINGHK_UPDATE_CANCELLED: &str = "更新已取消";
|
||||||
|
|
||||||
|
/// ThingHK 内核更新的跨命令状态:apply 过程中 need_stop 阶段等待前端确认。
|
||||||
|
/// 与 MihomoManager 的 install_confirm/kernel_cancel 同构。
|
||||||
|
pub struct ThinghkUpdateState {
|
||||||
|
/// need_stop 等待阶段的确认通道(前端调 confirm 命令时唤醒 apply 继续)
|
||||||
|
confirm_tx: Mutex<Option<oneshot::Sender<()>>>,
|
||||||
|
/// 取消标志 + 唤醒通道(前端调 cancel 命令时置位,apply 等待循环立即返回)
|
||||||
|
cancel_flag: AtomicBool,
|
||||||
|
cancel_tx: watch::Sender<bool>,
|
||||||
|
cancel_rx: watch::Receiver<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ThinghkUpdateState {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
let (tx, rx) = watch::channel(false);
|
||||||
|
Self {
|
||||||
|
confirm_tx: Mutex::new(None),
|
||||||
|
cancel_flag: AtomicBool::new(false),
|
||||||
|
cancel_tx: tx,
|
||||||
|
cancel_rx: rx,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 前端已停止监控内核,唤醒 apply 继续解压替换
|
||||||
|
fn confirm(&self) {
|
||||||
|
if let Some(tx) = self.confirm_tx.lock().unwrap_or_else(|e| e.into_inner()).take() {
|
||||||
|
let _ = tx.send(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 取消更新:置位取消标志并唤醒 apply 等待循环
|
||||||
|
fn cancel(&self) {
|
||||||
|
self.cancel_flag.store(true, Ordering::SeqCst);
|
||||||
|
let _ = self.cancel_tx.send(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 进入新的 apply 流程前复位取消标志
|
||||||
|
fn reset(&self) {
|
||||||
|
self.cancel_flag.store(false, Ordering::SeqCst);
|
||||||
|
let _ = self.cancel_tx.send(false);
|
||||||
|
*self.confirm_tx.lock().unwrap_or_else(|e| e.into_inner()) = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// 发布仓库(Gitea)
|
/// 发布仓库(Gitea)
|
||||||
const GITEA_REPO: &str = "LFeng/Thing";
|
const GITEA_REPO: &str = "LFeng/Thing";
|
||||||
const GITEA_BASE: &str = "https://gitea.atie.fun";
|
const GITEA_BASE: &str = "https://gitea.atie.fun";
|
||||||
@@ -125,72 +172,8 @@ async fn fetch_latest_release() -> Result<LatestRelease, String> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- 下载 / 解压 ----------
|
// ---------- 解压 ----------
|
||||||
|
// ThingHK 内核更新包由前端下载模块负责下载(同 mihomo),此处仅解压替换。
|
||||||
/// 下载文件到 dest,期间通过 UPDATE_PROGRESS 事件上报进度
|
|
||||||
async fn download_with_progress(app: &AppHandle, url: &str, dest: &Path) -> Result<(), String> {
|
|
||||||
// 注意:reqwest 的 timeout 是"从连接到响应体读完"的总超时。大文件(如
|
|
||||||
// thing.exe 10+MB)在慢速网络下 30s 内读不完会被掐断流导致下载到一半失败
|
|
||||||
// (此前 bug:更新包总在 ~50% 处中断)。这里只限制连接建立 15s,
|
|
||||||
// 总超时放宽到 10 分钟兜底防止永久悬挂。
|
|
||||||
let client = reqwest::Client::builder()
|
|
||||||
.connect_timeout(std::time::Duration::from_secs(15))
|
|
||||||
.timeout(std::time::Duration::from_secs(600))
|
|
||||||
.build()
|
|
||||||
.map_err(|e| format!("创建 HTTP 客户端失败: {}", e))?;
|
|
||||||
let resp = client
|
|
||||||
.get(url)
|
|
||||||
.header("User-Agent", "thing-app")
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.map_err(|e| format!("下载请求失败: {}", e))?;
|
|
||||||
if !resp.status().is_success() {
|
|
||||||
return Err(format!("下载失败: HTTP {}", resp.status()));
|
|
||||||
}
|
|
||||||
let total = resp.content_length();
|
|
||||||
let mut stream = resp.bytes_stream();
|
|
||||||
let mut file = fs::File::create(dest).map_err(|e| format!("创建文件失败: {}", e))?;
|
|
||||||
let mut downloaded: u64 = 0;
|
|
||||||
let mut last_percent: u8 = 0;
|
|
||||||
while let Some(chunk) = stream.next().await {
|
|
||||||
let chunk = chunk.map_err(|e| format!("读取下载流失败: {}", e))?;
|
|
||||||
file.write_all(&chunk).map_err(|e| format!("写入文件失败: {}", e))?;
|
|
||||||
downloaded += chunk.len() as u64;
|
|
||||||
let percent = match total {
|
|
||||||
Some(t) if t > 0 => ((downloaded as f64 / t as f64) * 100.0) as u8,
|
|
||||||
_ => 0,
|
|
||||||
};
|
|
||||||
if percent >= last_percent + 1 {
|
|
||||||
last_percent = percent;
|
|
||||||
let _ = app.emit(
|
|
||||||
UPDATE_PROGRESS,
|
|
||||||
UpdateProgress {
|
|
||||||
stage: "downloading".into(),
|
|
||||||
percent,
|
|
||||||
downloaded_bytes: downloaded,
|
|
||||||
total_bytes: total,
|
|
||||||
message: format!(
|
|
||||||
"已下载 {:.2} MB / {:.2} MB",
|
|
||||||
downloaded as f64 / 1024.0 / 1024.0,
|
|
||||||
total.unwrap_or(0) as f64 / 1024.0 / 1024.0
|
|
||||||
),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
file.flush().map_err(|e| format!("flush 失败: {}", e))?;
|
|
||||||
let _ = app.emit(
|
|
||||||
UPDATE_PROGRESS,
|
|
||||||
UpdateProgress {
|
|
||||||
stage: "downloaded".into(),
|
|
||||||
percent: 100,
|
|
||||||
downloaded_bytes: downloaded,
|
|
||||||
total_bytes: total,
|
|
||||||
message: "下载完成".into(),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 用 zip crate 解压(纯 Rust,避免 PowerShell 执行策略问题)
|
/// 用 zip crate 解压(纯 Rust,避免 PowerShell 执行策略问题)
|
||||||
fn extract_zip(zip_path: &Path, dest: &Path) -> Result<(), String> {
|
fn extract_zip(zip_path: &Path, dest: &Path) -> Result<(), String> {
|
||||||
@@ -377,13 +360,21 @@ pub async fn update_install(app: AppHandle, downloaded_path: String) -> Result<(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 更新 ThingHK 内核:下载 thing-hk_{v}.zip → 停止监控内核 → 覆盖内核文件
|
/// 应用 ThingHK 内核更新:下载阶段已由下载模块完成,本命令仅做
|
||||||
|
/// need_stop(等待前端停止监控内核并确认)→ 解压 → 替换。
|
||||||
|
/// 进度通过 UPDATE_PROGRESS 事件上报,前端据 need_stop 弹出确认对话框。
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
#[specta::specta]
|
#[specta::specta]
|
||||||
pub async fn update_thinghk(app: AppHandle) -> Result<(), String> {
|
pub async fn update_thinghk_apply(
|
||||||
let result = update_thinghk_inner(&app).await;
|
app: AppHandle,
|
||||||
|
state: tauri::State<'_, ThinghkUpdateState>,
|
||||||
|
zip_path: String,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
state.reset();
|
||||||
|
let result = update_thinghk_apply_inner(&app, &state, PathBuf::from(&zip_path)).await;
|
||||||
if let Err(ref e) = result {
|
if let Err(ref e) = result {
|
||||||
// 失败时 emit error 阶段,避免前端进度卡在最后状态无提示
|
// 取消是用户主动行为,静默返回即可;其余失败 emit error 阶段避免前端进度卡死
|
||||||
|
if e != THINGHK_UPDATE_CANCELLED {
|
||||||
let _ = app.emit(
|
let _ = app.emit(
|
||||||
UPDATE_PROGRESS,
|
UPDATE_PROGRESS,
|
||||||
UpdateProgress {
|
UpdateProgress {
|
||||||
@@ -395,29 +386,46 @@ pub async fn update_thinghk(app: AppHandle) -> Result<(), String> {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
result
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn update_thinghk_inner(app: &AppHandle) -> Result<(), String> {
|
async fn update_thinghk_apply_inner(
|
||||||
let latest = fetch_latest_release().await?;
|
app: &AppHandle,
|
||||||
let asset = latest
|
state: &ThinghkUpdateState,
|
||||||
.assets
|
zip_path: PathBuf,
|
||||||
.iter()
|
) -> Result<(), String> {
|
||||||
.find(|a| a.name.starts_with("thing-hk_") && a.name.ends_with(".zip"))
|
if !zip_path.exists() {
|
||||||
.ok_or("未在 release 中找到 ThingHK 内核包".to_string())?;
|
return Err(format!("下载文件不存在: {}", zip_path.display()));
|
||||||
// 停止监控内核(含提权模式的 /shutdown 兜底由前端先停模块),避免 exe 被占用
|
|
||||||
if let Some(monitor) = app.try_state::<crate::monitor_kernel::MonitorKernel>() {
|
|
||||||
monitor.stop_subscription(app).await;
|
|
||||||
}
|
}
|
||||||
if let Some(pm) = app.try_state::<crate::process_manager::ProcessManager>() {
|
|
||||||
let _ = pm.stop("monitor");
|
// need_stop:等待前端停止监控内核并确认(exe 被占用会导致覆盖失败)。
|
||||||
|
// 确认/取消由 confirm/cancel 命令跨命令唤醒(与 mihomo need_stop 同构)。
|
||||||
|
let _ = app.emit(
|
||||||
|
UPDATE_PROGRESS,
|
||||||
|
UpdateProgress {
|
||||||
|
stage: "need_stop".into(),
|
||||||
|
percent: 90,
|
||||||
|
downloaded_bytes: 0,
|
||||||
|
total_bytes: None,
|
||||||
|
message: "需要停止监控内核才能继续安装".into(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let (tx, mut rx) = oneshot::channel::<()>();
|
||||||
|
*state.confirm_tx.lock().unwrap_or_else(|e| e.into_inner()) = Some(tx);
|
||||||
|
let mut cancel_rx = state.cancel_rx.clone();
|
||||||
|
loop {
|
||||||
|
if state.cancel_flag.load(Ordering::SeqCst) {
|
||||||
|
*state.confirm_tx.lock().unwrap_or_else(|e| e.into_inner()) = None;
|
||||||
|
return Err(THINGHK_UPDATE_CANCELLED.to_string());
|
||||||
}
|
}
|
||||||
// 等待进程退出释放文件句柄
|
tokio::select! {
|
||||||
tokio::time::sleep(std::time::Duration::from_millis(600)).await;
|
_ = &mut rx => break,
|
||||||
let temp_dir = std::env::temp_dir().join("thing-update");
|
_ = cancel_rx.changed() => {}
|
||||||
fs::create_dir_all(&temp_dir).map_err(|e| format!("创建临时目录失败: {}", e))?;
|
}
|
||||||
let zip_path = temp_dir.join(&asset.name);
|
}
|
||||||
download_with_progress(app, &asset.browser_download_url, &zip_path).await?;
|
|
||||||
|
// 解压阶段
|
||||||
let _ = app.emit(
|
let _ = app.emit(
|
||||||
UPDATE_PROGRESS,
|
UPDATE_PROGRESS,
|
||||||
UpdateProgress {
|
UpdateProgress {
|
||||||
@@ -428,9 +436,11 @@ async fn update_thinghk_inner(app: &AppHandle) -> Result<(), String> {
|
|||||||
message: "正在解压内核...".into(),
|
message: "正在解压内核...".into(),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
let temp_dir = std::env::temp_dir().join("thing-update");
|
||||||
let extract_dir = temp_dir.join("thinghk_extract");
|
let extract_dir = temp_dir.join("thinghk_extract");
|
||||||
let _ = fs::remove_dir_all(&extract_dir);
|
let _ = fs::remove_dir_all(&extract_dir);
|
||||||
extract_zip(&zip_path, &extract_dir)?;
|
extract_zip(&zip_path, &extract_dir)?;
|
||||||
|
|
||||||
// 在解压目录中查找 ThingHK.exe
|
// 在解压目录中查找 ThingHK.exe
|
||||||
let exe_path = find_thinghk_exe(&extract_dir).ok_or("内核包中未找到 ThingHK.exe".to_string())?;
|
let exe_path = find_thinghk_exe(&extract_dir).ok_or("内核包中未找到 ThingHK.exe".to_string())?;
|
||||||
let app_data = app
|
let app_data = app
|
||||||
@@ -441,6 +451,7 @@ async fn update_thinghk_inner(app: &AppHandle) -> Result<(), String> {
|
|||||||
fs::create_dir_all(&cores_dir).map_err(|e| format!("创建内核目录失败: {}", e))?;
|
fs::create_dir_all(&cores_dir).map_err(|e| format!("创建内核目录失败: {}", e))?;
|
||||||
fs::copy(&exe_path, cores_dir.join("ThingHK.exe"))
|
fs::copy(&exe_path, cores_dir.join("ThingHK.exe"))
|
||||||
.map_err(|e| format!("覆盖内核文件失败(请确认监控模块已停止): {}", e))?;
|
.map_err(|e| format!("覆盖内核文件失败(请确认监控模块已停止): {}", e))?;
|
||||||
|
|
||||||
// 清理临时文件
|
// 清理临时文件
|
||||||
let _ = fs::remove_file(&zip_path);
|
let _ = fs::remove_file(&zip_path);
|
||||||
let _ = fs::remove_dir_all(&extract_dir);
|
let _ = fs::remove_dir_all(&extract_dir);
|
||||||
@@ -457,6 +468,22 @@ async fn update_thinghk_inner(app: &AppHandle) -> Result<(), String> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 前端已停止监控内核,确认继续解压替换(唤醒 need_stop 等待)
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub fn update_thinghk_confirm(state: tauri::State<'_, ThinghkUpdateState>) -> Result<(), String> {
|
||||||
|
state.confirm();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 取消 ThingHK 内核更新(need_stop 等待阶段有效:唤醒 apply 以「已取消」返回,zip 保留便于重试)
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub fn update_thinghk_cancel(state: tauri::State<'_, ThinghkUpdateState>) -> Result<(), String> {
|
||||||
|
state.cancel();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
fn find_thinghk_exe(dir: &Path) -> Option<PathBuf> {
|
fn find_thinghk_exe(dir: &Path) -> Option<PathBuf> {
|
||||||
if let Ok(entries) = fs::read_dir(dir) {
|
if let Ok(entries) = fs::read_dir(dir) {
|
||||||
for entry in entries.flatten() {
|
for entry in entries.flatten() {
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "thing",
|
"productName": "thing",
|
||||||
"version": "26.8.2",
|
"version": "26.8.4",
|
||||||
"identifier": "thing.lfeng.me",
|
"identifier": "thing.lfeng.me",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "bun run dev",
|
"beforeDevCommand": "bun run dev",
|
||||||
"devUrl": "http://localhost:1420",
|
"devUrl": "http://localhost:14210",
|
||||||
"beforeBuildCommand": "bun run build",
|
"beforeBuildCommand": "bun run build",
|
||||||
"frontendDist": "../dist"
|
"frontendDist": "../dist"
|
||||||
},
|
},
|
||||||
|
|||||||
+57
-9
@@ -14,8 +14,8 @@ import { useProcessStore } from '@/stores/processStore'
|
|||||||
import { TooltipProvider } from '@/components/ui/tooltip'
|
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||||
import { moduleRegistry } from '@/modules/registry'
|
import { moduleRegistry } from '@/modules/registry'
|
||||||
import type { ModuleMeta } from '@/types/module'
|
import type { ModuleMeta } from '@/types/module'
|
||||||
import { pendingNewDownload, pendingShowDownloadTasks } from '@/lib/trayEvents'
|
import { pendingNewDownload } from '@/lib/trayEvents'
|
||||||
import { EVENTS, STORAGE_KEYS } from '@/lib/constants'
|
import { EVENTS, STORAGE_KEYS, WINDOWS } from '@/lib/constants'
|
||||||
import { commands } from '@/lib/bindings'
|
import { commands } from '@/lib/bindings'
|
||||||
|
|
||||||
const appStore = useAppStore()
|
const appStore = useAppStore()
|
||||||
@@ -104,6 +104,56 @@ const handleSearch = (moduleId: string) => {
|
|||||||
handleModuleChange(moduleId)
|
handleModuleChange(moduleId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 为单个下载任务创建专属的一次性下载窗口(浏览器扩展发起)。
|
||||||
|
// label 带 task id 保证同时多个下载时各占一个窗口;对应 capabilities/download-window.json 的 glob "download-window-*"
|
||||||
|
async function openDownloadWindow(taskId: string) {
|
||||||
|
const { WebviewWindow } = await import('@tauri-apps/api/webviewWindow')
|
||||||
|
const { currentMonitor } = await import('@tauri-apps/api/window')
|
||||||
|
const label = `${WINDOWS.downloadWindow}-${taskId}`
|
||||||
|
try {
|
||||||
|
const existing = await WebviewWindow.getByLabel(label)
|
||||||
|
if (existing) {
|
||||||
|
// 已存在:用 Rust 端强制置前(绕过前台锁定,双屏/后台创建也能到前台)
|
||||||
|
await commands.downloaderFocusWindow(label)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 定位到主窗口当前所在显示器的中央偏上
|
||||||
|
const monitor = await currentMonitor()
|
||||||
|
const scale = monitor?.scaleFactor ?? 1
|
||||||
|
const w = 420
|
||||||
|
const h = 176
|
||||||
|
const x = Math.round(((monitor?.size.width ?? 1920) / scale - w) / 2)
|
||||||
|
const y = Math.round(((monitor?.size.height ?? 1080) / scale - h) / 2 * 0.8)
|
||||||
|
const win = new WebviewWindow(label, {
|
||||||
|
url: `index.html#download-window?task=${encodeURIComponent(taskId)}`,
|
||||||
|
title: '下载',
|
||||||
|
width: w,
|
||||||
|
height: h,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
decorations: false,
|
||||||
|
transparent: true,
|
||||||
|
resizable: false,
|
||||||
|
maximizable: false,
|
||||||
|
minimizable: true,
|
||||||
|
shadow: true,
|
||||||
|
visible: false,
|
||||||
|
focus: false,
|
||||||
|
// 默认不置顶、放入任务栏(可最小化,任务栏图标唤出);下载完成时窗口置前提醒。
|
||||||
|
// 隐藏创建:由 DownloadWindow 贴合内容高度后一次性 show,避免显示后再 resize 闪烁
|
||||||
|
})
|
||||||
|
win.once('tauri://error', (e) => console.error('创建下载窗口失败:', e))
|
||||||
|
// 窗口改为隐藏创建:由 DownloadWindow 在 onMounted 贴合内容高度后一次性 show,
|
||||||
|
// 避免"先以 176 高度显示、再 resize 到内容高度"造成的闪烁。
|
||||||
|
// 此处仅保留异常兜底:WebView 加载异常导致 DownloadWindow 未 reveal 时,强制显示。
|
||||||
|
win.once('tauri://created', () => {
|
||||||
|
window.setTimeout(() => { void commands.downloaderFocusWindow(label) }, 2500)
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
console.error('创建下载窗口失败:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const getFallbackModule = () => {
|
const getFallbackModule = () => {
|
||||||
const enabledIds = appStore.enabledModules.map(m => m.id)
|
const enabledIds = appStore.enabledModules.map(m => m.id)
|
||||||
const fallback = moduleRegistry.getAllMetas().find(
|
const fallback = moduleRegistry.getAllMetas().find(
|
||||||
@@ -198,14 +248,12 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
// 浏览器扩展新增下载:直接切到下载模块的任务列表页(主窗口已由 Rust 端置前)
|
// 浏览器扩展新增下载:为该任务创建一个专属的一次性下载窗口(不打断主界面)。
|
||||||
|
// 主窗口本身无需置前,下载进度/完成事件由独立窗口自行监听。
|
||||||
trayUnlisteners.push(
|
trayUnlisteners.push(
|
||||||
await listen(EVENTS.downloadExtensionAdded, () => {
|
await listen<{ id: string }>(EVENTS.downloadExtensionAdded, (e) => {
|
||||||
const enabledIds = appStore.enabledModules.map(m => m.id)
|
if (!e.payload?.id) return
|
||||||
if (enabledIds.includes('downloader') || moduleRegistry.getConfig('downloader')?.builtin) {
|
void openDownloadWindow(e.payload.id)
|
||||||
pendingShowDownloadTasks.value = true
|
|
||||||
handleModuleChange('downloader')
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
trayUnlisteners.push(
|
trayUnlisteners.push(
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ watch(
|
|||||||
<template>
|
<template>
|
||||||
<main
|
<main
|
||||||
ref="containerRef"
|
ref="containerRef"
|
||||||
class="flex-1"
|
class="flex-1 min-w-0"
|
||||||
:style="{ backgroundColor: 'var(--effect-bg)', backdropFilter: 'var(--effect-blur)' }"
|
:style="{ backgroundColor: 'var(--effect-bg)', backdropFilter: 'var(--effect-blur)' }"
|
||||||
>
|
>
|
||||||
<ScrollArea data-main-scroll class="h-full w-full">
|
<ScrollArea data-main-scroll class="h-full w-full">
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { HTMLAttributes } from "vue"
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import { ref } from "vue"
|
||||||
import { useVModel } from "@vueuse/core"
|
import { useVModel } from "@vueuse/core"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
@@ -17,10 +18,19 @@ const modelValue = useVModel(props, "modelValue", emits, {
|
|||||||
passive: true,
|
passive: true,
|
||||||
defaultValue: props.defaultValue,
|
defaultValue: props.defaultValue,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 暴露原生 input 元素与 focus,供父级 `ref="xx"` 后调用 xx.focus()
|
||||||
|
// (组件默认不转发,ref 拿到的是组件实例,调用 .focus() 会报 "focus is not a function")
|
||||||
|
const inputEl = ref<HTMLInputElement | null>(null)
|
||||||
|
defineExpose({
|
||||||
|
focus: () => inputEl.value?.focus(),
|
||||||
|
element: () => inputEl.value,
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<input
|
<input
|
||||||
|
ref="inputEl"
|
||||||
v-model="modelValue"
|
v-model="modelValue"
|
||||||
data-slot="input"
|
data-slot="input"
|
||||||
:class="cn(
|
:class="cn(
|
||||||
|
|||||||
+95
-6
@@ -16,8 +16,16 @@ export const commands = {
|
|||||||
* 调用返回前会触发应用退出。
|
* 调用返回前会触发应用退出。
|
||||||
*/
|
*/
|
||||||
updateInstall: (downloadedPath: string) => __TAURI_INVOKE<null>("update_install", { downloadedPath }),
|
updateInstall: (downloadedPath: string) => __TAURI_INVOKE<null>("update_install", { downloadedPath }),
|
||||||
/** 更新 ThingHK 内核:下载 thing-hk_{v}.zip → 停止监控内核 → 覆盖内核文件 */
|
/**
|
||||||
updateThinghk: () => __TAURI_INVOKE<null>("update_thinghk"),
|
* 应用 ThingHK 内核更新:下载阶段已由下载模块完成,本命令仅做
|
||||||
|
* need_stop(等待前端停止监控内核并确认)→ 解压 → 替换。
|
||||||
|
* 进度通过 UPDATE_PROGRESS 事件上报,前端据 need_stop 弹出确认对话框。
|
||||||
|
*/
|
||||||
|
updateThinghkApply: (zipPath: string) => __TAURI_INVOKE<null>("update_thinghk_apply", { zipPath }),
|
||||||
|
/** 前端已停止监控内核,确认继续解压替换(唤醒 need_stop 等待) */
|
||||||
|
updateThinghkConfirm: () => __TAURI_INVOKE<null>("update_thinghk_confirm"),
|
||||||
|
/** 取消 ThingHK 内核更新(need_stop 等待阶段有效:唤醒 apply 以「已取消」返回,zip 保留便于重试) */
|
||||||
|
updateThinghkCancel: () => __TAURI_INVOKE<null>("update_thinghk_cancel"),
|
||||||
proxyActivateProfile: (id: string) => __TAURI_INVOKE<null>("proxy_activate_profile", { id }),
|
proxyActivateProfile: (id: string) => __TAURI_INVOKE<null>("proxy_activate_profile", { id }),
|
||||||
/** 应用内核更新:下载阶段已由下载模块完成,本方法仅做 need_stop → 解压 → 替换。 */
|
/** 应用内核更新:下载阶段已由下载模块完成,本方法仅做 need_stop → 解压 → 替换。 */
|
||||||
proxyApplyKernelUpdate: (zipPath: string) => __TAURI_INVOKE<KernelInfo>("proxy_apply_kernel_update", { zipPath }),
|
proxyApplyKernelUpdate: (zipPath: string) => __TAURI_INVOKE<KernelInfo>("proxy_apply_kernel_update", { zipPath }),
|
||||||
@@ -43,6 +51,7 @@ export const commands = {
|
|||||||
proxyStart: () => __TAURI_INVOKE<ProcessInfo>("proxy_start"),
|
proxyStart: () => __TAURI_INVOKE<ProcessInfo>("proxy_start"),
|
||||||
proxyStatus: () => __TAURI_INVOKE<ProxyStatus>("proxy_status"),
|
proxyStatus: () => __TAURI_INVOKE<ProxyStatus>("proxy_status"),
|
||||||
proxyStop: () => __TAURI_INVOKE<null>("proxy_stop"),
|
proxyStop: () => __TAURI_INVOKE<null>("proxy_stop"),
|
||||||
|
proxyTraffic: () => __TAURI_INVOKE<TrafficSnapshot>("proxy_traffic"),
|
||||||
proxyTestDelay: (name: string, url: string | null, timeout: number | null) => __TAURI_INVOKE<number>("proxy_test_delay", { name, url, timeout }),
|
proxyTestDelay: (name: string, url: string | null, timeout: number | null) => __TAURI_INVOKE<number>("proxy_test_delay", { name, url, timeout }),
|
||||||
proxyUpdateProfile: (id: string) => __TAURI_INVOKE<ProfileMeta>("proxy_update_profile", { id }),
|
proxyUpdateProfile: (id: string) => __TAURI_INVOKE<ProfileMeta>("proxy_update_profile", { id }),
|
||||||
/** 读取快速面板设置(快捷键等) */
|
/** 读取快速面板设置(快捷键等) */
|
||||||
@@ -109,6 +118,10 @@ export const commands = {
|
|||||||
/**
|
/**
|
||||||
* 运行系统命令(不设置 CREATE_NO_WINDOW,使 cmd/powershell/regedit 等显示自身窗口)
|
* 运行系统命令(不设置 CREATE_NO_WINDOW,使 cmd/powershell/regedit 等显示自身窗口)
|
||||||
* 适用于内置系统工具:regedit、shutdown、cmd、powershell、taskmgr 等。
|
* 适用于内置系统工具:regedit、shutdown、cmd、powershell、taskmgr 等。
|
||||||
|
* - 控制台类交互程序(cmd/powershell/pwsh)额外设置 CREATE_NEW_CONSOLE,
|
||||||
|
* 否则从 GUI 宿主启动时无可见控制台窗口(表现为"点击没反应")。
|
||||||
|
* - .msc 控制台文件(如 devmgmt.msc)不可被 CreateProcess 直接执行,
|
||||||
|
* 改由 mmc 打开(路径解析到 System32,不受当前工作目录影响)。
|
||||||
*/
|
*/
|
||||||
quickpanelRunSystemCommand: (command: string, args: string[]) => __TAURI_INVOKE<null>("quickpanel_run_system_command", { command, args }),
|
quickpanelRunSystemCommand: (command: string, args: string[]) => __TAURI_INVOKE<null>("quickpanel_run_system_command", { command, args }),
|
||||||
/** 列出目录下的压缩包文件(供批量解压面板使用)。 */
|
/** 列出目录下的压缩包文件(供批量解压面板使用)。 */
|
||||||
@@ -198,11 +211,15 @@ export const commands = {
|
|||||||
/** 检查 URL 重复性并探测文件信息(添加下载前调用) */
|
/** 检查 URL 重复性并探测文件信息(添加下载前调用) */
|
||||||
downloaderCheckUrl: (url: string, dir: string | null, headers: { [key in string]: string } | null) => __TAURI_INVOKE<CheckUrlResult>("downloader_check_url", { url, dir, headers }),
|
downloaderCheckUrl: (url: string, dir: string | null, headers: { [key in string]: string } | null) => __TAURI_INVOKE<CheckUrlResult>("downloader_check_url", { url, dir, headers }),
|
||||||
/** 添加下载任务 */
|
/** 添加下载任务 */
|
||||||
downloaderAddTask: (url: string, filename: string | null, dir: string | null, headers: { [key in string]: string } | null, autoRename: boolean | null) => __TAURI_INVOKE<string>("downloader_add_task", { url, filename, dir, headers, autoRename }),
|
downloaderAddTask: (url: string, filename: string | null, dir: string | null, headers: { [key in string]: string } | null, autoRename: boolean | null, onlyFiles: number[] | null) => __TAURI_INVOKE<string>("downloader_add_task", { url, filename, dir, headers, autoRename, onlyFiles }),
|
||||||
/** 暂停任务 */
|
/** 暂停任务 */
|
||||||
downloaderPauseTask: (id: string) => __TAURI_INVOKE<null>("downloader_pause_task", { id }),
|
downloaderPauseTask: (id: string) => __TAURI_INVOKE<null>("downloader_pause_task", { id }),
|
||||||
/** 恢复任务 */
|
/** 恢复任务 */
|
||||||
downloaderResumeTask: (id: string) => __TAURI_INVOKE<null>("downloader_resume_task", { id }),
|
downloaderResumeTask: (id: string) => __TAURI_INVOKE<null>("downloader_resume_task", { id }),
|
||||||
|
/** 取消任务(置为已取消,清空进度并删除下载文件,但保留记录) */
|
||||||
|
downloaderCancelTask: (id: string) => __TAURI_INVOKE<null>("downloader_cancel_task", { id }),
|
||||||
|
/** 重新下载已取消/出错的任务 */
|
||||||
|
downloaderRedownload: (id: string) => __TAURI_INVOKE<null>("downloader_redownload", { id }),
|
||||||
/** 移除任务 */
|
/** 移除任务 */
|
||||||
downloaderRemoveTask: (id: string, deleteFiles: boolean | null) => __TAURI_INVOKE<null>("downloader_remove_task", { id, deleteFiles }),
|
downloaderRemoveTask: (id: string, deleteFiles: boolean | null) => __TAURI_INVOKE<null>("downloader_remove_task", { id, deleteFiles }),
|
||||||
/** 获取设置 */
|
/** 获取设置 */
|
||||||
@@ -213,6 +230,17 @@ export const commands = {
|
|||||||
downloaderOpenDir: (path: string) => __TAURI_INVOKE<null>("downloader_open_dir", { path }),
|
downloaderOpenDir: (path: string) => __TAURI_INVOKE<null>("downloader_open_dir", { path }),
|
||||||
/** 用系统默认浏览器打开 URL */
|
/** 用系统默认浏览器打开 URL */
|
||||||
downloaderOpenUrl: (url: string) => __TAURI_INVOKE<null>("downloader_open_url", { url }),
|
downloaderOpenUrl: (url: string) => __TAURI_INVOKE<null>("downloader_open_url", { url }),
|
||||||
|
/**
|
||||||
|
* 将指定 label 的下载窗口显示并强制置为前台。
|
||||||
|
* Tauri 的 set_focus 在 Windows 上受前台锁定限制(尤其下载窗口由后台进程创建、
|
||||||
|
* 或创建到非主显示器时更明显),改用原生 SetForegroundWindow + BringWindowToTop
|
||||||
|
* (模拟 Alt 键重置前台锁定),保证开始/完成下载时窗口能正确定位到前台。
|
||||||
|
*/
|
||||||
|
downloaderFocusWindow: (label: string) => __TAURI_INVOKE<null>("downloader_focus_window", { label }),
|
||||||
|
/** 解析磁力链 / .torrent 文件,返回种子信息(名称 / infohash / 文件列表),供前端做文件勾选 */
|
||||||
|
downloaderInspect: (input: string) => __TAURI_INVOKE<TorrentInfo>("downloader_inspect", { input }),
|
||||||
|
/** 磁力任务:元数据解析成功后,用户勾选文件并确认开始下载 */
|
||||||
|
downloaderSelectBtFiles: (id: string, onlyFiles: number[]) => __TAURI_INVOKE<null>("downloader_select_bt_files", { id, onlyFiles }),
|
||||||
/** 禁用指定窗口(按 label 查找)的显示/隐藏过渡动画,消除覆盖层出现/消失时的缩放动画 */
|
/** 禁用指定窗口(按 label 查找)的显示/隐藏过渡动画,消除覆盖层出现/消失时的缩放动画 */
|
||||||
screenshotDisableTransitions: (label: string) => __TAURI_INVOKE<null>("screenshot_disable_transitions", { label }),
|
screenshotDisableTransitions: (label: string) => __TAURI_INVOKE<null>("screenshot_disable_transitions", { label }),
|
||||||
/** 一次 IPC 完成指定窗口的显示与聚焦(截图覆盖层显示关键路径,减少串行往返) */
|
/** 一次 IPC 完成指定窗口的显示与聚焦(截图覆盖层显示关键路径,减少串行往返) */
|
||||||
@@ -280,6 +308,16 @@ export type ArchiveInfo = {
|
|||||||
size: number,
|
size: number,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** BT 种子内文件条目(多文件任务用;阶段1下载全部文件,但保留列表供 UI 展示) */
|
||||||
|
export type BtFileInfo = {
|
||||||
|
/** 文件在种子内的索引 */
|
||||||
|
index: number,
|
||||||
|
/** 相对种子根目录的路径(如 "sub/file.mkv") */
|
||||||
|
path: string,
|
||||||
|
/** 文件大小(字节) */
|
||||||
|
size: number,
|
||||||
|
};
|
||||||
|
|
||||||
/** 前端可见的捕获数据 */
|
/** 前端可见的捕获数据 */
|
||||||
export type CaptureData = {
|
export type CaptureData = {
|
||||||
pngBase64: string,
|
pngBase64: string,
|
||||||
@@ -372,12 +410,20 @@ export type DeleteResult = {
|
|||||||
export type DownloadTask = {
|
export type DownloadTask = {
|
||||||
/** 任务 ID(自增 hex 字符串) */
|
/** 任务 ID(自增 hex 字符串) */
|
||||||
id: string,
|
id: string,
|
||||||
/** 下载地址 */
|
/** 下载地址(HTTP URL 或磁力链接) */
|
||||||
url: string,
|
url: string,
|
||||||
/** 文件名 */
|
/** 文件名(HTTP:目标文件名;BT:种子名称) */
|
||||||
filename: string,
|
filename: string,
|
||||||
/** 保存目录(绝对路径) */
|
/** 保存目录(绝对路径) */
|
||||||
dir: string,
|
dir: string,
|
||||||
|
/** 协议类型 */
|
||||||
|
protocol?: TaskProtocol,
|
||||||
|
/** BT 种子 infohash(协议=BitTorrent 时存在) */
|
||||||
|
infoHash?: string | null,
|
||||||
|
/** BT 种子内文件列表(协议=BitTorrent 时存在) */
|
||||||
|
btFiles?: BtFileInfo[],
|
||||||
|
/** BT 元数据是否已解析就绪(异步添加时:后台解析完成前为 false,调度器跳过) */
|
||||||
|
btMetadataReady?: boolean,
|
||||||
/** 状态 */
|
/** 状态 */
|
||||||
status: TaskStatus,
|
status: TaskStatus,
|
||||||
/** 文件总大小(字节),0=未知 */
|
/** 文件总大小(字节),0=未知 */
|
||||||
@@ -420,6 +466,14 @@ export type DownloaderSettings = {
|
|||||||
checkDuplicate?: boolean,
|
checkDuplicate?: boolean,
|
||||||
/** 下载是否使用代理:true=尊重系统代理(mihomo 开启系统代理时经其转发),false=强制直连 */
|
/** 下载是否使用代理:true=尊重系统代理(mihomo 开启系统代理时经其转发),false=强制直连 */
|
||||||
useProxy?: boolean,
|
useProxy?: boolean,
|
||||||
|
/** BitTorrent 上传限速 KB/s(0=不限) */
|
||||||
|
btUploadLimitKb?: number,
|
||||||
|
/** BitTorrent 下载完成后是否继续做种上传(false=下载完即停止上传) */
|
||||||
|
btSeedAfterDownload?: boolean,
|
||||||
|
/** BitTorrent 监听端口(0=自动选择) */
|
||||||
|
btListenPort?: number,
|
||||||
|
/** BitTorrent 使用代理下载:开启后自动使用代理模块(mihomo)的 SOCKS5 端口;代理不可用时降级直连 */
|
||||||
|
btUseProxy?: boolean,
|
||||||
};
|
};
|
||||||
|
|
||||||
/** 重复类型 */
|
/** 重复类型 */
|
||||||
@@ -604,6 +658,13 @@ export type SpecialLocation = {
|
|||||||
args: string[],
|
args: string[],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** 任务下载协议类型 */
|
||||||
|
export type TaskProtocol =
|
||||||
|
/** HTTP/HTTPS 直链 */
|
||||||
|
"http" |
|
||||||
|
/** BitTorrent(磁力链 / .torrent 文件) */
|
||||||
|
"bittorrent";
|
||||||
|
|
||||||
/** 任务状态 */
|
/** 任务状态 */
|
||||||
export type TaskStatus =
|
export type TaskStatus =
|
||||||
/** 排队等待(并发数已满) */
|
/** 排队等待(并发数已满) */
|
||||||
@@ -615,7 +676,35 @@ export type TaskStatus =
|
|||||||
/** 已完成 */
|
/** 已完成 */
|
||||||
"complete" |
|
"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 中的一个资产 */
|
/** release 中的一个资产 */
|
||||||
export type UpdateAsset = {
|
export type UpdateAsset = {
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ export const WINDOWS = {
|
|||||||
osdOverlay: 'osd-overlay',
|
osdOverlay: 'osd-overlay',
|
||||||
screenshotOverlay: 'screenshot-overlay',
|
screenshotOverlay: 'screenshot-overlay',
|
||||||
screenshotPin: 'screenshot-pin',
|
screenshotPin: 'screenshot-pin',
|
||||||
|
/** 单文件一次性下载窗口前缀,实际 label = `${downloadWindow}-<taskId>` */
|
||||||
|
downloadWindow: 'download-window',
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
/** Tauri 事件名(前端 emit / listen 与 Rust constants::events 对应) */
|
/** Tauri 事件名(前端 emit / listen 与 Rust constants::events 对应) */
|
||||||
@@ -59,6 +61,10 @@ export const EVENTS = {
|
|||||||
osdContentSize: 'osd-content-size',
|
osdContentSize: 'osd-content-size',
|
||||||
osdSystemUiActive: 'osd-system-ui-active',
|
osdSystemUiActive: 'osd-system-ui-active',
|
||||||
osdSystemUiInactive: 'osd-system-ui-inactive',
|
osdSystemUiInactive: 'osd-system-ui-inactive',
|
||||||
|
/** 前台出现全屏应用(游戏):OSD 应隐藏以避免游戏掉帧 */
|
||||||
|
osdGameActive: 'osd-game-active',
|
||||||
|
/** 全屏应用退出前台:OSD 可恢复显示 */
|
||||||
|
osdGameInactive: 'osd-game-inactive',
|
||||||
osdStartDrag: 'osd-start-drag',
|
osdStartDrag: 'osd-start-drag',
|
||||||
osdEndDrag: 'osd-end-drag',
|
osdEndDrag: 'osd-end-drag',
|
||||||
monitorReady: 'monitor-ready',
|
monitorReady: 'monitor-ready',
|
||||||
@@ -72,7 +78,7 @@ export const EVENTS = {
|
|||||||
downloadAdded: 'download-added',
|
downloadAdded: 'download-added',
|
||||||
/** 任务被删除(浏览器扩展通过 HTTP API 删除任务时发出,前端据此刷新任务列表) */
|
/** 任务被删除(浏览器扩展通过 HTTP API 删除任务时发出,前端据此刷新任务列表) */
|
||||||
downloadRemoved: 'download-removed',
|
downloadRemoved: 'download-removed',
|
||||||
/** 浏览器扩展通过 HTTP API 新增下载(置前主窗口并跳到下载画面) */
|
/** 浏览器扩展通过 HTTP API 新增下载(负载 { id },前端据以为该任务创建专属下载窗口) */
|
||||||
downloadExtensionAdded: 'download-extension-added',
|
downloadExtensionAdded: 'download-extension-added',
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
@@ -94,6 +100,8 @@ export const STORAGE_KEYS = {
|
|||||||
quickpanelDeleteFilterFavs: 'thing_quickpanel_delete_filter_favs',
|
quickpanelDeleteFilterFavs: 'thing_quickpanel_delete_filter_favs',
|
||||||
currencyRates: 'thing_quickpanel_currency_rates',
|
currencyRates: 'thing_quickpanel_currency_rates',
|
||||||
monitorOsdConfig: 'thing_monitor_osd_config',
|
monitorOsdConfig: 'thing_monitor_osd_config',
|
||||||
|
/** 关闭"自动启动监控内核"时暂存的 OSD 开关状态(开启自动启动时据此恢复) */
|
||||||
|
monitorOsdPending: 'thing_monitor_osd_pending',
|
||||||
monitorOverviewCards: 'thing_monitor_overview_cards',
|
monitorOverviewCards: 'thing_monitor_overview_cards',
|
||||||
screenshotHistory: 'thing_screenshot_history',
|
screenshotHistory: 'thing_screenshot_history',
|
||||||
screenshotPinIndex: 'thing_screenshot_pin_index',
|
screenshotPinIndex: 'thing_screenshot_pin_index',
|
||||||
|
|||||||
+12
-4
@@ -9,8 +9,14 @@ const logger = createLogger('main')
|
|||||||
// 禁用 WebView 默认右键菜单(桌面应用体验,主窗口与独立窗口共用)
|
// 禁用 WebView 默认右键菜单(桌面应用体验,主窗口与独立窗口共用)
|
||||||
document.addEventListener('contextmenu', (e) => e.preventDefault())
|
document.addEventListener('contextmenu', (e) => e.preventDefault())
|
||||||
|
|
||||||
|
// 良性通知过滤:ResizeObserver 回调引发的布局变化在同一帧内级联时,
|
||||||
|
// 浏览器会派发此 ErrorEvent(规范定义为"通知"而非异常,无可操作信息)。
|
||||||
|
// 监控数据每秒刷新、reka-ui 组件挂载时高发,直接忽略避免污染日志。
|
||||||
|
const BENIGN_RESIZE_OBSERVER_RE = /^ResizeObserver loop (completed with undelivered notifications|limit exceeded)/i
|
||||||
|
|
||||||
// 全局未捕获异常日志
|
// 全局未捕获异常日志
|
||||||
window.addEventListener('error', (event) => {
|
window.addEventListener('error', (event) => {
|
||||||
|
if (event.message && BENIGN_RESIZE_OBSERVER_RE.test(event.message)) return
|
||||||
logger.error(`全局JS错误: ${event.message} @ ${event.filename}:${event.lineno}`)
|
logger.error(`全局JS错误: ${event.message} @ ${event.filename}:${event.lineno}`)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -31,14 +37,16 @@ const standaloneWindowApps: Array<[hash: string, label: string, loader: () => Pr
|
|||||||
['#screenshot-overlay', '截图覆盖层', () => import('./modules/screenshot/ScreenshotOverlay.vue')],
|
['#screenshot-overlay', '截图覆盖层', () => import('./modules/screenshot/ScreenshotOverlay.vue')],
|
||||||
['#screenshot-editor', '截图编辑器', () => import('./modules/screenshot/ScreenshotEditor.vue')],
|
['#screenshot-editor', '截图编辑器', () => import('./modules/screenshot/ScreenshotEditor.vue')],
|
||||||
['#screenshot-pin', '贴图窗口', () => import('./modules/screenshot/ScreenshotPin.vue')],
|
['#screenshot-pin', '贴图窗口', () => import('./modules/screenshot/ScreenshotPin.vue')],
|
||||||
|
['#download-window', '下载窗口', () => import('./modules/downloader/DownloadWindow.vue')],
|
||||||
]
|
]
|
||||||
|
|
||||||
const winHash = window.location.hash
|
const winHash = window.location.hash
|
||||||
|
|
||||||
// #screenshot-overlay 带窗口号参数(多屏),按前缀匹配;其余精确匹配
|
// #screenshot-overlay 带窗口号参数(多屏)、#download-window 带 ?task= 参数,按前缀匹配;其余精确匹配
|
||||||
const matched = standaloneWindowApps.find(([hash]) =>
|
const matched = standaloneWindowApps.find(([hash]) => {
|
||||||
hash === '#screenshot-overlay' ? winHash.startsWith(hash) : winHash === hash
|
if (hash === '#screenshot-overlay' || hash === '#download-window') return winHash.startsWith(hash)
|
||||||
)
|
return winHash === hash
|
||||||
|
})
|
||||||
|
|
||||||
if (matched) {
|
if (matched) {
|
||||||
const [, label, loader] = matched
|
const [, label, loader] = matched
|
||||||
|
|||||||
@@ -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,
|
Link2, Loader2, FolderOpen, Copy, Puzzle,
|
||||||
CheckCircle2, Clock, Zap, Eye, EyeOff, Search,
|
CheckCircle2, Clock, Zap, Eye, EyeOff, Search,
|
||||||
ArrowUp, ArrowDown, ChevronLeft, ChevronRight, ExternalLink, Globe,
|
ArrowUp, ArrowDown, ChevronLeft, ChevronRight, ExternalLink, Globe,
|
||||||
Info
|
Info, XCircle, Square
|
||||||
} from '@lucide/vue'
|
} from '@lucide/vue'
|
||||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||||
import { toast } from 'vue-sonner'
|
import { toast } from 'vue-sonner'
|
||||||
@@ -57,6 +57,118 @@ const addDir = ref('')
|
|||||||
const addingTask = ref(false)
|
const addingTask = ref(false)
|
||||||
const addDialogOpen = ref(false)
|
const addDialogOpen = ref(false)
|
||||||
|
|
||||||
|
// ===== BT 种子文件勾选对话框(异步添加:元数据解析成功后由事件驱动弹出) =====
|
||||||
|
interface BtSelectEntry {
|
||||||
|
taskId: string
|
||||||
|
/** 已勾选的文件索引(默认全选) */
|
||||||
|
selected: number[]
|
||||||
|
}
|
||||||
|
/** 待勾选文件的任务队列(可能多个磁力同时解析完成) */
|
||||||
|
const btSelectQueue = ref<BtSelectEntry[]>([])
|
||||||
|
const btSelectOpen = ref(false)
|
||||||
|
/** 当前展示的勾选入口 */
|
||||||
|
const btSelectEntry = computed<BtSelectEntry | null>(() => btSelectQueue.value[0] ?? null)
|
||||||
|
/** 当前勾选入口对应的任务(含文件列表) */
|
||||||
|
const btSelectTask = computed<DownloadTask | null>(() => {
|
||||||
|
const e = btSelectEntry.value
|
||||||
|
if (!e) return null
|
||||||
|
return store.tasks.find((t) => t.id === e.taskId) ?? null
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 判断单个输入是否为 BT 链接 */
|
||||||
|
const isBtInput = (input: string): boolean => {
|
||||||
|
const s = input.trim()
|
||||||
|
return s.toLowerCase().startsWith('magnet:') || /\.torrent($|\?)/i.test(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 切换某个文件勾选 */
|
||||||
|
const btnToggleFile = (idx: number) => {
|
||||||
|
const e = btSelectEntry.value
|
||||||
|
if (!e) return
|
||||||
|
const i = e.selected.indexOf(idx)
|
||||||
|
if (i >= 0) e.selected.splice(i, 1)
|
||||||
|
else e.selected.push(idx)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 全选 / 全不选 */
|
||||||
|
const btnSetAll = (all: boolean) => {
|
||||||
|
const e = btSelectEntry.value
|
||||||
|
const task = btSelectTask.value
|
||||||
|
if (!e || !task) return
|
||||||
|
e.selected = all ? task.btFiles.map((f) => f.index) : []
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 当前任务是否已全选 */
|
||||||
|
const btnIsAll = (): boolean => {
|
||||||
|
const task = btSelectTask.value
|
||||||
|
const e = btSelectEntry.value
|
||||||
|
if (!task || !e) return false
|
||||||
|
return e.selected.length === task.btFiles.length
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 当前已勾选文件总大小 */
|
||||||
|
const btnSelectedSize = (): number => {
|
||||||
|
const task = btSelectTask.value
|
||||||
|
const e = btSelectEntry.value
|
||||||
|
if (!task || !e) return 0
|
||||||
|
return task.btFiles
|
||||||
|
.filter((f) => e.selected.includes(f.index))
|
||||||
|
.reduce((sum, f) => sum + f.size, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 收起当前入口,展示队列中的下一个(或关闭) */
|
||||||
|
const nextBtSelect = () => {
|
||||||
|
btSelectQueue.value.shift()
|
||||||
|
btSelectOpen.value = btSelectQueue.value.length > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 磁力元数据就绪回调:刷新任务后把该任务加入勾选队列并弹出 */
|
||||||
|
const onBtInspectReady = (taskId: string) => {
|
||||||
|
const task = store.tasks.find((t) => t.id === taskId)
|
||||||
|
if (!task) return
|
||||||
|
// 单文件种子无勾选必要:直接全选并开始下载,跳过勾选对话框
|
||||||
|
if (task.btFiles.length === 1) {
|
||||||
|
store.selectBtFiles(taskId, [task.btFiles[0].index]).catch((err) => {
|
||||||
|
toast.error('开始下载失败: ' + err)
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
btSelectQueue.value.push({
|
||||||
|
taskId,
|
||||||
|
selected: task.btFiles.map((f) => f.index) // 默认全选
|
||||||
|
})
|
||||||
|
btSelectOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 确认:设置勾选文件并开始下载,然后处理下一个 */
|
||||||
|
const onBtFileConfirm = async () => {
|
||||||
|
const e = btSelectEntry.value
|
||||||
|
if (!e) return
|
||||||
|
try {
|
||||||
|
await store.selectBtFiles(e.taskId, e.selected)
|
||||||
|
} catch (err) {
|
||||||
|
toast.error('设置下载文件失败: ' + err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
nextBtSelect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 取消:不下载该任务的勾选,跳过到下一个 */
|
||||||
|
const onBtFileCancel = async () => {
|
||||||
|
const e = btSelectEntry.value
|
||||||
|
if (e) {
|
||||||
|
// 元数据已就绪且任务仍为 Queued,若不改状态会被下一次 schedule()
|
||||||
|
// 以"全选默认"自动开始下载;置为 Paused 才真正保持冻结,
|
||||||
|
// 用户想继续下载时点"继续"即可(恢复为全部文件)。
|
||||||
|
try {
|
||||||
|
await store.pauseTask(e.taskId)
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('暂停未确认的磁力任务失败: ' + err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
nextBtSelect()
|
||||||
|
}
|
||||||
|
|
||||||
// 扩展密钥显示
|
// 扩展密钥显示
|
||||||
const showSecret = ref(false)
|
const showSecret = ref(false)
|
||||||
|
|
||||||
@@ -94,6 +206,7 @@ const STATUS_OPTIONS: { label: string; value: StatusFilter }[] = [
|
|||||||
{ label: '等待中', value: 'queued' },
|
{ label: '等待中', value: 'queued' },
|
||||||
{ label: '已暂停', value: 'paused' },
|
{ label: '已暂停', value: 'paused' },
|
||||||
{ label: '已完成', value: 'complete' },
|
{ label: '已完成', value: 'complete' },
|
||||||
|
{ label: '已取消', value: 'cancelled' },
|
||||||
{ label: '错误', value: 'error' }
|
{ label: '错误', value: 'error' }
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -107,6 +220,17 @@ const SORT_OPTIONS: { label: string; value: SortField }[] = [
|
|||||||
const running = computed(() => store.status.running)
|
const running = computed(() => store.status.running)
|
||||||
|
|
||||||
// ===== 工具函数 =====
|
// ===== 工具函数 =====
|
||||||
|
/** BT 第 i 个文件已下载字节(progress 事件把每文件进度写入 segments[i].completed) */
|
||||||
|
const btFileDownloaded = (task: DownloadTask, i: number): number =>
|
||||||
|
task.segments?.[i]?.completed ?? 0
|
||||||
|
|
||||||
|
/** BT 第 i 个文件下载百分比 */
|
||||||
|
const btFileProgress = (task: DownloadTask, i: number): number => {
|
||||||
|
const f = task.btFiles?.[i]
|
||||||
|
if (!f || !f.size) return 0
|
||||||
|
return Math.min(100, Math.round((btFileDownloaded(task, i) / f.size) * 100))
|
||||||
|
}
|
||||||
|
|
||||||
const formatSize = (bytes: number): string => {
|
const formatSize = (bytes: number): string => {
|
||||||
if (!bytes || isNaN(bytes)) return '0 B'
|
if (!bytes || isNaN(bytes)) return '0 B'
|
||||||
if (bytes < 1024) return `${bytes} B`
|
if (bytes < 1024) return `${bytes} B`
|
||||||
@@ -167,6 +291,8 @@ const getTaskStatusBadge = (task: DownloadTask) => {
|
|||||||
return { variant: 'default' as const, text: '已完成', icon: CheckCircle2 }
|
return { variant: 'default' as const, text: '已完成', icon: CheckCircle2 }
|
||||||
case 'error':
|
case 'error':
|
||||||
return { variant: 'destructive' as const, text: '错误', icon: AlertCircle }
|
return { variant: 'destructive' as const, text: '错误', icon: AlertCircle }
|
||||||
|
case 'cancelled':
|
||||||
|
return { variant: 'secondary' as const, text: '已取消', icon: XCircle }
|
||||||
default:
|
default:
|
||||||
return { variant: 'outline' as const, text: task.status, icon: AlertCircle }
|
return { variant: 'outline' as const, text: task.status, icon: AlertCircle }
|
||||||
}
|
}
|
||||||
@@ -199,6 +325,45 @@ const handleResume = async (id: string) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== 取消下载(参考下载窗口:二次点击确认,3 秒内未二次点击则取消)=====
|
||||||
|
const cancelArmed = ref<Record<string, boolean>>({})
|
||||||
|
let cancelArmTimers: Record<string, number> = {}
|
||||||
|
const handleCancel = (id: string) => {
|
||||||
|
if (cancelArmed.value[id]) {
|
||||||
|
// 二次点击:执行取消
|
||||||
|
window.clearTimeout(cancelArmTimers[id])
|
||||||
|
delete cancelArmTimers[id]
|
||||||
|
cancelArmed.value[id] = false
|
||||||
|
doCancel(id)
|
||||||
|
} else {
|
||||||
|
// 首次点击:进入待确认状态
|
||||||
|
cancelArmed.value[id] = true
|
||||||
|
window.clearTimeout(cancelArmTimers[id])
|
||||||
|
cancelArmTimers[id] = window.setTimeout(() => {
|
||||||
|
cancelArmed.value[id] = false
|
||||||
|
delete cancelArmTimers[id]
|
||||||
|
}, 3000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const doCancel = async (id: string) => {
|
||||||
|
try {
|
||||||
|
await store.cancelTask(id)
|
||||||
|
toast.success('已取消,下载文件已删除')
|
||||||
|
} catch (e) {
|
||||||
|
toast.error('取消失败: ' + e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 重新下载(已取消/出错任务) =====
|
||||||
|
const handleRedownload = async (id: string) => {
|
||||||
|
try {
|
||||||
|
await store.redownload(id)
|
||||||
|
toast.success('已重新下载')
|
||||||
|
} catch (e) {
|
||||||
|
toast.error('重新下载失败: ' + e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ===== 删除任务专用对话框(带"同时删除文件"开关) =====
|
// ===== 删除任务专用对话框(带"同时删除文件"开关) =====
|
||||||
const removeDialogState = ref<{
|
const removeDialogState = ref<{
|
||||||
open: boolean
|
open: boolean
|
||||||
@@ -310,27 +475,54 @@ const handleAddDownload = async () => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 分离 BT 链接(磁力/种子)与 HTTP 链接
|
||||||
|
const httpUris: string[] = []
|
||||||
|
const btUris: string[] = []
|
||||||
|
for (const uri of uris) {
|
||||||
|
if (isBtInput(uri)) btUris.push(uri)
|
||||||
|
else httpUris.push(uri)
|
||||||
|
}
|
||||||
|
|
||||||
|
const dir = addDir.value.trim()
|
||||||
addingTask.value = true
|
addingTask.value = true
|
||||||
try {
|
try {
|
||||||
const dir = addDir.value.trim() || undefined
|
// HTTP 链接走原流程(含重复检查)
|
||||||
// 检查是否启用重复检查
|
if (httpUris.length > 0) {
|
||||||
const checkEnabled = store.settings?.checkDuplicate ?? true
|
const checkEnabled = store.settings?.checkDuplicate ?? true
|
||||||
if (checkEnabled) {
|
if (checkEnabled) {
|
||||||
// 逐个检查重复
|
|
||||||
duplicateSuccessCount.value = 0
|
duplicateSuccessCount.value = 0
|
||||||
await processUrlsWithCheck(uris, dir)
|
processUrlsWithCheck(httpUris, dir || undefined)
|
||||||
} else {
|
} else {
|
||||||
// 直接添加
|
|
||||||
let successCount = 0
|
let successCount = 0
|
||||||
for (const uri of uris) {
|
for (const uri of httpUris) {
|
||||||
try {
|
try {
|
||||||
await store.addTask(uri, undefined, dir, undefined, true)
|
await store.addTask(uri, undefined, dir || undefined, undefined, true)
|
||||||
successCount++
|
successCount++
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error(`添加 ${uri} 失败: ` + 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) {
|
} catch (e) {
|
||||||
toast.error('添加失败: ' + e)
|
toast.error('添加失败: ' + e)
|
||||||
@@ -498,6 +690,23 @@ const handleSelectDir = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 选择本地 .torrent 种子文件,追加到下载链接输入框(支持一次选多个,每行一个) */
|
||||||
|
const handleSelectTorrentFile = async () => {
|
||||||
|
try {
|
||||||
|
const selected = await openDialog({
|
||||||
|
multiple: true,
|
||||||
|
filters: [{ name: '种子文件', extensions: ['torrent'] }]
|
||||||
|
})
|
||||||
|
const paths = Array.isArray(selected) ? selected : selected ? [selected] : []
|
||||||
|
if (paths.length === 0) return
|
||||||
|
const existing = addUriText.value.trim()
|
||||||
|
const next = existing ? existing.trimEnd() + '\n' + paths.join('\n') : paths.join('\n')
|
||||||
|
addUriText.value = next
|
||||||
|
} catch (e) {
|
||||||
|
logger.error('选择种子文件失败: ' + e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleSelectSettingsDir = async () => {
|
const handleSelectSettingsDir = async () => {
|
||||||
try {
|
try {
|
||||||
const selected = await openDialog({ directory: true, multiple: false })
|
const selected = await openDialog({ directory: true, multiple: false })
|
||||||
@@ -588,6 +797,8 @@ watch(pendingShowDownloadTasks, (v) => {
|
|||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await store.init()
|
await store.init()
|
||||||
|
// 注册磁力元数据就绪回调:解析成功后弹文件勾选对话框
|
||||||
|
store.setBtInspectReadyHandler(onBtInspectReady)
|
||||||
// 消费托盘菜单"新建下载"标志位(挂载前设置的场景,watcher 尚未生效)
|
// 消费托盘菜单"新建下载"标志位(挂载前设置的场景,watcher 尚未生效)
|
||||||
if (pendingNewDownload.value) {
|
if (pendingNewDownload.value) {
|
||||||
pendingNewDownload.value = false
|
pendingNewDownload.value = false
|
||||||
@@ -601,6 +812,7 @@ onMounted(async () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
|
store.setBtInspectReadyHandler(null)
|
||||||
store.stopEventListeners()
|
store.stopEventListeners()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -610,7 +822,7 @@ const allTasks = computed<DownloadTask[]>(() => store.tasks)
|
|||||||
|
|
||||||
// 状态栏计数:单次遍历统计各状态任务数(替代模板内 4 次 filter 全量扫描)
|
// 状态栏计数:单次遍历统计各状态任务数(替代模板内 4 次 filter 全量扫描)
|
||||||
const statusCounts = computed(() => {
|
const statusCounts = computed(() => {
|
||||||
const counts: Record<TaskStatus, number> = { queued: 0, active: 0, paused: 0, complete: 0, error: 0 }
|
const counts: Record<TaskStatus, number> = { queued: 0, active: 0, paused: 0, complete: 0, error: 0, cancelled: 0 }
|
||||||
for (const t of allTasks.value) counts[t.status]++
|
for (const t of allTasks.value) counts[t.status]++
|
||||||
return counts
|
return counts
|
||||||
})
|
})
|
||||||
@@ -693,8 +905,8 @@ const toggleSortOrder = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ===== 下载任务 ===== -->
|
<!-- ===== 下载任务 ===== -->
|
||||||
<TabsContent value="tasks" class="flex-1 mt-4 min-h-0 tab-animate">
|
<TabsContent value="tasks" class="flex-1 mt-4 min-h-0 min-w-0 tab-animate">
|
||||||
<div class="h-full flex flex-col gap-4">
|
<div class="h-full flex flex-col gap-4 min-w-0">
|
||||||
<!-- 状态栏 -->
|
<!-- 状态栏 -->
|
||||||
<Card class="shrink-0 !py-0 !gap-0">
|
<Card class="shrink-0 !py-0 !gap-0">
|
||||||
<CardContent class="pl-4 pr-4 py-3">
|
<CardContent class="pl-4 pr-4 py-3">
|
||||||
@@ -727,6 +939,10 @@ const toggleSortOrder = () => {
|
|||||||
<Check class="size-3" />
|
<Check class="size-3" />
|
||||||
已完成 {{ statusCounts.complete }}
|
已完成 {{ statusCounts.complete }}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
<Badge v-if="statusCounts.cancelled > 0" variant="secondary" class="gap-1">
|
||||||
|
<XCircle class="size-3" />
|
||||||
|
已取消 {{ statusCounts.cancelled }}
|
||||||
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
@@ -849,7 +1065,7 @@ const toggleSortOrder = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 任务列表 -->
|
<!-- 任务列表 -->
|
||||||
<ScrollArea class="flex-1 min-h-0">
|
<ScrollArea class="flex-1 min-h-0 min-w-0">
|
||||||
<div v-if="pagedTasks.length === 0" key="empty" class="h-full flex flex-col items-center justify-center text-muted-foreground gap-2 pt-10">
|
<div v-if="pagedTasks.length === 0" key="empty" class="h-full flex flex-col items-center justify-center text-muted-foreground gap-2 pt-10">
|
||||||
<CheckCircle2 class="size-16 opacity-30" />
|
<CheckCircle2 class="size-16 opacity-30" />
|
||||||
<p>暂无符合条件的任务</p>
|
<p>暂无符合条件的任务</p>
|
||||||
@@ -863,10 +1079,15 @@ const toggleSortOrder = () => {
|
|||||||
<CardContent class="pl-4 pr-4 py-3">
|
<CardContent class="pl-4 pr-4 py-3">
|
||||||
<div class="flex items-start justify-between gap-3 mb-3">
|
<div class="flex items-start justify-between gap-3 mb-3">
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<div class="flex items-center gap-2 mb-1">
|
<div class="flex items-center gap-2 mb-1 min-w-0">
|
||||||
<span class="font-medium truncate cursor-default">
|
<!-- flex-1 + min-w-0 + truncate:flex 子项默认 min-width:auto 不收缩,
|
||||||
|
长文件名会把整行撑宽;flex-1 让名字占满可用宽度并省略号截断 -->
|
||||||
|
<span class="min-w-0 flex-1 font-medium truncate cursor-default" :title="getFileName(task)">
|
||||||
{{ getFileName(task) }}
|
{{ getFileName(task) }}
|
||||||
</span>
|
</span>
|
||||||
|
<Badge v-if="task.protocol === 'bittorrent'" variant="outline" class="shrink-0 gap-1 py-0 px-1.5 text-[10px]">
|
||||||
|
BT
|
||||||
|
</Badge>
|
||||||
<Badge
|
<Badge
|
||||||
:variant="getTaskStatusBadge(task).variant"
|
:variant="getTaskStatusBadge(task).variant"
|
||||||
class="shrink-0"
|
class="shrink-0"
|
||||||
@@ -895,7 +1116,7 @@ const toggleSortOrder = () => {
|
|||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent class="max-w-[480px] break-words">{{ task.dir }}</TooltipContent>
|
<TooltipContent class="max-w-[480px] break-words">{{ task.dir }}</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<span v-if="task.error" class="text-destructive">
|
<span v-if="task.error" class="text-destructive break-all">
|
||||||
{{ task.error }}
|
{{ task.error }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -927,6 +1148,35 @@ const toggleSortOrder = () => {
|
|||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>继续</TooltipContent>
|
<TooltipContent>继续</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
<!-- 已取消任务:只能再次下载 -->
|
||||||
|
<Tooltip v-if="task.status === 'cancelled'">
|
||||||
|
<TooltipTrigger as-child>
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="outline"
|
||||||
|
class="size-8 text-primary"
|
||||||
|
@click="handleRedownload(task.id)"
|
||||||
|
>
|
||||||
|
<RefreshCw class="size-4" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>再次下载</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
<!-- 取消下载(二次点击确认) -->
|
||||||
|
<Tooltip v-if="task.status !== 'complete' && task.status !== 'cancelled'">
|
||||||
|
<TooltipTrigger as-child>
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
class="size-8 text-destructive hover:text-destructive"
|
||||||
|
:class="{ 'font-bold': cancelArmed[task.id] }"
|
||||||
|
@click="handleCancel(task.id)"
|
||||||
|
>
|
||||||
|
<XCircle class="size-4" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>{{ cancelArmed[task.id] ? '再点一次确认取消' : '取消(再点一次确认)' }}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger as-child>
|
<TooltipTrigger as-child>
|
||||||
<Button
|
<Button
|
||||||
@@ -940,7 +1190,7 @@ const toggleSortOrder = () => {
|
|||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>详细信息</TooltipContent>
|
<TooltipContent>详细信息</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip v-if="task.dir">
|
<Tooltip v-if="task.dir && task.status !== 'cancelled'">
|
||||||
<TooltipTrigger as-child>
|
<TooltipTrigger as-child>
|
||||||
<Button
|
<Button
|
||||||
size="icon"
|
size="icon"
|
||||||
@@ -968,8 +1218,9 @@ const toggleSortOrder = () => {
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Progress :model-value="getProgress(task)" class="h-1.5" />
|
<!-- 已取消任务不展示进度条与进度(进度与文件均已清除) -->
|
||||||
<div class="flex justify-between mt-1 text-xs text-muted-foreground">
|
<Progress v-if="task.status !== 'cancelled'" :model-value="getProgress(task)" class="h-1.5" />
|
||||||
|
<div v-if="task.status !== 'cancelled'" class="flex justify-between mt-1 text-xs text-muted-foreground">
|
||||||
<span>{{ getProgress(task) }}%</span>
|
<span>{{ getProgress(task) }}%</span>
|
||||||
<span v-if="task.status === 'active' && task.speed > 0">
|
<span v-if="task.status === 'active' && task.speed > 0">
|
||||||
{{ formatEta(getEta(task)) }}
|
{{ formatEta(getEta(task)) }}
|
||||||
@@ -1153,6 +1404,60 @@ const toggleSortOrder = () => {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<!-- BT 专属设置 -->
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle class="flex items-center gap-2 text-base">
|
||||||
|
<Zap class="size-4 text-primary" />
|
||||||
|
BitTorrent 设置
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent class="flex flex-col gap-3">
|
||||||
|
<div class="flex flex-col gap-1.5">
|
||||||
|
<Label class="text-xs">上传限速 KB/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 设置 -->
|
<!-- 扩展 API 设置 -->
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
@@ -1452,6 +1757,68 @@ const toggleSortOrder = () => {
|
|||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
<!-- ===== BT 种子文件勾选弹窗(元数据解析成功后弹出) ===== -->
|
||||||
|
<Dialog v-model:open="btSelectOpen">
|
||||||
|
<DialogContent class="max-w-xl max-h-[80vh] flex flex-col">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle class="flex items-center gap-2">
|
||||||
|
<ListChecks class="size-4 text-primary" />
|
||||||
|
选择要下载的文件
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription class="text-xs">
|
||||||
|
<span class="block break-all font-medium">"{{ btSelectTask?.filename }}"</span>
|
||||||
|
<span class="block mt-0.5">已解析完成,默认全选,可取消不需要的文件。</span>
|
||||||
|
<span v-if="btSelectQueue.length > 1">(还有 {{ btSelectQueue.length - 1 }} 个任务待选择)</span>
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<ScrollArea class="flex-1 min-h-0 pr-3">
|
||||||
|
<div v-if="btSelectTask && btSelectTask.btFiles.length" class="flex flex-col gap-2 py-2">
|
||||||
|
<div class="flex items-center justify-between gap-2">
|
||||||
|
<span class="text-xs text-muted-foreground truncate">{{ btSelectTask.btFiles.length }} 个文件</span>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
class="h-7 px-2 text-xs shrink-0"
|
||||||
|
@click="btnSetAll(!btnIsAll())"
|
||||||
|
>
|
||||||
|
<Check v-if="btnIsAll()" class="size-3" />
|
||||||
|
<Square v-else class="size-3" />
|
||||||
|
{{ btnIsAll() ? '全不选' : '全选' }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-1 max-h-64 overflow-y-auto">
|
||||||
|
<label
|
||||||
|
v-for="f in btSelectTask.btFiles"
|
||||||
|
:key="f.index"
|
||||||
|
class="flex items-center gap-2 text-xs rounded px-1.5 py-1 hover:bg-muted cursor-pointer"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
class="accent-primary size-3.5"
|
||||||
|
:checked="btSelectEntry?.selected.includes(f.index)"
|
||||||
|
@change="btnToggleFile(f.index)"
|
||||||
|
/>
|
||||||
|
<span class="truncate flex-1">{{ f.path }}</span>
|
||||||
|
<span class="text-muted-foreground shrink-0">{{ formatSize(f.size) }}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-muted-foreground flex justify-end">
|
||||||
|
已选 {{ btSelectEntry?.selected.length ?? 0 }}/{{ btSelectTask.btFiles.length }} 文件 · {{ formatSize(btnSelectedSize()) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" @click="onBtFileCancel">取消</Button>
|
||||||
|
<Button @click="onBtFileConfirm">
|
||||||
|
<Download class="size-4" />
|
||||||
|
开始下载
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
<!-- ===== 新建下载弹窗 ===== -->
|
<!-- ===== 新建下载弹窗 ===== -->
|
||||||
<Dialog v-model:open="addDialogOpen">
|
<Dialog v-model:open="addDialogOpen">
|
||||||
<DialogContent class="max-w-lg">
|
<DialogContent class="max-w-lg">
|
||||||
@@ -1461,7 +1828,7 @@ const toggleSortOrder = () => {
|
|||||||
新建下载
|
新建下载
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription class="text-xs">
|
<DialogDescription class="text-xs">
|
||||||
支持 HTTP/HTTPS 直链,每行一个 URL。留空下载目录则使用默认目录。
|
支持 HTTP/HTTPS 直链、磁力链接(magnet:)与 .torrent 种子文件(本地文件或 http(s) 链接),每行一个。留空下载目录则使用默认目录。
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
@@ -1471,9 +1838,18 @@ const toggleSortOrder = () => {
|
|||||||
<Label class="text-xs">下载链接</Label>
|
<Label class="text-xs">下载链接</Label>
|
||||||
<Textarea
|
<Textarea
|
||||||
v-model="addUriText"
|
v-model="addUriText"
|
||||||
placeholder="https://example.com/file.zip 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"
|
class="min-h-[120px] font-mono text-sm [field-sizing:fixed] break-all"
|
||||||
/>
|
/>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
class="h-7 gap-1 px-2 text-xs bg-transparent self-start"
|
||||||
|
@click="handleSelectTorrentFile"
|
||||||
|
>
|
||||||
|
<FolderOpen class="size-3" />
|
||||||
|
选择本地种子文件
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 下载目录 -->
|
<!-- 下载目录 -->
|
||||||
@@ -1529,7 +1905,7 @@ const toggleSortOrder = () => {
|
|||||||
<span class="block font-mono text-xs break-all">{{ duplicateDialogState.url }}</span>
|
<span class="block font-mono text-xs break-all">{{ duplicateDialogState.url }}</span>
|
||||||
<span v-if="duplicateDialogState.result?.existing" class="block mt-2 text-xs">
|
<span v-if="duplicateDialogState.result?.existing" class="block mt-2 text-xs">
|
||||||
已存在:
|
已存在:
|
||||||
<span class="font-medium">{{ duplicateDialogState.result.existing.filename }}</span>
|
<span class="font-medium break-all">{{ duplicateDialogState.result.existing.filename }}</span>
|
||||||
</span>
|
</span>
|
||||||
<span class="block mt-2 text-muted-foreground">
|
<span class="block mt-2 text-muted-foreground">
|
||||||
选择"仍然下载"将自动重命名(追加序号),选择"跳过"将不下载此链接。
|
选择"仍然下载"将自动重命名(追加序号),选择"跳过"将不下载此链接。
|
||||||
@@ -1553,7 +1929,9 @@ const toggleSortOrder = () => {
|
|||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle>删除任务</AlertDialogTitle>
|
<AlertDialogTitle>删除任务</AlertDialogTitle>
|
||||||
<AlertDialogDescription>
|
<AlertDialogDescription>
|
||||||
确定要删除任务 "{{ removeDialogState.task ? getFileName(removeDialogState.task) : '' }}" 吗?
|
确定要删除任务
|
||||||
|
<span class="block font-mono text-xs break-all mt-1">{{ removeDialogState.task ? getFileName(removeDialogState.task) : '' }}</span>
|
||||||
|
吗?
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<div class="flex items-center justify-between py-2 px-1">
|
<div class="flex items-center justify-between py-2 px-1">
|
||||||
@@ -1665,6 +2043,44 @@ const toggleSortOrder = () => {
|
|||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
|
<!-- BT 种子信息(磁力/种子任务) -->
|
||||||
|
<template v-if="detailTask.protocol === 'bittorrent'">
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<span class="text-xs text-muted-foreground">BT 种子信息</span>
|
||||||
|
<div class="flex flex-col gap-1 text-xs">
|
||||||
|
<div class="flex items-start justify-between gap-2">
|
||||||
|
<span class="text-muted-foreground shrink-0">Infohash</span>
|
||||||
|
<span class="font-mono break-all">{{ detailTask.infoHash }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span class="text-muted-foreground">文件数</span>
|
||||||
|
<span>{{ detailTask.btFiles.length }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- 文件列表 + 单文件进度(progress 事件经 segments 下发每文件已下载字节) -->
|
||||||
|
<div v-if="detailTask.btFiles.length > 0" class="flex flex-col gap-1.5">
|
||||||
|
<div
|
||||||
|
v-for="(f, i) in detailTask.btFiles"
|
||||||
|
:key="f.index"
|
||||||
|
class="flex flex-col gap-0.5"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between gap-2 text-xs">
|
||||||
|
<span class="truncate">{{ f.path }}</span>
|
||||||
|
<span class="text-muted-foreground shrink-0">{{ btFileProgress(detailTask, i) }}%</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Progress :model-value="btFileProgress(detailTask, i)" class="h-1" />
|
||||||
|
<span class="text-muted-foreground text-[10px] shrink-0">
|
||||||
|
{{ formatSize(btFileDownloaded(detailTask, i)) }} / {{ formatSize(f.size) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
<!-- 文件大小信息 -->
|
<!-- 文件大小信息 -->
|
||||||
<div class="grid grid-cols-2 gap-3">
|
<div class="grid grid-cols-2 gap-3">
|
||||||
<div class="flex flex-col gap-0.5">
|
<div class="flex flex-col gap-0.5">
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
ArrowDown, ArrowUp, Wifi, FolderOpen, Copy, ListChecks,
|
ArrowDown, ArrowUp, Wifi, FolderOpen, Copy, ListChecks,
|
||||||
Monitor as MonitorIcon, GripVertical, SlidersHorizontal,
|
Monitor as MonitorIcon, GripVertical, SlidersHorizontal,
|
||||||
Eye, EyeOff, MousePointerClick, Plus, PencilLine,
|
Eye, EyeOff, MousePointerClick, Plus, PencilLine,
|
||||||
CircuitBoard, BatteryFull,
|
CircuitBoard, BatteryFull, Gamepad2,
|
||||||
} from '@lucide/vue'
|
} from '@lucide/vue'
|
||||||
import type { LucideIcon } from '@lucide/vue'
|
import type { LucideIcon } from '@lucide/vue'
|
||||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||||
@@ -1109,8 +1109,29 @@ function removeOsdItem(key: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 开启 OSD 但监控内核未运行时的提示对话框开关 */
|
||||||
|
const osdNeedsKernelOpen = ref(false)
|
||||||
|
|
||||||
|
/** 从对话框启动内核:先直接开启 OSD(立即显示),内核在后台自行启动 */
|
||||||
|
async function startKernelForOsd() {
|
||||||
|
osdNeedsKernelOpen.value = false
|
||||||
|
// 直接开启 OSD(绕过内联内核检查),store 的 overlayEnabled watch 立即创建/显示悬浮窗
|
||||||
|
osdConfig.value.overlayEnabled = true
|
||||||
|
saveOsdConfigDebounced(osdConfig.value)
|
||||||
|
// 后台启动内核(不 await,不阻塞 OSD 显示;内核就绪后 OSD 自动填充数据)
|
||||||
|
store.start().catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
/** OSD 配置项变更时自动保存(防抖:滑块拖动期间不逐帧写盘) */
|
/** OSD 配置项变更时自动保存(防抖:滑块拖动期间不逐帧写盘) */
|
||||||
function updateOsdConfig(field: keyof OsdConfig, value: unknown) {
|
function updateOsdConfig(field: keyof OsdConfig, value: unknown) {
|
||||||
|
// 开启 OSD 时,若监控内核未运行,弹出提示并取消本次开启(OSD 依赖内核提供数据)
|
||||||
|
if (field === 'overlayEnabled' && value === true) {
|
||||||
|
const kernelRunning = store.status?.running === true
|
||||||
|
if (!kernelRunning) {
|
||||||
|
osdNeedsKernelOpen.value = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
;(osdConfig.value as unknown as Record<string, unknown>)[field] = value
|
;(osdConfig.value as unknown as Record<string, unknown>)[field] = value
|
||||||
saveOsdConfigDebounced(osdConfig.value)
|
saveOsdConfigDebounced(osdConfig.value)
|
||||||
}
|
}
|
||||||
@@ -2135,6 +2156,20 @@ watch(() => store.status?.ready, (ready, prev) => {
|
|||||||
@update:model-value="updateOsdConfig('clickThrough', Boolean($event))"
|
@update:model-value="updateOsdConfig('clickThrough', Boolean($event))"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- 游戏全屏自动隐藏 -->
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex flex-col gap-0.5">
|
||||||
|
<Label class="text-sm flex items-center gap-1.5 cursor-pointer">
|
||||||
|
<Gamepad2 class="size-3.5 text-muted-foreground" />
|
||||||
|
游戏全屏时自动隐藏
|
||||||
|
</Label>
|
||||||
|
<span class="text-[11px] text-muted-foreground">检测到全屏应用(游戏)前台时隐藏悬浮窗,退出后自动恢复,避免游戏掉帧</span>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
:model-value="osdConfig.gameAutoHide"
|
||||||
|
@update:model-value="updateOsdConfig('gameAutoHide', Boolean($event))"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
@@ -2404,6 +2439,28 @@ watch(() => store.status?.ready, (ready, prev) => {
|
|||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
<!-- 开启 OSD 但监控内核未运行提示 -->
|
||||||
|
<Dialog v-model:open="osdNeedsKernelOpen">
|
||||||
|
<DialogContent class="max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle class="flex items-center gap-2">
|
||||||
|
<TriangleAlert class="size-4 text-amber-500" />
|
||||||
|
需先启动监控内核
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
OSD 悬浮窗依赖监控内核提供数据。当前内核未运行,请先启动内核后再开启 OSD 显示。
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" @click="osdNeedsKernelOpen = false">取消</Button>
|
||||||
|
<Button @click="startKernelForOsd">
|
||||||
|
<Play class="size-3.5" />
|
||||||
|
启动内核
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
<!-- OSD 显示项选择 Dialog -->
|
<!-- OSD 显示项选择 Dialog -->
|
||||||
<Dialog v-model:open="osdPickDialogOpen">
|
<Dialog v-model:open="osdPickDialogOpen">
|
||||||
<DialogContent class="max-w-lg">
|
<DialogContent class="max-w-lg">
|
||||||
|
|||||||
@@ -444,6 +444,23 @@ function scheduleMeasure() {
|
|||||||
}, 50)
|
}, 50)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 内容尺寸监视器:内核未启动时显示占位符(如 '--' 很短),数据就绪后实际值更长,
|
||||||
|
* 导致 osd-bar 变宽。仅靠配置变化触发测量不够——需监听 osd-bar 尺寸变化,
|
||||||
|
* 任何内容变宽/变高(数据加载、配置变更)都自动重测上报,驱动主窗口放大。 */
|
||||||
|
let resizeObserver: ResizeObserver | null = null
|
||||||
|
function observeBarSize() {
|
||||||
|
resizeObserver?.disconnect()
|
||||||
|
resizeObserver = null
|
||||||
|
const root = osdRootEl.value
|
||||||
|
const bar = root?.querySelector<HTMLElement>('.osd-bar')
|
||||||
|
if (!bar) return
|
||||||
|
resizeObserver = new ResizeObserver(() => scheduleMeasure())
|
||||||
|
resizeObserver.observe(bar)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 根容器就绪后(config 到达、v-if 挂载)开始监视 osd-bar 尺寸变化
|
||||||
|
watch(osdRootEl, () => { void nextTick(observeBarSize) })
|
||||||
|
|
||||||
// ===== 应用鼠标穿透 =====
|
// ===== 应用鼠标穿透 =====
|
||||||
// 同时调用 Tauri setIgnoreCursorEvents(处理 webview2 子窗口)和 Rust WS_EX_TRANSPARENT(处理原生窗口)
|
// 同时调用 Tauri setIgnoreCursorEvents(处理 webview2 子窗口)和 Rust WS_EX_TRANSPARENT(处理原生窗口)
|
||||||
// 仅靠原生 WS_EX_TRANSPARENT 不足:Tauri 窗口包含 webview2 子窗口,需两者都设置才能完全穿透
|
// 仅靠原生 WS_EX_TRANSPARENT 不足:Tauri 窗口包含 webview2 子窗口,需两者都设置才能完全穿透
|
||||||
@@ -495,6 +512,13 @@ onMounted(async () => {
|
|||||||
console.error('[OSD] 启动置顶监视失败:', e)
|
console.error('[OSD] 启动置顶监视失败:', e)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 启动游戏全屏监视(前台全屏应用时通知主窗口隐藏 OSD,避免游戏掉帧)
|
||||||
|
try {
|
||||||
|
await invoke('osd_start_game_watch')
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[OSD] 启动游戏全屏监视失败:', e)
|
||||||
|
}
|
||||||
|
|
||||||
// 监听主窗口推送的 OSD 配置(低频通道)
|
// 监听主窗口推送的 OSD 配置(低频通道)
|
||||||
unlistenFns.push(await listen<OsdStatePayload>(EVENTS.osdStateUpdate, (e) => {
|
unlistenFns.push(await listen<OsdStatePayload>(EVENTS.osdStateUpdate, (e) => {
|
||||||
config.value = e.payload.config
|
config.value = e.payload.config
|
||||||
@@ -537,6 +561,9 @@ onUnmounted(() => {
|
|||||||
unlistenFns.forEach(fn => fn())
|
unlistenFns.forEach(fn => fn())
|
||||||
// 停止监视线程
|
// 停止监视线程
|
||||||
invoke('osd_stop_watch').catch(() => {})
|
invoke('osd_stop_watch').catch(() => {})
|
||||||
|
// 断开内容尺寸监视器
|
||||||
|
resizeObserver?.disconnect()
|
||||||
|
resizeObserver = null
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
import {
|
import {
|
||||||
Globe, Play, Square, RotateCw, Power, Zap, Plus, RefreshCw, Trash2,
|
Globe, Play, Square, RotateCw, Power, Zap, Plus, RefreshCw, Trash2,
|
||||||
Check, AlertCircle, Server, Settings as SettingsIcon, ListChecks,
|
Check, AlertCircle, Server, Settings as SettingsIcon, ListChecks,
|
||||||
Upload, Link2, Loader2, Download, Timer, Target, FolderOpen, Copy, DownloadCloud
|
Upload, Link2, Loader2, Download, Timer, Target, FolderOpen, Copy, DownloadCloud,
|
||||||
|
Waypoints, ArrowDown, ArrowUp, Activity, X
|
||||||
} from '@lucide/vue'
|
} from '@lucide/vue'
|
||||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||||
import { toast } from 'vue-sonner'
|
import { toast } from 'vue-sonner'
|
||||||
@@ -10,7 +11,7 @@ import { invoke } from '@tauri-apps/api/core'
|
|||||||
import { listen, type UnlistenFn } from '@tauri-apps/api/event'
|
import { listen, type UnlistenFn } from '@tauri-apps/api/event'
|
||||||
import { appDataDir } from '@tauri-apps/api/path'
|
import { appDataDir } from '@tauri-apps/api/path'
|
||||||
import { revealItemInDir } from '@tauri-apps/plugin-opener'
|
import { revealItemInDir } from '@tauri-apps/plugin-opener'
|
||||||
import { useProxyStore, type ProxyNode } from '@/stores/proxyStore'
|
import { useProxyStore, type ProxyNode, type ProxyConnection } from '@/stores/proxyStore'
|
||||||
import { useModuleTabs } from '@/lib/use-module-tabs'
|
import { useModuleTabs } from '@/lib/use-module-tabs'
|
||||||
import { useModuleTabsStore } from '@/stores/moduleTabsStore'
|
import { useModuleTabsStore } from '@/stores/moduleTabsStore'
|
||||||
import { createLogger } from '@/lib/logger'
|
import { createLogger } from '@/lib/logger'
|
||||||
@@ -84,6 +85,7 @@ const activeTab = ref('overview')
|
|||||||
const tabsStore = useModuleTabsStore()
|
const tabsStore = useModuleTabsStore()
|
||||||
const tabsListRef = useModuleTabs('proxy', activeTab, [
|
const tabsListRef = useModuleTabs('proxy', activeTab, [
|
||||||
{ value: 'overview', label: '概览' },
|
{ value: 'overview', label: '概览' },
|
||||||
|
{ value: 'connections', label: '连接' },
|
||||||
{ value: 'proxies', label: '节点' },
|
{ value: 'proxies', label: '节点' },
|
||||||
{ value: 'profiles', label: '订阅' },
|
{ value: 'profiles', label: '订阅' },
|
||||||
{ value: 'settings', label: '设置' }
|
{ value: 'settings', label: '设置' }
|
||||||
@@ -141,9 +143,179 @@ const accordionValue = ref<string>('')
|
|||||||
|
|
||||||
// 进程状态轮询
|
// 进程状态轮询
|
||||||
let statusTimer: ReturnType<typeof setInterval> | null = null
|
let statusTimer: ReturnType<typeof setInterval> | null = null
|
||||||
|
let trafficTimer: ReturnType<typeof setInterval> | null = null
|
||||||
|
let connTimer: ReturnType<typeof setInterval> | null = null
|
||||||
|
|
||||||
|
/** 字节 → 人类可读大小(B / KB / MB / GB / TB) */
|
||||||
|
function fmtBytes(v: number): string {
|
||||||
|
if (!v && v !== 0) return '--'
|
||||||
|
if (v < 1024) return v + ' B'
|
||||||
|
const units = ['KB', 'MB', 'GB', 'TB']
|
||||||
|
let n = v / 1024
|
||||||
|
let u = 0
|
||||||
|
while (n >= 1024 && u < units.length - 1) {
|
||||||
|
n /= 1024
|
||||||
|
u++
|
||||||
|
}
|
||||||
|
return (n >= 100 ? n.toFixed(0) : n >= 10 ? n.toFixed(1) : n.toFixed(2)) + ' ' + units[u]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 速率显示(字节/秒 → /s) */
|
||||||
|
function fmtSpeed(v: number): string {
|
||||||
|
return fmtBytes(v) + '/s'
|
||||||
|
}
|
||||||
|
|
||||||
const running = computed(() => store.status.running)
|
const running = computed(() => store.status.running)
|
||||||
|
|
||||||
|
// ===== 连接页签 =====
|
||||||
|
/** 当前连接列表(store.connections 可能为 null → 视为空) */
|
||||||
|
const connList = computed(() => store.connections ?? [])
|
||||||
|
const connFilter = ref('')
|
||||||
|
/** 内网/国内/国外 一键过滤('all' = 全部) */
|
||||||
|
const connScopeFilter = ref<'all' | ConnScope>('all')
|
||||||
|
/** 一键过滤选项 */
|
||||||
|
const scopeFilterOptions: { value: 'all' | ConnScope; label: string }[] = [
|
||||||
|
{ value: 'all', label: '全部' },
|
||||||
|
{ value: 'direct', label: '国内' },
|
||||||
|
{ value: 'proxy', label: '国外' }
|
||||||
|
]
|
||||||
|
/** 顶部下载/上传速率(复用实时流量快照的整体速率) */
|
||||||
|
const connTotalDownloadSec = computed(() => store.traffic?.downloadSpeed ?? 0)
|
||||||
|
const connTotalUploadSec = computed(() => store.traffic?.uploadSpeed ?? 0)
|
||||||
|
/** 命中规则总数 = 活跃连接数(每条连接命中一条规则) */
|
||||||
|
const ruleHitCount = computed(() => connList.value.length)
|
||||||
|
/** 按规则聚合当前连接,便于观察哪些规则被频繁命中 */
|
||||||
|
const ruleHits = computed(() => {
|
||||||
|
const map = new Map<string, number>()
|
||||||
|
for (const c of connList.value) {
|
||||||
|
const r = c.rule || 'DIRECT'
|
||||||
|
map.set(r, (map.get(r) ?? 0) + 1)
|
||||||
|
}
|
||||||
|
return [...map.entries()]
|
||||||
|
.map(([rule, count]) => ({ rule, count }))
|
||||||
|
.sort((a, b) => b.count - a.count)
|
||||||
|
})
|
||||||
|
/** 按内网/国内/国外、通用关键词过滤后的连接 */
|
||||||
|
const filteredConnections = computed(() => {
|
||||||
|
const q = connFilter.value.trim().toLowerCase()
|
||||||
|
return connList.value.filter((c) => {
|
||||||
|
if (connScopeFilter.value !== 'all' && connScopeOf(c) !== connScopeFilter.value) return false
|
||||||
|
if (!q) return true
|
||||||
|
const process = (c.metadata?.process ?? '').toLowerCase()
|
||||||
|
const host = (c.metadata?.host ?? '').toLowerCase()
|
||||||
|
const rule = (c.rule ?? '').toLowerCase()
|
||||||
|
return process.includes(q) || host.includes(q) || rule.includes(q)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
/** 连接进程显示名 */
|
||||||
|
const connProcess = (c: ProxyConnection) => c.metadata?.process || '未知'
|
||||||
|
/** 连接源地址显示(IP:端口) */
|
||||||
|
const connSource = (c: ProxyConnection) => {
|
||||||
|
const ip = c.metadata?.sourceIP
|
||||||
|
const port = c.metadata?.sourcePort
|
||||||
|
return ip ? `${ip}${port ? ':' + port : ''}` : '--'
|
||||||
|
}
|
||||||
|
/** 连接目标显示:优先 host,否则用 IP:端口 */
|
||||||
|
const connHost = (c: ProxyConnection) => {
|
||||||
|
const h = c.metadata?.host
|
||||||
|
if (h) return h
|
||||||
|
const ip = c.metadata?.destinationIP
|
||||||
|
const port = c.metadata?.destinationPort
|
||||||
|
return ip ? `${ip}${port ? ':' + port : ''}` : '--'
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 规则中文名 / 内外网判断 =====
|
||||||
|
/** 规则类型归一化:忽略大小写与 "-" "_" 空格(订阅里可能写成 DomainSuffix / DOMAIN-SUFFIX) */
|
||||||
|
const normRuleType = (s: string) => s.trim().replace(/[-_\s]/g, '').toLowerCase()
|
||||||
|
const RULE_CN: Record<string, string> = {
|
||||||
|
// 匹配/动作
|
||||||
|
match: '兜底', final: '兜底', ruleset: '规则集', direct: '直连', reject: '拒绝',
|
||||||
|
// 域名
|
||||||
|
domain: '域名', domainsuffix: '域名后缀', domainkeyword: '域名关键字', domainregex: '域名正则',
|
||||||
|
// 地理 / 站点
|
||||||
|
geoip: '地区', geosite: '域名组', ipasn: 'ASN',
|
||||||
|
// 地址网段
|
||||||
|
ipcidr: 'IP段', ipcidr6: 'IP段(v6)', srcipcidr: '源IP段', srcipcidr6: '源IP段(v6)',
|
||||||
|
dstnet: '目标地址', srcnet: '源地址', network: '网络类型',
|
||||||
|
// 端口
|
||||||
|
srcport: '源端口', dstport: '目标端口', srcportrange: '源端口范围', dstportrange: '目标端口范围',
|
||||||
|
// 进程 / 用户
|
||||||
|
process: '进程', processname: '进程名', processpath: '进程路径', processpathregex: '进程路径正则', uid: '用户ID',
|
||||||
|
// 入站
|
||||||
|
intype: '入站类型', inuser: '入站用户', inname: '入站名称', inport: '入站端口',
|
||||||
|
// 规则集衍生
|
||||||
|
rulesetipcidr: '规则集IP', rulesetipcidr6: '规则集IP(v6)', rulesetdomainsuffix: '规则集域名后缀',
|
||||||
|
rulesetdomainkeyword: '规则集域名关键字', rulesetdomainregex: '规则集域名正则', rulesetgeoip: '规则集地区',
|
||||||
|
// 逻辑
|
||||||
|
and: '与', not: '非', or: '或', subrule: '子规则'
|
||||||
|
}
|
||||||
|
/** 将 mihomo 规则翻译为中文类型名(仅替换类型关键字,保留匹配内容) */
|
||||||
|
const translateRule = (rule: string): string => {
|
||||||
|
const parts = rule.split(',')
|
||||||
|
const mapped = RULE_CN[normRuleType(parts[0])]
|
||||||
|
if (!mapped) return rule
|
||||||
|
return [mapped, ...parts.slice(1)].join(',')
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* 连接走向分类:只区分国内/国外。
|
||||||
|
* - direct 国内(未走代理;内网/局域网因代理过滤也已直连,归入国内)
|
||||||
|
* - proxy 国外(已走代理节点;代理多用于访问境外,故视为国外)
|
||||||
|
* 判定依据:链路最后一跳是否为 DIRECT。
|
||||||
|
*/
|
||||||
|
type ConnScope = 'direct' | 'proxy'
|
||||||
|
const connScopeOf = (c: ProxyConnection): ConnScope => {
|
||||||
|
const chain = c.chains
|
||||||
|
if (chain && chain.length) {
|
||||||
|
return chain[chain.length - 1] === 'DIRECT' ? 'direct' : 'proxy'
|
||||||
|
}
|
||||||
|
// 退化:无链路信息时按国内直连兜底
|
||||||
|
return 'direct'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 各规则的简短释义(供「规则命中」展示),仅为便于理解,非精确语义 */
|
||||||
|
const RULE_DESC: Record<string, string> = {
|
||||||
|
// 匹配/动作
|
||||||
|
match: '未匹配任何规则时的兜底', final: '未匹配任何规则时的兜底', direct: '直连', reject: '拒绝访问',
|
||||||
|
// 域名
|
||||||
|
domain: '完全匹配该域名', domainsuffix: '匹配该域名及其子域名', domainkeyword: '域名包含该关键词', domainregex: '域名按正则匹配',
|
||||||
|
// 地理 / 站点
|
||||||
|
geoip: '按 IP 所属国家/地区', geosite: '按域名所属站点类别', ipasn: '按 IP 所属 ASN 自治域',
|
||||||
|
// 地址网段
|
||||||
|
ipcidr: '匹配该 IP 网段', ipcidr6: '匹配该 IPv6 网段', srcipcidr: '按源 IP 网段', srcipcidr6: '按源 IPv6 网段',
|
||||||
|
dstnet: '按目标 IP/域名', srcnet: '按源 IP/域名', network: '按网络类型(TCP/UDP)',
|
||||||
|
// 端口
|
||||||
|
srcport: '按源端口', dstport: '按目标端口', srcportrange: '按源端口范围', dstportrange: '按目标端口范围',
|
||||||
|
// 进程 / 用户
|
||||||
|
process: '按进程', processname: '按进程名', processpath: '按进程可执行路径', processpathregex: '按进程路径正则',
|
||||||
|
uid: '按 Linux 用户 ID',
|
||||||
|
// 入站
|
||||||
|
intype: '按入站类型', inuser: '按入站用户', inname: '按入站名称', inport: '按入站端口',
|
||||||
|
// 规则集衍生
|
||||||
|
ruleset: '按规则集内容匹配',
|
||||||
|
rulesetipcidr: '匹配规则集中任一 IP 网段', rulesetipcidr6: '匹配规则集中任一 IPv6 网段',
|
||||||
|
rulesetdomainsuffix: '匹配规则集中任一域名后缀', rulesetdomainkeyword: '匹配规则集中任一域名关键字',
|
||||||
|
rulesetdomainregex: '匹配规则集正则', rulesetgeoip: '匹配规则集中任一地区',
|
||||||
|
// 逻辑
|
||||||
|
and: '多个条件同时满足(与)', or: '任一条件满足(或)', not: '取反(非)', subrule: '子规则分发'
|
||||||
|
}
|
||||||
|
/** 取了某条规则的类型释义;未知类型返回空串 */
|
||||||
|
const ruleDesc = (rule: string): string => {
|
||||||
|
return RULE_DESC[normRuleType(rule.split(',')[0])] ?? ''
|
||||||
|
}
|
||||||
|
/** 断开全部连接 */
|
||||||
|
const closeAllConnections = async () => {
|
||||||
|
const ok = await showConfirm({
|
||||||
|
title: '断开全部连接',
|
||||||
|
description: `确定断开当前 ${connList.value.length} 条活跃连接吗?`,
|
||||||
|
confirmText: '断开',
|
||||||
|
destructive: true
|
||||||
|
})
|
||||||
|
if (!ok) return
|
||||||
|
for (const c of [...connList.value]) {
|
||||||
|
await store.closeConnection(c.id).catch(() => {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 伪节点关键词:DIRECT/REJECT/流量/套餐等非具体代理节点
|
// 伪节点关键词:DIRECT/REJECT/流量/套餐等非具体代理节点
|
||||||
const PSEUDO_NODE_KEYWORDS = [
|
const PSEUDO_NODE_KEYWORDS = [
|
||||||
'DIRECT', 'REJECT', 'PASS', 'COMPATIBLE',
|
'DIRECT', 'REJECT', 'PASS', 'COMPATIBLE',
|
||||||
@@ -353,6 +525,16 @@ onMounted(() => {
|
|||||||
// 同步系统代理真实状态(注册表可能被外部改动,3s 周期足够感知)
|
// 同步系统代理真实状态(注册表可能被外部改动,3s 周期足够感知)
|
||||||
await store.refreshSystemProxy()
|
await store.refreshSystemProxy()
|
||||||
}, 3000)
|
}, 3000)
|
||||||
|
// 流量采样:运行中每秒拉取一次实时速率/累计流量
|
||||||
|
trafficTimer = setInterval(async () => {
|
||||||
|
if (document.hidden) return
|
||||||
|
if (running.value) await store.refreshTraffic()
|
||||||
|
}, 1000)
|
||||||
|
// 连接列表:仅「连接」页签激活且运行时低频拉取
|
||||||
|
connTimer = setInterval(async () => {
|
||||||
|
if (document.hidden) return
|
||||||
|
if (activeTab.value === 'connections' && running.value) await store.refreshConnections()
|
||||||
|
}, 3000)
|
||||||
// 页面重新可见时立即刷新一次系统代理状态(切回标签页/从托盘返回主窗口)
|
// 页面重新可见时立即刷新一次系统代理状态(切回标签页/从托盘返回主窗口)
|
||||||
document.addEventListener('visibilitychange', onVisibilityChange)
|
document.addEventListener('visibilitychange', onVisibilityChange)
|
||||||
// 监听后端自动切换节点完成事件(后台执行,不依赖模块激活)
|
// 监听后端自动切换节点完成事件(后台执行,不依赖模块激活)
|
||||||
@@ -363,6 +545,8 @@ onMounted(() => {
|
|||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
if (statusTimer) clearInterval(statusTimer)
|
if (statusTimer) clearInterval(statusTimer)
|
||||||
|
if (trafficTimer) clearInterval(trafficTimer)
|
||||||
|
if (connTimer) clearInterval(connTimer)
|
||||||
document.removeEventListener('visibilitychange', onVisibilityChange)
|
document.removeEventListener('visibilitychange', onVisibilityChange)
|
||||||
autoSwitchUnlisten.forEach(fn => fn())
|
autoSwitchUnlisten.forEach(fn => fn())
|
||||||
autoSwitchUnlisten = []
|
autoSwitchUnlisten = []
|
||||||
@@ -960,8 +1144,9 @@ tabsStore.registerSave(saveSettingsForm)
|
|||||||
<div class="h-full p-6">
|
<div class="h-full p-6">
|
||||||
<Tabs v-model="activeTab" class="h-full flex flex-col">
|
<Tabs v-model="activeTab" class="h-full flex flex-col">
|
||||||
<div ref="tabsListRef">
|
<div ref="tabsListRef">
|
||||||
<TabsList class="grid w-full grid-cols-4 max-w-md !bg-transparent !p-0 !shadow-none">
|
<TabsList class="grid w-full grid-cols-5 max-w-md !bg-transparent !p-0 !shadow-none">
|
||||||
<TabsTrigger value="overview" class="gap-1.5"><Globe class="size-3.5" />概览</TabsTrigger>
|
<TabsTrigger value="overview" class="gap-1.5"><Globe class="size-3.5" />概览</TabsTrigger>
|
||||||
|
<TabsTrigger value="connections" class="gap-1.5"><Waypoints class="size-3.5" />连接</TabsTrigger>
|
||||||
<TabsTrigger value="proxies" class="gap-1.5"><Server class="size-3.5" />节点</TabsTrigger>
|
<TabsTrigger value="proxies" class="gap-1.5"><Server class="size-3.5" />节点</TabsTrigger>
|
||||||
<TabsTrigger value="profiles" class="gap-1.5"><ListChecks class="size-3.5" />订阅</TabsTrigger>
|
<TabsTrigger value="profiles" class="gap-1.5"><ListChecks class="size-3.5" />订阅</TabsTrigger>
|
||||||
<TabsTrigger value="settings" class="gap-1.5"><SettingsIcon class="size-3.5" />设置</TabsTrigger>
|
<TabsTrigger value="settings" class="gap-1.5"><SettingsIcon class="size-3.5" />设置</TabsTrigger>
|
||||||
@@ -972,6 +1157,42 @@ tabsStore.registerSave(saveSettingsForm)
|
|||||||
<TabsContent value="overview" class="flex-1 mt-4 tab-animate">
|
<TabsContent value="overview" class="flex-1 mt-4 tab-animate">
|
||||||
<ScrollArea class="h-full pr-3">
|
<ScrollArea class="h-full pr-3">
|
||||||
<div class="columns-1 md:columns-2 gap-4 [&>*]:mb-4 [&>*]:break-inside-avoid">
|
<div class="columns-1 md:columns-2 gap-4 [&>*]:mb-4 [&>*]:break-inside-avoid">
|
||||||
|
<!-- 实时流量 -->
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle class="flex items-center justify-between text-base">
|
||||||
|
<span class="flex items-center gap-2"><Activity class="size-4 text-primary" />实时流量</span>
|
||||||
|
<Badge v-if="running" variant="outline" class="gap-1 text-xs">
|
||||||
|
<span class="size-1.5 rounded-full bg-emerald-500" />实时更新
|
||||||
|
</Badge>
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent class="space-y-4 text-sm">
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center gap-1 text-muted-foreground text-xs mb-1">
|
||||||
|
<ArrowDown class="size-3.5 text-emerald-500" />下载
|
||||||
|
</div>
|
||||||
|
<p class="text-lg font-semibold tabular-nums">{{ fmtSpeed(store.traffic?.downloadSpeed ?? 0) }}</p>
|
||||||
|
<p class="text-xs text-muted-foreground tabular-nums">累计 {{ store.traffic ? fmtBytes(store.traffic.downloadTotal) : '--' }}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center gap-1 text-muted-foreground text-xs mb-1">
|
||||||
|
<ArrowUp class="size-3.5 text-rose-500" />上传
|
||||||
|
</div>
|
||||||
|
<p class="text-lg font-semibold tabular-nums">{{ fmtSpeed(store.traffic?.uploadSpeed ?? 0) }}</p>
|
||||||
|
<p class="text-xs text-muted-foreground tabular-nums">累计 {{ store.traffic ? fmtBytes(store.traffic.uploadTotal) : '--' }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Separator />
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="flex items-center gap-1.5 text-muted-foreground">
|
||||||
|
<Waypoints class="size-3.5" />活跃连接
|
||||||
|
</span>
|
||||||
|
<span class="font-semibold tabular-nums">{{ store.traffic?.activeConnections ?? '--' }}</span>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
<!-- 内核状态 -->
|
<!-- 内核状态 -->
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
@@ -1436,6 +1657,139 @@ tabsStore.registerSave(saveSettingsForm)
|
|||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
<!-- 连接 -->
|
||||||
|
<TabsContent value="connections" class="flex-1 mt-4 tab-animate">
|
||||||
|
<div v-if="!running" key="conn-not-running" class="h-full flex flex-col items-center justify-center text-muted-foreground gap-2 pt-10 pr-10">
|
||||||
|
<Waypoints class="size-12 opacity-30" />
|
||||||
|
<p class="text-sm">mihomo 未运行,请先在概览页启动</p>
|
||||||
|
</div>
|
||||||
|
<div v-else class="h-full flex flex-col gap-4 pr-3">
|
||||||
|
<!-- 顶部统计 -->
|
||||||
|
<div class="grid grid-cols-2 md:grid-cols-4 gap-2">
|
||||||
|
<Card>
|
||||||
|
<CardContent class="py-3">
|
||||||
|
<div class="flex items-center gap-1.5 text-muted-foreground text-xs mb-1"><Waypoints class="size-3.5" />活跃连接</div>
|
||||||
|
<p class="text-xl font-semibold tabular-nums">{{ connList.length }}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardContent class="py-3">
|
||||||
|
<div class="flex items-center gap-1.5 text-muted-foreground text-xs mb-1"><ArrowDown class="size-3.5 text-emerald-500" />下载速率</div>
|
||||||
|
<p class="text-xl font-semibold tabular-nums">{{ fmtSpeed(connTotalDownloadSec) }}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardContent class="py-3">
|
||||||
|
<div class="flex items-center gap-1.5 text-muted-foreground text-xs mb-1"><ArrowUp class="size-3.5 text-rose-500" />上传速率</div>
|
||||||
|
<p class="text-xl font-semibold tabular-nums">{{ fmtSpeed(connTotalUploadSec) }}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardContent class="py-3">
|
||||||
|
<div class="flex items-center gap-1.5 text-muted-foreground text-xs mb-1"><Target class="size-3.5" />命中规则</div>
|
||||||
|
<p class="text-xl font-semibold tabular-nums">{{ ruleHitCount }}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 规则命中分布 -->
|
||||||
|
<Card>
|
||||||
|
<CardHeader class="pb-2">
|
||||||
|
<CardTitle class="text-sm flex items-center gap-2"><Target class="size-3.5 text-primary" />规则命中</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent class="pt-0">
|
||||||
|
<div v-if="!ruleHits.length" class="text-xs text-muted-foreground py-2">暂无连接</div>
|
||||||
|
<div v-else class="space-y-1.5">
|
||||||
|
<div v-for="r in ruleHits.slice(0, 6)" :key="r.rule" class="flex items-baseline gap-2 text-xs">
|
||||||
|
<span class="w-1.5 h-1.5 rounded-full bg-primary shrink-0 self-center" />
|
||||||
|
<span class="font-medium shrink-0">{{ translateRule(r.rule) }}</span>
|
||||||
|
<span class="flex-1 min-w-0 truncate text-muted-foreground">
|
||||||
|
<template v-if="ruleDesc(r.rule)">({{ ruleDesc(r.rule) }})</template>
|
||||||
|
</span>
|
||||||
|
<span class="shrink-0 tabular-nums">×{{ r.count }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<!-- 连接列表 -->
|
||||||
|
<Card class="flex-1 min-h-0 flex flex-col">
|
||||||
|
<CardHeader class="pb-2 space-y-2">
|
||||||
|
<div class="flex items-center justify-between gap-3">
|
||||||
|
<CardTitle class="text-sm flex items-center gap-2"><Waypoints class="size-3.5 text-primary" />当前连接</CardTitle>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Input v-model="connFilter" placeholder="按进程/域名/规则过滤" class="h-8 w-56" />
|
||||||
|
<Button size="xs" variant="outline" :disabled="!connList.length" @click="closeAllConnections">
|
||||||
|
<Square class="size-3" />断开全部
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap items-center gap-1.5">
|
||||||
|
<span class="text-xs text-muted-foreground">走向:</span>
|
||||||
|
<Button
|
||||||
|
v-for="s in scopeFilterOptions"
|
||||||
|
:key="s.value"
|
||||||
|
size="xs"
|
||||||
|
:variant="connScopeFilter === s.value ? 'default' : 'outline'"
|
||||||
|
@click="connScopeFilter = s.value"
|
||||||
|
>{{ s.label }}</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent class="flex-1 min-h-0 overflow-hidden pt-0">
|
||||||
|
<ScrollArea class="h-full">
|
||||||
|
<table class="w-full text-xs">
|
||||||
|
<thead class="sticky top-0 z-10 bg-card text-muted-foreground">
|
||||||
|
<tr class="border-b">
|
||||||
|
<th class="text-left font-medium py-2 px-2">进程 / 源地址</th>
|
||||||
|
<th class="text-left font-medium py-2 px-2">目标</th>
|
||||||
|
<th class="text-left font-medium py-2 px-2">规则</th>
|
||||||
|
<th class="text-right font-medium py-2 px-2">下载</th>
|
||||||
|
<th class="text-right font-medium py-2 px-2">上传</th>
|
||||||
|
<th class="text-center font-medium py-2 px-2"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="c in filteredConnections" :key="c.id" class="border-b last:border-0 hover:bg-muted/40">
|
||||||
|
<td class="py-2 px-2 align-baseline">
|
||||||
|
<span class="font-medium truncate block max-w-[140px]">{{ connProcess(c) }}</span>
|
||||||
|
<span class="text-muted-foreground">{{ connSource(c) }}</span>
|
||||||
|
</td>
|
||||||
|
<td class="py-2 px-2 align-baseline">
|
||||||
|
<div class="flex items-center gap-1.5">
|
||||||
|
<Badge
|
||||||
|
v-if="connScopeOf(c) === 'direct'"
|
||||||
|
variant="outline" class="h-4 px-1.5 text-[10px] shrink-0 border-emerald-500 text-emerald-500"
|
||||||
|
>国内</Badge>
|
||||||
|
<Badge
|
||||||
|
v-else
|
||||||
|
variant="outline" class="h-4 px-1.5 text-[10px] shrink-0 border-sky-500 text-sky-500"
|
||||||
|
>国外</Badge>
|
||||||
|
<span class="truncate block max-w-[140px]">{{ connHost(c) }}</span>
|
||||||
|
</div>
|
||||||
|
<span class="text-muted-foreground">{{ c.metadata?.network }} / {{ c.metadata?.type }}</span>
|
||||||
|
</td>
|
||||||
|
<td class="py-2 px-2 align-baseline text-muted-foreground">
|
||||||
|
<span class="truncate block max-w-[160px]">{{ translateRule(c.rule || 'DIRECT') }}</span>
|
||||||
|
</td>
|
||||||
|
<td class="py-2 px-2 text-right tabular-nums align-baseline">{{ fmtBytes(c.download) }}</td>
|
||||||
|
<td class="py-2 px-2 text-right tabular-nums align-baseline">{{ fmtBytes(c.upload) }}</td>
|
||||||
|
<td class="py-2 px-2 text-center align-baseline">
|
||||||
|
<Button size="icon" variant="ghost" class="size-6" title="断开连接" @click="store.closeConnection(c.id)">
|
||||||
|
<X class="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="!connList.length">
|
||||||
|
<td colspan="6" class="text-center text-muted-foreground py-8">暂无活跃连接</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</ScrollArea>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
<!-- 节点 -->
|
<!-- 节点 -->
|
||||||
<TabsContent value="proxies" class="flex-1 mt-4 tab-animate">
|
<TabsContent value="proxies" class="flex-1 mt-4 tab-animate">
|
||||||
<div v-if="!running" key="not-running" class="h-full flex flex-col items-center justify-center text-muted-foreground gap-2 pt-10 pr-10">
|
<div v-if="!running" key="not-running" class="h-full flex flex-col items-center justify-center text-muted-foreground gap-2 pt-10 pr-10">
|
||||||
|
|||||||
@@ -5,11 +5,10 @@ import { getCurrentWindow, LogicalSize, Effect, EffectState } from '@tauri-apps/
|
|||||||
// Rust 端通过 tauri-specta 生成的命令绑定(bindings.ts)
|
// Rust 端通过 tauri-specta 生成的命令绑定(bindings.ts)
|
||||||
import { commands } from '@/lib/bindings'
|
import { commands } from '@/lib/bindings'
|
||||||
import type { ArchiveInfo, DeleteResult, ExtractResult, FileEntry, RenamePreview, RenameResult } from '@/lib/bindings'
|
import type { ArchiveInfo, DeleteResult, ExtractResult, FileEntry, RenamePreview, RenameResult } from '@/lib/bindings'
|
||||||
import { Search, Command, Loader2, CornerDownLeft, Calculator, Globe, Lock, ChevronRight, ChevronLeft, History, FolderOpen, Ruler, Trash2, Terminal, Archive as ArchiveIcon, Regex, FileText, Settings } from '@lucide/vue'
|
import { Search, Command, Loader2, CornerDownLeft, Calculator, Globe, Lock, ChevronRight, ChevronLeft, ChevronDown, History, FolderOpen, Ruler, Trash2, Terminal, Archive as ArchiveIcon, Regex, FileText, Settings } from '@lucide/vue'
|
||||||
import { Accordion, AccordionItem, AccordionTrigger, AccordionContent } from '@/components/ui/accordion'
|
|
||||||
import { TooltipProvider, Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'
|
import { TooltipProvider, Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'
|
||||||
import { Switch } from '@/components/ui/switch'
|
import { Switch } from '@/components/ui/switch'
|
||||||
import { aggregateSearch, loadAppIconsForResults, recordHistoryItem, getMoreHistoryItems, getMoreHistoryCount, setFileIndexReady, type QPItem, type QPSubAction } from './providers'
|
import { aggregateSearch, loadAppIconsForResults, recordHistoryItem, getAllHistoryItems, setFileIndexReady, type QPItem, type QPSubAction } from './providers'
|
||||||
import { EVENTS, STORAGE_KEYS } from '@/lib/constants'
|
import { EVENTS, STORAGE_KEYS } from '@/lib/constants'
|
||||||
import HistoryPicker from './HistoryPicker.vue'
|
import HistoryPicker from './HistoryPicker.vue'
|
||||||
|
|
||||||
@@ -143,14 +142,32 @@ const renameResults = ref<RenameResult[] | null>(null)
|
|||||||
const renameOkCount = ref(0)
|
const renameOkCount = ref(0)
|
||||||
let renameTimer: ReturnType<typeof setTimeout> | null = null
|
let renameTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
// ===== 历史分区(从 results 中分离历史项与其他结果) =====
|
// ===== 结果分区(从 results 中分离历史项与其他结果) =====
|
||||||
// 展示顺序:目录操作(批量解压/重命名/删除)> 历史 > 更多历史 > 其他
|
// 展示顺序:目录操作(批量解压/重命名/删除)> 历史(折叠分组)> 其他
|
||||||
const dirActionItems = computed(() => results.value.filter(r => r.group === '目录操作'))
|
// 目录操作为置顶行为项;历史在空查询时置顶但默认折叠,按 Tab 展开
|
||||||
const historyItems = computed(() => results.value.filter(r => r.group === '历史'))
|
const dirActionItems = ref<QPItem[]>([])
|
||||||
const otherItems = computed(() => results.value.filter(r => r.group !== '历史' && r.group !== '目录操作'))
|
const otherItems = computed(() => results.value)
|
||||||
// Accordion 中的更多历史项(不参与键盘上下导航,仅鼠标点击)
|
// 历史项单独从 localStorage 加载(不再混入 results),默认折叠
|
||||||
const moreHistoryItems = ref<QPItem[]>([])
|
const historyItems = ref<QPItem[]>([])
|
||||||
const moreHistoryCount = ref(0)
|
const historyExpanded = ref(false)
|
||||||
|
|
||||||
|
// 键盘导航扁平化序号:目录操作 + 历史(展开时)+ 其他
|
||||||
|
const dirCount = computed(() => dirActionItems.value.length)
|
||||||
|
const historyCount = computed(() => historyItems.value.length)
|
||||||
|
const otherCount = computed(() => otherItems.value.length)
|
||||||
|
const otherNavStart = computed(() => dirCount.value + (historyExpanded.value ? historyCount.value : 0))
|
||||||
|
const navTotal = computed(() => otherNavStart.value + otherCount.value)
|
||||||
|
// 键盘导航项(selectedIndex 指向该扁平数组):目录操作 + 历史(展开时)+ 其他
|
||||||
|
const navItems = computed<QPItem[]>(() => [
|
||||||
|
...dirActionItems.value,
|
||||||
|
...(historyExpanded.value ? historyItems.value : []),
|
||||||
|
...otherItems.value,
|
||||||
|
])
|
||||||
|
|
||||||
|
function toggleHistory() {
|
||||||
|
collapseSubActions()
|
||||||
|
historyExpanded.value = !historyExpanded.value
|
||||||
|
}
|
||||||
|
|
||||||
// ===== 历史频率(localStorage 持久化,用于排序加权) =====
|
// ===== 历史频率(localStorage 持久化,用于排序加权) =====
|
||||||
const HISTORY_KEY = STORAGE_KEYS.quickpanelHistory
|
const HISTORY_KEY = STORAGE_KEYS.quickpanelHistory
|
||||||
@@ -192,24 +209,25 @@ async function doSearch() {
|
|||||||
const seq = ++searchSeq
|
const seq = ++searchSeq
|
||||||
const q = query.value.trim()
|
const q = query.value.trim()
|
||||||
if (!q) {
|
if (!q) {
|
||||||
// 空查询:当前目录文件操作(若检测到 Explorer 目录)+ 历史置顶 + 系统相关条目
|
// 空查询:目录操作(若检测到 Explorer 目录)+ 历史置顶(默认折叠)+ 系统相关条目
|
||||||
// (程序相关设置不参与默认展示;所有 Provider 空查询零 IPC,首屏即时)
|
// (程序相关设置不参与默认展示;所有 Provider 空查询零 IPC,首屏即时)
|
||||||
const items = await aggregateSearch('')
|
const items = await aggregateSearch('')
|
||||||
if (seq !== searchSeq) return // 过期请求丢弃
|
if (seq !== searchSeq) return // 过期请求丢弃
|
||||||
const dirItems = getExplorerActions()
|
dirActionItems.value = getExplorerActions()
|
||||||
results.value = applyHistoryBoost([...dirItems, ...items])
|
results.value = applyHistoryBoost(items)
|
||||||
|
// 历史置顶但默认折叠,按 Tab 展开(每次显示重置为折叠态)
|
||||||
|
historyItems.value = getAllHistoryItems()
|
||||||
|
historyExpanded.value = false
|
||||||
selectedIndex.value = 0
|
selectedIndex.value = 0
|
||||||
// 加载更多历史(Accordion 折叠区,不参与键盘导航)
|
|
||||||
moreHistoryItems.value = getMoreHistoryItems()
|
|
||||||
moreHistoryCount.value = getMoreHistoryCount()
|
|
||||||
// 后台加载应用图标(含历史中的图标)
|
// 后台加载应用图标(含历史中的图标)
|
||||||
void loadAppIconsForResults(results.value)
|
void loadAppIconsForResults(results.value)
|
||||||
void loadAppIconsForResults(moreHistoryItems.value)
|
void loadAppIconsForResults(historyItems.value)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// 非空查询:清空历史分区
|
// 非空查询:清空历史分区与目录操作
|
||||||
moreHistoryItems.value = []
|
dirActionItems.value = []
|
||||||
moreHistoryCount.value = 0
|
historyItems.value = []
|
||||||
|
historyExpanded.value = false
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const items = await aggregateSearch(q)
|
const items = await aggregateSearch(q)
|
||||||
@@ -680,7 +698,7 @@ async function confirmDelete() {
|
|||||||
|
|
||||||
// 子动作展开/收起
|
// 子动作展开/收起
|
||||||
function toggleSubActions(idx: number) {
|
function toggleSubActions(idx: number) {
|
||||||
const item = results.value[idx]
|
const item = navItems.value[idx]
|
||||||
if (!item?.subActions?.length) return
|
if (!item?.subActions?.length) return
|
||||||
if (subActionExpanded.value === idx) {
|
if (subActionExpanded.value === idx) {
|
||||||
subActionExpanded.value = null
|
subActionExpanded.value = null
|
||||||
@@ -697,7 +715,7 @@ function collapseSubActions() {
|
|||||||
// 当前展开的子动作列表
|
// 当前展开的子动作列表
|
||||||
function currentSubActions(): QPSubAction[] {
|
function currentSubActions(): QPSubAction[] {
|
||||||
if (subActionExpanded.value === null) return []
|
if (subActionExpanded.value === null) return []
|
||||||
return results.value[subActionExpanded.value]?.subActions || []
|
return navItems.value[subActionExpanded.value]?.subActions || []
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== 键盘导航 =====
|
// ===== 键盘导航 =====
|
||||||
@@ -725,7 +743,7 @@ function onKeydown(e: KeyboardEvent) {
|
|||||||
|
|
||||||
const expanded = subActionExpanded.value !== null
|
const expanded = subActionExpanded.value !== null
|
||||||
const subs = currentSubActions()
|
const subs = currentSubActions()
|
||||||
const expandedItem = expanded ? results.value[subActionExpanded.value!] : undefined
|
const expandedItem = expanded ? navItems.value[subActionExpanded.value!] : undefined
|
||||||
|
|
||||||
if (expanded) {
|
if (expanded) {
|
||||||
// 子动作导航模式
|
// 子动作导航模式
|
||||||
@@ -760,7 +778,7 @@ function onKeydown(e: KeyboardEvent) {
|
|||||||
// 结果列表导航模式
|
// 结果列表导航模式
|
||||||
if (e.key === 'ArrowDown') {
|
if (e.key === 'ArrowDown') {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
selectedIndex.value = Math.min(selectedIndex.value + 1, results.value.length - 1)
|
selectedIndex.value = Math.min(selectedIndex.value + 1, navTotal.value - 1)
|
||||||
scrollSelectedIntoView()
|
scrollSelectedIntoView()
|
||||||
} else if (e.key === 'ArrowUp') {
|
} else if (e.key === 'ArrowUp') {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@@ -768,19 +786,25 @@ function onKeydown(e: KeyboardEvent) {
|
|||||||
scrollSelectedIntoView()
|
scrollSelectedIntoView()
|
||||||
} else if (e.key === 'Enter') {
|
} else if (e.key === 'Enter') {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
const item = results.value[selectedIndex.value]
|
const item = navItems.value[selectedIndex.value]
|
||||||
if (item) executeItem(item)
|
if (item) executeItem(item)
|
||||||
} else if (e.key === 'Escape') {
|
} else if (e.key === 'Escape') {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
hideWindow()
|
hideWindow()
|
||||||
} else if (e.key === 'Tab') {
|
} else if (e.key === 'Tab') {
|
||||||
// Tab 展开子动作
|
if (!query.value.trim()) {
|
||||||
const item = results.value[selectedIndex.value]
|
// 默认视图:Tab 展开/收起历史分组
|
||||||
|
e.preventDefault()
|
||||||
|
toggleHistory()
|
||||||
|
} else {
|
||||||
|
// 搜索视图:Tab 展开子动作
|
||||||
|
const item = navItems.value[selectedIndex.value]
|
||||||
if (item?.subActions?.length) {
|
if (item?.subActions?.length) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
toggleSubActions(selectedIndex.value)
|
toggleSubActions(selectedIndex.value)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function scrollSelectedIntoView() {
|
function scrollSelectedIntoView() {
|
||||||
@@ -1393,13 +1417,35 @@ onUnmounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- 历史置顶项(可键盘导航,索引偏移 dirActionItems.length) -->
|
<!-- 历史分组:置顶、默认折叠,按 Tab 展开 -->
|
||||||
|
<div v-if="historyCount > 0" class="qp-history-section">
|
||||||
|
<div
|
||||||
|
class="qp-item qp-history-trigger"
|
||||||
|
@click="toggleHistory"
|
||||||
|
>
|
||||||
|
<History class="size-4 text-muted-foreground shrink-0" />
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<p class="text-sm">历史</p>
|
||||||
|
<p class="text-xs text-muted-foreground">{{ historyCount }} 条最近记录</p>
|
||||||
|
</div>
|
||||||
|
<kbd class="qp-kbd shrink-0" @click.stop="toggleHistory">Tab</kbd>
|
||||||
|
<ChevronDown
|
||||||
|
v-if="historyExpanded"
|
||||||
|
class="h-3.5 w-3.5 text-muted-foreground shrink-0"
|
||||||
|
/>
|
||||||
|
<ChevronRight
|
||||||
|
v-else
|
||||||
|
class="h-3.5 w-3.5 text-muted-foreground shrink-0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<!-- 展开后的历史项(索引偏移 dirCount,可键盘导航) -->
|
||||||
|
<template v-if="historyExpanded">
|
||||||
<template v-for="(item, idx) in historyItems" :key="item.id">
|
<template v-for="(item, idx) in historyItems" :key="item.id">
|
||||||
<div
|
<div
|
||||||
class="qp-item"
|
class="qp-item"
|
||||||
:class="{ 'qp-item-selected': (idx + dirActionItems.length) === selectedIndex }"
|
:class="{ 'qp-item-selected': (dirCount + idx) === selectedIndex }"
|
||||||
@click="executeItem(item)"
|
@click="executeItem(item)"
|
||||||
@mouseenter="onItemHover(idx + dirActionItems.length)"
|
@mouseenter="onItemHover(dirCount + idx)"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
v-if="item.iconUrl"
|
v-if="item.iconUrl"
|
||||||
@@ -1419,62 +1465,21 @@ onUnmounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
<span class="qp-group-badge" :class="groupBadgeClass(item.group)">{{ item.group }}</span>
|
<span class="qp-group-badge" :class="groupBadgeClass(item.group)">{{ item.group }}</span>
|
||||||
<CornerDownLeft
|
<CornerDownLeft
|
||||||
v-if="(idx + dirActionItems.length) === selectedIndex"
|
v-if="(dirCount + idx) === selectedIndex"
|
||||||
class="h-3.5 w-3.5 text-primary shrink-0"
|
class="h-3.5 w-3.5 text-primary shrink-0"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
</template>
|
||||||
<!-- 更多历史 Accordion(固定在历史下方,不参与键盘导航) -->
|
|
||||||
<Accordion
|
|
||||||
v-if="moreHistoryCount > 0"
|
|
||||||
type="single"
|
|
||||||
collapsible
|
|
||||||
class="qp-more-history"
|
|
||||||
>
|
|
||||||
<AccordionItem value="more" class="border-0">
|
|
||||||
<AccordionTrigger class="qp-more-trigger">
|
|
||||||
<span class="flex items-center gap-2">
|
|
||||||
<History class="h-3.5 w-3.5 text-muted-foreground" />
|
|
||||||
更多历史({{ moreHistoryCount }} 条)
|
|
||||||
</span>
|
|
||||||
</AccordionTrigger>
|
|
||||||
<AccordionContent class="qp-more-content">
|
|
||||||
<div
|
|
||||||
v-for="item in moreHistoryItems"
|
|
||||||
:key="item.id"
|
|
||||||
class="qp-item qp-more-item"
|
|
||||||
@click="executeItem(item)"
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
v-if="item.iconUrl"
|
|
||||||
:src="item.iconUrl"
|
|
||||||
class="qp-app-icon shrink-0"
|
|
||||||
alt=""
|
|
||||||
/>
|
|
||||||
<component
|
|
||||||
v-else
|
|
||||||
:is="groupIcon(item.group)"
|
|
||||||
class="size-4 text-muted-foreground shrink-0"
|
|
||||||
:class="isAppLike(item) ? '' : 'mt-0.5'"
|
|
||||||
/>
|
|
||||||
<div class="flex-1 min-w-0 flex" :class="isAppLike(item) ? 'items-center' : 'flex-col'">
|
|
||||||
<p class="text-sm truncate">{{ item.title }}</p>
|
|
||||||
<p v-if="!isAppLike(item) && item.subtitle" class="text-xs text-muted-foreground truncate">{{ item.subtitle }}</p>
|
|
||||||
</div>
|
</div>
|
||||||
<span class="qp-group-badge" :class="groupBadgeClass(item.group)">{{ item.group }}</span>
|
|
||||||
</div>
|
|
||||||
</AccordionContent>
|
|
||||||
</AccordionItem>
|
|
||||||
</Accordion>
|
|
||||||
|
|
||||||
<!-- 其他结果(设置/应用/系统等,可键盘导航,索引偏移 dirActionItems.length + historyItems.length) -->
|
<!-- 其他结果(设置/应用/系统等,可键盘导航,索引偏移 otherNavStart) -->
|
||||||
<template v-for="(item, idx) in otherItems" :key="item.id">
|
<template v-for="(item, idx) in otherItems" :key="item.id">
|
||||||
<div
|
<div
|
||||||
class="qp-item"
|
class="qp-item"
|
||||||
:class="{ 'qp-item-selected': (idx + dirActionItems.length + historyItems.length) === selectedIndex }"
|
:class="{ 'qp-item-selected': (otherNavStart + idx) === selectedIndex }"
|
||||||
@click="executeItem(item)"
|
@click="executeItem(item)"
|
||||||
@mouseenter="onItemHover(idx + dirActionItems.length + historyItems.length)"
|
@mouseenter="onItemHover(otherNavStart + idx)"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
v-if="item.iconUrl"
|
v-if="item.iconUrl"
|
||||||
@@ -1494,21 +1499,21 @@ onUnmounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
<span class="qp-group-badge" :class="groupBadgeClass(item.group)">{{ item.group }}</span>
|
<span class="qp-group-badge" :class="groupBadgeClass(item.group)">{{ item.group }}</span>
|
||||||
<ChevronRight
|
<ChevronRight
|
||||||
v-if="item.subActions?.length && (idx + dirActionItems.length + historyItems.length) !== selectedIndex"
|
v-if="item.subActions?.length && (otherNavStart + idx) !== selectedIndex"
|
||||||
class="h-3.5 w-3.5 text-muted-foreground shrink-0"
|
class="h-3.5 w-3.5 text-muted-foreground shrink-0"
|
||||||
/>
|
/>
|
||||||
<kbd
|
<kbd
|
||||||
v-else-if="item.subActions?.length && (idx + dirActionItems.length + historyItems.length) === selectedIndex"
|
v-else-if="item.subActions?.length && (otherNavStart + idx) === selectedIndex"
|
||||||
class="qp-kbd shrink-0"
|
class="qp-kbd shrink-0"
|
||||||
@click.stop="toggleSubActions(idx + dirActionItems.length + historyItems.length)"
|
@click.stop="toggleSubActions(otherNavStart + idx)"
|
||||||
>Tab</kbd>
|
>Tab</kbd>
|
||||||
<CornerDownLeft
|
<CornerDownLeft
|
||||||
v-else-if="(idx + dirActionItems.length + historyItems.length) === selectedIndex"
|
v-else-if="(otherNavStart + idx) === selectedIndex"
|
||||||
class="h-3.5 w-3.5 text-primary shrink-0"
|
class="h-3.5 w-3.5 text-primary shrink-0"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<!-- 子动作展开面板 -->
|
<!-- 子动作展开面板 -->
|
||||||
<div v-if="subActionExpanded === (idx + dirActionItems.length + historyItems.length) && item.subActions?.length" class="qp-sub-panel">
|
<div v-if="subActionExpanded === (otherNavStart + idx) && item.subActions?.length" class="qp-sub-panel">
|
||||||
<div
|
<div
|
||||||
v-for="(sub, sIdx) in item.subActions"
|
v-for="(sub, sIdx) in item.subActions"
|
||||||
:key="sub.id"
|
:key="sub.id"
|
||||||
@@ -1529,7 +1534,8 @@ onUnmounted(() => {
|
|||||||
<!-- 底部提示 -->
|
<!-- 底部提示 -->
|
||||||
<div class="qp-footer">
|
<div class="qp-footer">
|
||||||
<span><kbd>↑</kbd><kbd>↓</kbd> 导航</span>
|
<span><kbd>↑</kbd><kbd>↓</kbd> 导航</span>
|
||||||
<span v-if="subActionExpanded === null"><kbd>Tab</kbd> 子动作</span>
|
<span v-if="!query.trim()"><kbd>Tab</kbd> 历史</span>
|
||||||
|
<span v-else-if="subActionExpanded === null"><kbd>Tab</kbd> 子动作</span>
|
||||||
<span v-else><kbd>1-9</kbd> 快捷执行</span>
|
<span v-else><kbd>1-9</kbd> 快捷执行</span>
|
||||||
<span><kbd>Enter</kbd> 执行</span>
|
<span><kbd>Enter</kbd> 执行</span>
|
||||||
<span><kbd>Esc</kbd> {{ subActionExpanded !== null ? '收起' : '关闭' }}</span>
|
<span><kbd>Esc</kbd> {{ subActionExpanded !== null ? '收起' : '关闭' }}</span>
|
||||||
@@ -1995,35 +2001,15 @@ onUnmounted(() => {
|
|||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 更多历史 Accordion */
|
/* 历史折叠分组 */
|
||||||
.qp-more-history {
|
.qp-history-section {
|
||||||
margin: 0 6px 4px;
|
margin: 0 6px 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.qp-more-trigger {
|
.qp-history-trigger {
|
||||||
padding: 6px 12px;
|
min-height: 36px;
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--muted-foreground);
|
|
||||||
min-height: 28px;
|
|
||||||
border-radius: var(--radius);
|
|
||||||
/* 覆盖 reka-ui 默认 py-4 */
|
|
||||||
padding-top: 6px;
|
|
||||||
padding-bottom: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.qp-more-trigger:hover {
|
|
||||||
background: var(--muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.qp-more-content {
|
|
||||||
/* 覆盖 AccordionContent 默认 pb-4 */
|
|
||||||
padding-top: 0;
|
|
||||||
padding-bottom: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.qp-more-item {
|
|
||||||
padding: 6px 12px;
|
padding: 6px 12px;
|
||||||
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.qp-item-selected:hover {
|
.qp-item-selected:hover {
|
||||||
|
|||||||
@@ -4,7 +4,6 @@
|
|||||||
*/
|
*/
|
||||||
import type { QPItem, QPProvider } from './types'
|
import type { QPItem, QPProvider } from './types'
|
||||||
import { appRankFromPath } from './utils'
|
import { appRankFromPath } from './utils'
|
||||||
import { HistoryProvider } from './history'
|
|
||||||
import { CommandProvider } from './command'
|
import { CommandProvider } from './command'
|
||||||
import { CustomCommandProvider } from './customCommand'
|
import { CustomCommandProvider } from './customCommand'
|
||||||
import { AppProvider } from './app'
|
import { AppProvider } from './app'
|
||||||
@@ -21,7 +20,6 @@ let providers: QPProvider[] | null = null
|
|||||||
export function getProviders(): QPProvider[] {
|
export function getProviders(): QPProvider[] {
|
||||||
if (!providers) {
|
if (!providers) {
|
||||||
providers = [
|
providers = [
|
||||||
new HistoryProvider(),
|
|
||||||
new CommandProvider(),
|
new CommandProvider(),
|
||||||
new CustomCommandProvider(),
|
new CustomCommandProvider(),
|
||||||
new AppProvider(),
|
new AppProvider(),
|
||||||
|
|||||||
@@ -1,18 +1,15 @@
|
|||||||
/**
|
/**
|
||||||
* history Provider:最近交互记录。
|
* history Provider:最近交互记录。
|
||||||
* 记录持久化到 localStorage,空查询时置顶展示最近几条;点击历史项时
|
* 记录持久化到 localStorage,供 QuickPanel 顶部折叠分组加载;点击历史项时
|
||||||
* 重新聚合搜索恢复原 action。
|
* 重新聚合搜索恢复原 action。
|
||||||
*/
|
*/
|
||||||
import { STORAGE_KEYS } from '@/lib/constants'
|
import { STORAGE_KEYS } from '@/lib/constants'
|
||||||
import type { HistoryEntry, QPItem, QPProvider } from './types'
|
import type { HistoryEntry, QPItem } from './types'
|
||||||
import { aggregateSearch } from './aggregate'
|
import { aggregateSearch } from './aggregate'
|
||||||
|
|
||||||
const HISTORY_ITEMS_KEY = STORAGE_KEYS.quickpanelHistoryItems
|
const HISTORY_ITEMS_KEY = STORAGE_KEYS.quickpanelHistoryItems
|
||||||
const HISTORY_MAX = 50
|
const HISTORY_MAX = 50
|
||||||
|
|
||||||
/** 空查询时默认展示的历史条数(置顶部分) */
|
|
||||||
export const HISTORY_PREVIEW_COUNT = 3
|
|
||||||
|
|
||||||
function loadHistoryEntries(): HistoryEntry[] {
|
function loadHistoryEntries(): HistoryEntry[] {
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(HISTORY_ITEMS_KEY)
|
const raw = localStorage.getItem(HISTORY_ITEMS_KEY)
|
||||||
@@ -76,32 +73,8 @@ export function clearHistory() {
|
|||||||
localStorage.removeItem(HISTORY_ITEMS_KEY)
|
localStorage.removeItem(HISTORY_ITEMS_KEY)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 获取置顶的历史项(最近 N 条),用于空查询时在结果列表顶部显示 */
|
/** 获取全部历史项(最近优先),供顶部可折叠的历史分组使用 */
|
||||||
export function getTopHistoryItems(): QPItem[] {
|
export function getAllHistoryItems(): QPItem[] {
|
||||||
const entries = loadHistoryEntries()
|
const entries = loadHistoryEntries()
|
||||||
return entries.slice(0, HISTORY_PREVIEW_COUNT).map(buildHistoryItem)
|
return entries.map(buildHistoryItem)
|
||||||
}
|
|
||||||
|
|
||||||
/** 获取置顶历史之后的剩余历史项,用于 Accordion 折叠显示 */
|
|
||||||
export function getMoreHistoryItems(): QPItem[] {
|
|
||||||
const entries = loadHistoryEntries()
|
|
||||||
return entries.slice(HISTORY_PREVIEW_COUNT).map(buildHistoryItem)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 获取剩余历史数量(用于 Accordion 标题显示) */
|
|
||||||
export function getMoreHistoryCount(): number {
|
|
||||||
const entries = loadHistoryEntries()
|
|
||||||
return Math.max(0, entries.length - HISTORY_PREVIEW_COUNT)
|
|
||||||
}
|
|
||||||
|
|
||||||
export class HistoryProvider implements QPProvider {
|
|
||||||
id = 'history'
|
|
||||||
label = '历史'
|
|
||||||
priority = 99 // 最高优先级,空查询时显示在最前
|
|
||||||
|
|
||||||
async search(query: string): Promise<QPItem[]> {
|
|
||||||
if (query.trim()) return [] // 历史只在空查询时显示
|
|
||||||
// 只返回置顶3条,剩余由 Accordion 承载
|
|
||||||
return getTopHistoryItems()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,10 +14,7 @@ export { loadAppIconsForResults, invalidateAppIconCache } from './app'
|
|||||||
export { setFileIndexReady } from './file'
|
export { setFileIndexReady } from './file'
|
||||||
export { invalidateCustomCommandsCache } from './customCommand'
|
export { invalidateCustomCommandsCache } from './customCommand'
|
||||||
export {
|
export {
|
||||||
HISTORY_PREVIEW_COUNT,
|
|
||||||
recordHistoryItem,
|
recordHistoryItem,
|
||||||
clearHistory,
|
clearHistory,
|
||||||
getTopHistoryItems,
|
getAllHistoryItems,
|
||||||
getMoreHistoryItems,
|
|
||||||
getMoreHistoryCount,
|
|
||||||
} from './history'
|
} from './history'
|
||||||
|
|||||||
@@ -54,6 +54,14 @@ const SYSTEM_COMMANDS: SystemCommandDef[] = [
|
|||||||
command: 'taskmgr',
|
command: 'taskmgr',
|
||||||
args: [],
|
args: [],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'sys-devmgmt',
|
||||||
|
title: '设备管理器',
|
||||||
|
subtitle: 'devmgmt.msc',
|
||||||
|
keywords: ['devmgmt', '设备管理', '硬件', '驱动', 'sheb'],
|
||||||
|
command: 'devmgmt.msc',
|
||||||
|
args: [],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'sys-explorer',
|
id: 'sys-explorer',
|
||||||
title: '资源管理器',
|
title: '资源管理器',
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import { useAppStore, type Theme, type EffectType, type ModuleInfo } from '@/sto
|
|||||||
import { useSearchStore } from '@/stores/searchStore'
|
import { useSearchStore } from '@/stores/searchStore'
|
||||||
import { useProcessStore } from '@/stores/processStore'
|
import { useProcessStore } from '@/stores/processStore'
|
||||||
import { useDownloaderStore } from '@/stores/downloaderStore'
|
import { useDownloaderStore } from '@/stores/downloaderStore'
|
||||||
|
import { useMonitorStore } from '@/stores/monitorStore'
|
||||||
|
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from '@/components/ui/alert-dialog'
|
||||||
import { getModuleIcon } from '@/modules/icons'
|
import { getModuleIcon } from '@/modules/icons'
|
||||||
import { commands, type UpdateCheckResult } from '@/lib/bindings'
|
import { commands, type UpdateCheckResult } from '@/lib/bindings'
|
||||||
import { EVENTS } from '@/lib/constants'
|
import { EVENTS } from '@/lib/constants'
|
||||||
@@ -44,7 +46,6 @@ const appUpdating = ref(false)
|
|||||||
/** ThingHK 内核更新中 */
|
/** ThingHK 内核更新中 */
|
||||||
const kernelUpdating = ref(false)
|
const kernelUpdating = ref(false)
|
||||||
const progress = ref<UpdateProgress | null>(null)
|
const progress = ref<UpdateProgress | null>(null)
|
||||||
const thinghkExists = ref(false)
|
|
||||||
let progressUnlisten: UnlistenFn | null = null
|
let progressUnlisten: UnlistenFn | null = null
|
||||||
|
|
||||||
const installTypeText = computed(() =>
|
const installTypeText = computed(() =>
|
||||||
@@ -215,19 +216,203 @@ const cancelUpdateDownload = async () => {
|
|||||||
cancelAppDownload?.()
|
cancelAppDownload?.()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 更新 ThingHK 内核:后端先停止监控内核再覆盖文件 */
|
/** 更新 ThingHK 内核:下载模块下载 → apply 命令 need_stop 等待确认 → 停止内核 → 解压替换。
|
||||||
|
* 与代理模块 mihomo 内核更新同模式。 */
|
||||||
|
const thinghkExists = ref(false)
|
||||||
|
/** 需要停止监控内核的确认弹窗状态(need_stop 阶段由后端 event 触发) */
|
||||||
|
const thinghkConfirmState = ref<{ open: boolean; wasRunning: boolean; busy: boolean; resolved: boolean }>({
|
||||||
|
open: false,
|
||||||
|
wasRunning: false,
|
||||||
|
busy: false,
|
||||||
|
resolved: false,
|
||||||
|
})
|
||||||
|
const monitorStore = useMonitorStore()
|
||||||
|
/** 下载中可取消(沿用应用更新下载的取消交互) */
|
||||||
|
const kernelDownloadCancellable = ref(false)
|
||||||
|
/** 取消 ThingHK 内核包下载的唤醒回调 */
|
||||||
|
let cancelKernelDownload: (() => void) | null = null
|
||||||
|
|
||||||
|
/** 停止 Kernel 确认弹窗中处理中标记(防止重复触发) */
|
||||||
|
let handlingThinghkNeedStop = false
|
||||||
|
const handleThinghkNeedStop = () => {
|
||||||
|
if (handlingThinghkNeedStop) return
|
||||||
|
handlingThinghkNeedStop = true
|
||||||
|
try {
|
||||||
|
// 若程序被最小化/隐藏到后台,先弹到前台再显示确认框
|
||||||
|
invoke('quickpanel_focus_main_window').catch(() => { /* 忽略 */ })
|
||||||
|
const wasRunning = monitorStore.status?.running ?? false
|
||||||
|
thinghkConfirmState.value = { open: true, wasRunning, busy: false, resolved: false }
|
||||||
|
} finally {
|
||||||
|
handlingThinghkNeedStop = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 确认停止内核并唤醒后端 apply 继续解压替换 */
|
||||||
|
const onThinghkNeedStopConfirm = async () => {
|
||||||
|
const s = thinghkConfirmState.value
|
||||||
|
if (s.resolved) return
|
||||||
|
// 置 resolved 防止 AlertDialog 的 update:open(false) 兜底逻辑把确认误判为取消
|
||||||
|
s.resolved = true
|
||||||
|
s.busy = true
|
||||||
|
try {
|
||||||
|
if (s.wasRunning) {
|
||||||
|
await monitorStore.stop()
|
||||||
|
}
|
||||||
|
await invoke('update_thinghk_confirm')
|
||||||
|
} catch (e) {
|
||||||
|
toast.error('停止监控内核失败', { description: String(e) })
|
||||||
|
// 停止失败则中止更新,避免替换阶段因 exe 占用而报错
|
||||||
|
try { await invoke('update_thinghk_cancel') } catch { /* 忽略 */ }
|
||||||
|
} finally {
|
||||||
|
s.busy = false
|
||||||
|
s.open = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 取消 ThingHK 内核更新(中止后端等待中的 apply 流程,zip 保留便于重试) */
|
||||||
|
const onThinghkNeedStopCancel = () => {
|
||||||
|
const s = thinghkConfirmState.value
|
||||||
|
if (s.resolved) return
|
||||||
|
s.resolved = true
|
||||||
|
s.open = false
|
||||||
|
invoke('update_thinghk_cancel').catch(() => { /* 忽略 */ })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** AlertDialog 关闭事件的兜底判定:cancel/action 点击会先 update:open(false) 再 click,
|
||||||
|
* 仅在此前未被 click 处理器 resolve 时才当作取消(遮罩/Esc 关闭),否则会误判确认/取消。 */
|
||||||
|
const onThinghkNeedStopOpenChange = (open: boolean) => {
|
||||||
|
const s = thinghkConfirmState.value
|
||||||
|
if (!open && !s.resolved) {
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!thinghkConfirmState.value.resolved) onThinghkNeedStopCancel()
|
||||||
|
}, 0)
|
||||||
|
}
|
||||||
|
thinghkConfirmState.value.open = open
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 取消 ThingHK 内核包下载(仅下载阶段) */
|
||||||
|
const cancelKernelUpdateDownload = () => {
|
||||||
|
if (!kernelDownloadCancellable.value) return
|
||||||
|
cancelKernelDownload?.()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 更新 ThingHK 内核 */
|
||||||
const updateThinghkKernel = async () => {
|
const updateThinghkKernel = async () => {
|
||||||
if (kernelUpdating.value) return
|
if (kernelUpdating.value) return
|
||||||
|
const result = updateResult.value
|
||||||
|
if (!result) return
|
||||||
|
// 从 release assets 中定位 ThingHK 内核包(zip);找不到则提示手动下载
|
||||||
|
const asset = result.assets.find(a => {
|
||||||
|
const n = a.name.toLowerCase()
|
||||||
|
return (n.includes('thing-hk') || n.includes('thinghk')) && n.endsWith('.zip')
|
||||||
|
})
|
||||||
|
if (!asset) {
|
||||||
|
toast.error('未找到 ThingHK 内核更新包', {
|
||||||
|
description: '请确认 release 资产中已上传 ThingHK 内核 zip 包',
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
kernelUpdating.value = true
|
kernelUpdating.value = true
|
||||||
|
progress.value = {
|
||||||
|
stage: 'downloading',
|
||||||
|
percent: 0,
|
||||||
|
downloadedBytes: 0,
|
||||||
|
totalBytes: asset.size || null,
|
||||||
|
message: '准备开始下载...',
|
||||||
|
}
|
||||||
|
|
||||||
|
let taskId: string | null = null
|
||||||
|
let downloadProgressFn: UnlistenFn | null = null
|
||||||
|
// 用对象持有完成事件解绑函数,避免闭包内赋值导致的 TS 类型收窄问题(同 proxyStore)
|
||||||
|
const completeHolder: { fn: UnlistenFn | null } = { fn: null }
|
||||||
|
let downloadOk = false
|
||||||
try {
|
try {
|
||||||
await commands.updateThinghk()
|
// 确保下载模块事件监听已注册(下载器 UI 与这里共用事件流)
|
||||||
|
try { await downloaderStore.startEventListeners() } catch { /* 忽略 */ }
|
||||||
|
taskId = await downloaderStore.addTask(asset.browserDownloadUrl, asset.name, undefined, {}, false)
|
||||||
|
|
||||||
|
// 下载进度 → 更新进度条
|
||||||
|
downloadProgressFn = await listen<{
|
||||||
|
id: string; completedSize: number; totalSize: number; speed: number; status: string
|
||||||
|
}>('download-progress', (e) => {
|
||||||
|
if (e.payload.id !== taskId || !kernelUpdating.value) return
|
||||||
|
const pct = e.payload.totalSize > 0
|
||||||
|
? Math.round((e.payload.completedSize / e.payload.totalSize) * 100)
|
||||||
|
: 0
|
||||||
|
progress.value = {
|
||||||
|
stage: 'downloading',
|
||||||
|
percent: pct,
|
||||||
|
downloadedBytes: e.payload.completedSize,
|
||||||
|
totalBytes: e.payload.totalSize,
|
||||||
|
message: e.payload.speed > 0
|
||||||
|
? `正在下载... ${fmtSpeed(e.payload.speed)}`
|
||||||
|
: '正在下载...',
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 等待下载完成 / 失败 / 取消
|
||||||
|
kernelDownloadCancellable.value = true
|
||||||
|
const dlResult = await new Promise<{ ok: boolean; error?: string }>((resolve) => {
|
||||||
|
let settled = false
|
||||||
|
const finish = (r: { ok: boolean; error?: string }) => {
|
||||||
|
if (settled) return
|
||||||
|
settled = true
|
||||||
|
cancelKernelDownload = null
|
||||||
|
resolve(r)
|
||||||
|
}
|
||||||
|
// 任务添加后瞬间进入终态(如探测即失败)
|
||||||
|
const initial = downloaderStore.tasks.find(t => t.id === taskId)
|
||||||
|
if (initial) {
|
||||||
|
if (initial.status === 'complete') { finish({ ok: true }); return }
|
||||||
|
if (initial.status === 'error') { finish({ ok: false, error: initial.error || '下载失败' }); return }
|
||||||
|
}
|
||||||
|
cancelKernelDownload = () => finish({ ok: false, error: '已取消下载' })
|
||||||
|
listen<{ id: string; status: string; error: string | null }>('download-complete', (e) => {
|
||||||
|
if (e.payload.id === taskId) {
|
||||||
|
if (e.payload.status === 'complete') finish({ ok: true })
|
||||||
|
else finish({ ok: false, error: e.payload.error || '下载失败' })
|
||||||
|
}
|
||||||
|
}).then(fn => { completeHolder.fn = fn })
|
||||||
|
})
|
||||||
|
kernelDownloadCancellable.value = false
|
||||||
|
if (!dlResult.ok) throw new Error(dlResult.error || '下载失败')
|
||||||
|
downloadOk = true
|
||||||
|
|
||||||
|
// 取下载文件路径 → 移除任务记录(保留文件,apply 命令内部会解压并清理)
|
||||||
|
const dlTask = downloaderStore.tasks.find(t => t.id === taskId)
|
||||||
|
if (!dlTask) throw new Error('下载任务未找到')
|
||||||
|
const zipPath = dlTask.dir + '/' + dlTask.filename
|
||||||
|
try {
|
||||||
|
await downloaderStore.removeTask(taskId, false)
|
||||||
|
taskId = null
|
||||||
|
} catch { /* 任务清理失败不阻断更新 */ }
|
||||||
|
|
||||||
|
// 应用阶段:apply 内部 need_stop 等待前端停止内核并确认 → 解压替换 → 完成
|
||||||
|
await invoke('update_thinghk_apply', { zipPath })
|
||||||
await loadAppInfo()
|
await loadAppInfo()
|
||||||
toast.success('ThingHK 内核更新完成')
|
toast.success('ThingHK 内核更新完成')
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('[updater] ThingHK 更新失败', e)
|
console.error('[updater] ThingHK 更新失败', e)
|
||||||
toast.error('ThingHK 内核更新失败', { description: String(e) })
|
const msg = String(e)
|
||||||
|
if (msg.includes('已取消下载')) {
|
||||||
|
toast.info('已取消更新下载')
|
||||||
|
} else if (msg.includes('更新已取消')) {
|
||||||
|
toast.info('已取消内核更新')
|
||||||
|
} else {
|
||||||
|
toast.error('ThingHK 内核更新失败', { description: msg })
|
||||||
|
}
|
||||||
|
// 清理下载任务:下载失败/取消时删除半成品文件;apply 失败保留 zip 便于重试
|
||||||
|
if (taskId) {
|
||||||
|
try { await downloaderStore.removeTask(taskId, !downloadOk) } catch { /* 忽略 */ }
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
kernelUpdating.value = false
|
kernelUpdating.value = false
|
||||||
|
progress.value = null
|
||||||
|
kernelDownloadCancellable.value = false
|
||||||
|
cancelKernelDownload = null
|
||||||
|
if (downloadProgressFn) downloadProgressFn()
|
||||||
|
if (completeHolder.fn) completeHolder.fn()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -240,10 +425,14 @@ onMounted(() => {
|
|||||||
// 监听更新进度事件(应用更新与 ThingHK 内核更新共用)
|
// 监听更新进度事件(应用更新与 ThingHK 内核更新共用)
|
||||||
listen<UpdateProgress>(EVENTS.updateProgress, (e) => {
|
listen<UpdateProgress>(EVENTS.updateProgress, (e) => {
|
||||||
progress.value = e.payload
|
progress.value = e.payload
|
||||||
if (e.payload.stage === 'done') {
|
const p = e.payload
|
||||||
|
if (p.stage === 'need_stop') {
|
||||||
|
// ThingHK 内核更新:解压替换前需停止监控内核,弹窗确认(若在后台自动弹到前台)
|
||||||
|
handleThinghkNeedStop()
|
||||||
|
} else if (p.stage === 'done') {
|
||||||
kernelUpdating.value = false
|
kernelUpdating.value = false
|
||||||
progress.value = null
|
progress.value = null
|
||||||
} else if (e.payload.stage === 'error') {
|
} else if (p.stage === 'error') {
|
||||||
// ThingHK 内核更新失败(后端 emit);应用更新失败走命令 reject 路径
|
// ThingHK 内核更新失败(后端 emit);应用更新失败走命令 reject 路径
|
||||||
kernelUpdating.value = false
|
kernelUpdating.value = false
|
||||||
}
|
}
|
||||||
@@ -407,6 +596,16 @@ const onDragEnd = () => {
|
|||||||
@update:model-value="(checked: boolean) => appStore.toggleAutoStart(checked)"
|
@update:model-value="(checked: boolean) => appStore.toggleAutoStart(checked)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex items-center justify-between py-2 border-t border-border/60 mt-2">
|
||||||
|
<div class="space-y-1">
|
||||||
|
<Label class="text-base font-medium">静默启动</Label>
|
||||||
|
<p class="text-sm text-muted-foreground">启动后不打开主界面,静默驻留托盘(托盘左键可呼出)</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
:model-value="appStore.silentAutoStart"
|
||||||
|
@update:model-value="(checked: boolean) => appStore.toggleSilentAutoStart(checked)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@@ -675,8 +874,42 @@ const onDragEnd = () => {
|
|||||||
<span>{{ progress.message }}</span>
|
<span>{{ progress.message }}</span>
|
||||||
<span class="font-mono">{{ progress.percent }}%</span>
|
<span class="font-mono">{{ progress.percent }}%</span>
|
||||||
</div>
|
</div>
|
||||||
<Progress :model-value="progress.percent" />
|
<div class="flex items-center gap-2">
|
||||||
|
<Progress :model-value="progress.percent" class="flex-1" />
|
||||||
|
<Button
|
||||||
|
v-if="kernelDownloadCancellable"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
class="h-5 px-1.5 text-xs"
|
||||||
|
@click="cancelKernelUpdateDownload"
|
||||||
|
>
|
||||||
|
<X class="size-3 mr-0.5" />
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 停止监控内核确认(need_stop 阶段:与 mihomo 同模式) -->
|
||||||
|
<AlertDialog :open="thinghkConfirmState.open" @update:open="onThinghkNeedStopOpenChange">
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>停止监控内核后继续</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
{{
|
||||||
|
thinghkConfirmState.wasRunning
|
||||||
|
? '内核更新包已下载。安装新内核前需要停止监控内核,点击「停止并继续」将自动停止监控并完成安装。'
|
||||||
|
: '内核更新包已下载。即将安装新内核,点击「继续」完成安装。'
|
||||||
|
}}
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel @click="onThinghkNeedStopCancel">取消</AlertDialogCancel>
|
||||||
|
<AlertDialogAction :disabled="thinghkConfirmState.busy" @click="onThinghkNeedStopConfirm">
|
||||||
|
{{ thinghkConfirmState.busy ? '正在停止...' : (thinghkConfirmState.wasRunning ? '停止并继续' : '继续') }}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|||||||
@@ -426,13 +426,25 @@ onMounted(async () => {
|
|||||||
mq.addEventListener('change', onThemeChange)
|
mq.addEventListener('change', onThemeChange)
|
||||||
unlistenFns.push(() => mq.removeEventListener('change', onThemeChange))
|
unlistenFns.push(() => mq.removeEventListener('change', onThemeChange))
|
||||||
|
|
||||||
|
// 右键显示的合并窗口:浏览器/线程内收到 tray-menu-show(基础状态、菜单较小)后,
|
||||||
|
// 不给 150ms 窗口等完整状态(含节点)到达,再一次性定位显示最终尺寸。
|
||||||
|
// 避免"先以小菜单显示 → 节点数据到达 → 可见后二次 resize"造成的闪烁。
|
||||||
|
let menuJustShown = 0 // 本次显示窗口内是否处于"等待合并完整状态"阶段
|
||||||
|
let finalizeTimer = 0
|
||||||
|
|
||||||
unlistenFns.push(await listen<TrayMenuState>('tray-menu-show', async (event) => {
|
unlistenFns.push(await listen<TrayMenuState>('tray-menu-show', async (event) => {
|
||||||
await applyTheme()
|
await applyTheme()
|
||||||
Object.assign(state, event.payload)
|
Object.assign(state, event.payload)
|
||||||
osdVisible.value = readOsdVisible()
|
osdVisible.value = readOsdVisible()
|
||||||
// 显示前重置上次残留的下拉/焦点状态(双保险)
|
// 显示前重置上次残留的下拉/焦点状态(双保险)
|
||||||
resetMenuState()
|
resetMenuState()
|
||||||
|
// 开始合并窗口:等待完整状态,结束时才显示
|
||||||
|
menuJustShown = Date.now()
|
||||||
|
window.clearTimeout(finalizeTimer)
|
||||||
|
finalizeTimer = window.setTimeout(async () => {
|
||||||
|
menuJustShown = 0
|
||||||
await measureAndShow()
|
await measureAndShow()
|
||||||
|
}, 150)
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// 菜单失焦(点击其他位置自动隐藏)时重置下拉框与焦点,避免下次打开时残留
|
// 菜单失焦(点击其他位置自动隐藏)时重置下拉框与焦点,避免下次打开时残留
|
||||||
@@ -440,12 +452,12 @@ onMounted(async () => {
|
|||||||
if (!focused) resetMenuState()
|
if (!focused) resetMenuState()
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// 仅更新状态数据,不重新显示窗口。
|
// - 显示流程的合并窗口内:完整状态补充到达,仅合并数据,让 finalizeTimer 统一显示
|
||||||
// - 动作完成后的状态更新:菜单已隐藏,只更新数据(不重新弹出)
|
// - 动作完成后的状态更新:菜单已隐藏,只更新数据(不重新弹出)
|
||||||
// - 右键后完整状态补充到达(基础状态先行显示,节点数据异步跟上):
|
// - 超时后(mihomo 慢)node 数据补充到达且菜单已可见:重新测量调整尺寸(兜底)
|
||||||
// 菜单可见时重新测量调整窗口尺寸(tray_menu_ready 幂等,重新定位+resize,不会重复显示动画)
|
|
||||||
unlistenFns.push(await listen<TrayMenuState>('tray-menu-state-updated', async (event) => {
|
unlistenFns.push(await listen<TrayMenuState>('tray-menu-state-updated', async (event) => {
|
||||||
Object.assign(state, event.payload)
|
Object.assign(state, event.payload)
|
||||||
|
if (menuJustShown) return // 正处于首次显示的合并窗口,等待 finalize 统一显示
|
||||||
try {
|
try {
|
||||||
const visible = await getCurrentWindow().isVisible()
|
const visible = await getCurrentWindow().isVisible()
|
||||||
if (visible) await measureAndShow()
|
if (visible) await measureAndShow()
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ export const useAppStore = defineStore('app', () => {
|
|||||||
const theme = ref<Theme>('system')
|
const theme = ref<Theme>('system')
|
||||||
const effect = ref<EffectType>('mica')
|
const effect = ref<EffectType>('mica')
|
||||||
const isAutoStart = ref(false)
|
const isAutoStart = ref(false)
|
||||||
|
const silentAutoStart = ref(false)
|
||||||
const isInitialized = ref(false)
|
const isInitialized = ref(false)
|
||||||
const modules = ref<ModuleInfo[]>(initModulesFromRegistry())
|
const modules = ref<ModuleInfo[]>(initModulesFromRegistry())
|
||||||
const moduleOrder = ref<string[]>(initModuleOrder())
|
const moduleOrder = ref<string[]>(initModuleOrder())
|
||||||
@@ -88,6 +89,8 @@ export const useAppStore = defineStore('app', () => {
|
|||||||
|
|
||||||
if (settings.theme) theme.value = settings.theme
|
if (settings.theme) theme.value = settings.theme
|
||||||
if (settings.effect) effect.value = settings.effect
|
if (settings.effect) effect.value = settings.effect
|
||||||
|
if (typeof settings.isAutoStart === 'boolean') isAutoStart.value = settings.isAutoStart
|
||||||
|
if (typeof settings.silentAutoStart === 'boolean') silentAutoStart.value = settings.silentAutoStart
|
||||||
if (settings.modules) {
|
if (settings.modules) {
|
||||||
const savedModules = settings.modules as Array<{ id: string; enabled: boolean }>
|
const savedModules = settings.modules as Array<{ id: string; enabled: boolean }>
|
||||||
savedModules.forEach(sm => {
|
savedModules.forEach(sm => {
|
||||||
@@ -127,6 +130,7 @@ export const useAppStore = defineStore('app', () => {
|
|||||||
theme: theme.value,
|
theme: theme.value,
|
||||||
effect: effect.value,
|
effect: effect.value,
|
||||||
isAutoStart: isAutoStart.value,
|
isAutoStart: isAutoStart.value,
|
||||||
|
silentAutoStart: silentAutoStart.value,
|
||||||
modules: modulesData,
|
modules: modulesData,
|
||||||
moduleOrder: moduleOrder.value
|
moduleOrder: moduleOrder.value
|
||||||
}))
|
}))
|
||||||
@@ -323,6 +327,11 @@ export const useAppStore = defineStore('app', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const toggleSilentAutoStart = (checked?: boolean) => {
|
||||||
|
silentAutoStart.value = checked !== undefined ? checked : !silentAutoStart.value
|
||||||
|
saveSettings()
|
||||||
|
}
|
||||||
|
|
||||||
const applyTheme = async () => {
|
const applyTheme = async () => {
|
||||||
const root = document.documentElement
|
const root = document.documentElement
|
||||||
root.classList.remove('dark')
|
root.classList.remove('dark')
|
||||||
@@ -472,6 +481,8 @@ export const useAppStore = defineStore('app', () => {
|
|||||||
|
|
||||||
isInitialized.value = true
|
isInitialized.value = true
|
||||||
} finally {
|
} finally {
|
||||||
|
// 静默启动:仅当未开启时才显示主窗口(开启后启动静默驻留托盘,托盘左键可呼出)
|
||||||
|
if (silentAutoStart.value) return
|
||||||
try {
|
try {
|
||||||
const tauriWindow = getCurrentWindow()
|
const tauriWindow = getCurrentWindow()
|
||||||
await tauriWindow.show()
|
await tauriWindow.show()
|
||||||
@@ -486,6 +497,7 @@ export const useAppStore = defineStore('app', () => {
|
|||||||
effect,
|
effect,
|
||||||
systemDark,
|
systemDark,
|
||||||
isAutoStart,
|
isAutoStart,
|
||||||
|
silentAutoStart,
|
||||||
isInitialized,
|
isInitialized,
|
||||||
modules,
|
modules,
|
||||||
moduleOrder,
|
moduleOrder,
|
||||||
@@ -497,6 +509,7 @@ export const useAppStore = defineStore('app', () => {
|
|||||||
setTheme,
|
setTheme,
|
||||||
setEffect,
|
setEffect,
|
||||||
toggleAutoStart,
|
toggleAutoStart,
|
||||||
|
toggleSilentAutoStart,
|
||||||
applyTheme,
|
applyTheme,
|
||||||
applyEffect,
|
applyEffect,
|
||||||
init,
|
init,
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import type {
|
|||||||
DownloadTask as BindDownloadTask,
|
DownloadTask as BindDownloadTask,
|
||||||
DownloaderSettings as BindDownloaderSettings,
|
DownloaderSettings as BindDownloaderSettings,
|
||||||
CheckUrlResult as BindCheckUrlResult,
|
CheckUrlResult as BindCheckUrlResult,
|
||||||
|
TorrentInfo as BindTorrentInfo,
|
||||||
TaskStatus,
|
TaskStatus,
|
||||||
} from '@/lib/bindings'
|
} from '@/lib/bindings'
|
||||||
|
|
||||||
@@ -19,11 +20,14 @@ const logger = createLogger('downloader')
|
|||||||
export type DownloadTask = Required<BindDownloadTask>
|
export type DownloadTask = Required<BindDownloadTask>
|
||||||
export type DownloaderSettings = Required<BindDownloaderSettings>
|
export type DownloaderSettings = Required<BindDownloaderSettings>
|
||||||
export type CheckUrlResult = Required<BindCheckUrlResult>
|
export type CheckUrlResult = Required<BindCheckUrlResult>
|
||||||
|
export type TorrentInfo = Required<BindTorrentInfo>
|
||||||
|
|
||||||
// re-export:跨端类型统一由 bindings 提供,组件从本 store import 的路径保持不变
|
// re-export:跨端类型统一由 bindings 提供,组件从本 store import 的路径保持不变
|
||||||
export type {
|
export type {
|
||||||
TaskStatus,
|
TaskStatus,
|
||||||
|
TaskProtocol,
|
||||||
Segment,
|
Segment,
|
||||||
|
BtFileInfo,
|
||||||
DuplicateKind,
|
DuplicateKind,
|
||||||
ExistingTaskInfo,
|
ExistingTaskInfo,
|
||||||
} from '@/lib/bindings'
|
} from '@/lib/bindings'
|
||||||
@@ -71,6 +75,10 @@ export const useDownloaderStore = defineStore('downloader', () => {
|
|||||||
let completeUnlisten: UnlistenFn | null = null
|
let completeUnlisten: UnlistenFn | null = null
|
||||||
let addedUnlisten: UnlistenFn | null = null
|
let addedUnlisten: UnlistenFn | null = null
|
||||||
let removedUnlisten: UnlistenFn | null = null
|
let removedUnlisten: UnlistenFn | null = null
|
||||||
|
let inspectReadyUnlisten: UnlistenFn | null = null
|
||||||
|
|
||||||
|
/** 磁力元数据解析就绪回调(模块注册,用于弹文件勾选对话框) */
|
||||||
|
let btInspectReadyHandler: ((id: string) => void) | null = null
|
||||||
|
|
||||||
// ===== 任务列表 =====
|
// ===== 任务列表 =====
|
||||||
const refreshTasks = async () => {
|
const refreshTasks = async () => {
|
||||||
@@ -85,13 +93,26 @@ export const useDownloaderStore = defineStore('downloader', () => {
|
|||||||
if (
|
if (
|
||||||
freshTask.status === 'paused' ||
|
freshTask.status === 'paused' ||
|
||||||
freshTask.status === 'complete' ||
|
freshTask.status === 'complete' ||
|
||||||
freshTask.status === 'error'
|
freshTask.status === 'error' ||
|
||||||
|
freshTask.status === 'cancelled'
|
||||||
) {
|
) {
|
||||||
return freshTask
|
return freshTask
|
||||||
}
|
}
|
||||||
const local = tasks.value.find(t => t.id === freshTask.id)
|
const local = tasks.value.find(t => t.id === freshTask.id)
|
||||||
if (!local) return freshTask
|
if (!local) return freshTask
|
||||||
return { ...local, status: freshTask.status, error: freshTask.error }
|
// 进度/速度沿用本地实时值;但元数据字段(文件名/文件列表/总大小/infohash)
|
||||||
|
// 必须以后端为准 —— 这些只在后台解析完成后才就绪,本地快照在添加时是占位空值,
|
||||||
|
// 若沿用会导致"元数据已解析但勾选对话框仍无文件列表"(旧快照覆盖新元数据)
|
||||||
|
return {
|
||||||
|
...local,
|
||||||
|
status: freshTask.status,
|
||||||
|
error: freshTask.error,
|
||||||
|
filename: freshTask.filename,
|
||||||
|
btFiles: freshTask.btFiles,
|
||||||
|
btMetadataReady: freshTask.btMetadataReady,
|
||||||
|
infoHash: freshTask.infoHash,
|
||||||
|
totalSize: freshTask.totalSize,
|
||||||
|
}
|
||||||
})
|
})
|
||||||
tasks.value = merged
|
tasks.value = merged
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -105,9 +126,15 @@ export const useDownloaderStore = defineStore('downloader', () => {
|
|||||||
const task = tasks.value.find((t) => t.id === payload.id)
|
const task = tasks.value.find((t) => t.id === payload.id)
|
||||||
if (task) {
|
if (task) {
|
||||||
// 终态任务忽略迟到的进度事件(下载完成后 in-flight 事件可能把状态/进度回退)
|
// 终态任务忽略迟到的进度事件(下载完成后 in-flight 事件可能把状态/进度回退)
|
||||||
if (task.status === 'complete' || task.status === 'error') return
|
if (task.status === 'complete' || task.status === 'error' || task.status === 'cancelled') return
|
||||||
// 已暂停任务忽略仍携带 active 的迟到事件(暂停瞬间发出的旧事件)
|
// 已暂停任务忽略"停止瞬间残留的 active 心跳"(无速度且进度未变化的迟到事件)。
|
||||||
if (task.status === 'paused' && payload.status === 'active') return
|
// 真正恢复下载后发来的 active(有速度或进度增长)必须放行,否则恢复后列表一直停留在暂停态
|
||||||
|
if (
|
||||||
|
task.status === 'paused' &&
|
||||||
|
payload.status === 'active' &&
|
||||||
|
payload.speed <= 0 &&
|
||||||
|
payload.completedSize <= task.completedSize
|
||||||
|
) return
|
||||||
task.completedSize = payload.completedSize
|
task.completedSize = payload.completedSize
|
||||||
task.totalSize = payload.totalSize
|
task.totalSize = payload.totalSize
|
||||||
task.speed = payload.speed
|
task.speed = payload.speed
|
||||||
@@ -134,19 +161,32 @@ export const useDownloaderStore = defineStore('downloader', () => {
|
|||||||
filename?: string,
|
filename?: string,
|
||||||
dir?: string,
|
dir?: string,
|
||||||
headers?: Record<string, string>,
|
headers?: Record<string, string>,
|
||||||
autoRename = false
|
autoRename = false,
|
||||||
|
onlyFiles?: number[]
|
||||||
): Promise<string> => {
|
): Promise<string> => {
|
||||||
const id = await commands.downloaderAddTask(
|
const id = await commands.downloaderAddTask(
|
||||||
url,
|
url,
|
||||||
filename || null,
|
filename || null,
|
||||||
dir || null,
|
dir || null,
|
||||||
headers || null,
|
headers || null,
|
||||||
autoRename
|
autoRename,
|
||||||
|
onlyFiles || null
|
||||||
)
|
)
|
||||||
await refreshTasks()
|
await refreshTasks()
|
||||||
return id
|
return id
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 解析磁力链 / .torrent,返回种子信息(文件勾选用) */
|
||||||
|
const inspect = async (input: string): Promise<TorrentInfo> => {
|
||||||
|
return await commands.downloaderInspect(input)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 磁力任务元数据解析成功后:设置勾选文件并开始下载 */
|
||||||
|
const selectBtFiles = async (id: string, onlyFiles: number[]) => {
|
||||||
|
await commands.downloaderSelectBtFiles(id, onlyFiles)
|
||||||
|
await refreshTasks()
|
||||||
|
}
|
||||||
|
|
||||||
/** 检查 URL 重复性并探测文件信息 */
|
/** 检查 URL 重复性并探测文件信息 */
|
||||||
const checkUrl = async (
|
const checkUrl = async (
|
||||||
url: string,
|
url: string,
|
||||||
@@ -171,6 +211,18 @@ export const useDownloaderStore = defineStore('downloader', () => {
|
|||||||
await refreshTasks()
|
await refreshTasks()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 取消下载:置为已取消、清空进度并删除下载文件,但保留记录 */
|
||||||
|
const cancelTask = async (id: string) => {
|
||||||
|
await commands.downloaderCancelTask(id)
|
||||||
|
await refreshTasks()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 重新下载已取消/出错的任务 */
|
||||||
|
const redownload = async (id: string) => {
|
||||||
|
await commands.downloaderRedownload(id)
|
||||||
|
await refreshTasks()
|
||||||
|
}
|
||||||
|
|
||||||
// ===== 设置 =====
|
// ===== 设置 =====
|
||||||
const loadSettings = async () => {
|
const loadSettings = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -250,6 +302,15 @@ export const useDownloaderStore = defineStore('downloader', () => {
|
|||||||
refreshTasks()
|
refreshTasks()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
if (!inspectReadyUnlisten) {
|
||||||
|
inspectReadyUnlisten = await listen<{ id: string }>('download-inspect-ready', async (e) => {
|
||||||
|
// 磁力元数据解析成功:先刷新任务(拿到文件列表),再通知模块弹文件勾选对话框。
|
||||||
|
// 必须 await —— 否则回调读取的 store.tasks 仍是旧快照(btFiles 为空),
|
||||||
|
// 导致勾选对话框无条目、默认选中空数组,进而在后端被 librqbit 秒判为"已完成 0%"
|
||||||
|
await refreshTasks()
|
||||||
|
btInspectReadyHandler?.(e.payload.id)
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const stopEventListeners = () => {
|
const stopEventListeners = () => {
|
||||||
@@ -269,6 +330,10 @@ export const useDownloaderStore = defineStore('downloader', () => {
|
|||||||
removedUnlisten()
|
removedUnlisten()
|
||||||
removedUnlisten = null
|
removedUnlisten = null
|
||||||
}
|
}
|
||||||
|
if (inspectReadyUnlisten) {
|
||||||
|
inspectReadyUnlisten()
|
||||||
|
inspectReadyUnlisten = null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== 初始化 =====
|
// ===== 初始化 =====
|
||||||
@@ -281,6 +346,11 @@ export const useDownloaderStore = defineStore('downloader', () => {
|
|||||||
// ===== 工具函数 =====
|
// ===== 工具函数 =====
|
||||||
const openDir = (path: string) => commands.downloaderOpenDir(path)
|
const openDir = (path: string) => commands.downloaderOpenDir(path)
|
||||||
|
|
||||||
|
/** 注册磁力元数据就绪回调(模块传入处理函数,替换式) */
|
||||||
|
const setBtInspectReadyHandler = (handler: ((id: string) => void) | null) => {
|
||||||
|
btInspectReadyHandler = handler
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
// state
|
// state
|
||||||
tasks,
|
tasks,
|
||||||
@@ -290,10 +360,14 @@ export const useDownloaderStore = defineStore('downloader', () => {
|
|||||||
// tasks
|
// tasks
|
||||||
refreshTasks,
|
refreshTasks,
|
||||||
addTask,
|
addTask,
|
||||||
|
inspect,
|
||||||
|
selectBtFiles,
|
||||||
checkUrl,
|
checkUrl,
|
||||||
pauseTask,
|
pauseTask,
|
||||||
resumeTask,
|
resumeTask,
|
||||||
removeTask,
|
removeTask,
|
||||||
|
cancelTask,
|
||||||
|
redownload,
|
||||||
// settings
|
// settings
|
||||||
loadSettings,
|
loadSettings,
|
||||||
saveSettings,
|
saveSettings,
|
||||||
@@ -307,6 +381,7 @@ export const useDownloaderStore = defineStore('downloader', () => {
|
|||||||
// init
|
// init
|
||||||
init,
|
init,
|
||||||
// utils
|
// utils
|
||||||
|
setBtInspectReadyHandler,
|
||||||
openDir
|
openDir
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
+203
-50
@@ -185,6 +185,8 @@ export interface OsdConfig {
|
|||||||
/** 悬浮窗窗口保存位置(null=使用百分比计算默认位置) */
|
/** 悬浮窗窗口保存位置(null=使用百分比计算默认位置) */
|
||||||
overlayX?: number | null
|
overlayX?: number | null
|
||||||
overlayY?: number | null
|
overlayY?: number | null
|
||||||
|
/** 游戏全屏时自动隐藏悬浮窗(前台全屏应用会因置顶透明窗口掉帧,默认开启) */
|
||||||
|
gameAutoHide: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
/** OSD 悬浮窗窗口 label(与 Tauri 窗口创建对应,见 constants::WINDOWS) */
|
/** OSD 悬浮窗窗口 label(与 Tauri 窗口创建对应,见 constants::WINDOWS) */
|
||||||
@@ -261,19 +263,20 @@ function defaultOsdConfig(): OsdConfig {
|
|||||||
labelLanguage: 'zh',
|
labelLanguage: 'zh',
|
||||||
layout: 'single',
|
layout: 'single',
|
||||||
updateIntervalMs: 1000,
|
updateIntervalMs: 1000,
|
||||||
// 默认关闭点击穿透:关闭后左键可直接拖动悬浮窗
|
// 默认开启点击穿透:悬浮窗不拦截鼠标,需拖动时临时关闭
|
||||||
clickThrough: false,
|
clickThrough: true,
|
||||||
fontColor: '#ffffff',
|
fontColor: '#ffffff',
|
||||||
fontOpacity: 100,
|
fontOpacity: 100,
|
||||||
bgColor: 'transparent',
|
bgColor: 'transparent',
|
||||||
colorThemeEnabled: true,
|
colorThemeEnabled: true,
|
||||||
colorTheme: { ...DEFAULT_COLOR_THEME },
|
colorTheme: { ...DEFAULT_COLOR_THEME },
|
||||||
fontStrokeEnabled: false,
|
fontStrokeEnabled: true,
|
||||||
fontStrokeWidth: 1,
|
fontStrokeWidth: 1,
|
||||||
fontStrokeColor: '#000000',
|
fontStrokeColor: '#000000',
|
||||||
alert: { ...DEFAULT_ALERT_CONFIG, maxValues: { ...DEFAULT_ALERT_CONFIG.maxValues } },
|
alert: { ...DEFAULT_ALERT_CONFIG, maxValues: { ...DEFAULT_ALERT_CONFIG.maxValues } },
|
||||||
overlayX: null,
|
overlayX: null,
|
||||||
overlayY: null,
|
overlayY: null,
|
||||||
|
gameAutoHide: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -340,6 +343,10 @@ export const useMonitorStore = defineStore('monitor', () => {
|
|||||||
*/
|
*/
|
||||||
const osdConfig = ref<OsdConfig>(loadOsdConfig())
|
const osdConfig = ref<OsdConfig>(loadOsdConfig())
|
||||||
|
|
||||||
|
/** 前台是否为全屏应用(游戏)。由 Rust 侧 osd-game-active/inactive 事件驱动,
|
||||||
|
* 用于游戏时隐藏 OSD(透明置顶窗口会占用 DWM 合成路径导致游戏掉帧) */
|
||||||
|
const gameFullscreen = ref(false)
|
||||||
|
|
||||||
/** OSD 配置防抖保存:滑块/输入连续变化时合并为一次 localStorage 写入(避免每帧全量序列化) */
|
/** OSD 配置防抖保存:滑块/输入连续变化时合并为一次 localStorage 写入(避免每帧全量序列化) */
|
||||||
let osdSaveTimer: ReturnType<typeof setTimeout> | null = null
|
let osdSaveTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
/** OSD 配置防抖推送定时器(initOsd 内注册的 deep watch 使用,dispose 时需清理) */
|
/** OSD 配置防抖推送定时器(initOsd 内注册的 deep watch 使用,dispose 时需清理) */
|
||||||
@@ -367,6 +374,8 @@ export const useMonitorStore = defineStore('monitor', () => {
|
|||||||
/** 推送 OSD 数据到所有 OSD 窗口(高频通道:仅显示项 key→value 映射 + 网速,每秒一次) */
|
/** 推送 OSD 数据到所有 OSD 窗口(高频通道:仅显示项 key→value 映射 + 网速,每秒一次) */
|
||||||
async function pushOsdState() {
|
async function pushOsdState() {
|
||||||
if (!osdConfig.value.overlayEnabled) return
|
if (!osdConfig.value.overlayEnabled) return
|
||||||
|
// 游戏全屏自动隐藏期间停止推送:OSD 窗口已隐藏,推送只会白白消耗 IPC 和 WebView JS 时间片
|
||||||
|
if (gameFullscreen.value && osdConfig.value.gameAutoHide) return
|
||||||
try {
|
try {
|
||||||
const map = sensorKeyMap.value
|
const map = sensorKeyMap.value
|
||||||
const data: Record<string, number | null> = {}
|
const data: Record<string, number | null> = {}
|
||||||
@@ -583,11 +592,41 @@ export const useMonitorStore = defineStore('monitor', () => {
|
|||||||
return autoStart.value
|
return autoStart.value
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 设置"自动启动监控内核"开关 */
|
/** 关闭"自动启动监控内核"时暂存的 OSD 开关状态(开启自动启动时据此恢复) */
|
||||||
|
const OSD_PENDING_KEY = STORAGE_KEYS.monitorOsdPending
|
||||||
|
|
||||||
|
/** 待恢复的 OSD 开关状态:关闭自动启动时记录、开启时按记录恢复。持久化于 localStorage,跨重启有效。 */
|
||||||
|
let pendingOsdEnabled: boolean | null = null
|
||||||
|
try {
|
||||||
|
pendingOsdEnabled = JSON.parse(localStorage.getItem(OSD_PENDING_KEY) ?? 'null')
|
||||||
|
} catch { /* 忽略非法缓存,视为无恢复记录 */ }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置"自动启动监控内核"开关。
|
||||||
|
* 与 OSD 开关联动(OSD 开关本身可自由开关):
|
||||||
|
* - 开→关:记录当前 OSD 开关状态,再关闭 OSD(OSD 依赖内核随应用启动)
|
||||||
|
* - 关→开:若记录状态为开,则恢复 OSD 显示
|
||||||
|
*/
|
||||||
async function setAutoStart(enabled: boolean) {
|
async function setAutoStart(enabled: boolean) {
|
||||||
try {
|
try {
|
||||||
await invoke('monitor_set_auto_start', { enabled })
|
await invoke('monitor_set_auto_start', { enabled })
|
||||||
autoStart.value = enabled
|
autoStart.value = enabled
|
||||||
|
if (!enabled) {
|
||||||
|
// 关闭时记录当前 OSD 状态,随后由 overlayEnabled watch 统一隐藏悬浮窗
|
||||||
|
pendingOsdEnabled = osdConfig.value.overlayEnabled
|
||||||
|
try { localStorage.setItem(OSD_PENDING_KEY, JSON.stringify(pendingOsdEnabled)) } catch { /* 忽略 */ }
|
||||||
|
osdConfig.value.overlayEnabled = false
|
||||||
|
saveOsdConfig(osdConfig.value)
|
||||||
|
} else {
|
||||||
|
// 开启时若有记录且曾为开,恢复 OSD 显示
|
||||||
|
if (pendingOsdEnabled === true) {
|
||||||
|
osdConfig.value.overlayEnabled = true
|
||||||
|
saveOsdConfig(osdConfig.value)
|
||||||
|
}
|
||||||
|
// 消费记录,避免下次开启再次恢复
|
||||||
|
pendingOsdEnabled = null
|
||||||
|
try { localStorage.removeItem(OSD_PENDING_KEY) } catch { /* 忽略 */ }
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
errorMsg.value = String(e)
|
errorMsg.value = String(e)
|
||||||
logger.error('设置自动启动开关失败: ' + e)
|
logger.error('设置自动启动开关失败: ' + e)
|
||||||
@@ -758,67 +797,172 @@ export const useMonitorStore = defineStore('monitor', () => {
|
|||||||
/** 根据显示项估算悬浮窗窗口尺寸(逻辑像素)
|
/** 根据显示项估算悬浮窗窗口尺寸(逻辑像素)
|
||||||
* single: 单行分组式,组间用 | 分隔,固定宽度数据列
|
* single: 单行分组式,组间用 | 分隔,固定宽度数据列
|
||||||
* group: 分组横排,标题在上 + 数据列在下
|
* 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(
|
function computeOsdWindowSize(
|
||||||
_itemCount: number,
|
_itemCount: number,
|
||||||
layout: 'single' | 'group' | 'multiline',
|
layout: 'single' | 'group' | 'multiline',
|
||||||
fontSize: number,
|
fontSize: number,
|
||||||
_hasNetItem = false,
|
_hasNetItem = false,
|
||||||
items?: OsdItem[],
|
items: OsdItem[] = [],
|
||||||
): { w: number; h: number } {
|
): { w: number; h: number } {
|
||||||
const charW = fontSize * 0.62
|
const charW = fontSize * 0.6 // 等宽 ASCII 字符宽(Cascadia/Consolas ≈0.6em)
|
||||||
|
const cjkW = fontSize // CJK 全角字符宽
|
||||||
const barHPad = 8 // osd-bar 左右 padding 4*2
|
const barHPad = 8 // osd-bar 左右 padding 4*2
|
||||||
|
|
||||||
// 按硬件类型分组(与渲染逻辑一致)
|
const isCjk = (ch: string) => {
|
||||||
const groupMap = new Map<string, OsdItem[]>()
|
const c = ch.codePointAt(0)!
|
||||||
if (items?.length) {
|
return (
|
||||||
for (const item of items) {
|
(c >= 0x2e80 && c <= 0x9fff) ||
|
||||||
let gkey: string
|
(c >= 0x3000 && c <= 0x303f) ||
|
||||||
if (item.special === 'net-up' || item.special === 'net-down') gkey = 'network'
|
(c >= 0xff00 && c <= 0xffef) ||
|
||||||
else if (item.groupId.startsWith('gpu')) gkey = 'gpu'
|
(c >= 0xf900 && c <= 0xfaff)
|
||||||
else gkey = item.groupId
|
)
|
||||||
if (!groupMap.has(gkey)) groupMap.set(gkey, [])
|
|
||||||
groupMap.get(gkey)!.push(item)
|
|
||||||
}
|
}
|
||||||
|
// 字符串像素宽(CJK 全角,ASCII 等宽)
|
||||||
|
const textPx = (s: string) => {
|
||||||
|
let w = 0
|
||||||
|
for (const ch of s) w += isCjk(ch) ? cjkW : charW
|
||||||
|
return w
|
||||||
}
|
}
|
||||||
const groupCount = Math.max(1, groupMap.size)
|
|
||||||
|
|
||||||
// 每组数据列宽度:标签(6ch) + 各项(数值+单位+箭头/gap)
|
const showLabel = osdConfig.value?.showLabel !== false
|
||||||
const groupWidths: number[] = []
|
const en = osdConfig.value?.labelLanguage === 'en'
|
||||||
for (const [, groupItems] of groupMap) {
|
const groupLabelText = (gkey: string): string => {
|
||||||
const labelW = 6
|
if (en) {
|
||||||
const dataW = groupItems.reduce((sum, item) => {
|
switch (gkey) {
|
||||||
const isNet = item.special === 'net-up' || item.special === 'net-down'
|
case 'cpu': return 'CPU'
|
||||||
return sum + (isNet ? 11 : 8) + 1
|
case 'gpu': return 'GPU'
|
||||||
}, 0)
|
case 'memory': return 'RAM'
|
||||||
groupWidths.push(labelW + dataW)
|
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') {
|
if (layout === 'multiline') {
|
||||||
// 多行:取最宽行
|
// 每行 = 标签(min 4ch) + 组内 gap(4px) + 各项;取最宽行
|
||||||
const maxLineW = groupWidths.length ? Math.max(...groupWidths) : 10
|
const labelMinW = 4 * charW
|
||||||
const w = Math.ceil(maxLineW * charW + barHPad)
|
let maxLineW = 0
|
||||||
const lineH = Math.ceil(fontSize + 2)
|
for (const [gkey, list] of groupEntries) {
|
||||||
const h = Math.ceil(groupCount * lineH + 6)
|
const labelW = showLabel ? Math.max(textPx(groupLabelText(gkey)), labelMinW) : 0
|
||||||
return { w: Math.max(120, w), h: Math.max(28, h) }
|
const itemsW = list.reduce((s, it) => s + itemPx(it), 0)
|
||||||
|
const gapCount = list.length + (showLabel ? 1 : 0) - 1
|
||||||
|
maxLineW = Math.max(maxLineW, labelW + itemsW + Math.max(0, gapCount) * 4)
|
||||||
|
}
|
||||||
|
const w = Math.max(120, Math.ceil(maxLineW + barHPad))
|
||||||
|
const h = Math.max(28, Math.ceil(groupCount * lineH + 6))
|
||||||
|
return { w, h }
|
||||||
}
|
}
|
||||||
|
|
||||||
if (layout === 'group') {
|
if (layout === 'group') {
|
||||||
// 分组横排:各组横排 + 标题行
|
// 分组横排:各组横排,组间 gap(8px),每组含 padding(4*2)
|
||||||
const totalW = groupWidths.reduce((s, w) => s + w + 4, 0) + (groupCount - 1) * 4
|
const totalW = gw.reduce((s, w) => s + w, 0)
|
||||||
const w = Math.ceil(totalW * charW + barHPad)
|
+ groupCount * 8 // 每组左右 padding 4*2
|
||||||
|
+ Math.max(0, groupCount - 1) * 8 // 组间 gap
|
||||||
|
const w = Math.max(120, Math.ceil(totalW + barHPad))
|
||||||
const titleH = Math.ceil(fontSize * 0.85) + 2
|
const titleH = Math.ceil(fontSize * 0.85) + 2
|
||||||
const dataH = Math.ceil(fontSize) + 2
|
const dataH = lineH
|
||||||
const h = Math.ceil(titleH + dataH + 10)
|
const h = Math.max(40, Math.ceil(titleH + dataH + 3 + 3))
|
||||||
return { w: Math.max(120, w), h: Math.max(40, h) }
|
return { w, h }
|
||||||
}
|
}
|
||||||
|
|
||||||
// single:单行分组式,各组横排 + 组间 | 分隔符(1ch)
|
// single:单行分组式,组间 | 分隔符 + 组间 gap(4px)
|
||||||
const sepW = (groupCount - 1) * 1
|
const sepW = (groupCount - 1) * (textPx('|') + 4)
|
||||||
const totalW = groupWidths.reduce((s, w) => s + w, 0) + sepW
|
const betweenW = Math.max(0, groupCount - 1) * 4
|
||||||
const w = Math.ceil(totalW * charW + barHPad)
|
const w = Math.max(120, Math.ceil(gw.reduce((s, w) => s + w, 0) + sepW + betweenW + barHPad))
|
||||||
const h = Math.ceil(fontSize + 8)
|
const h = Math.max(28, Math.ceil(fontSize + 8))
|
||||||
return { w: Math.max(120, w), h: Math.max(28, h) }
|
return { w, h }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 创建/显示悬浮窗窗口(默认置顶 + NoActivate + 点击穿透) */
|
/** 创建/显示悬浮窗窗口(默认置顶 + NoActivate + 点击穿透) */
|
||||||
@@ -1049,21 +1193,30 @@ export const useMonitorStore = defineStore('monitor', () => {
|
|||||||
// stop 句柄存入 osdWatchStops,dispose 时统一释放,避免模块重挂载后重复注册
|
// stop 句柄存入 osdWatchStops,dispose 时统一释放,避免模块重挂载后重复注册
|
||||||
|
|
||||||
// OSD 开关变化时创建/隐藏悬浮窗
|
// OSD 开关变化时创建/隐藏悬浮窗
|
||||||
|
// 注意:开启 OSD 不再自动开启"应用启动时自动启动监控内核"。
|
||||||
|
// 二者保持独立(双向联动会导致:关自动启动→关 OSD→再开 OSD→自动启动又被强行打开)。
|
||||||
osdWatchStops.push(watch(() => osdConfig.value.overlayEnabled, (enabled) => {
|
osdWatchStops.push(watch(() => osdConfig.value.overlayEnabled, (enabled) => {
|
||||||
if (enabled) {
|
if (enabled) {
|
||||||
// OSD 显示开启时自动开启"应用启动时自动启动监控内核",
|
|
||||||
// 使 OSD 持续显示不因重启而中断
|
|
||||||
if (!autoStart.value) {
|
|
||||||
setAutoStart(true).catch(e => logger.error('[OSD] 自动开启 autoStart 失败: ' + e))
|
|
||||||
}
|
|
||||||
// 开启时若显示项为空则不创建窗口
|
// 开启时若显示项为空则不创建窗口
|
||||||
if (osdConfig.value.overlayItems.length === 0) return
|
if (osdConfig.value.overlayItems.length === 0) return
|
||||||
|
// 游戏全屏自动隐藏期间不创建窗口(退出全屏时由 osd-game-inactive 统一恢复)
|
||||||
|
if (gameFullscreen.value && osdConfig.value.gameAutoHide) return
|
||||||
ensureOverlayWindow().catch(e => logger.error('[OSD] 创建悬浮窗失败: ' + e))
|
ensureOverlayWindow().catch(e => logger.error('[OSD] 创建悬浮窗失败: ' + e))
|
||||||
} else {
|
} else {
|
||||||
hideOverlayWindow().catch(e => logger.error('[OSD] 隐藏悬浮窗失败: ' + e))
|
hideOverlayWindow().catch(e => logger.error('[OSD] 隐藏悬浮窗失败: ' + e))
|
||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
// 游戏中切换"自动隐藏"开关:立即生效(关闭时恢复显示,开启时立即隐藏)
|
||||||
|
osdWatchStops.push(watch(() => osdConfig.value.gameAutoHide, (enabled) => {
|
||||||
|
if (!osdConfig.value.overlayEnabled || !gameFullscreen.value) return
|
||||||
|
if (enabled) {
|
||||||
|
hideOverlayWindow().catch(e => logger.error('[OSD] 游戏全屏隐藏悬浮窗失败: ' + e))
|
||||||
|
} else {
|
||||||
|
ensureOverlayWindow().catch(e => logger.error('[OSD] 恢复悬浮窗失败: ' + e))
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
// 显示项变化时:无项则隐藏窗口,有项且开关开则确保窗口存在
|
// 显示项变化时:无项则隐藏窗口,有项且开关开则确保窗口存在
|
||||||
osdWatchStops.push(watch(() => osdConfig.value.overlayItems.length, (len) => {
|
osdWatchStops.push(watch(() => osdConfig.value.overlayItems.length, (len) => {
|
||||||
if (!osdConfig.value.overlayEnabled) return
|
if (!osdConfig.value.overlayEnabled) return
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ import {
|
|||||||
type ProfileMeta,
|
type ProfileMeta,
|
||||||
type KernelInfo,
|
type KernelInfo,
|
||||||
type KernelUpdateInfo,
|
type KernelUpdateInfo,
|
||||||
type ProxyStatus
|
type ProxyStatus,
|
||||||
|
type TrafficSnapshot,
|
||||||
} from '@/lib/bindings'
|
} from '@/lib/bindings'
|
||||||
|
|
||||||
// Rust 端结构体字段均带 serde(default),返回必完整;用 Required 收窄 bindings 的 optional,
|
// Rust 端结构体字段均带 serde(default),返回必完整;用 Required 收窄 bindings 的 optional,
|
||||||
@@ -53,6 +54,32 @@ export interface ProxiesResponse {
|
|||||||
proxies: Record<string, ProxyNode>
|
proxies: Record<string, ProxyNode>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** mihomo /connections 单条连接(完整字段以原始 JSON 为准,仅取前端用到的部分) */
|
||||||
|
export interface ProxyConnection {
|
||||||
|
id: string
|
||||||
|
chains?: string[]
|
||||||
|
rule?: string
|
||||||
|
rulePayload?: string
|
||||||
|
upload: number
|
||||||
|
download: number
|
||||||
|
start: string
|
||||||
|
metadata?: {
|
||||||
|
network?: string
|
||||||
|
type?: string
|
||||||
|
process?: string
|
||||||
|
host?: string
|
||||||
|
sourceIP?: string
|
||||||
|
sourcePort?: number
|
||||||
|
destinationIP?: string
|
||||||
|
destinationPort?: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** mihomo /connections 响应(原始 JSON,字段可能缺失) */
|
||||||
|
export interface ConnectionsResponse {
|
||||||
|
connections?: ProxyConnection[]
|
||||||
|
}
|
||||||
|
|
||||||
export interface MihomoVersion {
|
export interface MihomoVersion {
|
||||||
version: string
|
version: string
|
||||||
meta?: boolean
|
meta?: boolean
|
||||||
@@ -65,6 +92,10 @@ export const useProxyStore = defineStore('proxy', () => {
|
|||||||
const proxies = ref<Record<string, ProxyNode>>({})
|
const proxies = ref<Record<string, ProxyNode>>({})
|
||||||
const settings = ref<FullProxySettings | null>(null)
|
const settings = ref<FullProxySettings | null>(null)
|
||||||
const systemProxy = ref(false)
|
const systemProxy = ref(false)
|
||||||
|
/** 实时流量快照(上传/下载速率、会话总量、活跃连接数) */
|
||||||
|
const traffic = ref<TrafficSnapshot | null>(null)
|
||||||
|
/** 当前活跃连接列表(仅连接页签需要时拉取) */
|
||||||
|
const connections = ref<ProxyConnection[] | null>(null)
|
||||||
|
|
||||||
/** 是否已完成首次加载(避免初始 null/false 导致闪烁误导状态) */
|
/** 是否已完成首次加载(避免初始 null/false 导致闪烁误导状态) */
|
||||||
const initialized = ref(false)
|
const initialized = ref(false)
|
||||||
@@ -209,6 +240,40 @@ export const useProxyStore = defineStore('proxy', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- 流量 / 连接 ----------
|
||||||
|
/** 刷新实时流量快照(速率 + 会话总量 + 活跃连接数)。失败时保留上一次数据,避免抖动。 */
|
||||||
|
const refreshTraffic = async () => {
|
||||||
|
try {
|
||||||
|
traffic.value = await commands.proxyTraffic()
|
||||||
|
} catch {
|
||||||
|
/* mihomo 瞬时不可用(如重启)时保留上一次数据 */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 刷新当前活跃连接列表(原始 /connections)。供「连接」页签低频拉取。 */
|
||||||
|
const refreshConnections = async () => {
|
||||||
|
try {
|
||||||
|
const resp = await invoke<ConnectionsResponse>('proxy_get_connections')
|
||||||
|
connections.value = resp.connections ?? []
|
||||||
|
} catch {
|
||||||
|
/* mihomo 瞬时不可用(如重启)时保留上一次数据 */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 关闭指定连接 */
|
||||||
|
const closeConnection = async (id: string) => {
|
||||||
|
try {
|
||||||
|
await commands.proxyCloseConnection(id)
|
||||||
|
// 本地立即移除,无需等下一轮轮询
|
||||||
|
if (connections.value) {
|
||||||
|
connections.value = connections.value.filter((c) => c.id !== id)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
logger.error('关闭连接失败: ' + e)
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const saveSettings = async (s: FullProxySettings) => {
|
const saveSettings = async (s: FullProxySettings) => {
|
||||||
await commands.proxySaveSettings(s)
|
await commands.proxySaveSettings(s)
|
||||||
settings.value = s
|
settings.value = s
|
||||||
@@ -487,6 +552,8 @@ export const useProxyStore = defineStore('proxy', () => {
|
|||||||
proxies,
|
proxies,
|
||||||
settings,
|
settings,
|
||||||
systemProxy,
|
systemProxy,
|
||||||
|
traffic,
|
||||||
|
connections,
|
||||||
initialized,
|
initialized,
|
||||||
installing,
|
installing,
|
||||||
installProgress,
|
installProgress,
|
||||||
@@ -516,6 +583,10 @@ export const useProxyStore = defineStore('proxy', () => {
|
|||||||
setSystemProxy,
|
setSystemProxy,
|
||||||
clearSystemProxy,
|
clearSystemProxy,
|
||||||
toggleSystemProxy,
|
toggleSystemProxy,
|
||||||
|
// traffic & connections
|
||||||
|
refreshTraffic,
|
||||||
|
refreshConnections,
|
||||||
|
closeConnection,
|
||||||
// kernel update / install
|
// kernel update / install
|
||||||
checkKernelUpdate,
|
checkKernelUpdate,
|
||||||
updateKernel,
|
updateKernel,
|
||||||
|
|||||||
+18
-4
@@ -54,19 +54,33 @@ export default defineConfig(async () => ({
|
|||||||
clearScreen: false,
|
clearScreen: false,
|
||||||
// 2. tauri expects a fixed port, fail if that port is not available
|
// 2. tauri expects a fixed port, fail if that port is not available
|
||||||
server: {
|
server: {
|
||||||
port: 1420,
|
// 1420/1421 落在 Windows 保留端口段 1333-1432 内(Hyper-V/WinNAT 动态保留),
|
||||||
|
// 绑定时报 EACCES,故改用 14210/14211(netsh excludedportrange 确认可用)
|
||||||
|
port: 14210,
|
||||||
strictPort: true,
|
strictPort: true,
|
||||||
host: host || false,
|
host: host || false,
|
||||||
hmr: host
|
hmr: host
|
||||||
? {
|
? {
|
||||||
protocol: "ws",
|
protocol: "ws",
|
||||||
host,
|
host,
|
||||||
port: 1421,
|
port: 14211,
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
watch: {
|
watch: {
|
||||||
// 3. tell Vite to ignore watching `src-tauri`
|
// 3. 让 Vite 忽略监听这些大目录,避免 Windows 上 chokidar 递归注册监听句柄
|
||||||
ignored: ["**/src-tauri/**"],
|
// 拖慢 dev 冷启动。src-tauri/target 是 Rust 构建产物(本仓库可达 5 万+ 文件、
|
||||||
|
// 数十 GB),默认仅忽略 node_modules/.git/.vite,若被递归遍历,WebView 首屏
|
||||||
|
// 加载会被阻塞几十秒(详见 juejin.cn/post/7657865700393451554)。
|
||||||
|
ignored: [
|
||||||
|
"**/src-tauri/**",
|
||||||
|
"**/node_modules/**",
|
||||||
|
"**/dist/**",
|
||||||
|
"**/release_stage/**",
|
||||||
|
"**/ThingHK/**",
|
||||||
|
"**/.git/**",
|
||||||
|
"**/.idea/**",
|
||||||
|
"**/.vite/**",
|
||||||
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
Reference in New Issue
Block a user