3 Commits
Author SHA1 Message Date
zhongluofeng 28e0c4664a 构建修正 2026-08-27 18:32:33 +08:00
zhongluofeng d21649c60e 优化调整、BT下载(有bug) 2026-08-27 18:25:45 +08:00
zhongluofeng 4dd60f42a1 细节调整及优化(26.8.3) 2026-08-21 17:25:47 +08:00
81 changed files with 9748 additions and 1571 deletions
+44 -10
View File
@@ -24,6 +24,9 @@ internal sealed class HardwareManager : IDisposable
private readonly bool _isAdmin; private readonly bool _isAdmin;
private readonly double _coldStartMs; private readonly double _coldStartMs;
private readonly Stopwatch _startupSw; private readonly Stopwatch _startupSw;
// 传感器 ID 缓存:避免每秒为每个传感器重复拼接字符串(减少 GC 压力)
private readonly Dictionary<ISensor, string> _sensorIdCache = new();
private bool _coldStartSent;
private bool _ready; private bool _ready;
private bool _closed; private bool _closed;
private HardwareConfig _config; private HardwareConfig _config;
@@ -84,7 +87,7 @@ internal sealed class HardwareManager : IDisposable
/// <summary> /// <summary>
/// 全量 Update 所有硬件。 /// 全量 Update 所有硬件。
/// 由 SamplingScheduler 按通道分频调用 /// 仅用于构造函数首轮填充,运行期由调度器分频调用 UpdateFastOnly/UpdateSlowOnly
/// </summary> /// </summary>
public void UpdateAll() public void UpdateAll()
{ {
@@ -94,9 +97,24 @@ internal sealed class HardwareManager : IDisposable
} }
} }
/// <summary>
/// 仅 Update 快通道硬件(CPU/GPU/Memory/Network 等)。
/// 由调度器快通道 tick 高频调用,避免 SMART 等重查询拖慢采样节奏。
/// </summary>
public void UpdateFastOnly()
{
foreach (var hw in _visitor.AllHardware)
{
if (!IsSlowHardware(hw.HardwareType))
{
try { hw.Update(); } catch { /* 单个硬件 Update 失败不影响整体 */ }
}
}
}
/// <summary> /// <summary>
/// 仅 Update 慢通道硬件(Storage/PSU/Battery 等)。 /// 仅 Update 慢通道硬件(Storage/PSU/Battery 等)。
/// 快通道硬件(CPU/GPU/Memory/Network)由调度器更高频调用 UpdateAll /// 快通道硬件(CPU/GPU/Memory/Network)由调度器更高频调用 UpdateFastOnly
/// </summary> /// </summary>
public void UpdateSlowOnly() public void UpdateSlowOnly()
{ {
@@ -123,10 +141,11 @@ internal sealed class HardwareManager : IDisposable
Ready = _ready, Ready = _ready,
}; };
// 首个快照带上冷启动耗时,后续为 0 // 首个快照带上冷启动耗时,后续为 0(修复:此前每个快照都携带 ColdStartMs
if (_coldStartMs > 0 && snap.Timestamp > 0) if (_coldStartMs > 0 && !_coldStartSent)
{ {
snap.ColdStartMs = Math.Round(_coldStartMs, 1); snap.ColdStartMs = Math.Round(_coldStartMs, 1);
_coldStartSent = true;
} }
// 重新遍历以读取最新传感器值(visitor 缓存的是 hardware 引用,sensor 值实时) // 重新遍历以读取最新传感器值(visitor 缓存的是 hardware 引用,sensor 值实时)
@@ -156,7 +175,7 @@ internal sealed class HardwareManager : IDisposable
g.Sensors.Add(new SensorEntry g.Sensors.Add(new SensorEntry
{ {
Id = $"{groupId}/{hw.Name}/{s.SensorType}/{s.Name}".Replace(' ', '_').ToLowerInvariant(), Id = GetSensorId(s, groupId, hw.Name),
Name = s.Name, Name = s.Name,
Type = sensorType, Type = sensorType,
Value = s.Value, Value = s.Value,
@@ -169,6 +188,21 @@ internal sealed class HardwareManager : IDisposable
return snap; return snap;
} }
/// <summary>
/// 获取传感器稳定 ID(带缓存)。
/// ID 格式:{groupId}/{hwName}/{sensorType}/{sensorName},空格转下划线、小写化。
/// 缓存命中直接返回,未命中(新传感器)计算后入缓存。
/// </summary>
private string GetSensorId(ISensor s, string groupId, string hwName)
{
if (!_sensorIdCache.TryGetValue(s, out var id))
{
id = $"{groupId}/{hwName}/{s.SensorType}/{s.Name}".Replace(' ', '_').ToLowerInvariant();
_sensorIdCache[s] = id;
}
return id;
}
/// <summary> /// <summary>
/// 为已启用但 LHB 未枚举到的硬件类型预创建空分组。 /// 为已启用但 LHB 未枚举到的硬件类型预创建空分组。
/// 场景:用户在设置中勾选了主板/电池/电源等,但 LHB 在当前权限或机型下检测不到对应硬件, /// 场景:用户在设置中勾选了主板/电池/电源等,但 LHB 在当前权限或机型下检测不到对应硬件,
@@ -290,14 +324,14 @@ internal sealed class SnapshotVisitor : IVisitor
} }
/// <summary> /// <summary>
/// 采样调度器:按快/慢通道分频驱动 HardwareManager.UpdateAll /// 采样调度器:按快/慢通道分频驱动 HardwareManager 的分层 Update。
/// 使用 Channel 向 SSE 推送层广播快照(解耦:调度器不关心有几个订阅者)。 /// 使用 Channel 向 SSE 推送层广播快照(解耦:调度器不关心有几个订阅者)。
/// ///
/// 调度策略: /// 调度策略:
/// - 快通道 tickUpdateAll(含慢通道硬件,因 UpdateAll 成本主要在 SMART,已通过慢通道分频减少调用频率) /// - 快通道 tickUpdateFastOnly(仅 CPU/GPU/Memory/Network 等轻量硬件),
/// 实际优化:快通道 tick 只 Update 快通道硬件(UpdateFastOnly),慢通道单独按慢节奏 Update /// 避免 SMART 等重查询每秒执行拖慢采样节奏
/// - 慢通道 tickUpdateSlowOnly(仅 Storage/PSU/Motherboard 等) /// - 慢通道 tickUpdateSlowOnly(仅 Storage/PSU/Motherboard 等)
/// - 每个 tick 结束后构建快照并广播 /// - 快通道每个 tick 结束后构建快照并广播(慢通道更新后的值随下一帧带出)
/// </summary> /// </summary>
internal sealed class SamplingScheduler : IDisposable internal sealed class SamplingScheduler : IDisposable
{ {
@@ -352,7 +386,7 @@ internal sealed class SamplingScheduler : IDisposable
{ {
try try
{ {
_hw.UpdateAll(); _hw.UpdateFastOnly();
var snap = _hw.BuildSnapshot(); var snap = _hw.BuildSnapshot();
_cache.Update(snap); _cache.Update(snap);
_broadcast.Writer.TryWrite(snap); _broadcast.Writer.TryWrite(snap);
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "thing", "name": "thing",
"private": true, "private": true,
"version": "26.8.1", "version": "26.8.4",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+1355 -24
View File
File diff suppressed because it is too large Load Diff
+17 -2
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "thing" name = "thing"
version = "26.8.1" 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"
@@ -57,6 +59,7 @@ windows-sys = { version = "0.52", features = [
"Win32_System_Ole", "Win32_System_Ole",
"Win32_System_Memory", "Win32_System_Memory",
"Win32_System_DataExchange", "Win32_System_DataExchange",
"Win32_System_SystemInformation",
"Win32_UI_WindowsAndMessaging", "Win32_UI_WindowsAndMessaging",
"Win32_UI_Shell", "Win32_UI_Shell",
"Win32_UI_HiDpi", "Win32_UI_HiDpi",
@@ -66,7 +69,7 @@ windows-sys = { version = "0.52", features = [
"Win32_Storage_Xps", "Win32_Storage_Xps",
"Win32_Storage_FileSystem", "Win32_Storage_FileSystem",
] } ] }
# Explorer 前台目录检测(IShellWindows 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",
@@ -79,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.
@@ -0,0 +1,19 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "clipboard-preview",
"description": "Capability for clipboard independent preview window",
"windows": ["clipboard-preview"],
"permissions": [
"core:default",
"core:window:allow-hide",
"core:window:allow-show",
"core:window:allow-set-focus",
"core:window:allow-close",
"core:window:allow-set-theme",
"core:window:allow-set-effects",
"core:window:allow-set-background-color",
"core:event:allow-listen",
"core:event:allow-emit",
"snap-layout:default"
]
}
@@ -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"
]
}
@@ -358,6 +358,11 @@ async function addDownload(url, filename, referer, cookies, headers) {
} }
// ===== 下载拦截 ===== // ===== 下载拦截 =====
// 处理中的 URL(防止同一 URL 并发/重入,也避免与引擎去重检查竞态)
const processingUrls = new Set()
// 我们自己用 chrome.downloads.download 回退创建的下载 URL(短时间内跳过,防止再次被拦截形成死循环)
const fallbackUrls = new Map() // url -> 过期时间戳
async function shouldIntercept(downloadItem) { async function shouldIntercept(downloadItem) {
const config = await getConfig() const config = await getConfig()
if (!config.interceptDownload) return false if (!config.interceptDownload) return false
@@ -373,27 +378,94 @@ async function shouldIntercept(downloadItem) {
return true return true
} }
async function handleDownloadCreated(downloadItem) { /**
if (!await shouldIntercept(downloadItem)) return * 判断是否为"历史下载"(安装插件之前就已存在、随后被浏览器恢复的旧下载)
* 这类下载一律不接管,交给浏览器原生处理,实现"只接管以后的下载,历史都不管"
*/
async function isHistoricalDownload(item) {
// 1) canResume=true 表示已存在有效的部分文件,说明浏览器在恢复旧下载
if (item.canResume) return true
// 2) 开始时间早于插件首次安装时间(浏览器重启后恢复的旧下载会保留原来的开始时间)
try { try {
await chrome.downloads.cancel(downloadItem.id) const start = item.startTime ? new Date(item.startTime).getTime() : 0
await chrome.downloads.erase({ id: downloadItem.id }) if (start > 0) {
const stored = await chrome.storage.local.get('installTime')
const installTime = stored.installTime || 0
if (installTime > 0 && start < installTime) return true
}
} catch { /* ignore */ } } catch { /* ignore */ }
return false
}
const url = downloadItem.finalUrl || downloadItem.url /**
const filename = downloadItem.filename || '' * 查询引擎是否已有同 URL 的非终态任务(活跃/排队/暂停)
* 用于防止同一下载被重复转发、重复下载
*/
async function hasExistingTask(url) {
try { try {
await addDownload(url, filename, downloadItem.referrer, '') const tasks = await apiRequest('/api/downloads')
} catch (e) { return tasks.some(t => {
// 添加失败:回退到浏览器自带下载,不弹通知 const s = t.status
try { await chrome.downloads.download({ url }) } catch { /* ignore */ } if (s === 'complete' || s === 'error') return false
return t.url === url
})
} catch {
return false
} }
} }
// ===== 右键菜单 ===== async function handleDownloadCreated(downloadItem) {
chrome.runtime.onInstalled.addListener(() => { const url = downloadItem.finalUrl || downloadItem.url
// 回退下载:我们自己用 chrome.downloads.download 创建的,直接跳过,避免无限循环
const fbExp = fallbackUrls.get(url)
if (fbExp && Date.now() < fbExp) {
fallbackUrls.delete(url)
return
}
// 历史下载(安装前的旧下载被浏览器恢复)一律不接管
if (await isHistoricalDownload(downloadItem)) return
if (!await shouldIntercept(downloadItem)) return
// 同一 URL 已在处理中,跳过(防并发/防重复转发)
if (processingUrls.has(url)) return
// 引擎不可达时不接管(保留浏览器原生下载),避免取消后下载无处可去
let connected = false
try { connected = await testConnection() } catch { connected = false }
if (!connected) return
// 引擎已有同 URL 的非终态任务,不重复转发
if (await hasExistingTask(url)) return
processingUrls.add(url)
try {
try {
await chrome.downloads.cancel(downloadItem.id)
await chrome.downloads.erase({ id: downloadItem.id })
} catch { /* ignore */ }
const filename = downloadItem.filename || ''
try {
await addDownload(url, filename, downloadItem.referrer, '')
} catch (e) {
// 添加失败:回退为浏览器自带下载,并标记该 URL 短时间内跳过,防止再次被拦截形成死循环
fallbackUrls.set(url, Date.now() + 3000)
try { await chrome.downloads.download({ url }) } catch { /* ignore */ }
}
} finally {
processingUrls.delete(url)
}
}
// ===== 右键菜单 & 安装标记 =====
chrome.runtime.onInstalled.addListener(async (details) => {
// 记录首次安装时间:用于区分"安装前的历史下载"(被浏览器恢复的旧下载)与"安装后的新下载"
if (details.reason === 'install') {
await chrome.storage.local.set({ installTime: Date.now() })
}
chrome.contextMenus.create({ chrome.contextMenus.create({
id: 'thing-download-link', id: 'thing-download-link',
title: '使用 Thing 下载此链接', title: '使用 Thing 下载此链接',
@@ -1,7 +1,7 @@
{ {
"manifest_version": 3, "manifest_version": 3,
"name": "Thing Extension", "name": "Thing Extension",
"version": "0.2.0", "version": "0.30",
"description": "发送浏览器下载到 Thing 下载引擎,嗅探网页资源。", "description": "发送浏览器下载到 Thing 下载引擎,嗅探网页资源。",
"icons": { "icons": {
"16": "icons/icon-16.png", "16": "icons/icon-16.png",
+95 -12
View File
@@ -7,7 +7,7 @@ use specta::Type;
use tauri::{AppHandle, State}; use tauri::{AppHandle, State};
use super::manager::{ClipboardManager, ClipboardSettings}; use super::manager::{ClipboardManager, ClipboardSettings};
use super::reader::dib_to_png; use super::reader::{dib_to_png, dib_to_thumbnail};
use super::storage::{ClipboardItem, ClipboardItemDetail}; use super::storage::{ClipboardItem, ClipboardItemDetail};
#[derive(Serialize, Type)] #[derive(Serialize, Type)]
@@ -95,6 +95,21 @@ pub async fn clipboard_get_item(
.map_err(|e| format!("查询任务失败: {}", e)) .map_err(|e| format!("查询任务失败: {}", e))
} }
/// 获取图片缩略图 PNG base64(弹窗悬停预览用,避免加载全尺寸图片)
#[tauri::command]
#[specta::specta]
pub async fn clipboard_get_thumb(
id: i64,
manager: State<'_, ClipboardManager>,
) -> Result<Option<String>, String> {
let storage = manager.storage().clone();
tauri::async_runtime::spawn_blocking(move || {
storage.get_thumb(id, |dib| dib_to_thumbnail(dib, 256))
})
.await
.map_err(|e| format!("查询任务失败: {}", e))
}
#[tauri::command] #[tauri::command]
#[specta::specta] #[specta::specta]
pub async fn clipboard_set_pinned( pub async fn clipboard_set_pinned(
@@ -162,25 +177,44 @@ pub async fn clipboard_save_settings(
app: AppHandle, app: AppHandle,
manager: State<'_, ClipboardManager>, manager: State<'_, ClipboardManager>,
) -> Result<(), String> { ) -> Result<(), String> {
let prev_enabled = manager.get_settings().enabled; let prev = manager.get_settings();
let prev_shortcut = manager.get_settings().shortcut.clone(); let prev_enabled = prev.enabled;
let prev_shortcut = prev.shortcut.clone();
// 快捷键变化且新值非空:先注册(原子化 + 冲突检测),成功后才保存设置。
// 注册失败时恢复旧快捷键并中止保存,避免设置被写入无法生效的组合键、旧键丢失。
if settings.shortcut != prev_shortcut && !settings.shortcut.trim().is_empty() {
if let Err(e) = crate::shortcut::register_shortcut(&app, "剪贴板", &settings.shortcut, |a| {
super::popup::show_popup(a)
}) {
// register_shortcut 内部已注销旧快捷键,失败时需重新注册旧键以恢复
if !prev_shortcut.trim().is_empty() {
let _ = crate::shortcut::register_shortcut(&app, "剪贴板", &prev_shortcut, |a| {
super::popup::show_popup(a)
});
}
return Err(e);
}
// 新快捷键非空时确保弹窗/预览窗口已预创建
super::popup::ensure_popup_window(&app);
super::popup::ensure_preview_window(&app);
}
// 保存设置
manager.save_settings(settings.clone()); manager.save_settings(settings.clone());
// 监听开关变化时联动启停 // 监听开关变化时联动启停
if settings.enabled && !prev_enabled { if settings.enabled && !prev_enabled {
manager.start(&app); manager.start(&app);
} else if !settings.enabled && prev_enabled { } else if !settings.enabled && prev_enabled {
manager.stop(); manager.stop();
} }
// 快捷键变化时重新注册(共享工具模块,原子化 + 冲突检测)
if settings.shortcut != prev_shortcut { // 快捷键改为空字符串(禁用):注销旧快捷键
crate::shortcut::register_shortcut(&app, "剪贴板", &settings.shortcut, |a| { if settings.shortcut.trim().is_empty() && settings.shortcut != prev_shortcut {
super::popup::show_popup(a) crate::shortcut::unregister_shortcut(&app, "剪贴板");
})?;
// 新快捷键非空时确保弹窗窗口已预创建
if !settings.shortcut.trim().is_empty() {
super::popup::ensure_popup_window(&app);
}
} }
Ok(()) Ok(())
} }
@@ -262,3 +296,52 @@ pub async fn clipboard_paste_to_target(app: AppHandle) -> Result<(), String> {
super::popup::paste_to_target(&app); super::popup::paste_to_target(&app);
Ok(()) Ok(())
} }
/// 在弹窗旁显示独立预览窗口(悬停/键盘选中时调用)
#[tauri::command]
#[specta::specta]
pub async fn clipboard_show_preview(app: AppHandle, id: i64) -> Result<(), String> {
super::popup::show_preview(&app, id);
Ok(())
}
/// 隐藏独立预览窗口
#[tauri::command]
#[specta::specta]
pub async fn clipboard_hide_preview(app: AppHandle) -> Result<(), String> {
super::popup::hide_preview(&app);
Ok(())
}
/// 按内容自适应调整预览窗大小(逻辑像素)。前端加载内容(文本测高、图片按宽高比)后调用,
/// 窗口贴合内容消除留白;后端按弹窗所在屏工作区钳制并重新对齐弹窗。
/// allow_flip:初始落位为 true(优先侧放不下可换侧);放大/还原为 false(保持原侧)。
#[tauri::command]
#[specta::specta]
pub async fn clipboard_resize_preview(
app: AppHandle,
width: f64,
height: f64,
allow_flip: bool,
) -> Result<(), String> {
super::popup::resize_preview(&app, width, height, allow_flip);
Ok(())
}
/// 显示已就绪的预览窗口。前端完成内容加载与 resize 后调用,窗口以最终尺寸出现,
/// 消除"先以上次尺寸(可能是放大态大窗)显示再缩回"的闪烁。
#[tauri::command]
#[specta::specta]
pub async fn clipboard_reveal_preview(app: AppHandle) -> Result<(), String> {
super::popup::reveal_preview(&app);
Ok(())
}
/// 预览窗交互锁定:前端预览窗收到 mousedown(放大/缩小、复制、选择文本)时调用。
/// 此后弹窗+预览不因失焦/鼠标离开而关闭,仅当点击外部或弹窗重新聚焦时退出锁定。
#[tauri::command]
#[specta::specta]
pub async fn clipboard_preview_interacted(app: AppHandle) -> Result<(), String> {
super::popup::preview_interacted(&app);
Ok(())
}
+9 -6
View File
@@ -12,6 +12,7 @@ use specta::Type;
use super::monitor::start_monitor; use super::monitor::start_monitor;
use super::reader::{write_dib, write_files, write_text}; use super::reader::{write_dib, write_files, write_text};
use super::storage::Storage; use super::storage::Storage;
use windows_sys::Win32::System::DataExchange::GetClipboardSequenceNumber;
/// 剪贴板设置(持久化到 clipboard/settings.json /// 剪贴板设置(持久化到 clipboard/settings.json
#[derive(Clone, Serialize, Deserialize, Type)] #[derive(Clone, Serialize, Deserialize, Type)]
@@ -55,7 +56,8 @@ impl Default for ClipboardSettings {
pub struct ClipboardManager { pub struct ClipboardManager {
storage: Arc<Storage>, storage: Arc<Storage>,
settings: Arc<Mutex<ClipboardSettings>>, settings: Arc<Mutex<ClipboardSettings>>,
suppress: Arc<AtomicBool>, /// 本应用 copy_back 写入后的剪贴板序列号,用于跳过自身写入产生的记录
suppress: Arc<Mutex<Option<u32>>>,
monitor_stop: Arc<AtomicBool>, monitor_stop: Arc<AtomicBool>,
monitor_handle: Mutex<Option<JoinHandle<()>>>, monitor_handle: Mutex<Option<JoinHandle<()>>>,
settings_path: PathBuf, settings_path: PathBuf,
@@ -73,7 +75,7 @@ impl ClipboardManager {
}; };
let settings_path = clip_dir.join("settings.json"); let settings_path = clip_dir.join("settings.json");
let settings = Arc::new(Mutex::new(load_settings(&settings_path))); let settings = Arc::new(Mutex::new(load_settings(&settings_path)));
let suppress = Arc::new(AtomicBool::new(false)); let suppress = Arc::new(Mutex::new(None));
let monitor_stop = Arc::new(AtomicBool::new(true)); let monitor_stop = Arc::new(AtomicBool::new(true));
Self { Self {
storage, storage,
@@ -139,13 +141,12 @@ impl ClipboardManager {
save_settings(&self.settings_path, &s); save_settings(&self.settings_path, &s);
} }
/// 将某条历史写回剪贴板。写回前置 suppress 标志以避免再次记录 /// 将某条历史写回剪贴板。写回成功后记录剪贴板序列号,供监听跳过自身写入
pub fn copy_back(&self, id: i64) -> Result<(), String> { pub fn copy_back(&self, id: i64) -> Result<(), String> {
let (kind, content, blob) = self let (kind, content, blob) = self
.storage .storage
.get_raw_for_copy(id) .get_raw_for_copy(id)
.ok_or_else(|| "条目不存在".to_string())?; .ok_or_else(|| "条目不存在".to_string())?;
self.suppress.store(true, Ordering::SeqCst);
let ok = match kind.as_str() { let ok = match kind.as_str() {
"text" => content.as_deref().map(write_text).unwrap_or(false), "text" => content.as_deref().map(write_text).unwrap_or(false),
"image" => blob.as_deref().map(write_dib).unwrap_or(false), "image" => blob.as_deref().map(write_dib).unwrap_or(false),
@@ -163,10 +164,12 @@ impl ClipboardManager {
_ => false, _ => false,
}; };
if ok { if ok {
// 绑定到写入完成后的剪贴板序列号:仅跳过本次写入产生的记录,
// 用户后续复制(序列号不同)不会被误吞。
let seq = unsafe { GetClipboardSequenceNumber() };
*self.suppress.lock().unwrap_or_else(|e| e.into_inner()) = Some(seq);
Ok(()) Ok(())
} else { } else {
// 写入失败也清除 suppress,避免误吞下次复制
self.suppress.store(false, Ordering::SeqCst);
Err("写回剪贴板失败".into()) Err("写回剪贴板失败".into())
} }
} }
+6 -3
View File
@@ -9,9 +9,12 @@ pub mod storage;
pub use commands::{ pub use commands::{
clipboard_clear, clipboard_copy_back, clipboard_count, clipboard_delete, clipboard_get_history, clipboard_clear, clipboard_copy_back, clipboard_count, clipboard_delete, clipboard_get_history,
clipboard_get_item, clipboard_get_pinned, clipboard_get_settings, clipboard_hide_popup, clipboard_get_item, clipboard_get_pinned, clipboard_get_settings, clipboard_get_thumb,
clipboard_paste_to_target, clipboard_register_shortcut, clipboard_save_settings, clipboard_search, clipboard_hide_popup, clipboard_hide_preview, clipboard_paste_to_target,
clipboard_set_pinned, clipboard_show_popup, clipboard_show_window, clipboard_start, clipboard_preview_interacted, clipboard_register_shortcut,
clipboard_reveal_preview,
clipboard_save_settings, clipboard_search, clipboard_set_pinned, clipboard_resize_preview,
clipboard_show_popup, clipboard_show_preview, clipboard_show_window, clipboard_start,
clipboard_status, clipboard_stop, clipboard_unregister_shortcut, clipboard_status, clipboard_stop, clipboard_unregister_shortcut,
}; };
pub use manager::ClipboardManager; pub use manager::ClipboardManager;
+14 -7
View File
@@ -1,7 +1,7 @@
//! 剪贴板监听线程:基于 GetClipboardSequenceNumber 轮询 //! 剪贴板监听线程:基于 GetClipboardSequenceNumber 轮询
//! //!
//! 选用轮询而非 AddClipboardFormatListener 消息窗口:实现更简单、无需消息循环, //! 选用轮询而非 AddClipboardFormatListener 消息窗口:实现更简单、无需消息循环,
//! 800ms 间隔对剪贴板场景延迟可接受,且 GetClipboardSequenceNumber 不需要 OpenClipboard,开销极小。 //! 250ms 间隔兼顾响应速度与开销,且 GetClipboardSequenceNumber 不需要 OpenClipboard,开销极小。
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
@@ -15,18 +15,19 @@ use super::storage::{NewItem, Storage};
use windows_sys::Win32::System::DataExchange::GetClipboardSequenceNumber; use windows_sys::Win32::System::DataExchange::GetClipboardSequenceNumber;
/// 启动监听线程,返回 JoinHandle。 /// 启动监听线程,返回 JoinHandle。
/// `suppress` 记录本应用 copy_back 写入后的剪贴板序列号,用于跳过自身写入产生的记录。
pub fn start_monitor( pub fn start_monitor(
storage: Arc<Storage>, storage: Arc<Storage>,
app: AppHandle, app: AppHandle,
settings: Arc<Mutex<super::manager::ClipboardSettings>>, settings: Arc<Mutex<super::manager::ClipboardSettings>>,
suppress: Arc<AtomicBool>, suppress: Arc<Mutex<Option<u32>>>,
stop: Arc<AtomicBool>, stop: Arc<AtomicBool>,
) -> thread::JoinHandle<()> { ) -> thread::JoinHandle<()> {
thread::spawn(move || loop { thread::spawn(move || loop {
if stop.load(Ordering::SeqCst) { if stop.load(Ordering::SeqCst) {
break; break;
} }
thread::sleep(Duration::from_millis(800)); thread::sleep(Duration::from_millis(250));
if stop.load(Ordering::SeqCst) { if stop.load(Ordering::SeqCst) {
break; break;
} }
@@ -40,10 +41,13 @@ pub fn start_monitor(
if seq == last { if seq == last {
continue; continue;
} }
// 序列号变化,处理一次 // 序列号变化:仅当变化来自本应用 copy_back(序列号精确匹配)时跳过
if suppress.swap(false, Ordering::SeqCst) { // 避免旧布尔标志在用户后续复制时被误吞。
// 由本应用 copy_back 触发,跳过记录 if let Ok(mut s) = suppress.lock() {
continue; if *s == Some(seq) {
*s = None;
continue;
}
} }
let (rec_text, rec_image, rec_files, max_items, max_image_kb, dedup) = { let (rec_text, rec_image, rec_files, max_items, max_image_kb, dedup) = {
let s = settings.lock().unwrap_or_else(|e| e.into_inner()); let s = settings.lock().unwrap_or_else(|e| e.into_inner());
@@ -99,6 +103,7 @@ fn build_text_item(t: &str) -> NewItem {
kind: "text".into(), kind: "text".into(),
content: Some(t.to_string()), content: Some(t.to_string()),
blob: None, blob: None,
thumb: None,
preview: make_preview(t, 200), preview: make_preview(t, 200),
size: t.len() as i64, size: t.len() as i64,
hash: hash_str(t), hash: hash_str(t),
@@ -110,6 +115,7 @@ fn build_image_item(dib: &[u8], w: u32, h: u32) -> NewItem {
kind: "image".into(), kind: "image".into(),
content: None, content: None,
blob: Some(dib.to_vec()), blob: Some(dib.to_vec()),
thumb: super::reader::dib_to_thumbnail(dib, 256),
preview: format!("图片 {}×{}", w, h), preview: format!("图片 {}×{}", w, h),
size: dib.len() as i64, size: dib.len() as i64,
hash: hash_bytes(dib), hash: hash_bytes(dib),
@@ -132,6 +138,7 @@ fn build_files_item(files: &[String]) -> NewItem {
kind: "files".into(), kind: "files".into(),
content: Some(content), content: Some(content),
blob: None, blob: None,
thumb: None,
preview, preview,
size: files.iter().map(|f| f.len()).sum::<usize>() as i64, size: files.iter().map(|f| f.len()).sum::<usize>() as i64,
hash, hash,
+531 -5
View File
@@ -10,12 +10,27 @@
use std::sync::Mutex; use std::sync::Mutex;
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, Ordering};
use std::time::{Duration, Instant};
use tauri::{AppHandle, Manager, WebviewUrl, WebviewWindowBuilder, Emitter}; use tauri::{AppHandle, Manager, WebviewUrl, WebviewWindowBuilder, Emitter};
use tauri::window::{Effect, EffectsBuilder}; use tauri::window::{Effect, EffectsBuilder};
/// 弹窗窗口标签 /// 弹窗窗口标签
pub const POPUP_LABEL: &str = "clipboard-popup"; pub const POPUP_LABEL: &str = "clipboard-popup";
/// 预览窗口标签(悬停/键盘选中条目时在弹窗旁显示内容)
pub const PREVIEW_LABEL: &str = "clipboard-preview";
/// 预览窗口初始逻辑尺寸(后续由前端按内容自适应调整,见 resize_preview
const PREVIEW_W: f64 = 340.0;
const PREVIEW_H: f64 = 300.0;
/// 预览窗口最小逻辑尺寸(防止内容过小时窗口退化)
const PREVIEW_MIN_W: f64 = 240.0;
const PREVIEW_MIN_H: f64 = 160.0;
/// 弹窗与预览窗之间的可视间距(逻辑像素,左右对称)
const PREVIEW_GAP: f64 = 12.0;
/// 标志:show_popup 兜底创建路径设为 true,前端 onMounted 回调 show_window 时据此判断是否显示。 /// 标志:show_popup 兜底创建路径设为 true,前端 onMounted 回调 show_window 时据此判断是否显示。
/// 预创建路径不设置,避免应用启动时弹窗自动弹出。 /// 预创建路径不设置,避免应用启动时弹窗自动弹出。
static POPUP_PENDING_SHOW: AtomicBool = AtomicBool::new(false); static POPUP_PENDING_SHOW: AtomicBool = AtomicBool::new(false);
@@ -24,6 +39,50 @@ static POPUP_PENDING_SHOW: AtomicBool = AtomicBool::new(false);
/// 避免窗口重建后仍停留在屏幕外 (-10000, -10000)。 /// 避免窗口重建后仍停留在屏幕外 (-10000, -10000)。
static PENDING_POS: Mutex<Option<(f64, f64)>> = Mutex::new(None); static PENDING_POS: Mutex<Option<(f64, f64)>> = Mutex::new(None);
/// 弹窗当前是否可见(同步单一事实来源)。
/// show_preview 用它代替 popup.is_visible():后者在 hide 异步在途时可能返回过期 true,
/// 导致"弹窗已隐藏但残留的悬停/键盘请求重新弹出孤立预览窗"。该标志在隐藏路径
/// 同步置 false,任何在途请求都会直接拒绝。
static POPUP_VISIBLE: AtomicBool = AtomicBool::new(false);
/// 预览窗当前是否可见(同步标志,与 POPUP_VISIBLE 同理:预览窗以原生
/// SW_SHOWNOACTIVATE 显示,Tauri 的 is_visible() 可能与其真实状态不同步,
/// 故用该标志代替 is_visible 判断预览交互状态)。
static PREVIEW_VISIBLE: AtomicBool = AtomicBool::new(false);
/// 弹窗因预览交互(光标位于预览窗内)而推迟隐藏的标记:
/// 点击预览窗(选择/复制文本、放大图片)时,弹窗会收到 Focused(false),但用户正在
/// 预览窗内交互,此时不应隐藏弹窗。置该标记后保持弹窗显示,待用户离开预览窗
/// hide_preview)时再连同弹窗一起隐藏,避免"点击预览即关闭弹窗"导致无法交互。
static POPUP_DEFER_HIDE: AtomicBool = AtomicBool::new(false);
/// 失焦隐藏的宽限期:WebView2 透明窗口在 show 后激活期间焦点可能短暂弹跳
/// (与快速面板同因),导致 Focused(false) 紧跟在 show 之后触发并立即隐藏刚显示的弹窗。
const SHOW_GRACE: Duration = Duration::from_millis(500);
/// 最近一次 show 的时间,用于失焦宽限期判断。
static LAST_SHOWN: Mutex<Option<Instant>> = Mutex::new(None);
/// 标记"已发起显示",并记录时间供失焦宽限期使用。
fn mark_shown() {
if let Ok(mut t) = LAST_SHOWN.lock() {
*t = Some(Instant::now());
}
POPUP_VISIBLE.store(true, Ordering::SeqCst);
// 重新显示弹窗时复位推迟隐藏标记(上一轮预览交互的遗留状态不应影响本轮)
POPUP_DEFER_HIDE.store(false, Ordering::SeqCst);
}
/// 判断距上次 show 是否仍在宽限期内(是则忽略失焦自动隐藏)。
fn within_show_grace() -> bool {
LAST_SHOWN
.lock()
.ok()
.and_then(|t| *t)
.map(|t| t.elapsed() < SHOW_GRACE)
.unwrap_or(false)
}
/// 创建弹窗窗口(隐藏状态)并注册失焦监听。 /// 创建弹窗窗口(隐藏状态)并注册失焦监听。
/// 位置默认在屏幕外,show_popup 时会重新定位。 /// 位置默认在屏幕外,show_popup 时会重新定位。
/// 预创建后首次按快捷键走"窗口已存在"分支直接 show,避免首次创建的时序问题。 /// 预创建后首次按快捷键走"窗口已存在"分支直接 show,避免首次创建的时序问题。
@@ -54,13 +113,41 @@ fn create_popup_window(app: &AppHandle) {
} }
}; };
// 监听窗口失焦:自动隐藏 // 监听窗口失焦:自动隐藏(同时隐藏预览窗)
let app_handle = app.clone(); let app_handle = app.clone();
let win_handle = win.clone(); let win_handle = win.clone();
win.on_window_event(move |event| { win.on_window_event(move |event| {
if let tauri::WindowEvent::Focused(false) = event { match event {
let _ = win_handle.hide(); tauri::WindowEvent::Focused(false) => {
let _ = app_handle.emit(crate::constants::events::CLIPBOARD_POPUP_HIDE, ()); // 失焦宽限期:show 后激活期间的焦点弹跳不隐藏弹窗,避免弹窗刚显示就被隐藏
if within_show_grace() {
return;
}
// 交互锁定模式(点击过预览窗):弹窗与预览保持显示,仅由看护线程在
// "点击外部"时关闭;此处的失焦是点击预览窗(NoActivate)所致,不隐藏。
if POPUP_INTERACTED.load(Ordering::SeqCst) {
return;
}
// 鼠标当前位于预览窗或弹窗内(用户正在交互:选择/复制文本、放大图片、或
// 已移回弹窗准备继续浏览):弹窗失焦是点击预览窗或焦点反弹所致,不应隐藏弹窗。
// 置推迟标记保持弹窗显示,待用户离开整个区域(hide_preview / 再次失焦)再隐藏。
if is_cursor_in_preview(&app_handle) || is_cursor_in_popup(&app_handle) {
POPUP_DEFER_HIDE.store(true, Ordering::SeqCst);
return;
}
// 先同步标记弹窗不可见,再隐藏窗口/预览:异步 hide 在途时,残留的
// show_preview 请求会因该标志为 false 而拒绝,预览窗不会孤立残留。
POPUP_VISIBLE.store(false, Ordering::SeqCst);
POPUP_DEFER_HIDE.store(false, Ordering::SeqCst);
let _ = win_handle.hide();
hide_preview(&app_handle);
let _ = app_handle.emit(crate::constants::events::CLIPBOARD_POPUP_HIDE, ());
}
tauri::WindowEvent::Focused(true) => {
// 弹窗重新获得焦点(用户点击/移回弹窗):取消推迟隐藏状态
POPUP_DEFER_HIDE.store(false, Ordering::SeqCst);
}
_ => {}
} }
}); });
@@ -112,6 +199,7 @@ pub fn show_popup(app: &AppHandle) {
y: y as i32, y: y as i32,
})); }));
let _ = win.show(); let _ = win.show();
mark_shown();
let _ = win.set_focus(); let _ = win.set_focus();
// 通知前端刷新数据 // 通知前端刷新数据
let _ = app.emit(crate::constants::events::CLIPBOARD_POPUP_SHOW, ()); let _ = app.emit(crate::constants::events::CLIPBOARD_POPUP_SHOW, ());
@@ -143,17 +231,27 @@ pub fn show_window(app: &AppHandle) {
})); }));
} }
let _ = win.show(); let _ = win.show();
mark_shown();
let _ = win.set_focus(); let _ = win.set_focus();
// 通知前端刷新数据 // 通知前端刷新数据
let _ = app.emit(crate::constants::events::CLIPBOARD_POPUP_SHOW, ()); let _ = app.emit(crate::constants::events::CLIPBOARD_POPUP_SHOW, ());
} }
} }
/// 隐藏弹窗(不销毁,保留复用) /// 隐藏弹窗(不销毁,保留复用),并通知前端清理悬停定时器/预览。
pub fn hide_popup(app: &AppHandle) { pub fn hide_popup(app: &AppHandle) {
// 复位推迟隐藏标记(先于 hide_preview,避免 hide_preview 重入 hide_popup 形成递归)
POPUP_DEFER_HIDE.store(false, Ordering::SeqCst);
// 复位交互锁定:弹窗关闭后下次呼出恢复"失焦即隐藏"的常规模式
POPUP_INTERACTED.store(false, Ordering::SeqCst);
// 同步标记弹窗不可见,拒绝此后在途的 show_preview 请求
POPUP_VISIBLE.store(false, Ordering::SeqCst);
hide_preview(app);
if let Some(win) = app.get_webview_window(POPUP_LABEL) { if let Some(win) = app.get_webview_window(POPUP_LABEL) {
let _ = win.hide(); let _ = win.hide();
} }
// 通知前端:取消悬停定时器并隐藏预览,避免弹窗隐藏后残留定时器重新弹出预览窗
let _ = app.emit(crate::constants::events::CLIPBOARD_POPUP_HIDE, ());
} }
/// 隐藏弹窗后延迟模拟 Ctrl+V 粘贴到之前聚焦的窗口。 /// 隐藏弹窗后延迟模拟 Ctrl+V 粘贴到之前聚焦的窗口。
@@ -207,6 +305,434 @@ fn simulate_paste() {
// 非 Windows 平台暂不支持自动粘贴 // 非 Windows 平台暂不支持自动粘贴
} }
// ===== 独立预览窗口 =====
/// 创建预览窗口(隐藏状态),应用 NoActivate 样式避免抢焦点。
fn create_preview_window(app: &AppHandle) {
let win = match WebviewWindowBuilder::new(
app,
PREVIEW_LABEL,
WebviewUrl::App("index.html#clipboard-preview".into()),
)
.title("预览")
.inner_size(PREVIEW_W, PREVIEW_H)
.position(-10000.0, -10000.0) // 屏幕外,避免隐藏时一闪
.decorations(false)
.transparent(true)
.shadow(true)
.always_on_top(true)
.skip_taskbar(true)
.resizable(false)
.visible(false)
.focused(false) // 不抢占焦点
.effects(EffectsBuilder::new().effects(vec![Effect::Mica]).build())
.build()
{
Ok(w) => w,
Err(e) => {
crate::logger::log_error("clipboard", &format!("创建预览窗失败: {}", e));
return;
}
};
// NoActivate:预览窗可交互但不激活,弹窗保持前台(键盘导航/粘贴不受影响)
// 圆角:NoActivate 悬浮窗系统不自动圆角,显式指定 DWMWCP_ROUND 与弹窗外观统一
#[cfg(windows)]
if let Ok(hwnd) = win.hwnd() {
crate::win32_util::apply_no_activate(hwnd.0 as isize);
crate::win32_util::apply_rounded_corners(hwnd.0 as isize);
}
crate::logger::log_info("clipboard", "预览窗口已预创建(隐藏状态)");
}
/// 应用启动时预创建预览窗口(隐藏),随弹窗一起就绪。
pub fn ensure_preview_window(app: &AppHandle) {
if app.get_webview_window(PREVIEW_LABEL).is_some() {
return;
}
create_preview_window(app);
}
/// 预览当前所在侧(default=右,left=左),供放大/还原重定位沿用同侧。
#[derive(Clone, Copy, PartialEq)]
enum PreviewSide {
Right,
Left,
}
static PREVIEW_SIDE: Mutex<Option<PreviewSide>> = Mutex::new(None);
/// 最近一次显示预览时的鼠标 Y 锚点(物理像素)。预览窗垂直中线对齐该位置
/// (贴近鼠标所悬停的条目),resize(切换条目/放大还原)时沿用,保持中线稳定不跳变。
static PREVIEW_ANCHOR_Y: Mutex<Option<i32>> = Mutex::new(None);
/// 弹窗离开看护线程是否已在运行(防重入,见 spawn_popup_leave_watch)。
static POPUP_LEAVE_WATCH: AtomicBool = AtomicBool::new(false);
/// 交互锁定模式:用户点击过预览窗(放大/缩小、复制文本等)后置位。
/// 该模式下弹窗与预览不因失焦/鼠标离开而关闭,仅当点击弹窗+预览之外的
/// 区域(看护线程检测鼠标按下沿)或弹窗重新聚焦(恢复正常模式)时才复位。
static POPUP_INTERACTED: AtomicBool = AtomicBool::new(false);
/// 重新定位预览窗口(物理像素)。preferred 指定优先放置侧,返回实际落位侧。
/// anchor_y 为鼠标 Y 锚点:预览窗垂直中线对齐该位置(贴近悬停条目),
/// 上下 clamp 到工作区;None 时退回弹窗顶部对齐。
///
/// 无边框+阴影窗口(tao 实现)的外框矩形在客户区四周各留一条不可见边框
/// SM_CXSIZEFRAME+SM_CXPADDEDBORDER,典型 8px):outer_position/outer_size 均含该边框,
/// 而可视内容仅占客户区。若直接用外框尺寸计算右侧落位,右侧可视间距会比左侧多出
/// 两侧边框之和(约 16px)。这里以"可视边缘"对齐:frame = (外框宽 - 客户区宽) / 2,
/// 左右两侧使用同一 GAP,保证两侧可视间距一致。
fn reposition_preview(
popup: &tauri::WebviewWindow,
win: &tauri::WebviewWindow,
vw: i32,
vh: i32,
preferred: PreviewSide,
anchor_y: Option<i32>,
allow_flip: bool,
) -> PreviewSide {
// 弹窗当前物理位置与尺寸(外框,含不可见边框)
let Ok(pos) = popup.outer_position() else { return preferred };
let Ok(osz) = popup.outer_size() else { return preferred };
let Ok(isz) = popup.inner_size() else { return preferred };
let (px, py) = (pos.x, pos.y);
let pw = osz.width as i32;
// 不可见边框宽度(外框与客户区之差的一半;无阴影窗口为 0)
let frame = ((osz.width.saturating_sub(isz.width)) / 2) as i32;
// 弹窗所在显示器工作区
let (wa_left, wa_top, wa_right, wa_bottom) = get_work_area_at_point(px, py)
.unwrap_or((0, 0, 1920, 1040));
let gap = (PREVIEW_GAP * popup.scale_factor().unwrap_or(1.0)).round() as i32;
// 可视边缘对齐:
// 右侧落位 = 弹窗可视右缘(px+pw-frame) + GAP - 预览窗自身左边框(frame)
// 左侧落位 = 弹窗可视左缘(px+frame) - GAP - 预览可视宽(vw) - 预览窗左边框(frame)
// (frame 两两抵消,与旧公式一致)
let right_x = px + pw - 2 * frame + gap;
let left_x = px - gap - vw;
// 可视区域(含预览窗自身边框偏移)不越工作区
let fits_right = right_x + frame + vw <= wa_right;
let fits_left = left_x + frame >= wa_left;
let mut x = match preferred {
PreviewSide::Right => right_x,
PreviewSide::Left => left_x,
};
// 仅初始落位(allow_flip=true)允许换侧:优先侧放不下时切到另一侧。
// 内容尺寸变化(放大/还原,allow_flip=false)不允许换侧:窗口跳到另一侧会使
// 鼠标瞬间落在窗外,误触发 mouseleave 而关闭预览/弹窗;此时保持原侧并靠
// 工作区 clamp(大图可能覆盖弹窗边缘,属合理取舍,鼠标仍在预览窗内可交互)。
if allow_flip {
match preferred {
PreviewSide::Right => {
if !fits_right {
x = left_x;
}
}
PreviewSide::Left => {
if !fits_left {
x = right_x;
}
}
}
}
x = (x + frame).max(wa_left).min(wa_right - vw) - frame;
// 仅初始落位(allow_flip)按最终位置更新所在侧(换侧时位置在弹窗另一侧,判定正确);
// 放大/还原(不允许换侧)时即使被工作区 clamp 覆盖到弹窗上方,语义上仍属原侧,
// 必须沿用 preferred——否则 clamp 后按位置误判为另一侧并存档,缩小后会跳侧。
let side = if allow_flip {
if x + vw <= px { PreviewSide::Left } else { PreviewSide::Right }
} else {
preferred
};
// 垂直定位:预览窗中线对齐鼠标 Y 锚点(贴近悬停条目),上下 clamp 到工作区;
// 无锚点时退回弹窗顶部对齐
let y = match anchor_y {
Some(ay) => (ay - vh / 2).max(wa_top).min(wa_bottom - vh),
None => py.max(wa_top).min(wa_bottom - vh),
};
let _ = win.set_position(tauri::Position::Physical(tauri::PhysicalPosition { x, y }));
if let Ok(mut s) = PREVIEW_SIDE.lock() {
*s = Some(side);
}
side
}
/// 通知预览前端加载指定条目。窗口此时保持隐藏:前端测得内容尺寸后先调用
/// resize_preview(隐藏状态下完成尺寸/位置调整)再 reveal_preview 显示,
/// 避免"先以上次尺寸(可能是放大态大窗)显示再缩回"的闪烁。
pub fn show_preview(app: &AppHandle, id: i64) {
// 弹窗已隐藏(或隐藏中)时拒绝显示预览:同步标志消除 is_visible() 的异步时序竞态,
// 保证"弹窗失焦消失后预览窗不会孤立残留"。
if !POPUP_VISIBLE.load(Ordering::SeqCst) {
return;
}
if app.get_webview_window(PREVIEW_LABEL).is_none() {
return;
}
let Some(popup) = app.get_webview_window(POPUP_LABEL) else {
return;
};
// 弹窗不可见时不显示预览:避免弹窗隐藏后仍在途的悬停请求重新弹出孤立的预览窗
if !popup.is_visible().unwrap_or(false) {
return;
}
// 记录鼠标 Y 锚点:预览窗中线对齐鼠标所悬停的条目位置;resize 时沿用
if let Some((_, y)) = get_cursor_pos() {
if let Ok(mut a) = PREVIEW_ANCHOR_Y.lock() {
*a = Some(y);
}
}
// 通知前端加载条目内容(前端完成后自行 resize + reveal
let _ = app.emit(crate::constants::events::CLIPBOARD_PREVIEW_SHOW, id);
}
/// 显示已就绪的预览窗口(前端完成内容加载与 resize 后调用)。
/// 显示前重查弹窗可见性,消除内容加载期间弹窗已隐藏的竞态。
pub fn reveal_preview(app: &AppHandle) {
// 弹窗已隐藏:在途请求作废
if !POPUP_VISIBLE.load(Ordering::SeqCst) {
return;
}
// 已可见则跳过(放大/还原触发的重复 reveal)
if PREVIEW_VISIBLE.load(Ordering::SeqCst) {
return;
}
let Some(win) = app.get_webview_window(PREVIEW_LABEL) else {
return;
};
// 同步标记预览窗可见(以原生方式显示,Tauri 的 is_visible 可能不同步)
PREVIEW_VISIBLE.store(true, Ordering::SeqCst);
// 不激活显示(SW_SHOWNOACTIVATE),弹窗保持前台
#[cfg(windows)]
if let Ok(hwnd) = win.hwnd() {
crate::win32_util::show_no_activate(hwnd.0 as isize);
}
#[cfg(not(windows))]
let _ = win.show();
// 显示期间弹窗可能已被隐藏(失焦/关闭):立即收回预览。
// 关闭初次检查与真正 show 之间的竞态——失焦处理器先置 POPUP_VISIBLE=false
// 再隐藏,这里 show 之后重查一次,若已被隐藏则收回预览,避免孤立残留。
if !POPUP_VISIBLE.load(Ordering::SeqCst) {
PREVIEW_VISIBLE.store(false, Ordering::SeqCst);
let _ = win.hide();
}
}
/// 按内容自适应调整预览窗大小(逻辑像素),位置沿用当前所在侧并重新对齐弹窗。
/// 前端加载/切换内容(文本测高、图片按宽高比)后调用,窗口贴合内容消除留白。
/// allow_flip:初始落位为 true(优先侧放不下可换侧);放大/还原为 false
/// (保持原侧靠工作区 clamp,避免窗口跳侧使鼠标落在窗外误触发隐藏)。
pub fn resize_preview(app: &AppHandle, width: f64, height: f64, allow_flip: bool) {
// 弹窗不可见时忽略(无对齐基准)
if !POPUP_VISIBLE.load(Ordering::SeqCst) {
return;
}
let Some(win) = app.get_webview_window(PREVIEW_LABEL) else {
return;
};
let Some(popup) = app.get_webview_window(POPUP_LABEL) else {
return;
};
let scale = popup.scale_factor().unwrap_or(1.0);
// 以弹窗所在屏工作区为上限钳制(逻辑像素),并保证不小于最小可视尺寸
let (wa_left, wa_top, wa_right, wa_bottom) = popup
.outer_position()
.ok()
.and_then(|p| get_work_area_at_point(p.x, p.y))
.unwrap_or((0, 0, 1920, 1040));
let max_w = ((wa_right - wa_left).max(0) as f64 / scale).floor().max(PREVIEW_MIN_W);
let max_h = ((wa_bottom - wa_top).max(0) as f64 / scale).floor().max(PREVIEW_MIN_H);
let w = width.clamp(PREVIEW_MIN_W, max_w);
let h = height.clamp(PREVIEW_MIN_H, max_h);
let vw = (w * scale).round() as i32;
let vh = (h * scale).round() as i32;
let side = PREVIEW_SIDE.lock().ok().and_then(|s| *s).unwrap_or(PreviewSide::Right);
// 沿用最近一次的鼠标 Y 锚点:高度变化时中线保持对齐悬停条目,不产生跳变
let anchor_y = PREVIEW_ANCHOR_Y.lock().ok().and_then(|a| *a);
reposition_preview(&popup, &win, vw, vh, side, anchor_y, allow_flip);
let _ = win.set_size(tauri::Size::Physical(tauri::PhysicalSize { width: vw as u32, height: vh as u32 }));
}
/// 交互锁定模式入口:前端预览窗收到 mousedown(放大/缩小、复制、选择文本)时调用。
/// 置位锁定标志并启动看护线程:此后弹窗+预览不因失焦/鼠标离开而关闭,
/// 仅当点击弹窗+预览之外的区域或弹窗重新聚焦时退出锁定。
pub fn preview_interacted(app: &AppHandle) {
POPUP_INTERACTED.store(true, Ordering::SeqCst);
// 交互锁定取代推迟隐藏模式:清除可能残留的 DEFER 标记,
// 避免后续取消钉住时 hide_preview 走 DEFER 分支误关弹窗
POPUP_DEFER_HIDE.store(false, Ordering::SeqCst);
spawn_popup_leave_watch(app.clone());
}
/// 隐藏预览窗口(保留复用),并通知前端清空内容。
pub fn hide_preview(app: &AppHandle) {
// 交互锁定模式:弹窗仍显示时预览保持(生命周期由看护线程管理,点击外部才
// 随弹窗一起关闭),忽略常规隐藏请求(条目离开、键盘切换等)。
if POPUP_INTERACTED.load(Ordering::SeqCst) && POPUP_VISIBLE.load(Ordering::SeqCst) {
return;
}
POPUP_INTERACTED.store(false, Ordering::SeqCst);
// 若此前因预览交互推迟了弹窗隐藏(弹窗已失焦但保持显示),现在用户离开预览窗,
// 需判断去向:光标已回到弹窗内(继续浏览弹窗)→ 仅隐藏预览、保持弹窗显示;
// 光标在弹窗之外(离开整个区域)→ 连同弹窗一起隐藏。
// hide_popup 会先复位该标记,故此处 swap(false) 后调用不会递归重入)
if POPUP_DEFER_HIDE.swap(false, Ordering::SeqCst) {
if !is_cursor_in_popup(app) {
hide_popup(app);
return;
}
// 光标已回到弹窗内。若弹窗仍持有焦点(曾点击弹窗重新激活),仅隐藏预览即可,
// 后续失焦仍走 Focused(false) 正常关闭;若弹窗已失焦(点击预览窗所致),
// 它不会再收到 Focused(false) 事件——光标一旦离开弹窗区域,弹窗将永远
// 无法关闭(无焦点残留窗口)。启动看护线程兜底关闭。
let focused = app
.get_webview_window(POPUP_LABEL)
.map(|w| w.is_focused().unwrap_or(false))
.unwrap_or(false);
if !focused {
spawn_popup_leave_watch(app.clone());
}
}
PREVIEW_VISIBLE.store(false, Ordering::SeqCst);
if let Some(win) = app.get_webview_window(PREVIEW_LABEL) {
let _ = win.hide();
// 预览窗以原生 SW_SHOWNOACTIVATE 方式显示,Tauri 内部可见性状态可能与其
// 不同步;补一次原生 SW_HIDE,确保任何路径下都被可靠隐藏。
#[cfg(windows)]
if let Ok(hwnd) = win.hwnd() {
crate::win32_util::hide_window(hwnd.0 as isize);
}
}
let _ = app.emit(crate::constants::events::CLIPBOARD_PREVIEW_HIDE, ());
}
/// 弹窗失焦但保持显示时的看护线程(防重入单例)。
///
/// 两种工作模式(每轮动态读取 POPUP_INTERACTED,可中途切换):
/// - 常规模式(未点击过预览窗):光标连续 ~300ms 既不在弹窗也不在预览窗内
/// (连续计数防"穿越弹窗-预览间隙"时的瞬时离开误判)→ 关闭弹窗;
/// - 交互锁定模式(点击过预览窗的放大/缩小/复制等):弹窗已失焦不会再收到
/// Focused(false),检测"鼠标按下沿且按下位置在弹窗+预览之外"→ 点击外部,关闭弹窗。
///
/// 公共退出条件:弹窗已隐藏/销毁(其他路径关闭);弹窗重新获得焦点(用户点击
/// 弹窗,恢复正常失焦关闭路径,并复位交互锁定);超时兜底防线程泄漏
/// (常规 30s;交互锁定为事件驱动,放宽至 10min)。
fn spawn_popup_leave_watch(app: AppHandle) {
// 防重入:已有看护在运行则跳过
if POPUP_LEAVE_WATCH.swap(true, Ordering::SeqCst) {
return;
}
std::thread::spawn(move || {
let start = Instant::now();
let mut outside = 0u32;
let mut was_down = false;
loop {
std::thread::sleep(Duration::from_millis(30));
// 弹窗已隐藏或被销毁:看护结束
if !POPUP_VISIBLE.load(Ordering::SeqCst)
|| app.get_webview_window(POPUP_LABEL).is_none()
{
break;
}
// 弹窗重新获得焦点(用户点击弹窗):恢复正常失焦关闭路径,复位交互锁定
if app
.get_webview_window(POPUP_LABEL)
.map(|w| w.is_focused().unwrap_or(false))
.unwrap_or(false)
{
POPUP_INTERACTED.store(false, Ordering::SeqCst);
break;
}
let interacted = POPUP_INTERACTED.load(Ordering::SeqCst);
// 超长兜底:避免异常路径下看护线程无限轮询
let timeout = if interacted {
Duration::from_secs(600)
} else {
Duration::from_secs(30)
};
if start.elapsed() > timeout {
hide_popup(&app);
break;
}
let cursor_in = is_cursor_in_popup(&app) || is_cursor_in_preview(&app);
if interacted {
// 交互锁定:左键按下沿发生在弹窗+预览之外 → 点击外部,关闭弹窗+预览。
// 按下沿判定(上一轮未按下、本轮按下)配合 30ms 轮询,可捕获常规点击;
// 在弹窗/预览内按下后拖出再松开不算外部点击(以按下位置为准)。
let down = crate::win32_util::is_left_button_down();
if down && !was_down && !cursor_in {
hide_popup(&app);
break;
}
was_down = down;
} else if cursor_in {
outside = 0;
} else {
outside += 1;
// 连续 ~300ms 不在弹窗/预览区域(排除穿越间隙的瞬时状态)才关闭
if outside >= 10 {
hide_popup(&app);
break;
}
}
}
POPUP_LEAVE_WATCH.store(false, Ordering::SeqCst);
});
}
/// 鼠标当前是否位于弹窗矩形内(弹窗可见且光标在其窗口范围内)。
/// 用于 hide_preview 判断"用户是回到弹窗继续浏览,还是离开整个区域"。
fn is_cursor_in_popup(app: &AppHandle) -> bool {
if !POPUP_VISIBLE.load(Ordering::SeqCst) {
return false;
}
let Some(win) = app.get_webview_window(POPUP_LABEL) else {
return false;
};
let Ok(pos) = win.outer_position() else { return false };
let Ok(size) = win.outer_size() else { return false };
let Some((cx, cy)) = crate::win32_util::get_cursor_pos() else {
return false;
};
let x = pos.x;
let y = pos.y;
let w = size.width as i32;
let h = size.height as i32;
cx >= x && cx <= x + w && cy >= y && cy <= y + h
}
/// 鼠标当前是否位于预览窗矩形内(预览窗可见且光标在其窗口范围内)。
/// 用于弹窗失焦时判断是否因预览交互所致,决定是否推迟隐藏弹窗。
fn is_cursor_in_preview(app: &AppHandle) -> bool {
if !PREVIEW_VISIBLE.load(Ordering::SeqCst) {
return false;
}
let Some(win) = app.get_webview_window(PREVIEW_LABEL) else {
return false;
};
let Ok(pos) = win.outer_position() else { return false };
let Ok(size) = win.outer_size() else { return false };
let Some((cx, cy)) = crate::win32_util::get_cursor_pos() else {
return false;
};
let x = pos.x;
let y = pos.y;
let w = size.width as i32;
let h = size.height as i32;
cx >= x && cx <= x + w && cy >= y && cy <= y + h
}
// ===== 屏幕/光标/DPI 工具已迁移至 crate::win32_util(跨模块共享) ===== // ===== 屏幕/光标/DPI 工具已迁移至 crate::win32_util(跨模块共享) =====
use crate::win32_util::{get_cursor_pos, get_work_area_at_point, get_dpi_for_point}; use crate::win32_util::{get_cursor_pos, get_work_area_at_point, get_dpi_for_point};
+26
View File
@@ -295,3 +295,29 @@ pub fn dib_to_png(dib: &[u8]) -> Option<Vec<u8>> {
.ok()?; .ok()?;
Some(buf) Some(buf)
} }
/// 生成缩略图 PNG(最长边不超过 max_dim 像素)。
/// 小图直接复用 `dib_to_png` 结果;大图降采样后重新编码,供弹窗悬停预览使用。
pub fn dib_to_thumbnail(dib: &[u8], max_dim: u32) -> Option<Vec<u8>> {
use image::codecs::png::PngEncoder;
use image::GenericImageView;
use image::ImageEncoder;
let png = dib_to_png(dib)?;
let img = image::load_from_memory(&png).ok()?;
let (w, h) = img.dimensions();
if w.max(h) <= max_dim {
return Some(png); // 小图直接使用,避免重复编码
}
// 让 image 库在 max_dim×max_dim 边界内自动等比缩放,避免手算 nw/nh 与库内部
// resize_dimensions 的取整不一致(如手算 256x10 而库实际输出 250x10),
// 否则 write_image 的缓冲区长度断言会失败。
let resized = img.resize(max_dim, max_dim, image::imageops::FilterType::Triangle);
let (rw, rh) = resized.dimensions();
let rgba = resized.to_rgba8();
let mut buf = Vec::new();
let enc = PngEncoder::new(&mut buf);
enc.write_image(rgba.as_raw(), rw, rh, image::ExtendedColorType::Rgba8)
.ok()?;
Some(buf)
}
+39 -2
View File
@@ -2,6 +2,7 @@
//! //!
//! 表结构见 `init_db`。所有方法线程安全(内部 Mutex 包裹 Connection)。 //! 表结构见 `init_db`。所有方法线程安全(内部 Mutex 包裹 Connection)。
use base64::Engine as _;
use rusqlite::{params, Connection, OptionalExtension}; use rusqlite::{params, Connection, OptionalExtension};
use specta::Type; use specta::Type;
use std::fs; use std::fs;
@@ -38,6 +39,8 @@ pub struct NewItem {
pub kind: String, pub kind: String,
pub content: Option<String>, pub content: Option<String>,
pub blob: Option<Vec<u8>>, pub blob: Option<Vec<u8>>,
/// 图片缩略图 PNG(仅 image 类型,供弹窗悬停预览)
pub thumb: Option<Vec<u8>>,
pub preview: String, pub preview: String,
pub size: i64, pub size: i64,
pub hash: String, pub hash: String,
@@ -88,6 +91,19 @@ impl Storage {
CREATE INDEX IF NOT EXISTS idx_hash ON clipboard_history(hash); CREATE INDEX IF NOT EXISTS idx_hash ON clipboard_history(hash);
CREATE INDEX IF NOT EXISTS idx_kind ON clipboard_history(kind);", CREATE INDEX IF NOT EXISTS idx_kind ON clipboard_history(kind);",
); );
// 迁移:老库无 thumb 列(图片缩略图,供弹窗悬停预览),补列
let has_thumb = conn
.prepare("PRAGMA table_info(clipboard_history)")
.ok()
.map(|mut stmt| {
stmt.query_map([], |r| r.get::<_, String>(1))
.map(|rows| rows.filter_map(|c| c.ok()).any(|c| c == "thumb"))
.unwrap_or(false)
})
.unwrap_or(false);
if !has_thumb {
let _ = conn.execute("ALTER TABLE clipboard_history ADD COLUMN thumb BLOB", []);
}
} }
/// 插入新条目;若 dedup 为 true 且 hash 已存在则仅更新 created_at,返回条目 id。 /// 插入新条目;若 dedup 为 true 且 hash 已存在则仅更新 created_at,返回条目 id。
@@ -120,12 +136,13 @@ impl Storage {
let now = now_ms(); let now = now_ms();
let res = conn.execute( let res = conn.execute(
"INSERT INTO clipboard_history "INSERT INTO clipboard_history
(kind, content, blob, preview, size, hash, pinned, pinned_order, created_at) (kind, content, blob, thumb, preview, size, hash, pinned, pinned_order, created_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, 0, NULL, ?7)", VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, 0, NULL, ?8)",
params![ params![
item.kind, item.kind,
item.content, item.content,
item.blob.as_deref(), item.blob.as_deref(),
item.thumb.as_deref(),
item.preview, item.preview,
item.size, item.size,
item.hash, item.hash,
@@ -249,6 +266,26 @@ impl Storage {
}) })
} }
/// 获取图片缩略图 PNG base64(弹窗悬停预览用)。
/// 老数据无缩略图时回退为 `thumb_from_dib` 现场生成。
pub fn get_thumb(&self, id: i64, thumb_from_dib: impl Fn(&[u8]) -> Option<Vec<u8>>) -> Option<String> {
let (thumb, blob) = {
let conn = self.conn.lock().ok()?;
conn.query_row(
"SELECT thumb, blob FROM clipboard_history WHERE id = ?1 AND kind = 'image'",
params![id],
|r| Ok((r.get::<_, Option<Vec<u8>>>(0)?, r.get::<_, Option<Vec<u8>>>(1)?)),
)
.ok()?
};
// conn 已在此处释放
let png = match thumb {
Some(t) if !t.is_empty() => Some(t),
_ => blob.as_deref().and_then(|b| thumb_from_dib(b)),
};
png.map(|p| base64::engine::general_purpose::STANDARD.encode(&p))
}
/// 获取原始字段供 copy_back 写回(避免 base64 转换开销) /// 获取原始字段供 copy_back 写回(避免 base64 转换开销)
pub fn get_raw_for_copy(&self, id: i64) -> Option<(String, Option<String>, Option<Vec<u8>>)> { pub fn get_raw_for_copy(&self, id: i64) -> Option<(String, Option<String>, Option<Vec<u8>>)> {
let conn = self.conn.lock().ok()?; let conn = self.conn.lock().ok()?;
+9 -1
View File
@@ -26,10 +26,14 @@ pub mod events {
pub const CLIPBOARD_CHANGED: &str = "clipboard-changed"; pub const CLIPBOARD_CHANGED: &str = "clipboard-changed";
pub const CLIPBOARD_POPUP_SHOW: &str = "clipboard-popup-show"; pub const CLIPBOARD_POPUP_SHOW: &str = "clipboard-popup-show";
pub const CLIPBOARD_POPUP_HIDE: &str = "clipboard-popup-hide"; pub const CLIPBOARD_POPUP_HIDE: &str = "clipboard-popup-hide";
pub const CLIPBOARD_PREVIEW_SHOW: &str = "clipboard-preview-show";
pub const CLIPBOARD_PREVIEW_HIDE: &str = "clipboard-preview-hide";
// 快速面板 // 快速面板
pub const QUICKPANEL_SHOW: &str = "quickpanel-show"; pub const QUICKPANEL_SHOW: &str = "quickpanel-show";
pub const QUICKPANEL_HIDE: &str = "quickpanel-hide"; pub const QUICKPANEL_HIDE: &str = "quickpanel-hide";
pub const QUICKPANEL_EXTRACT_PROGRESS: &str = "quickpanel-extract-progress"; pub const QUICKPANEL_EXTRACT_PROGRESS: &str = "quickpanel-extract-progress";
/// 文件索引构建完成(闲时自动建立/重建、手动构建),负载为条目数
pub const QUICKPANEL_INDEX_UPDATED: &str = "quickpanel-index-updated";
// 监控 // 监控
pub const MONITOR_DATA: &str = "monitor-data"; pub const MONITOR_DATA: &str = "monitor-data";
pub const MONITOR_NETWORK: &str = "monitor-network"; pub const MONITOR_NETWORK: &str = "monitor-network";
@@ -47,11 +51,15 @@ pub mod events {
pub const SCREENSHOT_PIN_SHORTCUT: &str = "screenshot-pin-shortcut"; pub const SCREENSHOT_PIN_SHORTCUT: &str = "screenshot-pin-shortcut";
// 内核安装进度 // 内核安装进度
pub const KERNEL_INSTALL_PROGRESS: &str = "kernel-install-progress"; pub const KERNEL_INSTALL_PROGRESS: &str = "kernel-install-progress";
// 后端自动切换节点完成(前端据以刷新节点列表并提示)
pub const PROXY_AUTO_SWITCH: &str = "proxy-auto-switch";
// 应用更新进度 // 应用更新进度
pub const UPDATE_PROGRESS: &str = "update-progress"; pub const UPDATE_PROGRESS: &str = "update-progress";
// 进程与下载 // 进程与下载
pub const PROCESS_STATUS_CHANGED: &str = "process-status-changed"; pub const PROCESS_STATUS_CHANGED: &str = "process-status-changed";
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";
/// 浏览器扩展通过 HTTP API 新增下载(负载 { id },前端据以为该任务创建专属下载窗口)
pub const DOWNLOAD_EXTENSION_ADDED: &str = "download-extension-added"; pub const DOWNLOAD_EXTENSION_ADDED: &str = "download-extension-added";
} }
+53 -2
View File
@@ -1,10 +1,11 @@
use std::collections::HashMap; use std::collections::HashMap;
use tauri::{AppHandle, State}; use tauri::{AppHandle, Manager, State};
use tauri_plugin_opener::OpenerExt; use tauri_plugin_opener::OpenerExt;
use super::engine::{CheckUrlResult, DownloadEngine}; use super::engine::{CheckUrlResult, DownloadEngine};
use super::task::{DownloadTask, DownloaderSettings}; use super::task::{DownloadTask, DownloaderSettings};
use super::torrent::TorrentInfo;
/// 获取所有任务 /// 获取所有任务
#[tauri::command] #[tauri::command]
@@ -13,6 +14,20 @@ pub fn downloader_get_tasks(engine: State<'_, DownloadEngine>) -> Vec<DownloadTa
engine.get_tasks() engine.get_tasks()
} }
/// 解析磁力链 / .torrent 文件,返回种子信息(名称 / infohash / 文件列表),供前端做文件勾选
#[tauri::command]
#[specta::specta]
pub async fn downloader_inspect(engine: State<'_, DownloadEngine>, input: String) -> Result<TorrentInfo, String> {
engine.inspect(&input).await
}
/// 磁力任务:元数据解析成功后,用户勾选文件并确认开始下载
#[tauri::command]
#[specta::specta]
pub async fn downloader_select_bt_files(engine: State<'_, DownloadEngine>, id: String, only_files: Vec<u32>) -> Result<(), String> {
engine.select_bt_files(&id, only_files).await
}
/// 检查 URL 重复性并探测文件信息(添加下载前调用) /// 检查 URL 重复性并探测文件信息(添加下载前调用)
#[tauri::command] #[tauri::command]
#[specta::specta] #[specta::specta]
@@ -57,8 +72,9 @@ pub async fn downloader_add_task(
dir: Option<String>, dir: Option<String>,
headers: Option<HashMap<String, String>>, headers: Option<HashMap<String, String>>,
auto_rename: Option<bool>, auto_rename: Option<bool>,
only_files: Option<Vec<u32>>,
) -> Result<String, String> { ) -> Result<String, String> {
engine.add_task(url, filename, dir, headers.unwrap_or_default(), auto_rename.unwrap_or(false)).await engine.add_task(url, filename, dir, headers.unwrap_or_default(), auto_rename.unwrap_or(false), only_files).await
} }
/// 暂停任务 /// 暂停任务
@@ -75,6 +91,20 @@ pub fn downloader_resume_task(engine: State<'_, DownloadEngine>, id: String) ->
engine.resume_task(&id) engine.resume_task(&id)
} }
/// 取消任务(置为已取消,清空进度并删除下载文件,但保留记录)
#[tauri::command]
#[specta::specta]
pub fn downloader_cancel_task(engine: State<'_, DownloadEngine>, id: String) -> Result<(), String> {
engine.cancel_task(&id)
}
/// 重新下载已取消/出错的任务
#[tauri::command]
#[specta::specta]
pub async fn downloader_redownload(engine: State<'_, DownloadEngine>, id: String) -> Result<(), String> {
engine.redownload(&id).await
}
/// 移除任务 /// 移除任务
#[tauri::command] #[tauri::command]
#[specta::specta] #[specta::specta]
@@ -141,3 +171,24 @@ pub fn downloader_open_url(app: AppHandle, url: String) -> Result<(), String> {
.open_url(url, None::<&str>) .open_url(url, None::<&str>)
.map_err(|e| format!("打开链接失败: {}", e)) .map_err(|e| format!("打开链接失败: {}", e))
} }
/// 将指定 label 的下载窗口显示并强制置为前台。
/// Tauri 的 set_focus 在 Windows 上受前台锁定限制(尤其下载窗口由后台进程创建、
/// 或创建到非主显示器时更明显),改用原生 SetForegroundWindow + BringWindowToTop
/// (模拟 Alt 键重置前台锁定),保证开始/完成下载时窗口能正确定位到前台。
#[tauri::command]
#[specta::specta]
pub fn downloader_focus_window(app: AppHandle, label: String) -> Result<(), String> {
let Some(window) = app.get_webview_window(&label) else {
return Ok(()); // 窗口已关闭则忽略
};
window.show().map_err(|e| e.to_string())?;
window.unminimize().map_err(|e| e.to_string())?;
match window.hwnd() {
Ok(hwnd) => crate::win32_util::force_foreground(hwnd.0 as isize),
Err(_) => {
window.set_focus().ok();
}
}
Ok(())
}
File diff suppressed because it is too large Load Diff
+93 -10
View File
@@ -21,27 +21,68 @@ const READ_STALL_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(3
/// HTTP/HTTPS 下载器 /// HTTP/HTTPS 下载器
#[derive(Clone)] #[derive(Clone)]
pub struct HttpDownloader { pub struct HttpDownloader {
client: Client, /// 默认客户端:尊重系统代理(reqwest 默认行为,mihomo 开启系统代理时经其转发)
system_client: Client,
/// 直连客户端:强制禁用系统代理(no_proxy)
direct_client: Client,
} }
impl HttpDownloader { impl HttpDownloader {
pub fn new() -> Self { pub fn new() -> Self {
let client = Client::builder() let system_client = Client::builder()
.build() .build()
.unwrap_or_else(|_| Client::new()); .unwrap_or_else(|_| Client::new());
Self { client } let direct_client = Client::builder()
// 强制直连:即使系统代理已开启,下载也不经过代理
.no_proxy()
.build()
.unwrap_or_else(|_| Client::new());
Self {
system_client,
direct_client,
}
}
/// 根据 use_proxy 选择客户端
fn client(&self, use_proxy: bool) -> &Client {
if use_proxy {
&self.system_client
} else {
&self.direct_client
}
} }
/// 探测下载资源信息(大小、是否支持 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,
) -> Result<ProbeResult, String> {
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> { ) -> Result<ProbeResult, String> {
// 先尝试 Range 请求(能同时判断 Accept-Ranges 和获取大小) // 先尝试 Range 请求(能同时判断 Accept-Ranges 和获取大小)
let mut req = self let mut req = client
.client
.get(url) .get(url)
.header("Range", "bytes=0-0") .header("Range", "bytes=0-0")
.header("User-Agent", "Thing-Download-Engine/1.0"); .header("User-Agent", "Thing-Download-Engine/1.0");
@@ -97,7 +138,7 @@ impl HttpDownloader {
} }
Err(_) => { Err(_) => {
// GET 失败,尝试 HEAD 作为回退 // GET 失败,尝试 HEAD 作为回退
let mut head_req = self.client.head(url); let mut head_req = client.head(url);
for (k, v) in headers { for (k, v) in headers {
head_req = head_req.header(k, v); head_req = head_req.header(k, v);
} }
@@ -132,6 +173,7 @@ impl HttpDownloader {
/// - `cancel`: 取消标志 /// - `cancel`: 取消标志
/// - `progress`: 每个分段的已下载字节(AtomicU64,与 segments 一一对应) /// - `progress`: 每个分段的已下载字节(AtomicU64,与 segments 一一对应)
/// - `limiter`: 全局限速器 /// - `limiter`: 全局限速器
/// - `use_proxy`: 是否使用系统代理(false=强制直连)
pub async fn download( pub async fn download(
&self, &self,
url: &str, url: &str,
@@ -141,6 +183,37 @@ impl HttpDownloader {
cancel: Arc<AtomicBool>, cancel: Arc<AtomicBool>,
progress: &[Arc<AtomicU64>], progress: &[Arc<AtomicU64>],
limiter: Arc<RateLimiter>, limiter: Arc<RateLimiter>,
use_proxy: bool,
) -> Result<(), String> {
// 代理降级:仅当 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> { ) -> Result<(), String> {
let total_size = segments.iter().map(|s| s.len()).sum(); let total_size = segments.iter().map(|s| s.len()).sum();
@@ -167,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) self.download_segment(url, headers, seg, file_path, cancel.clone(), prog.clone(), limiter.clone(), client)
.await?; .await?;
return Ok(()); return Ok(());
} }
@@ -187,7 +260,7 @@ impl HttpDownloader {
.unwrap_or_default(); .unwrap_or_default();
let limiter = limiter.clone(); let limiter = limiter.clone();
let file_path = file_path.to_path_buf(); let file_path = file_path.to_path_buf();
let client = self.client.clone(); let client = client.clone();
join_set.spawn(async move { join_set.spawn(async move {
download_segment_with_client( download_segment_with_client(
@@ -241,9 +314,10 @@ impl HttpDownloader {
cancel: Arc<AtomicBool>, cancel: Arc<AtomicBool>,
progress: Arc<AtomicU64>, progress: Arc<AtomicU64>,
limiter: Arc<RateLimiter>, limiter: Arc<RateLimiter>,
client: &Client,
) -> Result<(), String> { ) -> Result<(), String> {
download_segment_with_client( download_segment_with_client(
&self.client, client,
url, url,
headers, headers,
seg, seg,
@@ -366,6 +440,15 @@ async fn download_segment_with_client(
limiter.consume(buf.len() as u64).await; limiter.consume(buf.len() as u64).await;
buf.clear(); buf.clear();
} }
// 校验:已知大小的分段若流提前结束(收到的字节数不足分段长度),
// 说明服务器提前断开或返回不完整内容,不能标记为完成,否则文件会被截断
if !unknown_size && local_completed < seg.len() {
return Err(format!(
"文件不完整:已接收 {} / {} 字节,服务器提前结束连接",
local_completed,
seg.len()
));
}
break; break;
} }
Err(_) => { Err(_) => {
+3 -2
View File
@@ -5,10 +5,11 @@ pub mod rate_limit;
pub mod server; pub mod server;
pub mod storage; pub mod storage;
pub mod task; pub mod task;
pub mod torrent;
pub use commands::{ pub use commands::{
downloader_add_task, downloader_check_url, downloader_get_extension_info, downloader_get_settings, downloader_get_tasks, downloader_add_task, downloader_cancel_task, downloader_check_url, downloader_focus_window, downloader_get_extension_info, downloader_get_settings, downloader_get_tasks, downloader_inspect, downloader_select_bt_files,
downloader_open_dir, downloader_open_url, downloader_pause_task, downloader_remove_task, downloader_open_dir, downloader_open_url, downloader_pause_task, downloader_redownload, downloader_remove_task,
downloader_resume_task, downloader_save_settings, downloader_status, downloader_resume_task, downloader_save_settings, downloader_status,
}; };
pub use engine::DownloadEngine; pub use engine::DownloadEngine;
+22 -9
View File
@@ -9,10 +9,10 @@ use axum::{
Router, Router,
}; };
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use tauri::{AppHandle, Emitter, Manager}; use tauri::{AppHandle, Emitter};
use super::engine::DownloadEngine; use super::engine::DownloadEngine;
use super::task::DownloadTask; use super::task::{DownloadTask, TaskStatus};
/// 扩展 HTTP API 服务器 /// 扩展 HTTP API 服务器
pub struct ExtensionServer; pub struct ExtensionServer;
@@ -114,13 +114,22 @@ async fn create_download(
return Err((StatusCode::UNAUTHORIZED, Json(ErrorResponse { error: "未授权".into() }))); return Err((StatusCode::UNAUTHORIZED, Json(ErrorResponse { error: "未授权".into() })));
} }
match state.engine.add_task(req.url, req.filename, req.dir, req.headers, true).await { // 去重:同 URL 已有非终态任务(活跃/排队/暂停)时直接返回既有任务,
// 避免浏览器重复转发同一下载造成重复下载
if let Some(existing) = state.engine.get_tasks().into_iter().find(|t| {
matches!(t.status, TaskStatus::Active | TaskStatus::Queued | TaskStatus::Paused) && t.url == req.url
}) {
return Ok(Json(CreateDownloadResponse { id: existing.id }));
}
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 }))),
@@ -146,7 +155,11 @@ async fn remove_download(
return Err((StatusCode::UNAUTHORIZED, Json(ErrorResponse { error: "未授权".into() }))); return Err((StatusCode::UNAUTHORIZED, Json(ErrorResponse { error: "未授权".into() })));
} }
match state.engine.remove_task(&id, false) { match state.engine.remove_task(&id, false) {
Ok(()) => Ok(StatusCode::NO_CONTENT), Ok(()) => {
// 通知前端刷新任务列表(扩展删除时前端无从感知,否则列表残留已删除任务)
let _ = state.app_handle.emit(crate::constants::events::DOWNLOAD_REMOVED, serde_json::json!({ "id": id }));
Ok(StatusCode::NO_CONTENT)
}
Err(e) => Err((StatusCode::NOT_FOUND, Json(ErrorResponse { error: e }))), Err(e) => Err((StatusCode::NOT_FOUND, Json(ErrorResponse { error: e }))),
} }
} }
+68 -2
View File
@@ -2,6 +2,17 @@ use serde::{Deserialize, Serialize};
use specta::Type; use specta::Type;
use std::collections::HashMap; use std::collections::HashMap;
/// 任务下载协议类型
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Type, Default)]
#[serde(rename_all = "lowercase")]
pub enum TaskProtocol {
/// HTTP/HTTPS 直链
#[default]
Http,
/// BitTorrent(磁力链 / .torrent 文件)
BitTorrent,
}
/// 任务状态 /// 任务状态
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Type)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Type)]
#[serde(rename_all = "lowercase")] #[serde(rename_all = "lowercase")]
@@ -16,6 +27,8 @@ pub enum TaskStatus {
Complete, Complete,
/// 错误 /// 错误
Error, Error,
/// 已取消(用户取消:进度与文件已清除,仅保留记录,只能再次下载)
Cancelled,
} }
/// 下载分段(多线程 Range 下载 / 断点续传用) /// 下载分段(多线程 Range 下载 / 断点续传用)
@@ -37,24 +50,57 @@ impl Segment {
pub fn len(&self) -> u64 { pub fn len(&self) -> u64 {
self.end.saturating_sub(self.start) + 1 self.end.saturating_sub(self.start) + 1
} }
/// 是否为未知大小哨兵段(start=0, end=0,表示不支持 Range 或未探测到大小)
pub fn is_unknown_size(&self) -> bool {
self.start == 0 && self.end == 0
}
/// 是否已完成 /// 是否已完成
pub fn is_done(&self) -> bool { pub fn is_done(&self) -> bool {
// 未知大小段无法用长度判断是否完成,由流结束(Ok(None))判定;
// 若按 len()=1 判断,暂停/恢复后 completed>=1 会误判为已完成,导致文件被截断
if self.is_unknown_size() {
return false;
}
self.completed >= self.len() self.completed >= self.len()
} }
} }
/// 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=未知
@@ -129,6 +175,21 @@ pub struct DownloaderSettings {
/// 添加下载前检查重复(URL 或文件名重复时询问) /// 添加下载前检查重复(URL 或文件名重复时询问)
#[serde(default = "default_true")] #[serde(default = "default_true")]
pub check_duplicate: bool, pub check_duplicate: bool,
/// 下载是否使用代理:true=尊重系统代理(mihomo 开启系统代理时经其转发),false=强制直连
#[serde(default = "default_true")]
pub use_proxy: bool,
/// BitTorrent 上传限速 KB/s0=不限)
#[serde(default)]
pub bt_upload_limit_kb: u64,
/// BitTorrent 下载完成后是否继续做种上传(false=下载完即停止上传)
#[serde(default)]
pub bt_seed_after_download: bool,
/// BitTorrent 监听端口(0=自动选择)
#[serde(default)]
pub bt_listen_port: u16,
/// BitTorrent 使用代理下载:开启后自动使用代理模块(mihomo)的 SOCKS5 端口;代理不可用时降级直连
#[serde(default)]
pub bt_use_proxy: bool,
} }
fn default_max_concurrent() -> u32 { fn default_max_concurrent() -> u32 {
@@ -167,6 +228,11 @@ impl Default for DownloaderSettings {
extension_secret: String::new(), extension_secret: String::new(),
delete_files_on_remove: false, delete_files_on_remove: false,
check_duplicate: true, check_duplicate: true,
use_proxy: true,
bt_upload_limit_kb: 0,
bt_seed_after_download: false,
bt_listen_port: 0,
bt_use_proxy: false,
} }
} }
} }
+469
View File
@@ -0,0 +1,469 @@
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex, RwLock};
// 会话字段专用 tokio 异步互斥(与 std 互斥区分开):
// 创建 Session 是 async 操作,必须持锁跨 await,否则"双重检查"在 await 期间失效,
// 并发首次添加 BT 任务可能各建一个全局会话,产生孤儿会话与跨会话无效句柄。
use tokio::sync::Mutex as AsyncMutex;
use librqbit::api::TorrentIdOrHash;
use librqbit::{AddTorrent, AddTorrentOptions, AddTorrentResponse, ManagedTorrent, Session};
use super::task::BtFileInfo;
/// 磁力元数据解析超时(秒):依赖 DHT/tracker 拉取,过短易误报,过长体验差
pub(crate) const INSPECT_TIMEOUT_SECS: u64 = 60;
/// 每日 tracker 同步源(XIU2/TrackersListCollection,官方新域名 cf.trackerslist.com)。
/// 注意:GitHub 仓库 raw 路径(raw.githubusercontent.com/...trackers_best.txt)已随项目
/// 迁移失效(404),不要再用 GitHub 代理镜像。以下为官方 Cloudflare 分发地址,
/// 国内直连通常可达,按顺序尝试、首个成功即用。
const TRACKER_SYNC_SOURCES: &[&str] = &[
"https://cf.trackerslist.com/best.txt",
"https://trackerslist.com/best.txt",
"https://cf.trackerslist.com/all.txt",
];
/// 动态 tracker 持久化文件与同步元数据
const TRACKERS_FILE: &str = "bt_trackers.txt";
const TRACKERS_META_FILE: &str = "bt_trackers_meta.json";
/// 常用公共 tracker:磁力链接本身可能只带很少 tracker,追加这些可提升解析成功率。
/// 混合 UDP/HTTP(S)/WebSocket,覆盖 UDP 被屏蔽但 HTTP 可用的网络环境。
pub const PUBLIC_TRACKERS: &[&str] = &[
// UDP
"udp://tracker.opentrackr.org:1337/announce",
"udp://open.tracker.cl:1337/announce",
"udp://tracker.openbittorrent.com:6969/announce",
"udp://tracker.torrent.eu.org:451/announce",
"udp://open.stealth.si:80/announce",
"udp://exodus.desync.com:6969/announce",
"udp://tracker.tiny-vps.com:6969/announce",
"udp://open.demonii.com:1337/announce",
"udp://tracker.moeking.me:6969/announce",
"udp://ipv4.tracker.harry.lu:80/announce",
"udp://explodie.org:6969/announce",
"udp://tracker.birkenfeld.ru:7496/announce",
"udp://tracker.pomf.se:80/announce",
"udp://tracker.tamersunion.org:6969/announce",
"udp://retracker.lanta-net.ru:2710/announce",
// HTTP(S)
"http://tracker.opentrackr.org:1337/announce",
"http://tracker.openbittorrent.com:80/announce",
"http://tracker1.itzmx.com:8080/announce",
"http://tracker4.itzmx.com:2710/announce",
"https://tracker.gbitt.info:443/announce",
"https://tracker.nanoha.org:443/announce",
"http://tracker.bt4g.com:2095/announce",
"http://tracker.gbitt.info:80/announce",
];
/// 种子信息(inspect 解析结果,供命令返回给前端做文件勾选)
#[derive(Debug, Clone, serde::Serialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct TorrentInfo {
/// 种子名称
pub name: String,
/// infohashhex 小写字符串)
pub info_hash: String,
/// 种子内全部文件总大小(字节)
pub total_size: u64,
/// 种子内文件列表
pub files: Vec<BtFileInfo>,
}
/// BitTorrent 下载器:封装 librqbit 全局会话(多任务共享 DHT / 监听端口 / tracker 缓存)。
/// 会话懒创建:只有真正添加 BT 任务时才初始化,避免引擎启动即拉起 BT 内核。
#[derive(Clone)]
pub struct TorrentDownloader {
/// 全局会话(懒创建;tokio 异步互斥保证并发首次添加只创建一个会话)
session: Arc<AsyncMutex<Option<Arc<Session>>>>,
/// 会话默认输出目录(每个任务用 AddTorrentOptions.output_folder 覆盖)
base_dir: PathBuf,
/// 上传限速 bytes/s0=不限),per-torrent 应用
upload_limit_bps: Arc<std::sync::atomic::AtomicU64>,
/// 监听端口(0=自动)
listen_port: Arc<std::sync::atomic::AtomicU16>,
/// SOCKS5 代理地址(None=直连;来自代理模块,不可用时降级直连)
proxy_addr: Arc<Mutex<Option<String>>>,
/// 动态公共 tracker(每日从外部列表同步,叠加到内置列表)
dynamic_trackers: Arc<RwLock<Vec<String>>>,
}
impl TorrentDownloader {
pub fn new(base_dir: PathBuf) -> Self {
Self {
session: Arc::new(AsyncMutex::new(None)),
base_dir,
upload_limit_bps: Arc::new(std::sync::atomic::AtomicU64::new(0)),
listen_port: Arc::new(std::sync::atomic::AtomicU16::new(0)),
proxy_addr: Arc::new(Mutex::new(None)),
dynamic_trackers: Arc::new(RwLock::new(Vec::new())),
}
}
/// 更新 BT 专属设置(上传限速 KB/s、监听端口、SOCKS5 代理地址)— 会话创建时生效
pub fn set_settings(&self, upload_limit_kb: u64, listen_port: u16, proxy_addr: Option<String>) {
self.upload_limit_bps
.store(upload_limit_kb.saturating_mul(1024), std::sync::atomic::Ordering::SeqCst);
self.listen_port
.store(listen_port, std::sync::atomic::Ordering::SeqCst);
*self.proxy_addr.lock().unwrap_or_else(|e| e.into_inner()) = proxy_addr;
}
/// 设置动态 tracker(合并去重)
pub fn add_dynamic_trackers(&self, list: Vec<String>) {
if list.is_empty() {
return;
}
let mut g = self.dynamic_trackers.write().unwrap_or_else(|e| e.into_inner());
for t in list {
let t = t.trim().to_string();
if !t.is_empty() && !g.contains(&t) {
g.push(t);
}
}
}
/// 当前动态 tracker 数量
pub fn dynamic_count(&self) -> usize {
self.dynamic_trackers.read().unwrap_or_else(|e| e.into_inner()).len()
}
/// 合并内置 + 动态 tracker(去重),供 inspect / add_async 共用
fn build_trackers(&self) -> Vec<String> {
let dyn_trackers = self.dynamic_trackers.read().unwrap_or_else(|e| e.into_inner()).clone();
let mut trackers: Vec<String> = PUBLIC_TRACKERS.iter().map(|s| s.to_string()).collect();
for t in dyn_trackers {
if !trackers.contains(&t) {
trackers.push(t);
}
}
trackers
}
/// 每日同步动态 tracker:读取本地缓存 → 判断今天是否已同步 → 未同步则拉取更新。
/// 失败静默降级:保留上次成功的列表。
pub async fn sync_dynamic_trackers(&self, data_dir: &Path) {
fn parse_list(text: &str) -> Vec<String> {
text.lines()
.map(|l| l.trim())
.filter(|l| l.starts_with("http://") || l.starts_with("https://") || l.starts_with("udp://") || l.starts_with("ws://") || l.starts_with("wss://"))
.map(|l| l.to_string())
.collect()
}
let trackers_path = data_dir.join(TRACKERS_FILE);
let meta_path = data_dir.join(TRACKERS_META_FILE);
let today = chrono::Local::now().format("%Y-%m-%d").to_string();
// 1. 先加载上次成功保存的列表到内存(覆盖"重启后内存被清空")
if let Ok(s) = std::fs::read_to_string(&trackers_path) {
self.add_dynamic_trackers(parse_list(&s));
}
// 2. 今天已同步过则不再下载
if let Ok(meta) = std::fs::read_to_string(&meta_path) {
if let Ok(v) = serde_json::from_str::<serde_json::Value>(&meta) {
if v.get("date").and_then(|d| d.as_str()) == Some(&today) {
return;
}
}
}
// 3. 依次尝试各镜像源(记录每个源的失败原因,便于判断是 DNS 还是连接超时)
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(10))
.build()
.ok();
for url in TRACKER_SYNC_SOURCES {
let Some(c) = &client else { break };
match c.get(*url).send().await {
Err(e) => {
crate::logger::log_line("download", crate::logger::LogLevel::Debug, &format!("动态 tracker 源不可达 {}: {}", url, e));
continue;
}
Ok(resp) => {
let text = match resp.text().await {
Ok(t) => t,
Err(e) => {
crate::logger::log_line("download", crate::logger::LogLevel::Debug, &format!("动态 tracker 源读取失败 {}: {}", url, e));
continue;
}
};
let list = parse_list(&text);
if list.is_empty() {
crate::logger::log_line("download", crate::logger::LogLevel::Debug, &format!("动态 tracker 源返回空列表 {}", url));
continue;
}
// 保存列表与同步元数据
let _ = std::fs::write(&trackers_path, format!("{}\n", list.join("\n")));
let _ = std::fs::write(&meta_path, serde_json::json!({ "date": today }).to_string());
self.add_dynamic_trackers(list);
crate::logger::log_info("download", &format!("已同步动态 tracker(共 {} 条)", self.dynamic_count()));
return;
}
}
}
crate::logger::log_warn("download", "动态 tracker 全部同步源不可达,使用上次成功列表或内置列表(不影响磁力解析,公共 tracker 仍会生效)");
}
/// 会话级公共 trackerHashSet<Url>,供 SessionOptions.trackers 使用)。
/// 注意:librqbit 对磁力链接只采用 magnet URL 自带的 tr= 参数,完全忽略
/// AddTorrentOptions.trackers(见 librqbit session.rs 的 magnet 分支),导致
/// 纯磁力(无自带 tr)解析元数据时 "trackers list is empty"。而会话级
/// SessionOptions.trackers 会在 make_peer_rx 中合并进每个种子(含磁力),
/// 因此必须放到这里才能让磁力链接真正带上公共 tracker。
fn session_trackers(&self) -> std::collections::HashSet<url::Url> {
self.build_trackers()
.into_iter()
.filter_map(|t| url::Url::parse(&t).ok())
.collect()
}
/// 获取或创建全局会话。
/// 持 tokio 互斥锁覆盖整个构造过程:并发调用在此串行化,
/// 先进入者创建并写入,后续者锁内二次检查直接复用(避免多会话竞态)。
/// 开启代理时先尝试用代理初始化;失败则降级为直连(记录下来供 add 重试判断)。
async fn get_session(&self) -> Result<Arc<Session>, String> {
let mut guard = self.session.lock().await;
if let Some(s) = guard.as_ref() {
return Ok(s.clone());
}
let _ = std::fs::create_dir_all(&self.base_dir);
let port = self.listen_port.load(std::sync::atomic::Ordering::SeqCst);
let proxy = self.proxy_addr.lock().unwrap_or_else(|e| e.into_inner()).clone();
let connect_opt = proxy.as_ref().map(|p| librqbit::ConnectionOptions { proxy_url: Some(p.clone()), ..Default::default() });
let had_proxy = connect_opt.is_some();
// 构造带代理(若启用)+ 监听端口 + 会话级公共 tracker 的会话选项
let mut opts = lib_session_options();
opts.trackers = self.session_trackers();
if connect_opt.is_some() {
opts.connect = connect_opt;
}
if port > 0 {
if let Ok(addr) = format!("0.0.0.0:{}", port).parse::<std::net::SocketAddr>() {
opts.listen = Some(librqbit::ListenerOptions {
listen_addr: addr,
..Default::default()
});
} else {
crate::logger::log_error("download", &format!("BT 监听端口 {} 无效,使用自动端口", port));
}
}
// 尝试创建会话:先按配置(可能带代理),失败且有代理则降级直连重试
let s = match Session::new_with_opts(self.base_dir.clone(), opts).await {
Ok(s) => s,
Err(e) => {
if had_proxy {
crate::logger::log_error("download", &format!("BT 代理会话初始化失败({}),降级为直连", e));
let mut m = lib_session_options();
m.trackers = self.session_trackers();
if port > 0 {
if let Ok(addr) = format!("0.0.0.0:{}", port).parse::<std::net::SocketAddr>() {
m.listen = Some(librqbit::ListenerOptions { listen_addr: addr, ..Default::default() });
}
}
Session::new_with_opts(self.base_dir.clone(), m).await.map_err(|e2| format!("初始化 BitTorrent 会话失败: {}", e2))?
} else {
return Err(format!("初始化 BitTorrent 会话失败: {}", e));
}
}
};
*guard = Some(s.clone());
Ok(s)
}
/// 解析磁力链 / 本地 .torrent 文件,返回种子信息(不开始下载)。
/// list_only 模式:仅获取元数据,不加入会话,因此不影响后续真正添加。
/// 磁力元数据依赖 DHT/tracker 拉取,无超时会永久等待,故加超时。
pub async fn inspect(&self, input: &str) -> Result<TorrentInfo, String> {
let session = self.get_session().await?;
let trackers = self.build_trackers();
// 磁力:需从网络拉取元数据,可能较慢(取决于种子热度与网络连通性);
// 本地 .torrent / http(s) .torrent URL:元数据在文件内,from_cli_argument 会读取/下载并解析
let add = AddTorrent::from_cli_argument(input).map_err(|e| format!("无效的种子输入: {}", e))?;
let add_fut = session.add_torrent(
add,
Some(AddTorrentOptions {
list_only: true,
trackers: Some(trackers),
..Default::default()
}),
);
let resp = tokio::time::timeout(std::time::Duration::from_secs(INSPECT_TIMEOUT_SECS), add_fut)
.await
.map_err(|_| "解析磁力元数据超时:未能从 DHT/Tracker 获取种子信息,请确认种子有做种源或网络可直连 BT".to_string())?
.map_err(|e| format!("解析种子失败: {}", e))?;
let AddTorrentResponse::ListOnly(lo) = resp else {
return Err("该链接未返回有效的种子元数据".to_string());
};
let info = lo.info.info();
let name = info
.name
.as_ref()
.map(|b| String::from_utf8_lossy(b.as_ref()).into_owned())
.or_else(|| lo.info.name().map(|c| c.to_string()))
.unwrap_or_else(|| "未命名种子".to_string());
let mut files = Vec::new();
let mut total_size = 0u64;
if let Some(fs) = &info.files {
for (i, f) in fs.iter().enumerate() {
let path = f
.path
.iter()
.map(|p| String::from_utf8_lossy(p.as_ref()).into_owned())
.collect::<Vec<_>>()
.join("/");
files.push(BtFileInfo {
index: i as u32,
path,
size: f.length,
});
total_size += f.length;
}
} else if let Some(len) = info.length {
// 单文件种子
files.push(BtFileInfo {
index: 0,
path: name.clone(),
size: len,
});
total_size = len;
}
Ok(TorrentInfo {
name,
info_hash: hex_encode(&lo.info_hash.0),
total_size,
files,
})
}
/// 异步添加种子:立即返回句柄(不等待元数据),由调用方后台解析。
/// 附加公共 tracker 提升磁力元数据解析成功率;上传限速 per-torrent 应用。
pub async fn add_async(
&self,
input: &str,
output_dir: &str,
) -> Result<(usize, Arc<ManagedTorrent>), String> {
let session = self.get_session().await?;
std::fs::create_dir_all(output_dir).map_err(|e| format!("创建下载目录失败: {}", e))?;
let up = self.upload_limit_bps.load(std::sync::atomic::Ordering::SeqCst);
let upload_bps = std::num::NonZeroU32::new(up.min(u32::MAX as u64) as u32);
let trackers = self.build_trackers();
let opts = AddTorrentOptions {
paused: true,
output_folder: Some(output_dir.to_string()),
overwrite: true,
trackers: Some(trackers),
ratelimits: librqbit::limits::LimitsConfig {
upload_bps,
download_bps: None,
},
..Default::default()
};
let add = AddTorrent::from_cli_argument(input).map_err(|e| format!("无效的种子输入: {}", e))?;
let resp = session
.add_torrent(add, Some(opts))
.await
.map_err(|e| format!("添加种子失败: {}", e))?;
match resp {
AddTorrentResponse::Added(id, handle) => Ok((id, handle)),
AddTorrentResponse::AlreadyManaged(id, handle) => Ok((id, handle)),
_ => Err("种子已存在或元数据无效".to_string()),
}
}
/// 等待种子元数据初始化就绪(磁力需要从网络拉取,带超时)
pub async fn wait_initialized(&self, handle: &Arc<ManagedTorrent>) -> Result<(), String> {
tokio::time::timeout(
std::time::Duration::from_secs(INSPECT_TIMEOUT_SECS),
handle.wait_until_initialized(),
)
.await
.map_err(|_| "解析磁力元数据超时:未能从 DHT/Tracker 获取种子信息,请确认种子有做种源或网络可直连 BT".to_string())?
.map_err(|e| format!("获取种子元数据失败: {}", e))
}
/// 设置要下载的种子文件子集(用户文件勾选;传全部索引则全选)
pub async fn set_only_files(&self, handle: &Arc<ManagedTorrent>, files: &[u32]) -> Result<(), String> {
let session = self.get_session().await?;
let set: std::collections::HashSet<usize> = files.iter().map(|&i| i as usize).collect();
session
.update_only_files(handle, &set)
.await
.map_err(|e| format!("设置下载文件失败: {}", e))
}
/// 读取句柄当前进度:返回 (已下载字节, 总字节, 每文件已下载字节)
/// `file_progress` 与种子文件一一对应,供前端详情页展示单文件进度。
pub fn progress_full(handle: &Arc<ManagedTorrent>) -> (u64, u64, Vec<u64>) {
let s = handle.stats();
(s.progress_bytes, s.total_bytes, s.file_progress.clone())
}
/// 暂停种子(下载中的连接会停止,已下载片段保留,可继续)
pub async fn pause(&self, handle: &Arc<ManagedTorrent>) -> Result<(), String> {
let session = self.get_session().await?;
session
.pause(handle)
.await
.map_err(|e| format!("暂停种子失败: {}", e))
}
/// 继续种子
pub async fn unpause(&self, handle: &Arc<ManagedTorrent>) -> Result<(), String> {
let session = self.get_session().await?;
session
.unpause(handle)
.await
.map_err(|e| format!("继续种子失败: {}", e))
}
/// 删除种子(info_hash 为 hex 字符串;delete_files 是否同时删除已下载文件)
pub async fn delete(&self, info_hash: &str, delete_files: bool) -> Result<(), String> {
let session = self.get_session().await?;
let id = TorrentIdOrHash::parse(info_hash)
.map_err(|_| format!("无效的 infohash: {}", info_hash))?;
session
.delete(id, delete_files)
.await
.map_err(|e| format!("删除种子失败: {}", e))
}
/// 读取句柄当前下载进度 (已下载字节, 总字节)
pub fn progress(handle: &Arc<ManagedTorrent>) -> (u64, u64) {
let s = handle.stats();
(s.progress_bytes, s.total_bytes)
}
/// 停止全局会话(应用退出时调用)
pub async fn stop(&self) {
let session = {
let mut guard = self.session.lock().await;
guard.take()
};
if let Some(s) = session {
let _ = s.stop().await;
}
}
}
/// 字节数组转小写十六进制字符串(librqbit 的 Id 未实现 Display
pub(crate) fn hex_encode(bytes: &[u8]) -> String {
const HEX: &[u8; 16] = b"0123456789abcdef";
let mut out = String::with_capacity(bytes.len() * 2);
for &b in bytes {
out.push(HEX[(b >> 4) as usize] as char);
out.push(HEX[(b & 0x0f) as usize] as char);
}
out
}
/// 默认会话选项(后续如需新增 DHT/缓存等可在此统一配置)
fn lib_session_options() -> librqbit::SessionOptions {
librqbit::SessionOptions::default()
}
+70 -41
View File
@@ -20,19 +20,19 @@ 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::{
log_clear, log_info_state, log_list, log_message, log_clear, log_info_state, log_list, log_message,
}; };
use mihomo_manager::{ use mihomo_manager::{
proxy_activate_profile, proxy_check_kernel_update, proxy_clear_system_proxy, proxy_close_connection, proxy_delete_profile, proxy_activate_profile, proxy_apply_kernel_update, proxy_cancel_kernel_install, proxy_check_kernel_update, proxy_clear_system_proxy,
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_install_kernel, 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_kernel, proxy_update_profile, proxy_version, MihomoManager, proxy_test_delay, proxy_update_profile, proxy_version, MihomoManager,
}; };
use monitor_kernel::{ use monitor_kernel::{
monitor_elevate_self, monitor_get_auto_start, monitor_get_elevate_on_launch, monitor_elevate_self, monitor_get_auto_start, monitor_get_elevate_on_launch,
@@ -42,8 +42,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_click_through, osd_set_topmost, osd_apply_overlay_style, osd_begin_drag, osd_set_bounds, osd_set_click_through,
osd_start_drag_watch, osd_start_topmost_watch, osd_stop_watch, osd_set_topmost, osd_start_drag_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,
@@ -55,16 +55,21 @@ use screenshot::commands::{
screenshot_crop_stored, screenshot_cursor_pos, screenshot_delete_cache, screenshot_crop_stored, screenshot_cursor_pos, screenshot_delete_cache,
screenshot_disable_transitions, screenshot_enum_windows, screenshot_fullscreen_png, screenshot_disable_transitions, screenshot_enum_windows, screenshot_fullscreen_png,
screenshot_get_editor_image, screenshot_get_fullscreen_bmp, screenshot_load_cache, screenshot_get_editor_image, screenshot_get_fullscreen_bmp, screenshot_load_cache,
screenshot_register_pin_shortcut, screenshot_register_shortcut, screenshot_save_cache, screenshot_load_cache_raw, screenshot_pick_list, screenshot_register_pin_shortcut,
screenshot_save_png, screenshot_set_editor_image, screenshot_unregister_pin_shortcut, screenshot_register_shortcut,
screenshot_unregister_shortcut, screenshot_window_from_point, screenshot_save_cache, screenshot_save_png, screenshot_set_editor_image,
screenshot_show_overlay, screenshot_unregister_pin_shortcut,
screenshot_unregister_shortcut,
}; };
use clipboard::{ use clipboard::{
ClipboardManager, ClipboardManager,
clipboard_clear, clipboard_copy_back, clipboard_count, clipboard_delete, clipboard_get_history, clipboard_clear, clipboard_copy_back, clipboard_count, clipboard_delete, clipboard_get_history,
clipboard_get_item, clipboard_get_pinned, clipboard_get_settings, clipboard_hide_popup, clipboard_get_item, clipboard_get_pinned, clipboard_get_settings, clipboard_get_thumb,
clipboard_paste_to_target, clipboard_register_shortcut, clipboard_save_settings, clipboard_search, clipboard_hide_popup, clipboard_hide_preview, clipboard_paste_to_target,
clipboard_set_pinned, clipboard_show_popup, clipboard_show_window, clipboard_start, clipboard_preview_interacted, clipboard_register_shortcut,
clipboard_reveal_preview,
clipboard_save_settings, clipboard_search, clipboard_set_pinned, clipboard_resize_preview,
clipboard_show_popup, clipboard_show_preview, clipboard_show_window, clipboard_start,
clipboard_status, clipboard_stop, clipboard_unregister_shortcut, clipboard_status, clipboard_stop, clipboard_unregister_shortcut,
}; };
use quickpanel::{ use quickpanel::{
@@ -107,12 +112,12 @@ fn export_bindings() {
// 应用更新(4 // 应用更新(4
app_version, update_check, update_install, update_thinghk, app_version, update_check, update_install, update_thinghk,
// proxy20 // proxy20
proxy_activate_profile, 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_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_install_kernel, proxy_get_system_proxy, proxy_import_profile, proxy_kernel_info,
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_kernel, proxy_update_profile, proxy_test_delay, proxy_update_profile,
// quickpanel22 // quickpanel22
quickpanel_get_settings, quickpanel_save_settings, quickpanel_register_shortcut, quickpanel_get_settings, quickpanel_save_settings, quickpanel_register_shortcut,
quickpanel_unregister_shortcut, quickpanel_show_popup, quickpanel_hide_popup, quickpanel_unregister_shortcut, quickpanel_show_popup, quickpanel_hide_popup,
@@ -124,23 +129,25 @@ fn export_bindings() {
quickpanel_run_system_command, quickpanel_list_archives, quickpanel_list_dir, quickpanel_run_system_command, quickpanel_list_archives, quickpanel_list_dir,
quickpanel_batch_extract, quickpanel_preview_rename, quickpanel_apply_rename, quickpanel_batch_extract, quickpanel_preview_rename, quickpanel_apply_rename,
quickpanel_delete_files, quickpanel_focus_main_window, quickpanel_delete_files, quickpanel_focus_main_window,
// clipboard20 // clipboard23
clipboard_get_history, clipboard_get_pinned, clipboard_search, clipboard_get_item, clipboard_get_history, clipboard_get_pinned, clipboard_search, clipboard_get_item,
clipboard_set_pinned, clipboard_delete, clipboard_clear, clipboard_copy_back, clipboard_get_thumb, clipboard_set_pinned, clipboard_delete, clipboard_clear,
clipboard_count, clipboard_get_settings, clipboard_save_settings, clipboard_status, clipboard_copy_back, clipboard_count, clipboard_get_settings, clipboard_save_settings,
clipboard_start, clipboard_stop, clipboard_register_shortcut, clipboard_status, clipboard_start, clipboard_stop, clipboard_register_shortcut,
clipboard_unregister_shortcut, clipboard_show_popup, clipboard_hide_popup, clipboard_unregister_shortcut, clipboard_show_popup, clipboard_hide_popup,
clipboard_show_window, clipboard_paste_to_target, clipboard_show_window, clipboard_paste_to_target, clipboard_show_preview,
clipboard_hide_preview, clipboard_resize_preview, clipboard_reveal_preview,
clipboard_preview_interacted,
// download_engine10,豁免 2 // download_engine10,豁免 2
downloader_get_tasks, downloader_check_url, downloader_add_task, downloader_pause_task, downloader_get_tasks, downloader_check_url, downloader_add_task, downloader_pause_task,
downloader_resume_task, downloader_remove_task, downloader_get_settings, downloader_resume_task, downloader_cancel_task, downloader_redownload, downloader_remove_task, downloader_get_settings,
downloader_save_settings, downloader_open_dir, downloader_open_url, downloader_save_settings, downloader_open_dir, downloader_open_url, downloader_focus_window, downloader_inspect, downloader_select_bt_files,
// screenshot21,豁免 2get_fullscreen_bmp 返回 ipc::Response、compose_copy 接收 ipc::Request // screenshot22,豁免 3get_fullscreen_bmp / load_cache_raw 返回 ipc::Response、compose_copy 接收 ipc::Request
screenshot_disable_transitions, 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,
screenshot_unregister_pin_shortcut, screenshot_capture_fullscreen, screenshot_unregister_pin_shortcut, screenshot_capture_fullscreen,
screenshot_fullscreen_png, screenshot_clear_fullscreen, screenshot_crop_stored, screenshot_fullscreen_png, screenshot_clear_fullscreen, screenshot_crop_stored,
screenshot_crop_copy_stored, screenshot_window_from_point, screenshot_cursor_pos, screenshot_crop_copy_stored, screenshot_pick_list, screenshot_cursor_pos,
screenshot_enum_windows, screenshot_capture_window, screenshot_set_editor_image, screenshot_enum_windows, screenshot_capture_window, screenshot_set_editor_image,
screenshot_get_editor_image, screenshot_copy_image, screenshot_save_png, screenshot_get_editor_image, screenshot_copy_image, screenshot_save_png,
screenshot_save_cache, screenshot_load_cache, screenshot_delete_cache, screenshot_save_cache, screenshot_load_cache, screenshot_delete_cache,
@@ -187,11 +194,13 @@ pub fn run() {
proxy_save_settings, proxy_save_settings,
proxy_kernel_info, proxy_kernel_info,
proxy_check_kernel_update, proxy_check_kernel_update,
proxy_update_kernel, proxy_apply_kernel_update,
proxy_install_kernel, proxy_cancel_kernel_install,
proxy_confirm_install,
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,
@@ -224,6 +233,7 @@ pub fn run() {
network_status, network_status,
osd_apply_overlay_style, osd_apply_overlay_style,
osd_begin_drag, osd_begin_drag,
osd_set_bounds,
osd_set_click_through, osd_set_click_through,
osd_set_topmost, osd_set_topmost,
osd_start_drag_watch, osd_start_drag_watch,
@@ -234,6 +244,8 @@ pub fn run() {
downloader_check_url, downloader_check_url,
downloader_pause_task, downloader_pause_task,
downloader_resume_task, downloader_resume_task,
downloader_cancel_task,
downloader_redownload,
downloader_remove_task, downloader_remove_task,
downloader_get_settings, downloader_get_settings,
downloader_save_settings, downloader_save_settings,
@@ -241,10 +253,14 @@ 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,
clipboard_get_item, clipboard_get_item,
clipboard_get_thumb,
clipboard_set_pinned, clipboard_set_pinned,
clipboard_delete, clipboard_delete,
clipboard_clear, clipboard_clear,
@@ -261,6 +277,11 @@ pub fn run() {
clipboard_show_window, clipboard_show_window,
clipboard_hide_popup, clipboard_hide_popup,
clipboard_paste_to_target, clipboard_paste_to_target,
clipboard_show_preview,
clipboard_hide_preview,
clipboard_resize_preview,
clipboard_reveal_preview,
clipboard_preview_interacted,
quickpanel_get_settings, quickpanel_get_settings,
quickpanel_save_settings, quickpanel_save_settings,
quickpanel_register_shortcut, quickpanel_register_shortcut,
@@ -300,7 +321,8 @@ pub fn run() {
screenshot_clear_fullscreen, screenshot_clear_fullscreen,
screenshot_crop_stored, screenshot_crop_stored,
screenshot_crop_copy_stored, screenshot_crop_copy_stored,
screenshot_window_from_point, screenshot_pick_list,
screenshot_show_overlay,
screenshot_cursor_pos, screenshot_cursor_pos,
screenshot_enum_windows, screenshot_enum_windows,
screenshot_capture_window, screenshot_capture_window,
@@ -310,6 +332,7 @@ pub fn run() {
screenshot_save_png, screenshot_save_png,
screenshot_save_cache, screenshot_save_cache,
screenshot_load_cache, screenshot_load_cache,
screenshot_load_cache_raw,
screenshot_delete_cache, screenshot_delete_cache,
screenshot_register_shortcut, screenshot_register_shortcut,
screenshot_unregister_shortcut, screenshot_unregister_shortcut,
@@ -320,9 +343,13 @@ pub fn run() {
]) ])
.setup(setup::init) .setup(setup::init)
.on_window_event(|window, event| { .on_window_event(|window, event| {
if let tauri::WindowEvent::CloseRequested { api, .. } = event { // 仅主窗口拦截关闭(隐藏到托盘)。其他窗口(截图编辑器/OSD 等)
window.hide().ok(); // 调用 close() 是真实销毁语义,全局拦截会导致隐藏窗口累积泄漏。
api.prevent_close(); if window.label() == constants::windows::MAIN {
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
window.hide().ok();
api.prevent_close();
}
} }
}) })
.build(tauri::generate_context!()) .build(tauri::generate_context!())
@@ -337,16 +364,18 @@ pub fn run() {
} }
if let Some(monitor) = app.try_state::<MonitorKernel>() { if let Some(monitor) = app.try_state::<MonitorKernel>() {
// cleanup_on_exit 是 async;在事件循环回调中直接 block_on 有 panic 风险且阻塞退出, // cleanup_on_exit 是 async;在事件循环回调中直接 block_on 有 panic 风险且阻塞退出,
// 放到独立 OS 线程执行并限时等待(与托盘旧实现同模式)。 // 放到独立 OS 线程执行并通过 channel 限时等待 3s,超时放弃等待直接退出
// (进程终止时 OS 回收残留资源),避免清理挂起导致退出卡死。
let app_clone = app.clone(); let app_clone = app.clone();
let monitor_clone = monitor.inner().clone(); let monitor_clone = monitor.inner().clone();
let (tx, rx) = std::sync::mpsc::channel::<()>();
std::thread::spawn(move || { std::thread::spawn(move || {
tauri::async_runtime::block_on(async move { tauri::async_runtime::block_on(async move {
monitor_clone.cleanup_on_exit(&app_clone).await; monitor_clone.cleanup_on_exit(&app_clone).await;
}); });
}) let _ = tx.send(());
.join() });
.ok(); let _ = rx.recv_timeout(std::time::Duration::from_secs(3));
} }
if let Some(clip) = app.try_state::<ClipboardManager>() { if let Some(clip) = app.try_state::<ClipboardManager>() {
clip.stop(); clip.stop();
+192
View File
@@ -0,0 +1,192 @@
//! 自动切换节点:后端调度,独立于前端模块是否处于激活状态。
//! 应用启动后由 `setup.rs` 启动后台任务,按用户设置的间隔周期性测速并切换到当前最优节点,
//! 切换完成后通过 `proxy-auto-switch` 事件通知前端刷新界面。
//! 节点候选(目标组 + 地区筛选 + 伪节点过滤)与择优逻辑对前端、后台调度、托盘三处保持一致。
use futures_util::future::join_all;
use serde_json::{Map, Value};
use std::time::{Duration, Instant};
use tauri::{AppHandle, Emitter, Manager, State};
use super::{MihomoManager, ProxySettings};
use crate::constants::events::PROXY_AUTO_SWITCH;
use crate::process_manager::{ProcessManager, ProcessStatus};
/// 测速目标(同前端/托盘),用于判断节点可用性
const TEST_URL: &str = "http://www.gstatic.com/generate_204";
/// 单节点测速超时(毫秒)
const TEST_TIMEOUT: u32 = 5000;
/// 调度轮询粒度(秒)
const CHECK_INTERVAL: u64 = 30;
/// 后端启动自动切换调度任务(幂等,可安全多次调用)。
/// 仅在用户开启「自动切换节点」且 mihomo 运行时才会真正执行测速与切换。
pub fn start_auto_switch_loop(app: AppHandle) {
tauri::async_runtime::spawn(async move {
let mut last_run: Option<Instant> = None;
loop {
tokio::time::sleep(Duration::from_secs(CHECK_INTERVAL)).await;
let enabled = app
.try_state::<MihomoManager>()
.map(|m: State<'_, MihomoManager>| m.load_settings().auto_switch_enabled)
.unwrap_or(false);
if !enabled {
last_run = None;
continue;
}
let interval_secs = app
.try_state::<MihomoManager>()
.map(|m: State<'_, MihomoManager>| m.load_settings().auto_switch_interval.max(1) as u64)
.unwrap_or(5)
* 60;
let due = match last_run {
Some(t) => t.elapsed().as_secs() >= interval_secs,
None => true,
};
if !due {
continue;
}
if let Some((group, name, delay)) = perform_auto_switch(&app).await {
let _ = app.emit(
PROXY_AUTO_SWITCH,
serde_json::json!({
"switched": true,
"group": group,
"name": name,
"delay": delay,
}),
);
}
last_run = Some(Instant::now());
}
});
}
/// 执行一次自动切换:若当前不是最优节点则切换并返回 `(组名, 节点名, 延迟)`,否则返回 `None`。
/// 供后台调度任务使用(模块内私有)。
async fn perform_auto_switch(app: &AppHandle) -> Option<(String, String, u32)> {
let mihomo = app.state::<MihomoManager>();
let pm = app.state::<ProcessManager>();
// 仅在 mihomo 运行时执行
let running = matches!(
pm.get_status("proxy").map(|p| p.status),
Some(ProcessStatus::Running)
);
if !running {
return None;
}
let settings = mihomo.load_settings();
if !settings.auto_switch_enabled {
return None;
}
let (group, name, delay, now) = pick_best(&mihomo, &settings).await.ok()??;
if name != now && mihomo.select_proxy(&group, &name).await.is_ok() {
return Some((group, name, delay));
}
None
}
/// 按自动切换设置确定候选组与地区,并发测速并返回当前最优节点(不执行切换)。
/// `Some((组名, 最优节点, 延迟, 当前节点))`;无候选或全部超时返回 `Ok(None)`。
/// 当自动切换关闭时自动切换目标组为空、地区为空,故退化为「主组 + 全部节点」全量择优,
/// 托盘「开启代理」与调度任务复用此函数保证行为一致。
pub async fn pick_best(
mihomo: &MihomoManager,
settings: &ProxySettings,
) -> Result<Option<(String, String, u32, String)>, String> {
let proxies = mihomo.get_proxies().await?;
let map = proxies
.get("proxies")
.and_then(|v| v.as_object())
.cloned()
.ok_or_else(|| "无法解析代理数据".to_string())?;
let group_name = if !settings.auto_switch_group.is_empty() {
settings.auto_switch_group.clone()
} else {
match main_selector_group(&map) {
Some(g) => g,
None => return Ok(None),
}
};
let group = match map.get(&group_name) {
Some(g) => g,
None => return Ok(None),
};
let now = group
.get("now")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let mut nodes: Vec<String> = group
.get("all")
.and_then(|v| v.as_array())
.map(|arr| arr.iter().filter_map(|n| n.as_str().map(|s| s.to_string())).collect())
.unwrap_or_default();
if nodes.is_empty() {
return Ok(None);
}
// 过滤伪节点(DIRECT、REJECT、流量、套餐等)
nodes.retain(|n| !super::is_pseudo_node(n));
// 地区筛选
if !settings.auto_switch_region.is_empty() {
let region = settings.auto_switch_region.clone();
nodes.retain(|n| extract_region(n) == region);
}
if nodes.is_empty() {
return Ok(None);
}
// 并发测速
let futures = nodes.iter().map(|name| async move {
let d = mihomo.test_delay(name, TEST_URL, TEST_TIMEOUT).await.ok();
(name.clone(), d)
}).collect::<Vec<_>>();
let results = join_all(futures).await;
let best = results
.into_iter()
.filter_map(|(n, d)| d.filter(|x| *x > 0).map(|x| (n, x)))
.min_by_key(|(_, d)| *d);
Ok(best.map(|(name, delay)| (group_name, name, delay, now)))
}
/// 主选择分组:选取名为 PROXY/Proxy/节点选择/代理 的 Selector 组;找不到时回退到第一个 Selector 组
fn main_selector_group(map: &Map<String, Value>) -> Option<String> {
let mut preferred: Option<String> = None;
let mut first_selector: Option<String> = None;
for (name, val) in map {
let kind = val.get("type").and_then(|v| v.as_str()).unwrap_or("");
if kind != "Selector" {
continue;
}
if first_selector.is_none() {
first_selector = Some(name.clone());
}
if preferred.is_none() && ["PROXY", "Proxy", "节点选择", "代理"].contains(&name.as_str()) {
preferred = Some(name.clone());
}
}
preferred.or(first_selector)
}
/// 提取节点地区(与前端 extractRegion 保持一致):取名称中第一个数字/符号分隔符之前的文本
fn extract_region(name: &str) -> String {
for (idx, ch) in name.char_indices() {
if ch.is_ascii_digit() || matches!(ch, '|' | '-' | '' | '—' | '' | '(' | '【') {
return name[..idx].trim().to_string();
}
}
name.trim().to_string()
}
+109 -16
View File
@@ -4,10 +4,18 @@ 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}; use crate::process_manager::{ProcessInfo, ProcessManager, ProcessStatus};
/// 判断 mihomo 进程是否处于运行状态
fn mihomo_running(pm: &ProcessManager) -> bool {
matches!(
pm.get_status("proxy").map(|p| p.status),
Some(ProcessStatus::Running)
)
}
#[tauri::command] #[tauri::command]
#[specta::specta] #[specta::specta]
@@ -41,25 +49,33 @@ pub async fn proxy_check_kernel_update(
state.check_kernel_update().await state.check_kernel_update().await
} }
/// 取消内核下载/安装(设置取消标志,下载循环轮询后中止)
#[tauri::command] #[tauri::command]
#[specta::specta] #[specta::specta]
pub async fn proxy_update_kernel( pub fn proxy_cancel_kernel_install(state: State<'_, MihomoManager>) -> Result<(), String> {
state: State<'_, MihomoManager>, state.cancel_kernel_install();
app: AppHandle, Ok(())
mirror_prefix: Option<String>,
) -> Result<KernelInfo, String> {
state.install_kernel(&app, mirror_prefix.unwrap_or_default()).await
} }
/// 首次安装内核(与 update_kernel 共用 install_kernel 实现,语义独立便于前端区分场景) /// 前端确认 mihomo 已停止,唤醒等待中的安装流程继续解压替换。
/// (下载阶段允许 mihomo 运行以便走系统代理,解压替换前必须停止 mihomo,否则 exe 被占用)
#[tauri::command] #[tauri::command]
#[specta::specta] #[specta::specta]
pub async fn proxy_install_kernel( pub fn proxy_confirm_install(state: State<'_, MihomoManager>) -> Result<(), String> {
state.confirm_install();
Ok(())
}
/// 应用内核更新:下载阶段已由下载模块完成,本方法仅做 need_stop → 解压 → 替换。
#[tauri::command]
#[specta::specta]
pub async fn proxy_apply_kernel_update(
state: State<'_, MihomoManager>, state: State<'_, MihomoManager>,
app: AppHandle, app: AppHandle,
mirror_prefix: Option<String>, zip_path: String,
) -> Result<KernelInfo, String> { ) -> Result<KernelInfo, String> {
state.install_kernel(&app, mirror_prefix.unwrap_or_default()).await let path = std::path::PathBuf::from(zip_path);
state.apply_kernel_update(&app, path).await
} }
#[tauri::command] #[tauri::command]
@@ -87,12 +103,19 @@ pub fn proxy_start(
app: AppHandle, app: AppHandle,
) -> Result<ProcessInfo, String> { ) -> Result<ProcessInfo, String> {
let params = state.prepare_for_start(&app)?; let params = state.prepare_for_start(&app)?;
pm.start(params) let info = pm.start(params)?;
// 手动启动也遵循「启动时自动开启系统代理」设置
state.apply_auto_system_proxy();
Ok(info)
} }
#[tauri::command] #[tauri::command]
#[specta::specta] #[specta::specta]
pub fn proxy_stop(pm: State<'_, ProcessManager>) -> Result<(), String> { pub fn proxy_stop(state: State<'_, MihomoManager>, pm: State<'_, ProcessManager>) -> Result<(), String> {
// 关闭 mihomo 时同步关闭系统代理(若开启),避免系统代理指向已停止的端口导致断网
if get_system_proxy_windows() {
let _ = state.disable_system_proxy();
}
pm.stop("proxy") pm.stop("proxy")
} }
@@ -110,8 +133,65 @@ pub async fn proxy_restart(
}) })
.await .await
.map_err(|e| format!("sleep 失败: {}", e))?; .map_err(|e| format!("sleep 失败: {}", e))?;
let params = state.prepare_for_start(&app)?;
pm.start(params) // 启动并等待 API 就绪,带有限重试(共 3 次):
// - 冷加载大订阅(首次解析 + geo 下载)可能远超过前端 waitForApi 的 10s 预算,
// 这里在命令内等满就绪,避免重启成功后仍被前端误判为「重启失败」导致节点不刷新。
// - 偶发的端口未及时释放 / 启动瞬间退出,通过重试自愈。
let mut last_err = "mihomo 启动失败".to_string();
for _ in 0..3 {
match start_and_wait(state.inner(), pm.inner(), &app).await {
Ok(info) => return Ok(info),
Err(e) => {
last_err = e;
// 上个实例刚退出,多等一会儿释放端口再重试
tauri::async_runtime::spawn_blocking(|| {
std::thread::sleep(std::time::Duration::from_millis(1200));
})
.await
.map_err(|x| format!("sleep 失败: {}", x))?;
}
}
}
// 所有启动尝试均失败:mihomo 已停止,同步关闭系统代理(若开启),避免代理指向已停止端口导致断网
if get_system_proxy_windows() {
let _ = state.disable_system_proxy();
}
Err(last_err)
}
/// 启动 mihomo 并等待其 HTTP API 就绪(最多 20s)。
/// - 若启动后进程立即退出,快速返回(不白等满预算),便于外层尽早重试。
/// - 若 pm.start 返回「已在运行中」,说明崩溃监控已抢先拉起进程,同样等待其 API 就绪即可。
async fn start_and_wait(
state: &MihomoManager,
pm: &ProcessManager,
app: &AppHandle,
) -> Result<ProcessInfo, String> {
let params = state.prepare_for_start(app)?;
if let Err(e) = pm.start(params) {
// "已在运行中" = 崩溃监控已拉起,不算失败;其余为上抛
if !e.contains("运行中") {
return Err(e);
}
}
let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(20);
loop {
// 进程已退出且 API 未就绪 → 启动失败(快速失败,交外层重试)
if let Some(st) = pm.get_status("proxy") {
if !matches!(st.status, ProcessStatus::Running) {
return Err("mihomo 启动后立即退出".to_string());
}
}
if state.get_version().await.is_ok() {
return pm.get_status("proxy").ok_or_else(|| "mihomo 进程不存在".to_string());
}
if tokio::time::Instant::now() >= deadline {
return Err("mihomo 启动超时,API 无响应".to_string());
}
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
}
} }
#[tauri::command] #[tauri::command]
@@ -158,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(
@@ -220,7 +308,12 @@ pub fn proxy_activate_profile(
#[specta::specta] #[specta::specta]
pub fn proxy_set_system_proxy( pub fn proxy_set_system_proxy(
state: State<'_, MihomoManager>, state: State<'_, MihomoManager>,
pm: State<'_, ProcessManager>,
) -> Result<(), String> { ) -> Result<(), String> {
// 停机时禁止开启系统代理:否则系统代理指向已停止的端口,会导致所有网络请求失败
if !mihomo_running(&pm) {
return Err("mihomo 未运行,无法开启系统代理".into());
}
let settings = state.load_settings(); let settings = state.load_settings();
let addr = format!("127.0.0.1:{}", settings.mixed_port); let addr = format!("127.0.0.1:{}", settings.mixed_port);
set_system_proxy_windows(&addr)?; set_system_proxy_windows(&addr)?;
+55 -108
View File
@@ -1,15 +1,18 @@
//! 内核(mihomo.exe)安装 / 更新 / 版本查询。 //! 内核(mihomo.exe)安装 / 更新 / 版本查询。
//! 子模块通过 `impl super::MihomoManager` 为管理器追加方法,可访问父模块私有字段。 //! 子模块通过 `impl super::MihomoManager` 为管理器追加方法,可访问父模块私有字段。
use futures_util::StreamExt;
use std::fs; use std::fs;
use std::io::{Read, Write}; use std::io::{Read, Write};
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::atomic::Ordering;
use tauri::{AppHandle, Emitter, Manager}; use tauri::{AppHandle, Emitter, Manager};
use crate::constants::events::KERNEL_INSTALL_PROGRESS; use crate::constants::events::KERNEL_INSTALL_PROGRESS;
use super::{InstallProgress, KernelInfo, KernelUpdateInfo, MihomoManager}; use super::{InstallProgress, KernelInfo, KernelUpdateInfo, MihomoManager};
/// 用户主动取消下载的标记错误信息(前端据此静默处理,不弹错误 toast)
const KERNEL_CANCELLED: &str = "下载已取消";
impl MihomoManager { impl MihomoManager {
// ---------- 内核 ---------- // ---------- 内核 ----------
pub fn kernel_info(&self) -> KernelInfo { pub fn kernel_info(&self) -> KernelInfo {
@@ -203,57 +206,15 @@ impl MihomoManager {
} }
} }
/// 下载并安装内核(首次安装与更新共用此方法) /// 应用内核更新:下载阶段已由下载模块完成,本方法仅做 need_stop → 解压 → 替换。
/// - mirror_prefix: 用户选择的镜像源前缀(空串=直连 GitHub) /// zip_path: 下载模块下载完成的 zip 文件路径。
/// - 流式下载:实时推送下载进度到前端 /// 任何阶段失败都会 emit error 事件,避免前端进度卡住。
/// - zip crate 解压:替代 PowerShell,避免执行策略问题 pub async fn apply_kernel_update(&self, app: &AppHandle, zip_path: PathBuf) -> Result<KernelInfo, String> {
/// - 备份旧内核:替换前备份为 .bak self.kernel_cancel.store(false, Ordering::SeqCst);
/// 任何阶段失败都会 emit error 事件,避免前端进度卡在初始状态 let _ = self.kernel_cancel_tx.send(false);
pub async fn install_kernel(&self, app: &AppHandle, mirror_prefix: String) -> Result<KernelInfo, String> { let result = self.apply_kernel_inner(app, zip_path).await;
let result = self.install_kernel_inner(app, mirror_prefix).await;
if let Err(ref e) = result { if let Err(ref e) = result {
let _ = app.emit( if e != KERNEL_CANCELLED {
KERNEL_INSTALL_PROGRESS,
InstallProgress {
stage: "error".into(),
percent: 0,
downloaded_bytes: 0,
total_bytes: None,
message: e.clone(),
},
);
}
result
}
async fn install_kernel_inner(&self, app: &AppHandle, mirror_prefix: String) -> Result<KernelInfo, String> {
let info = self.check_kernel_update().await?;
let zip_path = self.cores_dir().join("mihomo-update.zip");
let extract_dir = self.cores_dir().join("mihomo-update-tmp");
// 拼接用户选择的镜像源 URL
let url = if mirror_prefix.is_empty() {
info.download_url.clone()
} else {
format!("{}{}", mirror_prefix, info.download_url)
};
let label = if mirror_prefix.is_empty() { "GitHub 直连".to_string() } else { mirror_prefix.clone() };
let _ = app.emit(
KERNEL_INSTALL_PROGRESS,
InstallProgress {
stage: "downloading".into(),
percent: 0,
downloaded_bytes: 0,
total_bytes: None,
message: format!("正在下载:{}", label),
},
);
// 单源下载(用户已选择)
match self.download_with_progress(app, &url, &zip_path).await {
Ok(()) => {}
Err(e) => {
let msg = format!("下载失败({}):{}", label, e);
let _ = app.emit( let _ = app.emit(
KERNEL_INSTALL_PROGRESS, KERNEL_INSTALL_PROGRESS,
InstallProgress { InstallProgress {
@@ -261,11 +222,50 @@ impl MihomoManager {
percent: 0, percent: 0,
downloaded_bytes: 0, downloaded_bytes: 0,
total_bytes: None, total_bytes: None,
message: msg.clone(), message: e.clone(),
}, },
); );
let _ = fs::remove_file(&zip_path); }
return Err(msg); }
self.kernel_cancel.store(false, Ordering::SeqCst);
let _ = self.kernel_cancel_tx.send(false);
result
}
async fn apply_kernel_inner(&self, app: &AppHandle, zip_path: PathBuf) -> Result<KernelInfo, String> {
let extract_dir = self.cores_dir().join("mihomo-update-tmp");
// 检查 zip 文件是否存在
if !zip_path.exists() {
return Err(format!("下载文件不存在: {}", zip_path.display()));
}
// 解压替换前需要等待前端确认 mihomo 已停止(否则 exe 文件被占用)
if self.kernel_cancel.load(Ordering::SeqCst) {
return Err(KERNEL_CANCELLED.to_string());
}
let _ = app.emit(
KERNEL_INSTALL_PROGRESS,
InstallProgress {
stage: "need_stop".into(),
percent: 90,
downloaded_bytes: 0,
total_bytes: None,
message: "需要停止 mihomo 才能继续安装".into(),
},
);
// 创建 oneshot 通道等待前端确认
let (tx, mut rx) = tokio::sync::oneshot::channel::<()>();
*self.install_confirm.lock().unwrap() = Some(tx);
let mut cancel_rx = self.kernel_cancel_tx.subscribe();
loop {
if self.kernel_cancel.load(Ordering::SeqCst) {
*self.install_confirm.lock().unwrap() = None;
return Err(KERNEL_CANCELLED.to_string());
}
tokio::select! {
_ = &mut rx => break,
_ = cancel_rx.changed() => {}
} }
} }
@@ -299,8 +299,6 @@ impl MihomoManager {
} }
// 在解压目录中递归查找 exe 文件 // 在解压目录中递归查找 exe 文件
// mihomo zip 内的 exe 名字通常与 zip 同名(如 mihomo-windows-amd64-v3-v1.19.13.exe),
// 不是固定的 mihomo.exe,所以查找唯一的 .exe 文件即可
let new_exe = self let new_exe = self
.find_exe_in_dir(&extract_dir) .find_exe_in_dir(&extract_dir)
.ok_or_else(|| "解压后未找到任何 .exe 文件".to_string())?; .ok_or_else(|| "解压后未找到任何 .exe 文件".to_string())?;
@@ -313,7 +311,7 @@ impl MihomoManager {
percent: 96, percent: 96,
downloaded_bytes: 0, downloaded_bytes: 0,
total_bytes: None, total_bytes: None,
message: "正在安装...".into(), message: "正在替换内核...".into(),
}, },
); );
let kernel = self.kernel_path(); let kernel = self.kernel_path();
@@ -345,57 +343,6 @@ impl MihomoManager {
Ok(final_info) Ok(final_info)
} }
/// 流式下载并实时推送进度事件
async fn download_with_progress(
&self,
app: &AppHandle,
url: &str,
dest: &PathBuf,
) -> Result<(), String> {
let resp = self
.client
.get(url)
.header("User-Agent", "thing-app")
.timeout(std::time::Duration::from_secs(30))
.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;
// 下载占总进度的 0-90%
let percent = match total {
Some(t) if t > 0 => ((downloaded as f64 / t as f64) * 90.0) as u8,
_ => 0,
};
// 仅在变化超过 1% 时 emit,避免事件轰炸
if percent >= last_percent + 1 {
last_percent = percent;
let _ = app.emit(
KERNEL_INSTALL_PROGRESS,
InstallProgress {
stage: "downloading".into(),
percent,
downloaded_bytes: downloaded,
total_bytes: total,
message: format!("已下载 {:.2} MB", downloaded as f64 / 1024.0 / 1024.0),
},
);
}
}
file.flush().map_err(|e| format!("flush 失败: {}", e))?;
Ok(())
}
/// 用 zip crate 解压(纯 Rust,避免 PowerShell 执行策略问题) /// 用 zip crate 解压(纯 Rust,避免 PowerShell 执行策略问题)
fn extract_zip(&self, zip_path: &PathBuf, dest: &PathBuf) -> Result<(), String> { fn extract_zip(&self, zip_path: &PathBuf, dest: &PathBuf) -> Result<(), String> {
let file = fs::File::open(zip_path).map_err(|e| format!("打开 zip 失败: {}", e))?; let file = fs::File::open(zip_path).map_err(|e| format!("打开 zip 失败: {}", e))?;
+115 -14
View File
@@ -6,6 +6,7 @@
//! - [`system_proxy`]Windows 系统代理开关 //! - [`system_proxy`]Windows 系统代理开关
//! - [`commands`]Tauri 命令层 //! - [`commands`]Tauri 命令层
mod autoswitch;
mod commands; mod commands;
mod kernel; mod kernel;
mod profiles; mod profiles;
@@ -13,22 +14,24 @@ mod pseudo;
mod system_proxy; mod system_proxy;
mod types; mod types;
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_check_kernel_update, proxy_clear_system_proxy, proxy_close_connection, proxy_activate_profile, proxy_apply_kernel_update, proxy_cancel_kernel_install, proxy_check_kernel_update, proxy_clear_system_proxy,
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_import_profile, proxy_install_kernel, proxy_kernel_info, proxy_patch_configs, proxy_restart, proxy_get_settings, proxy_get_system_proxy, proxy_import_profile, proxy_kernel_info, proxy_traffic,
proxy_save_settings, proxy_select_proxy, proxy_set_system_proxy, proxy_start, proxy_status, proxy_stop, proxy_patch_configs, proxy_restart, proxy_save_settings, proxy_select_proxy, proxy_set_system_proxy,
proxy_test_delay, proxy_update_kernel, proxy_update_profile, proxy_version, proxy_start, proxy_status, proxy_stop, proxy_test_delay, proxy_update_profile, proxy_version,
}; };
use reqwest::Client; use reqwest::Client;
use serde_yaml::Value as YamlValue; use serde_yaml::Value as YamlValue;
use std::fs; use std::fs;
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::Mutex; use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use tauri::AppHandle; use tauri::AppHandle;
@@ -42,10 +45,29 @@ 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>,
/// 取消唤醒通道:让停滞在流式读取(stream.next 最多等 30s)中的下载立即感知取消,
/// 否则旧任务会残留最长 30s,期间可能与新任务并发写临时文件/互相重置取消标志
kernel_cancel_tx: tokio::sync::watch::Sender<bool>,
/// 下载完成后解压替换前的确认通道:mihomo 运行时下载不受影响,但解压替换前
/// 必须等前端确认已停止 mihomo(否则 exe 文件被占用)。前端确认后通过
/// proxy_confirm_install 命令发送信号唤醒等待。
/// 用 std Mutex 而非 tokio Mutex:锁只短暂存取 sender,不跨 await 持有。
install_confirm: std::sync::Mutex<Option<tokio::sync::oneshot::Sender<()>>>,
} }
impl MihomoManager { impl MihomoManager {
@@ -62,6 +84,24 @@ 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_tx: tokio::sync::watch::channel(false).0,
install_confirm: std::sync::Mutex::new(None),
}
}
/// 请求取消内核安装/更新(由 proxy_cancel_kernel_install 命令调用)
pub fn cancel_kernel_install(&self) {
self.kernel_cancel.store(true, Ordering::SeqCst);
// 唤醒停滞的流式下载循环,使其立即中止而不是等 30s 超时
let _ = self.kernel_cancel_tx.send(true);
}
/// 前端确认 mihomo 已停止,唤醒等待中的安装流程继续解压替换(由 proxy_confirm_install 命令调用)
pub fn confirm_install(&self) {
if let Some(tx) = self.install_confirm.lock().unwrap().take() {
let _ = tx.send(());
} }
} }
@@ -274,6 +314,29 @@ impl MihomoManager {
}) })
} }
/// mihomo 启动成功后,若配置了「启动时自动开启系统代理」且当前未开,则开启系统代理。
/// 手动启动与 App 自启共用,保证设置语义一致(mihomo 运行期间自动跟随系统代理)。
pub fn apply_auto_system_proxy(&self) {
let settings = self.load_settings();
if !settings.auto_system_proxy {
return;
}
// 以注册表实际状态为准判断是否已开启:settings.system_proxy 是会话内标志,
// 上次退出 cleanup_on_exit 只清注册表不会回写该标志,重启后会残留 true,
// 若用它做守卫会导致「启动时自动开启系统代理」永远被短路而不生效。
if system_proxy::get_system_proxy_windows() {
return;
}
let addr = format!("127.0.0.1:{}", settings.mixed_port);
if let Err(e) = system_proxy::set_system_proxy_windows(&addr) {
crate::logger::log_warn("mihomo", &format!("自动开启系统代理失败: {}", e));
return;
}
let mut s = settings;
s.system_proxy = true;
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) {
let settings = self.load_settings(); let settings = self.load_settings();
@@ -284,13 +347,9 @@ impl MihomoManager {
Ok(params) => { Ok(params) => {
if let Err(e) = pm.start(params) { if let Err(e) = pm.start(params) {
crate::logger::log_error("mihomo", &format!("自动启动失败: {}", e)); crate::logger::log_error("mihomo", &format!("自动启动失败: {}", e));
} else if settings.auto_system_proxy { } else {
// 启动成功后开启系统代理 // 启动成功后按「启动时自动开启系统代理」设置决定是否开启系统代理
let addr = format!("127.0.0.1:{}", settings.mixed_port); self.apply_auto_system_proxy();
let _ = system_proxy::set_system_proxy_windows(&addr);
let mut s = settings;
s.system_proxy = true;
let _ = self.save_settings(&s);
} }
} }
Err(e) => { Err(e) => {
@@ -408,6 +467,48 @@ impl MihomoManager {
self.api_get("/connections").await self.api_get("/connections").await
} }
/// 拉取 /connections 并计算实时流量快照。
/// 速率由两次采样的会话总量差分得出;mihomo 重启导致总量回退时自动重置基线。
pub async fn traffic_snapshot(&self) -> Result<TrafficSnapshot, String> {
let conns = self.get_connections().await?;
let upload_total = conns["uploadTotal"].as_u64().unwrap_or(0);
let download_total = conns["downloadTotal"].as_u64().unwrap_or(0);
let active_connections = conns["connections"].as_array().map(|a| a.len()).unwrap_or(0);
let (upload_speed, download_speed) = {
let base = self.traffic_baseline.lock().unwrap();
match base.as_ref() {
// 正常差分:总量单调递增才计算速率
Some(b) if upload_total >= b.upload_total && download_total >= b.download_total => {
let dt = b.at.elapsed().as_secs_f64();
if dt > 0.0 {
let up = ((upload_total - b.upload_total) as f64 / dt).max(0.0) as u64;
let down = ((download_total - b.download_total) as f64 / dt).max(0.0) as u64;
(up, down)
} else {
(0, 0)
}
}
// 无基线或总量回退(mihomo 重启):本帧速率为 0,下方重置基线
_ => (0, 0),
}
};
*self.traffic_baseline.lock().unwrap() = Some(TrafficBaseline {
download_total,
upload_total,
at: Instant::now(),
});
Ok(TrafficSnapshot {
download_total,
upload_total,
download_speed,
upload_speed,
active_connections,
})
}
pub async fn close_connection(&self, id: &str) -> Result<(), String> { pub async fn close_connection(&self, id: &str) -> Result<(), String> {
self.api_request( self.api_request(
reqwest::Method::DELETE, reqwest::Method::DELETE,
+16
View File
@@ -124,6 +124,22 @@ pub struct ProxyStatus {
pub restart_count: u32, pub restart_count: u32,
} }
/// 实时流量快照(由 /connections 的会话总量差分得出实时速率)
#[derive(Serialize, Clone, Type)]
#[serde(rename_all = "camelCase")]
pub struct TrafficSnapshot {
/// 本次会话累计下载字节数
pub download_total: u64,
/// 本次会话累计上传字节数
pub upload_total: u64,
/// 实时下载速率(字节/秒)
pub download_speed: u64,
/// 实时上传速率(字节/秒)
pub upload_speed: u64,
/// 当前活跃连接数
pub active_connections: usize,
}
/// 内核安装进度事件载荷 /// 内核安装进度事件载荷
/// - stage: downloading | extracting | replacing | done | error /// - stage: downloading | extracting | replacing | done | error
/// - percent: 0-100(无 total_bytes 时为 0,前端按 downloadedBytes 显示) /// - percent: 0-100(无 total_bytes 时为 0,前端按 downloadedBytes 显示)
+47 -3
View File
@@ -60,8 +60,8 @@ mod win_api {
GetClassNameW, GetCursorPos, GetForegroundWindow, GetWindowLongPtrW, GetClassNameW, GetCursorPos, GetForegroundWindow, GetWindowLongPtrW,
GetWindowRect, SendMessageW, SetWindowLongPtrW, SetWindowPos, GetWindowRect, SendMessageW, SetWindowLongPtrW, SetWindowPos,
GWL_EXSTYLE, HTCAPTION, HWND_NOTOPMOST, HWND_TOPMOST, SWP_NOACTIVATE, SWP_NOMOVE, GWL_EXSTYLE, HTCAPTION, HWND_NOTOPMOST, HWND_TOPMOST, SWP_NOACTIVATE, SWP_NOMOVE,
SWP_NOSIZE, SWP_SHOWWINDOW, WM_NCLBUTTONDOWN, WS_EX_NOACTIVATE, WS_EX_TOOLWINDOW, SWP_NOSIZE, SWP_NOZORDER, WM_NCLBUTTONDOWN, WS_EX_NOACTIVATE,
WS_EX_TRANSPARENT, WS_EX_TOOLWINDOW, WS_EX_TRANSPARENT,
}; };
/// windows-sys 的 HWND 类型别名(isize /// windows-sys 的 HWND 类型别名(isize
@@ -165,6 +165,10 @@ mod win_api {
} else { } else {
HWND_NOTOPMOST HWND_NOTOPMOST
}; };
// 注意:不传 SWP_SHOWWINDOW,仅调整 Z 序,绝不改变窗口可见性。
// 否则当 OSD 被 .hide() 隐藏后,任务栏覆盖监视线程在系统 UI 前景切换时
// (点击任务栏/托盘关闭主界面、打开托盘菜单)会重新显示已隐藏的 OSD,
// 表现为"托盘关闭 OSD 无效 / 关闭主界面后 OSD 又出现"。
SetWindowPos( SetWindowPos(
hwnd, hwnd,
insert_after, insert_after,
@@ -172,7 +176,7 @@ mod win_api {
0, 0,
0, 0,
0, 0,
SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE | SWP_SHOWWINDOW, SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE,
); );
} }
} }
@@ -190,6 +194,24 @@ mod win_api {
} }
} }
/// 原子设置窗口位置与尺寸(物理像素)
///
/// 一次 SetWindowPos 调用同时更新 x/y/w/h,避免 setSize + setPosition
/// 两次调用之间出现"宽度已变、位置未动"的中间帧(视觉闪烁)。
pub fn set_bounds(hwnd: Hwnd, x: i32, y: i32, w: i32, h: i32) {
unsafe {
SetWindowPos(
hwnd,
0, // 不改 Z 序
x,
y,
w,
h,
SWP_NOACTIVATE | SWP_NOZORDER,
);
}
}
/// 判断窗口类名是否为系统 UI(任务栏、开始菜单、通知区域等) /// 判断窗口类名是否为系统 UI(任务栏、开始菜单、通知区域等)
pub fn is_system_ui_class(class_name: &str) -> bool { pub fn is_system_ui_class(class_name: &str) -> bool {
matches!( matches!(
@@ -378,6 +400,28 @@ pub fn osd_set_topmost(label: String, topmost: bool, app: AppHandle) -> Result<(
Ok(()) Ok(())
} }
/// 原子设置窗口位置与尺寸(物理像素)
///
/// 一次调用同时更新位置和尺寸,避免 setSize + setPosition 两次 IPC 之间的
/// 中间帧(宽度已变、位置未动 → 视觉闪烁)。前端传入物理像素坐标。
#[tauri::command]
pub fn osd_set_bounds(
label: String,
x: i32,
y: i32,
w: i32,
h: i32,
app: AppHandle,
) -> Result<(), String> {
#[cfg(windows)]
{
let hwnd = win_api::get_hwnd(&label, &app)
.ok_or_else(|| format!("窗口 {} 不存在", label))?;
win_api::set_bounds(hwnd, x, y, w, h);
}
Ok(())
}
/// 启动原生拖动(右键长按触发) /// 启动原生拖动(右键长按触发)
/// ///
/// 同步关闭点击穿透(WS_EX_TRANSPARENT),然后在独立线程中调用 /// 同步关闭点击穿透(WS_EX_TRANSPARENT),然后在独立线程中调用
+84 -12
View File
@@ -1,6 +1,6 @@
//! Tauri 命令:快速面板模块 //! Tauri 命令:快速面板模块
use tauri::{AppHandle, Manager}; use tauri::{AppHandle, Emitter, Manager};
use super::popup::{self, QuickPanelSettings}; use super::popup::{self, QuickPanelSettings};
use super::{file_index, app_scanner, icon_extractor}; use super::{file_index, app_scanner, icon_extractor};
@@ -22,14 +22,17 @@ pub async fn quickpanel_get_settings(app: AppHandle) -> Result<QuickPanelSetting
Ok(popup::load_settings(&app)) Ok(popup::load_settings(&app))
} }
/// 保存快速面板设置;快捷键变化时自动重新注册 + 预创建窗口 /// 保存快速面板设置;快捷键变化时自动重新注册 + 预创建窗口
/// 索引目录变化时闲时自动重建索引。
#[tauri::command] #[tauri::command]
#[specta::specta] #[specta::specta]
pub async fn quickpanel_save_settings( pub async fn quickpanel_save_settings(
settings: QuickPanelSettings, settings: QuickPanelSettings,
app: AppHandle, app: AppHandle,
) -> Result<(), String> { ) -> Result<(), String> {
let prev_shortcut = popup::load_settings(&app).shortcut; let prev = popup::load_settings(&app);
let prev_shortcut = prev.shortcut.clone();
let dirs_changed = prev.index_dirs != settings.index_dirs;
popup::save_settings(&app, &settings)?; popup::save_settings(&app, &settings)?;
// 快捷键变化时重新注册(共享工具模块,原子化 + 冲突检测) // 快捷键变化时重新注册(共享工具模块,原子化 + 冲突检测)
if settings.shortcut != prev_shortcut { if settings.shortcut != prev_shortcut {
@@ -41,6 +44,10 @@ pub async fn quickpanel_save_settings(
popup::ensure_window(&app); popup::ensure_window(&app);
} }
} }
// 索引目录变更:闲时自动重建(新增/移除路径后无需手动点"构建索引")
if dirs_changed {
schedule_auto_build(app, true);
}
Ok(()) Ok(())
} }
@@ -125,33 +132,98 @@ pub fn quickpanel_lock_screen() -> Result<(), String> {
Ok(()) Ok(())
} }
/// 闲时自动建立索引的启动延迟(秒):避开应用启动/模块加载的 IO 高峰
const AUTO_BUILD_START_DELAY: u64 = 6;
/// 判定"系统空闲"的阈值(毫秒):用户停止输入超过该时长才执行构建
const AUTO_BUILD_IDLE_MS: u64 = 3000;
/// 等待系统空闲的最长轮询次数(每次间隔 2s,约 60s 上限,超时后不再等待直接构建)
const AUTO_BUILD_MAX_WAIT_ITERS: u32 = 30;
/// 解析要索引的目录:设置为空时用默认(桌面/文档/下载)
fn resolve_index_dirs(app: &AppHandle) -> Vec<String> {
let settings = popup::load_settings(app);
if settings.index_dirs.is_empty() {
popup::QuickPanelSettings::default().index_dirs
} else {
settings.index_dirs
}
}
/// 闲时自动建立/重建文件索引。
/// - `force=false`:仅首次(从未构建过)自动建立
/// - `force=true`:忽略是否已构建,直接重建(索引目录变更后调用)
///
/// 流程:先延迟避开启动 IO 高峰,再轮询等待系统空闲(用户停止输入),
/// 空闲后才开始构建,避免与应用运行/用户操作抢 IO 导致卡顿。
/// 构建完成后向前端广播 `quickpanel-index-updated` 事件(负载为条目数),
/// 供设置页刷新统计、弹窗启用文件搜索。
fn schedule_auto_build(app: AppHandle, force: bool) {
tauri::async_runtime::spawn(async move {
// 1. 启动延迟:避开应用启动、模块加载等 IO 高峰
tokio::time::sleep(std::time::Duration::from_secs(AUTO_BUILD_START_DELAY)).await;
// 2. 轮询等待系统空闲(判定阈值见 AUTO_BUILD_IDLE_MS
for _ in 0..AUTO_BUILD_MAX_WAIT_ITERS {
if crate::win32_util::get_idle_time_ms() >= AUTO_BUILD_IDLE_MS {
break;
}
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
}
// 3. 非强制模式:已构建过则跳过(幂等,避免每次启动重建)
if !force {
file_index::ensure_initialized(&app);
let stats = file_index::stats();
if stats.last_built_at > 0 && !stats.last_built_dirs.is_empty() {
return;
}
}
// 4. 构建(build_index 内部有并发保护,重复调度/手动构建并发时幂等跳过)
let dirs = resolve_index_dirs(&app);
match tauri::async_runtime::spawn_blocking(move || file_index::build_index(&dirs)).await {
Ok(Ok(count)) => {
crate::logger::log_info(
"quickpanel",
&format!("闲时自动索引完成,共 {}", count),
);
let _ = app.emit(crate::constants::events::QUICKPANEL_INDEX_UPDATED, count);
}
_ => {
crate::logger::log_error("quickpanel", "闲时自动索引失败");
}
}
});
}
/// 初始化文件索引数据库(应用启动时调用)。 /// 初始化文件索引数据库(应用启动时调用)。
/// 若存在上次构建的索引(last_built_dirs 非空),自动恢复 notify 增量监听, /// 若存在上次构建的索引(last_built_dirs 非空),自动恢复 notify 增量监听,
/// 无需重建即可继续自动同步文件变更。 /// 无需重建即可继续自动同步文件变更。
/// 若从未构建过(首次运行),闲时自动建立索引,无需用户手动点"构建索引"。
#[tauri::command] #[tauri::command]
#[specta::specta] #[specta::specta]
pub async fn quickpanel_init_file_index(app: AppHandle) -> Result<(), String> { pub async fn quickpanel_init_file_index(app: AppHandle) -> Result<(), String> {
tauri::async_runtime::spawn_blocking(move || { let app_for_build = app.clone();
file_index::init(&app); let need_auto_build = tauri::async_runtime::spawn_blocking(move || {
file_index::init(&app_for_build);
let stats = file_index::stats(); let stats = file_index::stats();
if stats.last_built_at > 0 && !stats.last_built_dirs.is_empty() { if stats.last_built_at > 0 && !stats.last_built_dirs.is_empty() {
file_index::start_watcher(&stats.last_built_dirs); file_index::start_watcher(&stats.last_built_dirs);
} }
// 从未构建过索引 → 需要闲时自动建立
stats.last_built_at == 0 || stats.last_built_dirs.is_empty()
}) })
.await .await
.map_err(|e| format!("索引初始化任务失败: {}", e)) .map_err(|e| format!("索引初始化任务失败: {}", e))?;
if need_auto_build {
schedule_auto_build(app, false);
}
Ok(())
} }
/// 构建文件索引(全量重建,阻塞操作建议在 spawn_blocking 调用) /// 构建文件索引(全量重建,阻塞操作建议在 spawn_blocking 调用)
#[tauri::command] #[tauri::command]
#[specta::specta] #[specta::specta]
pub async fn quickpanel_build_file_index(app: AppHandle) -> Result<i64, String> { pub async fn quickpanel_build_file_index(app: AppHandle) -> Result<i64, String> {
let settings = popup::load_settings(&app); let dirs = resolve_index_dirs(&app);
let dirs = if settings.index_dirs.is_empty() {
popup::QuickPanelSettings::default().index_dirs
} else {
settings.index_dirs
};
// 懒加载:首次构建时自动初始化 DB 连接 // 懒加载:首次构建时自动初始化 DB 连接
file_index::ensure_initialized(&app); file_index::ensure_initialized(&app);
// 阻塞操作放到 spawn_blocking // 阻塞操作放到 spawn_blocking
+28
View File
@@ -6,6 +6,7 @@
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::Mutex; use std::sync::Mutex;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::UNIX_EPOCH; use std::time::UNIX_EPOCH;
use rusqlite::{params, Connection}; use rusqlite::{params, Connection};
@@ -118,10 +119,37 @@ where
None None
} }
/// 构建锁:防止手动构建与闲时自动构建并发执行(全量重建含 DELETE+INSERT
/// 两个构建交错会互相清空对方刚写入的数据,导致索引残缺)。
static BUILDING: AtomicBool = AtomicBool::new(false);
/// 尝试开始构建;已在构建中则返回 false。
pub fn try_begin_build() -> bool {
BUILDING
.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
.is_ok()
}
/// 构建结束(成功/失败)后调用,释放构建锁。
pub fn end_build() {
BUILDING.store(false, Ordering::SeqCst);
}
/// 遍历指定目录列表建立索引(全量重建)。 /// 遍历指定目录列表建立索引(全量重建)。
/// 返回索引条目数。在 spawn_blocking 中调用。 /// 返回索引条目数。在 spawn_blocking 中调用。
/// 重建完成后自动启动 notify 监听器做增量更新。 /// 重建完成后自动启动 notify 监听器做增量更新。
/// 若已有构建正在进行(手动/自动并发),直接返回 Ok(0),由进行中的构建负责更新索引。
pub fn build_index(dirs: &[String]) -> Result<i64, String> { pub fn build_index(dirs: &[String]) -> Result<i64, String> {
if !try_begin_build() {
crate::logger::log_info("quickpanel", "索引构建已在进行中,跳过本次请求");
return Ok(0);
}
let result = build_index_inner(dirs);
end_build();
result
}
fn build_index_inner(dirs: &[String]) -> Result<i64, String> {
// 清空旧数据 // 清空旧数据
let cleared = with_conn(|conn| { let cleared = with_conn(|conn| {
conn.execute("DELETE FROM files", []).ok() conn.execute("DELETE FROM files", []).ok()
+64 -6
View File
@@ -13,6 +13,7 @@
use std::sync::Mutex; use std::sync::Mutex;
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, Ordering};
use std::path::PathBuf; use std::path::PathBuf;
use std::time::{Duration, Instant};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use tauri::{AppHandle, Emitter, Manager, WebviewUrl, WebviewWindowBuilder}; use tauri::{AppHandle, Emitter, Manager, WebviewUrl, WebviewWindowBuilder};
@@ -57,6 +58,48 @@ static POPUP_PENDING_SHOW: AtomicBool = AtomicBool::new(false);
/// 避免窗口重建后仍停留在屏幕外 (-10000, -10000)。 /// 避免窗口重建后仍停留在屏幕外 (-10000, -10000)。
static PENDING_POS: Mutex<Option<(f64, f64)>> = Mutex::new(None); static PENDING_POS: Mutex<Option<(f64, f64)>> = Mutex::new(None);
/// 失焦隐藏的宽限期:show 后窗口激活期间焦点可能短暂弹跳(透明 + focus:false 的
/// WebView2 窗口在透明激活时尤其容易出现),导致 Focused(false) 紧跟在 show 之后
/// 触发并把刚显示的窗口立即隐藏。距上次 show 不足该时长的失焦事件直接忽略。
const SHOW_GRACE: Duration = Duration::from_millis(500);
/// 最近一次 show 的时间,用于失焦宽限期判断。
static LAST_SHOWN: Mutex<Option<Instant>> = Mutex::new(None);
/// 标记"已发起显示",并记录时间供失焦宽限期使用。
fn mark_shown() {
if let Ok(mut t) = LAST_SHOWN.lock() {
*t = Some(Instant::now());
}
}
/// 判断距上次 show 是否仍在宽限期内(是则忽略失焦自动隐藏)。
fn within_show_grace() -> bool {
LAST_SHOWN
.lock()
.ok()
.and_then(|t| *t)
.map(|t| t.elapsed() < SHOW_GRACE)
.unwrap_or(false)
}
/// 显示窗口并确保其到达前台。
/// set_focus 受 Windows 前台锁定限制,可能静默失败;此时用 win32_util::force_foreground
/// (模拟 Alt 释放重置前台锁定 + SetForegroundWindow + BringWindowToTop)兜底。
fn show_and_focus(win: &tauri::WebviewWindow) {
if let Err(e) = win.show() {
crate::logger::log_error("quickpanel", &format!("show popup failed: {}", e));
}
mark_shown();
let focused = win.set_focus();
if focused.is_err() {
// set_focus 被前台锁定拒绝时,退回到强制置前(与主窗口焦点命令同法)
if let Ok(hwnd) = win.hwnd() {
crate::win32_util::force_foreground(hwnd.0 as isize);
}
}
}
/// 自定义命令 /// 自定义命令
#[derive(Clone, Serialize, Deserialize, Type)] #[derive(Clone, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
@@ -184,10 +227,14 @@ fn create_popup_window(app: &AppHandle) {
}; };
// 监听窗口失焦:自动隐藏 // 监听窗口失焦:自动隐藏
// 距上次 show 不足宽限期(激活中焦点弹跳)的失焦事件忽略,避免弹窗刚显示就被隐藏
let app_handle = app.clone(); let app_handle = app.clone();
let win_handle = win.clone(); let win_handle = win.clone();
win.on_window_event(move |event| { win.on_window_event(move |event| {
if let tauri::WindowEvent::Focused(false) = event { if let tauri::WindowEvent::Focused(false) = event {
if within_show_grace() {
return;
}
let _ = win_handle.hide(); let _ = win_handle.hide();
let _ = app_handle.emit(crate::constants::events::QUICKPANEL_HIDE, ()); let _ = app_handle.emit(crate::constants::events::QUICKPANEL_HIDE, ());
} }
@@ -209,6 +256,7 @@ pub fn ensure_window(app: &AppHandle) {
/// popup_position = "cursor" 时在鼠标位置附近显示,否则在鼠标所在显示器中央显示。 /// popup_position = "cursor" 时在鼠标位置附近显示,否则在鼠标所在显示器中央显示。
/// 窗口不存在则创建(隐藏状态,等前端挂载后调用 show_window 显示)。 /// 窗口不存在则创建(隐藏状态,等前端挂载后调用 show_window 显示)。
pub fn show_popup(app: &AppHandle) { pub fn show_popup(app: &AppHandle) {
crate::logger::log_info("quickpanel", "show_popup triggered");
let settings = load_settings(app); let settings = load_settings(app);
let cursor_mode = settings.popup_position == "cursor"; let cursor_mode = settings.popup_position == "cursor";
@@ -253,8 +301,7 @@ pub fn show_popup(app: &AppHandle) {
x: x as i32, x: x as i32,
y: y as i32, y: y as i32,
})); }));
let _ = win.show(); show_and_focus(&win);
let _ = win.set_focus();
return; return;
} }
@@ -267,12 +314,24 @@ pub fn show_popup(app: &AppHandle) {
} }
/// 显示已创建的弹窗窗口(由前端 onMounted 后调用)。 /// 显示已创建的弹窗窗口(由前端 onMounted 后调用)。
/// 预创建路径下前端 onMounted 也会调用此函数,但 POPUP_PENDING_SHOW 为 false 时直接跳过, ///
/// 避免应用启动时弹窗自动弹出。仅 show_popup 兜底创建路径才真正显示。 /// 两个路径:
/// 1. 预创建路径(POPUP_PENDING_SHOW = false):show_popup 已调用 show_and_focus 显示窗口,
/// 但若 Vue 尚未挂载,quickpanel-show 事件可能丢失。检查窗口是否可见,若可见则重新发送事件。
/// 2. 兜底创建路径(POPUP_PENDING_SHOW = true):窗口尚未显示,位置为 PENDING_POS
/// 先定位再发事件最后显示+聚焦。
pub fn show_window(app: &AppHandle) { pub fn show_window(app: &AppHandle) {
if !POPUP_PENDING_SHOW.swap(false, Ordering::SeqCst) { if !POPUP_PENDING_SHOW.swap(false, Ordering::SeqCst) {
// 预创建路径:窗口已由 show_popup 显示,但事件可能因前端未挂载而丢失。
// 窗口可见时重新发送事件,让刚注册的监听器处理(清空输入、聚焦、刷新等)。
if let Some(win) = app.get_webview_window(POPUP_LABEL) {
if win.is_visible().unwrap_or(false) {
emit_show(app);
}
}
return; return;
} }
// 兜底创建路径:show_popup 兜底重建,窗口尚未显示
if let Some(win) = app.get_webview_window(POPUP_LABEL) { if let Some(win) = app.get_webview_window(POPUP_LABEL) {
// 应用 show_popup 计算的兜底位置(物理坐标),避免停留在屏幕外 // 应用 show_popup 计算的兜底位置(物理坐标),避免停留在屏幕外
let pos = PENDING_POS.lock().ok().and_then(|p| *p); let pos = PENDING_POS.lock().ok().and_then(|p| *p);
@@ -284,8 +343,7 @@ pub fn show_window(app: &AppHandle) {
} }
// 同样先检测 Explorer 目录再显示,避免面板抢焦点导致检测失败。 // 同样先检测 Explorer 目录再显示,避免面板抢焦点导致检测失败。
emit_show(app); emit_show(app);
let _ = win.show(); show_and_focus(&win);
let _ = win.set_focus();
} }
} }
+17 -24
View File
@@ -3,7 +3,8 @@
//! 实现: //! 实现:
//! - 全屏(虚拟屏)捕获:BitBlt 从屏幕 DC 拷贝到兼容位图,GetDIBits 取像素 //! - 全屏(虚拟屏)捕获:BitBlt 从屏幕 DC 拷贝到兼容位图,GetDIBits 取像素
//! - 窗口捕获:PrintWindow(PW_RENDERFULLCONTENT) 捕获 DWM 内容(覆盖硬件加速窗口) //! - 窗口捕获:PrintWindow(PW_RENDERFULLCONTENT) 捕获 DWM 内容(覆盖硬件加速窗口)
//! - 窗口拾取:EnumWindows Z 序命中测试(排除本进程窗口,避免命中覆盖层自身) //! - 窗口拾取:pick_windows 枚举 Z 序窗口列表(排除本进程,避免命中覆盖层自身)
//! 前端缓存列表后本地命中测试
//! - 顶层窗口枚举:EnumWindows //! - 顶层窗口枚举:EnumWindows
//! - 像素 → PNG / CF_DIB 转换 //! - 像素 → PNG / CF_DIB 转换
//! //!
@@ -276,18 +277,16 @@ pub fn capture_window(hwnd: isize) -> Result<CapturedImage, String> {
} }
} }
/// 获取指定屏幕坐标下的顶层窗口(窗口拾取 /// 枚举可拾取的顶层窗口(Z 序顶→底
/// ///
/// 入参 x/y 为物理屏幕坐标(前端需按显示器 scaleFactor 从逻辑坐标换算) /// 与逐点拾取同语义:排除本进程窗口(覆盖层/主窗口/编辑器)、不可见窗口、工具窗口
/// /// 前端在截图开始时缓存该列表,鼠标移动时在 JS 侧做命中测试(rect 包含点,取 Z 序
/// 不能直接用 WindowFromPoint:覆盖层是 alwaysOnTop 全屏窗口,会命中覆盖层自身。 /// 最顶的第一个命中),消除逐帧 window_from_point 的 IPC 往返;且列表与冻结底图
/// 改为 EnumWindows 按 Z 序(顶→底)枚举顶层窗口做命中测试,并排除本进程 /// 同一时刻生成,命中结果与画面严格一致。
/// (覆盖层/主窗口/编辑器)的窗口,从而取到覆盖层下面的目标窗口。 pub fn pick_windows() -> Vec<WindowInfo> {
pub fn window_from_point(x: i32, y: i32) -> Option<WindowInfo> {
struct PickContext { struct PickContext {
my_pid: u32, my_pid: u32,
pt: POINT, out: Vec<WindowInfo>,
found: Option<WindowInfo>,
} }
extern "system" fn enum_proc(hwnd: HWND, lparam: isize) -> i32 { extern "system" fn enum_proc(hwnd: HWND, lparam: isize) -> i32 {
@@ -311,30 +310,24 @@ pub fn window_from_point(x: i32, y: i32) -> Option<WindowInfo> {
if GetWindowRect(hwnd, &mut rect) == 0 { if GetWindowRect(hwnd, &mut rect) == 0 {
return 1; return 1;
} }
// 命中测试(物理坐标),Z 序最顶层的第一个命中即为目标 ctx.out.push(WindowInfo {
let pt = ctx.pt; hwnd,
if pt.x >= rect.left && pt.x < rect.right && pt.y >= rect.top && pt.y < rect.bottom { title: get_window_title(hwnd),
ctx.found = Some(WindowInfo { rect: ScreenRect::from(rect),
hwnd, visual_rect: extended_frame_bounds(hwnd),
title: get_window_title(hwnd), });
rect: ScreenRect::from(rect),
visual_rect: extended_frame_bounds(hwnd),
});
return 0; // 停止枚举
}
} }
1 1
} }
let mut ctx = PickContext { let mut ctx = PickContext {
my_pid: std::process::id(), my_pid: std::process::id(),
pt: POINT { x, y }, out: Vec::new(),
found: None,
}; };
unsafe { unsafe {
EnumWindows(Some(enum_proc), &mut ctx as *mut _ as isize); EnumWindows(Some(enum_proc), &mut ctx as *mut _ as isize);
} }
ctx.found ctx.out
} }
/// 获取当前鼠标物理屏幕坐标(覆盖层打开时定位初始悬停窗口) /// 获取当前鼠标物理屏幕坐标(覆盖层打开时定位初始悬停窗口)
+49 -19
View File
@@ -1,12 +1,13 @@
//! Tauri 命令:截图模块 //! Tauri 命令:截图模块
//! //!
//! 命令清单: //! 命令清单:
//! - screenshot_capture_fullscreen:捕获虚拟屏并存入静态(不做 PNG 编码) //! - screenshot_capture_fullscreen:捕获虚拟屏并存入静态(不做 PNG 编码),返回捕获时刻光标坐标
//! - screenshot_get_fullscreen_bmp:取出全屏捕获的 BMP 原始字节(raw IPC,覆盖层显示用,不移除) //! - screenshot_get_fullscreen_bmp:取出全屏捕获的 BMP 原始字节(raw IPC,覆盖层显示用,不移除)
//! - screenshot_fullscreen_png:全屏捕获编码 PNG base64 并清除(全屏截图进编辑器用) //! - screenshot_fullscreen_png:全屏捕获编码 PNG base64 并清除(全屏截图进编辑器用)
//! - screenshot_clear_fullscreen:清除静态全屏捕获(覆盖层关闭时) //! - screenshot_clear_fullscreen:清除静态全屏捕获(覆盖层关闭时)
//! - screenshot_crop_stored:按物理像素裁剪已存储的全屏捕获 //! - screenshot_crop_stored:按物理像素裁剪已存储的全屏捕获
//! - screenshot_window_from_point:拾取指定屏幕坐标下的顶层窗口 //! - screenshot_pick_list:枚举可拾取顶层窗口(Z 序,前端缓存后本地命中测试)
//! - screenshot_show_overlay:一次 IPC 完成覆盖层 show + focus(关键路径减少往返)
//! - screenshot_enum_windows:枚举可见顶层窗口 //! - screenshot_enum_windows:枚举可见顶层窗口
//! - screenshot_capture_window:按 hwnd 捕获指定窗口 //! - screenshot_capture_window:按 hwnd 捕获指定窗口
//! - screenshot_set_editor_image / screenshot_get_editor_image:编辑器图片传递 //! - screenshot_set_editor_image / screenshot_get_editor_image:编辑器图片传递
@@ -14,7 +15,7 @@
//! - screenshot_save_png:写入文件 //! - screenshot_save_png:写入文件
//! - screenshot_disable_transitions:禁用窗口显示/隐藏过渡动画(消除覆盖层缩放动画) //! - screenshot_disable_transitions:禁用窗口显示/隐藏过渡动画(消除覆盖层缩放动画)
use super::{CaptureData, WindowInfo}; use super::{CaptureData, CaptureStart, WindowInfo};
use tauri::{Emitter, Manager}; use tauri::{Emitter, Manager};
/// 禁用指定窗口(按 label 查找)的显示/隐藏过渡动画,消除覆盖层出现/消失时的缩放动画 /// 禁用指定窗口(按 label 查找)的显示/隐藏过渡动画,消除覆盖层出现/消失时的缩放动画
@@ -88,16 +89,19 @@ pub async fn screenshot_unregister_pin_shortcut(app: tauri::AppHandle) -> Result
Ok(()) Ok(())
} }
/// 捕获整个虚拟屏(多显示器拼接)存入静态,不做 PNG 编码 /// 捕获整个虚拟屏(多显示器拼接)存入静态,不做 PNG 编码
/// 同时返回捕获时刻的光标物理坐标(覆盖层据此做初始窗口拾取,省一次 IPC 往返)。
#[tauri::command] #[tauri::command]
#[specta::specta] #[specta::specta]
pub async fn screenshot_capture_fullscreen() -> Result<(), String> { pub async fn screenshot_capture_fullscreen() -> Result<CaptureStart, String> {
#[cfg(windows)] #[cfg(windows)]
{ {
// 屏幕捕获涉及 GDI 调用,放线程池避免阻塞 async 调度 // 屏幕捕获涉及 GDI 调用,放线程池避免阻塞 async 调度
tauri::async_runtime::spawn_blocking(|| { tauri::async_runtime::spawn_blocking(|| {
let img = super::capture::capture_virtual_screen()?; let img = super::capture::capture_virtual_screen()?;
super::capture::store_fullscreen(img) super::capture::store_fullscreen(img)?;
let (cursor_x, cursor_y) = super::capture::cursor_pos().unwrap_or((0, 0));
Ok(CaptureStart { cursor_x, cursor_y })
}) })
.await .await
.map_err(|e| format!("捕获任务失败: {}", e))? .map_err(|e| format!("捕获任务失败: {}", e))?
@@ -205,29 +209,39 @@ pub async fn screenshot_crop_copy_stored(
} }
} }
/// 拾取指定物理屏幕坐标下的顶层窗口 /// 枚举可拾取的顶层窗口(Z 序顶→底,排除本进程/不可见/工具窗口)。
/// 前端在截图开始时缓存列表,鼠标移动时在 JS 侧本地命中测试,消除逐帧 IPC 往返。
#[tauri::command] #[tauri::command]
#[specta::specta] #[specta::specta]
pub async fn screenshot_window_from_point( pub async fn screenshot_pick_list() -> Result<Vec<WindowInfo>, String> {
x: i32,
y: i32,
) -> Result<Option<WindowInfo>, String> {
#[cfg(windows)] #[cfg(windows)]
{ {
tauri::async_runtime::spawn_blocking(move || { tauri::async_runtime::spawn_blocking(super::capture::pick_windows)
Ok(super::capture::window_from_point(x, y)) .await
}) .map_err(|e| format!("枚举失败: {}", e))
.await
.map_err(|e| format!("查询失败: {}", e))?
} }
#[cfg(not(windows))] #[cfg(not(windows))]
{ {
let _ = (x, y); Ok(vec![])
Ok(None)
} }
} }
/// 获取当前鼠标物理屏幕坐标(覆盖层打开时定位初始悬停窗口 /// 一次 IPC 完成指定窗口的显示与聚焦(截图覆盖层显示关键路径,减少串行往返
#[tauri::command]
#[specta::specta]
pub async fn screenshot_show_overlay(
app: tauri::AppHandle,
label: String,
) -> Result<(), String> {
let win = app
.get_webview_window(&label)
.ok_or_else(|| format!("窗口不存在: {}", label))?;
win.show().map_err(|e| format!("显示窗口失败: {}", e))?;
win.set_focus().map_err(|e| format!("聚焦窗口失败: {}", e))?;
Ok(())
}
/// 获取当前鼠标物理屏幕坐标(贴图窗口拖动跟随等场景使用)
#[tauri::command] #[tauri::command]
#[specta::specta] #[specta::specta]
pub async fn screenshot_cursor_pos() -> Result<(i32, i32), String> { pub async fn screenshot_cursor_pos() -> Result<(i32, i32), String> {
@@ -433,6 +447,22 @@ pub async fn screenshot_load_cache(
.map_err(|e| format!("读取缓存任务失败: {}", e))? .map_err(|e| format!("读取缓存任务失败: {}", e))?
} }
/// 读取历史缓存 PNG 原始字节(raw IPC → 前端 ArrayBuffer,贴图窗口显示用:
/// 跳过 base64 编码,IPC 传输与前端内存占用均省 ~33%;同 get_fullscreen_bmp 豁免 specta
#[tauri::command]
pub async fn screenshot_load_cache_raw(
app: tauri::AppHandle,
path: String,
) -> Result<tauri::ipc::Response, String> {
tauri::async_runtime::spawn_blocking(move || {
let p = ensure_in_history_dir(&app, &path)?;
let bytes = std::fs::read(&p).map_err(|e| format!("读取缓存失败: {}", e))?;
Ok(tauri::ipc::Response::new(bytes))
})
.await
.map_err(|e| format!("读取缓存任务失败: {}", e))?
}
/// 删除历史缓存文件(历史项移除/清空时调用,静默忽略不存在文件) /// 删除历史缓存文件(历史项移除/清空时调用,静默忽略不存在文件)
#[tauri::command] #[tauri::command]
#[specta::specta] #[specta::specta]
+8
View File
@@ -20,6 +20,14 @@ pub struct CaptureData {
pub height: i32, pub height: i32,
} }
/// 截图启动信息:捕获时刻的光标物理坐标(覆盖层据此做初始窗口拾取,省一次 IPC 往返)
#[derive(serde::Serialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct CaptureStart {
pub cursor_x: i32,
pub cursor_y: i32,
}
/// 窗口信息(窗口拾取 / 枚举) /// 窗口信息(窗口拾取 / 枚举)
#[derive(serde::Serialize, Type)] #[derive(serde::Serialize, Type)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
+5
View File
@@ -88,6 +88,8 @@ pub fn init(app: &mut App<Wry>) -> Result<(), Box<dyn std::error::Error>> {
} }
// 预创建弹窗窗口(隐藏),首次按快捷键时直接 show,避免首次创建时序问题 // 预创建弹窗窗口(隐藏),首次按快捷键时直接 show,避免首次创建时序问题
crate::clipboard::popup::ensure_popup_window(&app_handle); crate::clipboard::popup::ensure_popup_window(&app_handle);
// 同时预创建独立预览窗口(隐藏),弹窗悬停条目时直接显示
crate::clipboard::popup::ensure_preview_window(&app_handle);
} }
app.manage(clipboard); app.manage(clipboard);
@@ -116,6 +118,9 @@ pub fn init(app: &mut App<Wry>) -> Result<(), Box<dyn std::error::Error>> {
// ===== 进程监控线程 ===== // ===== 进程监控线程 =====
start_monitoring_thread(app.handle().clone()); start_monitoring_thread(app.handle().clone());
// ===== 代理:自动切换节点后台调度(独立于模块激活状态) =====
crate::mihomo_manager::start_auto_switch_loop(app.handle().clone());
// ===== 自动启动(随应用启动,不依赖模块启用) ===== // ===== 自动启动(随应用启动,不依赖模块启用) =====
// mihomo:用户在设置中开启"自动启动"时随应用启动 // mihomo:用户在设置中开启"自动启动"时随应用启动
if let Some(mihomo) = app.try_state::<MihomoManager>() { if let Some(mihomo) = app.try_state::<MihomoManager>() {
+76 -64
View File
@@ -308,21 +308,26 @@ pub fn show_tray_menu(app: &AppHandle, cursor_pos: (f64, f64)) {
precreate_tray_menu_window(app); precreate_tray_menu_window(app);
} }
// 发送状态给前端(前端测量内容高度后调用 tray_menu_ready 显示窗口)。 // 1) 立即发送基础状态(不含代理节点,纯本地查询),菜单秒开——
// 代理节点拉取默认最长 10s;这里用 1.5s 短超时兜底,避免 mihomo API 卡死时 // 前端测量内容高度后调用 tray_menu_ready 显示窗口。
// 菜单迟迟不出现(超时则退回基础状态,节点区留空,后续 refresh 可补)。 let base = get_base_tray_state(app);
let _ = app.emit(crate::constants::events::TRAY_MENU_SHOW, base);
// 2) 异步拉取完整状态(含 mihomo /proxies 节点),到达后经 TRAY_MENU_STATE_UPDATED
// 补充给前端(前端在窗口可见时重新测量调整尺寸)。
// mihomo API 慢/挂起时不再阻塞菜单显示;超时 5s 放弃(保持基础状态)。
let app_clone = app.clone(); let app_clone = app.clone();
tauri::async_runtime::spawn(async move { tauri::async_runtime::spawn(async move {
let state = match tokio::time::timeout( let state = match tokio::time::timeout(
Duration::from_millis(1500), Duration::from_millis(5000),
get_full_tray_state(&app_clone), get_full_tray_state(&app_clone),
) )
.await .await
{ {
Ok(s) => s, Ok(s) => s,
Err(_) => get_base_tray_state(&app_clone), Err(_) => return,
}; };
let _ = app_clone.emit(crate::constants::events::TRAY_MENU_SHOW, state); let _ = app_clone.emit(crate::constants::events::TRAY_MENU_STATE_UPDATED, state);
}); });
} }
@@ -432,9 +437,20 @@ pub async fn tray_menu_action(
_ => {} _ => {}
} }
// 刷新状态并发送给前端(quit 除外,quit 后进程已退出) // 按动作类型选择性刷新,避免无关动作(OSD 开关/跳转设置)也全量请求 mihomo API:
if action != "quit" { // - 代理相关动作:全量刷新(节点列表/延迟可能已变化)
refresh_and_emit_state(&app).await; // - Kernel/OSD:仅刷新本地基础状态(monitor 运行状态)
// - 跳转/退出:不刷新(quit 后进程已退出)
match action.as_str() {
"proxy_enable" | "proxy_disable" | "proxy_refresh" | "proxy_select_node"
| "system_proxy_toggle" => {
refresh_and_emit_state(&app).await;
}
"kernel_restart" | "osd_toggle" => {
let state = get_base_tray_state(&app);
let _ = app.emit(crate::constants::events::TRAY_MENU_STATE_UPDATED, state);
}
_ => {}
} }
Ok(()) Ok(())
@@ -480,6 +496,12 @@ pub async fn tray_menu_ready(content_height: f64, app: AppHandle) -> Result<(),
y: y as i32, y: y as i32,
}); });
let _ = win.set_position(pos); let _ = win.set_position(pos);
// 以实际显示时刻重置失焦防抖起点:右键到此处可能间隔较久(前端测量 + 基础状态),
// 若沿用右键时刻,WebView2 显示瞬间的焦点抖动会被误判为失焦而立即隐藏菜单。
{
let mut t = LAST_SHOW_TIME.lock().unwrap_or_else(|e| e.into_inner());
*t = Some(Instant::now());
}
let _ = win.show(); let _ = win.show();
let _ = win.set_focus(); let _ = win.set_focus();
@@ -510,33 +532,57 @@ async fn enable_proxy(app: &AppHandle) -> Result<(), String> {
} }
} }
// 2. 获取节点列表 // 2. 获取节点并择优:遵循自动切换的目标组与地区筛选(关闭自动切换时退化为主组 + 全量节点)
let proxies = mihomo.get_proxies().await?; let settings = mihomo.load_settings();
let (group, nodes, _now) = parse_main_group(&proxies)
.ok_or_else(|| "无法解析代理组".to_string())?;
if !nodes.is_empty() { match crate::mihomo_manager::pick_best(&mihomo, &settings).await {
// 3. 并行测试所有节点延迟 Ok(Some((group, name, delay, now))) => {
let best = test_and_select_best(&mihomo, &group, &nodes).await; // 与自动切换一样,仅当当前节点不是最优时才切换
if name != now {
// 4. 发送通知 let _ = mihomo.select_proxy(&group, &name).await;
match &best {
Some((name, delay)) => {
send_notification(
app,
"代理已开启",
&format!("当前节点: {} ({}ms)", name, delay),
);
} }
None => { send_notification(app, "代理已开启", &format!("当前节点: {} ({}ms)", name, delay));
send_notification(app, "代理已开启", "所有节点均超时,未自动选择"); }
Ok(None) | Err(_) => {
// 区分「无可用节点」与「全部超时」两种情况
let unavailable = {
let proxies = mihomo.get_proxies().await.ok();
let group_name: Option<String> = if !settings.auto_switch_group.is_empty() {
Some(settings.auto_switch_group.clone())
} else {
proxies
.as_ref()
.and_then(|p| p.get("proxies"))
.and_then(|m| m.as_object())
.and_then(|map| {
let mut names = map.iter().filter(|(_, v)| {
v.get("type").and_then(|t| t.as_str()) == Some("Selector")
});
names.next().map(|(n, _)| n.clone())
})
};
let empty = group_name.map(|g| {
proxies
.as_ref()
.and_then(|p| p.get("proxies"))
.and_then(|m| m.as_object())
.and_then(|map| map.get(&g))
.and_then(|v| v.get("all"))
.and_then(|v| v.as_array())
.map(|a| a.is_empty())
.unwrap_or(true)
}).unwrap_or(true);
empty
};
if unavailable {
send_notification(app, "代理已开启", "无可用节点");
} else {
send_notification(app, "代理已开启", "所有候选节点均超时,未自动选择");
} }
} }
} else {
send_notification(app, "代理已开启", "无可用节点");
} }
// 5. 开启系统代理 // 3. 开启系统代理
mihomo.enable_system_proxy()?; mihomo.enable_system_proxy()?;
Ok(()) Ok(())
@@ -559,40 +605,6 @@ async fn disable_proxy(app: &AppHandle) -> Result<(), String> {
// ===== 择优选择节点 ===== // ===== 择优选择节点 =====
/// 并行测试所有节点延迟,选择最低延迟节点
async fn test_and_select_best(
mihomo: &MihomoManager,
group: &str,
nodes: &[String],
) -> Option<(String, u32)> {
use futures_util::future::join_all;
let futures: Vec<_> = nodes
.iter()
.map(|name| async move {
let delay = mihomo
.test_delay(name, "https://www.gstatic.com/generate_204", 5000)
.await
.ok();
(name.clone(), delay)
})
.collect();
let results = join_all(futures).await;
let best = results
.into_iter()
.filter_map(|(name, delay)| delay.map(|d| (name, d)))
.filter(|(_, d)| *d > 0)
.min_by_key(|(_, d)| *d);
if let Some((ref name, _)) = best {
let _ = mihomo.select_proxy(group, name).await;
}
best
}
/// 并行测试所有节点延迟(更新 mihomo 内部 history /// 并行测试所有节点延迟(更新 mihomo 内部 history
async fn test_all_delays(app: &AppHandle) { async fn test_all_delays(app: &AppHandle) {
let mihomo = app.state::<MihomoManager>(); let mihomo = app.state::<MihomoManager>();
+49 -27
View File
@@ -129,11 +129,18 @@ async fn fetch_latest_release() -> Result<LatestRelease, String> {
/// 下载文件到 dest,期间通过 UPDATE_PROGRESS 事件上报进度 /// 下载文件到 dest,期间通过 UPDATE_PROGRESS 事件上报进度
async fn download_with_progress(app: &AppHandle, url: &str, dest: &Path) -> Result<(), String> { async fn download_with_progress(app: &AppHandle, url: &str, dest: &Path) -> Result<(), String> {
let client = reqwest::Client::new(); // 注意: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 let resp = client
.get(url) .get(url)
.header("User-Agent", "thing-app") .header("User-Agent", "thing-app")
.timeout(std::time::Duration::from_secs(30))
.send() .send()
.await .await
.map_err(|e| format!("下载请求失败: {}", e))?; .map_err(|e| format!("下载请求失败: {}", e))?;
@@ -324,34 +331,31 @@ pub async fn update_check(app: AppHandle) -> Result<UpdateCheckResult, String> {
}) })
} }
/// 更新应用本体 /// 更新应用本体(安装阶段)。下载由前端下载模块完成,本命令接收已下载的
/// 便携版:下载 thing_{v}_x64.exe → update.bat 覆盖重启; /// 安装包路径(便携版 thing_{v}_x64.exe / 安装版 thing_{v}_x64-setup.exe)。
/// 安装版:下载 thing_{v}_x64-setup.exe → 提权静默安装 /S。 /// 便携版:copy 到临时目录 → update.bat 覆盖重启;
/// 下载进度通过 UPDATE_PROGRESS 事件上报,调用方返回前会触发应用退出 /// 安装版:copy 到临时目录 → 提权静默安装 /S
/// 调用返回前会触发应用退出。
#[tauri::command] #[tauri::command]
#[specta::specta] #[specta::specta]
pub async fn update_install(app: AppHandle) -> Result<(), String> { pub async fn update_install(app: AppHandle, downloaded_path: String) -> Result<(), String> {
let latest = fetch_latest_release().await?; let src = PathBuf::from(&downloaded_path);
if !src.exists() {
return Err(format!("下载文件不存在: {}", downloaded_path));
}
let installed = is_installed_version(); let installed = is_installed_version();
let (target_name, target_url) = if installed { // copy 到临时目录:与 update.bat / 安装器解耦,随后即可删除下载目录中的源文件
latest
.assets
.iter()
.find(|a| a.name.ends_with("-setup.exe"))
.map(|a| (a.name.clone(), a.browser_download_url.clone()))
.ok_or("未在 release 中找到安装包 (setup.exe)".to_string())?
} else {
latest
.assets
.iter()
.find(|a| a.name.ends_with(".exe") && !a.name.contains("setup"))
.map(|a| (a.name.clone(), a.browser_download_url.clone()))
.ok_or("未在 release 中找到便携版程序 (thing.exe)".to_string())?
};
let temp_dir = std::env::temp_dir().join("thing-update"); let temp_dir = std::env::temp_dir().join("thing-update");
fs::create_dir_all(&temp_dir).map_err(|e| format!("创建临时目录失败: {}", e))?; fs::create_dir_all(&temp_dir).map_err(|e| format!("创建临时目录失败: {}", e))?;
let dest = temp_dir.join(&target_name); let file_name = src
download_with_progress(&app, &target_url, &dest).await?; .file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| "thing_update.exe".into());
let dest = temp_dir.join(&file_name);
fs::copy(&src, &dest).map_err(|e| format!("复制安装包到临时目录失败: {}", e))?;
// 临时副本就绪后清理下载目录中的源文件(失败不影响更新流程)
let _ = fs::remove_file(&src);
let _ = app.emit( let _ = app.emit(
UPDATE_PROGRESS, UPDATE_PROGRESS,
UpdateProgress { UpdateProgress {
@@ -377,6 +381,24 @@ pub async fn update_install(app: AppHandle) -> Result<(), String> {
#[tauri::command] #[tauri::command]
#[specta::specta] #[specta::specta]
pub async fn update_thinghk(app: AppHandle) -> Result<(), String> { pub async fn update_thinghk(app: AppHandle) -> Result<(), String> {
let result = update_thinghk_inner(&app).await;
if let Err(ref e) = result {
// 失败时 emit error 阶段,避免前端进度卡在最后状态无提示
let _ = app.emit(
UPDATE_PROGRESS,
UpdateProgress {
stage: "error".into(),
percent: 0,
downloaded_bytes: 0,
total_bytes: None,
message: e.clone(),
},
);
}
result
}
async fn update_thinghk_inner(app: &AppHandle) -> Result<(), String> {
let latest = fetch_latest_release().await?; let latest = fetch_latest_release().await?;
let asset = latest let asset = latest
.assets .assets
@@ -385,7 +407,7 @@ pub async fn update_thinghk(app: AppHandle) -> Result<(), String> {
.ok_or("未在 release 中找到 ThingHK 内核包".to_string())?; .ok_or("未在 release 中找到 ThingHK 内核包".to_string())?;
// 停止监控内核(含提权模式的 /shutdown 兜底由前端先停模块),避免 exe 被占用 // 停止监控内核(含提权模式的 /shutdown 兜底由前端先停模块),避免 exe 被占用
if let Some(monitor) = app.try_state::<crate::monitor_kernel::MonitorKernel>() { if let Some(monitor) = app.try_state::<crate::monitor_kernel::MonitorKernel>() {
monitor.stop_subscription(&app).await; monitor.stop_subscription(app).await;
} }
if let Some(pm) = app.try_state::<crate::process_manager::ProcessManager>() { if let Some(pm) = app.try_state::<crate::process_manager::ProcessManager>() {
let _ = pm.stop("monitor"); let _ = pm.stop("monitor");
@@ -395,7 +417,7 @@ pub async fn update_thinghk(app: AppHandle) -> Result<(), String> {
let temp_dir = std::env::temp_dir().join("thing-update"); let temp_dir = std::env::temp_dir().join("thing-update");
fs::create_dir_all(&temp_dir).map_err(|e| format!("创建临时目录失败: {}", e))?; fs::create_dir_all(&temp_dir).map_err(|e| format!("创建临时目录失败: {}", e))?;
let zip_path = temp_dir.join(&asset.name); let zip_path = temp_dir.join(&asset.name);
download_with_progress(&app, &asset.browser_download_url, &zip_path).await?; download_with_progress(app, &asset.browser_download_url, &zip_path).await?;
let _ = app.emit( let _ = app.emit(
UPDATE_PROGRESS, UPDATE_PROGRESS,
UpdateProgress { UpdateProgress {
+106
View File
@@ -123,6 +123,97 @@ pub fn force_foreground(hwnd: isize) {
} }
} }
/// 应用 WS_EX_NOACTIVATE | WS_EX_TOOLWINDOW 扩展样式到指定窗口。
/// 用于预览窗等不抢焦点的悬浮窗:窗口可接收鼠标交互但不激活、不进 Alt-Tab。
#[cfg(windows)]
pub fn apply_no_activate(hwnd: isize) {
use windows_sys::Win32::UI::WindowsAndMessaging::{
GetWindowLongPtrW, SetWindowLongPtrW, GWL_EXSTYLE, WS_EX_NOACTIVATE, WS_EX_TOOLWINDOW,
};
unsafe {
let ex = GetWindowLongPtrW(hwnd, GWL_EXSTYLE);
let new_ex = ex | (WS_EX_NOACTIVATE as isize) | (WS_EX_TOOLWINDOW as isize);
SetWindowLongPtrW(hwnd, GWL_EXSTYLE, new_ex);
}
}
/// 鼠标左键当前是否按下(GetAsyncKeyState,全局异步状态,无需窗口焦点)。
/// 供看护线程轮询检测"点击外部"(配合按下沿判定)。
#[cfg(windows)]
pub fn is_left_button_down() -> bool {
use windows_sys::Win32::UI::Input::KeyboardAndMouse::GetAsyncKeyState;
const VK_LBUTTON: i32 = 0x01;
((unsafe { GetAsyncKeyState(VK_LBUTTON) } as u32) & 0x8000) != 0
}
#[cfg(not(windows))]
pub fn is_left_button_down() -> bool {
false
}
/// 强制窗口应用系统圆角(DWMWCP_ROUNDWin11 约 8px)。
/// 带 WS_EX_NOACTIVATE 等扩展样式的悬浮窗系统不会自动圆角(呈现直角),
/// 与常规弹窗外观不一致;通过 DWMWA_WINDOW_CORNER_PREFERENCE 显式指定圆角偏好。
#[cfg(windows)]
pub fn apply_rounded_corners(hwnd: isize) {
use windows_sys::Win32::Graphics::Dwm::{
DwmSetWindowAttribute, DWMWA_WINDOW_CORNER_PREFERENCE, DWMWCP_ROUND,
};
unsafe {
let pref: i32 = DWMWCP_ROUND;
let _ = DwmSetWindowAttribute(
hwnd,
DWMWA_WINDOW_CORNER_PREFERENCE as u32,
&pref as *const i32 as *const core::ffi::c_void,
std::mem::size_of::<i32>() as u32,
);
}
}
/// 以不激活方式显示窗口(SW_SHOWNOACTIVATE),避免抢走弹窗等前台窗口的焦点。
#[cfg(windows)]
pub fn show_no_activate(hwnd: isize) {
use windows_sys::Win32::UI::WindowsAndMessaging::{ShowWindow, SW_SHOWNOACTIVATE};
unsafe {
ShowWindow(hwnd, SW_SHOWNOACTIVATE);
}
}
/// 隐藏窗口(SW_HIDE)。供以原生方式显示(SW_SHOWNOACTIVATE)的悬浮窗兜底隐藏,
/// 与 Tauri 的 hide() 并存以确保任何路径下都被可靠隐藏。
#[cfg(windows)]
pub fn hide_window(hwnd: isize) {
use windows_sys::Win32::UI::WindowsAndMessaging::{ShowWindow, SW_HIDE};
unsafe {
ShowWindow(hwnd, SW_HIDE);
}
}
/// 系统自上次用户输入(鼠标/键盘)以来的空闲时长(毫秒)。
/// 用于闲时执行耗时后台任务(如文件索引构建),避免与应用运行/用户操作抢 IO。
/// GetLastInputInfo 的 dwTime 与 GetTickCount 同为系统启动后毫秒数,用 wrapping 减法防回绕。
#[cfg(windows)]
pub fn get_idle_time_ms() -> u64 {
use windows_sys::Win32::System::SystemInformation::GetTickCount;
use windows_sys::Win32::UI::Input::KeyboardAndMouse::{
GetLastInputInfo, LASTINPUTINFO,
};
use std::mem::size_of;
let mut lii = LASTINPUTINFO {
cbSize: size_of::<LASTINPUTINFO>() as u32,
dwTime: 0,
};
unsafe {
if GetLastInputInfo(&mut lii) != 0 {
return GetTickCount().wrapping_sub(lii.dwTime) as u64;
}
}
0
}
// ===== 非 Windows 平台空实现 ===== // ===== 非 Windows 平台空实现 =====
#[cfg(not(windows))] #[cfg(not(windows))]
@@ -142,3 +233,18 @@ pub fn get_monitor_bounds_at_point(_x: i32, _y: i32) -> Option<(i32, i32, i32, i
#[cfg(not(windows))] #[cfg(not(windows))]
pub fn force_foreground(_hwnd: isize) {} pub fn force_foreground(_hwnd: isize) {}
#[cfg(not(windows))]
pub fn apply_no_activate(_hwnd: isize) {}
#[cfg(not(windows))]
pub fn apply_rounded_corners(_hwnd: isize) {}
#[cfg(not(windows))]
pub fn show_no_activate(_hwnd: isize) {}
#[cfg(not(windows))]
pub fn hide_window(_hwnd: isize) {}
#[cfg(not(windows))]
pub fn get_idle_time_ms() -> u64 { 0 }
+2 -2
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "thing", "productName": "thing",
"version": "26.8.1", "version": "26.8.4",
"identifier": "thing.lfeng.me", "identifier": "thing.lfeng.me",
"build": { "build": {
"beforeDevCommand": "bun run dev", "beforeDevCommand": "bun run dev",
@@ -19,7 +19,7 @@
"transparent": true, "transparent": true,
"visible": false, "visible": false,
"windowEffects": { "windowEffects": {
"effects": ["acrylic", "mica"] "effects": ["mica", "acrylic"]
} }
} }
], ],
+65 -15
View File
@@ -14,8 +14,8 @@ import { useProcessStore } from '@/stores/processStore'
import { TooltipProvider } from '@/components/ui/tooltip' import { TooltipProvider } from '@/components/ui/tooltip'
import { moduleRegistry } from '@/modules/registry' import { moduleRegistry } from '@/modules/registry'
import type { ModuleMeta } from '@/types/module' import type { ModuleMeta } from '@/types/module'
import { pendingNewDownload, pendingShowDownloadTasks } from '@/lib/trayEvents' import { pendingNewDownload } from '@/lib/trayEvents'
import { EVENTS, STORAGE_KEYS } from '@/lib/constants' import { EVENTS, STORAGE_KEYS, WINDOWS } from '@/lib/constants'
import { commands } from '@/lib/bindings' import { commands } from '@/lib/bindings'
const appStore = useAppStore() const appStore = useAppStore()
@@ -87,6 +87,9 @@ const loadModule = async (moduleId: string) => {
} }
const handleModuleChange = (moduleId: string) => { const handleModuleChange = (moduleId: string) => {
// / tab
if (activeModule.value === moduleId) return
// onDeactivate // onDeactivate
const prevConfig = moduleRegistry.getConfig(activeModule.value) const prevConfig = moduleRegistry.getConfig(activeModule.value)
prevConfig?.lifecycle?.onDeactivate?.() prevConfig?.lifecycle?.onDeactivate?.()
@@ -96,10 +99,59 @@ const handleModuleChange = (moduleId: string) => {
loadModule(moduleId) loadModule(moduleId)
} }
// onDeactivate
const handleSearch = (moduleId: string) => { const handleSearch = (moduleId: string) => {
activeModule.value = moduleId handleModuleChange(moduleId)
localStorage.setItem(LAST_MODULE_KEY, moduleId) }
loadModule(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 = () => {
@@ -110,14 +162,14 @@ const getFallbackModule = () => {
return fallback?.id || 'settings' return fallback?.id || 'settings'
} }
watch(() => appStore.enabledModules.length, () => { // id length + length 退
watch(() => appStore.enabledModules.map(m => m.id).join(','), () => {
// activeModule // activeModule
if (!activeModule.value) return if (!activeModule.value) return
const enabledIds = appStore.enabledModules.map(m => m.id) const enabledIds = appStore.enabledModules.map(m => m.id)
if (activeModule.value !== 'settings' && !enabledIds.includes(activeModule.value)) { if (activeModule.value !== 'settings' && !enabledIds.includes(activeModule.value)) {
const fallback = getFallbackModule() const fallback = getFallbackModule()
activeModule.value = fallback handleModuleChange(fallback)
loadModule(fallback)
} }
// / // /
quickpanelStore.syncCommands() quickpanelStore.syncCommands()
@@ -196,14 +248,12 @@ onMounted(async () => {
} }
}) })
) )
// Rust //
// /
trayUnlisteners.push( trayUnlisteners.push(
await listen(EVENTS.downloadExtensionAdded, () => { await listen<{ id: string }>(EVENTS.downloadExtensionAdded, (e) => {
const enabledIds = appStore.enabledModules.map(m => m.id) if (!e.payload?.id) return
if (enabledIds.includes('downloader') || moduleRegistry.getConfig('downloader')?.builtin) { void openDownloadWindow(e.payload.id)
pendingShowDownloadTasks.value = true
handleModuleChange('downloader')
}
}) })
) )
trayUnlisteners.push( trayUnlisteners.push(
+1 -1
View File
@@ -27,7 +27,7 @@ watch(
<template> <template>
<main <main
ref="containerRef" ref="containerRef"
class="flex-1" class="flex-1 min-w-0"
:style="{ backgroundColor: 'var(--effect-bg)', backdropFilter: 'var(--effect-blur)' }" :style="{ backgroundColor: 'var(--effect-bg)', backdropFilter: 'var(--effect-blur)' }"
> >
<ScrollArea data-main-scroll class="h-full w-full"> <ScrollArea data-main-scroll class="h-full w-full">
+84 -6
View File
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue' import { ref, computed, onMounted, onUnmounted, nextTick, watch } from 'vue'
import { Search, Settings, ChevronRight, ArrowUp, Check, Loader2 } from '@lucide/vue' import { Search, Settings, ChevronRight, ArrowUp, Check, Loader2 } from '@lucide/vue'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { invoke } from '@tauri-apps/api/core' import { invoke } from '@tauri-apps/api/core'
@@ -66,6 +66,65 @@ const handleSettingSelect = (item: SearchItem) => {
isSearchFocused.value = false isSearchFocused.value = false
} }
// ===== / Enter / ESC =====
/** 下拉扁平结果(模块在前、设置项在后),用于统一索引 */
const flatResults = computed(() => [
...filteredModules.value.map(m => ({ kind: 'module' as const, id: m.id })),
...searchResults.value.map(s => ({ kind: 'setting' as const, id: s.id }))
])
/** 当前高亮索引(-1 无高亮) */
const highlightIndex = ref(-1)
const isDropdownOpen = computed(() => isSearchFocused.value && hasSearchContent.value)
//
watch(searchQuery, () => {
highlightIndex.value = flatResults.value.length > 0 ? 0 : -1
})
const selectIndex = (index: number) => {
const entry = flatResults.value[index]
if (!entry) return
if (entry.kind === 'module') {
handleSearchSelect(entry.id)
} else {
const item = searchResults.value.find(s => s.id === entry.id)
if (item) handleSettingSelect(item)
}
}
const handleSearchKeydown = (e: KeyboardEvent) => {
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
if (!isDropdownOpen.value || flatResults.value.length === 0) return
e.preventDefault()
const delta = e.key === 'ArrowDown' ? 1 : -1
const len = flatResults.value.length
highlightIndex.value = (highlightIndex.value + delta + len) % len
// overflow-y-auto
nextTick(() => {
document
.querySelector(`[data-search-idx="${highlightIndex.value}"]`)
?.scrollIntoView({ block: 'nearest' })
})
} else if (e.key === 'Enter') {
if (isDropdownOpen.value && highlightIndex.value >= 0) {
e.preventDefault()
selectIndex(highlightIndex.value)
}
} else if (e.key === 'Escape') {
e.preventDefault()
if (searchQuery.value) {
//
searchQuery.value = ''
} else {
//
;(e.target as HTMLElement).blur()
}
}
}
let tauriWindow: ReturnType<typeof getCurrentWindow> | null = null let tauriWindow: ReturnType<typeof getCurrentWindow> | null = null
try { try {
tauriWindow = getCurrentWindow() tauriWindow = getCurrentWindow()
@@ -196,12 +255,23 @@ const initScrollListener = () => {
} }
} }
// resize onResized isMaximized IPC
let resizeDebounce: ReturnType<typeof setTimeout> | null = null
onMounted(async () => { onMounted(async () => {
if (tauriWindow) { if (tauriWindow) {
try { try {
isMaximized.value = await tauriWindow.isMaximized() isMaximized.value = await tauriWindow.isMaximized()
unlistenMaximize = await tauriWindow.onResized(async () => { unlistenMaximize = await tauriWindow.onResized(() => {
isMaximized.value = await tauriWindow!.isMaximized() if (resizeDebounce) return
resizeDebounce = setTimeout(async () => {
resizeDebounce = null
try {
isMaximized.value = await tauriWindow!.isMaximized()
} catch {
/* 窗口已销毁等异常忽略 */
}
}, 150)
}) })
} catch { } catch {
// Tauri // Tauri
@@ -218,6 +288,7 @@ onMounted(async () => {
onUnmounted(() => { onUnmounted(() => {
window.removeEventListener('mousemove', handleFirstMouseMove) window.removeEventListener('mousemove', handleFirstMouseMove)
if (restoreHoverTimer) clearTimeout(restoreHoverTimer) if (restoreHoverTimer) clearTimeout(restoreHoverTimer)
if (resizeDebounce) clearTimeout(resizeDebounce)
if (unlistenMaximize) unlistenMaximize() if (unlistenMaximize) unlistenMaximize()
if (unlistenFocus) unlistenFocus() if (unlistenFocus) unlistenFocus()
if (scrollViewport) scrollViewport.removeEventListener('scroll', handleMainScroll) if (scrollViewport) scrollViewport.removeEventListener('scroll', handleMainScroll)
@@ -302,9 +373,10 @@ const handleBlur = () => {
class="h-7 pl-8 text-sm bg-secondary/50 border-0 focus-visible:ring-1" class="h-7 pl-8 text-sm bg-secondary/50 border-0 focus-visible:ring-1"
@focus="isSearchFocused = true" @focus="isSearchFocused = true"
@blur="handleBlur" @blur="handleBlur"
@keydown="handleSearchKeydown"
/> />
<div <div
v-if="isSearchFocused && hasSearchContent" v-if="isDropdownOpen"
class="absolute top-full left-0 right-0 mt-1 bg-popover border border-border rounded-md shadow-lg z-50 overflow-hidden max-h-64 overflow-y-auto" class="absolute top-full left-0 right-0 mt-1 bg-popover border border-border rounded-md shadow-lg z-50 overflow-hidden max-h-64 overflow-y-auto"
> >
<template v-if="filteredModules.length > 0"> <template v-if="filteredModules.length > 0">
@@ -312,10 +384,13 @@ const handleBlur = () => {
模块 模块
</div> </div>
<button <button
v-for="module in filteredModules" v-for="(module, mi) in filteredModules"
:key="module.id" :key="module.id"
class="w-full px-3 py-2 text-left text-sm hover:bg-accent transition-colors flex items-center gap-2" class="w-full px-3 py-2 text-left text-sm hover:bg-accent transition-colors flex items-center gap-2"
:class="highlightIndex === mi ? 'bg-accent' : ''"
:data-search-idx="mi"
@click="handleSearchSelect(module.id)" @click="handleSearchSelect(module.id)"
@mouseenter="highlightIndex = mi"
> >
<span>{{ module.name }}</span> <span>{{ module.name }}</span>
</button> </button>
@@ -327,10 +402,13 @@ const handleBlur = () => {
设置项 设置项
</div> </div>
<button <button
v-for="item in searchResults" v-for="(item, si) in searchResults"
:key="item.id" :key="item.id"
class="w-full px-3 py-2 text-left text-sm hover:bg-accent transition-colors flex items-center gap-2" class="w-full px-3 py-2 text-left text-sm hover:bg-accent transition-colors flex items-center gap-2"
:class="highlightIndex === filteredModules.length + si ? 'bg-accent' : ''"
:data-search-idx="filteredModules.length + si"
@click="handleSettingSelect(item)" @click="handleSettingSelect(item)"
@mouseenter="highlightIndex = filteredModules.length + si"
> >
<Settings class="size-4 text-muted-foreground shrink-0" /> <Settings class="size-4 text-muted-foreground shrink-0" />
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
+10
View File
@@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import type { HTMLAttributes } from "vue" import type { HTMLAttributes } from "vue"
import { ref } from "vue"
import { useVModel } from "@vueuse/core" import { useVModel } from "@vueuse/core"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
@@ -17,10 +18,19 @@ const modelValue = useVModel(props, "modelValue", emits, {
passive: true, passive: true,
defaultValue: props.defaultValue, defaultValue: props.defaultValue,
}) })
// input focus `ref="xx"` xx.focus()
// ref .focus() "focus is not a function"
const inputEl = ref<HTMLInputElement | null>(null)
defineExpose({
focus: () => inputEl.value?.focus(),
element: () => inputEl.value,
})
</script> </script>
<template> <template>
<input <input
ref="inputEl"
v-model="modelValue" v-model="modelValue"
data-slot="input" data-slot="input"
:class="cn( :class="cn(
+144 -24
View File
@@ -9,24 +9,32 @@ export const commands = {
/** 检查 Gitea 最新 release,返回版本对比与可用资产 */ /** 检查 Gitea 最新 release,返回版本对比与可用资产 */
updateCheck: () => __TAURI_INVOKE<UpdateCheckResult>("update_check"), updateCheck: () => __TAURI_INVOKE<UpdateCheckResult>("update_check"),
/** /**
* *
* 便 thing_{v}_x64.exe update.bat * 便 thing_{v}_x64.exe / thing_{v}_x64-setup.exe
* thing_{v}_x64-setup.exe /S * 便copy update.bat
* UPDATE_PROGRESS 退 * copy /S
* 退
*/ */
updateInstall: () => __TAURI_INVOKE<null>("update_install"), updateInstall: (downloadedPath: string) => __TAURI_INVOKE<null>("update_install", { downloadedPath }),
/** 更新 ThingHK 内核:下载 thing-hk_{v}.zip → 停止监控内核 → 覆盖内核文件 */ /** 更新 ThingHK 内核:下载 thing-hk_{v}.zip → 停止监控内核 → 覆盖内核文件 */
updateThinghk: () => __TAURI_INVOKE<null>("update_thinghk"), updateThinghk: () => __TAURI_INVOKE<null>("update_thinghk"),
proxyActivateProfile: (id: string) => __TAURI_INVOKE<null>("proxy_activate_profile", { id }), proxyActivateProfile: (id: string) => __TAURI_INVOKE<null>("proxy_activate_profile", { id }),
/** 应用内核更新:下载阶段已由下载模块完成,本方法仅做 need_stop → 解压 → 替换。 */
proxyApplyKernelUpdate: (zipPath: string) => __TAURI_INVOKE<KernelInfo>("proxy_apply_kernel_update", { zipPath }),
/** 取消内核下载/安装(设置取消标志,下载循环轮询后中止) */
proxyCancelKernelInstall: () => __TAURI_INVOKE<null>("proxy_cancel_kernel_install"),
proxyCheckKernelUpdate: () => __TAURI_INVOKE<KernelUpdateInfo>("proxy_check_kernel_update"), proxyCheckKernelUpdate: () => __TAURI_INVOKE<KernelUpdateInfo>("proxy_check_kernel_update"),
proxyClearSystemProxy: () => __TAURI_INVOKE<null>("proxy_clear_system_proxy"), proxyClearSystemProxy: () => __TAURI_INVOKE<null>("proxy_clear_system_proxy"),
proxyCloseConnection: (id: string) => __TAURI_INVOKE<null>("proxy_close_connection", { id }), proxyCloseConnection: (id: string) => __TAURI_INVOKE<null>("proxy_close_connection", { id }),
/**
* mihomo
* mihomo 便 mihomo exe
*/
proxyConfirmInstall: () => __TAURI_INVOKE<null>("proxy_confirm_install"),
proxyDeleteProfile: (id: string) => __TAURI_INVOKE<null>("proxy_delete_profile", { id }), proxyDeleteProfile: (id: string) => __TAURI_INVOKE<null>("proxy_delete_profile", { id }),
proxyGetSettings: () => __TAURI_INVOKE<ProxySettings>("proxy_get_settings"), proxyGetSettings: () => __TAURI_INVOKE<ProxySettings>("proxy_get_settings"),
proxyGetSystemProxy: () => __TAURI_INVOKE<boolean>("proxy_get_system_proxy"), proxyGetSystemProxy: () => __TAURI_INVOKE<boolean>("proxy_get_system_proxy"),
proxyImportProfile: (url: string, name: string) => __TAURI_INVOKE<ProfileMeta>("proxy_import_profile", { url, name }), proxyImportProfile: (url: string, name: string) => __TAURI_INVOKE<ProfileMeta>("proxy_import_profile", { url, name }),
/** 首次安装内核(与 update_kernel 共用 install_kernel 实现,语义独立便于前端区分场景) */
proxyInstallKernel: (mirrorPrefix: string | null) => __TAURI_INVOKE<KernelInfo>("proxy_install_kernel", { mirrorPrefix }),
proxyKernelInfo: () => __TAURI_INVOKE<KernelInfo>("proxy_kernel_info"), proxyKernelInfo: () => __TAURI_INVOKE<KernelInfo>("proxy_kernel_info"),
proxyRestart: () => __TAURI_INVOKE<ProcessInfo>("proxy_restart"), proxyRestart: () => __TAURI_INVOKE<ProcessInfo>("proxy_restart"),
proxySaveSettings: (settings: ProxySettings) => __TAURI_INVOKE<null>("proxy_save_settings", { settings }), proxySaveSettings: (settings: ProxySettings) => __TAURI_INVOKE<null>("proxy_save_settings", { settings }),
@@ -35,12 +43,15 @@ 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 }),
proxyUpdateKernel: (mirrorPrefix: string | null) => __TAURI_INVOKE<KernelInfo>("proxy_update_kernel", { mirrorPrefix }),
proxyUpdateProfile: (id: string) => __TAURI_INVOKE<ProfileMeta>("proxy_update_profile", { id }), proxyUpdateProfile: (id: string) => __TAURI_INVOKE<ProfileMeta>("proxy_update_profile", { id }),
/** 读取快速面板设置(快捷键等) */ /** 读取快速面板设置(快捷键等) */
quickpanelGetSettings: () => __TAURI_INVOKE<QuickPanelSettings>("quickpanel_get_settings"), quickpanelGetSettings: () => __TAURI_INVOKE<QuickPanelSettings>("quickpanel_get_settings"),
/** 保存快速面板设置;快捷键变化时自动重新注册 + 预创建窗口 */ /**
* +
*
*/
quickpanelSaveSettings: (settings: QuickPanelSettings) => __TAURI_INVOKE<null>("quickpanel_save_settings", { settings }), quickpanelSaveSettings: (settings: QuickPanelSettings) => __TAURI_INVOKE<null>("quickpanel_save_settings", { settings }),
/** 注册(或切换)快速面板全局快捷键 */ /** 注册(或切换)快速面板全局快捷键 */
quickpanelRegisterShortcut: (shortcut: string) => __TAURI_INVOKE<null>("quickpanel_register_shortcut", { shortcut }), quickpanelRegisterShortcut: (shortcut: string) => __TAURI_INVOKE<null>("quickpanel_register_shortcut", { shortcut }),
@@ -58,6 +69,7 @@ export const commands = {
* *
* last_built_dirs notify * last_built_dirs notify
* *
* "构建索引"
*/ */
quickpanelInitFileIndex: () => __TAURI_INVOKE<null>("quickpanel_init_file_index"), quickpanelInitFileIndex: () => __TAURI_INVOKE<null>("quickpanel_init_file_index"),
/** 构建文件索引(全量重建,阻塞操作建议在 spawn_blocking 调用) */ /** 构建文件索引(全量重建,阻塞操作建议在 spawn_blocking 调用) */
@@ -138,6 +150,8 @@ export const commands = {
/** 图片 PNG base64(仅 image 类型) */ /** 图片 PNG base64(仅 image 类型) */
imageBase64: string | null, imageBase64: string | null,
}) & (ClipboardItem) | null>("clipboard_get_item", { id }), }) & (ClipboardItem) | null>("clipboard_get_item", { id }),
/** 获取图片缩略图 PNG base64(弹窗悬停预览用,避免加载全尺寸图片) */
clipboardGetThumb: (id: number) => __TAURI_INVOKE<string | null>("clipboard_get_thumb", { id }),
clipboardSetPinned: (id: number, pinned: boolean) => __TAURI_INVOKE<boolean>("clipboard_set_pinned", { id, pinned }), clipboardSetPinned: (id: number, pinned: boolean) => __TAURI_INVOKE<boolean>("clipboard_set_pinned", { id, pinned }),
clipboardDelete: (id: number) => __TAURI_INVOKE<boolean>("clipboard_delete", { id }), clipboardDelete: (id: number) => __TAURI_INVOKE<boolean>("clipboard_delete", { id }),
clipboardClear: () => __TAURI_INVOKE<boolean>("clipboard_clear"), clipboardClear: () => __TAURI_INVOKE<boolean>("clipboard_clear"),
@@ -160,16 +174,40 @@ export const commands = {
clipboardShowWindow: () => __TAURI_INVOKE<null>("clipboard_show_window"), clipboardShowWindow: () => __TAURI_INVOKE<null>("clipboard_show_window"),
/** 隐藏弹窗并模拟 Ctrl+V 粘贴到原窗口 */ /** 隐藏弹窗并模拟 Ctrl+V 粘贴到原窗口 */
clipboardPasteToTarget: () => __TAURI_INVOKE<null>("clipboard_paste_to_target"), clipboardPasteToTarget: () => __TAURI_INVOKE<null>("clipboard_paste_to_target"),
/** 在弹窗旁显示独立预览窗口(悬停/键盘选中时调用) */
clipboardShowPreview: (id: number) => __TAURI_INVOKE<null>("clipboard_show_preview", { id }),
/** 隐藏独立预览窗口 */
clipboardHidePreview: () => __TAURI_INVOKE<null>("clipboard_hide_preview"),
/**
*
*
* allow_flip true/ false
*/
clipboardResizePreview: (width: number | null, height: number | null, allowFlip: boolean) => __TAURI_INVOKE<null>("clipboard_resize_preview", { width, height, allowFlip }),
/**
* resize
* "先以上次尺寸(可能是放大态大窗)显示再缩回"
*/
clipboardRevealPreview: () => __TAURI_INVOKE<null>("clipboard_reveal_preview"),
/**
* mousedown/
* +/退
*/
clipboardPreviewInteracted: () => __TAURI_INVOKE<null>("clipboard_preview_interacted"),
/** 获取所有任务 */ /** 获取所有任务 */
downloaderGetTasks: () => __TAURI_INVOKE<DownloadTask[]>("downloader_get_tasks"), downloaderGetTasks: () => __TAURI_INVOKE<DownloadTask[]>("downloader_get_tasks"),
/** 检查 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 }),
/** 获取设置 */ /** 获取设置 */
@@ -180,8 +218,21 @@ 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 完成指定窗口的显示与聚焦(截图覆盖层显示关键路径,减少串行往返) */
screenshotShowOverlay: (label: string) => __TAURI_INVOKE<null>("screenshot_show_overlay", { label }),
/** 注册(或切换)截图全局快捷键。传入空字符串则禁用快捷键。 */ /** 注册(或切换)截图全局快捷键。传入空字符串则禁用快捷键。 */
screenshotRegisterShortcut: (shortcut: string) => __TAURI_INVOKE<null>("screenshot_register_shortcut", { shortcut }), screenshotRegisterShortcut: (shortcut: string) => __TAURI_INVOKE<null>("screenshot_register_shortcut", { shortcut }),
/** 注销截图全局快捷键 */ /** 注销截图全局快捷键 */
@@ -193,8 +244,11 @@ export const commands = {
screenshotRegisterPinShortcut: (shortcut: string) => __TAURI_INVOKE<null>("screenshot_register_pin_shortcut", { shortcut }), screenshotRegisterPinShortcut: (shortcut: string) => __TAURI_INVOKE<null>("screenshot_register_pin_shortcut", { shortcut }),
/** 注销贴图全局快捷键 */ /** 注销贴图全局快捷键 */
screenshotUnregisterPinShortcut: () => __TAURI_INVOKE<null>("screenshot_unregister_pin_shortcut"), screenshotUnregisterPinShortcut: () => __TAURI_INVOKE<null>("screenshot_unregister_pin_shortcut"),
/** 捕获整个虚拟屏(多显示器拼接)存入静态,不做 PNG 编码 */ /**
screenshotCaptureFullscreen: () => __TAURI_INVOKE<null>("screenshot_capture_fullscreen"), * PNG
* IPC
*/
screenshotCaptureFullscreen: () => __TAURI_INVOKE<CaptureStart>("screenshot_capture_fullscreen"),
/** 全屏捕获编码为 PNG base64 并清除(全屏截图直接进编辑器) */ /** 全屏捕获编码为 PNG base64 并清除(全屏截图直接进编辑器) */
screenshotFullscreenPng: () => __TAURI_INVOKE<CaptureData>("screenshot_fullscreen_png"), screenshotFullscreenPng: () => __TAURI_INVOKE<CaptureData>("screenshot_fullscreen_png"),
/** 清除静态全屏捕获(覆盖层关闭/取消时释放内存) */ /** 清除静态全屏捕获(覆盖层关闭/取消时释放内存) */
@@ -203,15 +257,12 @@ export const commands = {
screenshotCropStored: (x: number, y: number, w: number, h: number) => __TAURI_INVOKE<CaptureData>("screenshot_crop_stored", { x, y, w, h }), screenshotCropStored: (x: number, y: number, w: number, h: number) => __TAURI_INVOKE<CaptureData>("screenshot_crop_stored", { x, y, w, h }),
/** 裁剪已存储的全屏捕获并直接写入剪贴板(一次 IPC 完成"裁剪+复制" */ /** 裁剪已存储的全屏捕获并直接写入剪贴板(一次 IPC 完成"裁剪+复制" */
screenshotCropCopyStored: (x: number, y: number, w: number, h: number) => __TAURI_INVOKE<CaptureData>("screenshot_crop_copy_stored", { x, y, w, h }), screenshotCropCopyStored: (x: number, y: number, w: number, h: number) => __TAURI_INVOKE<CaptureData>("screenshot_crop_copy_stored", { x, y, w, h }),
/** 拾取指定物理屏幕坐标下的顶层窗口 */ /**
screenshotWindowFromPoint: (x: number, y: number) => __TAURI_INVOKE<{ * Z //
hwnd: number, * JS IPC
title: string, */
rect: ScreenRect, screenshotPickList: () => __TAURI_INVOKE<WindowInfo[]>("screenshot_pick_list"),
/** DWM 扩展边框矩形(视觉边界,去掉最大化窗口的隐形缩放边框),命中测试用 rect,高亮用 visual_rect */ /** 获取当前鼠标物理屏幕坐标(贴图窗口拖动跟随等场景使用) */
visualRect: ScreenRect | null,
} | null>("screenshot_window_from_point", { x, y }),
/** 获取当前鼠标物理屏幕坐标(覆盖层打开时定位初始悬停窗口) */
screenshotCursorPos: () => __TAURI_INVOKE<[number, number]>("screenshot_cursor_pos"), screenshotCursorPos: () => __TAURI_INVOKE<[number, number]>("screenshot_cursor_pos"),
/** 枚举所有可见顶层窗口 */ /** 枚举所有可见顶层窗口 */
screenshotEnumWindows: () => __TAURI_INVOKE<WindowInfo[]>("screenshot_enum_windows"), screenshotEnumWindows: () => __TAURI_INVOKE<WindowInfo[]>("screenshot_enum_windows"),
@@ -245,6 +296,16 @@ export type ArchiveInfo = {
size: number, size: number,
}; };
/** BT 种子内文件条目(多文件任务用;阶段1下载全部文件,但保留列表供 UI 展示) */
export type BtFileInfo = {
/** 文件在种子内的索引 */
index: number,
/** 相对种子根目录的路径(如 "sub/file.mkv" */
path: string,
/** 文件大小(字节) */
size: number,
};
/** 前端可见的捕获数据 */ /** 前端可见的捕获数据 */
export type CaptureData = { export type CaptureData = {
pngBase64: string, pngBase64: string,
@@ -252,6 +313,12 @@ export type CaptureData = {
height: number, height: number,
}; };
/** 截图启动信息:捕获时刻的光标物理坐标(覆盖层据此做初始窗口拾取,省一次 IPC 往返) */
export type CaptureStart = {
cursorX: number,
cursorY: number,
};
/** check_url 命令返回的结果 */ /** check_url 命令返回的结果 */
export type CheckUrlResult = { export type CheckUrlResult = {
/** 探测是否成功 */ /** 探测是否成功 */
@@ -331,12 +398,20 @@ export type DeleteResult = {
export type DownloadTask = { export type DownloadTask = {
/** 任务 ID(自增 hex 字符串) */ /** 任务 ID(自增 hex 字符串) */
id: string, id: string,
/** 下载地址 */ /** 下载地址HTTP URL 或磁力链接) */
url: string, url: string,
/** 文件名 */ /** 文件名(HTTP:目标文件名;BT:种子名称) */
filename: string, filename: string,
/** 保存目录(绝对路径) */ /** 保存目录(绝对路径) */
dir: string, dir: string,
/** 协议类型 */
protocol?: TaskProtocol,
/** BT 种子 infohash(协议=BitTorrent 时存在) */
infoHash?: string | null,
/** BT 种子内文件列表(协议=BitTorrent 时存在) */
btFiles?: BtFileInfo[],
/** BT 元数据是否已解析就绪(异步添加时:后台解析完成前为 false,调度器跳过) */
btMetadataReady?: boolean,
/** 状态 */ /** 状态 */
status: TaskStatus, status: TaskStatus,
/** 文件总大小(字节),0=未知 */ /** 文件总大小(字节),0=未知 */
@@ -377,6 +452,16 @@ export type DownloaderSettings = {
deleteFilesOnRemove?: boolean, deleteFilesOnRemove?: boolean,
/** 添加下载前检查重复(URL 或文件名重复时询问) */ /** 添加下载前检查重复(URL 或文件名重复时询问) */
checkDuplicate?: boolean, checkDuplicate?: boolean,
/** 下载是否使用代理:true=尊重系统代理(mihomo 开启系统代理时经其转发),false=强制直连 */
useProxy?: boolean,
/** BitTorrent 上传限速 KB/s0=不限) */
btUploadLimitKb?: number,
/** BitTorrent 下载完成后是否继续做种上传(false=下载完即停止上传) */
btSeedAfterDownload?: boolean,
/** BitTorrent 监听端口(0=自动选择) */
btListenPort?: number,
/** BitTorrent 使用代理下载:开启后自动使用代理模块(mihomo)的 SOCKS5 端口;代理不可用时降级直连 */
btUseProxy?: boolean,
}; };
/** 重复类型 */ /** 重复类型 */
@@ -561,6 +646,13 @@ export type SpecialLocation = {
args: string[], args: string[],
}; };
/** 任务下载协议类型 */
export type TaskProtocol =
/** HTTP/HTTPS 直链 */
"http" |
/** BitTorrent(磁力链 / .torrent 文件) */
"bittorrent";
/** 任务状态 */ /** 任务状态 */
export type TaskStatus = export type TaskStatus =
/** 排队等待(并发数已满) */ /** 排队等待(并发数已满) */
@@ -572,7 +664,35 @@ export type TaskStatus =
/** 已完成 */ /** 已完成 */
"complete" | "complete" |
/** 错误 */ /** 错误 */
"error"; "error" |
/** 已取消(用户取消:进度与文件已清除,仅保留记录,只能再次下载) */
"cancelled";
/** 种子信息(inspect 解析结果,供命令返回给前端做文件勾选) */
export type TorrentInfo = {
/** 种子名称 */
name: string,
/** infohashhex 小写字符串) */
infoHash: string,
/** 种子内全部文件总大小(字节) */
totalSize: number,
/** 种子内文件列表 */
files: BtFileInfo[],
};
/** 实时流量快照(由 /connections 的会话总量差分得出实时速率) */
export type TrafficSnapshot = {
/** 本次会话累计下载字节数 */
downloadTotal: number,
/** 本次会话累计上传字节数 */
uploadTotal: number,
/** 实时下载速率(字节/秒) */
downloadSpeed: number,
/** 实时上传速率(字节/秒) */
uploadSpeed: number,
/** 当前活跃连接数 */
activeConnections: number,
};
/** release 中的一个资产 */ /** release 中的一个资产 */
export type UpdateAsset = { export type UpdateAsset = {
+21 -1
View File
@@ -10,6 +10,8 @@ export const WINDOWS = {
osdOverlay: 'osd-overlay', osdOverlay: 'osd-overlay',
screenshotOverlay: 'screenshot-overlay', screenshotOverlay: 'screenshot-overlay',
screenshotPin: 'screenshot-pin', screenshotPin: 'screenshot-pin',
/** 单文件一次性下载窗口前缀,实际 label = `${downloadWindow}-<taskId>` */
downloadWindow: 'download-window',
} as const } as const
/** Tauri 事件名(前端 emit / listen 与 Rust constants::events 对应) */ /** Tauri 事件名(前端 emit / listen 与 Rust constants::events 对应) */
@@ -24,11 +26,18 @@ export const EVENTS = {
clipboardChanged: 'clipboard-changed', clipboardChanged: 'clipboard-changed',
clipboardPopupShow: 'clipboard-popup-show', clipboardPopupShow: 'clipboard-popup-show',
clipboardPopupHide: 'clipboard-popup-hide', clipboardPopupHide: 'clipboard-popup-hide',
clipboardPreviewShow: 'clipboard-preview-show',
clipboardPreviewHide: 'clipboard-preview-hide',
// 鼠标进入/离开独立预览窗(弹窗据此决定是否延迟隐藏预览,便于点击复制/放大)
clipboardPreviewEnter: 'clipboard-preview-enter',
clipboardPreviewLeave: 'clipboard-preview-leave',
// 快速面板 // 快速面板
quickpanelShow: 'quickpanel-show', quickpanelShow: 'quickpanel-show',
quickpanelHide: 'quickpanel-hide', quickpanelHide: 'quickpanel-hide',
quickpanelExecuteCommand: 'quickpanel-execute-command', quickpanelExecuteCommand: 'quickpanel-execute-command',
quickpanelExtractProgress: 'quickpanel-extract-progress', quickpanelExtractProgress: 'quickpanel-extract-progress',
// 文件索引构建完成(闲时自动建立/重建、手动构建)
quickpanelIndexUpdated: 'quickpanel-index-updated',
// 截图 // 截图
screenshotBegin: 'screenshot-begin', screenshotBegin: 'screenshot-begin',
screenshotOverlayReady: 'screenshot-overlay-ready', screenshotOverlayReady: 'screenshot-overlay-ready',
@@ -39,10 +48,16 @@ export const EVENTS = {
screenshotExported: 'screenshot-exported', screenshotExported: 'screenshot-exported',
// 内核安装进度 // 内核安装进度
kernelInstallProgress: 'kernel-install-progress', kernelInstallProgress: 'kernel-install-progress',
// 后端自动切换节点完成(后台执行,刷新节点列表并提示)
proxyAutoSwitch: 'proxy-auto-switch',
// 应用更新进度 // 应用更新进度
updateProgress: 'update-progress', updateProgress: 'update-progress',
// 监控 OSD // 监控 OSD
osdStateUpdate: 'osd-state-update', osdStateUpdate: 'osd-state-update',
/** OSD 数据通道:仅推送显示项 key→value 映射 + 网速(高频,每秒) */
osdDataUpdate: 'osd-data-update',
/** OSD 窗口挂载后请求主窗口补发配置+数据(防止错过创建时的首推) */
osdConfigRequest: 'osd-config-request',
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',
@@ -57,7 +72,9 @@ export const EVENTS = {
// 其他 // 其他
processStatusChanged: 'process-status-changed', processStatusChanged: 'process-status-changed',
downloadAdded: 'download-added', downloadAdded: 'download-added',
/** 浏览器扩展通过 HTTP API 新增下载(置前主窗口并跳到下载画面 */ /** 任务被删除(浏览器扩展通过 HTTP API 删除任务时发出,前端据此刷新任务列表 */
downloadRemoved: 'download-removed',
/** 浏览器扩展通过 HTTP API 新增下载(负载 { id },前端据以为该任务创建专属下载窗口) */
downloadExtensionAdded: 'download-extension-added', downloadExtensionAdded: 'download-extension-added',
} as const } as const
@@ -79,6 +96,9 @@ 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',
screenshotHistory: 'thing_screenshot_history', screenshotHistory: 'thing_screenshot_history',
screenshotPinIndex: 'thing_screenshot_pin_index', screenshotPinIndex: 'thing_screenshot_pin_index',
} as const } as const
+7 -4
View File
@@ -25,19 +25,22 @@ window.addEventListener('unhandledrejection', (event) => {
const standaloneWindowApps: Array<[hash: string, label: string, loader: () => Promise<{ default: Component }>]> = [ const standaloneWindowApps: Array<[hash: string, label: string, loader: () => Promise<{ default: Component }>]> = [
['#osd-overlay', 'OSD', () => import('./modules/monitor/OsdWindow.vue')], ['#osd-overlay', 'OSD', () => import('./modules/monitor/OsdWindow.vue')],
['#clipboard-popup', '剪贴板弹窗', () => import('./modules/clipboard/ClipboardPopup.vue')], ['#clipboard-popup', '剪贴板弹窗', () => import('./modules/clipboard/ClipboardPopup.vue')],
['#clipboard-preview', '剪贴板预览', () => import('./modules/clipboard/ClipboardPreview.vue')],
['#quick-panel', '快速面板弹窗', () => import('./modules/quickpanel/QuickPanel.vue')], ['#quick-panel', '快速面板弹窗', () => import('./modules/quickpanel/QuickPanel.vue')],
['#tray-menu', '托盘菜单', () => import('./modules/tray/TrayMenu.vue')], ['#tray-menu', '托盘菜单', () => import('./modules/tray/TrayMenu.vue')],
['#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
+5 -1
View File
@@ -76,6 +76,8 @@ const gotoPage = async (p: number) => {
const detailOpen = ref(false) const detailOpen = ref(false)
const detailLoading = ref(false) const detailLoading = ref(false)
const detail = ref<ClipboardItemDetail | null>(null) const detail = ref<ClipboardItemDetail | null>(null)
/** 详情加载序号:快速点击多个条目时丢弃过期请求结果,避免旧请求覆盖新详情 */
let detailSeq = 0
const openDetail = async (item: ClipboardItem) => { const openDetail = async (item: ClipboardItem) => {
// reka-ui Dialog triggerElement preventScroll focus // reka-ui Dialog triggerElement preventScroll focus
// ScrollAreaViewport(tabindex=0) blur // ScrollAreaViewport(tabindex=0) blur
@@ -86,7 +88,9 @@ const openDetail = async (item: ClipboardItem) => {
detailOpen.value = true detailOpen.value = true
detailLoading.value = true detailLoading.value = true
detail.value = null detail.value = null
const seq = ++detailSeq
const d = await store.getItem(item.id) const d = await store.getItem(item.id)
if (seq !== detailSeq) return //
detail.value = d detail.value = d
detailLoading.value = false detailLoading.value = false
} }
@@ -616,7 +620,7 @@ onUnmounted(() => {
<p v-else-if="detail.kind === 'image'" class="text-sm text-muted-foreground text-center py-8"> <p v-else-if="detail.kind === 'image'" class="text-sm text-muted-foreground text-center py-8">
图片预览不可用 图片预览不可用
</p> </p>
<pre v-else-if="detail.kind === 'text'" class="text-sm whitespace-pre-wrap break-all font-mono bg-muted/50 p-3 rounded">{{ detail.content }}</pre> <pre v-else-if="detail.kind === 'text'" class="text-sm whitespace-pre-wrap break-all font-mono bg-muted/50 p-3 rounded select-text cursor-text">{{ detail.content }}</pre>
<ul v-else-if="detail.kind === 'files'" class="space-y-1 text-sm"> <ul v-else-if="detail.kind === 'files'" class="space-y-1 text-sm">
<li <li
v-for="(p, i) in (parseFiles(detail.content))" v-for="(p, i) in (parseFiles(detail.content))"
+231 -145
View File
@@ -12,6 +12,10 @@ import {
} from '@lucide/vue' } from '@lucide/vue'
import { ScrollArea } from '@/components/ui/scroll-area' import { ScrollArea } from '@/components/ui/scroll-area'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from '@/components/ui/select'
import { import {
Pagination, PaginationContent, PaginationItem, PaginationEllipsis, Pagination, PaginationContent, PaginationItem, PaginationEllipsis,
} from '@/components/ui/pagination' } from '@/components/ui/pagination'
@@ -27,6 +31,8 @@ const total = ref(0)
const currentPage = ref(1) const currentPage = ref(1)
const PAGE_SIZE = 50 const PAGE_SIZE = 50
const searchQuery = ref('') const searchQuery = ref('')
/** 类型筛选:全部/文本/图片/文件 */
const kindFilter = ref<'all' | 'text' | 'image' | 'files'>('all')
const selectedIndex = ref(0) const selectedIndex = ref(0)
const loading = ref(false) const loading = ref(false)
const searchInputRef = ref<HTMLInputElement | null>(null) const searchInputRef = ref<HTMLInputElement | null>(null)
@@ -38,21 +44,37 @@ const totalPages = computed(() => Math.max(1, Math.ceil(total.value / PAGE_SIZE)
// ===== ===== // ===== =====
/** 加载请求序号:翻页/搜索快速操作时丢弃过期请求结果,避免旧请求覆盖新结果 */ /** 加载请求序号:翻页/搜索快速操作时丢弃过期请求结果,避免旧请求覆盖新结果 */
let loadSeq = 0 let loadSeq = 0
/** 列表视图:history = 全部历史(后端分页);pinned = 仅钉住条目(前端筛选+分页) */
const viewMode = ref<'history' | 'pinned'>('history')
async function loadData() { async function loadData() {
const seq = ++loadSeq const seq = ++loadSeq
loading.value = true loading.value = true
try { try {
const q = searchQuery.value.trim() if (viewMode.value === 'pinned') {
const offset = (currentPage.value - 1) * PAGE_SIZE // /
let res: HistoryPage const all = await commands.clipboardGetPinned()
if (q) { if (seq !== loadSeq) return
res = await commands.clipboardSearch(q, PAGE_SIZE, offset) const q = searchQuery.value.trim().toLowerCase()
const kind = kindFilter.value
let filtered = all
if (kind !== 'all') filtered = filtered.filter(i => i.kind === kind)
if (q) filtered = filtered.filter(i => i.preview.toLowerCase().includes(q))
total.value = filtered.length
const start = (currentPage.value - 1) * PAGE_SIZE
items.value = filtered.slice(start, start + PAGE_SIZE)
} else { } else {
res = await commands.clipboardGetHistory(PAGE_SIZE, offset, 'all') const q = searchQuery.value.trim()
const offset = (currentPage.value - 1) * PAGE_SIZE
let res: HistoryPage
if (q) {
res = await commands.clipboardSearch(q, PAGE_SIZE, offset)
} else {
res = await commands.clipboardGetHistory(PAGE_SIZE, offset, kindFilter.value)
}
if (seq !== loadSeq) return //
items.value = res.items
total.value = res.total
} }
if (seq !== loadSeq) return //
items.value = res.items
total.value = res.total
selectedIndex.value = 0 selectedIndex.value = 0
} catch (e) { } catch (e) {
if (seq !== loadSeq) return if (seq !== loadSeq) return
@@ -67,13 +89,20 @@ async function loadData() {
} }
} }
/** 切换历史/钉住视图 */
async function toggleViewMode() {
viewMode.value = viewMode.value === 'history' ? 'pinned' : 'history'
currentPage.value = 1
await loadData()
}
async function gotoPage(p: number) { async function gotoPage(p: number) {
currentPage.value = Math.min(Math.max(1, p), totalPages.value) currentPage.value = Math.min(Math.max(1, p), totalPages.value)
await loadData() await loadData()
} }
// // /
watch(searchQuery, () => { watch([searchQuery, kindFilter], () => {
currentPage.value = 1 currentPage.value = 1
if (searchTimer) clearTimeout(searchTimer) if (searchTimer) clearTimeout(searchTimer)
searchTimer = setTimeout(loadData, 200) searchTimer = setTimeout(loadData, 200)
@@ -107,7 +136,14 @@ async function deleteItem(item: ClipboardItem, ev: Event) {
ev.stopPropagation() ev.stopPropagation()
try { try {
await commands.clipboardDelete(item.id) await commands.clipboardDelete(item.id)
// total/
//
items.value = items.value.filter((i) => i.id !== item.id) items.value = items.value.filter((i) => i.id !== item.id)
// 退
if (items.value.length === 0 && currentPage.value > 1) {
currentPage.value -= 1
}
await loadData()
} catch (e) { } catch (e) {
console.error('[clipboard-popup] 删除失败:', e) console.error('[clipboard-popup] 删除失败:', e)
} }
@@ -121,19 +157,109 @@ async function hideWindow() {
} }
} }
// ===== =====
async function showPreview(item: ClipboardItem) {
try {
await commands.clipboardShowPreview(item.id)
} catch {
/* 忽略 */
}
}
async function hidePreview() {
try {
await commands.clipboardHidePreview()
} catch {
/* 忽略 */
}
}
/** 存储条目预览文本元素,用于检测是否被截断(仅截断的文本才显示预览) */
const previewEls = new Map<number, HTMLElement>()
function setPreviewEl(id: number, el: unknown) {
if (el instanceof HTMLElement) previewEls.set(id, el)
else previewEls.delete(id)
}
/** line-clamp-1 截断检测:内容高度超过单行即视为截断 */
function isTextTruncated(id: number): boolean {
const el = previewEls.get(id)
if (!el) return true //
return el.scrollHeight > el.clientHeight
}
/** 当前条目是否需要预览:图片始终预览;文本仅被截断时预览 */
function needsPreview(item: ClipboardItem): boolean {
if (item.kind === 'image') return true
if (item.kind === 'text') return isTextTruncated(item.id)
return false
}
let hoverTimer: ReturnType<typeof setTimeout> | null = null
/** 鼠标是否位于独立预览窗内:为 true 时离开条目不隐藏预览,便于点击复制/放大 */
const mouseInPreview = ref(false)
/** 键盘选中:立即显示/隐藏预览(不等待悬停延迟) */
function showSelectedPreview() {
cancelHoverTimer()
mouseInPreview.value = false
const item = items.value[selectedIndex.value]
if (item && needsPreview(item)) {
void showPreview(item)
} else {
void hidePreview()
}
}
function onItemHover(idx: number, item: ClipboardItem) {
selectedIndex.value = idx
//
cancelHoverTimer()
mouseInPreview.value = false
//
if (!needsPreview(item)) {
void hidePreview()
return
}
// 100ms
hoverTimer = setTimeout(() => {
void showPreview(item)
}, 100)
}
function onItemLeave() {
//
// / preview-enter
cancelHoverTimer()
if (mouseInPreview.value) return
hoverTimer = setTimeout(() => {
void hidePreview()
}, 250)
}
function cancelHoverTimer() {
if (hoverTimer) {
clearTimeout(hoverTimer)
hoverTimer = null
}
}
// ===== ===== // ===== =====
// Select /Enter/Esc Select
// //
const selectOpen = ref(false)
function onKeydown(e: KeyboardEvent) { function onKeydown(e: KeyboardEvent) {
if (selectOpen.value) return
if (e.key === 'ArrowDown') { if (e.key === 'ArrowDown') {
e.preventDefault() e.preventDefault()
selectedIndex.value = Math.min(selectedIndex.value + 1, items.value.length - 1) selectedIndex.value = Math.min(selectedIndex.value + 1, items.value.length - 1)
cancelHoverTimer() showSelectedPreview()
previewVisible.value = false
scrollSelectedIntoView() scrollSelectedIntoView()
} else if (e.key === 'ArrowUp') { } else if (e.key === 'ArrowUp') {
e.preventDefault() e.preventDefault()
selectedIndex.value = Math.max(selectedIndex.value - 1, 0) selectedIndex.value = Math.max(selectedIndex.value - 1, 0)
cancelHoverTimer() showSelectedPreview()
previewVisible.value = false
scrollSelectedIntoView() scrollSelectedIntoView()
} else if (e.key === 'Enter') { } else if (e.key === 'Enter') {
e.preventDefault() e.preventDefault()
@@ -141,6 +267,8 @@ function onKeydown(e: KeyboardEvent) {
if (item) selectAndPaste(item) if (item) selectAndPaste(item)
} else if (e.key === 'Escape') { } else if (e.key === 'Escape') {
e.preventDefault() e.preventDefault()
cancelHoverTimer()
void hidePreview()
hideWindow() hideWindow()
} }
} }
@@ -176,62 +304,6 @@ const formatTime = (ms: number) => {
const hasItems = computed(() => items.value.length > 0) const hasItems = computed(() => items.value.length > 0)
// ===== 100ms =====
const previewSrc = ref('')
const previewVisible = ref(false)
let hoverTimer: ReturnType<typeof setTimeout> | null = null
// id dataUrl
const imageCache = new Map<number, string>()
/** 根据 base64 前缀判断 MIME 类型 */
function buildImageDataUrl(b64: string): string {
const mime = b64.startsWith('/9j/') ? 'image/jpeg' : 'image/png'
return `data:${mime};base64,${b64}`
}
async function onItemHover(idx: number, item: ClipboardItem) {
selectedIndex.value = idx
//
if (item.kind !== 'image') {
cancelHoverTimer()
previewVisible.value = false
return
}
//
cancelHoverTimer()
// 100ms
hoverTimer = setTimeout(async () => {
try {
let src = imageCache.get(item.id)
if (!src) {
const detail = await commands.clipboardGetItem(item.id)
if (detail?.imageBase64) {
src = buildImageDataUrl(detail.imageBase64)
imageCache.set(item.id, src)
}
}
if (src) {
previewSrc.value = src
previewVisible.value = true
}
} catch {
/* 忽略加载失败 */
}
}, 100)
}
function cancelHoverTimer() {
if (hoverTimer) {
clearTimeout(hoverTimer)
hoverTimer = null
}
}
function onItemLeave() {
cancelHoverTimer()
previewVisible.value = false
}
// ===== ===== // ===== =====
/** 从 localStorage 读取主应用的主题设置 */ /** 从 localStorage 读取主应用的主题设置 */
function readMainTheme(): { theme: string; effect: string } { function readMainTheme(): { theme: string; effect: string } {
@@ -334,15 +406,37 @@ onMounted(async () => {
await applyTheme() await applyTheme()
searchQuery.value = '' searchQuery.value = ''
currentPage.value = 1 currentPage.value = 1
// //
cancelHoverTimer() cancelHoverTimer()
previewVisible.value = false mouseInPreview.value = false
imageCache.clear() void hidePreview()
await loadData() await loadData()
await nextTick() await nextTick()
searchInputRef.value?.focus() searchInputRef.value?.focus()
})) }))
//
unlistenFns.push(await listen(EVENTS.clipboardPopupHide, () => {
cancelHoverTimer()
mouseInPreview.value = false
void hidePreview()
}))
// /
unlistenFns.push(await listen(EVENTS.clipboardPreviewEnter, () => {
mouseInPreview.value = true
cancelHoverTimer()
}))
// onItemHover
unlistenFns.push(await listen(EVENTS.clipboardPreviewLeave, () => {
mouseInPreview.value = false
cancelHoverTimer()
hoverTimer = setTimeout(() => {
void hidePreview()
}, 250)
}))
// //
await loadData() await loadData()
await nextTick() await nextTick()
@@ -364,9 +458,9 @@ onUnmounted(() => {
<template> <template>
<div class="popup-root flex flex-col h-screen w-screen" @keydown="onKeydown"> <div class="popup-root flex flex-col h-screen w-screen" @keydown="onKeydown">
<!-- 搜索栏与剪切板主页统一样式 --> <!-- 搜索栏与剪切板主页统一样式+ 类型筛选 -->
<div class="flex items-center gap-2 px-3 py-2 border-b border-border shrink-0"> <div class="flex items-center gap-2 px-3 py-2 border-b border-border shrink-0">
<div class="relative flex-1 max-w-sm"> <div class="relative flex-1 min-w-0">
<Search class="absolute left-2.5 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" /> <Search class="absolute left-2.5 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
<Input <Input
ref="searchInputRef" ref="searchInputRef"
@@ -376,16 +470,20 @@ onUnmounted(() => {
spellcheck="false" spellcheck="false"
/> />
</div> </div>
<span class="text-xs text-muted-foreground whitespace-nowrap">{{ total }} </span> <Select v-model="kindFilter" :open="selectOpen" @update:open="selectOpen = $event">
<SelectTrigger size="sm" class="w-[76px] shrink-0 text-xs" title="按类型筛选">
<SelectValue />
</SelectTrigger>
<!-- 内容宽度与 trigger 等宽覆盖默认 min-w-[8rem]128px紧凑弹窗内不显过宽 -->
<SelectContent class="w-(--reka-select-trigger-width) min-w-0">
<SelectItem value="all">全部</SelectItem>
<SelectItem value="text">文本</SelectItem>
<SelectItem value="image">图片</SelectItem>
<SelectItem value="files">文件</SelectItem>
</SelectContent>
</Select>
</div> </div>
<!-- 图片悬停预览浮层 -->
<Transition name="popup-preview">
<div v-if="previewVisible && previewSrc" class="popup-preview">
<img :src="previewSrc" alt="预览" />
</div>
</Transition>
<!-- 列表 --> <!-- 列表 -->
<ScrollArea class="popup-list flex-1 min-h-0"> <ScrollArea class="popup-list flex-1 min-h-0">
<div class="space-y-1.5 p-2"> <div class="space-y-1.5 p-2">
@@ -419,7 +517,10 @@ onUnmounted(() => {
> >
<component :is="kindIcon(item.kind)" class="size-4 text-muted-foreground shrink-0 mt-0.5" /> <component :is="kindIcon(item.kind)" class="size-4 text-muted-foreground shrink-0 mt-0.5" />
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<p class="text-sm break-all line-clamp-1">{{ item.preview }}</p> <p
class="text-sm break-all line-clamp-1"
:ref="el => setPreviewEl(item.id, el)"
>{{ item.preview }}</p>
<div class="flex items-center gap-2 mt-0.5 text-xs text-muted-foreground flex-wrap"> <div class="flex items-center gap-2 mt-0.5 text-xs text-muted-foreground flex-wrap">
<span class="popup-item-kind px-1.5 py-0 text-[10px] border rounded-sm" :class="kindBadgeClass(item.kind)">{{ kindLabel(item.kind) }}</span> <span class="popup-item-kind px-1.5 py-0 text-[10px] border rounded-sm" :class="kindBadgeClass(item.kind)">{{ kindLabel(item.kind) }}</span>
<span>{{ formatTime(item.createdAt) }}</span> <span>{{ formatTime(item.createdAt) }}</span>
@@ -437,38 +538,56 @@ onUnmounted(() => {
</div> </div>
</ScrollArea> </ScrollArea>
<!-- 分页与剪切板历史统一样式 --> <!-- 底部总数 + 分页 + 视图切换grid 两侧 1fr 等宽分页严格居中 -->
<div v-if="totalPages > 1" class="flex items-center justify-center gap-1 px-2 py-1 border-t border-border"> <div class="grid grid-cols-[1fr_auto_1fr] items-center gap-2 px-3 py-1 border-t border-border shrink-0">
<Pagination <span class="text-xs text-muted-foreground whitespace-nowrap justify-self-start">{{ total }} </span>
v-slot="{ page }" <div v-if="totalPages > 1" class="justify-self-center">
:page="currentPage" <Pagination
:total="total" v-slot="{ page }"
:items-per-page="PAGE_SIZE" :page="currentPage"
:sibling-count="1" :total="total"
show-edges :items-per-page="PAGE_SIZE"
@update:page="gotoPage" :sibling-count="1"
> show-edges
<PaginationContent v-slot="{ items: pageItems }" class="gap-1"> @update:page="gotoPage"
<template v-for="(item, index) in pageItems" :key="index"> >
<PaginationItem <PaginationContent v-slot="{ items: pageItems }" class="gap-1">
v-if="item.type === 'page'" <template v-for="(item, index) in pageItems" :key="index">
:value="item.value" <PaginationItem
:is-active="item.value === page" v-if="item.type === 'page'"
size="icon" :value="item.value"
class="size-7 text-xs" :is-active="item.value === page"
> size="icon"
{{ item.value }} class="size-7 text-xs"
</PaginationItem> >
<PaginationEllipsis v-else class="size-7" /> {{ item.value }}
</template> </PaginationItem>
</PaginationContent> <PaginationEllipsis v-else class="size-7" />
</Pagination> </template>
</PaginationContent>
</Pagination>
</div>
<!-- col-start-3无分页时中间轨道宽为 0若不固定列位置按钮会被自动放置
到第 2 轨道视觉居中显式固定第 3 轨道保证始终贴右 -->
<div class="justify-self-end col-start-3">
<Button
:variant="viewMode === 'pinned' ? 'default' : 'outline'"
size="sm"
class="h-7 px-2 text-xs gap-1"
:title="viewMode === 'pinned' ? '当前:仅钉住条目,点击查看全部历史' : '当前:全部历史,点击仅查看钉住条目'"
@click="toggleViewMode"
>
<!-- 图标与文字同义Pin=钉住视图 / ClipboardList=历史视图高亮表示当前视图 -->
<component :is="viewMode === 'pinned' ? Pin : ClipboardList" class="size-3.5" />
{{ viewMode === 'pinned' ? '钉住' : '历史' }}
</Button>
</div>
</div> </div>
<!-- 底部提示 --> <!-- 底部提示 -->
<div class="popup-footer shrink-0"> <div class="popup-footer shrink-0">
<span><kbd></kbd><kbd></kbd> 导航</span> <span><kbd></kbd><kbd></kbd> 导航</span>
<span><kbd>Enter</kbd> 粘贴</span> <span><kbd>Enter</kbd>/<kbd>左键</kbd> 粘贴</span>
<span><kbd>Esc</kbd> 关闭</span> <span><kbd>Esc</kbd> 关闭</span>
</div> </div>
</div> </div>
@@ -588,39 +707,6 @@ onUnmounted(() => {
color: var(--muted-foreground); color: var(--muted-foreground);
} }
/* 图片悬停预览浮层:固定在弹窗右上角,不遮挡列表操作 */
.popup-preview {
position: absolute;
top: 50px;
right: 10px;
z-index: 100;
max-width: 180px;
max-height: 180px;
border-radius: 6px;
overflow: hidden;
border: 1px solid var(--border);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.25);
background: var(--popover, var(--background));
pointer-events: none;
}
.popup-preview img {
display: block;
max-width: 100%;
max-height: 180px;
object-fit: contain;
}
/* 预览浮层淡入淡出 */
.popup-preview-enter-active,
.popup-preview-leave-active {
transition: opacity 0.15s ease;
}
.popup-preview-enter-from,
.popup-preview-leave-to {
opacity: 0;
}
.popup-footer kbd { .popup-footer kbd {
background: var(--muted); background: var(--muted);
color: var(--foreground); color: var(--foreground);
+369
View File
@@ -0,0 +1,369 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import { listen, emit, type UnlistenFn } from '@tauri-apps/api/event'
import { getCurrentWindow, Effect, EffectState } from '@tauri-apps/api/window'
import { EVENTS, STORAGE_KEYS } from '@/lib/constants'
import { commands } from '@/lib/bindings'
import { ZoomIn, ZoomOut, FileText, Image as ImageIcon } from '@lucide/vue'
import { ScrollArea } from '@/components/ui/scroll-area'
// ===== =====
const kind = ref<'text' | 'image' | ''>('')
const textContent = ref('')
const thumbSrc = ref('')
const fullSrc = ref('')
const enlarged = ref(false)
const loading = ref(false)
let unlistenFns: UnlistenFn[] = []
//
let loadSeq = 0
/** 根据 base64 前缀判断 MIME 类型 */
function buildImageDataUrl(b64: string): string {
const mime = b64.startsWith('/9j/') ? 'image/jpeg' : 'image/png'
return `data:${mime};base64,${b64}`
}
// ===== =====
//
const PAD = 12 // p-3
const HEADER_H = 30 // py-1.5*2 + +
const TEXT_W = 360 //
const THUMB_MAX_W = 440 //
const THUMB_MAX_H = 340 //
const DEF_W = 340 // /
const DEF_H = 300 // /
/** 离屏测量文本在指定内容宽下的自然高度(样式与模板 pre 一致,保证测量准确) */
function measureTextHeight(text: string, width: number): number {
const el = document.createElement('pre')
el.className = 'text-xs whitespace-pre-wrap break-all font-mono leading-relaxed'
el.style.cssText = `position:fixed;left:-9999px;top:0;width:${width}px;margin:0;visibility:hidden`
el.textContent = text
document.body.appendChild(el)
const h = el.getBoundingClientRect().height
el.remove()
return h
}
/** 读取图片原始尺寸 */
function loadImageSize(src: string): Promise<{ w: number; h: number }> {
return new Promise((resolve) => {
const img = new Image()
img.onload = () => resolve({ w: img.naturalWidth || 0, h: img.naturalHeight || 0 })
img.onerror = () => resolve({ w: 0, h: 0 })
img.src = src
})
}
/**
* 后端按工作区钳制并重新对齐弹窗完成后 reveal 显示窗口出现即最终尺寸
* "先大后小"闪烁seq 用于丢弃快速切换时的过期请求
* allowFlip初始落位为 true放不下可换侧放大/还原为 false保持原侧 */
async function fitWindow(seq: number, allowFlip: boolean) {
let w = DEF_W
let h = DEF_H
if (kind.value === 'text') {
const th = measureTextHeight(textContent.value, TEXT_W - PAD * 2)
w = TEXT_W
h = HEADER_H + PAD * 2 + th
} else if (kind.value === 'image') {
const src = enlarged.value ? fullSrc.value : thumbSrc.value
if (src) {
const { w: iw, h: ih } = await loadImageSize(src)
if (seq !== loadSeq) return
if (iw && ih) {
let dw = iw
let dh = ih
if (!enlarged.value) {
//
const scale = Math.min(THUMB_MAX_W / iw, THUMB_MAX_H / ih, 1)
dw = Math.round(iw * scale)
dh = Math.round(ih * scale)
}
w = dw + PAD * 2
h = dh + HEADER_H + PAD * 2
}
}
}
if (seq !== loadSeq) return
try {
// resize reveal
await commands.clipboardResizePreview(w, h, allowFlip)
await commands.clipboardRevealPreview()
} catch {
/* 忽略 */
}
}
/** 根据条目 id 加载预览内容(文本取全文,图片先取缩略图) */
async function loadItem(id: number) {
const seq = ++loadSeq
loading.value = true
enlarged.value = false
kind.value = ''
textContent.value = ''
thumbSrc.value = ''
fullSrc.value = ''
try {
const detail = await commands.clipboardGetItem(id)
if (seq !== loadSeq) return
if (detail?.kind === 'text') {
kind.value = 'text'
textContent.value = detail.content ?? ''
} else if (detail?.kind === 'image') {
kind.value = 'image'
if (detail.imageBase64) fullSrc.value = buildImageDataUrl(detail.imageBase64)
const thumb = await commands.clipboardGetThumb(id).catch(() => null)
if (seq !== loadSeq) return
thumbSrc.value = thumb ? buildImageDataUrl(thumb) : fullSrc.value
}
} catch {
/* 加载失败保持空态 */
} finally {
if (seq === loadSeq) {
loading.value = false
//
void fitWindow(seq, true)
}
}
}
/** 图片点击:缩略图 ↔ 原图放大切换(窗口随内容自适应:放大态按原图尺寸) */
function toggleEnlarge() {
if (kind.value !== 'image' || !fullSrc.value) return
enlarged.value = !enlarged.value
// 使 clamp
void fitWindow(loadSeq, false)
}
// ===== / =====
// onItemLeave
// "/"
// mousedown WebView2 mouseleave
// "/"
// /
// +/退
// leave线
const interacted = ref(false)
function onRootEnter() {
void emit(EVENTS.clipboardPreviewEnter)
}
function onRootMouseDown() {
onRootEnter()
interacted.value = true
void commands.clipboardPreviewInteracted().catch(() => {})
}
function onRootLeave() {
//
if (interacted.value) return
void emit(EVENTS.clipboardPreviewLeave)
}
/** Ctrl+C:有选区交给浏览器默认复制;无选区则复制整段文本,支持预览窗内自由复制 */
function onKeydown(e: KeyboardEvent) {
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'c') {
if (window.getSelection()?.toString().trim()) return
if (textContent.value) {
e.preventDefault()
navigator.clipboard?.writeText(textContent.value).catch(() => {})
}
}
}
// ===== =====
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' }
}
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()
try {
const tauriWin = getCurrentWindow()
if (theme === 'system') await tauriWin.setTheme(null)
else await tauriWin.setTheme(theme as 'dark' | 'light')
} catch {
/* 非 Tauri 环境忽略 */
}
const isDark = resolveIsDark(theme)
root.classList.remove('dark', 'effect-mica', 'effect-acrylic', 'effect-normal')
root.classList.add(`effect-${effect}`)
if (isDark) root.classList.add('dark')
try {
const tauriWin = getCurrentWindow()
await tauriWin.clearEffects()
if (effect === 'mica') {
await tauriWin.setEffects({
effects: [Effect.Mica],
// NoActivate
// FollowsWindowActiveState
// Active
state: EffectState.Active,
color: isDark ? [30, 41, 59, 0] : [248, 250, 252, 0],
})
await tauriWin.setBackgroundColor('#00000000')
root.style.setProperty('--popup-bg', 'transparent')
} else if (effect === 'acrylic') {
await tauriWin.setEffects({
effects: [Effect.Acrylic],
state: EffectState.Active,
color: isDark ? [30, 41, 59, 0] : [248, 250, 252, 0],
})
await tauriWin.setBackgroundColor('#00000000')
root.style.setProperty('--popup-bg', 'transparent')
} else {
await tauriWin.setBackgroundColor(isDark ? '#0f172a' : '#ffffff')
root.style.setProperty('--popup-bg', isDark ? '#0f172a' : '#ffffff')
}
} catch {
/* 非 Tauri 环境忽略 */
}
}
onMounted(async () => {
await applyTheme()
const mq = window.matchMedia('(prefers-color-scheme: dark)')
const onThemeChange = () => applyTheme()
mq.addEventListener('change', onThemeChange)
unlistenFns.push(() => mq.removeEventListener('change', onThemeChange))
// /
unlistenFns.push(await listen<number>(EVENTS.clipboardPreviewShow, async (e) => {
await applyTheme()
void loadItem(e.payload)
}))
// /
unlistenFns.push(await listen(EVENTS.clipboardPreviewHide, () => {
loadSeq++ //
kind.value = ''
textContent.value = ''
thumbSrc.value = ''
fullSrc.value = ''
enlarged.value = false
loading.value = false
interacted.value = false
}))
})
onUnmounted(() => {
unlistenFns.forEach((fn) => fn())
})
</script>
<template>
<div
class="preview-root h-screen w-screen outline-none"
tabindex="0"
@mouseenter="onRootEnter"
@mousedown="onRootMouseDown"
@mouseleave="onRootLeave"
@keydown="onKeydown"
>
<!-- 加载中 -->
<div v-if="loading" class="h-full flex items-center justify-center text-xs text-muted-foreground">
加载中
</div>
<!-- 文本预览 -->
<div v-else-if="kind === 'text'" class="h-full flex flex-col">
<div class="flex items-center px-3 py-1.5 border-b border-border shrink-0">
<span class="text-xs text-muted-foreground flex items-center gap-1.5">
<FileText class="size-3.5" />文本预览/自由复制
</span>
</div>
<ScrollArea class="flex-1 min-h-0">
<pre class="text-xs whitespace-pre-wrap break-all font-mono select-text cursor-text leading-relaxed p-3">{{ textContent }}</pre>
</ScrollArea>
</div>
<!-- 图片预览 -->
<div v-else-if="kind === 'image'" class="h-full flex flex-col">
<div class="flex items-center justify-between px-3 py-1.5 border-b border-border shrink-0">
<span class="text-xs text-muted-foreground flex items-center gap-1.5">
<ImageIcon class="size-3.5" />图片预览
</span>
<button v-if="fullSrc" class="preview-btn" :title="enlarged ? '还原' : '点击查看原图'" @click="toggleEnlarge">
<component :is="enlarged ? ZoomOut : ZoomIn" class="h-3.5 w-3.5" />{{ enlarged ? '还原' : '放大' }}
</button>
</div>
<!-- 放大态ScrollArea 滚动查看原图缩略态内容居中不滚动 -->
<ScrollArea v-if="enlarged" class="flex-1 min-h-0">
<div class="p-3">
<img
:src="fullSrc"
alt="剪贴板图片预览"
class="rounded block cursor-default"
@click="toggleEnlarge"
/>
</div>
</ScrollArea>
<div v-else class="flex-1 min-h-0 p-3 preview-img-thumb">
<img
:src="thumbSrc"
alt="剪贴板图片预览"
class="rounded block max-w-full max-h-full mx-auto cursor-zoom-in"
@click="toggleEnlarge"
/>
</div>
</div>
<!-- 空态 -->
<div v-else class="h-full flex items-center justify-center text-xs text-muted-foreground">
将鼠标悬停条目查看预览
</div>
</div>
</template>
<style scoped>
.preview-root {
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Microsoft YaHei', 'PingFang SC', sans-serif;
background: var(--popup-bg, transparent);
color: var(--foreground);
border-radius: 8px;
overflow: hidden;
}
.preview-btn {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 11px;
padding: 2px 8px;
border-radius: 5px;
border: 1px solid var(--border);
background: var(--muted);
color: var(--foreground);
cursor: pointer;
transition: background-color 0.1s;
}
.preview-btn:hover {
background: var(--accent);
}
/* 缩略图态:内容居中,图片不放大 */
.preview-img-thumb {
display: flex;
align-items: center;
justify-content: center;
}
</style>
+495
View File
@@ -0,0 +1,495 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { getCurrentWindow, Effect, EffectState } from '@tauri-apps/api/window'
import { LogicalSize } from '@tauri-apps/api/dpi'
import { listen, type UnlistenFn } from '@tauri-apps/api/event'
import { Pause, Play, X, Minus, FolderOpen, AlertCircle, Loader2, XCircle } from '@lucide/vue'
import { commands, type DownloadTask, type Segment, type TaskStatus } from '@/lib/bindings'
import { STORAGE_KEYS } from '@/lib/constants'
import { createLogger } from '@/lib/logger'
const logger = createLogger('download-window')
// ===== URL hash id#download-window?task=<id> =====
const hash = window.location.hash
const taskId = new URLSearchParams(hash.split('?')[1] ?? '').get('task') ?? ''
if (!taskId) logger.warn('下载窗口缺少 task 参数')
const win = getCurrentWindow()
const task = ref<DownloadTask | null>(null)
const loading = ref(true)
const actionError = ref('')
// ===== Rust ProgressPayload =====
interface ProgressPayload {
id: string
completedSize: number
totalSize: number
speed: number
status: TaskStatus
segments: number[]
}
const loadTask = async () => {
try {
const all = await commands.downloaderGetTasks()
task.value = all.find(t => t.id === taskId) ?? null
} catch (e) {
logger.error('获取任务失败: ' + e)
} finally {
loading.value = false
}
// /
if (!task.value) {
await win.close()
return
}
//
try { await win.setTitle(task.value.filename) } catch { /* 忽略 */ }
}
// ===== =====
const totalSize = computed(() => task.value?.totalSize ?? 0)
const completedSize = computed(() => task.value?.completedSize ?? 0)
const speed = computed(() => task.value?.speed ?? 0)
const status = computed<TaskStatus>(() => task.value?.status ?? 'active')
const progress = computed(() => {
if (!totalSize.value || totalSize.value === 0) return 0
return Math.min(100, Math.round((completedSize.value / totalSize.value) * 100))
})
const segments = computed(() => task.value?.segments ?? [])
const showSegments = computed(() => segments.value.length > 1)
const statusText = computed(() => {
switch (status.value) {
case 'active': return '下载中'
case 'queued': return '等待中'
case 'paused': return '已暂停'
case 'complete': return '已完成'
case 'error': return '出错'
case 'cancelled': return '已取消'
}
})
const statusTone = computed(() => {
if (status.value === 'complete') return 'success'
if (status.value === 'error') return 'destructive'
if (status.value === 'paused' || status.value === 'cancelled') return 'muted'
return 'active'
})
// ===== =====
const formatByte = (b: number) => {
if (!b || isNaN(b)) return '0 B'
if (b < 1024) return `${b} B`
if (b < 1024 * 1024) return `${(b / 1024).toFixed(1)} KB`
if (b < 1024 * 1024 * 1024) return `${(b / 1024 / 1024).toFixed(2)} MB`
return `${(b / 1024 / 1024 / 1024).toFixed(2)} GB`
}
const formatTotal = (b: number) => (!b || b === 0 ? '未知' : formatByte(b))
// ===== =====
const segLen = (s: Segment) => s.end - s.start + 1
const totalSpan = computed(() => segments.value.reduce((a, s) => a + segLen(s), 0))
const segPercent = (s: Segment) => {
if (segLen(s) <= 0) return 0
return Math.min(100, Math.round((s.completed / segLen(s)) * 100))
}
// ===== =====
const errorTip = (e: unknown) => {
actionError.value = typeof e === 'string' ? e : '操作失败'
}
const doPause = async () => {
try { await commands.downloaderPauseTask(taskId) } catch (e) { return errorTip(e) }
await loadTask().catch(() => {})
}
const doResume = async () => {
try { await commands.downloaderResumeTask(taskId) } catch (e) { return errorTip(e) }
await loadTask().catch(() => {})
}
// 3
const armedCancel = ref(false)
let cancelArmTimer = 0
const doCancel = async () => {
if (!armedCancel.value) {
armedCancel.value = true
window.clearTimeout(cancelArmTimer)
cancelArmTimer = window.setTimeout(() => (armedCancel.value = false), 3000)
return
}
window.clearTimeout(cancelArmTimer)
armedCancel.value = false
try { await commands.downloaderCancelTask(taskId) } catch (e) { return errorTip(e) }
await win.close()
}
const doOpenFolder = async () => {
if (!task.value?.dir) return
try { await commands.downloaderOpenDir(task.value.dir) } catch (e) { errorTip(e) }
}
const doMinimize = () => win.minimize()
const doClose = () => win.close()
// data-tauri-drag-region
// pointer startDragging
const startDrag = () => {
win.startDragging().catch(() => {})
}
// 3
const clearErrorSoon = () => {
if (!actionError.value) return
window.setTimeout(() => { if (!actionError.value) return; actionError.value = '' }, 3000)
}
// ===== + mica/acrylic =====
function readMainTheme(): { theme: string; effect: string } {
try {
const raw = localStorage.getItem(STORAGE_KEYS.appSettings)
if (raw) {
const s = JSON.parse(raw)
return { theme: s.theme ?? 'system', effect: s.effect ?? 'mica' }
}
} catch { /* 忽略 */ }
return { theme: 'system', effect: 'mica' }
}
/** 判断当前是否应为深色主题(弹窗独立窗口,system 模式用 matchMedia 可靠) */
function resolveIsDark(theme: string): boolean {
if (theme === 'dark') return true
if (theme === 'light') return false
return window.matchMedia('(prefers-color-scheme: dark)').matches
}
async function applyTheme() {
const root = document.documentElement
const { theme, effect } = readMainTheme()
// 1. system null
try {
if (theme === 'system') await win.setTheme(null)
else await win.setTheme(theme as 'dark' | 'light')
} catch { /* 忽略 */ }
// 2. matchMedia
const isDark = resolveIsDark(theme)
// 3. DOM classeffect style.css
root.classList.remove('dark', 'effect-mica', 'effect-acrylic', 'effect-normal')
root.classList.add(`effect-${effect}`)
if (isDark) root.classList.add('dark')
// 4. mica/acrylic
try {
await win.clearEffects()
if (effect === 'mica') {
await win.setEffects({
effects: [Effect.Mica],
state: EffectState.FollowsWindowActiveState,
color: isDark ? [30, 41, 59, 0] : [248, 250, 252, 0],
})
await win.setBackgroundColor('#00000000')
root.style.setProperty('--popup-bg', 'transparent')
} else if (effect === 'acrylic') {
await win.setEffects({
effects: [Effect.Acrylic],
state: EffectState.FollowsWindowActiveState,
color: isDark ? [30, 41, 59, 0] : [248, 250, 252, 0],
})
await win.setBackgroundColor('#00000000')
root.style.setProperty('--popup-bg', 'transparent')
} else {
await win.setBackgroundColor(isDark ? '#0f172a' : '#ffffff')
root.style.setProperty('--popup-bg', isDark ? '#0f172a' : '#ffffff')
}
} catch { /* 忽略 */ }
}
// ===== =====
const rootRef = ref<HTMLElement | null>(null)
let lastHeight = 0
let autoResizeObserver: ResizeObserver | null = null
//
// titlebar/loading getBoundingClientRect().height
const MIN_H = 52
const WINDOW_W = 420
const applyAutoHeight = async () => {
if (!rootRef.value) return
try {
if (await win.isMinimized()) return
} catch { /* 忽略 */ }
const h = Math.max(MIN_H, Math.ceil(rootRef.value.getBoundingClientRect().height))
if (Math.abs(h - lastHeight) > 4) {
lastHeight = h
win.setSize(new LogicalSize(WINDOW_W, h)).catch(() => {})
}
}
// ===== =====
let progressUnlisten: UnlistenFn | null = null
let completeUnlisten: UnlistenFn | null = null
let onThemeChange: (() => void) | null = null
// + + /
// Rust downloaderFocusWindow Windows setFocus
const handleComplete = async () => {
await loadTask().catch(() => {})
if (status.value === 'complete') {
try { await commands.downloaderFocusWindow(win.label) } catch { /* 忽略 */ }
}
}
onMounted(async () => {
await applyTheme()
await loadTask()
progressUnlisten = await listen<ProgressPayload>('download-progress', (e) => {
if (e.payload.id !== taskId || !task.value) return
const t = task.value
t.completedSize = e.payload.completedSize
t.totalSize = e.payload.totalSize
t.speed = e.payload.speed
t.status = e.payload.status
if (Array.isArray(e.payload.segments) && t.segments) {
t.segments.forEach((seg, i) => {
const v = e.payload.segments[i]
if (v !== undefined) seg.completed = v
})
}
})
completeUnlisten = await listen<{ id: string }>('download-complete', (e) => {
if (e.payload?.id !== taskId) return
handleComplete()
})
const mq = window.matchMedia('(prefers-color-scheme: dark)')
onThemeChange = () => applyTheme()
mq.addEventListener('change', onThemeChange)
//
lastHeight = 0
autoResizeObserver = new ResizeObserver(applyAutoHeight)
if (rootRef.value) autoResizeObserver.observe(rootRef.value)
// App.vue
// setSize show +
// " resize"
void applyAutoHeight()
window.setTimeout(async () => {
//
await applyAutoHeight()
try { await win.show() } catch { /* 忽略 */ }
try { await win.unminimize() } catch { /* 忽略 */ }
void commands.downloaderFocusWindow(win.label).catch(() => {})
}, 120)
})
onUnmounted(() => {
progressUnlisten?.()
completeUnlisten?.()
autoResizeObserver?.disconnect()
if (onThemeChange) window.matchMedia('(prefers-color-scheme: dark)').removeEventListener('change', onThemeChange)
window.clearTimeout(cancelArmTimer)
})
</script>
<template>
<div ref="rootRef" class="dlw-root">
<!-- 顶部标题栏可拖动文件名作为标题右侧放操作按钮 + 最小化 + 关闭 -->
<div class="dlw-titlebar" data-tauri-drag-region>
<div class="dlw-title-left" data-tauri-drag-region>
<Loader2 v-if="status === 'active'" class="size-3 shrink-0 animate-spin text-primary" />
<XCircle v-else-if="status === 'error'" class="size-3 shrink-0 text-red-500" />
<Pause v-else class="size-3 shrink-0 text-muted-foreground" />
<span v-if="loading" class="text-xs font-medium text-muted-foreground">下载</span>
<span v-else-if="task" class="dlw-title-name" :title="task.filename"
@mousedown.left.prevent="startDrag">{{ task.filename }}</span>
</div>
<div class="dlw-title-right">
<button v-if="status === 'active'" class="dlw-btn" @click="doPause">
<Pause class="size-3" /> 暂停
</button>
<button v-else-if="status === 'paused'" class="dlw-btn" @click="doResume">
<Play class="size-3" /> 继续
</button>
<button v-if="status !== 'complete' && status !== 'error'" class="dlw-btn dlw-btn-danger" @click="doCancel">
{{ armedCancel ? '再点一次取消' : '取消' }}
</button>
<button v-if="status === 'complete'" class="dlw-btn dlw-btn-primary" @click="doOpenFolder">
<FolderOpen class="size-3" /> 打开文件夹
</button>
<button v-else-if="status === 'error' && task?.dir" class="dlw-btn" @click="doOpenFolder">
<FolderOpen class="size-3" /> 打开目录
</button>
<button class="dlw-icon-btn" title="最小化" @click="doMinimize">
<Minus class="size-3.5" />
</button>
<button class="dlw-icon-btn" title="关闭(下载在后台继续)" @click="doClose">
<X class="size-3.5" />
</button>
</div>
</div>
<!-- 主体状态 + 总进度 + 分段条 -->
<div class="dlw-body">
<div v-if="loading" class="dlw-loading">
<Loader2 class="size-5 animate-spin text-muted-foreground" />
</div>
<template v-else-if="task">
<div class="flex items-center justify-between gap-2 text-xs">
<span class="dlw-status-badge" :class="`tone-${statusTone}`">{{ statusText }}</span>
<span class="truncate text-muted-foreground">
{{ formatByte(completedSize) }} / {{ formatTotal(totalSize) }} · {{ progress }}%
<template v-if="status === 'active' && speed > 0"> · {{ formatByte(speed) }}/s</template>
</span>
</div>
<div class="dlw-bar mt-1.5">
<div class="dlw-bar-fill" :style="{ width: progress + '%' }" />
</div>
<!-- 分段进度横向分段条 -->
<div v-if="showSegments" class="mt-2">
<span class="text-[10px] text-muted-foreground">分段 ({{ segments.length }} 线程)</span>
<div class="dlw-seg-strip mt-1">
<div
v-for="(seg, i) in segments"
:key="i"
class="dlw-seg"
:style="{ width: ((segLen(seg) / totalSpan) * 100).toFixed(2) + '%' }"
:title="`#${i + 1} ${segPercent(seg)}%`"
>
<div class="dlw-seg-fill" :style="{ width: segPercent(seg) + '%' }" />
</div>
</div>
</div>
<div v-if="actionError" class="mt-1.5 truncate text-right text-[10px] text-red-500" @click="clearErrorSoon">
{{ actionError }}
</div>
</template>
<div v-else class="dlw-loading">
<span class="flex items-center text-xs text-muted-foreground">
<AlertCircle class="size-4 mr-1.5" /> 任务已不存在
</span>
</div>
</div>
</div>
</template>
<style scoped>
.dlw-root {
display: flex;
flex-direction: column;
gap: 4px;
padding: 4px 6px 6px;
overflow: hidden;
}
/* 标题栏 */
.dlw-titlebar {
display: flex;
align-items: center;
justify-content: space-between;
height: 34px;
padding: 0 2px 0 8px;
gap: 6px;
user-select: none;
flex-shrink: 0;
}
.dlw-title-left {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
}
.dlw-title-name {
font-size: 12px;
font-weight: 600;
line-height: 1.3;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.dlw-title-right {
display: flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
min-width: 0;
}
/* 图标按钮(最小化/关闭) */
.dlw-icon-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: 5px;
color: var(--muted-foreground);
}
.dlw-icon-btn:hover { background: var(--accent); color: var(--accent-foreground); }
/* --popup-bg mica/acrylic
flex:1 撑满 titlebar 下方的剩余高度避免内容尤其无分段时比窗口矮导致底部露出
白色/透明的窗口背景区空白 */
.dlw-body {
display: flex;
flex-direction: column;
flex: 1 1 auto;
min-height: 0;
border: 1px solid var(--border);
border-radius: 9px;
background: var(--popup-bg, transparent);
padding: 9px 11px 9px;
box-shadow: 0 8px 30px rgb(0 0 0 / 0.18);
}
/* 加载/任务缺失等仅有一行的场景,内容垂直居中于撑满的卡片内 */
.dlw-body > .dlw-loading { flex: 1 1 auto; margin: auto; }
.dlw-loading {
display: flex;
align-items: center;
justify-content: center;
min-height: 56px;
}
.dlw-status-badge {
flex-shrink: 0;
font-size: 10px;
line-height: 1.6;
padding: 0 8px;
border-radius: 999px;
white-space: nowrap;
}
.dlw-status-badge.tone-active { background: var(--primary); color: var(--primary-foreground); }
.dlw-status-badge.tone-success { background: #10b981; color: #fff; }
.dlw-status-badge.tone-destructive { background: #ef4444; color: #fff; }
.dlw-status-badge.tone-muted { background: var(--muted); color: var(--muted-foreground); }
.dlw-bar { height: 7px; border-radius: 999px; background: var(--muted); overflow: hidden; }
.dlw-bar-fill { height: 100%; border-radius: 999px; background: var(--primary); transition: width 0.2s linear; }
.dlw-seg-strip { display: flex; gap: 2px; height: 7px; }
.dlw-seg { height: 100%; border-radius: 3px; background: var(--muted); overflow: hidden; }
.dlw-seg-fill { height: 100%; background: var(--primary); opacity: 0.75; transition: width 0.2s linear; }
/* 操作按钮(紧凑) */
.dlw-btn {
display: inline-flex;
align-items: center;
gap: 4px;
height: 24px;
padding: 0 8px;
font-size: 11px;
border-radius: 6px;
border: 1px solid var(--border);
background: transparent;
color: var(--foreground);
cursor: pointer;
white-space: nowrap;
}
.dlw-btn:hover { background: var(--accent); color: var(--accent-foreground); }
.dlw-btn-danger { color: #ef4444; }
.dlw-btn-danger:hover { background: rgb(239 68 68 / 0.12); color: #dc2626; }
.dlw-btn-primary { background: var(--primary); color: var(--primary-foreground); border-color: transparent; }
/* hover 时显式保持前景色,避免被上面的 .dlw-btn:hover 覆盖成深色导致黑字黑底不可见 */
.dlw-btn-primary:hover { background: var(--primary); color: var(--primary-foreground); filter: brightness(1.05); }
</style>
File diff suppressed because it is too large Load Diff
+362 -232
View File
@@ -5,8 +5,10 @@ import {
ShieldCheck, ShieldOff, Zap, Thermometer, Clock, ChevronDown, ShieldCheck, ShieldOff, Zap, Thermometer, Clock, ChevronDown,
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, Eye, EyeOff, MousePointerClick, Plus, PencilLine,
CircuitBoard, BatteryFull,
} from '@lucide/vue' } 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'
import { toast } from 'vue-sonner' import { toast } from 'vue-sonner'
import { VueDraggable } from 'vue-draggable-plus' import { VueDraggable } from 'vue-draggable-plus'
@@ -23,8 +25,10 @@ import {
type AlertConfig, type AlertConfig,
DEFAULT_COLOR_THEME, DEFAULT_COLOR_THEME,
} from '@/stores/monitorStore' } from '@/stores/monitorStore'
import { STORAGE_KEYS } from '@/lib/constants'
import { useModuleTabs } from '@/lib/use-module-tabs' import { useModuleTabs } from '@/lib/use-module-tabs'
import { fmt, tempColor, loadColor, fmtSpeed, typeLabel, groupDisplayName, groupIcon } from './format' import { fmt, tempColor, fmtSpeed, typeLabel, groupDisplayName, groupIcon } from './format'
import OverviewCard, { type OverviewCardView } from './OverviewCard.vue'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
@@ -193,6 +197,183 @@ const storageDrives = computed<StorageDrive[]>(() => {
const downSpeed = computed(() => fmtSpeed(store.networkSpeed?.downloadBps ?? null)) const downSpeed = computed(() => fmtSpeed(store.networkSpeed?.downloadBps ?? null))
const upSpeed = computed(() => fmtSpeed(store.networkSpeed?.uploadBps ?? null)) const upSpeed = computed(() => fmtSpeed(store.networkSpeed?.uploadBps ?? null))
// ===== + / =====
/** 概览卡片类型 id */
type OverviewCardId = 'cpu' | 'gpu' | 'memory' | 'network' | 'storage' | 'motherboard' | 'battery' | 'psu'
/** 卡片目录:可添加的全部硬件卡片 */
const OVERVIEW_CARD_CATALOG: { id: OverviewCardId; name: string; desc: string; icon: LucideIcon }[] = [
{ id: 'cpu', name: 'CPU', desc: '温度 / 功耗 / 负载', icon: Cpu },
{ id: 'gpu', name: 'GPU', desc: '温度 / 功耗 / 负载', icon: Gauge },
{ id: 'memory', name: '内存', desc: '用量 / 负载', icon: MemoryStick },
{ id: 'network', name: '网络', desc: '下载 / 上传速率', icon: Wifi },
{ id: 'storage', name: '存储', desc: '各硬盘温度 / 容量 / 使用率', icon: HardDrive },
{ id: 'motherboard', name: '主板', desc: '温度 / 风扇', icon: CircuitBoard },
{ id: 'battery', name: '电池', desc: '电量 / 充放电功率', icon: BatteryFull },
{ id: 'psu', name: '电源', desc: '输出功率', icon: Zap },
]
const OVERVIEW_CARDS_VERSION = 1
/** 默认显示的卡片(用户需求:cpu/gpu/内存/网络) */
const DEFAULT_OVERVIEW_CARDS: OverviewCardId[] = ['cpu', 'gpu', 'memory', 'network']
/** 加载持久化的卡片列表(过滤未知 id,空则回退默认) */
function loadOverviewCards(): OverviewCardId[] {
try {
const saved = localStorage.getItem(STORAGE_KEYS.monitorOverviewCards)
if (!saved) return [...DEFAULT_OVERVIEW_CARDS]
const parsed = JSON.parse(saved)
if (parsed?.version !== OVERVIEW_CARDS_VERSION) return [...DEFAULT_OVERVIEW_CARDS]
const ids: string[] = Array.isArray(parsed.cards) ? parsed.cards : []
const valid = ids.filter(id => OVERVIEW_CARD_CATALOG.some(c => c.id === id)) as OverviewCardId[]
return valid.length ? valid : [...DEFAULT_OVERVIEW_CARDS]
} catch {
return [...DEFAULT_OVERVIEW_CARDS]
}
}
/** 当前显示的卡片列表(顺序即显示顺序) */
const overviewCards = ref<OverviewCardId[]>(loadOverviewCards())
function saveOverviewCards() {
try {
localStorage.setItem(STORAGE_KEYS.monitorOverviewCards, JSON.stringify({
version: OVERVIEW_CARDS_VERSION,
cards: overviewCards.value,
}))
} catch { /* 忽略 localStorage 写入失败 */ }
}
/** 编辑模式:显示拖拽把手/移除按钮,启用拖拽排序 */
const overviewEditing = ref(false)
/** 添加卡片 Popover 开关 */
const overviewAddOpen = ref(false)
/** 尚未添加的卡片(添加菜单内容) */
const addableOverviewCards = computed(() =>
OVERVIEW_CARD_CATALOG.filter(c => !overviewCards.value.includes(c.id))
)
function addOverviewCard(id: OverviewCardId) {
if (overviewCards.value.includes(id)) return
overviewCards.value.push(id)
saveOverviewCards()
overviewAddOpen.value = false
}
function removeOverviewCard(id: string) {
overviewCards.value = overviewCards.value.filter(c => c !== id)
saveOverviewCards()
}
/** 拖拽排序结束:持久化新顺序 */
function onOverviewDragEnd() {
saveOverviewCards()
}
// --- // ---
/** 主板分组(motherboard 为空时回退 superio / embeddedcontroller */
const boardGroup = computed(() =>
store.groupById['motherboard'] ?? store.groupById['superio'] ?? store.groupById['embeddedcontroller'] ?? null
)
const boardName = computed(() => boardGroup.value?.sensors[0]?.hardwareName ?? null)
const boardTemp = computed(() =>
boardGroup.value?.sensors.find(s => s.type === 'temperature' && s.value != null)?.value ?? null
)
const boardFan = computed(() =>
boardGroup.value?.sensors.find(s => s.type === 'fan' && s.value != null)?.value ?? null
)
const batteryLevel = computed(() => {
const g = store.groupById['battery']
if (!g) return null
return g.sensors.find(s => s.name === 'Battery Level' && s.type === 'level')?.value
?? g.sensors.find(s => s.type === 'level')?.value
?? null
})
const batteryCharge = computed(() => store.findSensorValue('battery', { name: 'Battery Charge', type: 'power' }))
const batteryDischarge = computed(() => store.findSensorValue('battery', { name: 'Battery Discharge', type: 'power' }))
const batteryName = computed(() => store.groupById['battery']?.sensors[0]?.hardwareName ?? null)
const psuPower = computed(() => store.findSensorValue('psu', { type: 'power' }))
const psuName = computed(() => store.groupById['psu']?.sensors[0]?.hardwareName ?? null)
/** 构建各卡片视图数据(从上面的 computed 提取值并格式化) */
function buildOverviewCardView(id: OverviewCardId): OverviewCardView {
switch (id) {
case 'cpu':
return {
id, title: 'CPU', icon: Cpu, subtitle: cpuModel.value,
main: { label: '封装温度', labelIcon: Thermometer, text: fmt(cpuTemp.value, 0), unit: '°C', colorClass: tempColor(cpuTemp.value) },
subs: [{ label: '功耗', labelIcon: Zap, text: fmt(cpuPower.value, 1), unit: 'W' }],
load: { label: '总负载', value: cpuLoad.value },
}
case 'gpu':
return {
id, title: 'GPU', icon: Gauge, subtitle: gpuModel.value,
main: { label: '核心温度', labelIcon: Thermometer, text: fmt(gpuTemp.value, 0), unit: '°C', colorClass: tempColor(gpuTemp.value) },
subs: [{ label: '功耗', labelIcon: Zap, text: fmt(gpuPower.value, 1), unit: 'W' }],
load: { label: '3D 负载', value: gpuLoad.value },
}
case 'memory':
return {
id, title: '内存', icon: MemoryStick,
subtitle: memTotalGB.value != null ? `${fmt(memTotalGB.value, 0)} GB` : null,
subtitleFull: memModuleModels.value.join(', ') || null,
main: { label: '已使用', labelIcon: MemoryStick, text: fmt(memUsedGB.value, 1), unit: ` / ${fmt(memTotalGB.value, 1)} GB` },
subs: [],
load: { label: '负载', value: memLoad.value },
}
case 'network':
return {
id, title: '网络', icon: Wifi,
main: { label: '下载', labelIcon: ArrowDown, text: downSpeed.value.value, unit: downSpeed.value.unit, colorClass: 'text-sky-500' },
subs: [{ label: '上传', labelIcon: ArrowUp, text: upSpeed.value.value, unit: upSpeed.value.unit, colorClass: 'text-violet-500' }],
load: null,
}
case 'storage':
return {
id, title: '存储', icon: HardDrive,
subtitle: storageDrives.value.length ? `${storageDrives.value.length} 个设备` : null,
main: { label: '', text: '' },
subs: [],
load: null,
wide: true,
}
case 'motherboard':
return {
id, title: '主板', icon: CircuitBoard, subtitle: boardName.value,
main: { label: '温度', labelIcon: Thermometer, text: fmt(boardTemp.value, 0), unit: '°C', colorClass: tempColor(boardTemp.value) },
subs: [{ label: '风扇', text: fmt(boardFan.value, 0), unit: 'RPM' }],
load: null,
}
case 'battery':
return {
id, title: '电池', icon: BatteryFull, subtitle: batteryName.value,
main: { label: '电量', labelIcon: BatteryFull, text: fmt(batteryLevel.value, 0), unit: '%' },
subs: [
{ label: '充电', labelIcon: Zap, text: fmt(batteryCharge.value, 1), unit: 'W' },
{ label: '放电', labelIcon: Zap, text: fmt(batteryDischarge.value, 1), unit: 'W' },
],
load: null,
}
case 'psu':
return {
id, title: '电源', icon: Zap, subtitle: psuName.value,
main: { label: '输出功率', labelIcon: Zap, text: fmt(psuPower.value, 1), unit: 'W' },
subs: [],
load: null,
}
}
}
/** 当前卡片视图列表(与 overviewCards 顺序一一对应) */
const overviewCardViews = computed<OverviewCardView[]>(() =>
overviewCards.value.map(buildOverviewCardView)
)
// ===== ===== // ===== =====
const stateMeta: Record<ConnectionState, { text: string; class: string }> = { const stateMeta: Record<ConnectionState, { text: string; class: string }> = {
idle: { text: '未启动', class: 'bg-muted text-muted-foreground' }, idle: { text: '未启动', class: 'bg-muted text-muted-foreground' },
@@ -928,8 +1109,29 @@ function removeOsdItem(key: string) {
} }
} }
/** 开启 OSD 但监控内核未运行时的提示对话框开关 */
const osdNeedsKernelOpen = ref(false)
/** 从对话框启动内核:先直接开启 OSD(立即显示),内核在后台自行启动 */
async function startKernelForOsd() {
osdNeedsKernelOpen.value = false
// OSDstore 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)
} }
@@ -1139,249 +1341,155 @@ watch(() => store.status?.ready, (ready, prev) => {
<p class="text-sm">正在加载...</p> <p class="text-sm">正在加载...</p>
</div> </div>
<!-- 始终显示卡片网格未启动时数据以占位符显示保持画面完整 --> <template v-else>
<div v-else key="content" class="grid grid-cols-1 md:grid-cols-3 gap-2.5"> <!-- ===== Kernel 状态栏置顶紧凑横条 ===== -->
<!-- CPU温度 + 功耗 + 频率未读数据以 -- 占位 --> <Card class="py-0 gap-0 mb-2.5">
<Card class="py-0 gap-0"> <CardContent class="px-3.5 py-2 flex flex-wrap items-center gap-x-3 gap-y-1.5 text-xs">
<CardHeader class="pb-1.5 px-3.5 pt-2.5"> <span class="flex items-center gap-1.5 font-medium text-sm">
<CardTitle class="flex items-center justify-between text-sm"> <Activity class="size-4 text-primary" />Kernel
<span class="flex items-center gap-1.5"><Cpu class="size-4 text-primary" />CPU</span> </span>
<Tooltip> <span :class="['px-2 py-0.5 rounded-full', stateMeta[store.connState].class]">
{{ stateMeta[store.connState].text }}
</span>
<Badge :variant="store.snapshot?.isAdmin ? 'default' : 'outline'" :class="store.snapshot?.isAdmin ? 'bg-emerald-500 hover:bg-emerald-500' : ''">
{{ store.snapshot?.isAdmin ? '管理员' : '普通' }}
</Badge>
<Badge v-if="store.status?.thingElevated" variant="outline" class="border-emerald-500/50 text-emerald-600 dark:text-emerald-400">
<ShieldCheck class="size-2.5 mr-0.5" />提权
</Badge>
<span class="text-muted-foreground">PID: <span class="font-mono text-foreground">{{ store.status?.pid ?? '--' }}</span></span>
<span class="text-muted-foreground">传感器: <span class="font-mono text-foreground">{{ store.status?.sensorCount ?? '--' }}</span></span>
<span class="text-muted-foreground">重启: <span class="font-mono text-foreground">{{ store.status?.restartCount ?? 0 }}</span></span>
<span class="text-muted-foreground">事件: <span class="font-mono text-foreground">{{ store.eventCount }}</span></span>
<div class="flex items-center gap-1.5 ml-auto">
<!-- 启动中 loadingstarting=true 但状态还没变为 loading 时显示 -->
<Button v-if="store.starting && store.connState === 'idle'" size="xs" variant="outline" disabled>
<Loader2 class="size-3 animate-spin" />启动中
</Button>
<!-- 启动按钮未运行且非启动中时显示 -->
<Button v-if="store.connState === 'idle' && !store.starting" size="xs" :disabled="store.starting" @click="handleStart">
<Play class="size-3" />启动
</Button>
<!-- 提权按钮标志未启用时显示设置标志 + 以管理员权限重启 Thing -->
<Tooltip v-if="!store.elevateOnLaunch">
<TooltipTrigger as-child> <TooltipTrigger as-child>
<span class="text-xs text-muted-foreground font-normal truncate ml-2">{{ cpuModel ?? '--' }}</span> <Button size="xs" variant="outline" class="gap-1 text-emerald-600 dark:text-emerald-400 border-emerald-500/40 hover:bg-emerald-500/10" :disabled="store.starting" @click="handleElevateSelf">
<Loader2 v-if="store.starting" class="size-3 animate-spin" />
<ShieldCheck v-else class="size-3" />提权
</Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent>{{ cpuModel ?? '' }}</TooltipContent> <TooltipContent class="max-w-[480px] break-words">以管理员权限重启 Thing UACThingHK 子进程继承权限后续启动自动提权崩溃自动重启</TooltipContent>
</Tooltip> </Tooltip>
</CardTitle> <!-- 取消提权按钮标志已启用时显示清除标志下次启动不触发 UAC -->
</CardHeader> <Tooltip v-else>
<CardContent class="px-3.5 pb-2.5 space-y-1.5">
<!-- 温度 + 功耗 -->
<div class="flex items-end justify-between gap-2">
<div>
<div class="text-xs text-muted-foreground flex items-center gap-1"><Thermometer class="size-3" />封装温度</div>
<div :class="['text-2xl font-bold tabular-nums leading-tight', tempColor(cpuTemp)]">
{{ fmt(cpuTemp, 0) }}<span class="text-sm font-normal">°C</span>
</div>
</div>
<div class="text-right">
<div class="text-xs text-muted-foreground flex items-center gap-1 justify-end"><Zap class="size-3" />功耗</div>
<div class="text-base font-medium tabular-nums">{{ fmt(cpuPower, 1) }}<span class="text-xs text-muted-foreground ml-0.5">W</span></div>
</div>
</div>
<!-- 负载 -->
<div>
<div class="flex items-center justify-between text-xs mb-0.5">
<span class="text-muted-foreground flex items-center gap-1"><Gauge class="size-3" />总负载</span>
<span :class="['font-medium tabular-nums', loadColor(cpuLoad)]">{{ fmt(cpuLoad, 0) }}%</span>
</div>
<Progress :model-value="cpuLoad ?? 0" class="h-1.5" />
</div>
</CardContent>
</Card>
<!-- GPU结构与 CPU 一致温度 + 功耗未读数据以 -- 占位 -->
<Card class="py-0 gap-0">
<CardHeader class="pb-1.5 px-3.5 pt-2.5">
<CardTitle class="flex items-center justify-between text-sm">
<span class="flex items-center gap-1.5"><Gauge class="size-4 text-primary" />GPU</span>
<Tooltip>
<TooltipTrigger as-child> <TooltipTrigger as-child>
<span class="text-xs text-muted-foreground font-normal truncate ml-2">{{ gpuModel ?? '--' }}</span> <Button size="xs" variant="outline" class="gap-1 text-muted-foreground hover:text-foreground" @click="handleCancelElevation">
<ShieldOff class="size-3" />取消提权
</Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent>{{ gpuModel ?? '' }}</TooltipContent> <TooltipContent class="max-w-[480px] break-words">取消提权下次启动将以普通权限运行不影响当前会话</TooltipContent>
</Tooltip> </Tooltip>
</CardTitle> <Button size="xs" variant="outline" :disabled="store.starting || store.stopping || store.connState === 'idle'" @click="handleRefresh">
</CardHeader> <RefreshCw class="size-3" />刷新
<CardContent class="px-3.5 pb-2.5 space-y-1.5"> </Button>
<!-- 温度 + 功耗 CPU 卡片结构一致 --> <Button size="xs" variant="outline" :disabled="store.starting || store.stopping || store.connState === 'idle'" @click="handleStop">
<div class="flex items-end justify-between gap-2"> <Square class="size-3" />停止
<div> </Button>
<div class="text-xs text-muted-foreground flex items-center gap-1"><Thermometer class="size-3" />核心温度</div> <Separator orientation="vertical" class="h-4 mx-0.5" />
<div :class="['text-2xl font-bold tabular-nums leading-tight', tempColor(gpuTemp)]"> <!-- 编辑布局开关拖拽排序 / 删除卡片 -->
{{ fmt(gpuTemp, 0) }}<span class="text-sm font-normal">°C</span> <Button
</div> size="xs"
</div> :variant="overviewEditing ? 'default' : 'outline'"
<div class="text-right"> :title="overviewEditing ? '完成编辑' : '编辑卡片布局(拖拽排序 / 删除)'"
<div class="text-xs text-muted-foreground flex items-center gap-1 justify-end"><Zap class="size-3" />功耗</div> @click="overviewEditing = !overviewEditing"
<div class="text-base font-medium tabular-nums">{{ fmt(gpuPower, 1) }}<span class="text-xs text-muted-foreground ml-0.5">W</span></div> >
</div> <PencilLine class="size-3" />{{ overviewEditing ? '完成' : '编辑布局' }}
</div> </Button>
<!-- 负载 --> <!-- 添加卡片 -->
<div> <Popover v-model:open="overviewAddOpen">
<div class="flex items-center justify-between text-xs mb-0.5"> <PopoverTrigger as-child>
<span class="text-muted-foreground flex items-center gap-1"><Gauge class="size-3" />3D 负载</span> <Button size="xs" variant="outline" :disabled="!addableOverviewCards.length" title="添加硬件卡片">
<span :class="['font-medium tabular-nums', loadColor(gpuLoad)]">{{ fmt(gpuLoad, 0) }}%</span> <Plus class="size-3" />添加卡片
</div> </Button>
<Progress :model-value="gpuLoad ?? 0" class="h-1.5" /> </PopoverTrigger>
<PopoverContent class="w-64 p-1.5" align="end">
<button
v-for="c in addableOverviewCards"
:key="c.id"
class="w-full flex items-center gap-2.5 rounded-md px-2.5 py-2 text-sm hover:bg-accent transition-colors text-left"
@click="addOverviewCard(c.id)"
>
<component :is="c.icon" class="size-4 text-primary shrink-0" />
<span class="min-w-0">
<span class="block truncate">{{ c.name }}</span>
<span class="block text-xs text-muted-foreground truncate">{{ c.desc }}</span>
</span>
</button>
<div v-if="!addableOverviewCards.length" class="px-2.5 py-2 text-xs text-muted-foreground text-center">所有卡片均已添加</div>
</PopoverContent>
</Popover>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
<!-- 内存主指标大字 + 进度条未读数据以 -- 占位 --> <!-- ===== 硬件信息卡片网格模板化可编辑拖拽排序 / 删除 / 添加 ===== -->
<Card class="py-0 gap-0"> <VueDraggable
<CardHeader class="pb-1.5 px-3.5 pt-2.5"> v-model="overviewCards"
<CardTitle class="flex items-center justify-between text-sm"> :animation="200"
<span class="flex items-center gap-1.5"><MemoryStick class="size-4 text-primary" />内存</span> :force-fallback="true"
<Tooltip> handle=".overview-drag-handle"
<TooltipTrigger as-child> ghost-class="opacity-40"
<span class="text-xs text-muted-foreground font-normal truncate ml-2"> chosen-class="drag-chosen"
{{ memTotalGB != null ? fmt(memTotalGB, 0) + ' GB' : '--' }} :disabled="!overviewEditing"
</span> class="grid grid-cols-1 md:grid-cols-4 gap-2.5"
</TooltipTrigger> @end="onOverviewDragEnd()"
<TooltipContent>{{ memModuleModels.join(', ') }}</TooltipContent> >
</Tooltip> <OverviewCard
</CardTitle> v-for="view in overviewCardViews"
</CardHeader> :key="view.id"
<CardContent class="px-3.5 pb-2.5 space-y-1.5"> :view="view"
<!-- 已使用 / 总容量主指标 CPU 温度对齐 --> :editing="overviewEditing"
<div class="flex items-end justify-between gap-2"> @remove="removeOverviewCard(view.id)"
<div> >
<div class="text-xs text-muted-foreground flex items-center gap-1"><MemoryStick class="size-3" />已使用</div> <!-- 存储卡多盘列表 -->
<div class="text-2xl font-bold tabular-nums leading-tight"> <template v-if="view.id === 'storage'" #body>
{{ fmt(memUsedGB, 1) }}<span class="text-sm font-normal text-muted-foreground"> / {{ fmt(memTotalGB, 1) }} GB</span> <div v-if="storageDrives.length" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2">
</div> <div v-for="drive in storageDrives" :key="drive.name" class="border rounded-md p-2 space-y-1">
</div> <div class="flex items-center justify-between gap-2">
</div> <Tooltip>
<!-- 负载进度条 CPU 负载对齐 --> <TooltipTrigger as-child>
<div> <span class="text-xs font-medium truncate">{{ drive.name }}</span>
<div class="flex items-center justify-between text-xs mb-0.5"> </TooltipTrigger>
<span class="text-muted-foreground flex items-center gap-1"><Gauge class="size-3" />负载</span> <TooltipContent>{{ drive.name }}</TooltipContent>
<span :class="['font-medium tabular-nums', loadColor(memLoad)]">{{ fmt(memLoad, 0) }}%</span> </Tooltip>
</div> <span :class="['text-xs font-mono tabular-nums shrink-0', tempColor(drive.temp)]">{{ fmt(drive.temp, 0) }}°C</span>
<Progress :model-value="memLoad ?? 0" class="h-1.5" /> </div>
</div> <div>
</CardContent> <div class="flex items-center justify-between text-xs mb-0.5">
</Card> <span class="text-muted-foreground">使用率</span>
<span class="font-mono tabular-nums">{{ fmt(drive.usedPct, 0) }}%</span>
<!-- 网络跨3列下载/上传速率独立于 Kernel Tauri 后台推送 --> </div>
<Card class="md:col-span-3 py-0 gap-0"> <Progress :model-value="drive.usedPct ?? 0" class="h-1" />
<CardHeader class="pb-1.5 px-3.5 pt-2.5"> </div>
<CardTitle class="flex items-center gap-1.5 text-sm"> <div class="flex items-center justify-between text-xs text-muted-foreground">
<Wifi class="size-4 text-primary" />网络 <span>容量</span>
</CardTitle> <span class="font-mono tabular-nums">{{ fmt(drive.usedGB, 1) }} / {{ fmt(drive.totalGB, 1) }} GB</span>
</CardHeader>
<CardContent class="px-3.5 pb-2.5">
<div class="grid grid-cols-2 gap-4">
<!-- 下载 -->
<div>
<div class="text-xs text-muted-foreground flex items-center gap-1 mb-0.5"><ArrowDown class="size-3 text-sky-500" />下载</div>
<div class="text-2xl font-bold tabular-nums leading-tight text-sky-500">
{{ downSpeed.value }}<span class="text-sm font-normal text-muted-foreground ml-0.5">{{ downSpeed.unit }}</span>
</div>
</div>
<!-- 上传 -->
<div>
<div class="text-xs text-muted-foreground flex items-center gap-1 mb-0.5"><ArrowUp class="size-3 text-violet-500" />上传</div>
<div class="text-2xl font-bold tabular-nums leading-tight text-violet-500">
{{ upSpeed.value }}<span class="text-sm font-normal text-muted-foreground ml-0.5">{{ upSpeed.unit }}</span>
</div>
</div>
</div>
</CardContent>
</Card>
<!-- 存储跨3列列出各硬盘温度/容量/使用率未读到以占位符显示 -->
<Card class="md:col-span-3 py-0 gap-0">
<CardHeader class="pb-1.5 px-3.5 pt-2.5">
<CardTitle class="flex items-center gap-1.5 text-sm">
<HardDrive class="size-4 text-primary" />存储
</CardTitle>
</CardHeader>
<CardContent class="px-3.5 pb-2.5">
<div v-if="storageDrives.length" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2">
<div v-for="drive in storageDrives" :key="drive.name" class="border rounded-md p-2 space-y-1">
<div class="flex items-center justify-between gap-2">
<Tooltip>
<TooltipTrigger as-child>
<span class="text-xs font-medium truncate">{{ drive.name }}</span>
</TooltipTrigger>
<TooltipContent>{{ drive.name }}</TooltipContent>
</Tooltip>
<span :class="['text-xs font-mono tabular-nums shrink-0', tempColor(drive.temp)]">{{ fmt(drive.temp, 0) }}°C</span>
</div>
<div>
<div class="flex items-center justify-between text-xs mb-0.5">
<span class="text-muted-foreground">使用率</span>
<span class="font-mono tabular-nums">{{ fmt(drive.usedPct, 0) }}%</span>
</div> </div>
<Progress :model-value="drive.usedPct ?? 0" class="h-1" />
</div>
<div class="flex items-center justify-between text-xs text-muted-foreground">
<span>容量</span>
<span class="font-mono tabular-nums">{{ fmt(drive.usedGB, 1) }} / {{ fmt(drive.totalGB, 1) }} GB</span>
</div> </div>
</div> </div>
</div> <div v-else class="text-xs text-muted-foreground py-2 text-center">暂无存储数据</div>
<!-- 无数据占位保持卡片结构完整 --> </template>
<div v-else class="text-xs text-muted-foreground py-2 text-center">暂无存储数据</div> </OverviewCard>
</CardContent> <!-- 空占位全部卡片移除后显示 -->
</Card> <Card v-if="!overviewCards.length" class="col-span-full py-0 gap-0 no-drag">
<CardContent class="py-8 text-center text-sm text-muted-foreground">
<!-- Kernel 状态卡片跨3列含启动/停止/刷新/提权按钮 --> 暂无卡片点击上方"添加卡片"添加硬件
<Card class="md:col-span-3 py-0 gap-0"> </CardContent>
<CardHeader class="pb-1.5 px-3.5 pt-2.5"> </Card>
<CardTitle class="flex items-center justify-between text-sm"> </VueDraggable>
<span class="flex items-center gap-1.5"><Activity class="size-4 text-primary" />Kernel 状态</span>
<div class="flex items-center gap-1.5">
<span :class="['text-xs px-2 py-0.5 rounded-full', stateMeta[store.connState].class]">
{{ stateMeta[store.connState].text }}
</span>
<Badge :variant="store.snapshot?.isAdmin ? 'default' : 'outline'" :class="store.snapshot?.isAdmin ? 'bg-emerald-500 hover:bg-emerald-500' : ''">
{{ store.snapshot?.isAdmin ? '管理员' : '普通' }}
</Badge>
<Badge v-if="store.status?.thingElevated" variant="outline" class="text-xs border-emerald-500/50 text-emerald-600 dark:text-emerald-400">
<ShieldCheck class="size-2.5 mr-0.5" />提权
</Badge>
</div>
</CardTitle>
</CardHeader>
<CardContent class="px-3.5 pb-2.5">
<div class="flex flex-wrap items-center gap-x-4 gap-y-1.5 text-xs">
<span class="text-muted-foreground">PID: <span class="font-mono text-foreground">{{ store.status?.pid ?? '--' }}</span></span>
<span class="text-muted-foreground">传感器: <span class="font-mono text-foreground">{{ store.status?.sensorCount ?? '--' }}</span></span>
<span class="text-muted-foreground">重启: <span class="font-mono text-foreground">{{ store.status?.restartCount ?? 0 }}</span></span>
<span class="text-muted-foreground">事件: <span class="font-mono text-foreground">{{ store.eventCount }}</span></span>
<div class="flex items-center gap-1.5 ml-auto">
<!-- 启动中 loadingstarting=true 但状态还没变为 loading 时显示 -->
<Button v-if="store.starting && store.connState === 'idle'" size="xs" variant="outline" disabled>
<Loader2 class="size-3 animate-spin" />启动中
</Button>
<!-- 启动按钮未运行且非启动中时显示 -->
<Button v-if="store.connState === 'idle' && !store.starting" size="xs" :disabled="store.starting" @click="handleStart">
<Play class="size-3" />启动
</Button>
<!-- 提权按钮标志未启用时显示设置标志 + 以管理员权限重启 Thing -->
<Tooltip v-if="!store.elevateOnLaunch">
<TooltipTrigger as-child>
<Button size="xs" variant="outline" class="gap-1 text-emerald-600 dark:text-emerald-400 border-emerald-500/40 hover:bg-emerald-500/10" :disabled="store.starting" @click="handleElevateSelf">
<Loader2 v-if="store.starting" class="size-3 animate-spin" />
<ShieldCheck v-else class="size-3" />提权
</Button>
</TooltipTrigger>
<TooltipContent class="max-w-[480px] break-words">以管理员权限重启 Thing UACThingHK 子进程继承权限后续启动自动提权崩溃自动重启</TooltipContent>
</Tooltip>
<!-- 取消提权按钮标志已启用时显示清除标志下次启动不触发 UAC -->
<Tooltip v-else>
<TooltipTrigger as-child>
<Button size="xs" variant="outline" class="gap-1 text-muted-foreground hover:text-foreground" @click="handleCancelElevation">
<ShieldOff class="size-3" />取消提权
</Button>
</TooltipTrigger>
<TooltipContent class="max-w-[480px] break-words">取消提权下次启动将以普通权限运行不影响当前会话</TooltipContent>
</Tooltip>
<Button size="xs" variant="outline" :disabled="store.starting || store.stopping || store.connState === 'idle'" @click="handleRefresh">
<RefreshCw class="size-3" />刷新
</Button>
<Button size="xs" variant="outline" :disabled="store.starting || store.stopping || store.connState === 'idle'" @click="handleStop">
<Square class="size-3" />停止
</Button>
</div>
</div>
</CardContent>
</Card>
<!-- 断线提示 --> <!-- 断线提示 -->
<Card v-if="store.connState === 'disconnected'" class="md:col-span-3 border-orange-500/40 py-0 gap-0"> <Card v-if="store.connState === 'disconnected'" class="mt-2.5 border-orange-500/40 py-0 gap-0">
<CardContent class="pt-3 flex items-start gap-2 text-sm"> <CardContent class="pt-3 flex items-start gap-2 text-sm">
<AlertTriangle class="size-4 text-orange-500 mt-0.5 shrink-0" /> <AlertTriangle class="size-4 text-orange-500 mt-0.5 shrink-0" />
<div> <div>
@@ -1392,7 +1500,7 @@ watch(() => store.status?.ready, (ready, prev) => {
</Card> </Card>
<!-- 错误提示 --> <!-- 错误提示 -->
<Card v-if="store.errorMsg" class="md:col-span-3 border-red-500/40 py-0 gap-0"> <Card v-if="store.errorMsg" class="mt-2.5 border-red-500/40 py-0 gap-0">
<CardContent class="pt-3 flex items-start gap-2 text-sm"> <CardContent class="pt-3 flex items-start gap-2 text-sm">
<AlertTriangle class="size-4 text-red-500 mt-0.5 shrink-0" /> <AlertTriangle class="size-4 text-red-500 mt-0.5 shrink-0" />
<div> <div>
@@ -1401,7 +1509,7 @@ watch(() => store.status?.ready, (ready, prev) => {
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
</div> </template>
</ScrollArea> </ScrollArea>
</TabsContent> </TabsContent>
@@ -2317,6 +2425,28 @@ watch(() => store.status?.ready, (ready, prev) => {
</DialogContent> </DialogContent>
</Dialog> </Dialog>
<!-- 开启 OSD 但监控内核未运行提示 -->
<Dialog v-model:open="osdNeedsKernelOpen">
<DialogContent class="max-w-md">
<DialogHeader>
<DialogTitle class="flex items-center gap-2">
<TriangleAlert class="size-4 text-amber-500" />
需先启动监控内核
</DialogTitle>
<DialogDescription>
OSD 悬浮窗依赖监控内核提供数据当前内核未运行请先启动内核后再开启 OSD 显示
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" @click="osdNeedsKernelOpen = false">取消</Button>
<Button @click="startKernelForOsd">
<Play class="size-3.5" />
启动内核
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<!-- OSD 显示项选择 Dialog --> <!-- OSD 显示项选择 Dialog -->
<Dialog v-model:open="osdPickDialogOpen"> <Dialog v-model:open="osdPickDialogOpen">
<DialogContent class="max-w-lg"> <DialogContent class="max-w-lg">
+49 -33
View File
@@ -77,31 +77,19 @@ interface OsdConfig {
overlayY?: number | null overlayY?: number | null
} }
interface SensorEntry {
name: string
type: string
hardwareName: string
value: number | null
unit: string
}
interface SensorGroup {
id: string
sensors: SensorEntry[]
}
interface SensorSnapshot {
groups: SensorGroup[]
}
interface NetworkSpeed { interface NetworkSpeed {
downloadBps: number downloadBps: number
uploadBps: number uploadBps: number
} }
/** 配置通道载荷(低频:配置变化时推送) */
interface OsdStatePayload { interface OsdStatePayload {
config: OsdConfig config: OsdConfig
snapshot: SensorSnapshot | null }
/** 数据通道载荷(高频:仅显示项 key→value 映射 + 网速) */
interface OsdDataPayload {
data: Record<string, number | null>
networkSpeed: NetworkSpeed | null networkSpeed: NetworkSpeed | null
} }
@@ -333,7 +321,8 @@ function fmtFixedUnit(item: OsdItem): string {
// ===== ===== // ===== =====
const config = ref<OsdConfig | null>(null) const config = ref<OsdConfig | null>(null)
const snapshot = ref<SensorSnapshot | null>(null) /** 数据通道:显示项 key→value 映射(由主窗口每秒推送,替代全量快照) */
const dataMap = ref<Record<string, number | null>>({})
const networkSpeed = ref<NetworkSpeed | null>(null) const networkSpeed = ref<NetworkSpeed | null>(null)
let unlistenFns: UnlistenFn[] = [] let unlistenFns: UnlistenFn[] = []
@@ -341,15 +330,7 @@ let unlistenFns: UnlistenFn[] = []
function getOsdItemValue(item: OsdItem): number | null { function getOsdItemValue(item: OsdItem): number | null {
if (item.special === 'net-up') return networkSpeed.value?.uploadBps ?? null if (item.special === 'net-up') return networkSpeed.value?.uploadBps ?? null
if (item.special === 'net-down') return networkSpeed.value?.downloadBps ?? null if (item.special === 'net-down') return networkSpeed.value?.downloadBps ?? null
if (!snapshot.value) return null return dataMap.value[item.key] ?? null
for (const g of snapshot.value.groups) {
if (g.id !== item.groupId) continue
const s = g.sensors.find(s =>
s.hardwareName === item.hardwareName && s.name === item.sensorName && s.type === item.type
)
if (s) return s.value ?? null
}
return null
} }
// ===== ===== // ===== =====
@@ -463,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 穿
@@ -514,15 +512,19 @@ onMounted(async () => {
console.error('[OSD] 启动置顶监视失败:', e) console.error('[OSD] 启动置顶监视失败:', e)
} }
// OSD // OSD
unlistenFns.push(await listen<OsdStatePayload>('osd-state-update', (e) => { unlistenFns.push(await listen<OsdStatePayload>(EVENTS.osdStateUpdate, (e) => {
config.value = e.payload.config config.value = e.payload.config
snapshot.value = e.payload.snapshot // //
networkSpeed.value = e.payload.networkSpeed
// /
scheduleMeasure() scheduleMeasure()
})) }))
// OSD keyvalue +
unlistenFns.push(await listen<OsdDataPayload>(EVENTS.osdDataUpdate, (e) => {
dataMap.value = e.payload.data
networkSpeed.value = e.payload.networkSpeed
}))
// UI // UI
unlistenFns.push(await listen(EVENTS.osdSystemUiActive, async () => { unlistenFns.push(await listen(EVENTS.osdSystemUiActive, async () => {
await applyTopmost(false) await applyTopmost(false)
@@ -531,6 +533,10 @@ onMounted(async () => {
unlistenFns.push(await listen(EVENTS.osdSystemUiInactive, async () => { unlistenFns.push(await listen(EVENTS.osdSystemUiInactive, async () => {
await applyTopmost(true) await applyTopmost(true)
})) }))
// +
//
await emit(EVENTS.osdConfigRequest)
}) })
/** 鼠标按下:仅在关闭穿透时响应左键拖动 */ /** 鼠标按下:仅在关闭穿透时响应左键拖动 */
@@ -548,6 +554,9 @@ onUnmounted(() => {
unlistenFns.forEach(fn => fn()) unlistenFns.forEach(fn => fn())
// 线 // 线
invoke('osd_stop_watch').catch(() => {}) invoke('osd_stop_watch').catch(() => {})
//
resizeObserver?.disconnect()
resizeObserver = null
}) })
</script> </script>
@@ -652,6 +661,13 @@ onUnmounted(() => {
display: inline-flex; display: inline-flex;
flex-wrap: nowrap; flex-wrap: nowrap;
align-items: stretch; align-items: stretch;
/* resize
若允许换行中文标签内存/网络会在旧窗口宽度内竖排禁止后仅临时溢出
随内容测量上报触发 setSize 立即恢复 */
white-space: nowrap;
/* osd-root100vw getBoundingClientRect
被旧窗口钳制的宽度而非真实内容宽度上报后 setSize 不变窗口永远无法变宽 */
flex-shrink: 0;
backdrop-filter: blur(8px); backdrop-filter: blur(8px);
padding: 3px 4px; padding: 3px 4px;
gap: 0; gap: 0;
+179
View File
@@ -0,0 +1,179 @@
<script setup lang="ts">
/**
* 概览页信息卡片模板统一 CPU/GPU/内存/网络/主板/电池/电源等硬件卡片的结构
* - 标题行图标 + 标题 + 右侧次要信息截断 + tooltip
* - 主体主指标大字+ 次指标右侧小字+ 负载进度条
* - #body 插槽可整体替换主体如存储卡的多盘列表
* - 编辑模式显示拖拽把手.overview-drag-handle与移除按钮
*/
import type { LucideIcon } from '@lucide/vue'
import { GripVertical } from '@lucide/vue'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Progress } from '@/components/ui/progress'
import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'
import { computed, ref } from 'vue'
import { useElementSize } from '@vueuse/core'
import { fmt, loadColor } from './format'
export interface CardMetric {
/** 指标名(如 封装温度/功耗) */
label: string
/** 已格式化的值文本(null 数据传 '--',由 fmt 处理) */
text: string
/** 值后缀单位(小号弱化显示,可为 ' / 32.0 GB' 这类复合后缀) */
unit?: string
/** 值颜色 class(温度/负载等着色) */
colorClass?: string
/** 标签前小图标 */
labelIcon?: LucideIcon
}
export interface CardLoad {
label: string
value: number | null
}
export interface OverviewCardView {
/** 卡片类型 id(同目录卡片持久化标识) */
id: string
title: string
icon: LucideIcon
/** 右上角次要信息(型号等,截断 + tooltip 完整内容) */
subtitle?: string | null
/** tooltip 完整内容(默认同 subtitle */
subtitleFull?: string | null
/** 主指标(大字) */
main: CardMetric
/** 次指标(右侧小字) */
subs: CardMetric[]
/** 负载进度条(null 不显示) */
load?: CardLoad | null
/** 是否占满整行(如存储多盘列表) */
wide?: boolean
}
const props = withDefaults(defineProps<{
view: OverviewCardView
/** 编辑模式:显示拖拽把手与移除按钮 */
editing?: boolean
}>(), { editing: false })
defineEmits<{ remove: [] }>()
/**
* 型号显示文本超长时截断头部保留尾部 (TM) i5-10400
* 后缀型号核心部分通常最具辨识度完整内容见 tooltip
* 型号 span 自身为 flex-1宽度由布局决定不随内容变化直接测量它
* canvas 按该元素实际计算字体测量 + 二分查找保留最长尾部
* 编辑模式把手/删除按钮占位时 span 自动变窄截断随之收紧
*/
const subtitleRef = ref<HTMLElement | null>(null)
const { width: subtitleAvailWidth } = useElementSize(subtitleRef)
let measureCtx: CanvasRenderingContext2D | null = null
/** 按型号 span 的实际计算字体测量文本宽度;canvas 不可用时按字符数估算 */
function textWidth(text: string): number {
const el = subtitleRef.value
if (!measureCtx) measureCtx = document.createElement('canvas').getContext('2d')
if (!measureCtx || !el) return text.length * 7
const cs = getComputedStyle(el)
measureCtx.font = `${cs.fontWeight} ${cs.fontSize} ${cs.fontFamily}`
return measureCtx.measureText(text).width
}
const displaySubtitle = computed(() => {
const s = props.view.subtitle
if (!s) return s
const avail = subtitleAvailWidth.value - 2 // /
if (avail <= 0) return s // CSS truncate
if (textWidth(s) <= avail) return s
// n'' + n
let lo = 1
let hi = s.length
while (lo < hi) {
const mid = Math.ceil((lo + hi) / 2)
if (textWidth(`${s.slice(-mid)}`) <= avail) lo = mid
else hi = mid - 1
}
return `${s.slice(-lo)}`
})
</script>
<template>
<Card
:class="[
'py-0 gap-0 overflow-hidden',
view.wide ? 'col-span-full' : '',
]"
>
<CardHeader class="pb-1.5 px-3.5 pt-2.5">
<CardTitle class="flex items-center gap-1.5 text-sm">
<!-- 扁平单行 flex把手/图标/标题 + 型号flex-1 占满剩余+ 删除按钮 -->
<span
v-if="editing"
class="overview-drag-handle cursor-grab active:cursor-grabbing text-muted-foreground/40 hover:text-muted-foreground transition-colors no-native-drag shrink-0"
title="拖动排序"
>
<GripVertical class="size-3.5" />
</span>
<component :is="view.icon" class="size-4 text-primary shrink-0" />
<span class="truncate">{{ view.title }}</span>
<!-- 型号直接 flex 子项自动块化truncate 生效flex-1 宽度由布局决定 -->
<Tooltip v-if="view.subtitle">
<TooltipTrigger as-child>
<span ref="subtitleRef" class="min-w-0 flex-1 truncate text-right text-xs text-muted-foreground font-normal">{{ displaySubtitle }}</span>
</TooltipTrigger>
<!-- align=end触发 span flex-1 撑满标题行默认居中对齐会让 tooltip 落在卡片中央
对齐右缘使其出现在右对齐的型号文字正上方 -->
<TooltipContent align="end">{{ view.subtitleFull ?? view.subtitle }}</TooltipContent>
</Tooltip>
<span v-else class="flex-1" aria-hidden="true" />
<Button
v-if="editing"
size="icon-xs"
variant="ghost"
class="text-muted-foreground hover:text-destructive shrink-0"
title="移除卡片"
@click="$emit('remove')"
>
<span class="text-lg leading-none">×</span>
</Button>
</CardTitle>
</CardHeader>
<CardContent class="px-3.5 pb-2.5 space-y-1.5">
<!-- 自定义主体如存储多盘列表 -->
<slot name="body">
<!-- 主指标 + 次指标 -->
<div class="flex items-end justify-between gap-2">
<div class="min-w-0">
<div class="text-xs text-muted-foreground flex items-center gap-1">
<component :is="view.main.labelIcon" v-if="view.main.labelIcon" class="size-3 shrink-0" />
<span class="truncate">{{ view.main.label }}</span>
</div>
<div :class="['text-2xl font-bold tabular-nums leading-tight', view.main.colorClass]">
{{ view.main.text }}<span v-if="view.main.unit" class="text-sm font-normal text-muted-foreground">{{ view.main.unit }}</span>
</div>
</div>
<div v-for="sub in view.subs" :key="sub.label" class="text-right shrink-0">
<div class="text-xs text-muted-foreground flex items-center gap-1 justify-end">
<component :is="sub.labelIcon" v-if="sub.labelIcon" class="size-3 shrink-0" />
<span>{{ sub.label }}</span>
</div>
<div :class="['text-base font-medium tabular-nums', sub.colorClass]">
{{ sub.text }}<span v-if="sub.unit" class="text-xs text-muted-foreground ml-0.5 font-normal">{{ sub.unit }}</span>
</div>
</div>
</div>
<!-- 负载进度条 -->
<div v-if="view.load">
<div class="flex items-center justify-between text-xs mb-0.5">
<span class="text-muted-foreground">{{ view.load.label }}</span>
<span :class="['font-medium tabular-nums', loadColor(view.load.value)]">{{ fmt(view.load.value, 0) }}%</span>
</div>
<Progress :model-value="view.load.value ?? 0" class="h-1.5" />
</div>
</slot>
</CardContent>
</Card>
</template>
+526 -116
View File
@@ -2,17 +2,20 @@
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'
import { invoke } from '@tauri-apps/api/core' import { invoke } from '@tauri-apps/api/core'
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'
import { EVENTS } from '@/lib/constants'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
@@ -66,7 +69,14 @@ const onConfirmCancel = () => {
confirmState.value.resolve?.(false) confirmState.value.resolve?.(false)
} }
const onConfirmOpenChange = (open: boolean) => { const onConfirmOpenChange = (open: boolean) => {
if (!open) onConfirmCancel() // reka-ui AlertDialogAction/Cancel update:open(false)
// @click
// click
if (!open && !confirmState.value.resolved) {
setTimeout(() => {
if (!confirmState.value.resolved) onConfirmCancel()
}, 0)
}
confirmState.value.open = open confirmState.value.open = open
} }
@@ -75,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: '设置' }
@@ -90,16 +101,13 @@ const testingGroups = ref<Set<string>>(new Set())
const loadingProxies = ref(false) const loadingProxies = ref(false)
const checkingUpdate = ref(false) const checkingUpdate = ref(false)
const updatingKernel = ref(false) const updatingKernel = ref(false)
const kernelUpdateInfo = ref<{ latestVersion: string; hasUpdate: boolean } | null>(null) const kernelUpdateInfo = ref<{ latestVersion: string; hasUpdate: boolean; downloadUrl: string } | null>(null)
// settings // settings
const autoSwitchEnabled = ref(false) const autoSwitchEnabled = ref(false)
const autoSwitchInterval = ref(5) // const autoSwitchInterval = ref(5) //
const autoSwitchTargetGroup = ref('') // const autoSwitchTargetGroup = ref('') //
const autoSwitchRegion = ref('') // = const autoSwitchRegion = ref('') // =
let autoSwitchTimer: ReturnType<typeof setInterval> | null = null
/** 自动切换执行中标志(防重入:测速超时时上一轮未结束,间隔触发会重叠) */
let autoSwitchRunning = false
// store.settings // store.settings
const syncAutoSwitchSettings = () => { const syncAutoSwitchSettings = () => {
@@ -127,14 +135,187 @@ const saveAutoSwitchSettings = async () => {
} }
} }
//
let autoSwitchUnlisten: UnlistenFn[] = []
// //
const accordionValue = ref<string>('') 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',
@@ -328,10 +509,6 @@ const init = async () => {
await store.waitForApi() await store.waitForApi()
store.refreshVersion() store.refreshVersion()
loadProxiesWithError() loadProxiesWithError()
//
if (autoSwitchEnabled.value) {
startAutoSwitch(false, false)
}
} }
} finally { } finally {
// "" // ""
@@ -348,14 +525,31 @@ 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)
//
listen<{ switched?: boolean; group?: string; name?: string; delay?: number }>(EVENTS.proxyAutoSwitch, onProxyAutoSwitch)
.then(fn => autoSwitchUnlisten.push(fn))
.catch(err => logger.error('注册自动切换事件监听失败: ' + err))
}) })
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)
stopAutoSwitch() autoSwitchUnlisten.forEach(fn => fn())
autoSwitchUnlisten = []
}) })
/** 页面可见性变化时刷新系统代理状态(低成本感知外部修改) */ /** 页面可见性变化时刷新系统代理状态(低成本感知外部修改) */
@@ -370,11 +564,6 @@ watch(running, async (val, old) => {
await store.waitForApi() await store.waitForApi()
await store.refreshVersion() await store.refreshVersion()
await loadProxiesWithError() await loadProxiesWithError()
// mihomo /
// handleStop
if (autoSwitchEnabled.value) {
startAutoSwitch(false)
}
} }
}) })
@@ -417,7 +606,6 @@ const handleStart = async () => {
const handleStop = async () => { const handleStop = async () => {
stopping.value = true stopping.value = true
try { try {
stopAutoSwitch()
await store.stop() await store.stop()
toast.success('mihomo 已停止') toast.success('mihomo 已停止')
} catch (e) { } catch (e) {
@@ -448,6 +636,12 @@ const handleRestart = async () => {
// ===== ===== // ===== =====
const onToggleSystemProxy = async (on: boolean) => { const onToggleSystemProxy = async (on: boolean) => {
//
if (on && !running.value) {
toast.warning('请先启动 mihomo 再开启系统代理')
store.refreshSystemProxy()
return
}
sysProxyLoading.value = true sysProxyLoading.value = true
try { try {
await store.toggleSystemProxy(on) await store.toggleSystemProxy(on)
@@ -485,46 +679,26 @@ const quickSwitchNode = async (name: string) => {
try { try {
await store.selectProxy(mainGroupName.value, name) await store.selectProxy(mainGroupName.value, name)
toast.success('节点已切换', { description: name }) toast.success('节点已切换', { description: name })
// // testDelayBatch history Badge
store.testDelay(name).then(delay => { store.testDelayBatch([name]).then(() => {
toast.success(`${name}`, { description: `延迟 ${delay}ms` }) const delay = store.proxies[name]?.history?.[0]?.delay
if (delay && delay > 0) {
toast.success(`${name}`, { description: `延迟 ${delay}ms` })
}
}).catch(() => {}) }).catch(() => {})
} catch (e) { } catch (e) {
toast.error('切换节点失败', { description: String(e) }) toast.error('切换节点失败', { description: String(e) })
} }
} }
// ===== ===== // ===== / =====
const startAutoSwitch = (notify = true, immediate = true) => {
stopAutoSwitch()
if (!autoSwitchEnabled.value) return
const ms = autoSwitchInterval.value * 60 * 1000
autoSwitchTimer = setInterval(runAutoSwitch, ms)
// /
if (notify) {
toast.success('自动切换已开启', {
description: `${autoSwitchInterval.value} 分钟测试并切换至最优节点`
})
}
// /
if (immediate) {
runAutoSwitch()
}
}
const stopAutoSwitch = () => {
if (autoSwitchTimer) {
clearInterval(autoSwitchTimer)
autoSwitchTimer = null
}
}
const onToggleAutoSwitch = (on: boolean) => { const onToggleAutoSwitch = (on: boolean) => {
autoSwitchEnabled.value = on autoSwitchEnabled.value = on
if (on) { if (on) {
startAutoSwitch() toast.success('自动切换已开启', {
description: `${autoSwitchInterval.value} 分钟测试并切换至最优节点`
})
} else { } else {
stopAutoSwitch()
toast.info('自动切换已关闭') toast.info('自动切换已关闭')
} }
saveAutoSwitchSettings() saveAutoSwitchSettings()
@@ -532,52 +706,21 @@ const onToggleAutoSwitch = (on: boolean) => {
const onAutoSwitchIntervalChange = (val: unknown) => { const onAutoSwitchIntervalChange = (val: unknown) => {
autoSwitchInterval.value = Number(val) || 5 autoSwitchInterval.value = Number(val) || 5
if (autoSwitchEnabled.value) {
startAutoSwitch()
}
saveAutoSwitchSettings() saveAutoSwitchSettings()
} }
const runAutoSwitch = async () => { /** 后端自动切换完成后刷新节点列表并提示(后台亦可运行,不依赖模块激活) */
if (autoSwitchRunning) return const onProxyAutoSwitch = async (e: { payload: { switched?: boolean; group?: string; name?: string; delay?: number } }) => {
autoSwitchRunning = true const p = e.payload
try { try {
const groupName = autoSwitchTargetGroup.value || mainGroupName.value await store.loadProxies()
if (!groupName || !running.value) return } catch (err) {
const nodes = filteredNodes.value logger.error('自动切换后刷新节点失败: ' + err)
if (!nodes.length) return }
if (p?.switched && p.name && p.delay) {
// 使 testDelayBatch store.proxies[name].history toast.success('已自动切换到最优节点', {
// UI description: `${p.name} (${p.delay}ms)`
await store.testDelayBatch(nodes) })
// history
const results = nodes.map(name => ({
name,
delay: store.proxies[name]?.history?.[0]?.delay ?? 0
}))
//
const valid = results.filter(r => r.delay > 0)
if (!valid.length) {
toast.warning('所有节点均超时,未切换')
return
}
valid.sort((a, b) => a.delay - b.delay)
const best = valid[0]
//
const currentNow = store.proxies[groupName]?.now ?? ''
if (currentNow !== best.name) {
await store.selectProxy(groupName, best.name)
toast.success('已自动切换到最优节点', {
description: `${best.name} (${best.delay}ms)`
})
}
} catch (e) {
logger.error('自动切换失败: ' + e)
} finally {
autoSwitchRunning = false
} }
} }
@@ -586,7 +729,7 @@ const handleCheckUpdate = async () => {
checkingUpdate.value = true checkingUpdate.value = true
try { try {
const info = await store.checkKernelUpdate() const info = await store.checkKernelUpdate()
kernelUpdateInfo.value = { latestVersion: info.latestVersion, hasUpdate: info.hasUpdate } kernelUpdateInfo.value = { latestVersion: info.latestVersion, hasUpdate: info.hasUpdate, downloadUrl: info.downloadUrl }
if (info.hasUpdate) { if (info.hasUpdate) {
toast.info('发现新版本', { description: `最新: ${info.latestVersion}` }) toast.info('发现新版本', { description: `最新: ${info.latestVersion}` })
} else { } else {
@@ -607,31 +750,26 @@ const handleUpdateKernel = async () => {
/** 是否展开"更新内核"区块(下载源 + 进度) */ /** 是否展开"更新内核"区块(下载源 + 进度) */
const updateExpanded = ref(false) const updateExpanded = ref(false)
/** 开始更新:停止 mihomo → 调用 updateKernel(复用 installProgress 进度机制) */ /** URL updateKernel installProgress
* 下载阶段允许 mihomo 运行可通过当前系统代理下载
* 解压替换前由 need_stop 阶段弹窗要求停止 mihomo */
const handleStartUpdate = async () => { const handleStartUpdate = async () => {
if (store.installing) return if (store.installing) return
// mihomo // 使 URL GitHub
if (running.value) { let url = kernelUpdateInfo.value?.downloadUrl ?? ''
const ok = await showConfirm({ if (!url) {
title: '更新内核',
description: '更新内核需要先停止 mihomo,确认继续?',
confirmText: '继续更新'
})
if (!ok) return
updatingKernel.value = true
try { try {
await store.stop() const info = await store.checkKernelUpdate()
url = info.downloadUrl
kernelUpdateInfo.value = { latestVersion: info.latestVersion, hasUpdate: info.hasUpdate, downloadUrl: info.downloadUrl }
} catch (e) { } catch (e) {
toast.error('停止 mihomo 失败', { description: String(e) }) toast.error('获取更新信息失败', { description: String(e) })
updatingKernel.value = false
return return
} finally {
updatingKernel.value = false
} }
} }
toast.info('开始下载更新...') toast.info('开始下载更新...')
try { try {
await store.updateKernel(selectedMirrorPrefix.value) await store.updateKernel(selectedMirrorPrefix.value, url)
} catch (e) { } catch (e) {
toast.error('内核更新失败', { description: String(e) }) toast.error('内核更新失败', { description: String(e) })
} }
@@ -641,7 +779,9 @@ const handleStartUpdate = async () => {
const installStageText = computed(() => { const installStageText = computed(() => {
const stage = store.installProgress?.stage const stage = store.installProgress?.stage
switch (stage) { switch (stage) {
case 'checking': return '正在检查'
case 'downloading': return '正在下载' case 'downloading': return '正在下载'
case 'need_stop': return '等待停止 mihomo'
case 'extracting': return '正在解压' case 'extracting': return '正在解压'
case 'replacing': return '正在安装' case 'replacing': return '正在安装'
case 'done': return '安装完成' case 'done': return '安装完成'
@@ -654,6 +794,7 @@ const installStageColor = computed(() => {
const stage = store.installProgress?.stage const stage = store.installProgress?.stage
if (stage === 'done') return 'text-emerald-500' if (stage === 'done') return 'text-emerald-500'
if (stage === 'error') return 'text-destructive' if (stage === 'error') return 'text-destructive'
if (stage === 'need_stop') return 'text-amber-500'
return 'text-primary' return 'text-primary'
}) })
@@ -672,6 +813,25 @@ const installPercentDisplay = computed(() => {
const installHasTotal = computed(() => store.installProgress?.totalBytes != null) const installHasTotal = computed(() => store.installProgress?.totalBytes != null)
/** 停止下载进行中标记(防止重复点击) */
const stoppingDownload = ref(false)
/** 停止下载:通知后端中止,并立即回退到下载方式卡片(保留 updateExpanded 供重新选择) */
const handleStopDownload = async () => {
if (stoppingDownload.value || !store.installing) return
stoppingDownload.value = true
try {
await store.cancelKernelInstall()
// UI 退updateKernel
store.installing = false
store.clearInstallProgress()
} catch (e) {
toast.error('停止下载失败', { description: String(e) })
} finally {
stoppingDownload.value = false
}
}
const formatMB = (bytes: number) => `${(bytes / 1024 / 1024).toFixed(2)} MB` const formatMB = (bytes: number) => `${(bytes / 1024 / 1024).toFixed(2)} MB`
// ===== ===== // ===== =====
@@ -709,12 +869,55 @@ const handleInstallKernel = async () => {
} }
} }
/** need_stop 弹窗处理中标记(防止重复触发) */
let handlingNeedStop = false
/**
* 下载完成解压替换前弹窗提示用户停止 mihomo确认后停止 mihomo 并唤醒后端继续安装
* 取消则中止整个安装流程后端正在等待确认通过取消唤醒
*/
const handleNeedStop = async () => {
if (handlingNeedStop) return
handlingNeedStop = true
try {
const wasRunning = running.value
const ok = await showConfirm({
title: '停止 mihomo 后继续',
description: wasRunning
? '下载已完成。安装新内核前需要停止 mihomo,点击「停止并继续」将自动停止 mihomo 并完成安装。'
: '下载已完成。即将安装新内核,点击「继续」完成安装。',
confirmText: wasRunning ? '停止并继续' : '继续'
})
if (!ok) {
//
await store.cancelKernelInstall()
return
}
if (wasRunning) {
await store.stop()
}
await store.confirmInstall()
} catch (e) {
toast.error('停止 mihomo 失败', { description: String(e) })
// exe
try {
await store.cancelKernelInstall()
} catch {
// cancelKernelInstall
}
} finally {
handlingNeedStop = false
}
}
// / toast // / toast
// updateExpanded installProgress // updateExpanded installProgress
watch( watch(
() => store.installProgress?.stage, () => store.installProgress?.stage,
(stage) => { (stage) => {
if (stage === 'done') { if (stage === 'need_stop') {
handleNeedStop()
} else if (stage === 'done') {
toast.success('内核安装完成', { toast.success('内核安装完成', {
description: store.installProgress?.message description: store.installProgress?.message
}) })
@@ -899,12 +1102,35 @@ watch(() => store.settings, syncLocalSettings, { immediate: true })
const saveSettingsForm = async () => { const saveSettingsForm = async () => {
if (!store.settings) return if (!store.settings) return
const prev = store.settings
try { try {
await store.saveSettings({ await store.saveSettings({
...store.settings, ...store.settings,
...localSettings.value ...localSettings.value
}) })
toast.success('设置已保存')
// // mihomo
// API
const networkChanged =
localSettings.value.mixedPort !== prev.mixedPort ||
localSettings.value.externalController !== prev.externalController ||
localSettings.value.secret !== prev.secret
if (networkChanged && running.value) {
toast.warning('端口/接口/密钥已保存,重启 mihomo 后生效(期间代理 API 使用新地址可能暂时不可用)')
} else {
toast.success('设置已保存')
}
//
// UI
if (!networkChanged && localSettings.value.mode !== prev.mode && running.value) {
try {
await invokePatchConfigs({ mode: localSettings.value.mode })
await store.loadProxies()
} catch (modeErr) {
logger.error('运行中应用模式失败,重启后生效: ' + modeErr)
}
}
} catch (e) { } catch (e) {
toast.error('保存失败', { description: String(e) }) toast.error('保存失败', { description: String(e) })
} }
@@ -918,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>
@@ -930,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>
@@ -1128,12 +1391,12 @@ tabsStore.registerSave(saveSettingsForm)
<div class="flex items-center justify-between text-xs"> <div class="flex items-center justify-between text-xs">
<span :class="installStageColor" class="flex items-center gap-1.5 font-medium"> <span :class="installStageColor" class="flex items-center gap-1.5 font-medium">
<Loader2 <Loader2
v-if="['downloading', 'extracting', 'replacing'].includes(store.installProgress.stage)" v-if="['checking', 'downloading', 'extracting', 'replacing'].includes(store.installProgress.stage)"
key="stage-loading" key="stage-loading"
class="size-3 animate-spin" class="size-3 animate-spin"
/> />
<Check v-else-if="store.installProgress.stage === 'done'" key="stage-done" class="size-3" /> <Check v-else-if="store.installProgress.stage === 'done'" key="stage-done" class="size-3" />
<AlertCircle v-else-if="store.installProgress.stage === 'error'" key="stage-error" class="size-3" /> <AlertCircle v-else-if="['need_stop', 'error'].includes(store.installProgress.stage)" key="stage-warn" class="size-3" />
{{ installStageText }} {{ installStageText }}
</span> </span>
<span v-if="installHasTotal && store.installProgress.stage === 'downloading'" class="font-mono text-muted-foreground"> <span v-if="installHasTotal && store.installProgress.stage === 'downloading'" class="font-mono text-muted-foreground">
@@ -1141,7 +1404,7 @@ tabsStore.registerSave(saveSettingsForm)
</span> </span>
</div> </div>
<Progress <Progress
v-if="installHasTotal || store.installProgress.stage !== 'downloading'" v-if="installHasTotal || (store.installProgress.stage !== 'downloading' && store.installProgress.stage !== 'checking')"
key="progress-bar" key="progress-bar"
:model-value="installPercentDisplay" :model-value="installPercentDisplay"
class="h-2" class="h-2"
@@ -1163,6 +1426,20 @@ tabsStore.registerSave(saveSettingsForm)
</template> </template>
<template v-else>{{ store.installProgress.message }}</template> <template v-else>{{ store.installProgress.message }}</template>
</p> </p>
<!-- 停止下载仅在进行中的检查/下载阶段显示点击后回退到下载方式卡片 -->
<div
v-if="['checking', 'downloading'].includes(store.installProgress.stage)"
class="flex justify-end"
>
<Button
size="sm" variant="outline" class="h-7 text-xs"
:disabled="stoppingDownload"
@click="handleStopDownload"
>
<Loader2 v-if="stoppingDownload" class="size-3 animate-spin" />
<Square v-else class="size-3" />停止下载
</Button>
</div>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
@@ -1371,7 +1648,7 @@ tabsStore.registerSave(saveSettingsForm)
</div> </div>
<Switch <Switch
:model-value="store.systemProxy" :model-value="store.systemProxy"
:disabled="sysProxyLoading" :disabled="sysProxyLoading || !running"
@update:model-value="onToggleSystemProxy" @update:model-value="onToggleSystemProxy"
/> />
</CardContent> </CardContent>
@@ -1380,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">
@@ -1627,7 +2037,7 @@ tabsStore.registerSave(saveSettingsForm)
</Tabs> </Tabs>
<!-- 通用确认对话框 --> <!-- 通用确认对话框 -->
<AlertDialog :model-value="confirmState.open" @update:model-value="onConfirmOpenChange"> <AlertDialog :open="confirmState.open" @update:open="onConfirmOpenChange">
<AlertDialogContent> <AlertDialogContent>
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>{{ confirmState.opts.title }}</AlertDialogTitle> <AlertDialogTitle>{{ confirmState.opts.title }}</AlertDialogTitle>
+63 -29
View File
@@ -34,6 +34,8 @@ const explorerDir = ref('')
const actionMode = ref<'none' | 'extract' | 'rename' | 'delete'>('none') const actionMode = ref<'none' | 'extract' | 'rename' | 'delete'>('none')
// + // +
const ACTION_HEIGHT = 620 const ACTION_HEIGHT = 620
// query show/hide 使
let skipNextSearch = false
// specta Rust actions.rs // specta Rust actions.rs
interface ExtractProgress { interface ExtractProgress {
@@ -190,7 +192,8 @@ 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
const items = await aggregateSearch('') const items = await aggregateSearch('')
if (seq !== searchSeq) return // if (seq !== searchSeq) return //
const dirItems = getExplorerActions() const dirItems = getExplorerActions()
@@ -227,9 +230,14 @@ async function doSearch() {
// //
watch(query, () => { watch(query, () => {
// 退 // show/hide /
if (skipNextSearch) {
skipNextSearch = false
return
}
// 退
if (actionMode.value !== 'none') { if (actionMode.value !== 'none') {
exitMode() exitMode(true)
} }
if (searchTimer) clearTimeout(searchTimer) if (searchTimer) clearTimeout(searchTimer)
collapseSubActions() collapseSubActions()
@@ -309,14 +317,15 @@ async function enterDeleteMode() {
document.querySelector<HTMLInputElement>('.qp-fa-delete-filter')?.focus() document.querySelector<HTMLInputElement>('.qp-fa-delete-filter')?.focus()
} }
async function exitMode() { async function exitMode(skipSearch = false) {
if (actionMode.value === 'none') return if (actionMode.value === 'none') return
actionMode.value = 'none' actionMode.value = 'none'
extractReset() extractReset()
renameReset() renameReset()
deleteReset() deleteReset()
await setWindowHeight(420) await setWindowHeight(420)
await doSearch() // watch(query) query
if (!skipSearch) await doSearch()
} }
function extractReset() { function extractReset() {
@@ -804,7 +813,7 @@ function onSubActionHover(idx: number) {
/** 应用类条目(含历史中的应用)不显示 subtitle(路径),让布局更紧凑 */ /** 应用类条目(含历史中的应用)不显示 subtitle(路径),让布局更紧凑 */
const isAppLike = (item: QPItem) => !!item.iconPath const isAppLike = (item: QPItem) => !!item.iconPath
const groupIcon = (group: string) => { const groupIcon = (group: string) => {
if (group === '命令') return Command if (group === '设置') return Settings
if (group === '计算') return Calculator if (group === '计算') return Calculator
if (group === '网页') return Globe if (group === '网页') return Globe
if (group === '系统') return Lock if (group === '系统') return Lock
@@ -818,7 +827,7 @@ const groupIcon = (group: string) => {
/** 类型 badge 颜色映射(柔和色块风格,跟随亮/暗主题) */ /** 类型 badge 颜色映射(柔和色块风格,跟随亮/暗主题) */
const GROUP_COLORS: Record<string, string> = { const GROUP_COLORS: Record<string, string> = {
命令: 'bg-indigo-500/15 text-indigo-600 dark:text-indigo-400', 设置: 'bg-indigo-500/15 text-indigo-600 dark:text-indigo-400',
计算: 'bg-amber-500/15 text-amber-600 dark:text-amber-400', 计算: 'bg-amber-500/15 text-amber-600 dark:text-amber-400',
网页: 'bg-rose-500/15 text-rose-600 dark:text-rose-400', 网页: 'bg-rose-500/15 text-rose-600 dark:text-rose-400',
系统: 'bg-slate-500/15 text-slate-600 dark:text-slate-400', 系统: 'bg-slate-500/15 text-slate-600 dark:text-slate-400',
@@ -910,6 +919,16 @@ async function applyTheme() {
} }
} }
/** 检查文件索引状态(后台自动构建完成后启用文件搜索)。首次调用需打开 SQLite,宜后台执行 */
async function refreshIndexReady() {
try {
const stats = await commands.quickpanelFileIndexStats()
setFileIndexReady((stats?.total ?? 0) > 0)
} catch {
/* 索引未初始化,忽略 */
}
}
onMounted(async () => { onMounted(async () => {
await applyTheme() await applyTheme()
@@ -929,10 +948,12 @@ onMounted(async () => {
window.addEventListener('storage', onStorage) window.addEventListener('storage', onStorage)
unlistenFns.push(() => window.removeEventListener('storage', onStorage)) unlistenFns.push(() => window.removeEventListener('storage', onStorage))
// + Explorer + + // Explorer + +
// SQLite
unlistenFns.push(await listen<{ dir: string | null }>(EVENTS.quickpanelShow, async (e) => { unlistenFns.push(await listen<{ dir: string | null }>(EVENTS.quickpanelShow, async (e) => {
await applyTheme()
explorerDir.value = e.payload?.dir ?? '' explorerDir.value = e.payload?.dir ?? ''
void applyTheme()
void refreshIndexReady()
// //
if (actionMode.value !== 'none') { if (actionMode.value !== 'none') {
actionMode.value = 'none' actionMode.value = 'none'
@@ -941,7 +962,15 @@ onMounted(async () => {
deleteReset() deleteReset()
await setWindowHeight(420) await setWindowHeight(420)
} }
query.value = '' //
if (searchTimer) {
clearTimeout(searchTimer)
searchTimer = null
}
if (query.value !== '') {
skipNextSearch = true
query.value = ''
}
await doSearch() await doSearch()
await nextTick() await nextTick()
inputRef.value?.focus() inputRef.value?.focus()
@@ -953,27 +982,32 @@ onMounted(async () => {
})) }))
unlistenFns.push(await listen(EVENTS.quickpanelHide, () => { unlistenFns.push(await listen(EVENTS.quickpanelHide, () => {
query.value = '' //
if (searchTimer) {
clearTimeout(searchTimer)
searchTimer = null
}
if (query.value !== '') {
skipNextSearch = true
query.value = ''
}
results.value = [] results.value = []
})) }))
// //
// setFileIndexReady // / dev ""
try {
const stats = await commands.quickpanelFileIndexStats()
setFileIndexReady((stats?.total ?? 0) > 0)
} catch {
/* 索引未初始化,忽略 */
}
await doSearch()
await nextTick()
inputRef.value?.focus()
try { try {
await commands.quickpanelShowWindow() await commands.quickpanelShowWindow()
} catch { } catch {
/* 忽略 */ /* 忽略 */
} }
// + Provider IPC
// setFileIndexReady
void refreshIndexReady()
await doSearch()
await nextTick()
inputRef.value?.focus()
}) })
onUnmounted(() => { onUnmounted(() => {
@@ -992,7 +1026,7 @@ onUnmounted(() => {
ref="inputRef" ref="inputRef"
v-model="query" v-model="query"
class="qp-input" class="qp-input"
placeholder="搜索命令、应用、文件…" placeholder="搜索应用、文件、系统命令…"
spellcheck="false" spellcheck="false"
autocomplete="off" autocomplete="off"
/> />
@@ -1007,7 +1041,7 @@ onUnmounted(() => {
<!-- 批量解压面板 --> <!-- 批量解压面板 -->
<template v-if="actionMode === 'extract'"> <template v-if="actionMode === 'extract'">
<div class="qp-action-head"> <div class="qp-action-head">
<button class="qp-back-btn" title="返回搜索" @click="exitMode"><ChevronLeft class="size-4" /></button> <button class="qp-back-btn" title="返回搜索" @click="exitMode()"><ChevronLeft class="size-4" /></button>
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<p class="qp-action-title">批量解压</p> <p class="qp-action-title">批量解压</p>
<p class="qp-action-dir truncate" :title="explorerDir">{{ explorerDir }}</p> <p class="qp-action-dir truncate" :title="explorerDir">{{ explorerDir }}</p>
@@ -1108,7 +1142,7 @@ onUnmounted(() => {
<!-- 批量重命名面板 --> <!-- 批量重命名面板 -->
<template v-else-if="actionMode === 'rename'"> <template v-else-if="actionMode === 'rename'">
<div class="qp-action-head"> <div class="qp-action-head">
<button class="qp-back-btn" title="返回搜索" @click="exitMode"><ChevronLeft class="size-4" /></button> <button class="qp-back-btn" title="返回搜索" @click="exitMode()"><ChevronLeft class="size-4" /></button>
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<p class="qp-action-title">批量重命名</p> <p class="qp-action-title">批量重命名</p>
<p class="qp-action-dir truncate" :title="explorerDir">{{ explorerDir }}</p> <p class="qp-action-dir truncate" :title="explorerDir">{{ explorerDir }}</p>
@@ -1215,7 +1249,7 @@ onUnmounted(() => {
<!-- 批量删除面板 --> <!-- 批量删除面板 -->
<template v-else-if="actionMode === 'delete'"> <template v-else-if="actionMode === 'delete'">
<div class="qp-action-head"> <div class="qp-action-head">
<button class="qp-back-btn" title="返回搜索" @click="exitMode"><ChevronLeft class="size-4" /></button> <button class="qp-back-btn" title="返回搜索" @click="exitMode()"><ChevronLeft class="size-4" /></button>
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<p class="qp-action-title">批量删除</p> <p class="qp-action-title">批量删除</p>
<p class="qp-action-dir truncate" :title="explorerDir">{{ explorerDir }}</p> <p class="qp-action-dir truncate" :title="explorerDir">{{ explorerDir }}</p>
@@ -1324,7 +1358,7 @@ onUnmounted(() => {
<div v-else-if="!hasResults()" class="qp-empty"> <div v-else-if="!hasResults()" class="qp-empty">
<Command class="size-10 mb-3 opacity-40" /> <Command class="size-10 mb-3 opacity-40" />
<p class="text-sm">输入关键词开始搜索</p> <p class="text-sm">输入关键词开始搜索</p>
<p class="text-xs mt-1 opacity-60">命令 · 计算 · 系统 · 网页</p> <p class="text-xs mt-1 opacity-60">设置 · 应用 · 文件 · 系统 · 网页</p>
</div> </div>
<template v-else> <template v-else>
<!-- 目录操作批量解压/重命名/删除置顶可键盘导航 --> <!-- 目录操作批量解压/重命名/删除置顶可键盘导航 -->
@@ -1434,7 +1468,7 @@ onUnmounted(() => {
</AccordionItem> </AccordionItem>
</Accordion> </Accordion>
<!-- 其他结果命令/应用/系统等可键盘导航索引偏移 dirActionItems.length + historyItems.length --> <!-- 其他结果设置/应用/系统等可键盘导航索引偏移 dirActionItems.length + historyItems.length -->
<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"
+12 -4
View File
@@ -1,6 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, reactive, onMounted, onUnmounted, watch, nextTick } from 'vue' import { ref, reactive, onMounted, onUnmounted, watch, nextTick } from 'vue'
import { open } from '@tauri-apps/plugin-dialog' import { open } from '@tauri-apps/plugin-dialog'
import { listen, type UnlistenFn } from '@tauri-apps/api/event'
import { toast } from 'vue-sonner' import { toast } from 'vue-sonner'
// Rust tauri-specta bindings.ts // Rust tauri-specta bindings.ts
import { commands } from '@/lib/bindings' import { commands } from '@/lib/bindings'
@@ -10,7 +11,7 @@ import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { useModuleTabsStore } from '@/stores/moduleTabsStore' import { useModuleTabsStore } from '@/stores/moduleTabsStore'
import { setFileIndexReady, invalidateCustomCommandsCache } from './providers' import { setFileIndexReady, invalidateCustomCommandsCache } from './providers'
import { STORAGE_KEYS } from '@/lib/constants' import { STORAGE_KEYS, EVENTS } from '@/lib/constants'
interface CustomCommand { interface CustomCommand {
id: string id: string
@@ -43,6 +44,8 @@ interface IndexStats {
} }
const indexStats = ref<IndexStats | null>(null) const indexStats = ref<IndexStats | null>(null)
const building = ref(false) const building = ref(false)
// onUnmounted
let indexUpdatedUnlisten: UnlistenFn | null = null
async function refreshStats() { async function refreshStats() {
try { try {
@@ -96,6 +99,10 @@ onMounted(async () => {
console.error('[quickpanel] 读取设置失败:', e) console.error('[quickpanel] 读取设置失败:', e)
} }
await refreshStats() await refreshStats()
// /
indexUpdatedUnlisten = await listen<number>(EVENTS.quickpanelIndexUpdated, () => {
void refreshStats()
})
}) })
// ===== ===== // ===== =====
@@ -233,6 +240,7 @@ async function clearShortcut() {
onUnmounted(() => { onUnmounted(() => {
window.removeEventListener('keydown', onRecordKey, true) window.removeEventListener('keydown', onRecordKey, true)
indexUpdatedUnlisten?.()
// activeTab=settings saveSettings // activeTab=settings saveSettings
tabsStore.unregisterTabs() tabsStore.unregisterTabs()
}) })
@@ -515,8 +523,8 @@ async function changeEngine(v: string) {
</CardHeader> </CardHeader>
<CardContent class="space-y-2"> <CardContent class="space-y-2">
<div class="flex items-center gap-2 text-sm"> <div class="flex items-center gap-2 text-sm">
<Badge variant="secondary">命令</Badge> <Badge variant="secondary">设置</Badge>
<span class="text-muted-foreground">跳转到已启用模块</span> <span class="text-muted-foreground">本程序模块导航与退出应用搜索时显示不占用默认视图</span>
</div> </div>
<div class="flex items-center gap-2 text-sm"> <div class="flex items-center gap-2 text-sm">
<Badge variant="secondary">自定义</Badge> <Badge variant="secondary">自定义</Badge>
@@ -556,7 +564,7 @@ async function changeEngine(v: string) {
</div> </div>
<div class="flex items-center gap-2 text-sm"> <div class="flex items-center gap-2 text-sm">
<Badge variant="secondary">系统</Badge> <Badge variant="secondary">系统</Badge>
<span class="text-muted-foreground">系统命令注册表CMD/PowerShell任务管理器控制面板关机/重启/休眠及锁屏退出应用</span> <span class="text-muted-foreground">系统命令注册表CMD/PowerShell任务管理器控制面板关机/重启/休眠及锁屏打开面板默认显示</span>
</div> </div>
<div class="flex items-center gap-2 text-sm"> <div class="flex items-center gap-2 text-sm">
<Badge variant="secondary">网页</Badge> <Badge variant="secondary">网页</Badge>
+1 -1
View File
@@ -40,7 +40,7 @@ export const moduleConfig: ModuleConfig = {
id: 'quickpanel', id: 'quickpanel',
name: '快速面板', name: '快速面板',
icon: 'quickpanel', icon: 'quickpanel',
description: '全局快捷键唤起的多源命令面板(命令/应用/文件/计算)', description: '全局快捷键唤起的多源快速启动面板(设置/应用/文件/系统/计算)',
category: 'tool', category: 'tool',
defaultEnabled: true, defaultEnabled: true,
loader: () => import('./QuickPanelModule.vue'), loader: () => import('./QuickPanelModule.vue'),
@@ -39,7 +39,8 @@ export function getProviders(): QPProvider[] {
/** /**
* Provider score * Provider score
* command Provider + system Provider * + system Provider
*
*/ */
export async function aggregateSearch(query: string): Promise<QPItem[]> { export async function aggregateSearch(query: string): Promise<QPItem[]> {
const all = getProviders() const all = getProviders()
+4 -5
View File
@@ -37,20 +37,20 @@ export class AppProvider implements QPProvider {
priority = 95 priority = 95
async search(query: string): Promise<QPItem[]> { async search(query: string): Promise<QPItem[]> {
const apps = await loadApps()
if (!query.trim()) { if (!query.trim()) {
// 空查询:不显示应用(避免列表过长),由命令入口承担 // 空查询:不显示应用(避免列表过长),也跳过应用扫描 IPC(不阻塞默认视图首屏)
return [] return []
} }
const apps = await loadApps()
const results: Array<{ item: QPItem; score: number }> = [] const results: Array<{ item: QPItem; score: number }> = []
let idx = 0
for (const app of apps) { for (const app of apps) {
const forms = buildItemForms(app.name) const forms = buildItemForms(app.name)
const score = bestScore(query, forms) const score = bestScore(query, forms)
if (score >= 0) { if (score >= 0) {
results.push({ results.push({
item: { item: {
id: `app-${idx}`, // 稳定 id(基于路径):扫描结果顺序变化时历史记录仍能恢复原应用
id: `app-${app.path}`,
title: app.name, title: app.name,
subtitle: app.path, subtitle: app.path,
group: '应用', group: '应用',
@@ -62,7 +62,6 @@ export class AppProvider implements QPProvider {
score, score,
}) })
} }
idx++
} }
results.sort((a, b) => b.score - a.score) results.sort((a, b) => b.score - a.score)
return results.slice(0, 15).map(r => ({ ...r.item, score: r.score })) return results.slice(0, 15).map(r => ({ ...r.item, score: r.score }))
+25 -17
View File
@@ -1,7 +1,8 @@
/** /**
* command Provider * command Provider
* store localStorage * store localStorage
* emit `quickpanel-execute-command` * emit `quickpanel-execute-command`
*
*/ */
import { emit } from '@tauri-apps/api/event' import { emit } from '@tauri-apps/api/event'
import { bestScore } from '../engine' import { bestScore } from '../engine'
@@ -19,37 +20,43 @@ interface CachedCommand {
keywords: string[] keywords: string[]
} }
// 解析结果缓存:避免每次按键重复 JSON.parse
// 主窗口写入命令缓存时通过跨窗口 storage 事件失效
let commandCache: CachedCommand[] | null = null
if (typeof window !== 'undefined') {
window.addEventListener('storage', (e) => {
if (e.key === COMMANDS_KEY) commandCache = null
})
}
function loadCommands(): CachedCommand[] { function loadCommands(): CachedCommand[] {
if (commandCache) return commandCache
try { try {
const raw = localStorage.getItem(COMMANDS_KEY) const raw = localStorage.getItem(COMMANDS_KEY)
if (!raw) return [] commandCache = raw ? (JSON.parse(raw) as CachedCommand[]) : []
return JSON.parse(raw) as CachedCommand[]
} catch { } catch {
return [] commandCache = []
} }
return commandCache
} }
export class CommandProvider implements QPProvider { export class CommandProvider implements QPProvider {
id = 'command' id = 'command'
label = '命令' label = '设置'
priority = 100 priority = 100
search(query: string): QPItem[] { search(query: string): QPItem[] {
// 空查询不返回程序相关设置:默认视图只显示系统相关条目
if (!query.trim()) return []
const commands = loadCommands() const commands = loadCommands()
if (!query.trim() || !commands.length) { if (!commands.length) return []
// 无输入时返回前几条命令作为快捷入口
if (!query.trim()) {
return commands.slice(0, 6).map((c, i) => this.toItem(c, i))
}
return []
}
const results: Array<{ item: QPItem; score: number }> = [] const results: Array<{ item: QPItem; score: number }> = []
commands.forEach((c, idx) => { commands.forEach((c) => {
const forms = buildItemForms(c.title, c.keywords) const forms = buildItemForms(c.title, c.keywords)
const score = bestScore(query, forms) const score = bestScore(query, forms)
if (score >= 0) { if (score >= 0) {
const item = this.toItem(c, idx) const item = this.toItem(c)
results.push({ item, score }) results.push({ item, score })
} }
}) })
@@ -57,12 +64,13 @@ export class CommandProvider implements QPProvider {
return results.map(r => ({ ...r.item, score: r.score })) return results.map(r => ({ ...r.item, score: r.score }))
} }
private toItem(c: CachedCommand, idx: number): QPItem { private toItem(c: CachedCommand): QPItem {
return { return {
id: `cmd-${c.moduleId}-${idx}`, // 稳定 idmoduleId + title):模块启停导致列表重排时,历史记录仍能恢复原条目
id: `cmd-${c.moduleId}-${c.title}`,
title: c.title, title: c.title,
subtitle: c.description || c.moduleName, subtitle: c.description || c.moduleName,
group: '命令', group: '设置',
action: async () => { action: async () => {
// 通知主窗口切换到对应模块 // 通知主窗口切换到对应模块
await emit(EVENTS.quickpanelExecuteCommand, { moduleId: c.moduleId }) await emit(EVENTS.quickpanelExecuteCommand, { moduleId: c.moduleId })
@@ -40,8 +40,8 @@ export class CustomCommandProvider implements QPProvider {
priority = 92 priority = 92
async search(query: string): Promise<QPItem[]> { async search(query: string): Promise<QPItem[]> {
if (!query.trim()) return [] // 空查询跳过设置读取 IPC
const cmds = await loadCustomCommands() const cmds = await loadCustomCommands()
if (!query.trim()) return []
const results: Array<{ item: QPItem; score: number }> = [] const results: Array<{ item: QPItem; score: number }> = []
for (const cmd of cmds) { for (const cmd of cmds) {
const forms = buildItemForms(cmd.title) const forms = buildItemForms(cmd.title)
+5 -3
View File
@@ -19,13 +19,14 @@ export class FileProvider implements QPProvider {
if (!fileIndexReady) return [] if (!fileIndexReady) return []
try { try {
const files = await commands.quickpanelSearchFiles(query.trim(), 20) const files = await commands.quickpanelSearchFiles(query.trim(), 20)
return files.map((f, idx) => { return files.map((f) => {
// .lnk 快捷方式按应用处理:带图标、用启动命令,并与开始菜单应用统一去重 // .lnk 快捷方式按应用处理:带图标、用启动命令,并与开始菜单应用统一去重
// 注意:Rust 返回的 ext 不带点(如 "lnk"),这里直接按文件名判断最稳妥 // 注意:Rust 返回的 ext 不带点(如 "lnk"),这里直接按文件名判断最稳妥
const isLnk = !f.isDir && f.name.toLowerCase().endsWith('.lnk') const isLnk = !f.isDir && f.name.toLowerCase().endsWith('.lnk')
if (isLnk) { if (isLnk) {
return { return {
id: `file-app-${idx}`, // 稳定 id(基于路径):搜索结果顺序变化时历史记录仍能恢复原条目
id: `file-app-${f.path}`,
title: f.name, title: f.name,
subtitle: f.path, subtitle: f.path,
group: '应用', group: '应用',
@@ -46,7 +47,8 @@ export class FileProvider implements QPProvider {
} }
} }
return { return {
id: `file-${idx}`, // 稳定 id(基于路径):搜索结果顺序变化时历史记录仍能恢复原条目
id: `file-${f.path}`,
title: f.name, title: f.name,
subtitle: f.path, subtitle: f.path,
group: '文件', group: '文件',
+1 -1
View File
@@ -33,9 +33,9 @@ export class SpecialProvider implements QPProvider {
priority = 60 priority = 60
async search(query: string): Promise<QPItem[]> { async search(query: string): Promise<QPItem[]> {
if (!query.trim()) return [] // 空查询不占用列表(也跳过 IPC),由用户主动搜索
const list = await loadSpecials() const list = await loadSpecials()
if (!list.length) return [] if (!list.length) return []
if (!query.trim()) return [] // 空查询不占用列表,由用户主动搜索
const open = async (s: SpecialLocation) => { const open = async (s: SpecialLocation) => {
try { try {
+5 -3
View File
@@ -2,6 +2,7 @@
* system Provider * system Provider
* regedit / cmd / powershell title * regedit / cmd / powershell title
* keywords / title CJK * keywords / title CJK
* 退 Thing
*/ */
import { invoke } from '@tauri-apps/api/core' import { invoke } from '@tauri-apps/api/core'
import { bestScore, type TextForms } from '../engine' import { bestScore, type TextForms } from '../engine'
@@ -122,7 +123,7 @@ export class SystemProvider implements QPProvider {
} }
}, },
})) }))
// 锁屏 + 退出 应用本身 // 锁屏(系统操作)+ 退出应用(本程序相关 → 设置分类,默认视图不显示)
items.push( items.push(
{ {
id: 'sys-lock', id: 'sys-lock',
@@ -141,7 +142,7 @@ export class SystemProvider implements QPProvider {
id: 'sys-quit', id: 'sys-quit',
title: '退出 Thing', title: '退出 Thing',
subtitle: '关闭应用程序', subtitle: '关闭应用程序',
group: '系统', group: '设置',
action: async () => { action: async () => {
try { try {
await invoke('quit_app') await invoke('quit_app')
@@ -163,7 +164,8 @@ export class SystemProvider implements QPProvider {
search(query: string): QPItem[] { search(query: string): QPItem[] {
const items = this.buildItems() const items = this.buildItems()
if (!query.trim()) return items // 空查询:只返回系统相关条目(「退出 Thing」等程序相关项归入设置分类,不默认显示)
if (!query.trim()) return items.filter(i => i.group === '系统')
const scored: Array<{ item: QPItem; score: number }> = [] const scored: Array<{ item: QPItem; score: number }> = []
for (const item of items) { for (const item of items) {
const forms = this.itemForms(item) const forms = this.itemForms(item)
+8 -7
View File
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onUnmounted, watch, nextTick } from 'vue' import { ref, computed, onUnmounted, watch, nextTick } from 'vue'
import { import {
Keyboard, Settings, FolderOpen, Camera, Copy, Save, Trash2, Timer, StickyNote, Keyboard, Settings, FolderOpen, Camera, Copy, Save, Trash2, Timer, StickyNote,
Image as ImageIcon, Loader2, Image as ImageIcon, Loader2,
@@ -29,10 +29,8 @@ const DELAY_OPTIONS = [0, 1, 2, 3, 5]
function formatTime(t: number): string { function formatTime(t: number): string {
const d = new Date(t) const d = new Date(t)
const hh = String(d.getHours()).padStart(2, '0') const pad = (n: number) => String(n).padStart(2, '0')
const mm = String(d.getMinutes()).padStart(2, '0') return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
const ss = String(d.getSeconds()).padStart(2, '0')
return `${hh}:${mm}:${ss}`
} }
function thumbSrc(item: RecentCapture): string { function thumbSrc(item: RecentCapture): string {
@@ -82,6 +80,8 @@ function handleDelete(item: RecentCapture) {
// ===== + ===== // ===== + =====
const customLimit = ref('') const customLimit = ref('')
/** 当前值不在预设中 → 自定义模式(输入框高亮表示选中) */
const isCustomLimit = computed(() => !HISTORY_LIMITS.includes(store.settings.historyLimit))
function onLimitPreset(n: number) { function onLimitPreset(n: number) {
store.setSettings({ historyLimit: n }) store.setSettings({ historyLimit: n })
@@ -411,7 +411,8 @@ onUnmounted(() => {
type="number" type="number"
min="1" min="1"
max="999" max="999"
class="h-8 w-16 rounded-md border border-input bg-transparent px-2 text-sm text-center outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50" class="h-8 w-24 rounded-md border bg-transparent px-2 text-sm text-center outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
:class="isCustomLimit ? 'border-primary bg-primary/10 text-primary' : 'border-input'"
placeholder="自定义" placeholder="自定义"
title="输入自定义保留数量" title="输入自定义保留数量"
@keydown.enter="onCustomLimit" @keydown.enter="onCustomLimit"
@@ -426,7 +427,7 @@ onUnmounted(() => {
</TabsContent> </TabsContent>
<!-- 历史记录 --> <!-- 历史记录 -->
<TabsContent value="history" class="flex-1 min-h-0 mt-0"> <TabsContent value="history" class="flex-1 min-h-0 mt-4">
<ScrollArea class="h-full"> <ScrollArea class="h-full">
<div> <div>
<!-- 空状态 --> <!-- 空状态 -->
+111 -66
View File
@@ -18,6 +18,7 @@ import {
type Point, type Sel, type CaptureData, type WindowInfo, type HandleDir, type Point, type Sel, type CaptureData, type WindowInfo, type HandleDir,
type RectAnno, type EllipseAnno, type ArrowAnno, type PenAnno, type TextAnno, type RectAnno, type EllipseAnno, type ArrowAnno, type PenAnno, type TextAnno,
type MosaicAnno, type HighlightAnno, type NumberAnno, type MosaicAnno, type HighlightAnno, type NumberAnno,
type ScreenshotBeginPayload,
} from './types' } from './types'
// ===== / ===== // ===== / =====
@@ -65,6 +66,21 @@ const currentHwnd = ref(0)
let pickRaf = 0 let pickRaf = 0
let lastPickX = -1 let lastPickX = -1
let lastPickY = -1 let lastPickY = -1
/** 拾取窗口列表(Z 序顶→底,begin 事件携带,与冻结底图同一时刻),JS 本地命中测试零 IPC */
let pickWindows: WindowInfo[] = []
/** 最近一次鼠标物理坐标(ESC 等回到 pick 阶段时按当前位置恢复高亮,无需移动鼠标) */
const lastMousePhys = { x: 0, y: 0 }
// idle= show fade= done=
const enterState = ref<'idle' | 'fade' | 'done'>('done')
let enterTimer: number | null = null
function resetEnterAnim() {
if (enterTimer !== null) {
clearTimeout(enterTimer)
enterTimer = null
}
enterState.value = 'idle'
}
// / // /
const moving = ref(false) const moving = ref(false)
@@ -655,51 +671,22 @@ function canvasPoint(e: MouseEvent): Point {
} }
} }
// ===== ===== // ===== IPC =====
function scheduleWindowPick(cssX: number, cssY: number) { /** 在缓存列表中命中测试(与 Rust 拾取同语义:rect 包含点,取 Z 序最顶的第一个命中) */
const physX = Math.round(winOuterX + cssX * dpr) function hitTestWindow(physX: number, physY: number): WindowInfo | null {
const physY = Math.round(winOuterY + cssY * dpr) for (const info of pickWindows) {
if (physX === lastPickX && physY === lastPickY) return const r = info.rect
lastPickX = physX if (physX >= r.x && physX < r.x + r.width && physY >= r.y && physY < r.y + r.height) {
lastPickY = physY return info
if (pickRaf) return
pickRaf = requestAnimationFrame(async () => {
pickRaf = 0
try {
const info = await invoke<WindowInfo | null>('screenshot_window_from_point', {
x: physX,
y: physY,
})
if (phase.value !== 'pick') return
if (info) {
// DWM GetWindowRect
const r = info.visualRect ?? info.rect
winHighlight.value = {
x: (r.x - winOuterX) / dpr,
y: (r.y - winOuterY) / dpr,
w: r.width / dpr,
h: r.height / dpr,
title: info.title,
}
currentHwnd.value = info.hwnd
} else {
winHighlight.value = null
currentHwnd.value = 0
}
} catch {
//
} }
}) }
return null
} }
/** 按物理坐标拾取窗口(覆盖层打开时定位鼠标所在窗口) */ /** 应用窗口命中结果到高亮状态 */
async function pickAt(physX: number, physY: number) { function applyWindowInfo(info: WindowInfo | null) {
try { if (info) {
const info = await invoke<WindowInfo | null>('screenshot_window_from_point', { // DWM GetWindowRect
x: physX,
y: physY,
})
if (phase.value !== 'pick' || !info) return
const r = info.visualRect ?? info.rect const r = info.visualRect ?? info.rect
winHighlight.value = { winHighlight.value = {
x: (r.x - winOuterX) / dpr, x: (r.x - winOuterX) / dpr,
@@ -709,11 +696,34 @@ async function pickAt(physX: number, physY: number) {
title: info.title, title: info.title,
} }
currentHwnd.value = info.hwnd currentHwnd.value = info.hwnd
} catch { } else {
// winHighlight.value = null
currentHwnd.value = 0
} }
} }
function scheduleWindowPick(cssX: number, cssY: number) {
const physX = Math.round(winOuterX + cssX * dpr)
const physY = Math.round(winOuterY + cssY * dpr)
if (physX === lastPickX && physY === lastPickY) return
lastPickX = physX
lastPickY = physY
if (pickRaf) return
pickRaf = requestAnimationFrame(() => {
pickRaf = 0
if (phase.value !== 'pick') return
//
applyWindowInfo(hitTestWindow(lastPickX, lastPickY))
})
}
/** 按物理坐标拾取窗口并立即应用高亮(同步,无 IPC) */
function pickWindowAt(physX: number, physY: number) {
lastPickX = physX
lastPickY = physY
applyWindowInfo(hitTestWindow(physX, physY))
}
// ===== ===== // ===== =====
function resetAnnotations() { function resetAnnotations() {
annotations.value = [] annotations.value = []
@@ -739,6 +749,13 @@ function enterSelected() {
phase.value = 'selected' phase.value = 'selected'
} }
/** 回到窗口拾取阶段,并按当前鼠标位置立即恢复高亮(ESC/点击选区外时无需移动鼠标) */
function backToPick() {
dragTracking.value = false
phase.value = 'pick'
pickWindowAt(lastMousePhys.x, lastMousePhys.y)
}
/** 点击窗口 → 以窗口矩形为选区 */ /** 点击窗口 → 以窗口矩形为选区 */
function selectWindow(w: { x: number; y: number; w: number; h: number }) { function selectWindow(w: { x: number; y: number; w: number; h: number }) {
sel.value = { x: w.x, y: w.y, w: w.w, h: w.h } sel.value = { x: w.x, y: w.y, w: w.w, h: w.h }
@@ -752,9 +769,9 @@ function onMouseDown(e: MouseEvent) {
dragTracking.value = true dragTracking.value = true
dragStart.value = { x: e.clientX, y: e.clientY } dragStart.value = { x: e.clientX, y: e.clientY }
} else if (phase.value === 'selected') { } else if (phase.value === 'selected') {
// //
resetAnnotations() resetAnnotations()
phase.value = 'pick' backToPick()
} else if (phase.value === 'editing') { } else if (phase.value === 'editing') {
// 退 // 退
commitText() commitText()
@@ -816,6 +833,9 @@ function onHandleMouseDown(dir: HandleDir, e: MouseEvent) {
} }
function onMouseMove(e: MouseEvent) { function onMouseMove(e: MouseEvent) {
// ESC pick
lastMousePhys.x = Math.round(winOuterX + e.clientX * dpr)
lastMousePhys.y = Math.round(winOuterY + e.clientY * dpr)
// rAF // rAF
scheduleMagnifier(e) scheduleMagnifier(e)
@@ -975,10 +995,9 @@ function onKeyDown(e: KeyboardEvent) {
} }
} else if (phase.value === 'selected') { } else if (phase.value === 'selected') {
resetAnnotations() resetAnnotations()
phase.value = 'pick' backToPick()
} else if (phase.value === 'drawing') { } else if (phase.value === 'drawing') {
dragTracking.value = false backToPick()
phase.value = 'pick'
} else { } else {
cancel() cancel()
} }
@@ -1901,15 +1920,17 @@ onMounted(async () => {
// // // //
commands.screenshotDisableTransitions(WINDOWS.screenshotOverlay).catch(() => {}) commands.screenshotDisableTransitions(WINDOWS.screenshotOverlay).catch(() => {})
// begin store // begin store
beginUnlisten = await listen(EVENTS.screenshotBegin, () => { beginUnlisten = await listen<ScreenshotBeginPayload>(EVENTS.screenshotBegin, (e) => {
void beginCapture() void beginCapture(e.payload)
}) })
await emit(EVENTS.screenshotOverlayReady) await emit(EVENTS.screenshotOverlayReady)
}) })
/** 响应 store 的 'screenshot-begin':先装载底图(隐藏中),解码完成后再一次性显示窗口 */ /** 响应 store 的 'screenshot-begin':先装载底图(隐藏中),解码完成后再一次性显示窗口 */
async function beginCapture() { async function beginCapture(payload?: ScreenshotBeginPayload) {
try { try {
// show //
resetEnterAnim()
// //
applyTheme() applyTheme()
// "" // ""
@@ -1927,13 +1948,23 @@ async function beginCapture() {
pixelCanvas = null pixelCanvas = null
pixelCtx = null pixelCtx = null
// pick IPC
pickWindows = payload?.windows ?? []
lastMousePhys.x = payload?.cursorX ?? 0
lastMousePhys.y = payload?.cursorY ?? 0
lastPickX = -1
lastPickY = -1
// "" // ""
imgSrc.value = '' imgSrc.value = ''
resetImgReady() resetImgReady()
loading.value = true loading.value = true
// BMP raw IPC ArrayBufferBlob URL // BMP raw IPC ArrayBufferBlob URL +
const buf = await invoke<ArrayBuffer>('screenshot_get_fullscreen_bmp') const [buf, pos] = await Promise.all([
invoke<ArrayBuffer>('screenshot_get_fullscreen_bmp'),
win.outerPosition(),
])
if (objectUrl) URL.revokeObjectURL(objectUrl) if (objectUrl) URL.revokeObjectURL(objectUrl)
const blob = new Blob([buf], { type: 'image/bmp' }) const blob = new Blob([buf], { type: 'image/bmp' })
objectUrl = URL.createObjectURL(blob) objectUrl = URL.createObjectURL(blob)
@@ -1946,31 +1977,36 @@ async function beginCapture() {
]) ])
loading.value = false loading.value = false
const pos = await win.outerPosition()
winOuterX = pos.x winOuterX = pos.x
winOuterY = pos.y winOuterY = pos.y
dpr = window.devicePixelRatio || 1 dpr = window.devicePixelRatio || 1
// store setSize webview ready // store setSize webview ready
// onResized winW/winH // onResized winW/winH
await refreshWinSize() await refreshWinSize()
await win.show() // """"
await win.setFocus() pickWindowAt(lastMousePhys.x, lastMousePhys.y)
// IPC show + setFocus JS
await commands.screenshotShowOverlay(WINDOWS.screenshotOverlay)
// ++
enterState.value = 'fade'
enterTimer = window.setTimeout(() => {
enterState.value = 'done'
enterTimer = null
}, 220)
// innerWidth/innerHeight 800×600 // innerWidth/innerHeight 800×600
// show() WebView2 DOM // show() WebView2 DOM
await refreshWinSize() await refreshWinSize()
// WebView2 // WebView2
requestAnimationFrame(() => void refreshWinSize()) requestAnimationFrame(() => void refreshWinSize())
//
try {
const [cx, cy] = await invoke<[number, number]>('screenshot_cursor_pos')
await pickAt(cx, cy)
} catch {
//
}
} catch (e) { } catch (e) {
errorMsg.value = (e as Error).message errorMsg.value = (e as Error).message
loading.value = false loading.value = false
// idle
if (enterTimer !== null) {
clearTimeout(enterTimer)
enterTimer = null
}
enterState.value = 'done'
// //
await win.show().catch(() => {}) await win.show().catch(() => {})
} }
@@ -1986,6 +2022,7 @@ onUnmounted(() => {
if (pickRaf) cancelAnimationFrame(pickRaf) if (pickRaf) cancelAnimationFrame(pickRaf)
if (magRaf) cancelAnimationFrame(magRaf) if (magRaf) cancelAnimationFrame(magRaf)
if (redrawRaf) cancelAnimationFrame(redrawRaf) if (redrawRaf) cancelAnimationFrame(redrawRaf)
if (enterTimer !== null) clearTimeout(enterTimer)
staticCanvas = null staticCanvas = null
magGridCanvas = null magGridCanvas = null
if (objectUrl) URL.revokeObjectURL(objectUrl) if (objectUrl) URL.revokeObjectURL(objectUrl)
@@ -1997,7 +2034,7 @@ onUnmounted(() => {
<template> <template>
<div <div
class="overlay-root" class="overlay-root"
:class="cursorClass" :class="[cursorClass, enterState !== 'done' ? `overlay-enter-${enterState}` : '']"
@mousedown="onMouseDown" @mousedown="onMouseDown"
@mousemove="onMouseMove" @mousemove="onMouseMove"
@mouseup="onMouseUp" @mouseup="onMouseUp"
@@ -2317,6 +2354,14 @@ onUnmounted(() => {
-webkit-user-select: none; -webkit-user-select: none;
touch-action: none; touch-action: none;
} }
/* 入场淡入:idle=透明起点(窗口 show 前已就位),fade=180ms ease-out 浮现定格画面+遮罩 */
.overlay-enter-idle {
opacity: 0;
}
.overlay-enter-fade {
opacity: 1;
transition: opacity 180ms ease-out;
}
.bg-img { .bg-img {
position: absolute; position: absolute;
inset: 0; inset: 0;
+328 -74
View File
@@ -1,12 +1,13 @@
<script setup lang="ts"> <script setup lang="ts">
// //
// //
// hover / / // / / ESC
import { ref, onMounted, onUnmounted } from 'vue' import { ref, onMounted, onUnmounted } from 'vue'
import { invoke } from '@tauri-apps/api/core'
import { getCurrentWindow, currentMonitor } from '@tauri-apps/api/window' import { getCurrentWindow, currentMonitor } from '@tauri-apps/api/window'
import { LogicalPosition, LogicalSize } from '@tauri-apps/api/dpi' import { LogicalPosition, LogicalSize } from '@tauri-apps/api/dpi'
import { emit, listen, type UnlistenFn } from '@tauri-apps/api/event' import { emit, listen, type UnlistenFn } from '@tauri-apps/api/event'
import { ChevronLeft, ChevronRight, X, Maximize2, Loader2 } from '@lucide/vue' import { ChevronLeft, ChevronRight, X, Loader2 } from '@lucide/vue'
import { EVENTS, STORAGE_KEYS } from '@/lib/constants' import { EVENTS, STORAGE_KEYS } from '@/lib/constants'
// Rust tauri-specta bindings.ts // Rust tauri-specta bindings.ts
import { commands } from '@/lib/bindings' import { commands } from '@/lib/bindings'
@@ -26,6 +27,181 @@ const cur = ref(0)
let showUnlisten: UnlistenFn | null = null let showUnlisten: UnlistenFn | null = null
// ===== LRU Blob URL/ IPC =====
const imgCache = new Map<string, string>()
const IMG_CACHE_MAX = 4
function cacheGet(key: string): string | undefined {
const v = imgCache.get(key)
if (v !== undefined) {
imgCache.delete(key)
imgCache.set(key, v) // LRU
}
return v
}
function cachePut(key: string, val: string) {
if (imgCache.has(key)) imgCache.delete(key)
imgCache.set(key, val)
if (imgCache.size > IMG_CACHE_MAX) {
const oldest = imgCache.keys().next().value
if (oldest !== undefined) {
// Blob
const url = imgCache.get(oldest)
if (url) URL.revokeObjectURL(url)
imgCache.delete(oldest)
}
}
}
/** 读取历史缓存 PNG 原始字节并包装为 Blob URLraw IPC,省 base64 编码与 ~33% 传输开销) */
async function loadCacheBlobUrl(path: string): Promise<string> {
const buf = await invoke<ArrayBuffer>('screenshot_load_cache_raw', { path })
return URL.createObjectURL(new Blob([buf], { type: 'image/png' }))
}
/** 后台预取相邻贴图:切换上一张/下一张时命中缓存,瞬间完成 */
function preloadAdjacent() {
const list = items.value
for (const i of [cur.value + 1, cur.value - 1]) {
if (i < 0 || i >= list.length) continue
const it = list[i]
if (imgCache.has(it.filePath)) continue
void loadCacheBlobUrl(it.filePath)
.then(url => cachePut(it.filePath, url))
.catch(() => {})
}
}
// ===== / ESC =====
const menuOpen = ref(false)
const menuX = ref(0)
const menuY = ref(0)
const confirmOpen = ref(false)
/** 菜单/确认框估算尺寸(定位钳制 + 小贴图扩窗依据) */
const MENU_W = 156
const MENU_H = 132
const DIALOG_W = 250
const DIALOG_H = 118
/** 扩窗前的原始窗口尺寸(菜单/确认框关闭后还原) */
let baseSize: { w: number; h: number } | null = null
// focus:false WS_EX_NOACTIVATE
// /ESC
// show()SW_SHOW
let focusable = false
async function ensureFocused() {
try {
if (!focusable) {
focusable = true
await win.setFocusable(true)
}
if (!document.hasFocus()) await win.setFocus()
} catch { /* 忽略 */ }
}
async function resetFocusable() {
focusable = false
await win.setFocusable(false).catch(() => {})
}
/** Windows 上非 resizable 窗口 setSize 失效,须先开后还原 */
async function resizeWindow(w: number, h: number) {
await win.setResizable(true)
await win.setSize(new LogicalSize(w, h))
await win.setResizable(false)
}
/** 小贴图(窗口小于菜单/确认框)时临时扩窗容纳,关闭后还原原尺寸 */
async function syncViewport(needW: number, needH: number) {
try {
if (needW > 0) {
const w = window.innerWidth
const h = window.innerHeight
if (w >= needW && h >= needH) return
if (!baseSize) baseSize = { w, h }
await resizeWindow(Math.max(w, needW), Math.max(h, needH))
} else if (baseSize) {
const s = baseSize
baseSize = null
await resizeWindow(s.w, s.h)
}
} catch { /* 忽略 */ }
}
/** 收起菜单并还原视口(后续无重排时使用;切换图片等随后 layoutTo 的路径不要调) */
function closeMenu() {
menuOpen.value = false
void syncViewport(0, 0)
}
function cancelConfirm() {
confirmOpen.value = false
void syncViewport(0, 0)
}
async function openConfirm() {
confirmOpen.value = true
await syncViewport(DIALOG_W, DIALOG_H)
}
/** 键盘:ESC 收起菜单 / 取消确认 / 唤起关闭确认;Enter 确认关闭 */
function onKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape') {
e.preventDefault()
if (menuOpen.value) {
// baseSize
menuOpen.value = false
void openConfirm()
} else if (confirmOpen.value) {
cancelConfirm()
} else {
void openConfirm()
}
} else if (e.key === 'Enter' && confirmOpen.value) {
e.preventDefault()
void closePin()
}
}
/** 失焦时收起右键菜单(确认框保留,属模态意图) */
function onBlur() {
if (menuOpen.value) closeMenu()
}
/** 右键图片弹出操作菜单;同时抢占键盘焦点使 ESC 可用 */
function onContextMenu(e: MouseEvent) {
e.preventDefault()
void ensureFocused()
//
const vw = Math.max(window.innerWidth, MENU_W + 8)
const vh = Math.max(window.innerHeight, MENU_H + 8)
menuX.value = Math.max(4, Math.min(e.clientX + 2, vw - MENU_W - 4))
menuY.value = Math.max(4, Math.min(e.clientY + 2, vh - MENU_H - 4))
confirmOpen.value = false
menuOpen.value = true
void syncViewport(MENU_W + 8, MENU_H + 8)
}
/** 菜单项:切换图片(跳过视口还原,layoutTo 会重排窗口) */
function onMenuPrev() {
menuOpen.value = false
baseSize = null
prev()
}
function onMenuNext() {
menuOpen.value = false
baseSize = null
next()
}
function onMenuClose() {
menuOpen.value = false
void closePin()
}
/** 同步主界面主题(独立窗口无 pinia,从 localStorage 读取应用设置) */ /** 同步主界面主题(独立窗口无 pinia,从 localStorage 读取应用设置) */
function applyTheme() { function applyTheme() {
const root = document.documentElement const root = document.documentElement
@@ -118,8 +294,12 @@ async function loadImage(index: number) {
loading.value = true loading.value = true
errorMsg.value = '' errorMsg.value = ''
try { try {
const b64 = await commands.screenshotLoadCache(item.filePath) // + IPC
const dataUrl = `data:image/png;base64,${b64}` let dataUrl = cacheGet(item.filePath)
if (!dataUrl) {
dataUrl = await loadCacheBlobUrl(item.filePath)
cachePut(item.filePath, dataUrl)
}
// //
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
const img = new Image() const img = new Image()
@@ -134,6 +314,8 @@ async function loadImage(index: number) {
imgSrc.value = dataUrl imgSrc.value = dataUrl
await win.show().catch(() => {}) await win.show().catch(() => {})
loading.value = false loading.value = false
//
preloadAdjacent()
} catch (e) { } catch (e) {
console.error('[screenshot] 贴图加载失败', e) console.error('[screenshot] 贴图加载失败', e)
errorMsg.value = '加载图片失败:' + (e as Error).message errorMsg.value = '加载图片失败:' + (e as Error).message
@@ -152,8 +334,13 @@ function next() {
void loadImage(cur.value - 1) void loadImage(cur.value - 1)
} }
function closePin() { async function closePin() {
void win.hide().catch(() => {}) menuOpen.value = false
confirmOpen.value = false
baseSize = null
// show()
await resetFocusable()
await win.hide().catch(() => {})
} }
/** OSD /** OSD
@@ -161,12 +348,24 @@ function closePin() {
function onImageMouseDown(e: MouseEvent) { function onImageMouseDown(e: MouseEvent) {
if (e.button !== 0) return if (e.button !== 0) return
e.preventDefault() e.preventDefault()
//
if (menuOpen.value) {
closeMenu()
return
}
//
void ensureFocused()
void win.startDragging().catch(() => {}) void win.startDragging().catch(() => {})
} }
/** 响应主窗口 'screenshot-pin-show':读取要贴的历史索引并加载显示 */ /** 响应主窗口 'screenshot-pin-show':读取要贴的历史索引并加载显示 */
function openPin() { function openPin() {
items.value = readHistory() items.value = readHistory()
// store
menuOpen.value = false
confirmOpen.value = false
baseSize = null
focusable = false
let index = 0 let index = 0
try { try {
index = Number(localStorage.getItem(STORAGE_KEYS.screenshotPinIndex) ?? 0) || 0 index = Number(localStorage.getItem(STORAGE_KEYS.screenshotPinIndex) ?? 0) || 0
@@ -176,6 +375,8 @@ function openPin() {
onMounted(async () => { onMounted(async () => {
applyTheme() applyTheme()
window.addEventListener('keydown', onKeyDown)
window.addEventListener('blur', onBlur)
// show store // show store
showUnlisten = await listen(EVENTS.screenshotPinShow, () => { showUnlisten = await listen(EVENTS.screenshotPinShow, () => {
openPin() openPin()
@@ -184,17 +385,20 @@ onMounted(async () => {
}) })
onUnmounted(() => { onUnmounted(() => {
window.removeEventListener('keydown', onKeyDown)
window.removeEventListener('blur', onBlur)
showUnlisten?.() showUnlisten?.()
}) })
</script> </script>
<template> <template>
<div class="pin-root"> <div class="pin-root">
<!-- 图片区左键按住自由拖动显式 startDragging蓝色光晕阴影 --> <!-- 图片区左键按住自由拖动显式 startDragging右键弹出操作菜单蓝色光晕阴影 -->
<div <div
class="pin-image" class="pin-image"
:style="{ width: imgCssW + 'px', height: imgCssH + 'px' }" :style="{ width: imgCssW + 'px', height: imgCssH + 'px' }"
@mousedown="onImageMouseDown" @mousedown="onImageMouseDown"
@contextmenu="onContextMenu"
> >
<img <img
v-if="imgSrc" v-if="imgSrc"
@@ -207,32 +411,40 @@ onUnmounted(() => {
<div v-else-if="loading" class="pin-tip"><Loader2 class="size-5 animate-spin" /></div> <div v-else-if="loading" class="pin-tip"><Loader2 class="size-5 animate-spin" /></div>
</div> </div>
<!-- 下角控制展开图标hover 展开 上一张 / 下一张 / 关闭 --> <!-- 键操作菜单上一张 / 下一张 / 关闭贴图 -->
<div class="pin-controls"> <div
<div class="pin-actions"> v-if="menuOpen"
<button class="pin-menu"
class="pin-btn" :style="{ left: menuX + 'px', top: menuY + 'px' }"
title="上一张" @mousedown.stop
:disabled="cur >= items.length - 1" @contextmenu.prevent
@click="prev" >
> <button class="pin-menu-item" :disabled="cur >= items.length - 1" @click="onMenuPrev">
<ChevronLeft class="size-4" /> <ChevronLeft class="size-4" /><span>上一张</span>
</button>
<button
class="pin-btn"
title="下一张"
:disabled="cur <= 0"
@click="next"
>
<ChevronRight class="size-4" />
</button>
<button class="pin-btn pin-btn-danger" title="关闭" @click="closePin">
<X class="size-4" />
</button>
</div>
<button class="pin-expand" title="更多操作">
<Maximize2 class="size-4" />
</button> </button>
<button class="pin-menu-item" :disabled="cur <= 0" @click="onMenuNext">
<ChevronRight class="size-4" /><span>下一张</span>
</button>
<div class="pin-menu-sep" />
<button class="pin-menu-item pin-menu-danger" @click="onMenuClose">
<X class="size-4" /><span>关闭贴图</span>
</button>
</div>
<!-- ESC 关闭确认Enter / 按钮确认ESC / 点击遮罩取消 -->
<div
v-if="confirmOpen"
class="pin-confirm-mask"
@mousedown.self="cancelConfirm"
@contextmenu.prevent.stop
>
<div class="pin-confirm">
<div class="pin-confirm-text">关闭这张贴图</div>
<div class="pin-confirm-btns">
<button class="pin-confirm-btn" @click="cancelConfirm">取消</button>
<button class="pin-confirm-btn pin-confirm-danger" @click="closePin">关闭</button>
</div>
</div>
</div> </div>
</div> </div>
</template> </template>
@@ -284,64 +496,106 @@ onUnmounted(() => {
background: rgba(15, 23, 42, 0.85); background: rgba(15, 23, 42, 0.85);
} }
/* 右下角控制:独立于图片拖动区(兄弟节点),点击不触发拖动 */ /* 右键操作菜单:毛玻璃暗色卡片,定位在右键点附近(脚本钳制在窗口内) */
.pin-controls { .pin-menu {
position: absolute; position: absolute;
right: 26px; z-index: 30;
bottom: 26px; min-width: 148px;
z-index: 10; padding: 4px;
display: flex; border-radius: 10px;
align-items: center; background: rgba(15, 23, 42, 0.92);
gap: 6px; backdrop-filter: blur(8px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
} }
.pin-expand, .pin-menu-item {
.pin-btn {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; gap: 8px;
width: 28px; width: 100%;
height: 28px; padding: 7px 10px;
border: none; border: none;
border-radius: 8px; border-radius: 6px;
cursor: pointer; background: transparent;
color: rgba(255, 255, 255, 0.92); color: rgba(255, 255, 255, 0.92);
background: rgba(15, 23, 42, 0.65); font-size: 13px;
backdrop-filter: blur(4px); cursor: pointer;
transition: background-color 0.15s ease; transition: background-color 0.12s ease;
} }
.pin-btn:hover { .pin-menu-item:hover:not(:disabled) {
background: rgba(30, 58, 138, 0.85); background: rgba(59, 130, 246, 0.9);
} }
.pin-btn-danger:hover { .pin-menu-item:disabled {
background: rgba(185, 28, 28, 0.85);
}
.pin-btn:disabled {
opacity: 0.4; opacity: 0.4;
cursor: not-allowed; cursor: not-allowed;
} }
.pin-actions { .pin-menu-danger:hover:not(:disabled) {
display: flex; background: rgba(185, 28, 28, 0.9);
align-items: center;
gap: 6px;
opacity: 0;
visibility: hidden;
transform: translateX(8px);
transition:
opacity 0.15s ease,
transform 0.15s ease,
visibility 0.15s;
} }
/* hover 展开三个图标按钮 */ .pin-menu-sep {
.pin-controls:hover .pin-actions, height: 1px;
.pin-controls:focus-within .pin-actions { margin: 4px 6px;
opacity: 1; background: rgba(255, 255, 255, 0.12);
visibility: visible; }
transform: translateX(0);
/* ESC 关闭确认:全窗遮罩 + 居中卡片 */
.pin-confirm-mask {
position: absolute;
inset: 0;
z-index: 40;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.25);
}
.pin-confirm {
display: flex;
flex-direction: column;
gap: 12px;
padding: 14px 16px;
border-radius: 12px;
background: rgba(15, 23, 42, 0.95);
backdrop-filter: blur(8px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45);
color: #fff;
}
.pin-confirm-text {
font-size: 13px;
white-space: nowrap;
}
.pin-confirm-btns {
display: flex;
justify-content: flex-end;
gap: 8px;
}
.pin-confirm-btn {
padding: 5px 14px;
border: none;
border-radius: 7px;
font-size: 12px;
cursor: pointer;
background: rgba(255, 255, 255, 0.12);
color: #fff;
transition: background-color 0.12s ease;
}
.pin-confirm-btn:hover {
background: rgba(255, 255, 255, 0.22);
}
.pin-confirm-danger {
background: rgba(185, 28, 28, 0.85);
}
.pin-confirm-danger:hover {
background: rgba(220, 38, 38, 0.95);
} }
</style> </style>
+7
View File
@@ -39,6 +39,13 @@ export interface WindowInfo {
visualRect: { x: number; y: number; width: number; height: number } | null visualRect: { x: number; y: number; width: number; height: number } | null
} }
/** screenshot-begin 事件 payload:捕获时刻光标物理坐标 + 拾取窗口列表(Z 序顶→底) */
export interface ScreenshotBeginPayload {
cursorX: number
cursorY: number
windows: WindowInfo[]
}
// ===== 工具与选项 ===== // ===== 工具与选项 =====
export const TOOLS: { value: ToolType; icon: Component; label: string }[] = [ export const TOOLS: { value: ToolType; icon: Component; label: string }[] = [
{ value: 'rect', icon: Square, label: '矩形' }, { value: 'rect', icon: Square, label: '矩形' },
+152 -4
View File
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { Monitor, Sun, Moon, Sparkles, Layers, LogOut, Package, GripVertical, Info, RefreshCw, Download, Check, Loader2 } from '@lucide/vue' import { Monitor, Sun, Moon, Sparkles, Layers, LogOut, Package, GripVertical, Info, RefreshCw, Download, Check, Loader2, X } from '@lucide/vue'
import { Switch } from '@/components/ui/switch' import { Switch } from '@/components/ui/switch'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
@@ -9,6 +9,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip
import { useAppStore, type Theme, type EffectType, type ModuleInfo } from '@/stores/appStore' import { useAppStore, type Theme, type EffectType, type ModuleInfo } from '@/stores/appStore'
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 { 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'
@@ -16,6 +17,7 @@ import { listen, type UnlistenFn } from '@tauri-apps/api/event'
import { invoke } from '@tauri-apps/api/core' import { invoke } from '@tauri-apps/api/core'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue' import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { VueDraggable } from 'vue-draggable-plus' import { VueDraggable } from 'vue-draggable-plus'
import { toast } from 'vue-sonner'
const appStore = useAppStore() const appStore = useAppStore()
const searchStore = useSearchStore() const searchStore = useSearchStore()
@@ -67,23 +69,152 @@ const checkUpdate = async () => {
updateResult.value = await commands.updateCheck() updateResult.value = await commands.updateCheck()
} catch (e) { } catch (e) {
console.error('[updater] 检查更新失败', e) console.error('[updater] 检查更新失败', e)
toast.error('检查更新失败', { description: String(e) })
} finally { } finally {
checking.value = false checking.value = false
} }
} }
/** 下载并应用应用更新(便携版替换 exe / 安装版静默安装),触发应用退出重启 */ // ===== //
// update_install 退 mihomo =====
const downloaderStore = useDownloaderStore()
/** 下载中显示取消按钮(下载完成进入 applying 阶段后不可取消) */
const downloadCancellable = ref(false)
/** 取消下载的唤醒回调(由等待 Promise 设置) */
let cancelAppDownload: (() => void) | null = null
/** 格式化速度 MB/s */
const fmtSpeed = (bytesPerSec: number) => `${(bytesPerSec / 1024 / 1024).toFixed(2)} MB/s`
/** 下载并应用应用更新:下载模块下载 → update_install 安装(触发应用退出重启) */
const installUpdate = async () => { const installUpdate = async () => {
if (appUpdating.value) return if (appUpdating.value) return
const result = updateResult.value
if (!result) return
// -setup.exe便 setup .exe
const asset = result.installType === 'installed'
? result.assets.find(a => a.name.endsWith('-setup.exe'))
: result.assets.find(a => a.name.endsWith('.exe') && !a.name.includes('setup'))
if (!asset) {
toast.error('未找到可用的更新安装包', { description: `安装类型: ${installTypeText.value},请在 release 页手动下载` })
return
}
appUpdating.value = true appUpdating.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.updateInstall() // 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 || !appUpdating.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)}`
: '正在下载...'
}
})
// / /
downloadCancellable.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
cancelAppDownload = 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 }
}
cancelAppDownload = () => 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 })
})
downloadCancellable.value = false
if (!dlResult.ok) throw new Error(dlResult.error || '下载失败')
downloadOk = true
// copy
const dlTask = downloaderStore.tasks.find(t => t.id === taskId)
if (!dlTask) throw new Error('下载任务未找到')
const exePath = dlTask.dir + '/' + dlTask.filename
try {
await downloaderStore.removeTask(taskId, false)
taskId = null
} catch { /* 任务清理失败不阻断安装 */ }
// emit applying app.exit(0)
progress.value = {
stage: 'applying',
percent: 100,
downloadedBytes: 0,
totalBytes: null,
message: installTypeText.value === '安装版' ? '正在启动安装程序...' : '正在替换程序文件...'
}
await commands.updateInstall(exePath)
// 退
} catch (e) { } catch (e) {
console.error('[updater] 应用更新失败', e) console.error('[updater] 应用更新失败', e)
const msg = String(e)
if (msg.includes('已取消下载')) {
toast.info('已取消更新下载')
} else {
toast.error('应用更新失败', { description: msg })
}
// /
if (taskId) {
try { await downloaderStore.removeTask(taskId, !downloadOk) } catch { /* 忽略 */ }
}
appUpdating.value = false appUpdating.value = false
progress.value = null
} finally {
downloadCancellable.value = false
cancelAppDownload = null
if (downloadProgressFn) downloadProgressFn()
if (completeHolder.fn) completeHolder.fn()
} }
} }
/** 取消应用更新下载(仅下载阶段可取消) */
const cancelUpdateDownload = async () => {
if (!downloadCancellable.value) return
cancelAppDownload?.()
}
/** 更新 ThingHK 内核:后端先停止监控内核再覆盖文件 */ /** 更新 ThingHK 内核:后端先停止监控内核再覆盖文件 */
const updateThinghkKernel = async () => { const updateThinghkKernel = async () => {
if (kernelUpdating.value) return if (kernelUpdating.value) return
@@ -91,8 +222,10 @@ const updateThinghkKernel = async () => {
try { try {
await commands.updateThinghk() await commands.updateThinghk()
await loadAppInfo() await loadAppInfo()
toast.success('ThingHK 内核更新完成')
} catch (e) { } catch (e) {
console.error('[updater] ThingHK 更新失败', e) console.error('[updater] ThingHK 更新失败', e)
toast.error('ThingHK 内核更新失败', { description: String(e) })
} finally { } finally {
kernelUpdating.value = false kernelUpdating.value = false
} }
@@ -110,6 +243,9 @@ onMounted(() => {
if (e.payload.stage === 'done') { if (e.payload.stage === 'done') {
kernelUpdating.value = false kernelUpdating.value = false
progress.value = null progress.value = null
} else if (e.payload.stage === 'error') {
// ThingHK emit reject
kernelUpdating.value = false
} }
}).then((fn) => { }).then((fn) => {
progressUnlisten = fn progressUnlisten = fn
@@ -501,7 +637,19 @@ const onDragEnd = () => {
<div v-if="appUpdating && progress" class="space-y-1.5 py-1"> <div v-if="appUpdating && progress" class="space-y-1.5 py-1">
<div class="flex items-center justify-between text-xs text-muted-foreground"> <div class="flex items-center justify-between text-xs text-muted-foreground">
<span>{{ progress.message }}</span> <span>{{ progress.message }}</span>
<span class="font-mono">{{ progress.percent }}%</span> <div class="flex items-center gap-2">
<span class="font-mono">{{ progress.percent }}%</span>
<Button
v-if="downloadCancellable"
variant="ghost"
size="sm"
class="h-5 px-1.5 text-xs"
@click="cancelUpdateDownload"
>
<X class="size-3 mr-0.5" />
取消
</Button>
</div>
</div> </div>
<Progress :model-value="progress.percent" /> <Progress :model-value="progress.percent" />
</div> </div>
+32 -7
View File
@@ -315,6 +315,8 @@ function delayClass(delay: number | null): string {
function onKeydown(e: KeyboardEvent) { function onKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') { if (e.key === 'Escape') {
// Select ESC
if (nodeSelectOpen.value) return
e.preventDefault() e.preventDefault()
resetMenuState() resetMenuState()
invoke('tray_menu_hide').catch(() => {}) invoke('tray_menu_hide').catch(() => {})
@@ -365,9 +367,17 @@ function resolveIsDark(theme: string): boolean {
return window.matchMedia('(prefers-color-scheme: dark)').matches return window.matchMedia('(prefers-color-scheme: dark)').matches
} }
/** 上次已应用的主题组合缓存:未变化时跳过(省 4-6 次 IPC,降低菜单显示延迟) */
let lastAppliedThemeKey = ''
async function applyTheme() { async function applyTheme() {
const root = document.documentElement const root = document.documentElement
const { theme, effect } = readMainTheme() const { theme, effect } = readMainTheme()
const isDark = resolveIsDark(theme)
const key = `${theme}|${effect}|${isDark}`
if (key === lastAppliedThemeKey) return
lastAppliedThemeKey = key
try { try {
const tauriWin = getCurrentWindow() const tauriWin = getCurrentWindow()
@@ -378,8 +388,6 @@ async function applyTheme() {
} }
} catch { /* 非 Tauri 环境忽略 */ } } catch { /* 非 Tauri 环境忽略 */ }
const isDark = resolveIsDark(theme)
root.classList.remove('dark', 'effect-mica', 'effect-acrylic', 'effect-normal') root.classList.remove('dark', 'effect-mica', 'effect-acrylic', 'effect-normal')
root.classList.add(`effect-${effect}`) root.classList.add(`effect-${effect}`)
if (isDark) root.classList.add('dark') if (isDark) root.classList.add('dark')
@@ -418,13 +426,25 @@ onMounted(async () => {
mq.addEventListener('change', onThemeChange) mq.addEventListener('change', onThemeChange)
unlistenFns.push(() => mq.removeEventListener('change', onThemeChange)) unlistenFns.push(() => mq.removeEventListener('change', onThemeChange))
// /线 tray-menu-show
// 150ms
// " resize"
let menuJustShown = 0 // ""
let finalizeTimer = 0
unlistenFns.push(await listen<TrayMenuState>('tray-menu-show', async (event) => { unlistenFns.push(await listen<TrayMenuState>('tray-menu-show', async (event) => {
await applyTheme() await applyTheme()
Object.assign(state, event.payload) Object.assign(state, event.payload)
osdVisible.value = readOsdVisible() osdVisible.value = readOsdVisible()
// / // /
resetMenuState() resetMenuState()
await measureAndShow() //
menuJustShown = Date.now()
window.clearTimeout(finalizeTimer)
finalizeTimer = window.setTimeout(async () => {
menuJustShown = 0
await measureAndShow()
}, 150)
})) }))
// //
@@ -432,11 +452,16 @@ onMounted(async () => {
if (!focused) resetMenuState() if (!focused) resetMenuState()
})) }))
// // - finalizeTimer
// measureAndShow win.show // -
// tray-menu-show // - mihomo node
unlistenFns.push(await listen<TrayMenuState>('tray-menu-state-updated', (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 {
const visible = await getCurrentWindow().isVisible()
if (visible) await measureAndShow()
} catch { /* 非 Tauri 环境忽略 */ }
})) }))
// tray_menu_show_window tray_menu_ready // tray_menu_show_window tray_menu_ready
+13 -4
View File
@@ -86,6 +86,9 @@ export const useClipboardStore = defineStore('clipboard', () => {
} }
// ===== 查询 ===== // ===== 查询 =====
/** 列表请求序号:翻页/搜索/过滤快速操作时丢弃过期请求结果,避免旧请求覆盖新结果 */
let listSeq = 0
/** 拉取指定页的历史数据。pageSize 默认 50。 */ /** 拉取指定页的历史数据。pageSize 默认 50。 */
const fetchHistoryPage = async (opts: { const fetchHistoryPage = async (opts: {
kind?: string kind?: string
@@ -96,10 +99,13 @@ export const useClipboardStore = defineStore('clipboard', () => {
const pageSize = opts.pageSize ?? 50 const pageSize = opts.pageSize ?? 50
const page = Math.max(1, opts.page ?? 1) const page = Math.max(1, opts.page ?? 1)
const offset = (page - 1) * pageSize const offset = (page - 1) * pageSize
const seq = ++listSeq
try { try {
const res = await commands.clipboardGetHistory(pageSize, offset, kind) const res = await commands.clipboardGetHistory(pageSize, offset, kind)
history.value = res.items if (seq === listSeq) {
historyTotal.value = res.total history.value = res.items
historyTotal.value = res.total
}
} catch (e) { } catch (e) {
logger.error('获取历史失败: ' + e) logger.error('获取历史失败: ' + e)
} }
@@ -123,10 +129,13 @@ export const useClipboardStore = defineStore('clipboard', () => {
if (!query.trim()) { if (!query.trim()) {
return fetchHistoryPage({ page, pageSize }) return fetchHistoryPage({ page, pageSize })
} }
const seq = ++listSeq
try { try {
const res = await commands.clipboardSearch(query, pageSize, (page - 1) * pageSize) const res = await commands.clipboardSearch(query, pageSize, (page - 1) * pageSize)
history.value = res.items if (seq === listSeq) {
historyTotal.value = res.total history.value = res.items
historyTotal.value = res.total
}
} catch (e) { } catch (e) {
logger.error('搜索失败: ' + e) logger.error('搜索失败: ' + e)
} }
+103 -8
View File
@@ -9,6 +9,7 @@ import type {
DownloadTask as BindDownloadTask, DownloadTask as BindDownloadTask,
DownloaderSettings as BindDownloaderSettings, DownloaderSettings as BindDownloaderSettings,
CheckUrlResult as BindCheckUrlResult, CheckUrlResult as BindCheckUrlResult,
TorrentInfo as BindTorrentInfo,
TaskStatus, TaskStatus,
} from '@/lib/bindings' } from '@/lib/bindings'
@@ -19,11 +20,14 @@ const logger = createLogger('downloader')
export type DownloadTask = Required<BindDownloadTask> export type DownloadTask = Required<BindDownloadTask>
export type DownloaderSettings = Required<BindDownloaderSettings> export type DownloaderSettings = Required<BindDownloaderSettings>
export type CheckUrlResult = Required<BindCheckUrlResult> export type CheckUrlResult = Required<BindCheckUrlResult>
export type TorrentInfo = Required<BindTorrentInfo>
// re-export:跨端类型统一由 bindings 提供,组件从本 store import 的路径保持不变 // re-export:跨端类型统一由 bindings 提供,组件从本 store import 的路径保持不变
export type { export type {
TaskStatus, TaskStatus,
TaskProtocol,
Segment, Segment,
BtFileInfo,
DuplicateKind, DuplicateKind,
ExistingTaskInfo, ExistingTaskInfo,
} from '@/lib/bindings' } from '@/lib/bindings'
@@ -48,6 +52,8 @@ interface ProgressPayload {
totalSize: number totalSize: number
speed: number speed: number
status: TaskStatus status: TaskStatus
/** 每个分段的已下载字节(与任务 segments 一一对应,供详情弹窗实时展示) */
segments: number[]
} }
/** 下载完成事件载荷 */ /** 下载完成事件载荷 */
@@ -68,6 +74,11 @@ export const useDownloaderStore = defineStore('downloader', () => {
let progressUnlisten: UnlistenFn | null = null let progressUnlisten: UnlistenFn | null = null
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 inspectReadyUnlisten: UnlistenFn | null = null
/** 磁力元数据解析就绪回调(模块注册,用于弹文件勾选对话框) */
let btInspectReadyHandler: ((id: string) => void) | null = null
// ===== 任务列表 ===== // ===== 任务列表 =====
const refreshTasks = async () => { const refreshTasks = async () => {
@@ -82,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) {
@@ -102,13 +126,26 @@ 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
task.status = payload.status task.status = payload.status
// 分段实时进度(详情弹窗分段条随下载动态更新)
if (Array.isArray(payload.segments)) {
task.segments.forEach((seg, i) => {
const v = payload.segments?.[i]
if (v !== undefined) seg.completed = v
})
}
} }
} }
@@ -124,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,
@@ -161,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 {
@@ -215,7 +277,7 @@ export const useDownloaderStore = defineStore('downloader', () => {
} }
const startEventListeners = async () => { const startEventListeners = async () => {
if (progressUnlisten && completeUnlisten && addedUnlisten) return if (progressUnlisten && completeUnlisten && addedUnlisten && removedUnlisten) return
if (!progressUnlisten) { if (!progressUnlisten) {
progressUnlisten = await listen<ProgressPayload>('download-progress', (e) => { progressUnlisten = await listen<ProgressPayload>('download-progress', (e) => {
// 同名任务只保留最新进度,合并后由 rAF 统一应用 // 同名任务只保留最新进度,合并后由 rAF 统一应用
@@ -234,6 +296,21 @@ export const useDownloaderStore = defineStore('downloader', () => {
refreshTasks() refreshTasks()
}) })
} }
if (!removedUnlisten) {
removedUnlisten = await listen<{ id: string }>('download-removed', () => {
// 任务被删除(浏览器扩展通过 HTTP API 删除时前端无从感知),刷新任务列表
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 = () => {
@@ -249,6 +326,14 @@ export const useDownloaderStore = defineStore('downloader', () => {
addedUnlisten() addedUnlisten()
addedUnlisten = null addedUnlisten = null
} }
if (removedUnlisten) {
removedUnlisten()
removedUnlisten = null
}
if (inspectReadyUnlisten) {
inspectReadyUnlisten()
inspectReadyUnlisten = null
}
} }
// ===== 初始化 ===== // ===== 初始化 =====
@@ -261,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,
@@ -270,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,
@@ -287,6 +381,7 @@ export const useDownloaderStore = defineStore('downloader', () => {
// init // init
init, init,
// utils // utils
setBtInspectReadyHandler,
openDir openDir
} }
}) })
+290 -113
View File
@@ -3,7 +3,7 @@ import { computed, ref, watch } from 'vue'
import { invoke } from '@tauri-apps/api/core' import { invoke } from '@tauri-apps/api/core'
import { emit, listen, type UnlistenFn } from '@tauri-apps/api/event' import { emit, listen, type UnlistenFn } from '@tauri-apps/api/event'
import { WebviewWindow } from '@tauri-apps/api/webviewWindow' import { WebviewWindow } from '@tauri-apps/api/webviewWindow'
import { currentMonitor, LogicalPosition, LogicalSize } from '@tauri-apps/api/window' import { currentMonitor, LogicalPosition } from '@tauri-apps/api/window'
import { toast } from 'vue-sonner' import { toast } from 'vue-sonner'
import { createLogger } from '@/lib/logger' import { createLogger } from '@/lib/logger'
import { EVENTS, STORAGE_KEYS, WINDOWS } from '@/lib/constants' import { EVENTS, STORAGE_KEYS, WINDOWS } from '@/lib/constants'
@@ -261,14 +261,14 @@ function defaultOsdConfig(): OsdConfig {
labelLanguage: 'zh', labelLanguage: 'zh',
layout: 'single', layout: 'single',
updateIntervalMs: 1000, updateIntervalMs: 1000,
// 默认关闭点击穿透:关闭后左键可直接拖动悬浮窗 // 默认开启点击穿透:悬浮窗不拦截鼠标,需拖动时临时关闭
clickThrough: false, clickThrough: true,
fontColor: '#ffffff', fontColor: '#ffffff',
fontOpacity: 100, fontOpacity: 100,
bgColor: 'transparent', bgColor: 'transparent',
colorThemeEnabled: true, colorThemeEnabled: true,
colorTheme: { ...DEFAULT_COLOR_THEME }, colorTheme: { ...DEFAULT_COLOR_THEME },
fontStrokeEnabled: false, fontStrokeEnabled: true,
fontStrokeWidth: 1, fontStrokeWidth: 1,
fontStrokeColor: '#000000', fontStrokeColor: '#000000',
alert: { ...DEFAULT_ALERT_CONFIG, maxValues: { ...DEFAULT_ALERT_CONFIG.maxValues } }, alert: { ...DEFAULT_ALERT_CONFIG, maxValues: { ...DEFAULT_ALERT_CONFIG.maxValues } },
@@ -335,7 +335,7 @@ export const useMonitorStore = defineStore('monitor', () => {
/** /**
* OSD store App initOsd * OSD store App initOsd
* / OSD store osd-state-update * / OSD store osd-data-update keyvalue
* 使 OSD OSD * 使 OSD OSD
*/ */
const osdConfig = ref<OsdConfig>(loadOsdConfig()) const osdConfig = ref<OsdConfig>(loadOsdConfig())
@@ -352,17 +352,47 @@ export const useMonitorStore = defineStore('monitor', () => {
}, 200) }, 200)
} }
/** 推送 OSD 状态到所有 OSD 窗口(仅 OSD 开启时生效) */ /** 传感器 key→value 映射(key 格式与 OSD 显示项一致:{groupId}/{hwName}/{sensorName}/{type}
* OSD O(1) OSD */
const sensorKeyMap = computed<Record<string, number | null>>(() => {
const map: Record<string, number | null> = {}
for (const g of snapshot.value?.groups ?? []) {
for (const s of g.sensors) {
map[`${g.id}/${s.hardwareName}/${s.name}/${s.type}`.replace(/\s+/g, '_').toLowerCase()] = s.value ?? null
}
}
return map
})
/** 推送 OSD 数据到所有 OSD 窗口(高频通道:仅显示项 key→value 映射 + 网速,每秒一次) */
async function pushOsdState() { async function pushOsdState() {
if (!osdConfig.value.overlayEnabled) return
try {
const map = sensorKeyMap.value
const data: Record<string, number | null> = {}
for (const item of osdConfig.value.overlayItems) {
// 网速特殊项不查快照,通过 networkSpeed 字段携带
if (item.special) continue
data[item.key] = map[item.key] ?? null
}
await emit(EVENTS.osdDataUpdate, {
data,
networkSpeed: networkSpeed.value,
})
} catch (e) {
logger.error('[OSD] 推送数据失败: ' + e)
}
}
/** 推送 OSD 配置到所有 OSD 窗口(低频通道:配置变化时调用,防抖合并) */
async function pushOsdConfig() {
if (!osdConfig.value.overlayEnabled) return if (!osdConfig.value.overlayEnabled) return
try { try {
await emit(EVENTS.osdStateUpdate, { await emit(EVENTS.osdStateUpdate, {
config: osdConfig.value, config: osdConfig.value,
snapshot: snapshot.value,
networkSpeed: networkSpeed.value,
}) })
} catch (e) { } catch (e) {
logger.error('[OSD] 推送状态失败: ' + e) logger.error('[OSD] 推送配置失败: ' + e)
} }
} }
@@ -553,11 +583,41 @@ export const useMonitorStore = defineStore('monitor', () => {
return autoStart.value return autoStart.value
} }
/** 设置"自动启动监控内核"开关 */ /** 关闭"自动启动监控内核"时暂存的 OSD 开关状态(开启自动启动时据此恢复) */
const OSD_PENDING_KEY = STORAGE_KEYS.monitorOsdPending
/** 待恢复的 OSD 开关状态:关闭自动启动时记录、开启时按记录恢复。持久化于 localStorage,跨重启有效。 */
let pendingOsdEnabled: boolean | null = null
try {
pendingOsdEnabled = JSON.parse(localStorage.getItem(OSD_PENDING_KEY) ?? 'null')
} catch { /* 忽略非法缓存,视为无恢复记录 */ }
/**
* "自动启动监控内核"
* OSD OSD
* - OSD OSDOSD
* - OSD
*/
async function setAutoStart(enabled: boolean) { async function setAutoStart(enabled: boolean) {
try { try {
await invoke('monitor_set_auto_start', { enabled }) await invoke('monitor_set_auto_start', { enabled })
autoStart.value = enabled autoStart.value = enabled
if (!enabled) {
// 关闭时记录当前 OSD 状态,随后由 overlayEnabled watch 统一隐藏悬浮窗
pendingOsdEnabled = osdConfig.value.overlayEnabled
try { localStorage.setItem(OSD_PENDING_KEY, JSON.stringify(pendingOsdEnabled)) } catch { /* 忽略 */ }
osdConfig.value.overlayEnabled = false
saveOsdConfig(osdConfig.value)
} else {
// 开启时若有记录且曾为开,恢复 OSD 显示
if (pendingOsdEnabled === true) {
osdConfig.value.overlayEnabled = true
saveOsdConfig(osdConfig.value)
}
// 消费记录,避免下次开启再次恢复
pendingOsdEnabled = null
try { localStorage.removeItem(OSD_PENDING_KEY) } catch { /* 忽略 */ }
}
} catch (e) { } catch (e) {
errorMsg.value = String(e) errorMsg.value = String(e)
logger.error('设置自动启动开关失败: ' + e) logger.error('设置自动启动开关失败: ' + e)
@@ -705,6 +765,8 @@ export const useMonitorStore = defineStore('monitor', () => {
/** 抑制百分比 watch 的程序定位标志:拖动 onMoved 更新百分比时置 true,避免触发 resetOverlayPosition 循环 */ /** 抑制百分比 watch 的程序定位标志:拖动 onMoved 更新百分比时置 true,避免触发 resetOverlayPosition 循环 */
let suppressPercentWatch = false let suppressPercentWatch = false
/** 抑制 onMoved 处理的程序定位标志:osd_set_bounds 原子调整触发 moved 事件时跳过反算(百分比已是驱动值,无需回写) */
let suppressMovedHandling = false
/** 构建用于 OSD 窗口的 URL(基于当前页面 URL 替换 hash) */ /** 构建用于 OSD 窗口的 URL(基于当前页面 URL 替换 hash) */
function osdUrl(hash: string): string { function osdUrl(hash: string): string {
@@ -726,76 +788,183 @@ export const useMonitorStore = defineStore('monitor', () => {
/** /**
* single: 单行分组式 | * single: 单行分组式 |
* group: 分组横排 + * group: 分组横排 +
* multiline: 多行 + */ * multiline: 多行 +
*
* OsdWindow.vue
* - / CJK fontSizeASCII 0.6em
* - fmtFixedValue pad fmtFixedUnit
* - gap 3pxsingle 4pxgroup 8px gap margin
* - osd-bar padding4*2
*
* computePositionFromPct */
function computeOsdWindowSize( function computeOsdWindowSize(
_itemCount: number, _itemCount: number,
layout: 'single' | 'group' | 'multiline', layout: 'single' | 'group' | 'multiline',
fontSize: number, fontSize: number,
_hasNetItem = false, _hasNetItem = false,
items?: OsdItem[], items: OsdItem[] = [],
): { w: number; h: number } { ): { w: number; h: number } {
const charW = fontSize * 0.62 const charW = fontSize * 0.6 // 等宽 ASCII 字符宽(Cascadia/Consolas ≈0.6em
const cjkW = fontSize // CJK 全角字符宽
const barHPad = 8 // osd-bar 左右 padding 4*2 const barHPad = 8 // osd-bar 左右 padding 4*2
// 按硬件类型分组(与渲染逻辑一致) const isCjk = (ch: string) => {
const groupMap = new Map<string, OsdItem[]>() const c = ch.codePointAt(0)!
if (items?.length) { return (
for (const item of items) { (c >= 0x2e80 && c <= 0x9fff) ||
let gkey: string (c >= 0x3000 && c <= 0x303f) ||
if (item.special === 'net-up' || item.special === 'net-down') gkey = 'network' (c >= 0xff00 && c <= 0xffef) ||
else if (item.groupId.startsWith('gpu')) gkey = 'gpu' (c >= 0xf900 && c <= 0xfaff)
else gkey = item.groupId )
if (!groupMap.has(gkey)) groupMap.set(gkey, []) }
groupMap.get(gkey)!.push(item) // 字符串像素宽(CJK 全角,ASCII 等宽)
const textPx = (s: string) => {
let w = 0
for (const ch of s) w += isCjk(ch) ? cjkW : charW
return w
}
const showLabel = osdConfig.value?.showLabel !== false
const en = osdConfig.value?.labelLanguage === 'en'
const groupLabelText = (gkey: string): string => {
if (en) {
switch (gkey) {
case 'cpu': return 'CPU'
case 'gpu': return 'GPU'
case 'memory': return 'RAM'
case 'storage': return 'DISK'
case 'network': return 'NET'
case 'motherboard': return 'MB'
case 'battery': return 'BAT'
case 'psu': return 'PSU'
default: return gkey.toUpperCase().slice(0, 6)
}
}
switch (gkey) {
case 'cpu': return 'CPU'
case 'gpu': return 'GPU'
case 'memory': return '内存'
case 'storage': return '存储'
case 'network': return '网络'
case 'motherboard': return '主板'
case 'battery': return '电池'
case 'psu': return '电源'
default: return gkey
} }
} }
const groupCount = Math.max(1, groupMap.size)
// 每组数据列宽度:标签(6ch) + 各项(数值+单位+箭头/gap) // 数值固定宽度(字符数,对应 fmtFixedValue 的 pad 宽度)
const groupWidths: number[] = [] const numChars = (it: OsdItem): number => {
for (const [, groupItems] of groupMap) { if (it.special === 'net-up' || it.special === 'net-down') return 6
const labelW = 6 switch (it.type) {
const dataW = groupItems.reduce((sum, item) => { case 'load':
const isNet = item.special === 'net-up' || item.special === 'net-down' case 'level':
return sum + (isNet ? 11 : 8) + 1 case 'temperature': return 3
}, 0) case 'power':
groupWidths.push(labelW + dataW) case 'voltage': return 5
case 'clock':
case 'frequency': return 4
case 'fan': return 4
case 'data':
case 'smalldata': return 5
default: return 5
}
}
// 单位文本(对应 fmtFixedUnit;网速取最宽单位 MB/s 估算)
const showUnit = osdConfig.value?.showUnit !== false
const unitText = (it: OsdItem): string => {
if (it.special === 'net-up' || it.special === 'net-down') return showUnit ? 'MB/s' : ''
if (!showUnit) return ''
switch (it.type) {
case 'temperature': return '°C'
case 'load': return '%'
case 'power': return 'W'
case 'voltage': return 'V'
case 'fan': return 'RPM'
case 'clock':
case 'frequency': return 'MHz'
case 'data':
case 'smalldata': return 'GB'
case 'level': return '%'
default: return it.unit || ''
}
}
// 单一项像素宽:箭头 + 数值 + 单位 + 项内 gap(1px) + 单位 margin(1px)
const itemPx = (it: OsdItem): number => {
let w = it.special ? charW : 0 // 箭头
if (it.special) w += 1 // 箭头与数值 gap
w += numChars(it) * charW
const unit = unitText(it)
if (unit) w += textPx(unit) + 1 + 1 // 数值-单位 gap + 单位 left margin
return w
}
// 一个分组的像素宽:组内各子项 gap(3px) + 标签 + 各项
const groupWidthPx = (gkey: string, list: OsdItem[]): number => {
const labelW = showLabel ? textPx(groupLabelText(gkey)) : 0
const itemsW = list.reduce((s, it) => s + itemPx(it), 0)
const gapCount = list.length + (showLabel ? 1 : 0) - 1
return Math.ceil(labelW + itemsW + Math.max(0, gapCount) * 3)
} }
// 构建分组(归一化 key,与渲染逻辑一致)
const groupMap = new Map<string, OsdItem[]>()
for (const it of items) {
let gkey = it.groupId
if (it.special === 'net-up' || it.special === 'net-down') gkey = 'network'
else if (it.groupId.startsWith('gpu')) gkey = 'gpu'
if (!groupMap.has(gkey)) groupMap.set(gkey, [])
groupMap.get(gkey)!.push(it)
}
const groupEntries = [...groupMap.entries()]
const groupCount = Math.max(1, groupEntries.length)
const gw = groupEntries.map(([k, list]) => groupWidthPx(k, list))
const lineH = Math.ceil(fontSize + 2)
if (layout === 'multiline') { if (layout === 'multiline') {
// 多行:取最宽行 // 每行 = 标签(min 4ch) + 组内 gap(4px) + 各项;取最宽行
const maxLineW = groupWidths.length ? Math.max(...groupWidths) : 10 const labelMinW = 4 * charW
const w = Math.ceil(maxLineW * charW + barHPad) let maxLineW = 0
const lineH = Math.ceil(fontSize + 2) for (const [gkey, list] of groupEntries) {
const h = Math.ceil(groupCount * lineH + 6) const labelW = showLabel ? Math.max(textPx(groupLabelText(gkey)), labelMinW) : 0
return { w: Math.max(120, w), h: Math.max(28, h) } const itemsW = list.reduce((s, it) => s + itemPx(it), 0)
const gapCount = list.length + (showLabel ? 1 : 0) - 1
maxLineW = Math.max(maxLineW, labelW + itemsW + Math.max(0, gapCount) * 4)
}
const w = Math.max(120, Math.ceil(maxLineW + barHPad))
const h = Math.max(28, Math.ceil(groupCount * lineH + 6))
return { w, h }
} }
if (layout === 'group') { if (layout === 'group') {
// 分组横排:各组横排 + 标题行 // 分组横排:各组横排,组间 gap(8px),每组含 padding(4*2)
const totalW = groupWidths.reduce((s, w) => s + w + 4, 0) + (groupCount - 1) * 4 const totalW = gw.reduce((s, w) => s + w, 0)
const w = Math.ceil(totalW * charW + barHPad) + groupCount * 8 // 每组左右 padding 4*2
+ Math.max(0, groupCount - 1) * 8 // 组间 gap
const w = Math.max(120, Math.ceil(totalW + barHPad))
const titleH = Math.ceil(fontSize * 0.85) + 2 const titleH = Math.ceil(fontSize * 0.85) + 2
const dataH = Math.ceil(fontSize) + 2 const dataH = lineH
const h = Math.ceil(titleH + dataH + 10) const h = Math.max(40, Math.ceil(titleH + dataH + 3 + 3))
return { w: Math.max(120, w), h: Math.max(40, h) } return { w, h }
} }
// single:单行分组式,各组横排 + 组间 | 分隔符(1ch) // single:单行分组式,组间 | 分隔符 + 组间 gap(4px)
const sepW = (groupCount - 1) * 1 const sepW = (groupCount - 1) * (textPx('|') + 4)
const totalW = groupWidths.reduce((s, w) => s + w, 0) + sepW const betweenW = Math.max(0, groupCount - 1) * 4
const w = Math.ceil(totalW * charW + barHPad) const w = Math.max(120, Math.ceil(gw.reduce((s, w) => s + w, 0) + sepW + betweenW + barHPad))
const h = Math.ceil(fontSize + 8) const h = Math.max(28, Math.ceil(fontSize + 8))
return { w: Math.max(120, w), h: Math.max(28, h) } return { w, h }
} }
/** 创建/显示悬浮窗窗口(默认置顶 + NoActivate + 点击穿透) */ /** 创建/显示悬浮窗窗口(默认置顶 + NoActivate + 点击穿透) */
async function ensureOverlayWindow() { async function ensureOverlayWindow() {
const existing = await WebviewWindow.getByLabel(OSD_OVERLAY_LABEL) const existing = await WebviewWindow.getByLabel(OSD_OVERLAY_LABEL)
if (existing) { if (existing) {
// 窗口已存在,仅显示并推送最新状态 // 窗口已存在,仅显示并推送最新配置 + 数据
// 不做估算 resize:内容测量上报(osd-content-size)是唯一尺寸/位置更新源,
// 推送配置后 OSD 重新渲染并上报实际尺寸,由监听端原子 setBounds
await existing.show() await existing.show()
await updateOsdWindowSize() await pushOsdConfig()
await pushOsdState() await pushOsdState()
return return
} }
@@ -816,16 +985,11 @@ export const useMonitorStore = defineStore('monitor', () => {
osdConfig.value.overlayItems, osdConfig.value.overlayItems,
) )
// 优先使用保存的像素位置;否则根据百分比计算默认位置 // 位置统一由百分比计算(百分比是唯一位置真相源:拖动后反算更新百分比,
let x: number, y: number // 尺寸变化时按百分比重锚;不再使用保存的像素位置,避免旧尺寸像素与新尺寸不匹配)
if (osdConfig.value.overlayX != null && osdConfig.value.overlayY != null) { const pos = computePositionFromPct(logicalW, logicalH, w, h, osdConfig.value.positionXPct, osdConfig.value.positionYPct)
x = osdConfig.value.overlayX const x = pos.x
y = osdConfig.value.overlayY const y = pos.y
} else {
const pos = computePositionFromPct(logicalW, logicalH, w, h, osdConfig.value.positionXPct, osdConfig.value.positionYPct)
x = pos.x
y = pos.y
}
const win = new WebviewWindow(OSD_OVERLAY_LABEL, { const win = new WebviewWindow(OSD_OVERLAY_LABEL, {
url: osdUrl('osd-overlay'), url: osdUrl('osd-overlay'),
@@ -848,29 +1012,29 @@ export const useMonitorStore = defineStore('monitor', () => {
}) })
win.once('tauri://created', async () => { win.once('tauri://created', async () => {
// 等待 webview 加载后推送初始状态 // 等待 webview 加载后推送初始配置 + 数据
setTimeout(() => pushOsdState(), 300) setTimeout(() => { void pushOsdConfig(); void pushOsdState() }, 300)
// 监听窗口移动,保存像素位置并同步更新百分比(拖动结束后触发) // 监听窗口移动(用户拖动结束后触发),反算更新百分比
try { try {
const winInstance = await win const winInstance = await win
const unlisten = await winInstance.onMoved(async ({ payload }) => { const unlisten = await winInstance.onMoved(async ({ payload }) => {
osdConfig.value.overlayX = payload.x // 程序性 setBounds/setPosition 触发的移动:位置由百分比驱动,无需反算
osdConfig.value.overlayY = payload.y if (suppressMovedHandling) return
// 反算百分比:xPct = x / availW * 100availW = screenW - windowW // payload 为物理像素(PhysicalPosition),需转逻辑像素后再反算百分比
// 置 suppressPercentWatch=true 避免百分比变化触发 resetOverlayPosition 循环 // 置 suppressPercentWatch=true 避免百分比变化触发 resetOverlayPosition 循环
suppressPercentWatch = true suppressPercentWatch = true
try { try {
const monitor = await currentMonitor() const monitor = await currentMonitor()
const screenW = (monitor?.size.width ?? 1920) / (monitor?.scaleFactor ?? 1)
const screenH = (monitor?.size.height ?? 1080) / (monitor?.scaleFactor ?? 1)
const size = await winInstance.outerSize()
const scale = monitor?.scaleFactor ?? 1 const scale = monitor?.scaleFactor ?? 1
const screenW = (monitor?.size.width ?? 1920) / scale
const screenH = (monitor?.size.height ?? 1080) / scale
const size = await winInstance.outerSize()
const winW = size.width / scale const winW = size.width / scale
const winH = size.height / scale const winH = size.height / scale
const availW = Math.max(1, screenW - winW) const availW = Math.max(1, screenW - winW)
const availH = Math.max(1, screenH - winH) const availH = Math.max(1, screenH - winH)
osdConfig.value.positionXPct = Math.round((payload.x / availW) * 100) osdConfig.value.positionXPct = Math.round(((payload.x / scale) / availW) * 100)
osdConfig.value.positionYPct = Math.round((payload.y / availH) * 100) osdConfig.value.positionYPct = Math.round(((payload.y / scale) / availH) * 100)
} catch { /* 忽略百分比反算失败 */ } } catch { /* 忽略百分比反算失败 */ }
saveOsdConfig(osdConfig.value) saveOsdConfig(osdConfig.value)
// 下一个微任务后解除抑制(让本次 watch 回调跳过即可) // 下一个微任务后解除抑制(让本次 watch 回调跳过即可)
@@ -893,21 +1057,38 @@ export const useMonitorStore = defineStore('monitor', () => {
} }
} }
/** 根据当前配置更新悬浮窗窗口尺寸(显示项数量/布局/字号变化时调用) */ /**
async function updateOsdWindowSize() { * //
* 使/xPct=100
* Rust osd_set_bounds SetWindowPos +
* setSize setPosition */
async function resizeOsdWindowWithAnchor(width: number, height: number) {
const w = await WebviewWindow.getByLabel(OSD_OVERLAY_LABEL)
if (!w) return
try { try {
const existing = await WebviewWindow.getByLabel(OSD_OVERLAY_LABEL) const monitor = await currentMonitor()
if (!existing) return const scale = monitor?.scaleFactor ?? 1
const hasNetItem = osdConfig.value.overlayItems.some(i => i.special === 'net-up' || i.special === 'net-down') const screenW = (monitor?.size.width ?? 1920) / scale
const { w, h } = computeOsdWindowSize( const screenH = (monitor?.size.height ?? 1080) / scale
osdConfig.value.overlayItems.length, const pos = computePositionFromPct(screenW, screenH, width, height, osdConfig.value.positionXPct, osdConfig.value.positionYPct)
osdConfig.value.layout, // 抑制本次程序性移动触发的 onMoved 反算(百分比已是驱动值,回写可能因 round 产生扰动)
osdConfig.value.fontSize, suppressMovedHandling = true
hasNetItem, suppressPercentWatch = true
osdConfig.value.overlayItems, // 逻辑坐标 → 物理像素(osd_set_bounds 接收物理像素)
) await invoke('osd_set_bounds', {
await existing.setSize(new LogicalSize(w, h)) label: OSD_OVERLAY_LABEL,
} catch { /* 忽略 */ } x: Math.round(pos.x * scale),
y: Math.round(pos.y * scale),
w: Math.round(width * scale),
h: Math.round(height * scale),
})
} catch { /* 忽略 */ } finally {
// moved 事件异步到达,延迟解除抑制
setTimeout(() => {
suppressMovedHandling = false
suppressPercentWatch = false
}, 150)
}
} }
/** /**
@@ -929,7 +1110,10 @@ export const useMonitorStore = defineStore('monitor', () => {
const w = size.width / scale const w = size.width / scale
const h = size.height / scale const h = size.height / scale
const pos = computePositionFromPct(screenW, screenH, w, h, osdConfig.value.positionXPct, osdConfig.value.positionYPct) const pos = computePositionFromPct(screenW, screenH, w, h, osdConfig.value.positionXPct, osdConfig.value.positionYPct)
// 抑制程序性移动触发的 onMoved 反算
suppressMovedHandling = true
await existing.setPosition(new LogicalPosition(pos.x, pos.y)) await existing.setPosition(new LogicalPosition(pos.x, pos.y))
setTimeout(() => { suppressMovedHandling = false }, 150)
} }
} catch { /* 忽略 */ } } catch { /* 忽略 */ }
} }
@@ -946,17 +1130,21 @@ export const useMonitorStore = defineStore('monitor', () => {
// 仅当尺寸变化超过 1px 时才 setSize,避免无意义的频繁调用 // 仅当尺寸变化超过 1px 时才 setSize,避免无意义的频繁调用
let lastW = 0 let lastW = 0
let lastH = 0 let lastH = 0
const unlisten = await listen<{ width: number; height: number }>('osd-content-size', async (e) => { const unlisten = await listen<{ width: number; height: number }>(EVENTS.osdContentSize, async (e) => {
const { width, height } = e.payload const { width, height } = e.payload
if (Math.abs(width - lastW) < 1 && Math.abs(height - lastH) < 1) return if (Math.abs(width - lastW) < 1 && Math.abs(height - lastH) < 1) return
lastW = width lastW = width
lastH = height lastH = height
try { await resizeOsdWindowWithAnchor(width, height)
const w = await WebviewWindow.getByLabel(OSD_OVERLAY_LABEL)
if (w) await w.setSize(new LogicalSize(width, height))
} catch { /* 忽略 */ }
}) })
osdEventUnlisteners.push(unlisten) osdEventUnlisteners.push(unlisten)
// OSD 窗口挂载完成后请求补发配置+数据:
// 数据通道不含配置,若窗口加载慢错过创建时的首推(300ms),需由窗口主动请求
const unlistenReq = await listen(EVENTS.osdConfigRequest, () => {
void pushOsdConfig()
void pushOsdState()
})
osdEventUnlisteners.push(unlistenReq)
} }
/** initOsd 幂等守卫:监听/配置 watcher 只注册一次(App 启动 + 模块挂载均会调用) */ /** initOsd 幂等守卫:监听/配置 watcher 只注册一次(App 启动 + 模块挂载均会调用) */
@@ -996,13 +1184,10 @@ export const useMonitorStore = defineStore('monitor', () => {
// stop 句柄存入 osdWatchStopsdispose 时统一释放,避免模块重挂载后重复注册 // stop 句柄存入 osdWatchStopsdispose 时统一释放,避免模块重挂载后重复注册
// OSD 开关变化时创建/隐藏悬浮窗 // OSD 开关变化时创建/隐藏悬浮窗
// 注意:开启 OSD 不再自动开启"应用启动时自动启动监控内核"。
// 二者保持独立(双向联动会导致:关自动启动→关 OSD→再开 OSD→自动启动又被强行打开)。
osdWatchStops.push(watch(() => osdConfig.value.overlayEnabled, (enabled) => { osdWatchStops.push(watch(() => osdConfig.value.overlayEnabled, (enabled) => {
if (enabled) { if (enabled) {
// OSD 显示开启时自动开启"应用启动时自动启动监控内核",
// 使 OSD 持续显示不因重启而中断
if (!autoStart.value) {
setAutoStart(true).catch(e => logger.error('[OSD] 自动开启 autoStart 失败: ' + e))
}
// 开启时若显示项为空则不创建窗口 // 开启时若显示项为空则不创建窗口
if (osdConfig.value.overlayItems.length === 0) return if (osdConfig.value.overlayItems.length === 0) return
ensureOverlayWindow().catch(e => logger.error('[OSD] 创建悬浮窗失败: ' + e)) ensureOverlayWindow().catch(e => logger.error('[OSD] 创建悬浮窗失败: ' + e))
@@ -1033,29 +1218,21 @@ export const useMonitorStore = defineStore('monitor', () => {
resetOverlayPosition().catch(() => {}) resetOverlayPosition().catch(() => {})
})) }))
// OSD 配置变化 → 推送到 OSD 窗口(位置/字体/显示项等 // OSD 配置变化 → 推送配置到 OSD 窗口(低频通道,数据通道不受影响
// 防抖 200ms 合并:滑块拖动期间只推送最终状态,避免每帧 emit 整份快照 // 防抖 200ms 合并:滑块拖动期间只推送最终状态,避免每帧 emit 整份配置
// 注意:不做估算 resize——配置推送后 OSD 重渲染并测量上报实际尺寸,
// osd-content-size 监听端原子 setBounds,是唯一尺寸/位置更新源
// (估算 resize 会先跳到估算位置再跳到实际位置,产生闪烁)
osdWatchStops.push(watch(osdConfig, () => { osdWatchStops.push(watch(osdConfig, () => {
if (osdPushTimer) clearTimeout(osdPushTimer) if (osdPushTimer) clearTimeout(osdPushTimer)
osdPushTimer = setTimeout(() => { osdPushTimer = setTimeout(() => {
osdPushTimer = null osdPushTimer = null
if (osdConfig.value.overlayEnabled) { if (osdConfig.value.overlayEnabled) {
pushOsdState() pushOsdConfig()
} }
}, 200) }, 200)
}, { deep: true })) }, { deep: true }))
// 显示项数量/布局/字号变化 → 更新悬浮窗窗口尺寸(自适应内容)
osdWatchStops.push(watch([
() => osdConfig.value.overlayItems.length,
() => osdConfig.value.layout,
() => osdConfig.value.fontSize,
], () => {
if (osdConfig.value.overlayEnabled) {
updateOsdWindowSize().catch(() => {})
}
}))
// 初始化悬浮窗(如果开关已开启) // 初始化悬浮窗(如果开关已开启)
if (osdConfig.value.overlayEnabled) { if (osdConfig.value.overlayEnabled) {
ensureOverlayWindow().catch(e => logger.error('[OSD] 初始化悬浮窗失败: ' + e)) ensureOverlayWindow().catch(e => logger.error('[OSD] 初始化悬浮窗失败: ' + e))
@@ -1111,9 +1288,9 @@ export const useMonitorStore = defineStore('monitor', () => {
saveOsdConfig, saveOsdConfig,
saveOsdConfigDebounced, saveOsdConfigDebounced,
pushOsdState, pushOsdState,
pushOsdConfig,
ensureOverlayWindow, ensureOverlayWindow,
hideOverlayWindow, hideOverlayWindow,
updateOsdWindowSize,
resetOverlayPosition, resetOverlayPosition,
initOsd, initOsd,
disposeOsd, disposeOsd,
+271 -56
View File
@@ -4,6 +4,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 { createLogger } from '@/lib/logger' import { createLogger } from '@/lib/logger'
import { EVENTS } from '@/lib/constants' import { EVENTS } from '@/lib/constants'
import { useDownloaderStore } from '@/stores/downloaderStore'
// Rust 端通过 tauri-specta 生成的类型与命令绑定(bindings.ts // Rust 端通过 tauri-specta 生成的类型与命令绑定(bindings.ts
import { import {
commands, commands,
@@ -11,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
@@ -52,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
@@ -64,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)
@@ -72,6 +104,10 @@ export const useProxyStore = defineStore('proxy', () => {
const installing = ref(false) const installing = ref(false)
const installProgress = ref<InstallProgress | null>(null) const installProgress = ref<InstallProgress | null>(null)
let progressUnlisten: UnlistenFn | null = null let progressUnlisten: UnlistenFn | null = null
/** 当前进行中的内核安装/更新任务(取消时需等待其完全收尾,避免旧任务 finally 覆盖新任务状态) */
let activeInstall: Promise<void> | null = null
/** 取消下载等待的唤醒函数(cancelKernelInstall 调用,置位后下载等待立即以「已取消」返回) */
let cancelDownload: (() => void) | null = null
/** 内核信息(同时尝试从 resource 提取到 cores/ */ /** 内核信息(同时尝试从 resource 提取到 cores/ */
const refreshKernel = async () => { const refreshKernel = async () => {
@@ -101,11 +137,15 @@ export const useProxyStore = defineStore('proxy', () => {
const stop = async () => { const stop = async () => {
await commands.proxyStop() await commands.proxyStop()
await refreshStatus() await refreshStatus()
// 后端关闭 mihomo 时会同步关闭系统代理,这里立即刷新 UI 状态(不等 3s 轮询)
await refreshSystemProxy()
} }
const restart = async () => { const restart = async () => {
await commands.proxyRestart() await commands.proxyRestart()
await refreshStatus() await refreshStatus()
// 重启完成后刷新系统代理状态(后端失败时已同步关闭,成功时重新开启)
await refreshSystemProxy()
} }
/** 等待 mihomo API 就绪(轮询 version 接口,最多等 10 秒) */ /** 等待 mihomo API 就绪(轮询 version 接口,最多等 10 秒) */
@@ -200,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
@@ -260,12 +334,34 @@ export const useProxyStore = defineStore('proxy', () => {
} }
/** /**
* installKernel installing/installProgress/ * zip apply_kernel_update
* installKernel stop mihomo * download-progress / kernel-install-progress
* proxy_update_kernel proxy_install_kernel install_kernel *
* @param mirrorPrefix =GitHub
* @param githubUrl GitHub URL checkKernelUpdate checkKernelUpdate
*/ */
const updateKernel = async (mirrorPrefix: string = ''): Promise<void> => { const updateKernel = async (mirrorPrefix: string = '', githubUrl: string = ''): Promise<void> => {
if (installing.value) return if (installing.value) return
// 等待上一个任务完全收尾
if (activeInstall) {
try { await activeInstall } catch {}
}
// 确定下载 URL
let finalUrl = githubUrl
if (!finalUrl) {
try {
const info = await commands.proxyCheckKernelUpdate()
finalUrl = info.downloadUrl
} catch (e) {
throw new Error('获取更新信息失败: ' + String(e))
}
}
// 应用镜像前缀
if (mirrorPrefix) {
finalUrl = mirrorPrefix + finalUrl
}
installing.value = true installing.value = true
installProgress.value = { installProgress.value = {
stage: 'downloading', stage: 'downloading',
@@ -274,62 +370,132 @@ export const useProxyStore = defineStore('proxy', () => {
totalBytes: null, totalBytes: null,
message: '准备开始下载...' message: '准备开始下载...'
} }
if (!progressUnlisten) {
progressUnlisten = await listen<InstallProgress>(EVENTS.kernelInstallProgress, (e) => { const task = (async () => {
installProgress.value = e.payload let downloadProgressFn: UnlistenFn | null = null
}) // 用对象持有完成事件解绑函数,避免闭包内赋值导致的 TS 类型收窄问题
} const completeHolder: { fn: UnlistenFn | null } = { fn: null }
try { /** 下载阶段是否已成功完成(决定清理时是否删除文件:下载失败删除,apply 失败保留以便重试) */
await commands.proxyUpdateKernel(mirrorPrefix) let downloadOk = false
await refreshKernel() let taskId: string | null = null
} catch (e) { try {
logger.error('内核更新失败: ' + e) // 注册内核安装进度监听(解压/替换/need_stop 阶段由后端 emit
throw e if (!progressUnlisten) {
} finally { progressUnlisten = await listen<InstallProgress>(EVENTS.kernelInstallProgress, (e) => {
installing.value = false installProgress.value = e.payload
if (progressUnlisten) { })
progressUnlisten() }
progressUnlisten = null
// 使用下载模块添加下载任务(保持下载模块自身的代理/进度机制)
const downloaderStore = useDownloaderStore()
try {
// 确保下载模块事件监听已注册(下载器 UI 与这里共用事件流)
await downloaderStore.startEventListeners()
} catch {
// 忽略:本流程自己也会监听进度/完成事件
}
taskId = await downloaderStore.addTask(finalUrl, 'mihomo-update.zip', undefined, {}, false)
// 监听下载进度,转为 InstallProgress 格式
downloadProgressFn = await listen<{
id: string; completedSize: number; totalSize: number; speed: number; status: string
}>('download-progress', (e) => {
if (e.payload.id === taskId) {
const pct = e.payload.totalSize > 0
? Math.round((e.payload.completedSize / e.payload.totalSize) * 90)
: 0
installProgress.value = {
stage: 'downloading',
percent: pct,
downloadedBytes: e.payload.completedSize,
totalBytes: e.payload.totalSize,
message: '正在下载...'
}
}
})
// 等待下载完成(download-complete 事件 / 初始终态 / 取消唤醒 三选一)
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
cancelDownload = null
resolve(r)
}
// 任务添加后瞬间进入终态(如探测即失败 → Error,无 complete 事件)
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 }
}
cancelDownload = () => finish({ ok: false, error: '下载已取消' })
listen<{ id: string; filename: 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 })
})
if (!dlResult.ok) {
throw new Error(dlResult.error || '下载失败')
}
downloadOk = true
// 获取下载任务的保存路径
const dlTask = downloaderStore.tasks.find(t => t.id === taskId)
if (!dlTask) throw new Error('下载任务未找到')
const zipPath = dlTask.dir + '/' + dlTask.filename
// 调用后端执行解压替换(need_stop → 解压 → 替换,进度由 kernel-install-progress 事件上报)
await commands.proxyApplyKernelUpdate(zipPath)
await refreshKernel()
// 更新成功后自动删除下载任务(zip 已被后端清理,不删除磁盘文件)
await downloaderStore.removeTask(taskId, false)
taskId = null
} catch (e) {
// 清理下载任务:下载阶段失败/取消时删除文件;apply 阶段失败仅移除任务(zip 保留便于重试)
if (taskId) {
try {
const downloaderStore = useDownloaderStore()
await downloaderStore.removeTask(taskId, !downloadOk)
} catch {
// 忽略清理错误(任务可能已被移除)
}
}
if (String(e).includes('下载已取消')) return
logger.error('内核更新失败: ' + e)
throw e
} finally {
installing.value = false
cancelDownload = null
if (progressUnlisten) {
progressUnlisten()
progressUnlisten = null
}
if (downloadProgressFn) downloadProgressFn()
if (completeHolder.fn) completeHolder.fn()
} }
})()
activeInstall = task
try {
await task
} finally {
if (activeInstall === task) activeInstall = null
} }
} }
/** /**
* install_kernel * updateKernel githubUrl
* @param mirrorPrefix =GitHub * 使使 apply_kernel_update
* UI
*/ */
const installKernel = async (mirrorPrefix: string = ''): Promise<void> => { const installKernel = async (mirrorPrefix: string = '', _githubUrl: string = ''): Promise<void> => {
if (installing.value) return // 直接委托给 updateKernel(无 githubUrl 时内部自动获取)
installing.value = true await updateKernel(mirrorPrefix, _githubUrl)
installProgress.value = {
stage: 'downloading',
percent: 0,
downloadedBytes: 0,
totalBytes: null,
message: '准备开始下载...'
}
// 注册进度事件监听
if (!progressUnlisten) {
progressUnlisten = await listen<InstallProgress>(EVENTS.kernelInstallProgress, (e) => {
installProgress.value = e.payload
})
}
try {
await commands.proxyInstallKernel(mirrorPrefix)
await refreshKernel()
} catch (e) {
// 错误事件已由后端 emit,这里仅记录日志
logger.error('内核安装失败: ' + e)
throw e
} finally {
// 保留 installProgress 一段时间供 UI 显示终态,由调用方负责清空
installing.value = false
if (progressUnlisten) {
progressUnlisten()
progressUnlisten = null
}
}
} }
/** 清空进度状态(UI 在动画结束后调用) */ /** 清空进度状态(UI 在动画结束后调用) */
@@ -337,6 +503,47 @@ export const useProxyStore = defineStore('proxy', () => {
installProgress.value = null installProgress.value = null
} }
/**
* mihomo
* store.stop() exe
*/
const confirmInstall = async (): Promise<void> => {
await commands.proxyConfirmInstall()
}
/**
* /
* -
* - need_stop zip 便
* finally
* installing"点了没反应"
*/
const cancelKernelInstall = async (): Promise<void> => {
// 1. 唤醒下载等待(若正在下载,下载等待立即以「已取消」返回,由流程内清理任务)
cancelDownload?.()
// 2. 通知后端(若处于 need_stop 等待阶段,置位取消标志唤醒其返回)
try {
await commands.proxyCancelKernelInstall()
} catch (e) {
logger.error('取消内核下载失败: ' + e)
}
// 3. 等待旧任务完全收尾(其内部会移除下载任务并复位状态)
if (activeInstall) {
try {
await activeInstall
} catch {
// 旧任务错误已在任务内部处理
}
}
// 4. 复位 UI 状态
if (progressUnlisten) {
progressUnlisten()
progressUnlisten = null
}
installing.value = false
installProgress.value = null
}
return { return {
// state // state
kernel, kernel,
@@ -345,6 +552,8 @@ export const useProxyStore = defineStore('proxy', () => {
proxies, proxies,
settings, settings,
systemProxy, systemProxy,
traffic,
connections,
initialized, initialized,
installing, installing,
installProgress, installProgress,
@@ -374,10 +583,16 @@ 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,
installKernel, installKernel,
clearInstallProgress clearInstallProgress,
cancelKernelInstall,
confirmInstall
} }
}) })
+27 -8
View File
@@ -8,6 +8,7 @@ import { toast } from 'vue-sonner'
import { EVENTS, STORAGE_KEYS, WINDOWS } from '@/lib/constants' import { EVENTS, STORAGE_KEYS, WINDOWS } from '@/lib/constants'
// Rust 端通过 tauri-specta 生成的命令绑定(bindings.ts // Rust 端通过 tauri-specta 生成的命令绑定(bindings.ts
import { commands } from '@/lib/bindings' import { commands } from '@/lib/bindings'
import type { WindowInfo } from '@/modules/screenshot/types'
export interface RecentCapture { export interface RecentCapture {
id: string id: string
@@ -67,6 +68,8 @@ export const useScreenshotStore = defineStore('screenshot', () => {
let overlayReadyResolve: (() => void) | null = null let overlayReadyResolve: (() => void) | null = null
let overlayReadyPromise: Promise<void> | null = null let overlayReadyPromise: Promise<void> | null = null
let readyListenerInit = false let readyListenerInit = false
/** 上次已应用的覆盖层布局矩形(虚拟屏未变化时跳过 setPosition/setSize,省 2 次 IPC 往返) */
let lastOverlayRectKey = ''
// ===== 贴图窗口 ===== // ===== 贴图窗口 =====
const PIN_LABEL = WINDOWS.screenshotPin const PIN_LABEL = WINDOWS.screenshotPin
@@ -141,7 +144,7 @@ export const useScreenshotStore = defineStore('screenshot', () => {
} }
// ===== 截图流程 ===== // ===== 截图流程 =====
/** 启动截图:延时倒计时 → 捕获虚拟屏 → 定位常驻覆盖层 → 通知覆盖层开始 */ /** 启动截图:延时倒计时 → 并行(捕获虚拟屏 + 枚举拾取窗口 + 计算虚拟屏矩形)→ 定位覆盖层 → 通知覆盖层开始 */
async function startCapture() { async function startCapture() {
if (capturing.value) return if (capturing.value) return
capturing.value = true capturing.value = true
@@ -163,14 +166,28 @@ export const useScreenshotStore = defineStore('screenshot', () => {
await new Promise<void>((r) => setTimeout(r, 1000)) await new Promise<void>((r) => setTimeout(r, 1000))
} }
} }
// 捕获虚拟屏(覆盖层隐藏 → 不会出现在截图中),仅存原始像素,不做 PNG 编码 // 三路并行:捕获(含光标)/ 拾取窗口列表 / 虚拟屏矩形 —— 互不依赖,缩短关键路径
await commands.screenshotCaptureFullscreen() //(捕获时覆盖层已隐藏 → 不会出现在截图中;窗口列表与冻结底图同一时刻生成,命中一致)
// 用物理像素把覆盖层对齐到虚拟屏(多显示器/混合 DPI 下保证底图 1:1 与坐标一致) const [start, pickWindows, rect] = await Promise.all([
const rect = await computeVirtualPhysicalRect() commands.screenshotCaptureFullscreen(),
await overlayWin?.setPosition(new PhysicalPosition(rect.x, rect.y)) commands.screenshotPickList().catch(() => [] as WindowInfo[]),
await overlayWin?.setSize(new PhysicalSize(rect.width, rect.height)) computeVirtualPhysicalRect(),
])
// 用物理像素把覆盖层对齐到虚拟屏(多显示器/混合 DPI 下保证底图 1:1 与坐标一致);
// 矩形未变化时跳过(覆盖层仅由本流程定位,跳过安全),省 2 次 IPC 往返
const rectKey = `${rect.x},${rect.y},${rect.width},${rect.height}`
if (rectKey !== lastOverlayRectKey) {
await overlayWin?.setPosition(new PhysicalPosition(rect.x, rect.y))
await overlayWin?.setSize(new PhysicalSize(rect.width, rect.height))
lastOverlayRectKey = rectKey
}
await waitOverlayReady() await waitOverlayReady()
await emit(EVENTS.screenshotBegin) // 携带捕获时刻光标 + 窗口列表:覆盖层显示前即可完成初始高亮,无"遮罩→高亮"闪烁
await emit(EVENTS.screenshotBegin, {
cursorX: start.cursorX,
cursorY: start.cursorY,
windows: pickWindows,
})
} catch (e) { } catch (e) {
console.error('[screenshot] 捕获失败', e) console.error('[screenshot] 捕获失败', e)
toast.error('截图启动失败:' + (e as Error).message) toast.error('截图启动失败:' + (e as Error).message)
@@ -552,6 +569,8 @@ export const useScreenshotStore = defineStore('screenshot', () => {
if (pinWin) { if (pinWin) {
try { try {
if (await pinWin.isVisible()) { if (await pinWin.isVisible()) {
// 还原不可聚焦(贴图窗口交互时可能已置为可聚焦),避免下次 show() 激活窗口抢走当前应用焦点
await pinWin.setFocusable(false).catch(() => {})
await pinWin.hide() await pinWin.hide()
return return
} }
+14 -2
View File
@@ -65,8 +65,20 @@ export default defineConfig(async () => ({
} }
: undefined, : undefined,
watch: { watch: {
// 3. tell Vite to ignore watching `src-tauri` // 3. Vite 忽略监听这些大目录,避免 Windows 上 chokidar 递归注册监听句柄
ignored: ["**/src-tauri/**"], // 拖慢 dev 冷启动。src-tauri/target 是 Rust 构建产物(本仓库可达 5 万+ 文件、
// 数十 GB),默认仅忽略 node_modules/.git/.vite,若被递归遍历,WebView 首屏
// 加载会被阻塞几十秒(详见 juejin.cn/post/7657865700393451554)。
ignored: [
"**/src-tauri/**",
"**/node_modules/**",
"**/dist/**",
"**/release_stage/**",
"**/ThingHK/**",
"**/.git/**",
"**/.idea/**",
"**/.vite/**",
],
}, },
}, },
})); }));