托盘
This commit is contained in:
@@ -15,6 +15,7 @@
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-autostart": "~2",
|
||||
"@tauri-apps/plugin-dialog": "^2",
|
||||
"@tauri-apps/plugin-global-shortcut": "^2",
|
||||
"@tauri-apps/plugin-opener": "^2",
|
||||
"@vueuse/core": "^14.3.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
|
||||
Generated
+193
@@ -19,6 +19,18 @@ dependencies = [
|
||||
"cpufeatures",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ahash"
|
||||
version = "0.8.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"once_cell",
|
||||
"version_check",
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aho-corasick"
|
||||
version = "1.1.4"
|
||||
@@ -420,6 +432,12 @@ version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
|
||||
|
||||
[[package]]
|
||||
name = "byteorder-lite"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
|
||||
|
||||
[[package]]
|
||||
name = "bytes"
|
||||
version = "1.12.1"
|
||||
@@ -1172,6 +1190,18 @@ dependencies = [
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fallible-iterator"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649"
|
||||
|
||||
[[package]]
|
||||
name = "fallible-streaming-iterator"
|
||||
version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
|
||||
|
||||
[[package]]
|
||||
name = "fastrand"
|
||||
version = "2.4.1"
|
||||
@@ -1469,6 +1499,16 @@ dependencies = [
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gethostname"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8"
|
||||
dependencies = [
|
||||
"rustix",
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.2.17"
|
||||
@@ -1590,6 +1630,24 @@ version = "0.3.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
|
||||
|
||||
[[package]]
|
||||
name = "global-hotkey"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8c386b0a4a70cb2d39fffd74480f985b6f0bfbcb934b6a6b6b7e630e448f242e"
|
||||
dependencies = [
|
||||
"crossbeam-channel",
|
||||
"keyboard-types",
|
||||
"objc2",
|
||||
"objc2-app-kit",
|
||||
"once_cell",
|
||||
"serde",
|
||||
"thiserror 2.0.18",
|
||||
"windows-sys 0.59.0",
|
||||
"x11rb",
|
||||
"xkeysym",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gobject-sys"
|
||||
version = "0.18.0"
|
||||
@@ -1678,12 +1736,30 @@ version = "0.12.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.14.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.17.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||
|
||||
[[package]]
|
||||
name = "hashlink"
|
||||
version = "0.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af"
|
||||
dependencies = [
|
||||
"hashbrown 0.14.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
version = "0.4.1"
|
||||
@@ -1993,6 +2069,19 @@ dependencies = [
|
||||
"icu_properties",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "image"
|
||||
version = "0.25.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"byteorder-lite",
|
||||
"moxcms",
|
||||
"num-traits",
|
||||
"png 0.18.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "1.9.3"
|
||||
@@ -2244,6 +2333,17 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libsqlite3-sys"
|
||||
version = "0.30.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"pkg-config",
|
||||
"vcpkg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.12.1"
|
||||
@@ -2351,6 +2451,16 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "moxcms"
|
||||
version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
"pxfm",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "muda"
|
||||
version = "0.19.3"
|
||||
@@ -3019,6 +3129,12 @@ dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pxfm"
|
||||
version = "0.1.30"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
|
||||
|
||||
[[package]]
|
||||
name = "quick-xml"
|
||||
version = "0.41.0"
|
||||
@@ -3270,6 +3386,20 @@ dependencies = [
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rusqlite"
|
||||
version = "0.32.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e"
|
||||
dependencies = [
|
||||
"bitflags 2.13.0",
|
||||
"fallible-iterator",
|
||||
"fallible-streaming-iterator",
|
||||
"hashlink",
|
||||
"libsqlite3-sys",
|
||||
"smallvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustc-hash"
|
||||
version = "2.1.3"
|
||||
@@ -4168,6 +4298,21 @@ dependencies = [
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-global-shortcut"
|
||||
version = "2.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b4dd9f4c5136c09cd962da0c86dc4accd4666db2ea591cf16e6597435843bd2b"
|
||||
dependencies = [
|
||||
"global-hotkey",
|
||||
"log",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-opener"
|
||||
version = "2.5.4"
|
||||
@@ -4333,10 +4478,14 @@ name = "thing"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"base64 0.22.1",
|
||||
"chrono",
|
||||
"dirs 5.0.1",
|
||||
"futures-util",
|
||||
"image",
|
||||
"raw-window-handle",
|
||||
"reqwest 0.12.28",
|
||||
"rusqlite",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_yaml",
|
||||
@@ -4345,6 +4494,7 @@ dependencies = [
|
||||
"tauri-build",
|
||||
"tauri-plugin-autostart",
|
||||
"tauri-plugin-dialog",
|
||||
"tauri-plugin-global-shortcut",
|
||||
"tauri-plugin-opener",
|
||||
"tauri-plugin-snap-layout",
|
||||
"tokio",
|
||||
@@ -5814,6 +5964,29 @@ dependencies = [
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "x11rb"
|
||||
version = "0.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414"
|
||||
dependencies = [
|
||||
"gethostname",
|
||||
"rustix",
|
||||
"x11rb-protocol",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "x11rb-protocol"
|
||||
version = "0.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd"
|
||||
|
||||
[[package]]
|
||||
name = "xkeysym"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56"
|
||||
|
||||
[[package]]
|
||||
name = "xz2"
|
||||
version = "0.1.7"
|
||||
@@ -5907,6 +6080,26 @@ dependencies = [
|
||||
"zvariant",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy"
|
||||
version = "0.8.55"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb"
|
||||
dependencies = [
|
||||
"zerocopy-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy-derive"
|
||||
version = "0.8.55"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.118",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerofrom"
|
||||
version = "0.1.8"
|
||||
|
||||
@@ -22,6 +22,7 @@ tauri = { version = "2", features = ["tray-icon"] }
|
||||
tauri-plugin-opener = "2"
|
||||
tauri-plugin-dialog = "2"
|
||||
tauri-plugin-snap-layout = "1"
|
||||
tauri-plugin-global-shortcut = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
serde_yaml = "0.9"
|
||||
@@ -34,15 +35,23 @@ url = "2"
|
||||
zip = "2"
|
||||
dirs = "5"
|
||||
sysinfo = "0.32"
|
||||
rusqlite = { version = "0.32", features = ["bundled"] }
|
||||
base64 = "0.22"
|
||||
image = { version = "0.25", default-features = false, features = ["png"] }
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
winreg = "0.52"
|
||||
raw-window-handle = "0.6"
|
||||
windows-sys = { version = "0.52", features = [
|
||||
"Win32_Networking_WinInet",
|
||||
"Win32_Foundation",
|
||||
"Win32_Security",
|
||||
"Win32_System_Threading",
|
||||
"Win32_System_Ole",
|
||||
"Win32_System_Memory",
|
||||
"Win32_System_DataExchange",
|
||||
"Win32_UI_WindowsAndMessaging",
|
||||
"Win32_UI_Shell",
|
||||
"Win32_UI_HiDpi",
|
||||
"Win32_UI_Input_KeyboardAndMouse",
|
||||
"Win32_Graphics_Gdi",
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "clipboard-popup",
|
||||
"description": "Capability for clipboard quick popup window",
|
||||
"windows": ["clipboard-popup"],
|
||||
"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-emit",
|
||||
"core:event:allow-listen",
|
||||
"snap-layout:default"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
//! Tauri 命令:剪贴板模块
|
||||
|
||||
use base64::engine::general_purpose::STANDARD;
|
||||
use base64::Engine as _;
|
||||
use serde::Serialize;
|
||||
use tauri::{AppHandle, State};
|
||||
|
||||
use super::manager::{ClipboardManager, ClipboardSettings};
|
||||
use super::reader::dib_to_png;
|
||||
use super::storage::{ClipboardItem, ClipboardItemDetail};
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ClipboardStatus {
|
||||
pub running: bool,
|
||||
pub count: i64,
|
||||
}
|
||||
|
||||
fn img_to_base64(b: &[u8]) -> Option<String> {
|
||||
dib_to_png(b).map(|png| STANDARD.encode(&png))
|
||||
}
|
||||
|
||||
/// 历史查询结果(含总数,用于分页)
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct HistoryPage {
|
||||
pub items: Vec<ClipboardItem>,
|
||||
pub total: i64,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn clipboard_get_history(
|
||||
limit: Option<i64>,
|
||||
offset: Option<i64>,
|
||||
kind: Option<String>,
|
||||
manager: State<'_, ClipboardManager>,
|
||||
) -> Result<HistoryPage, String> {
|
||||
let storage = manager.storage();
|
||||
let kind = kind.unwrap_or_else(|| "all".into());
|
||||
let limit = limit.unwrap_or(50);
|
||||
let offset = offset.unwrap_or(0);
|
||||
let items = storage.get_history(limit, offset, &kind);
|
||||
let total = storage.count_kind(&kind);
|
||||
Ok(HistoryPage { items, total })
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn clipboard_get_pinned(
|
||||
manager: State<'_, ClipboardManager>,
|
||||
) -> Result<Vec<ClipboardItem>, String> {
|
||||
Ok(manager.storage().get_pinned())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn clipboard_search(
|
||||
query: String,
|
||||
limit: Option<i64>,
|
||||
offset: Option<i64>,
|
||||
manager: State<'_, ClipboardManager>,
|
||||
) -> Result<HistoryPage, String> {
|
||||
let storage = manager.storage();
|
||||
let limit = limit.unwrap_or(50);
|
||||
let offset = offset.unwrap_or(0);
|
||||
let items = storage.search(&query, limit, offset);
|
||||
let total = storage.count_search(&query);
|
||||
Ok(HistoryPage { items, total })
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn clipboard_get_item(
|
||||
id: i64,
|
||||
manager: State<'_, ClipboardManager>,
|
||||
) -> Result<Option<ClipboardItemDetail>, String> {
|
||||
Ok(manager.storage().get_detail(id, img_to_base64))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn clipboard_set_pinned(
|
||||
id: i64,
|
||||
pinned: bool,
|
||||
manager: State<'_, ClipboardManager>,
|
||||
) -> Result<bool, String> {
|
||||
Ok(manager.storage().set_pinned(id, pinned))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn clipboard_delete(
|
||||
id: i64,
|
||||
manager: State<'_, ClipboardManager>,
|
||||
) -> Result<bool, String> {
|
||||
Ok(manager.storage().delete(id))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn clipboard_clear(manager: State<'_, ClipboardManager>) -> Result<bool, String> {
|
||||
Ok(manager.storage().clear_non_pinned())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn clipboard_copy_back(
|
||||
id: i64,
|
||||
manager: State<'_, ClipboardManager>,
|
||||
) -> Result<(), String> {
|
||||
manager.copy_back(id)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn clipboard_count(manager: State<'_, ClipboardManager>) -> Result<i64, String> {
|
||||
Ok(manager.storage().count())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn clipboard_get_settings(
|
||||
manager: State<'_, ClipboardManager>,
|
||||
) -> Result<ClipboardSettings, String> {
|
||||
Ok(manager.get_settings())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn clipboard_save_settings(
|
||||
settings: ClipboardSettings,
|
||||
app: AppHandle,
|
||||
manager: State<'_, ClipboardManager>,
|
||||
) -> Result<(), String> {
|
||||
let prev_enabled = manager.get_settings().enabled;
|
||||
let prev_shortcut = manager.get_settings().shortcut.clone();
|
||||
manager.save_settings(settings.clone());
|
||||
// 监听开关变化时联动启停
|
||||
if settings.enabled && !prev_enabled {
|
||||
manager.start(&app);
|
||||
} else if !settings.enabled && prev_enabled {
|
||||
manager.stop();
|
||||
}
|
||||
// 快捷键变化时重新注册
|
||||
if settings.shortcut != prev_shortcut {
|
||||
super::popup::register_shortcut(&app, &settings.shortcut)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn clipboard_status(
|
||||
manager: State<'_, ClipboardManager>,
|
||||
) -> Result<ClipboardStatus, String> {
|
||||
Ok(ClipboardStatus {
|
||||
running: manager.is_running(),
|
||||
count: manager.storage().count(),
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn clipboard_start(app: AppHandle, manager: State<'_, ClipboardManager>) -> Result<(), String> {
|
||||
manager.start(&app);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn clipboard_stop(manager: State<'_, ClipboardManager>) -> Result<(), String> {
|
||||
manager.stop();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 注册(或切换)快捷弹窗全局快捷键
|
||||
#[tauri::command]
|
||||
pub async fn clipboard_register_shortcut(
|
||||
shortcut: String,
|
||||
app: AppHandle,
|
||||
) -> Result<(), String> {
|
||||
super::popup::register_shortcut(&app, &shortcut)
|
||||
}
|
||||
|
||||
/// 注销快捷弹窗全局快捷键
|
||||
#[tauri::command]
|
||||
pub async fn clipboard_unregister_shortcut(app: AppHandle) -> Result<(), String> {
|
||||
super::popup::unregister_shortcut(&app);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 手动触发显示快捷弹窗(供 UI 按钮调用)
|
||||
#[tauri::command]
|
||||
pub async fn clipboard_show_popup(app: AppHandle) -> Result<(), String> {
|
||||
super::popup::show_popup(&app);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 隐藏快捷弹窗
|
||||
#[tauri::command]
|
||||
pub async fn clipboard_hide_popup(app: AppHandle) -> Result<(), String> {
|
||||
super::popup::hide_popup(&app);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 显示已创建的弹窗窗口(前端 onMounted 后调用)
|
||||
#[tauri::command]
|
||||
pub async fn clipboard_show_window(app: AppHandle) -> Result<(), String> {
|
||||
super::popup::show_window(&app);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 隐藏弹窗并模拟 Ctrl+V 粘贴到原窗口
|
||||
#[tauri::command]
|
||||
pub async fn clipboard_paste_to_target(app: AppHandle) -> Result<(), String> {
|
||||
super::popup::paste_to_target(&app);
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
//! ClipboardManager:管理监听线程生命周期、设置持久化、copy_back。
|
||||
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::thread::JoinHandle;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::monitor::start_monitor;
|
||||
use super::reader::{write_dib, write_files, write_text};
|
||||
use super::storage::Storage;
|
||||
|
||||
/// 剪贴板设置(持久化到 clipboard/settings.json)
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", default)]
|
||||
pub struct ClipboardSettings {
|
||||
/// 监听是否启用
|
||||
pub enabled: bool,
|
||||
/// 非固定历史最大条数
|
||||
pub max_items: i64,
|
||||
/// 图片大小上限(KB),0 表示不限
|
||||
pub max_image_kb: u64,
|
||||
pub record_text: bool,
|
||||
pub record_image: bool,
|
||||
pub record_files: bool,
|
||||
/// 去重(相同内容更新时间而非新增)
|
||||
pub dedup: bool,
|
||||
/// 快捷弹窗全局快捷键(如 "Alt+V",空字符串表示禁用)
|
||||
#[serde(default = "default_shortcut")]
|
||||
pub shortcut: String,
|
||||
}
|
||||
|
||||
fn default_shortcut() -> String {
|
||||
"Alt+V".to_string()
|
||||
}
|
||||
|
||||
impl Default for ClipboardSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: true,
|
||||
max_items: 500,
|
||||
max_image_kb: 5120,
|
||||
record_text: true,
|
||||
record_image: true,
|
||||
record_files: true,
|
||||
dedup: true,
|
||||
shortcut: default_shortcut(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ClipboardManager {
|
||||
storage: Arc<Storage>,
|
||||
settings: Arc<Mutex<ClipboardSettings>>,
|
||||
suppress: Arc<AtomicBool>,
|
||||
monitor_stop: Arc<AtomicBool>,
|
||||
monitor_handle: Mutex<Option<JoinHandle<()>>>,
|
||||
settings_path: PathBuf,
|
||||
}
|
||||
|
||||
impl ClipboardManager {
|
||||
pub fn new(app_data_dir: PathBuf) -> Self {
|
||||
let clip_dir = app_data_dir.join("clipboard");
|
||||
let storage = match Storage::new(&clip_dir) {
|
||||
Ok(s) => Arc::new(s),
|
||||
Err(e) => {
|
||||
eprintln!("[clipboard] 磁盘存储初始化失败,回退内存: {}", e);
|
||||
Arc::new(Storage::new_in_memory())
|
||||
}
|
||||
};
|
||||
let settings_path = clip_dir.join("settings.json");
|
||||
let settings = Arc::new(Mutex::new(load_settings(&settings_path)));
|
||||
let suppress = Arc::new(AtomicBool::new(false));
|
||||
let monitor_stop = Arc::new(AtomicBool::new(true));
|
||||
Self {
|
||||
storage,
|
||||
settings,
|
||||
suppress,
|
||||
monitor_stop,
|
||||
monitor_handle: Mutex::new(None),
|
||||
settings_path,
|
||||
}
|
||||
}
|
||||
|
||||
/// 启动监听(若已运行则跳过)
|
||||
pub fn start(&self, app: &tauri::AppHandle) {
|
||||
let mut handle = self.monitor_handle.lock().unwrap();
|
||||
if handle.is_some() {
|
||||
return;
|
||||
}
|
||||
self.monitor_stop.store(false, Ordering::SeqCst);
|
||||
let h = start_monitor(
|
||||
self.storage.clone(),
|
||||
app.clone(),
|
||||
self.settings.clone(),
|
||||
self.suppress.clone(),
|
||||
self.monitor_stop.clone(),
|
||||
);
|
||||
*handle = Some(h);
|
||||
eprintln!("[clipboard] 监听已启动");
|
||||
}
|
||||
|
||||
/// 停止监听
|
||||
pub fn stop(&self) {
|
||||
self.monitor_stop.store(true, Ordering::SeqCst);
|
||||
if let Some(h) = self.monitor_handle.lock().unwrap().take() {
|
||||
// 不阻塞等待;轮询线程最迟 800ms 后退出
|
||||
let _ = h.join();
|
||||
}
|
||||
eprintln!("[clipboard] 监听已停止");
|
||||
}
|
||||
|
||||
pub fn is_running(&self) -> bool {
|
||||
self.monitor_handle.lock().unwrap().is_some()
|
||||
}
|
||||
|
||||
pub fn get_settings(&self) -> ClipboardSettings {
|
||||
self.settings.lock().unwrap().clone()
|
||||
}
|
||||
|
||||
pub fn save_settings(&self, s: ClipboardSettings) {
|
||||
{
|
||||
*self.settings.lock().unwrap() = s.clone();
|
||||
}
|
||||
save_settings(&self.settings_path, &s);
|
||||
}
|
||||
|
||||
/// 将某条历史写回剪贴板。写回前置 suppress 标志以避免再次记录。
|
||||
pub fn copy_back(&self, id: i64) -> Result<(), String> {
|
||||
let (kind, content, blob) = self
|
||||
.storage
|
||||
.get_raw_for_copy(id)
|
||||
.ok_or_else(|| "条目不存在".to_string())?;
|
||||
self.suppress.store(true, Ordering::SeqCst);
|
||||
let ok = match kind.as_str() {
|
||||
"text" => content.as_deref().map(write_text).unwrap_or(false),
|
||||
"image" => blob.as_deref().map(write_dib).unwrap_or(false),
|
||||
"files" => {
|
||||
let paths: Vec<String> = content
|
||||
.as_deref()
|
||||
.and_then(|c| serde_json::from_str(c).ok())
|
||||
.unwrap_or_default();
|
||||
if paths.is_empty() {
|
||||
false
|
||||
} else {
|
||||
write_files(&paths)
|
||||
}
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
if ok {
|
||||
Ok(())
|
||||
} else {
|
||||
// 写入失败也清除 suppress,避免误吞下次复制
|
||||
self.suppress.store(false, Ordering::SeqCst);
|
||||
Err("写回剪贴板失败".into())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn storage(&self) -> &Arc<Storage> {
|
||||
&self.storage
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ClipboardManager {
|
||||
fn drop(&mut self) {
|
||||
self.stop();
|
||||
}
|
||||
}
|
||||
|
||||
fn load_settings(path: &Path) -> ClipboardSettings {
|
||||
match fs::read_to_string(path) {
|
||||
Ok(s) => serde_json::from_str(&s).unwrap_or_default(),
|
||||
Err(_) => ClipboardSettings::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn save_settings(path: &Path, s: &ClipboardSettings) {
|
||||
if let Some(parent) = path.parent() {
|
||||
let _ = fs::create_dir_all(parent);
|
||||
}
|
||||
if let Ok(json) = serde_json::to_string_pretty(s) {
|
||||
let _ = fs::write(path, json);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
//! 剪贴板增强模块:监听、SQLite 持久化、多格式预览、固定、写回、快捷弹窗。
|
||||
|
||||
pub mod commands;
|
||||
pub mod manager;
|
||||
pub mod monitor;
|
||||
pub mod popup;
|
||||
pub mod reader;
|
||||
pub mod storage;
|
||||
|
||||
pub use commands::{
|
||||
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_paste_to_target, clipboard_register_shortcut, clipboard_save_settings, clipboard_search,
|
||||
clipboard_set_pinned, clipboard_show_popup, clipboard_show_window, clipboard_start,
|
||||
clipboard_status, clipboard_stop, clipboard_unregister_shortcut,
|
||||
};
|
||||
pub use manager::ClipboardManager;
|
||||
@@ -0,0 +1,169 @@
|
||||
//! 剪贴板监听线程:基于 GetClipboardSequenceNumber 轮询
|
||||
//!
|
||||
//! 选用轮询而非 AddClipboardFormatListener 消息窗口:实现更简单、无需消息循环,
|
||||
//! 800ms 间隔对剪贴板场景延迟可接受,且 GetClipboardSequenceNumber 不需要 OpenClipboard,开销极小。
|
||||
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
use tauri::{AppHandle, Emitter};
|
||||
|
||||
use super::reader::{read_clipboard, ClipData};
|
||||
use super::storage::{NewItem, Storage};
|
||||
use windows_sys::Win32::System::DataExchange::GetClipboardSequenceNumber;
|
||||
|
||||
/// 启动监听线程,返回 JoinHandle。
|
||||
pub fn start_monitor(
|
||||
storage: Arc<Storage>,
|
||||
app: AppHandle,
|
||||
settings: Arc<Mutex<super::manager::ClipboardSettings>>,
|
||||
suppress: Arc<AtomicBool>,
|
||||
stop: Arc<AtomicBool>,
|
||||
) -> thread::JoinHandle<()> {
|
||||
thread::spawn(move || loop {
|
||||
if stop.load(Ordering::SeqCst) {
|
||||
break;
|
||||
}
|
||||
thread::sleep(Duration::from_millis(800));
|
||||
if stop.load(Ordering::SeqCst) {
|
||||
break;
|
||||
}
|
||||
let seq = unsafe { GetClipboardSequenceNumber() };
|
||||
// 首次记录基准;后续对比变化
|
||||
let last = LAST_SEQ.with(|c| {
|
||||
let prev = c.get().unwrap_or(seq);
|
||||
c.set(Some(seq));
|
||||
prev
|
||||
});
|
||||
if seq == last {
|
||||
continue;
|
||||
}
|
||||
// 序列号变化,处理一次
|
||||
if suppress.swap(false, Ordering::SeqCst) {
|
||||
// 由本应用 copy_back 触发,跳过记录
|
||||
continue;
|
||||
}
|
||||
let (rec_text, rec_image, rec_files, max_items, max_image_kb, dedup) = {
|
||||
let s = settings.lock().unwrap();
|
||||
(
|
||||
s.record_text,
|
||||
s.record_image,
|
||||
s.record_files,
|
||||
s.max_items,
|
||||
s.max_image_kb,
|
||||
s.dedup,
|
||||
)
|
||||
};
|
||||
let data = match read_clipboard() {
|
||||
Some(d) => d,
|
||||
None => continue,
|
||||
};
|
||||
let item = match &data {
|
||||
ClipData::Text(t) => {
|
||||
if !rec_text {
|
||||
continue;
|
||||
}
|
||||
build_text_item(t)
|
||||
}
|
||||
ClipData::Image { dib, width, height } => {
|
||||
if !rec_image {
|
||||
continue;
|
||||
}
|
||||
if max_image_kb > 0 && (dib.len() as u64 / 1024) > max_image_kb {
|
||||
continue;
|
||||
}
|
||||
build_image_item(dib, *width, *height)
|
||||
}
|
||||
ClipData::Files(fs) => {
|
||||
if !rec_files {
|
||||
continue;
|
||||
}
|
||||
build_files_item(fs)
|
||||
}
|
||||
};
|
||||
if let Some(_id) = storage.insert_or_touch(item, dedup) {
|
||||
storage.prune_to_max(max_items);
|
||||
let _ = app.emit("clipboard-changed", ());
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
static LAST_SEQ: std::cell::Cell<Option<u32>> = std::cell::Cell::new(None);
|
||||
}
|
||||
|
||||
fn build_text_item(t: &str) -> NewItem {
|
||||
NewItem {
|
||||
kind: "text".into(),
|
||||
content: Some(t.to_string()),
|
||||
blob: None,
|
||||
preview: make_preview(t, 200),
|
||||
size: t.len() as i64,
|
||||
hash: hash_str(t),
|
||||
}
|
||||
}
|
||||
|
||||
fn build_image_item(dib: &[u8], w: u32, h: u32) -> NewItem {
|
||||
NewItem {
|
||||
kind: "image".into(),
|
||||
content: None,
|
||||
blob: Some(dib.to_vec()),
|
||||
preview: format!("图片 {}×{}", w, h),
|
||||
size: dib.len() as i64,
|
||||
hash: hash_bytes(dib),
|
||||
}
|
||||
}
|
||||
|
||||
fn build_files_item(files: &[String]) -> NewItem {
|
||||
let content = serde_json::to_string(files).unwrap_or_default();
|
||||
let preview = if files.len() == 1 {
|
||||
make_preview(&files[0], 200)
|
||||
} else {
|
||||
format!(
|
||||
"{} 个文件 · {}",
|
||||
files.len(),
|
||||
make_preview(files.first().map(|s| s.as_str()).unwrap_or(""), 60)
|
||||
)
|
||||
};
|
||||
let hash = hash_str(&content);
|
||||
NewItem {
|
||||
kind: "files".into(),
|
||||
content: Some(content),
|
||||
blob: None,
|
||||
preview,
|
||||
size: files.iter().map(|f| f.len()).sum::<usize>() as i64,
|
||||
hash,
|
||||
}
|
||||
}
|
||||
|
||||
fn make_preview(s: &str, max_chars: usize) -> String {
|
||||
let single: String = s
|
||||
.chars()
|
||||
.map(|c| if c.is_control() { ' ' } else { c })
|
||||
.collect();
|
||||
let trimmed = single.trim().to_string();
|
||||
if trimmed.chars().count() <= max_chars {
|
||||
trimmed
|
||||
} else {
|
||||
let truncated: String = trimmed.chars().take(max_chars).collect();
|
||||
format!("{}…", truncated)
|
||||
}
|
||||
}
|
||||
|
||||
fn hash_str(s: &str) -> String {
|
||||
use std::collections::hash_map::DefaultHasher;
|
||||
use std::hash::Hasher;
|
||||
let mut h = DefaultHasher::new();
|
||||
h.write(s.as_bytes());
|
||||
format!("{:016x}", h.finish())
|
||||
}
|
||||
|
||||
fn hash_bytes(b: &[u8]) -> String {
|
||||
use std::collections::hash_map::DefaultHasher;
|
||||
use std::hash::Hasher;
|
||||
let mut h = DefaultHasher::new();
|
||||
h.write(b);
|
||||
format!("{:016x}", h.finish())
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
//! 剪贴板快捷弹窗:在鼠标位置显示历史记录弹窗。
|
||||
//!
|
||||
//! 流程:
|
||||
//! 1. 全局快捷键触发 → `show_popup` 在鼠标位置创建(隐藏)或显示窗口
|
||||
//! 2. 前端 Vue 挂载完成、主题应用后调用 `clipboard_show_window` 显示窗口
|
||||
//! 3. 前端监听 `clipboard-popup-show` 事件刷新数据
|
||||
//! 4. 用户选择 → 前端调用 `clipboard_copy_back` 写回剪贴板
|
||||
//! 5. 前端调用 `clipboard_paste_to_target` → 隐藏窗口 → 延迟模拟 Ctrl+V 粘贴
|
||||
//! 6. 窗口失焦自动隐藏
|
||||
|
||||
use std::sync::Mutex;
|
||||
use tauri::{AppHandle, Manager, WebviewUrl, WebviewWindowBuilder, Emitter};
|
||||
use tauri::window::{Effect, EffectsBuilder};
|
||||
use tauri_plugin_global_shortcut::{GlobalShortcutExt, Shortcut, ShortcutState};
|
||||
|
||||
/// 弹窗窗口标签
|
||||
pub const POPUP_LABEL: &str = "clipboard-popup";
|
||||
|
||||
/// 当前注册的快捷键(用于注销旧快捷键)
|
||||
static CURRENT_SHORTCUT: Mutex<Option<String>> = Mutex::new(None);
|
||||
|
||||
/// 解析快捷键字符串为 Shortcut(格式如 "Alt+V"、"Ctrl+Shift+V")
|
||||
/// 失败返回 None。
|
||||
pub fn parse_shortcut(s: &str) -> Option<Shortcut> {
|
||||
s.trim().parse::<Shortcut>().ok()
|
||||
}
|
||||
|
||||
/// 注册全局快捷键。重复调用会先注销旧快捷键。
|
||||
/// 传入空字符串则仅注销不注册。
|
||||
pub fn register_shortcut(app: &AppHandle, shortcut_str: &str) -> Result<(), String> {
|
||||
// 先注销旧快捷键
|
||||
unregister_shortcut(app);
|
||||
|
||||
if shortcut_str.trim().is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let shortcut = parse_shortcut(shortcut_str)
|
||||
.ok_or_else(|| format!("无效的快捷键: {}", shortcut_str))?;
|
||||
|
||||
let app_handle = app.clone();
|
||||
app.global_shortcut()
|
||||
.on_shortcut(shortcut, move |_app, _shortcut, event| {
|
||||
// 仅在按下时触发(松开不触发)
|
||||
if event.state == ShortcutState::Pressed {
|
||||
show_popup(&app_handle);
|
||||
}
|
||||
})
|
||||
.map_err(|e| format!("注册快捷键失败: {}", e))?;
|
||||
|
||||
if let Ok(mut cur) = CURRENT_SHORTCUT.lock() {
|
||||
*cur = Some(shortcut_str.to_string());
|
||||
}
|
||||
eprintln!("[clipboard] 已注册快捷键: {}", shortcut_str);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 注销当前快捷键
|
||||
pub fn unregister_shortcut(app: &AppHandle) {
|
||||
if let Ok(cur) = CURRENT_SHORTCUT.lock() {
|
||||
if let Some(ref s) = *cur {
|
||||
if let Some(shortcut) = parse_shortcut(s) {
|
||||
let _ = app.global_shortcut().unregister(shortcut);
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Ok(mut cur) = CURRENT_SHORTCUT.lock() {
|
||||
*cur = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// 在鼠标当前位置显示弹窗。
|
||||
/// 窗口不存在则创建(隐藏状态,等前端挂载后调用 show_window 显示)。
|
||||
/// 窗口已存在则移动到鼠标位置并显示。
|
||||
pub fn show_popup(app: &AppHandle) {
|
||||
// 获取鼠标位置(屏幕坐标)
|
||||
let (mx, my) = match get_cursor_pos() {
|
||||
Some(p) => p,
|
||||
None => return,
|
||||
};
|
||||
|
||||
// 窗口尺寸(逻辑像素)
|
||||
let w = 380.0_f64;
|
||||
let h = 460.0_f64;
|
||||
|
||||
// 获取屏幕工作区以避免弹出位置超出屏幕
|
||||
let (screen_w, screen_h) = get_work_area().unwrap_or((1920.0, 1080.0));
|
||||
|
||||
// 计算窗口位置:鼠标位置作为左上角,超出屏幕则调整
|
||||
let x = (mx as f64).min(screen_w - w).max(0.0);
|
||||
let y = (my as f64).min(screen_h - h).max(0.0);
|
||||
|
||||
// 窗口已存在:移动 + 显示 + 请求焦点
|
||||
if let Some(win) = app.get_webview_window(POPUP_LABEL) {
|
||||
let _ = win.set_position(tauri::Position::Logical(tauri::LogicalPosition {
|
||||
x,
|
||||
y,
|
||||
}));
|
||||
let _ = win.show();
|
||||
let _ = win.set_focus();
|
||||
// 通知前端刷新数据
|
||||
let _ = app.emit("clipboard-popup-show", ());
|
||||
return;
|
||||
}
|
||||
|
||||
// 首次创建窗口:先隐藏,等前端挂载完成后再显示
|
||||
// 这样确保主题效果(dark class、mica)在窗口可见前已应用
|
||||
let win = match WebviewWindowBuilder::new(
|
||||
app,
|
||||
POPUP_LABEL,
|
||||
WebviewUrl::App("index.html#clipboard-popup".into()),
|
||||
)
|
||||
.title("剪贴板")
|
||||
.inner_size(w, h)
|
||||
.position(x, y)
|
||||
.decorations(false)
|
||||
.transparent(true)
|
||||
.shadow(true)
|
||||
.always_on_top(true)
|
||||
.skip_taskbar(true)
|
||||
.resizable(false)
|
||||
.visible(false) // 关键:先隐藏,前端 onMounted 后调用 show_window
|
||||
.focused(true)
|
||||
.effects(EffectsBuilder::new().effects(vec![Effect::Mica]).build())
|
||||
.build()
|
||||
{
|
||||
Ok(w) => w,
|
||||
Err(e) => {
|
||||
eprintln!("[clipboard] 创建弹窗失败: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// 监听窗口失焦:自动隐藏
|
||||
let app_handle = app.clone();
|
||||
let win_handle = win.clone();
|
||||
win.on_window_event(move |event| {
|
||||
if let tauri::WindowEvent::Focused(false) = event {
|
||||
let _ = win_handle.hide();
|
||||
let _ = app_handle.emit("clipboard-popup-hide", ());
|
||||
}
|
||||
});
|
||||
|
||||
eprintln!("[clipboard] 弹窗窗口已创建(隐藏状态)");
|
||||
}
|
||||
|
||||
/// 显示已创建的弹窗窗口(由前端 onMounted 后调用)。
|
||||
pub fn show_window(app: &AppHandle) {
|
||||
if let Some(win) = app.get_webview_window(POPUP_LABEL) {
|
||||
let _ = win.show();
|
||||
let _ = win.set_focus();
|
||||
// 通知前端刷新数据
|
||||
let _ = app.emit("clipboard-popup-show", ());
|
||||
}
|
||||
}
|
||||
|
||||
/// 隐藏弹窗(不销毁,保留复用)
|
||||
pub fn hide_popup(app: &AppHandle) {
|
||||
if let Some(win) = app.get_webview_window(POPUP_LABEL) {
|
||||
let _ = win.hide();
|
||||
}
|
||||
}
|
||||
|
||||
/// 隐藏弹窗后延迟模拟 Ctrl+V 粘贴到之前聚焦的窗口。
|
||||
/// 延迟 150ms 确保焦点切换回原窗口后再发送按键。
|
||||
pub fn paste_to_target(app: &AppHandle) {
|
||||
// 先隐藏弹窗,让焦点回到原窗口
|
||||
hide_popup(app);
|
||||
// 在独立线程中延迟模拟 Ctrl+V
|
||||
std::thread::spawn(move || {
|
||||
std::thread::sleep(std::time::Duration::from_millis(150));
|
||||
simulate_paste();
|
||||
});
|
||||
}
|
||||
|
||||
/// 使用 Win32 SendInput 模拟 Ctrl+V 按键
|
||||
#[cfg(windows)]
|
||||
fn simulate_paste() {
|
||||
use windows_sys::Win32::UI::Input::KeyboardAndMouse::{
|
||||
SendInput, INPUT, INPUT_KEYBOARD, KEYBDINPUT, KEYEVENTF_KEYUP,
|
||||
VK_CONTROL,
|
||||
};
|
||||
|
||||
const VK_V: u16 = 0x56;
|
||||
|
||||
// 安全初始化为零值,避免未定义字段
|
||||
let mut inputs: [INPUT; 4] = unsafe { std::mem::zeroed() };
|
||||
|
||||
// Ctrl down
|
||||
inputs[0].r#type = INPUT_KEYBOARD;
|
||||
inputs[0].Anonymous.ki = KEYBDINPUT { wVk: VK_CONTROL, wScan: 0, dwFlags: 0, time: 0, dwExtraInfo: 0 };
|
||||
|
||||
// V down
|
||||
inputs[1].r#type = INPUT_KEYBOARD;
|
||||
inputs[1].Anonymous.ki = KEYBDINPUT { wVk: VK_V, wScan: 0, dwFlags: 0, time: 0, dwExtraInfo: 0 };
|
||||
|
||||
// V up
|
||||
inputs[2].r#type = INPUT_KEYBOARD;
|
||||
inputs[2].Anonymous.ki = KEYBDINPUT { wVk: VK_V, wScan: 0, dwFlags: KEYEVENTF_KEYUP, time: 0, dwExtraInfo: 0 };
|
||||
|
||||
// Ctrl up
|
||||
inputs[3].r#type = INPUT_KEYBOARD;
|
||||
inputs[3].Anonymous.ki = KEYBDINPUT { wVk: VK_CONTROL, wScan: 0, dwFlags: KEYEVENTF_KEYUP, time: 0, dwExtraInfo: 0 };
|
||||
|
||||
unsafe {
|
||||
SendInput(4, inputs.as_ptr(), std::mem::size_of::<INPUT>() as i32);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn simulate_paste() {
|
||||
// 非 Windows 平台暂不支持自动粘贴
|
||||
}
|
||||
|
||||
// ===== Win32 API:获取鼠标位置和工作区 =====
|
||||
|
||||
#[cfg(windows)]
|
||||
mod win_api {
|
||||
use windows_sys::Win32::Foundation::POINT;
|
||||
use windows_sys::Win32::UI::WindowsAndMessaging::{GetCursorPos, SystemParametersInfoW, SPI_GETWORKAREA};
|
||||
|
||||
/// 获取鼠标位置(屏幕坐标,逻辑像素)
|
||||
pub fn get_cursor_pos() -> Option<(i32, i32)> {
|
||||
let mut pt = POINT { x: 0, y: 0 };
|
||||
unsafe {
|
||||
if GetCursorPos(&mut pt) != 0 {
|
||||
Some((pt.x, pt.y))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取屏幕工作区(排除任务栏,逻辑像素)
|
||||
pub fn get_work_area() -> Option<(f64, f64)> {
|
||||
use windows_sys::Win32::Foundation::RECT;
|
||||
let mut rect = RECT { left: 0, top: 0, right: 0, bottom: 0 };
|
||||
unsafe {
|
||||
if SystemParametersInfoW(SPI_GETWORKAREA, 0, &mut rect as *mut _ as *mut _, 0) != 0 {
|
||||
Some(((rect.right - rect.left) as f64, (rect.bottom - rect.top) as f64))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
mod win_api {
|
||||
pub fn get_cursor_pos() -> Option<(i32, i32)> { None }
|
||||
pub fn get_work_area() -> Option<(f64, f64)> { None }
|
||||
}
|
||||
|
||||
pub use win_api::{get_cursor_pos, get_work_area};
|
||||
@@ -0,0 +1,280 @@
|
||||
//! Win32 剪贴板读取与写回
|
||||
//!
|
||||
//! 读取优先级:文本 > 图片 > 文件。写回支持三种格式。
|
||||
//! 句柄类型说明(windows-sys 0.52):
|
||||
//! - HWND / HANDLE = isize
|
||||
//! - HGLOBAL / HLOCAL = *mut c_void
|
||||
//! - HDROP = isize(与 HANDLE 同)
|
||||
|
||||
use windows_sys::Win32::System::DataExchange::{
|
||||
CloseClipboard, EmptyClipboard, GetClipboardData, IsClipboardFormatAvailable, OpenClipboard,
|
||||
SetClipboardData,
|
||||
};
|
||||
use windows_sys::Win32::System::Memory::{GlobalAlloc, GlobalLock, GlobalSize, GlobalUnlock, GMEM_MOVEABLE, GMEM_ZEROINIT};
|
||||
use windows_sys::Win32::System::Ole::{CF_DIB, CF_HDROP, CF_UNICODETEXT};
|
||||
use windows_sys::Win32::UI::Shell::DragQueryFileW;
|
||||
|
||||
/// 剪贴板当前内容
|
||||
pub enum ClipData {
|
||||
Text(String),
|
||||
Image { dib: Vec<u8>, width: u32, height: u32 },
|
||||
Files(Vec<String>),
|
||||
}
|
||||
|
||||
/// 读取剪贴板(需在 monitor 线程或命令线程调用)。OpenClipboard 失败返回 None。
|
||||
pub fn read_clipboard() -> Option<ClipData> {
|
||||
unsafe {
|
||||
if OpenClipboard(0) == 0 {
|
||||
return None;
|
||||
}
|
||||
let result = if IsClipboardFormatAvailable(CF_UNICODETEXT as u32) != 0 {
|
||||
read_text()
|
||||
} else if IsClipboardFormatAvailable(CF_DIB as u32) != 0 {
|
||||
read_image()
|
||||
} else if IsClipboardFormatAvailable(CF_HDROP as u32) != 0 {
|
||||
read_files()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
CloseClipboard();
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
unsafe fn read_text() -> Option<ClipData> {
|
||||
let h = GetClipboardData(CF_UNICODETEXT as u32); // HANDLE = isize
|
||||
if h == 0 {
|
||||
return None;
|
||||
}
|
||||
let hglob = h as *mut core::ffi::c_void; // 实际是 HGLOBAL
|
||||
let ptr = GlobalLock(hglob) as *const u16;
|
||||
if ptr.is_null() {
|
||||
return None;
|
||||
}
|
||||
let mut len = 0usize;
|
||||
while *ptr.add(len) != 0 {
|
||||
len += 1;
|
||||
}
|
||||
let slice = core::slice::from_raw_parts(ptr, len);
|
||||
let s = String::from_utf16_lossy(slice);
|
||||
GlobalUnlock(hglob);
|
||||
if s.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(ClipData::Text(s))
|
||||
}
|
||||
}
|
||||
|
||||
unsafe fn read_image() -> Option<ClipData> {
|
||||
let h = GetClipboardData(CF_DIB as u32);
|
||||
if h == 0 {
|
||||
return None;
|
||||
}
|
||||
let hglob = h as *mut core::ffi::c_void;
|
||||
let ptr = GlobalLock(hglob) as *const u8;
|
||||
if ptr.is_null() {
|
||||
return None;
|
||||
}
|
||||
let size = GlobalSize(hglob);
|
||||
let slice = core::slice::from_raw_parts(ptr, size);
|
||||
let dib = slice.to_vec();
|
||||
GlobalUnlock(hglob);
|
||||
let (w, hgt) = dib_info(&dib).unwrap_or((0, 0));
|
||||
Some(ClipData::Image {
|
||||
dib,
|
||||
width: w,
|
||||
height: hgt,
|
||||
})
|
||||
}
|
||||
|
||||
unsafe fn read_files() -> Option<ClipData> {
|
||||
let h = GetClipboardData(CF_HDROP as u32); // HANDLE = isize
|
||||
if h == 0 {
|
||||
return None;
|
||||
}
|
||||
let hdrop = h; // HDROP = isize
|
||||
let count = DragQueryFileW(hdrop, 0xFFFFFFFF, core::ptr::null_mut(), 0);
|
||||
let mut files = Vec::with_capacity(count as usize);
|
||||
for i in 0..count {
|
||||
let len = DragQueryFileW(hdrop, i, core::ptr::null_mut(), 0);
|
||||
if len == 0 {
|
||||
continue;
|
||||
}
|
||||
let mut buf = vec![0u16; (len as usize) + 1];
|
||||
let got = DragQueryFileW(hdrop, i, buf.as_mut_ptr(), buf.len() as u32);
|
||||
let s = String::from_utf16_lossy(&buf[..got as usize]);
|
||||
files.push(s);
|
||||
}
|
||||
// 剪贴板拥有句柄,不调用 DragFinish
|
||||
if files.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(ClipData::Files(files))
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 写回剪贴板(copy_back) =====
|
||||
|
||||
/// 写回文本。失败返回 false。写回会清空其他格式。
|
||||
pub fn write_text(s: &str) -> bool {
|
||||
unsafe {
|
||||
if OpenClipboard(0) == 0 {
|
||||
return false;
|
||||
}
|
||||
let r = write_text_inner(s);
|
||||
CloseClipboard();
|
||||
r
|
||||
}
|
||||
}
|
||||
|
||||
unsafe fn write_text_inner(s: &str) -> bool {
|
||||
if EmptyClipboard() == 0 {
|
||||
return false;
|
||||
}
|
||||
let mut utf16: Vec<u16> = s.encode_utf16().collect();
|
||||
utf16.push(0); // null terminator
|
||||
let byte_len = utf16.len() * 2;
|
||||
let hglob = GlobalAlloc(GMEM_MOVEABLE, byte_len);
|
||||
if hglob.is_null() {
|
||||
return false;
|
||||
}
|
||||
let ptr = GlobalLock(hglob) as *mut u16;
|
||||
if ptr.is_null() {
|
||||
return false;
|
||||
}
|
||||
core::ptr::copy_nonoverlapping(utf16.as_ptr(), ptr, utf16.len());
|
||||
GlobalUnlock(hglob);
|
||||
SetClipboardData(CF_UNICODETEXT as u32, hglob as isize) != 0
|
||||
}
|
||||
|
||||
/// 写回图片(CF_DIB 原始字节)。
|
||||
pub fn write_dib(dib: &[u8]) -> bool {
|
||||
unsafe {
|
||||
if OpenClipboard(0) == 0 {
|
||||
return false;
|
||||
}
|
||||
let r = (|| {
|
||||
if EmptyClipboard() == 0 {
|
||||
return false;
|
||||
}
|
||||
let hglob = GlobalAlloc(GMEM_MOVEABLE, dib.len());
|
||||
if hglob.is_null() {
|
||||
return false;
|
||||
}
|
||||
let ptr = GlobalLock(hglob) as *mut u8;
|
||||
if ptr.is_null() {
|
||||
return false;
|
||||
}
|
||||
core::ptr::copy_nonoverlapping(dib.as_ptr(), ptr, dib.len());
|
||||
GlobalUnlock(hglob);
|
||||
SetClipboardData(CF_DIB as u32, hglob as isize) != 0
|
||||
})();
|
||||
CloseClipboard();
|
||||
r
|
||||
}
|
||||
}
|
||||
|
||||
/// 写回文件列表(CF_HDROP)。
|
||||
pub fn write_files(paths: &[String]) -> bool {
|
||||
unsafe {
|
||||
if OpenClipboard(0) == 0 {
|
||||
return false;
|
||||
}
|
||||
let r = (|| {
|
||||
if EmptyClipboard() == 0 {
|
||||
return false;
|
||||
}
|
||||
// DROPFILES 头部 20 字节:pFiles(u32) + pt.x(i32) + pt.y(i32) + fNC(i32) + fWide(i32)
|
||||
const DF_SIZE: usize = 20;
|
||||
let mut wide: Vec<u16> = Vec::new();
|
||||
for p in paths {
|
||||
wide.extend(p.encode_utf16());
|
||||
wide.push(0); // 每条字符串 null 结尾
|
||||
}
|
||||
wide.push(0); // 末尾额外 null 表示列表结束
|
||||
let total = DF_SIZE + wide.len() * 2;
|
||||
let hglob = GlobalAlloc(GMEM_MOVEABLE | GMEM_ZEROINIT, total);
|
||||
if hglob.is_null() {
|
||||
return false;
|
||||
}
|
||||
let ptr = GlobalLock(hglob) as *mut u8;
|
||||
if ptr.is_null() {
|
||||
return false;
|
||||
}
|
||||
let offset = DF_SIZE as u32;
|
||||
let off_bytes = offset.to_ne_bytes();
|
||||
core::ptr::copy_nonoverlapping(off_bytes.as_ptr(), ptr, 4); // pFiles
|
||||
let fwide: u32 = 1;
|
||||
let fwide_bytes = fwide.to_ne_bytes();
|
||||
core::ptr::copy_nonoverlapping(fwide_bytes.as_ptr(), ptr.add(16), 4); // fWide = 1
|
||||
let str_ptr = ptr.add(DF_SIZE) as *mut u16;
|
||||
core::ptr::copy_nonoverlapping(wide.as_ptr(), str_ptr, wide.len());
|
||||
GlobalUnlock(hglob);
|
||||
SetClipboardData(CF_HDROP as u32, hglob as isize) != 0
|
||||
})();
|
||||
CloseClipboard();
|
||||
r
|
||||
}
|
||||
}
|
||||
|
||||
// ===== DIB → PNG(用于预览) =====
|
||||
|
||||
/// 从 DIB 字节解析宽高
|
||||
pub fn dib_info(dib: &[u8]) -> Option<(u32, u32)> {
|
||||
if dib.len() < 40 {
|
||||
return None;
|
||||
}
|
||||
let width = i32::from_le_bytes([dib[4], dib[5], dib[6], dib[7]]);
|
||||
let height = i32::from_le_bytes([dib[8], dib[9], dib[10], dib[11]]);
|
||||
if width <= 0 {
|
||||
return None;
|
||||
}
|
||||
Some((width as u32, height.abs() as u32))
|
||||
}
|
||||
|
||||
/// 将 CF_DIB 字节转换为 PNG 字节(仅支持 24/32bpp BI_RGB)。
|
||||
pub fn dib_to_png(dib: &[u8]) -> Option<Vec<u8>> {
|
||||
use image::codecs::png::PngEncoder;
|
||||
use image::ImageEncoder;
|
||||
if dib.len() < 40 {
|
||||
return None;
|
||||
}
|
||||
let header_size = u32::from_le_bytes([dib[0], dib[1], dib[2], dib[3]]);
|
||||
let width = i32::from_le_bytes([dib[4], dib[5], dib[6], dib[7]]);
|
||||
let height_raw = i32::from_le_bytes([dib[8], dib[9], dib[10], dib[11]]);
|
||||
let bpp = u16::from_le_bytes([dib[14], dib[15]]);
|
||||
let compression = u32::from_le_bytes([dib[16], dib[17], dib[18], dib[19]]);
|
||||
if width <= 0 || compression != 0 {
|
||||
return None;
|
||||
}
|
||||
if bpp != 24 && bpp != 32 {
|
||||
return None;
|
||||
}
|
||||
let height = height_raw.abs();
|
||||
let top_down = height_raw < 0;
|
||||
let pixel_offset = header_size as usize; // 24/32bpp 无调色板
|
||||
let row_size = ((bpp as u32 * width as u32 + 31) / 32) * 4;
|
||||
let bytes_per_pixel = (bpp / 8) as usize;
|
||||
let needed = pixel_offset + (row_size as usize) * (height as usize);
|
||||
if dib.len() < needed {
|
||||
return None;
|
||||
}
|
||||
let mut rgba = vec![0u8; (width as usize) * (height as usize) * 4];
|
||||
for y in 0..height {
|
||||
let src_row = if top_down { y } else { height - 1 - y };
|
||||
let src_off = pixel_offset + (src_row as usize) * (row_size as usize);
|
||||
for x in 0..width {
|
||||
let sp = src_off + (x as usize) * bytes_per_pixel;
|
||||
let dp = (y as usize) * (width as usize) * 4 + (x as usize) * 4;
|
||||
rgba[dp] = dib[sp + 2]; // R
|
||||
rgba[dp + 1] = dib[sp + 1]; // G
|
||||
rgba[dp + 2] = dib[sp]; // B
|
||||
rgba[dp + 3] = 255; // A
|
||||
}
|
||||
}
|
||||
let mut buf = Vec::new();
|
||||
let enc = PngEncoder::new(&mut buf);
|
||||
enc.write_image(&rgba, width as u32, height as u32, image::ExtendedColorType::Rgba8)
|
||||
.ok()?;
|
||||
Some(buf)
|
||||
}
|
||||
@@ -0,0 +1,375 @@
|
||||
//! SQLite 持久化:剪贴板历史记录
|
||||
//!
|
||||
//! 表结构见 `init_db`。所有方法线程安全(内部 Mutex 包裹 Connection)。
|
||||
|
||||
use rusqlite::{params, Connection, OptionalExtension};
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Mutex;
|
||||
|
||||
/// 列表项(不含大字段,用于历史/搜索结果)
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ClipboardItem {
|
||||
pub id: i64,
|
||||
pub kind: String,
|
||||
pub preview: String,
|
||||
pub size: i64,
|
||||
pub pinned: bool,
|
||||
pub pinned_order: Option<i64>,
|
||||
pub created_at: i64,
|
||||
}
|
||||
|
||||
/// 详情(含文本内容或图片 base64)
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ClipboardItemDetail {
|
||||
#[serde(flatten)]
|
||||
pub item: ClipboardItem,
|
||||
/// 文本内容 / 文件列表 JSON
|
||||
pub content: Option<String>,
|
||||
/// 图片 PNG base64(仅 image 类型)
|
||||
pub image_base64: Option<String>,
|
||||
}
|
||||
|
||||
/// 新捕获的剪贴板数据
|
||||
pub struct NewItem {
|
||||
pub kind: String,
|
||||
pub content: Option<String>,
|
||||
pub blob: Option<Vec<u8>>,
|
||||
pub preview: String,
|
||||
pub size: i64,
|
||||
pub hash: String,
|
||||
}
|
||||
|
||||
pub struct Storage {
|
||||
conn: Mutex<Connection>,
|
||||
}
|
||||
|
||||
impl Storage {
|
||||
pub fn new(data_dir: &PathBuf) -> std::io::Result<Self> {
|
||||
fs::create_dir_all(data_dir)?;
|
||||
let db_path = data_dir.join("history.db");
|
||||
let conn = Connection::open(&db_path)
|
||||
.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e.to_string()))?;
|
||||
conn.busy_timeout(std::time::Duration::from_secs(3)).ok();
|
||||
Self::init_db(&conn);
|
||||
Ok(Self {
|
||||
conn: Mutex::new(conn),
|
||||
})
|
||||
}
|
||||
|
||||
/// 内存数据库回退(磁盘初始化失败时使用,数据不持久化)
|
||||
pub fn new_in_memory() -> Self {
|
||||
let conn = Connection::open_in_memory().expect("open_in_memory");
|
||||
Self::init_db(&conn);
|
||||
Self {
|
||||
conn: Mutex::new(conn),
|
||||
}
|
||||
}
|
||||
|
||||
fn init_db(conn: &Connection) {
|
||||
let _ = conn.execute_batch(
|
||||
"CREATE TABLE IF NOT EXISTS clipboard_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
kind TEXT NOT NULL,
|
||||
content TEXT,
|
||||
blob BLOB,
|
||||
preview TEXT NOT NULL DEFAULT '',
|
||||
size INTEGER NOT NULL DEFAULT 0,
|
||||
hash TEXT NOT NULL DEFAULT '',
|
||||
pinned INTEGER NOT NULL DEFAULT 0,
|
||||
pinned_order INTEGER,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_created_at ON clipboard_history(created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_pinned ON clipboard_history(pinned, pinned_order);
|
||||
CREATE INDEX IF NOT EXISTS idx_hash ON clipboard_history(hash);
|
||||
CREATE INDEX IF NOT EXISTS idx_kind ON clipboard_history(kind);",
|
||||
);
|
||||
}
|
||||
|
||||
/// 插入新条目;若 dedup 为 true 且 hash 已存在则仅更新 created_at,返回条目 id。
|
||||
/// 返回 None 表示未插入(内容为空)。
|
||||
pub fn insert_or_touch(&self, item: NewItem, dedup: bool) -> Option<i64> {
|
||||
if item.preview.is_empty() && item.content.is_none() && item.blob.is_none() {
|
||||
return None;
|
||||
}
|
||||
let conn = self.conn.lock().ok()?;
|
||||
if dedup {
|
||||
let existing: Option<i64> = conn
|
||||
.query_row(
|
||||
"SELECT id FROM clipboard_history WHERE hash = ?1",
|
||||
params![item.hash],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.optional()
|
||||
.ok()
|
||||
.flatten();
|
||||
if let Some(id) = existing {
|
||||
// 更新时间戳到当前(置顶到列表最前)
|
||||
let now = now_ms();
|
||||
let _ = conn.execute(
|
||||
"UPDATE clipboard_history SET created_at = ?1 WHERE id = ?2",
|
||||
params![now, id],
|
||||
);
|
||||
return Some(id);
|
||||
}
|
||||
}
|
||||
let now = now_ms();
|
||||
let res = conn.execute(
|
||||
"INSERT INTO clipboard_history
|
||||
(kind, content, blob, preview, size, hash, pinned, pinned_order, created_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, 0, NULL, ?7)",
|
||||
params![
|
||||
item.kind,
|
||||
item.content,
|
||||
item.blob.as_deref(),
|
||||
item.preview,
|
||||
item.size,
|
||||
item.hash,
|
||||
now,
|
||||
],
|
||||
);
|
||||
if res.is_err() {
|
||||
return None;
|
||||
}
|
||||
Some(conn.last_insert_rowid())
|
||||
}
|
||||
|
||||
/// 获取非固定的历史记录(按时间倒序)
|
||||
pub fn get_history(&self, limit: i64, offset: i64, kind: &str) -> Vec<ClipboardItem> {
|
||||
let conn = match self.conn.lock() {
|
||||
Ok(c) => c,
|
||||
Err(_) => return vec![],
|
||||
};
|
||||
let sql = if kind == "all" {
|
||||
"SELECT id, kind, preview, size, pinned, pinned_order, created_at
|
||||
FROM clipboard_history WHERE pinned = 0
|
||||
ORDER BY created_at DESC LIMIT ?1 OFFSET ?2"
|
||||
} else {
|
||||
"SELECT id, kind, preview, size, pinned, pinned_order, created_at
|
||||
FROM clipboard_history WHERE pinned = 0 AND kind = ?1
|
||||
ORDER BY created_at DESC LIMIT ?2 OFFSET ?3"
|
||||
};
|
||||
let mut stmt = match conn.prepare(sql) {
|
||||
Ok(s) => s,
|
||||
Err(_) => return vec![],
|
||||
};
|
||||
let rows = if kind == "all" {
|
||||
stmt.query_map(params![limit, offset], row_to_item)
|
||||
} else {
|
||||
stmt.query_map(params![kind, limit, offset], row_to_item)
|
||||
};
|
||||
rows.map(|r| r.filter_map(|i| i.ok()).collect())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// 获取固定条目(按 pinned_order,未设置则按时间倒序)
|
||||
pub fn get_pinned(&self) -> Vec<ClipboardItem> {
|
||||
let conn = match self.conn.lock() {
|
||||
Ok(c) => c,
|
||||
Err(_) => return vec![],
|
||||
};
|
||||
let mut stmt = match conn.prepare(
|
||||
"SELECT id, kind, preview, size, pinned, pinned_order, created_at
|
||||
FROM clipboard_history WHERE pinned = 1
|
||||
ORDER BY pinned_order ASC NULLS LAST, created_at DESC",
|
||||
) {
|
||||
Ok(s) => s,
|
||||
Err(_) => return vec![],
|
||||
};
|
||||
stmt.query_map([], row_to_item)
|
||||
.map(|r| r.filter_map(|i| i.ok()).collect())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// 搜索(跨固定/非固定,按时间倒序)
|
||||
pub fn search(&self, query: &str, limit: i64, offset: i64) -> Vec<ClipboardItem> {
|
||||
let conn = match self.conn.lock() {
|
||||
Ok(c) => c,
|
||||
Err(_) => return vec![],
|
||||
};
|
||||
let like = format!("%{}%", query);
|
||||
let mut stmt = match conn.prepare(
|
||||
"SELECT id, kind, preview, size, pinned, pinned_order, created_at
|
||||
FROM clipboard_history
|
||||
WHERE pinned = 0 AND (preview LIKE ?1 OR content LIKE ?1)
|
||||
ORDER BY created_at DESC LIMIT ?2 OFFSET ?3",
|
||||
) {
|
||||
Ok(s) => s,
|
||||
Err(_) => return vec![],
|
||||
};
|
||||
stmt.query_map(params![like, limit, offset], row_to_item)
|
||||
.map(|r| r.filter_map(|i| i.ok()).collect())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// 获取详情(含文本/图片预览 base64)
|
||||
pub fn get_detail(&self, id: i64, image_to_base64: impl Fn(&[u8]) -> Option<String>) -> Option<ClipboardItemDetail> {
|
||||
let conn = self.conn.lock().ok()?;
|
||||
let row = conn
|
||||
.query_row(
|
||||
"SELECT id, kind, preview, size, pinned, pinned_order, created_at, content, blob
|
||||
FROM clipboard_history WHERE id = ?1",
|
||||
params![id],
|
||||
|r| {
|
||||
let kind: String = r.get(1)?;
|
||||
let content: Option<String> = r.get(7)?;
|
||||
let blob: Option<Vec<u8>> = r.get(8)?;
|
||||
Ok((
|
||||
ClipboardItem {
|
||||
id: r.get(0)?,
|
||||
kind,
|
||||
preview: r.get(2)?,
|
||||
size: r.get(3)?,
|
||||
pinned: r.get::<_, i64>(4)? != 0,
|
||||
pinned_order: r.get(5)?,
|
||||
created_at: r.get(6)?,
|
||||
},
|
||||
content,
|
||||
blob,
|
||||
))
|
||||
},
|
||||
)
|
||||
.ok()?;
|
||||
let (item, content, blob) = row;
|
||||
let image_base64 = if item.kind == "image" {
|
||||
blob.as_deref().and_then(|b| image_to_base64(b))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Some(ClipboardItemDetail {
|
||||
item,
|
||||
content,
|
||||
image_base64,
|
||||
})
|
||||
}
|
||||
|
||||
/// 获取原始字段供 copy_back 写回(避免 base64 转换开销)
|
||||
pub fn get_raw_for_copy(&self, id: i64) -> Option<(String, Option<String>, Option<Vec<u8>>)> {
|
||||
let conn = self.conn.lock().ok()?;
|
||||
conn.query_row(
|
||||
"SELECT kind, content, blob FROM clipboard_history WHERE id = ?1",
|
||||
params![id],
|
||||
|r| Ok((r.get::<_, String>(0)?, r.get(1)?, r.get(2)?)),
|
||||
)
|
||||
.ok()
|
||||
}
|
||||
|
||||
/// 切换固定状态。设为固定时分配一个递减的 pinned_order(靠前)。
|
||||
pub fn set_pinned(&self, id: i64, pinned: bool) -> bool {
|
||||
let conn = match self.conn.lock() {
|
||||
Ok(c) => c,
|
||||
Err(_) => return false,
|
||||
};
|
||||
if pinned {
|
||||
// 取当前最小 pinned_order,再 -1 使其排到最前
|
||||
let min_order: Option<i64> = conn
|
||||
.query_row("SELECT MIN(pinned_order) FROM clipboard_history WHERE pinned = 1", [], |r| r.get(0))
|
||||
.ok()
|
||||
.flatten();
|
||||
let new_order = min_order.unwrap_or(0) - 1;
|
||||
conn.execute(
|
||||
"UPDATE clipboard_history SET pinned = 1, pinned_order = ?1 WHERE id = ?2",
|
||||
params![new_order, id],
|
||||
)
|
||||
.is_ok()
|
||||
} else {
|
||||
conn.execute(
|
||||
"UPDATE clipboard_history SET pinned = 0, pinned_order = NULL WHERE id = ?1",
|
||||
params![id],
|
||||
)
|
||||
.is_ok()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn delete(&self, id: i64) -> bool {
|
||||
self.conn
|
||||
.lock()
|
||||
.ok()
|
||||
.map(|c| c.execute("DELETE FROM clipboard_history WHERE id = ?1", params![id]).is_ok())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// 清空所有非固定条目
|
||||
pub fn clear_non_pinned(&self) -> bool {
|
||||
self.conn
|
||||
.lock()
|
||||
.ok()
|
||||
.map(|c| c.execute("DELETE FROM clipboard_history WHERE pinned = 0", []).is_ok())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// 超过 max 时自动清理最旧的非固定条目
|
||||
pub fn prune_to_max(&self, max_items: i64) {
|
||||
let conn = match self.conn.lock() {
|
||||
Ok(c) => c,
|
||||
Err(_) => return,
|
||||
};
|
||||
let _ = conn.execute(
|
||||
"DELETE FROM clipboard_history
|
||||
WHERE pinned = 0 AND id IN (
|
||||
SELECT id FROM clipboard_history
|
||||
WHERE pinned = 0
|
||||
ORDER BY created_at DESC
|
||||
LIMIT -1 OFFSET ?1
|
||||
)",
|
||||
params![max_items],
|
||||
);
|
||||
}
|
||||
|
||||
pub fn count(&self) -> i64 {
|
||||
self.count_kind("all")
|
||||
}
|
||||
|
||||
/// 按类型统计非固定条目总数(kind="all" 表示全部)
|
||||
pub fn count_kind(&self, kind: &str) -> i64 {
|
||||
let conn = match self.conn.lock() {
|
||||
Ok(c) => c,
|
||||
Err(_) => return 0,
|
||||
};
|
||||
let sql = if kind == "all" {
|
||||
"SELECT COUNT(*) FROM clipboard_history WHERE pinned = 0"
|
||||
} else {
|
||||
"SELECT COUNT(*) FROM clipboard_history WHERE pinned = 0 AND kind = ?1"
|
||||
};
|
||||
if kind == "all" {
|
||||
conn.query_row(sql, [], |r| r.get(0)).ok().unwrap_or(0)
|
||||
} else {
|
||||
conn.query_row(sql, params![kind], |r| r.get(0)).ok().unwrap_or(0)
|
||||
}
|
||||
}
|
||||
|
||||
/// 按搜索关键词统计匹配的非固定条目总数
|
||||
pub fn count_search(&self, query: &str) -> i64 {
|
||||
let conn = match self.conn.lock() {
|
||||
Ok(c) => c,
|
||||
Err(_) => return 0,
|
||||
};
|
||||
let pattern = format!("%{}%", query);
|
||||
conn.query_row(
|
||||
"SELECT COUNT(*) FROM clipboard_history WHERE pinned = 0 AND preview LIKE ?1",
|
||||
params![pattern],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.ok()
|
||||
.unwrap_or(0)
|
||||
}
|
||||
}
|
||||
|
||||
fn row_to_item(r: &rusqlite::Row<'_>) -> rusqlite::Result<ClipboardItem> {
|
||||
Ok(ClipboardItem {
|
||||
id: r.get(0)?,
|
||||
kind: r.get(1)?,
|
||||
preview: r.get(2)?,
|
||||
size: r.get(3)?,
|
||||
pinned: r.get::<_, i64>(4)? != 0,
|
||||
pinned_order: r.get(5)?,
|
||||
created_at: r.get(6)?,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn now_ms() -> i64 {
|
||||
chrono::Local::now().timestamp_millis()
|
||||
}
|
||||
@@ -3,7 +3,7 @@ use std::collections::HashMap;
|
||||
use tauri::{AppHandle, State};
|
||||
use tauri_plugin_opener::OpenerExt;
|
||||
|
||||
use super::engine::DownloadEngine;
|
||||
use super::engine::{CheckUrlResult, DownloadEngine};
|
||||
use super::task::{DownloadTask, DownloaderSettings};
|
||||
|
||||
/// 获取所有任务
|
||||
@@ -12,6 +12,39 @@ pub fn downloader_get_tasks(engine: State<'_, DownloadEngine>) -> Vec<DownloadTa
|
||||
engine.get_tasks()
|
||||
}
|
||||
|
||||
/// 检查 URL 重复性并探测文件信息(添加下载前调用)
|
||||
#[tauri::command]
|
||||
pub async fn downloader_check_url(
|
||||
engine: State<'_, DownloadEngine>,
|
||||
url: String,
|
||||
dir: Option<String>,
|
||||
headers: Option<HashMap<String, String>>,
|
||||
) -> Result<CheckUrlResult, String> {
|
||||
let headers = headers.unwrap_or_default();
|
||||
let (probe, duplicate, existing) = engine.check_url(&url, dir.as_deref(), &headers).await;
|
||||
let result = match probe {
|
||||
Ok(p) => CheckUrlResult {
|
||||
ok: true,
|
||||
error: None,
|
||||
filename: p.filename.clone(),
|
||||
total_size: p.total_size,
|
||||
supports_resume: p.supports_resume,
|
||||
duplicate,
|
||||
existing,
|
||||
},
|
||||
Err(e) => CheckUrlResult {
|
||||
ok: false,
|
||||
error: Some(e),
|
||||
filename: None,
|
||||
total_size: None,
|
||||
supports_resume: false,
|
||||
duplicate,
|
||||
existing,
|
||||
},
|
||||
};
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// 添加下载任务
|
||||
#[tauri::command]
|
||||
pub async fn downloader_add_task(
|
||||
@@ -20,8 +53,9 @@ pub async fn downloader_add_task(
|
||||
filename: Option<String>,
|
||||
dir: Option<String>,
|
||||
headers: Option<HashMap<String, String>>,
|
||||
auto_rename: Option<bool>,
|
||||
) -> Result<String, String> {
|
||||
engine.add_task(url, filename, dir, headers.unwrap_or_default()).await
|
||||
engine.add_task(url, filename, dir, headers.unwrap_or_default(), auto_rename.unwrap_or(false)).await
|
||||
}
|
||||
|
||||
/// 暂停任务
|
||||
|
||||
@@ -9,7 +9,50 @@ use tauri::{AppHandle, Emitter};
|
||||
use super::http_dl::{HttpDownloader, split_segments};
|
||||
use super::rate_limit::RateLimiter;
|
||||
use super::storage::{EngineState, Storage};
|
||||
use super::task::{DownloadTask, DownloaderSettings, Segment, TaskStatus};
|
||||
use super::task::{DownloadTask, DownloaderSettings, ProbeResult, Segment, TaskStatus};
|
||||
|
||||
/// 重复类型
|
||||
#[derive(Debug, Clone, PartialEq, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum DuplicateKind {
|
||||
/// 无重复
|
||||
None,
|
||||
/// URL 重复(已有相同链接的任务)
|
||||
Url,
|
||||
/// 文件名重复(已有同名任务下载到同一目录)
|
||||
Filename,
|
||||
/// 磁盘文件已存在
|
||||
FileExists,
|
||||
}
|
||||
|
||||
/// 已存在的任务信息(用于前端展示)
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ExistingTaskInfo {
|
||||
pub id: String,
|
||||
pub filename: String,
|
||||
pub status: TaskStatus,
|
||||
}
|
||||
|
||||
/// check_url 命令返回的结果
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CheckUrlResult {
|
||||
/// 探测是否成功
|
||||
pub ok: bool,
|
||||
/// 错误信息(探测失败时)
|
||||
pub error: Option<String>,
|
||||
/// 文件名(探测成功时)
|
||||
pub filename: Option<String>,
|
||||
/// 文件大小(字节)
|
||||
pub total_size: Option<u64>,
|
||||
/// 是否支持断点续传
|
||||
pub supports_resume: bool,
|
||||
/// 重复类型
|
||||
pub duplicate: DuplicateKind,
|
||||
/// 已存在的任务信息
|
||||
pub existing: Option<ExistingTaskInfo>,
|
||||
}
|
||||
|
||||
/// 进度事件载荷(发给前端 download-progress 事件)
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
@@ -130,13 +173,109 @@ impl DownloadEngine {
|
||||
|
||||
// ===================== 公开 API =====================
|
||||
|
||||
/// 检查 URL 重复性并探测文件信息。
|
||||
/// 返回 (探测结果, 重复类型, 已存在任务信息)。
|
||||
pub async fn check_url(
|
||||
&self,
|
||||
url: &str,
|
||||
dir: Option<&str>,
|
||||
headers: &HashMap<String, String>,
|
||||
) -> (Result<ProbeResult, String>, DuplicateKind, Option<ExistingTaskInfo>) {
|
||||
let probe = self.inner.http.probe(url, headers).await;
|
||||
let settings = self.inner.settings.lock().unwrap().clone();
|
||||
let task_dir = dir.map(|d| d.to_string()).unwrap_or_else(|| settings.download_dir.clone());
|
||||
|
||||
let filename = probe.as_ref().ok()
|
||||
.and_then(|p| p.filename.clone())
|
||||
.unwrap_or_else(|| {
|
||||
url.split('?').next()
|
||||
.and_then(|u| u.rsplit('/').next())
|
||||
.filter(|n| !n.is_empty())
|
||||
.map(|n| n.to_string())
|
||||
.unwrap_or_else(|| format!("download_{}", chrono::Utc::now().timestamp()))
|
||||
});
|
||||
|
||||
let mut duplicate = DuplicateKind::None;
|
||||
let mut existing: Option<ExistingTaskInfo> = None;
|
||||
|
||||
{
|
||||
let tasks = self.inner.tasks.lock().unwrap();
|
||||
for t in tasks.values() {
|
||||
// URL 完全相同
|
||||
if t.url == url {
|
||||
duplicate = DuplicateKind::Url;
|
||||
existing = Some(ExistingTaskInfo {
|
||||
id: t.id.clone(),
|
||||
filename: t.filename.clone(),
|
||||
status: t.status.clone(),
|
||||
});
|
||||
break;
|
||||
}
|
||||
// 目标文件名 + 目录相同(可能 URL 不同但下载到同一文件)
|
||||
if t.filename == filename && t.dir == task_dir {
|
||||
duplicate = DuplicateKind::Filename;
|
||||
existing = Some(ExistingTaskInfo {
|
||||
id: t.id.clone(),
|
||||
filename: t.filename.clone(),
|
||||
status: t.status.clone(),
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 检查磁盘文件是否已存在(仅探测成功时)
|
||||
if duplicate == DuplicateKind::None {
|
||||
if let Ok(ref _p) = probe {
|
||||
let filepath = PathBuf::from(&task_dir).join(&filename);
|
||||
if filepath.exists() {
|
||||
duplicate = DuplicateKind::FileExists;
|
||||
existing = Some(ExistingTaskInfo {
|
||||
id: String::new(),
|
||||
filename,
|
||||
status: TaskStatus::Complete,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
(probe, duplicate, existing)
|
||||
}
|
||||
|
||||
/// 生成不冲突的文件名(同名时追加 (1)、(2)...)
|
||||
fn generate_unique_filename(&self, dir: &str, filename: &str) -> String {
|
||||
let path = PathBuf::from(dir).join(filename);
|
||||
if !path.exists() {
|
||||
return filename.to_string();
|
||||
}
|
||||
let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("download");
|
||||
let ext = path.extension().and_then(|s| s.to_str());
|
||||
for i in 1..1000 {
|
||||
let new_name = match ext {
|
||||
Some(e) => format!("{} ({}).{}", stem, i, e),
|
||||
None => format!("{} ({})", stem, i),
|
||||
};
|
||||
let new_path = PathBuf::from(dir).join(&new_name);
|
||||
if !new_path.exists() {
|
||||
return new_name;
|
||||
}
|
||||
}
|
||||
// 极端情况:追加时间戳
|
||||
match ext {
|
||||
Some(e) => format!("{} ({}).{}", stem, chrono::Utc::now().timestamp_millis(), e),
|
||||
None => format!("{} ({})", stem, chrono::Utc::now().timestamp_millis()),
|
||||
}
|
||||
}
|
||||
|
||||
/// 添加下载任务
|
||||
/// auto_rename: 同名时自动重命名(追加 (1)、(2)...),否则覆盖
|
||||
pub async fn add_task(
|
||||
&self,
|
||||
url: String,
|
||||
filename: Option<String>,
|
||||
dir: Option<String>,
|
||||
headers: HashMap<String, String>,
|
||||
auto_rename: bool,
|
||||
) -> Result<String, String> {
|
||||
// 探测资源信息
|
||||
let probe = self.inner.http.probe(&url, &headers).await;
|
||||
@@ -145,7 +284,7 @@ impl DownloadEngine {
|
||||
let task_dir = dir.unwrap_or_else(|| settings.download_dir.clone());
|
||||
|
||||
// 确定文件名
|
||||
let task_filename = filename
|
||||
let mut task_filename = filename
|
||||
.or_else(|| probe.as_ref().ok().and_then(|p| p.filename.clone()))
|
||||
.unwrap_or_else(|| {
|
||||
url.split('?')
|
||||
@@ -156,6 +295,11 @@ impl DownloadEngine {
|
||||
.unwrap_or_else(|| format!("download_{}", chrono::Utc::now().timestamp()))
|
||||
});
|
||||
|
||||
// 自动重命名:若磁盘已存在同名文件,追加 (1)、(2)...
|
||||
if auto_rename {
|
||||
task_filename = self.generate_unique_filename(&task_dir, &task_filename);
|
||||
}
|
||||
|
||||
let id = self.inner.storage.next_task_id();
|
||||
|
||||
// 创建分段(受 continue_download 设置控制:关闭时强制单线程、不支持续传)
|
||||
|
||||
@@ -7,7 +7,7 @@ pub mod storage;
|
||||
pub mod task;
|
||||
|
||||
pub use commands::{
|
||||
downloader_add_task, downloader_get_extension_info, downloader_get_settings, downloader_get_tasks,
|
||||
downloader_add_task, downloader_check_url, downloader_get_extension_info, downloader_get_settings, downloader_get_tasks,
|
||||
downloader_open_dir, downloader_open_url, downloader_pause_task, downloader_remove_task,
|
||||
downloader_resume_task, downloader_save_settings, downloader_status,
|
||||
};
|
||||
|
||||
@@ -108,7 +108,7 @@ async fn create_download(
|
||||
return Err((StatusCode::UNAUTHORIZED, Json(ErrorResponse { error: "未授权".into() })));
|
||||
}
|
||||
|
||||
match state.engine.add_task(req.url, req.filename, req.dir, req.headers).await {
|
||||
match state.engine.add_task(req.url, req.filename, req.dir, req.headers, true).await {
|
||||
Ok(id) => Ok(Json(CreateDownloadResponse { id })),
|
||||
Err(e) => Err((StatusCode::BAD_REQUEST, Json(ErrorResponse { error: e }))),
|
||||
}
|
||||
|
||||
@@ -125,6 +125,9 @@ pub struct DownloaderSettings {
|
||||
/// 删除任务时是否同时删除已下载的文件
|
||||
#[serde(default)]
|
||||
pub delete_files_on_remove: bool,
|
||||
/// 添加下载前检查重复(URL 或文件名重复时询问)
|
||||
#[serde(default = "default_true")]
|
||||
pub check_duplicate: bool,
|
||||
}
|
||||
|
||||
fn default_max_concurrent() -> u32 {
|
||||
@@ -162,11 +165,13 @@ impl Default for DownloaderSettings {
|
||||
extension_port: default_extension_port(),
|
||||
extension_secret: String::new(),
|
||||
delete_files_on_remove: false,
|
||||
check_duplicate: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// HEAD/Range 探测结果
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ProbeResult {
|
||||
/// 文件大小(字节),None=未知
|
||||
pub total_size: Option<u64>,
|
||||
|
||||
+59
-47
@@ -1,6 +1,7 @@
|
||||
use std::sync::Arc;
|
||||
use tauri::Manager;
|
||||
|
||||
mod clipboard;
|
||||
mod download_engine;
|
||||
mod logger;
|
||||
mod mihomo_manager;
|
||||
@@ -8,10 +9,12 @@ mod monitor_kernel;
|
||||
mod network_monitor;
|
||||
mod osd_window;
|
||||
mod process_manager;
|
||||
mod snap_fix;
|
||||
mod tray_menu;
|
||||
|
||||
use download_engine::{
|
||||
DownloadEngine, ExtensionServer,
|
||||
downloader_add_task, downloader_get_extension_info, downloader_get_settings, downloader_get_tasks,
|
||||
downloader_add_task, downloader_check_url, downloader_get_extension_info, downloader_get_settings, downloader_get_tasks,
|
||||
downloader_open_dir, downloader_open_url, downloader_pause_task, downloader_remove_task,
|
||||
downloader_resume_task, downloader_save_settings, downloader_status,
|
||||
};
|
||||
@@ -40,6 +43,14 @@ use process_manager::{
|
||||
get_all_process_status, get_process_status, start_monitoring_thread, start_process,
|
||||
stop_all_processes, stop_process, ProcessManager,
|
||||
};
|
||||
use clipboard::{
|
||||
ClipboardManager,
|
||||
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_paste_to_target, clipboard_register_shortcut, clipboard_save_settings, clipboard_search,
|
||||
clipboard_set_pinned, clipboard_show_popup, clipboard_show_window, clipboard_start,
|
||||
clipboard_status, clipboard_stop, clipboard_unregister_shortcut,
|
||||
};
|
||||
|
||||
#[tauri::command]
|
||||
fn greet(name: &str) -> String {
|
||||
@@ -52,6 +63,7 @@ fn quit_app(
|
||||
mihomo: tauri::State<'_, MihomoManager>,
|
||||
engine: tauri::State<'_, DownloadEngine>,
|
||||
monitor: tauri::State<'_, MonitorKernel>,
|
||||
clipboard: tauri::State<'_, ClipboardManager>,
|
||||
app: tauri::AppHandle,
|
||||
) {
|
||||
// 退出前清理系统代理,避免遗留导致网络问题
|
||||
@@ -60,6 +72,8 @@ fn quit_app(
|
||||
engine.cleanup_on_exit();
|
||||
// 停止提权 Kernel(普通权限由 stop_all 统一清理,提权 Kernel 需通过 /shutdown)
|
||||
tauri::async_runtime::block_on(monitor.cleanup_on_exit(&app));
|
||||
// 停止剪贴板监听线程
|
||||
clipboard.stop();
|
||||
// 停止所有子进程(同步 kill + 带超时的 wait,确保进程真正终止)
|
||||
state.stop_all();
|
||||
// 通过 app.exit 触发 RunEvent::ExitRequested,统一退出路径
|
||||
@@ -72,6 +86,7 @@ pub fn run() {
|
||||
.plugin(tauri_plugin_autostart::Builder::new().build())
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_global_shortcut::Builder::new().build())
|
||||
.plugin(
|
||||
tauri_plugin_snap_layout::init()
|
||||
.button_id("titlebar-maximize")
|
||||
@@ -136,6 +151,7 @@ pub fn run() {
|
||||
osd_stop_watch,
|
||||
downloader_get_tasks,
|
||||
downloader_add_task,
|
||||
downloader_check_url,
|
||||
downloader_pause_task,
|
||||
downloader_resume_task,
|
||||
downloader_remove_task,
|
||||
@@ -144,7 +160,28 @@ pub fn run() {
|
||||
downloader_status,
|
||||
downloader_get_extension_info,
|
||||
downloader_open_dir,
|
||||
downloader_open_url
|
||||
downloader_open_url,
|
||||
clipboard_get_history,
|
||||
clipboard_get_pinned,
|
||||
clipboard_search,
|
||||
clipboard_get_item,
|
||||
clipboard_set_pinned,
|
||||
clipboard_delete,
|
||||
clipboard_clear,
|
||||
clipboard_copy_back,
|
||||
clipboard_count,
|
||||
clipboard_get_settings,
|
||||
clipboard_save_settings,
|
||||
clipboard_status,
|
||||
clipboard_start,
|
||||
clipboard_stop,
|
||||
clipboard_register_shortcut,
|
||||
clipboard_unregister_shortcut,
|
||||
clipboard_show_popup,
|
||||
clipboard_show_window,
|
||||
clipboard_hide_popup,
|
||||
clipboard_paste_to_target,
|
||||
snap_fix::fix_snap_background
|
||||
])
|
||||
.setup(|app| {
|
||||
// 初始化日志系统,日志目录: {app_data_dir}/logs/
|
||||
@@ -196,52 +233,24 @@ pub fn run() {
|
||||
ExtensionServer::start(server_engine, server_port, server_secret).await;
|
||||
});
|
||||
|
||||
let open = tauri::menu::MenuItem::with_id(app, "open", "设置", true, None::<&str>)?;
|
||||
let quit = tauri::menu::MenuItem::with_id(app, "quit", "退出", true, None::<&str>)?;
|
||||
let menu = tauri::menu::Menu::with_items(app, &[&open, &quit])?;
|
||||
// 初始化 ClipboardManager,数据目录: {app_data_dir}/clipboard/
|
||||
let clipboard = ClipboardManager::new(app_data_dir.clone());
|
||||
// 应用启动时若已启用则自动开始监听
|
||||
if clipboard.get_settings().enabled {
|
||||
clipboard.start(&app.handle());
|
||||
}
|
||||
// 应用启动时注册快捷弹窗全局快捷键
|
||||
let shortcut = clipboard.get_settings().shortcut.clone();
|
||||
if !shortcut.trim().is_empty() {
|
||||
let app_handle = app.handle().clone();
|
||||
if let Err(e) = clipboard::popup::register_shortcut(&app_handle, &shortcut) {
|
||||
eprintln!("[clipboard] 快捷键注册失败: {}", e);
|
||||
}
|
||||
}
|
||||
app.manage(clipboard);
|
||||
|
||||
let _tray = tauri::tray::TrayIconBuilder::new()
|
||||
.icon(app.default_window_icon().unwrap().clone())
|
||||
.menu(&menu)
|
||||
.show_menu_on_left_click(false)
|
||||
.on_menu_event(|app, event| match event.id().as_ref() {
|
||||
"open" => {
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
window.show().ok();
|
||||
window.set_focus().ok();
|
||||
}
|
||||
}
|
||||
"quit" => {
|
||||
if let Some(mihomo) = app.try_state::<MihomoManager>() {
|
||||
mihomo.cleanup_on_exit();
|
||||
}
|
||||
if let Some(engine) = app.try_state::<DownloadEngine>() {
|
||||
engine.cleanup_on_exit();
|
||||
}
|
||||
if let Some(monitor) = app.try_state::<MonitorKernel>() {
|
||||
tauri::async_runtime::block_on(monitor.cleanup_on_exit(app));
|
||||
}
|
||||
if let Some(pm) = app.try_state::<ProcessManager>() {
|
||||
pm.stop_all();
|
||||
}
|
||||
app.exit(0);
|
||||
}
|
||||
_ => {}
|
||||
})
|
||||
.on_tray_icon_event(|tray, event| {
|
||||
if let tauri::tray::TrayIconEvent::Click {
|
||||
button: tauri::tray::MouseButton::Left,
|
||||
..
|
||||
} = event
|
||||
{
|
||||
let app = tray.app_handle();
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
window.show().ok();
|
||||
window.set_focus().ok();
|
||||
}
|
||||
}
|
||||
})
|
||||
.build(app)?;
|
||||
// 自定义托盘菜单(代理/OSD/Kernel/下载/设置/退出)
|
||||
tray_menu::create_tray_menu(app.handle())?;
|
||||
|
||||
// 启动进程监控线程
|
||||
start_monitoring_thread(app.handle().clone());
|
||||
@@ -286,6 +295,9 @@ pub fn run() {
|
||||
if let Some(monitor) = app.try_state::<MonitorKernel>() {
|
||||
tauri::async_runtime::block_on(monitor.cleanup_on_exit(app));
|
||||
}
|
||||
if let Some(clip) = app.try_state::<ClipboardManager>() {
|
||||
clip.stop();
|
||||
}
|
||||
if let Some(pm) = app.try_state::<ProcessManager>() {
|
||||
pm.stop_all();
|
||||
}
|
||||
|
||||
@@ -825,6 +825,24 @@ impl MihomoManager {
|
||||
}
|
||||
}
|
||||
|
||||
/// 开启系统代理(托盘菜单调用)
|
||||
pub fn enable_system_proxy(&self) -> Result<(), String> {
|
||||
let settings = self.load_settings();
|
||||
let addr = format!("127.0.0.1:{}", settings.mixed_port);
|
||||
set_system_proxy_windows(&addr)?;
|
||||
let mut s = settings;
|
||||
s.system_proxy = true;
|
||||
self.save_settings(&s)
|
||||
}
|
||||
|
||||
/// 关闭系统代理(托盘菜单调用)
|
||||
pub fn disable_system_proxy(&self) -> Result<(), String> {
|
||||
clear_system_proxy_windows()?;
|
||||
let mut s = self.load_settings();
|
||||
s.system_proxy = false;
|
||||
self.save_settings(&s)
|
||||
}
|
||||
|
||||
// ---------- 订阅管理 ----------
|
||||
pub async fn import_profile(&self, url: &str, name: &str) -> Result<ProfileMeta, String> {
|
||||
// 先读取当前 settings(此时新 profile 文件还未写入,reconcile 不会误添加)
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
//! 修复 tauri-plugin-snap-layout 子窗口的白色背景问题。
|
||||
//!
|
||||
//! snap-layout 插件创建的原生 Win32 STATIC 子窗口默认有白色背景,
|
||||
//! 切换主题/效果时 clearEffects/setEffects 触发窗口重绘,
|
||||
//! 导致子窗口白色背景显现,遮挡按钮样式。
|
||||
//!
|
||||
//! 本模块通过子类化拦截 WM_ERASEBKGND 和 WM_PAINT,阻止白色背景绘制。
|
||||
//! 幂等:可多次调用,SetWindowSubclass 重复调用会替换旧实例。
|
||||
|
||||
use raw_window_handle::{HasWindowHandle, RawWindowHandle};
|
||||
use tauri::{AppHandle, Manager};
|
||||
use windows_sys::Win32::{
|
||||
Foundation::{HWND, LPARAM, LRESULT, WPARAM},
|
||||
Graphics::Gdi::{BeginPaint, EndPaint, PAINTSTRUCT},
|
||||
UI::{
|
||||
Shell::{DefSubclassProc, RemoveWindowSubclass, SetWindowSubclass},
|
||||
WindowsAndMessaging::{GetPropW, IsWindow, WM_ERASEBKGND, WM_NCDESTROY, WM_PAINT, WM_PRINTCLIENT},
|
||||
},
|
||||
};
|
||||
|
||||
/// snap-layout 在父窗口上存储子窗口 HWND 的属性名(与 snap.rs 中 CHILD_PROP 一致)
|
||||
const CHILD_PROP: &[u16] = &[
|
||||
b'T' as u16, b'a' as u16, b'u' as u16, b'r' as u16, b'i' as u16,
|
||||
b'S' as u16, b'n' as u16, b'a' as u16, b'p' as u16, b'C' as u16,
|
||||
b'h' as u16, b'i' as u16, b'l' as u16, b'd' as u16, 0,
|
||||
];
|
||||
|
||||
/// 子类化 ID(与 snap-layout 的 SUBCLASS_ID 不同)
|
||||
const FIX_SUBCLASS_ID: usize = 0x736E_6170_6669_7800;
|
||||
|
||||
/// 子类化过程:拦截背景绘制相关消息
|
||||
/// - WM_ERASEBKGND: 返回 1,阻止 DefWindowProcW 用白色画刷填充背景
|
||||
/// - WM_PAINT: 调用 BeginPaint/EndPaint 但不绘制任何内容,阻止 DefWindowProcW 绘制白色背景
|
||||
/// - WM_PRINTCLIENT: 返回 0,阻止打印白色背景
|
||||
unsafe extern "system" fn fix_subclass_proc(
|
||||
hwnd: HWND,
|
||||
msg: u32,
|
||||
wparam: WPARAM,
|
||||
lparam: LPARAM,
|
||||
_subclass_id: usize,
|
||||
_ref_data: usize,
|
||||
) -> LRESULT {
|
||||
match msg {
|
||||
WM_ERASEBKGND => return 1,
|
||||
WM_PAINT => {
|
||||
// 调用 BeginPaint/EndPaint 清除 WM_PAINT 消息,但不绘制任何内容
|
||||
let mut ps: PAINTSTRUCT = std::mem::zeroed();
|
||||
BeginPaint(hwnd, &mut ps);
|
||||
EndPaint(hwnd, &ps);
|
||||
return 0;
|
||||
}
|
||||
WM_PRINTCLIENT => return 0,
|
||||
WM_NCDESTROY => {
|
||||
RemoveWindowSubclass(hwnd, Some(fix_subclass_proc), FIX_SUBCLASS_ID);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
DefSubclassProc(hwnd, msg, wparam, lparam)
|
||||
}
|
||||
|
||||
/// 查找主窗口的 snap-layout 子窗口并做子类化修复。
|
||||
/// 幂等:可多次调用,重复子类化无副作用。
|
||||
#[tauri::command]
|
||||
pub async fn fix_snap_background(app: AppHandle) -> Result<(), String> {
|
||||
let main_window = app
|
||||
.get_webview_window("main")
|
||||
.ok_or("main window not found")?;
|
||||
|
||||
let hwnd_isize: isize = {
|
||||
let handle = main_window.window_handle().map_err(|e| e.to_string())?;
|
||||
match handle.as_raw() {
|
||||
RawWindowHandle::Win32(h) => h.hwnd.get() as isize,
|
||||
_ => return Err("not win32".to_string()),
|
||||
}
|
||||
};
|
||||
|
||||
main_window
|
||||
.run_on_main_thread(move || unsafe {
|
||||
let parent = hwnd_isize as HWND;
|
||||
if IsWindow(parent) == 0 {
|
||||
return;
|
||||
}
|
||||
// snap-layout 把子窗口 HWND 存在父窗口的 "TauriSnapChild" 属性中
|
||||
let child = GetPropW(parent, CHILD_PROP.as_ptr()) as HWND;
|
||||
if child != 0 && IsWindow(child) != 0 {
|
||||
SetWindowSubclass(child, Some(fix_subclass_proc), FIX_SUBCLASS_ID, 0);
|
||||
}
|
||||
})
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,738 @@
|
||||
//! tray_menu.rs — 自定义系统托盘右键菜单
|
||||
//!
|
||||
//! 菜单结构:
|
||||
//! 代理 ▸
|
||||
//! 开启代理 (启动mihomo + 择优选节点 + 系统代理 + Windows通知)
|
||||
//! 关闭代理
|
||||
//! ---
|
||||
//! 选择代理 ▸
|
||||
//! 刷新节点列表
|
||||
//! ---
|
||||
//! 节点1 (120ms)
|
||||
//! 节点2 (超时)
|
||||
//! ...
|
||||
//! ---
|
||||
//! 切换 OSD
|
||||
//! 重启 Kernel
|
||||
//! ---
|
||||
//! 新建下载
|
||||
//! ---
|
||||
//! 常规设置
|
||||
//! 退出
|
||||
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
|
||||
use tauri::{
|
||||
AppHandle, Emitter, Manager,
|
||||
menu::{IsMenuItem, Menu, MenuItem, PredefinedMenuItem, Submenu},
|
||||
tray::{MouseButton, TrayIconBuilder, TrayIconEvent, TrayIconId},
|
||||
};
|
||||
|
||||
use crate::clipboard::ClipboardManager;
|
||||
use crate::download_engine::DownloadEngine;
|
||||
use crate::mihomo_manager::MihomoManager;
|
||||
use crate::monitor_kernel::MonitorKernel;
|
||||
use crate::process_manager::{ProcessManager, ProcessStatus};
|
||||
|
||||
// ===== 全局状态:代理节点菜单项映射 =====
|
||||
|
||||
/// menu_id → (group, node_name)
|
||||
static PROXY_NODE_MAP: OnceLock<Mutex<Vec<(String, String)>>> = OnceLock::new();
|
||||
|
||||
fn proxy_node_map() -> &'static Mutex<Vec<(String, String)>> {
|
||||
PROXY_NODE_MAP.get_or_init(|| Mutex::new(Vec::new()))
|
||||
}
|
||||
|
||||
// ===== 伪节点过滤(与前端 ProxyModule 保持一致) =====
|
||||
|
||||
const PSEUDO_KEYWORDS: &[&str] = &[
|
||||
"DIRECT",
|
||||
"REJECT",
|
||||
"PASS",
|
||||
"COMPATIBLE",
|
||||
"流量",
|
||||
"套餐",
|
||||
"到期",
|
||||
"续费",
|
||||
"官网",
|
||||
"网站",
|
||||
"刷新",
|
||||
"更新",
|
||||
"⭐",
|
||||
"★",
|
||||
"☆",
|
||||
"✕",
|
||||
"✖",
|
||||
"×",
|
||||
];
|
||||
|
||||
fn is_pseudo_node(name: &str) -> bool {
|
||||
let upper = name.trim().to_uppercase();
|
||||
if upper == "DIRECT" || upper == "REJECT" || upper == "PASS" || upper == "GLOBAL" {
|
||||
return true;
|
||||
}
|
||||
PSEUDO_KEYWORDS.iter().any(|kw| name.contains(kw))
|
||||
}
|
||||
|
||||
// ===== 状态查询 =====
|
||||
|
||||
fn is_proxy_running(app: &AppHandle) -> bool {
|
||||
if let Some(pm) = app.try_state::<ProcessManager>() {
|
||||
if let Some(info) = pm.get_status("proxy") {
|
||||
return matches!(info.status, ProcessStatus::Running);
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn is_monitor_running(app: &AppHandle) -> bool {
|
||||
if let Some(pm) = app.try_state::<ProcessManager>() {
|
||||
if let Some(info) = pm.get_status("monitor") {
|
||||
if matches!(info.status, ProcessStatus::Running) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(monitor) = app.try_state::<MonitorKernel>() {
|
||||
if monitor.is_elevated() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
// ===== 从 mihomo /proxies 响应中提取主选择组及其节点 =====
|
||||
|
||||
/// 返回 (组名, 有效节点列表, 当前节点)
|
||||
fn parse_main_group(proxies_response: &serde_json::Value) -> Option<(String, Vec<String>, String)> {
|
||||
let proxies = proxies_response.get("proxies")?.as_object()?;
|
||||
|
||||
// 收集所有可选择组
|
||||
let selector_groups: Vec<&str> = proxies
|
||||
.iter()
|
||||
.filter(|(_, v)| {
|
||||
v.get("type").and_then(|t| t.as_str()).map(|t| t == "Selector").unwrap_or(false)
|
||||
})
|
||||
.map(|(k, _)| k.as_str())
|
||||
.collect();
|
||||
|
||||
if selector_groups.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// 优先选择名为 PROXY / Proxy / 节点选择 / 代理 的组
|
||||
let preferred = selector_groups
|
||||
.iter()
|
||||
.find(|name| matches!(**name, "PROXY" | "Proxy" | "节点选择" | "代理"))
|
||||
.copied();
|
||||
let group_name = preferred.unwrap_or(selector_groups[0]);
|
||||
let group = &proxies[group_name];
|
||||
|
||||
let now = group.get("now").and_then(|n| n.as_str()).unwrap_or("").to_string();
|
||||
let all: Vec<String> = group
|
||||
.get("all")
|
||||
.and_then(|a| a.as_array())
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(|v| v.as_str().map(String::from))
|
||||
.filter(|n| !is_pseudo_node(n))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
Some((group_name.to_string(), all, now))
|
||||
}
|
||||
|
||||
/// 从 /proxies 响应中读取某节点最近一次延迟
|
||||
fn parse_node_delay(proxies_response: &serde_json::Value, name: &str) -> Option<u32> {
|
||||
proxies_response
|
||||
.get("proxies")
|
||||
.and_then(|p| p.get(name))
|
||||
.and_then(|n| n.get("history"))
|
||||
.and_then(|h| h.as_array())
|
||||
.and_then(|arr| arr.first())
|
||||
.and_then(|first| first.get("delay"))
|
||||
.and_then(|d| d.as_u64())
|
||||
.map(|d| d as u32)
|
||||
}
|
||||
|
||||
// ===== 菜单构建 =====
|
||||
|
||||
/// 获取代理节点数据(异步,调用 mihomo API)
|
||||
async fn fetch_proxy_nodes(app: &AppHandle) -> Option<(String, Vec<(String, Option<u32>)>, String)> {
|
||||
let mihomo = app.state::<MihomoManager>();
|
||||
let proxies = mihomo.get_proxies().await.ok()?;
|
||||
let (group, nodes, now) = parse_main_group(&proxies)?;
|
||||
let node_delays: Vec<(String, Option<u32>)> = nodes
|
||||
.iter()
|
||||
.map(|name| (name.clone(), parse_node_delay(&proxies, name)))
|
||||
.collect();
|
||||
Some((group, node_delays, now))
|
||||
}
|
||||
|
||||
fn build_menu(
|
||||
app: &AppHandle,
|
||||
proxy_running: bool,
|
||||
monitor_running: bool,
|
||||
proxy_nodes: Option<(String, Vec<(String, Option<u32>)>, String)>,
|
||||
) -> Result<Menu<tauri::Wry>, tauri::Error> {
|
||||
// ---------- 代理子菜单 ----------
|
||||
let enable_proxy = MenuItem::with_id(
|
||||
app,
|
||||
"proxy_enable",
|
||||
"开启代理",
|
||||
!proxy_running,
|
||||
None::<&str>,
|
||||
)?;
|
||||
let disable_proxy = MenuItem::with_id(
|
||||
app,
|
||||
"proxy_disable",
|
||||
"关闭代理",
|
||||
proxy_running,
|
||||
None::<&str>,
|
||||
)?;
|
||||
let proxy_sep = PredefinedMenuItem::separator(app)?;
|
||||
|
||||
// 选择代理子菜单
|
||||
let (node_menu_items, node_sep) = build_proxy_select_items(app, proxy_running, &proxy_nodes)?;
|
||||
|
||||
// "刷新节点列表"项(仅代理运行时创建,提前声明以保证引用生命周期)
|
||||
let refresh = if proxy_running {
|
||||
Some(MenuItem::with_id(app, "proxy_refresh", "🔄 刷新节点列表", true, None::<&str>)?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// 组装选择代理子菜单
|
||||
let mut select_refs: Vec<&dyn IsMenuItem<tauri::Wry>> = Vec::new();
|
||||
if !proxy_running {
|
||||
// 只有一个 hint 项
|
||||
for item in &node_menu_items {
|
||||
select_refs.push(item);
|
||||
}
|
||||
} else {
|
||||
if let Some(ref r) = refresh {
|
||||
select_refs.push(r);
|
||||
}
|
||||
if let Some(ref sep) = node_sep {
|
||||
select_refs.push(sep);
|
||||
}
|
||||
for item in &node_menu_items {
|
||||
select_refs.push(item);
|
||||
}
|
||||
}
|
||||
let select_proxy = Submenu::with_items(app, "选择代理", proxy_running, &select_refs)?;
|
||||
|
||||
// 组装代理子菜单
|
||||
let proxy_refs: Vec<&dyn IsMenuItem<tauri::Wry>> = vec![&enable_proxy, &disable_proxy, &proxy_sep, &select_proxy];
|
||||
let proxy_submenu = Submenu::with_items(app, "代理", true, &proxy_refs)?;
|
||||
|
||||
// ---------- 分隔线 ----------
|
||||
let sep1 = PredefinedMenuItem::separator(app)?;
|
||||
|
||||
// ---------- OSD + Kernel ----------
|
||||
let osd_toggle = MenuItem::with_id(app, "osd_toggle", "切换 OSD", true, None::<&str>)?;
|
||||
let kernel_restart = MenuItem::with_id(
|
||||
app,
|
||||
"kernel_restart",
|
||||
"重启 Kernel",
|
||||
monitor_running,
|
||||
None::<&str>,
|
||||
)?;
|
||||
|
||||
// ---------- 分隔线 ----------
|
||||
let sep2 = PredefinedMenuItem::separator(app)?;
|
||||
|
||||
// ---------- 新建下载 ----------
|
||||
let download_new = MenuItem::with_id(app, "download_new", "新建下载", true, None::<&str>)?;
|
||||
|
||||
// ---------- 分隔线 ----------
|
||||
let sep3 = PredefinedMenuItem::separator(app)?;
|
||||
|
||||
// ---------- 系统菜单 ----------
|
||||
let settings = MenuItem::with_id(app, "settings", "常规设置", true, None::<&str>)?;
|
||||
let quit = MenuItem::with_id(app, "quit", "退出", true, None::<&str>)?;
|
||||
|
||||
// ---------- 组装主菜单 ----------
|
||||
let main_refs: Vec<&dyn IsMenuItem<tauri::Wry>> = vec![
|
||||
&proxy_submenu,
|
||||
&sep1,
|
||||
&osd_toggle,
|
||||
&kernel_restart,
|
||||
&sep2,
|
||||
&download_new,
|
||||
&sep3,
|
||||
&settings,
|
||||
&quit,
|
||||
];
|
||||
Menu::with_items(app, &main_refs)
|
||||
}
|
||||
|
||||
/// 构建代理节点菜单项(返回 MenuItem 列表 + 可选分隔线)
|
||||
fn build_proxy_select_items(
|
||||
app: &AppHandle,
|
||||
proxy_running: bool,
|
||||
proxy_nodes: &Option<(String, Vec<(String, Option<u32>)>, String)>,
|
||||
) -> Result<(Vec<MenuItem<tauri::Wry>>, Option<PredefinedMenuItem<tauri::Wry>>), tauri::Error> {
|
||||
if !proxy_running {
|
||||
let hint = MenuItem::with_id(app, "proxy_noop", "代理未运行", false, None::<&str>)?;
|
||||
return Ok((vec![hint], None));
|
||||
}
|
||||
|
||||
let (group, nodes, now) = match proxy_nodes {
|
||||
Some(data) => data,
|
||||
None => {
|
||||
let hint = MenuItem::with_id(app, "proxy_loading", "加载中…", false, None::<&str>)?;
|
||||
return Ok((vec![hint], None));
|
||||
}
|
||||
};
|
||||
|
||||
if nodes.is_empty() {
|
||||
let hint = MenuItem::with_id(app, "proxy_noop", "无可用节点", false, None::<&str>)?;
|
||||
return Ok((vec![hint], None));
|
||||
}
|
||||
|
||||
let sep = PredefinedMenuItem::separator(app)?;
|
||||
|
||||
// 清空并重建全局映射
|
||||
{
|
||||
let mut map = proxy_node_map().lock().unwrap();
|
||||
map.clear();
|
||||
}
|
||||
|
||||
let mut items: Vec<MenuItem<tauri::Wry>> = Vec::new();
|
||||
|
||||
// 限制节点数量避免菜单过长
|
||||
for (idx, (name, delay)) in nodes.iter().take(30).enumerate() {
|
||||
let delay_text = match delay {
|
||||
Some(0) => "(超时)".to_string(),
|
||||
Some(d) => format!("({}ms)", d),
|
||||
None => "(未测试)".to_string(),
|
||||
};
|
||||
let is_current = name == now;
|
||||
let prefix = if is_current { "✓ " } else { " " };
|
||||
let text = format!("{}{} {}", prefix, name, delay_text);
|
||||
|
||||
let menu_id = format!("proxy_node_{}", idx);
|
||||
let item = MenuItem::with_id(app, &menu_id, &text, true, None::<&str>)?;
|
||||
|
||||
// 记录映射
|
||||
{
|
||||
let mut map = proxy_node_map().lock().unwrap();
|
||||
map.push((group.clone(), name.clone()));
|
||||
}
|
||||
|
||||
items.push(item);
|
||||
}
|
||||
|
||||
Ok((items, Some(sep)))
|
||||
}
|
||||
|
||||
// ===== 异步菜单重建 =====
|
||||
|
||||
async fn rebuild_menu(app: &AppHandle) {
|
||||
let proxy_running = is_proxy_running(app);
|
||||
let monitor_running = is_monitor_running(app);
|
||||
let proxy_nodes = if proxy_running {
|
||||
fetch_proxy_nodes(app).await
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
if let Ok(menu) = build_menu(app, proxy_running, monitor_running, proxy_nodes) {
|
||||
if let Some(tray) = app.tray_by_id(&TrayIconId::new("main")) {
|
||||
let _ = tray.set_menu(Some(menu));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 菜单事件处理 =====
|
||||
|
||||
fn handle_menu_event(app: &AppHandle, event: tauri::menu::MenuEvent) {
|
||||
let id = event.id().as_ref().to_string();
|
||||
match id.as_str() {
|
||||
"proxy_enable" => {
|
||||
let app = app.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
if let Err(e) = enable_proxy(&app).await {
|
||||
eprintln!("[tray] 开启代理失败: {}", e);
|
||||
send_notification("代理启动失败", &e);
|
||||
}
|
||||
});
|
||||
}
|
||||
"proxy_disable" => {
|
||||
let app = app.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
if let Err(e) = disable_proxy(&app).await {
|
||||
eprintln!("[tray] 关闭代理失败: {}", e);
|
||||
send_notification("代理关闭失败", &e);
|
||||
}
|
||||
});
|
||||
}
|
||||
"proxy_refresh" => {
|
||||
let app = app.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
// 先重建菜单(保留旧数据),然后并行测试延迟并再次重建
|
||||
rebuild_menu(&app).await;
|
||||
test_all_delays_and_rebuild(&app).await;
|
||||
});
|
||||
}
|
||||
"osd_toggle" => {
|
||||
let _ = app.emit("tray:toggle-osd", ());
|
||||
}
|
||||
"kernel_restart" => {
|
||||
let app = app.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
if let Err(e) = restart_kernel(&app).await {
|
||||
eprintln!("[tray] 重启 Kernel 失败: {}", e);
|
||||
send_notification("Kernel 重启失败", &e);
|
||||
}
|
||||
});
|
||||
}
|
||||
"download_new" => {
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
window.show().ok();
|
||||
window.set_focus().ok();
|
||||
}
|
||||
let _ = app.emit("tray:new-download", ());
|
||||
}
|
||||
"settings" => {
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
window.show().ok();
|
||||
window.set_focus().ok();
|
||||
}
|
||||
let _ = app.emit("tray:open-settings", ());
|
||||
}
|
||||
"quit" => {
|
||||
quit_cleanup(app);
|
||||
}
|
||||
id_str if id_str.starts_with("proxy_node_") => {
|
||||
let idx: usize = id_str
|
||||
.strip_prefix("proxy_node_")
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(usize::MAX);
|
||||
let app = app.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
if let Some((group, name)) = group_name_from_map(idx) {
|
||||
select_proxy_node(&app, &group, &name).await;
|
||||
} else {
|
||||
eprintln!("[tray] 节点索引无效: {}", idx);
|
||||
}
|
||||
});
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// 从全局映射中获取 (group, name)
|
||||
fn group_name_from_map(idx: usize) -> Option<(String, String)> {
|
||||
let map = proxy_node_map().lock().unwrap();
|
||||
map.get(idx).cloned()
|
||||
}
|
||||
|
||||
/// 选择代理节点并测试延迟
|
||||
async fn select_proxy_node(app: &AppHandle, group: &str, name: &str) {
|
||||
let mihomo = app.state::<MihomoManager>();
|
||||
match mihomo.select_proxy(group, name).await {
|
||||
Ok(()) => {
|
||||
// 测试延迟
|
||||
let delay = mihomo
|
||||
.test_delay(name, "https://www.gstatic.com/generate_204", 5000)
|
||||
.await
|
||||
.ok();
|
||||
let delay_text = match delay {
|
||||
Some(0) => "超时".to_string(),
|
||||
Some(d) => format!("{}ms", d),
|
||||
None => "未测速".to_string(),
|
||||
};
|
||||
send_notification("节点已切换", &format!("{}\n延迟: {}", name, delay_text));
|
||||
// 重建菜单
|
||||
rebuild_menu(app).await;
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("[tray] 切换节点失败: {}", e);
|
||||
send_notification("切换节点失败", &e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 代理开启/关闭 =====
|
||||
|
||||
async fn enable_proxy(app: &AppHandle) -> Result<(), String> {
|
||||
let mihomo = app.state::<MihomoManager>();
|
||||
let pm = app.state::<ProcessManager>();
|
||||
|
||||
// 1. 启动 mihomo(如果未运行)
|
||||
if !is_proxy_running(app) {
|
||||
let params = mihomo.prepare_for_start(app)?;
|
||||
pm.start(params)?;
|
||||
// 等待 API 就绪
|
||||
if !wait_for_mihomo_api(&mihomo, 10_000).await {
|
||||
return Err("mihomo API 启动超时".into());
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 获取节点列表
|
||||
let proxies = mihomo.get_proxies().await?;
|
||||
let (group, nodes, _now) = parse_main_group(&proxies)
|
||||
.ok_or_else(|| "无法解析代理组".to_string())?;
|
||||
|
||||
if !nodes.is_empty() {
|
||||
// 3. 并行测试所有节点延迟
|
||||
let best = test_and_select_best(&mihomo, &group, &nodes).await;
|
||||
|
||||
// 4. 发送通知
|
||||
match &best {
|
||||
Some((name, delay)) => {
|
||||
send_notification(
|
||||
"代理已开启",
|
||||
&format!("当前节点: {} ({}ms)", name, delay),
|
||||
);
|
||||
}
|
||||
None => {
|
||||
send_notification("代理已开启", "所有节点均超时,未自动选择");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
send_notification("代理已开启", "无可用节点");
|
||||
}
|
||||
|
||||
// 5. 开启系统代理
|
||||
mihomo.enable_system_proxy()?;
|
||||
|
||||
// 6. 重建菜单
|
||||
rebuild_menu(app).await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn disable_proxy(app: &AppHandle) -> Result<(), String> {
|
||||
let mihomo = app.state::<MihomoManager>();
|
||||
let pm = app.state::<ProcessManager>();
|
||||
|
||||
// 关闭系统代理
|
||||
mihomo.disable_system_proxy()?;
|
||||
|
||||
// 停止 mihomo
|
||||
pm.stop("proxy")?;
|
||||
|
||||
send_notification("代理已关闭", "mihomo 已停止,系统代理已清除");
|
||||
|
||||
// 重建菜单
|
||||
rebuild_menu(app).await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ===== 择优选择节点 =====
|
||||
|
||||
/// 并行测试所有节点延迟,选择最低延迟节点
|
||||
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
|
||||
}
|
||||
|
||||
/// 测试所有节点延迟后重建菜单
|
||||
async fn test_all_delays_and_rebuild(app: &AppHandle) {
|
||||
let mihomo = app.state::<MihomoManager>();
|
||||
let proxies = match mihomo.get_proxies().await {
|
||||
Ok(p) => p,
|
||||
Err(_) => {
|
||||
return;
|
||||
}
|
||||
};
|
||||
let (_group, nodes, _) = match parse_main_group(&proxies) {
|
||||
Some(g) => g,
|
||||
None => return,
|
||||
};
|
||||
|
||||
// 并行测试延迟(更新 mihomo 内部 history)
|
||||
use futures_util::future::join_all;
|
||||
let futures: Vec<_> = nodes
|
||||
.iter()
|
||||
.map(|name| async {
|
||||
let _ = mihomo
|
||||
.test_delay(name, "https://www.gstatic.com/generate_204", 5000)
|
||||
.await;
|
||||
})
|
||||
.collect();
|
||||
join_all(futures).await;
|
||||
|
||||
// 再次获取 proxies(此时 history 已更新)并重建菜单
|
||||
rebuild_menu(app).await;
|
||||
}
|
||||
|
||||
// ===== 等待 mihomo API 就绪 =====
|
||||
|
||||
async fn wait_for_mihomo_api(mihomo: &MihomoManager, timeout_ms: u64) -> bool {
|
||||
let start = std::time::Instant::now();
|
||||
while start.elapsed().as_millis() < timeout_ms as u128 {
|
||||
if mihomo.get_version().await.is_ok() {
|
||||
return true;
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
// ===== 重启 Kernel =====
|
||||
|
||||
async fn restart_kernel(app: &AppHandle) -> Result<(), String> {
|
||||
let monitor = app.state::<MonitorKernel>();
|
||||
let pm = app.state::<ProcessManager>();
|
||||
|
||||
// 停止当前 Kernel
|
||||
monitor.stop_subscription(app).await;
|
||||
let _ = pm.stop("monitor");
|
||||
|
||||
// 等待端口释放
|
||||
tokio::time::sleep(std::time::Duration::from_millis(800)).await;
|
||||
|
||||
// 重新启动
|
||||
monitor.start_with_subscription(app).await?;
|
||||
|
||||
send_notification("Kernel 已重启", "ThingHK 监控内核已重新启动");
|
||||
rebuild_menu(app).await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ===== Windows 通知 =====
|
||||
|
||||
#[cfg(windows)]
|
||||
fn send_notification(title: &str, message: &str) {
|
||||
use std::os::windows::process::CommandExt;
|
||||
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
||||
|
||||
// 转义 PowerShell 单引号
|
||||
let title_escaped = title.replace('\'', "''");
|
||||
let msg_escaped = message.replace('\'', "''").replace('\n', "`n");
|
||||
|
||||
let script = format!(
|
||||
"[System.Reflection.Assembly]::LoadWithPartialName('System.Windows.Forms')|Out-Null;\
|
||||
$n=New-Object System.Windows.Forms.NotifyIcon;\
|
||||
$n.Icon=[System.Drawing.SystemIcons]::Information;\
|
||||
$n.Visible=$true;\
|
||||
$n.BalloonTipTitle='{}';\
|
||||
$n.BalloonTipText='{}';\
|
||||
$n.ShowBalloonTip(5000);\
|
||||
Start-Sleep -Seconds 6;\
|
||||
$n.Dispose()",
|
||||
title_escaped, msg_escaped
|
||||
);
|
||||
|
||||
let _ = std::process::Command::new("powershell")
|
||||
.args([
|
||||
"-NoProfile",
|
||||
"-NonInteractive",
|
||||
"-WindowStyle",
|
||||
"Hidden",
|
||||
"-Command",
|
||||
&script,
|
||||
])
|
||||
.creation_flags(CREATE_NO_WINDOW)
|
||||
.spawn();
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn send_notification(_title: &str, _message: &str) {
|
||||
// 非 Windows 平台不显示通知
|
||||
}
|
||||
|
||||
// ===== 创建托盘 =====
|
||||
|
||||
/// 创建自定义托盘菜单(在 setup 中调用)
|
||||
pub fn create_tray_menu(app: &AppHandle) -> Result<(), tauri::Error> {
|
||||
let proxy_running = is_proxy_running(app);
|
||||
let monitor_running = is_monitor_running(app);
|
||||
|
||||
// 初始菜单(不含代理节点,异步获取后替换)
|
||||
let menu = build_menu(app, proxy_running, monitor_running, None)?;
|
||||
|
||||
let _tray = TrayIconBuilder::with_id("main")
|
||||
.icon(app.default_window_icon().unwrap().clone())
|
||||
.tooltip("Thing")
|
||||
.menu(&menu)
|
||||
.show_menu_on_left_click(false)
|
||||
.on_menu_event(handle_menu_event)
|
||||
.on_tray_icon_event(|tray, event| {
|
||||
let app = tray.app_handle().clone();
|
||||
match event {
|
||||
TrayIconEvent::Click {
|
||||
button: MouseButton::Left,
|
||||
..
|
||||
} => {
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
window.show().ok();
|
||||
window.set_focus().ok();
|
||||
}
|
||||
}
|
||||
TrayIconEvent::Enter { .. } => {
|
||||
// 鼠标进入托盘区域时预刷新菜单
|
||||
tauri::async_runtime::spawn(async move {
|
||||
rebuild_menu(&app).await;
|
||||
});
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
})
|
||||
.build(app)?;
|
||||
|
||||
// 异步获取代理节点并重建菜单
|
||||
let app_clone = app.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
rebuild_menu(&app_clone).await;
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 退出应用时调用(与 quit_app 命令共享清理逻辑)
|
||||
pub fn quit_cleanup(app: &AppHandle) {
|
||||
if let Some(mihomo) = app.try_state::<MihomoManager>() {
|
||||
mihomo.cleanup_on_exit();
|
||||
}
|
||||
if let Some(engine) = app.try_state::<DownloadEngine>() {
|
||||
engine.cleanup_on_exit();
|
||||
}
|
||||
if let Some(monitor) = app.try_state::<MonitorKernel>() {
|
||||
tauri::async_runtime::block_on(monitor.cleanup_on_exit(app));
|
||||
}
|
||||
if let Some(clip) = app.try_state::<ClipboardManager>() {
|
||||
clip.stop();
|
||||
}
|
||||
if let Some(pm) = app.try_state::<ProcessManager>() {
|
||||
pm.stop_all();
|
||||
}
|
||||
app.exit(0);
|
||||
}
|
||||
+50
-1
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, shallowRef, computed, watch, type Component } from 'vue'
|
||||
import { ref, onMounted, onUnmounted, shallowRef, computed, watch, type Component } from 'vue'
|
||||
import { listen, type UnlistenFn } from '@tauri-apps/api/event'
|
||||
import TitleBar from '@/components/layout/TitleBar.vue'
|
||||
import Sidebar from '@/components/layout/Sidebar.vue'
|
||||
import ModuleContainer from '@/components/layout/ModuleContainer.vue'
|
||||
@@ -8,6 +9,7 @@ import { useAppStore } from '@/stores/appStore'
|
||||
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||
import { moduleRegistry } from '@/modules/registry'
|
||||
import type { ModuleMeta } from '@/types/module'
|
||||
import { pendingNewDownload, pendingOpenSettings } from '@/lib/trayEvents'
|
||||
|
||||
const appStore = useAppStore()
|
||||
|
||||
@@ -31,6 +33,14 @@ const activeModule = ref('')
|
||||
|
||||
const activeComponent = shallowRef<Component | null>(null)
|
||||
|
||||
/** 预加载的监控模块组件(用于隐藏预渲染,确保 OSD 在启动时创建) */
|
||||
const monitorComponent = shallowRef<Component | null>(null)
|
||||
|
||||
/** 监控模块是否已启用 */
|
||||
const monitorEnabled = computed(() =>
|
||||
appStore.modules.find(m => m.id === 'monitor')?.enabled ?? false
|
||||
)
|
||||
|
||||
/** 当前可显示的模块(内置模块 + 已启用的用户模块),按用户排序显示 */
|
||||
const availableModules = computed<NavModule[]>(() => {
|
||||
const enabledIds = appStore.enabledModules.map(m => m.id)
|
||||
@@ -116,9 +126,43 @@ const resolveDefaultModule = (): string => {
|
||||
|
||||
onMounted(async () => {
|
||||
await appStore.init().catch(e => console.error('App init error:', e))
|
||||
|
||||
// 预加载监控模块组件,用于隐藏预渲染
|
||||
// 这样即使启动时默认模块不是监控,MonitorModule 的 onMounted 也会执行
|
||||
// 从而在应用启动时自动创建 OSD 窗口(如果 OSD 配置已开启)
|
||||
if (monitorEnabled.value) {
|
||||
monitorComponent.value = await moduleRegistry.loadComponent('monitor')
|
||||
}
|
||||
|
||||
const defaultModule = resolveDefaultModule()
|
||||
activeModule.value = defaultModule
|
||||
loadModule(defaultModule)
|
||||
|
||||
// 监听托盘菜单事件
|
||||
// tray:toggle-osd 由 MonitorModule 直接监听(预渲染实例始终挂载)
|
||||
trayUnlisteners.push(
|
||||
await listen('tray:new-download', () => {
|
||||
// 设置标志位,DownloaderModule 挂载后消费
|
||||
pendingNewDownload.value = true
|
||||
// 切换到下载模块(如果未启用则切换到设置)
|
||||
const enabledIds = appStore.enabledModules.map(m => m.id)
|
||||
if (enabledIds.includes('downloader') || moduleRegistry.getConfig('downloader')?.builtin) {
|
||||
handleModuleChange('downloader')
|
||||
}
|
||||
})
|
||||
)
|
||||
trayUnlisteners.push(
|
||||
await listen('tray:open-settings', () => {
|
||||
pendingOpenSettings.value = true
|
||||
handleModuleChange('settings')
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
const trayUnlisteners: UnlistenFn[] = []
|
||||
|
||||
onUnmounted(() => {
|
||||
trayUnlisteners.forEach(fn => fn())
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -136,5 +180,10 @@ onMounted(async () => {
|
||||
</div>
|
||||
</div>
|
||||
<Toaster position="bottom-right" rich-colors close-button :style="{ zIndex: 99999 }" />
|
||||
<!-- 预渲染监控模块(隐藏):确保 OSD 窗口在应用启动时创建,不依赖用户切换到监控模块。
|
||||
当 activeModule === 'monitor' 时不渲染(由 ModuleContainer 正常渲染),避免重复实例 -->
|
||||
<div v-if="monitorComponent && monitorEnabled && activeModule !== 'monitor'" style="display:none">
|
||||
<component :is="monitorComponent" />
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</template>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { Search, Settings, ChevronRight } from '@lucide/vue'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { getCurrentWindow } from '@tauri-apps/api/window'
|
||||
import { useSearchStore, type SearchItem } from '@/stores/searchStore'
|
||||
import { useModuleTabsStore } from '@/stores/moduleTabsStore'
|
||||
@@ -146,6 +147,10 @@ onMounted(async () => {
|
||||
} catch {
|
||||
// 非 Tauri 环境忽略
|
||||
}
|
||||
// 延迟修复 snap-layout 子窗口背景(等待插件初始化创建子窗口)
|
||||
setTimeout(() => {
|
||||
invoke('fix_snap_background').catch(() => {})
|
||||
}, 1000)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
/**
|
||||
* 托盘菜单触发的待处理动作
|
||||
*
|
||||
* Rust 后端通过 Tauri 事件通知前端执行模块切换 / 对话框打开等操作。
|
||||
* 由于目标模块可能尚未挂载(延迟加载),使用 ref 作为共享标志位,
|
||||
* 模块在 onMounted 中检查并消费。
|
||||
*/
|
||||
|
||||
/** 待打开新建下载对话框(由托盘"新建下载"触发) */
|
||||
export const pendingNewDownload = ref(false)
|
||||
|
||||
/** 待切换到设置模块(由托盘"常规设置"触发) */
|
||||
export const pendingOpenSettings = ref(false)
|
||||
+11
-5
@@ -16,15 +16,21 @@ window.addEventListener('unhandledrejection', (event) => {
|
||||
})
|
||||
|
||||
// ===== OSD 窗口模式检测 =====
|
||||
// 通过 URL hash 识别:#osd-overlay(悬浮窗)
|
||||
// OSD 窗口是精简的独立 Vue 应用,不加载主应用的 store 和模块
|
||||
const osdHash = window.location.hash
|
||||
if (osdHash === '#osd-overlay') {
|
||||
logger.info(`OSD 窗口启动: ${osdHash}`)
|
||||
// 通过 URL hash 识别:#osd-overlay(悬浮窗)/ #clipboard-popup(剪贴板快捷弹窗)
|
||||
// 这些窗口是精简的独立 Vue 应用,不加载主应用的 store 和模块
|
||||
const winHash = window.location.hash
|
||||
if (winHash === '#osd-overlay') {
|
||||
logger.info(`OSD 窗口启动: ${winHash}`)
|
||||
void import('./modules/monitor/OsdWindow.vue').then(({ default: OsdWindow }) => {
|
||||
const app = createApp(OsdWindow)
|
||||
app.mount('#app')
|
||||
})
|
||||
} else if (winHash === '#clipboard-popup') {
|
||||
logger.info(`剪贴板弹窗窗口启动: ${winHash}`)
|
||||
void import('./modules/clipboard/ClipboardPopup.vue').then(({ default: ClipboardPopup }) => {
|
||||
const app = createApp(ClipboardPopup)
|
||||
app.mount('#app')
|
||||
})
|
||||
} else {
|
||||
// ===== 主应用模式 =====
|
||||
void import('./App.vue').then(async ({ default: App }) => {
|
||||
|
||||
@@ -1,24 +1,479 @@
|
||||
<script setup lang="ts">
|
||||
import { ClipboardList } from '@lucide/vue'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
||||
import {
|
||||
ClipboardList, Copy, Pin, PinOff, Trash2, Search, Image as ImageIcon,
|
||||
FileText, Files, Settings as SettingsIcon, Loader2, Eraser, Check, Keyboard,
|
||||
ChevronLeft, ChevronRight,
|
||||
} from '@lucide/vue'
|
||||
import { toast } from 'vue-sonner'
|
||||
import { useClipboardStore, type ClipboardItem, type ClipboardKind } from '@/stores/clipboardStore'
|
||||
import { useModuleTabs } from '@/lib/useModuleTabs'
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription,
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription,
|
||||
AlertDialogFooter, AlertDialogHeader, AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
|
||||
const store = useClipboardStore()
|
||||
|
||||
const activeTab = ref('history')
|
||||
const tabsListRef = useModuleTabs(activeTab, [
|
||||
{ value: 'history', label: '历史' },
|
||||
{ value: 'pinned', label: '固定' },
|
||||
{ value: 'settings', label: '设置' },
|
||||
])
|
||||
|
||||
// 历史搜索/过滤
|
||||
const searchQuery = ref('')
|
||||
const kindFilter = ref<'all' | ClipboardKind>('all')
|
||||
const PAGE_SIZE = 50
|
||||
const currentPage = ref(1)
|
||||
let searchTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const totalPages = computed(() =>
|
||||
Math.max(1, Math.ceil(store.historyTotal / PAGE_SIZE))
|
||||
)
|
||||
|
||||
const loadPage = async () => {
|
||||
if (searchQuery.value.trim()) {
|
||||
await store.searchPage(searchQuery.value.trim(), currentPage.value, PAGE_SIZE)
|
||||
} else {
|
||||
await store.fetchHistoryPage({ kind: kindFilter.value, page: currentPage.value, pageSize: PAGE_SIZE })
|
||||
}
|
||||
}
|
||||
|
||||
watch([searchQuery, kindFilter], () => {
|
||||
// 过滤/搜索变化时回到第一页
|
||||
currentPage.value = 1
|
||||
if (searchTimer) clearTimeout(searchTimer)
|
||||
searchTimer = setTimeout(loadPage, 300)
|
||||
})
|
||||
|
||||
const gotoPage = async (p: number) => {
|
||||
currentPage.value = Math.min(Math.max(1, p), totalPages.value)
|
||||
await loadPage()
|
||||
}
|
||||
|
||||
// 详情弹窗
|
||||
const detailOpen = ref(false)
|
||||
const detailLoading = ref(false)
|
||||
const detail = ref<ReturnType<typeof Object> & { content?: string | null; imageBase64?: string | null; kind?: string } | null>(null)
|
||||
const openDetail = async (item: ClipboardItem) => {
|
||||
detailOpen.value = true
|
||||
detailLoading.value = true
|
||||
detail.value = null
|
||||
const d = await store.getItem(item.id)
|
||||
detail.value = d
|
||||
detailLoading.value = false
|
||||
}
|
||||
|
||||
// 清空确认
|
||||
const clearOpen = ref(false)
|
||||
|
||||
// 设置表单(本地副本,保存时提交)
|
||||
const form = ref({ ...store.settings })
|
||||
watch(() => store.settings, (s) => { form.value = { ...s } }, { deep: true })
|
||||
|
||||
const handleEnabledToggle = async (val: boolean) => {
|
||||
form.value.enabled = val
|
||||
try {
|
||||
await store.saveSettings({ ...form.value })
|
||||
toast.success(val ? '已开启剪贴板监听' : '已停止剪贴板监听')
|
||||
} catch {
|
||||
toast.error('切换监听失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleSaveSettings = async () => {
|
||||
try {
|
||||
await store.saveSettings({ ...form.value })
|
||||
toast.success('设置已保存')
|
||||
} catch {
|
||||
toast.error('保存设置失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleShortcutChange = async () => {
|
||||
// 快捷键变化立即保存并注册(不等点击"保存设置")
|
||||
try {
|
||||
await store.saveSettings({ ...form.value })
|
||||
toast.success(`快捷键已更新为 ${form.value.shortcut || '(已禁用)'}`)
|
||||
} catch {
|
||||
toast.error('快捷键注册失败,可能被其他程序占用')
|
||||
}
|
||||
}
|
||||
|
||||
const handleTestPopup = async () => {
|
||||
try {
|
||||
await store.showPopup()
|
||||
} catch {
|
||||
toast.error('打开弹窗失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 操作
|
||||
const handleCopy = async (item: ClipboardItem) => {
|
||||
try {
|
||||
await store.copyBack(item.id)
|
||||
toast.success('已复制到剪贴板')
|
||||
} catch {
|
||||
toast.error('复制失败')
|
||||
}
|
||||
}
|
||||
const handlePin = async (item: ClipboardItem) => {
|
||||
await store.setPinned(item.id, !item.pinned)
|
||||
toast.success(item.pinned ? '已取消固定' : '已固定')
|
||||
// 固定/取消后当前页数据变化,重新加载
|
||||
await loadPage()
|
||||
}
|
||||
const handleDelete = async (item: ClipboardItem) => {
|
||||
await store.remove(item.id)
|
||||
toast.success('已删除')
|
||||
// 删除后总数可能变化,若当前页已空则回退一页
|
||||
if (!store.history.length && currentPage.value > 1) {
|
||||
currentPage.value -= 1
|
||||
}
|
||||
await loadPage()
|
||||
}
|
||||
const handleClear = async () => {
|
||||
clearOpen.value = false
|
||||
await store.clear()
|
||||
currentPage.value = 1
|
||||
await loadPage()
|
||||
toast.success('已清空历史')
|
||||
}
|
||||
|
||||
// 显示辅助
|
||||
const kindIcon = (k: ClipboardKind) => {
|
||||
if (k === 'text') return FileText
|
||||
if (k === 'image') return ImageIcon
|
||||
return Files
|
||||
}
|
||||
const kindLabel = (k: ClipboardKind) => (k === 'text' ? '文本' : k === 'image' ? '图片' : '文件')
|
||||
const kindBadgeClass = (k: ClipboardKind) =>
|
||||
k === 'text'
|
||||
? 'border-blue-500/40 bg-blue-500/10 text-blue-600 dark:text-blue-400'
|
||||
: k === 'image'
|
||||
? 'border-emerald-500/40 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400'
|
||||
: 'border-amber-500/40 bg-amber-500/10 text-amber-600 dark:text-amber-400'
|
||||
const formatSize = (bytes: number) => {
|
||||
if (!bytes) return ''
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
|
||||
}
|
||||
const formatTime = (ms: number) => {
|
||||
const diff = Date.now() - ms
|
||||
if (diff < 60_000) return '刚刚'
|
||||
if (diff < 3600_000) return `${Math.floor(diff / 60_000)} 分钟前`
|
||||
if (diff < 86_400_000) return `${Math.floor(diff / 3600_000)} 小时前`
|
||||
if (diff < 7 * 86_400_000) return `${Math.floor(diff / 86_400_000)} 天前`
|
||||
const d = new Date(ms)
|
||||
return `${d.getMonth() + 1}/${d.getDate()} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
const historyList = computed(() => store.history)
|
||||
const pinnedList = computed(() => store.pinned)
|
||||
|
||||
onMounted(async () => {
|
||||
await store.init()
|
||||
await Promise.all([loadPage(), store.refreshPinned()])
|
||||
form.value = { ...store.settings }
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
store.dispose()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-full p-6 overflow-y-auto">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle class="flex items-center gap-2">
|
||||
<ClipboardList class="h-5 w-5 text-primary" />
|
||||
剪贴板增强模块
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div class="flex flex-col items-center justify-center h-64 text-muted-foreground">
|
||||
<ClipboardList class="h-16 w-16 mb-4 opacity-50" />
|
||||
<p>剪贴板增强功能开发中...</p>
|
||||
<p class="text-sm mt-2">支持剪贴板历史记录、搜索、固定常用条目等功能</p>
|
||||
<div class="h-full p-6 overflow-hidden flex flex-col">
|
||||
<Tabs v-model="activeTab" class="flex-1 min-h-0 flex flex-col">
|
||||
<div ref="tabsListRef" class="shrink-0">
|
||||
<TabsList class="grid w-full max-w-md grid-cols-3 !bg-transparent !p-0 !shadow-none">
|
||||
<TabsTrigger value="history" class="gap-1.5"><ClipboardList class="size-3.5" />历史</TabsTrigger>
|
||||
<TabsTrigger value="pinned" class="gap-1.5"><Pin class="size-3.5" />固定</TabsTrigger>
|
||||
<TabsTrigger value="settings" class="gap-1.5"><SettingsIcon class="size-3.5" />设置</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
<!-- 历史 -->
|
||||
<TabsContent value="history" class="flex-1 min-h-0 flex flex-col mt-4 tab-animate">
|
||||
<div class="flex items-center gap-2 mb-3 shrink-0">
|
||||
<div class="relative flex-1 max-w-sm">
|
||||
<Search class="absolute left-2.5 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input v-model="searchQuery" placeholder="搜索历史..." class="pl-8" />
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<Button
|
||||
v-for="k in (['all','text','image','files'] as const)"
|
||||
:key="k"
|
||||
:variant="kindFilter === k ? 'default' : 'outline'"
|
||||
size="sm"
|
||||
@click="kindFilter = k"
|
||||
>
|
||||
{{ k === 'all' ? '全部' : k === 'text' ? '文本' : k === 'image' ? '图片' : '文件' }}
|
||||
</Button>
|
||||
</div>
|
||||
<div class="flex-1" />
|
||||
<Badge variant="secondary">
|
||||
<Loader2 v-if="store.status.running" class="h-3 w-3 mr-1 animate-spin" />
|
||||
{{ store.historyTotal }} 条
|
||||
</Badge>
|
||||
<Button variant="outline" size="sm" @click="clearOpen = true" :disabled="!historyList.length">
|
||||
<Eraser class="h-4 w-4 mr-1" />清空
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div class="flex-1 min-h-0 overflow-y-auto space-y-1.5 pr-1">
|
||||
<div
|
||||
v-if="!historyList.length"
|
||||
class="flex flex-col items-center justify-center h-full text-muted-foreground"
|
||||
>
|
||||
<ClipboardList class="h-12 w-12 mb-3 opacity-40" />
|
||||
<p class="text-sm">暂无历史记录,复制内容后将自动收录</p>
|
||||
</div>
|
||||
<Card
|
||||
v-for="item in historyList"
|
||||
:key="item.id"
|
||||
class="group hover:shadow-md transition-shadow py-0"
|
||||
>
|
||||
<CardContent class="flex items-center gap-3 px-3 py-2">
|
||||
<component :is="kindIcon(item.kind)" class="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
<div class="flex-1 min-w-0 cursor-pointer" @click="openDetail(item)">
|
||||
<p class="text-sm break-all line-clamp-1" :title="item.preview">{{ item.preview }}</p>
|
||||
<div class="flex items-center gap-2 mt-0.5 text-xs text-muted-foreground flex-wrap">
|
||||
<Badge variant="outline" :class="['px-1.5 py-0 text-[10px] border', kindBadgeClass(item.kind)]">{{ kindLabel(item.kind) }}</Badge>
|
||||
<span>{{ formatTime(item.createdAt) }}</span>
|
||||
<span v-if="item.size">· {{ formatSize(item.size) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-0.5 shrink-0 opacity-0 group-hover:opacity-100 transition">
|
||||
<Button variant="ghost" size="icon" class="h-7 w-7" title="复制" @click.stop="handleCopy(item)">
|
||||
<Copy class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" class="h-7 w-7" :title="item.pinned ? '取消固定' : '固定'" @click.stop="handlePin(item)">
|
||||
<component :is="item.pinned ? PinOff : Pin" class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" class="h-7 w-7 hover:text-destructive" title="删除" @click.stop="handleDelete(item)">
|
||||
<Trash2 class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div v-if="totalPages > 1" class="flex items-center justify-center gap-2 pt-3 shrink-0">
|
||||
<Button variant="outline" size="icon" class="h-7 w-7" :disabled="currentPage <= 1" @click="gotoPage(currentPage - 1)">
|
||||
<ChevronLeft class="h-4 w-4" />
|
||||
</Button>
|
||||
<span class="text-xs text-muted-foreground tabular-nums">{{ currentPage }} / {{ totalPages }}</span>
|
||||
<Button variant="outline" size="icon" class="h-7 w-7" :disabled="currentPage >= totalPages" @click="gotoPage(currentPage + 1)">
|
||||
<ChevronRight class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<!-- 固定 -->
|
||||
<TabsContent value="pinned" class="flex-1 min-h-0 flex flex-col mt-4 tab-animate">
|
||||
<div class="flex-1 min-h-0 overflow-y-auto space-y-1.5 pr-1">
|
||||
<div
|
||||
v-if="!pinnedList.length"
|
||||
class="flex flex-col items-center justify-center h-full text-muted-foreground"
|
||||
>
|
||||
<Pin class="h-12 w-12 mb-3 opacity-40" />
|
||||
<p class="text-sm">暂无固定条目</p>
|
||||
<p class="text-xs mt-1">鼠标悬停历史条目,点击图钉按钮即可固定</p>
|
||||
</div>
|
||||
<Card v-for="item in pinnedList" :key="item.id" class="group hover:shadow-md transition-shadow py-0">
|
||||
<CardContent class="flex items-center gap-3 px-3 py-2">
|
||||
<component :is="kindIcon(item.kind)" class="h-4 w-4 text-primary shrink-0" />
|
||||
<div class="flex-1 min-w-0 cursor-pointer" @click="openDetail(item)">
|
||||
<p class="text-sm break-all line-clamp-1" :title="item.preview">{{ item.preview }}</p>
|
||||
<div class="flex items-center gap-2 mt-0.5 text-xs text-muted-foreground flex-wrap">
|
||||
<Badge variant="outline" :class="['px-1.5 py-0 text-[10px] border', kindBadgeClass(item.kind)]">{{ kindLabel(item.kind) }}</Badge>
|
||||
<span>{{ formatTime(item.createdAt) }}</span>
|
||||
<span v-if="item.size">· {{ formatSize(item.size) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-0.5 shrink-0 opacity-0 group-hover:opacity-100 transition">
|
||||
<Button variant="ghost" size="icon" class="h-7 w-7" title="复制" @click.stop="handleCopy(item)">
|
||||
<Copy class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" class="h-7 w-7" title="取消固定" @click.stop="handlePin(item)">
|
||||
<PinOff class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" class="h-7 w-7 hover:text-destructive" title="删除" @click.stop="handleDelete(item)">
|
||||
<Trash2 class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<!-- 设置 -->
|
||||
<TabsContent value="settings" class="flex-1 min-h-0 overflow-y-auto mt-4 tab-animate">
|
||||
<div class="max-w-xl space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-base font-medium flex items-center gap-2">
|
||||
<SettingsIcon class="h-4 w-4" />基本设置
|
||||
</h3>
|
||||
<Button @click="handleSaveSettings"><Check class="h-4 w-4 mr-1" />保存设置</Button>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardContent class="space-y-4 p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<Label>启用剪贴板监听</Label>
|
||||
<p class="text-xs text-muted-foreground mt-1">关闭后将停止记录剪贴板内容</p>
|
||||
</div>
|
||||
<Switch
|
||||
:model-value="form.enabled"
|
||||
@update:model-value="handleEnabledToggle"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<Label>记录文本</Label>
|
||||
<p class="text-xs text-muted-foreground mt-1">收录复制/剪切的文本</p>
|
||||
</div>
|
||||
<Switch :model-value="form.recordText" @update:model-value="form.recordText = $event" />
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<Label>记录图片</Label>
|
||||
<p class="text-xs text-muted-foreground mt-1">收录复制的图片(截图等)</p>
|
||||
</div>
|
||||
<Switch :model-value="form.recordImage" @update:model-value="form.recordImage = $event" />
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<Label>记录文件</Label>
|
||||
<p class="text-xs text-muted-foreground mt-1">收录复制的文件列表</p>
|
||||
</div>
|
||||
<Switch :model-value="form.recordFiles" @update:model-value="form.recordFiles = $event" />
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<Label>内容去重</Label>
|
||||
<p class="text-xs text-muted-foreground mt-1">相同内容只保留一条,重复复制将置顶</p>
|
||||
</div>
|
||||
<Switch :model-value="form.dedup" @update:model-value="form.dedup = $event" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent class="space-y-4 p-4">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<Label>最大历史条数</Label>
|
||||
<Input v-model.number="form.maxItems" type="number" min="50" max="10000" />
|
||||
<p class="text-xs text-muted-foreground">超出后自动清理最旧的非固定项</p>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label>图片大小上限 (KB)</Label>
|
||||
<Input v-model.number="form.maxImageKb" type="number" min="0" />
|
||||
<p class="text-xs text-muted-foreground">0 表示不限制</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent class="space-y-4 p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<Label class="flex items-center gap-2">
|
||||
<Keyboard class="h-4 w-4" />快捷弹窗快捷键
|
||||
</Label>
|
||||
<p class="text-xs text-muted-foreground mt-1">全局快捷键触发鼠标位置历史弹窗,留空禁用</p>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" @click="handleTestPopup">测试弹窗</Button>
|
||||
</div>
|
||||
<Input
|
||||
v-model="form.shortcut"
|
||||
placeholder="如 Alt+V / Ctrl+Shift+V"
|
||||
@change="handleShortcutChange"
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground">格式:修饰键+主键,如 Ctrl+C、Alt+V、Shift+F1。修改后自动生效。</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<!-- 详情弹窗 -->
|
||||
<Dialog v-model:open="detailOpen">
|
||||
<DialogContent class="max-w-2xl max-h-[80vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>内容详情</DialogTitle>
|
||||
<DialogDescription>{{ detail ? kindLabel(detail.kind as ClipboardKind) : '' }}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div class="flex-1 min-h-0 overflow-auto">
|
||||
<div v-if="detailLoading" class="flex items-center justify-center py-12">
|
||||
<Loader2 class="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
<template v-else-if="detail">
|
||||
<img
|
||||
v-if="detail.kind === 'image' && detail.imageBase64"
|
||||
:src="`data:image/png;base64,${detail.imageBase64}`"
|
||||
class="max-w-full max-h-[60vh] mx-auto rounded"
|
||||
alt="剪贴板图片"
|
||||
/>
|
||||
<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>
|
||||
<ul v-else-if="detail.kind === 'files'" class="space-y-1 text-sm">
|
||||
<li
|
||||
v-for="(p, i) in (parseFiles(detail.content))"
|
||||
:key="i"
|
||||
class="font-mono break-all p-2 rounded bg-muted/50"
|
||||
>{{ p }}</li>
|
||||
</ul>
|
||||
</template>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<!-- 清空确认 -->
|
||||
<AlertDialog v-model:open="clearOpen">
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>清空历史记录?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
将删除所有非固定历史条目,此操作不可撤销。
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||
<AlertDialogAction class="bg-destructive text-destructive-foreground hover:bg-destructive/90" @click="handleClear">
|
||||
清空
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
function parseFiles(content: string | null): string[] {
|
||||
if (!content) return []
|
||||
try {
|
||||
return JSON.parse(content) as string[]
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
export default {}
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,633 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted, nextTick, watch } from 'vue'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { listen, type UnlistenFn } from '@tauri-apps/api/event'
|
||||
import { getCurrentWindow } from '@tauri-apps/api/window'
|
||||
import { Effect, EffectState } from '@tauri-apps/api/window'
|
||||
import {
|
||||
ClipboardList, Pin, PinOff, Trash2, Search, Image as ImageIcon,
|
||||
FileText, Files, Loader2, ChevronLeft, ChevronRight,
|
||||
} from '@lucide/vue'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
|
||||
// ===== 与 Rust 端对应的数据结构(camelCase) =====
|
||||
type ClipboardKind = 'text' | 'image' | 'files'
|
||||
interface ClipboardItem {
|
||||
id: number
|
||||
kind: ClipboardKind
|
||||
preview: string
|
||||
size: number
|
||||
pinned: boolean
|
||||
pinnedOrder: number | null
|
||||
createdAt: number
|
||||
}
|
||||
interface HistoryPage {
|
||||
items: ClipboardItem[]
|
||||
total: number
|
||||
}
|
||||
|
||||
// ===== 状态 =====
|
||||
const items = ref<ClipboardItem[]>([])
|
||||
const total = ref(0)
|
||||
const currentPage = ref(1)
|
||||
const PAGE_SIZE = 50
|
||||
const searchQuery = ref('')
|
||||
const selectedIndex = ref(0)
|
||||
const loading = ref(false)
|
||||
const searchInputRef = ref<HTMLInputElement | null>(null)
|
||||
let unlistenFns: UnlistenFn[] = []
|
||||
let searchTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / PAGE_SIZE)))
|
||||
|
||||
// ===== 数据加载 =====
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const q = searchQuery.value.trim()
|
||||
const offset = (currentPage.value - 1) * PAGE_SIZE
|
||||
let res: HistoryPage
|
||||
if (q) {
|
||||
res = await invoke<HistoryPage>('clipboard_search', { query: q, limit: PAGE_SIZE, offset })
|
||||
} else {
|
||||
res = await invoke<HistoryPage>('clipboard_get_history', { limit: PAGE_SIZE, offset, kind: 'all' })
|
||||
}
|
||||
items.value = res.items
|
||||
total.value = res.total
|
||||
selectedIndex.value = 0
|
||||
} catch (e) {
|
||||
console.error('[clipboard-popup] 加载失败:', e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
await nextTick()
|
||||
scrollSelectedIntoView()
|
||||
}
|
||||
|
||||
async function gotoPage(p: number) {
|
||||
currentPage.value = Math.min(Math.max(1, p), totalPages.value)
|
||||
await loadData()
|
||||
}
|
||||
|
||||
// 防抖搜索
|
||||
watch(searchQuery, () => {
|
||||
currentPage.value = 1
|
||||
if (searchTimer) clearTimeout(searchTimer)
|
||||
searchTimer = setTimeout(loadData, 200)
|
||||
})
|
||||
|
||||
// ===== 选择与粘贴 =====
|
||||
/// 选中条目 → 写回剪贴板 → 隐藏窗口 → 模拟 Ctrl+V 粘贴到原窗口
|
||||
async function selectAndPaste(item: ClipboardItem) {
|
||||
try {
|
||||
await invoke('clipboard_copy_back', { id: item.id })
|
||||
// paste_to_target 会先隐藏窗口,再延迟模拟 Ctrl+V
|
||||
await invoke('clipboard_paste_to_target')
|
||||
} catch (e) {
|
||||
console.error('[clipboard-popup] 粘贴失败:', e)
|
||||
// 失败时至少隐藏窗口
|
||||
await hideWindow()
|
||||
}
|
||||
}
|
||||
|
||||
async function togglePin(item: ClipboardItem, ev: Event) {
|
||||
ev.stopPropagation()
|
||||
try {
|
||||
await invoke('clipboard_set_pinned', { id: item.id, pinned: !item.pinned })
|
||||
await loadData()
|
||||
} catch (e) {
|
||||
console.error('[clipboard-popup] 固定失败:', e)
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteItem(item: ClipboardItem, ev: Event) {
|
||||
ev.stopPropagation()
|
||||
try {
|
||||
await invoke('clipboard_delete', { id: item.id })
|
||||
items.value = items.value.filter((i) => i.id !== item.id)
|
||||
} catch (e) {
|
||||
console.error('[clipboard-popup] 删除失败:', e)
|
||||
}
|
||||
}
|
||||
|
||||
async function hideWindow() {
|
||||
try {
|
||||
await invoke('clipboard_hide_popup')
|
||||
} catch {
|
||||
/* 忽略 */
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 键盘导航 =====
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
selectedIndex.value = Math.min(selectedIndex.value + 1, items.value.length - 1)
|
||||
scrollSelectedIntoView()
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
selectedIndex.value = Math.max(selectedIndex.value - 1, 0)
|
||||
scrollSelectedIntoView()
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
const item = items.value[selectedIndex.value]
|
||||
if (item) selectAndPaste(item)
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
hideWindow()
|
||||
}
|
||||
}
|
||||
|
||||
function scrollSelectedIntoView() {
|
||||
nextTick(() => {
|
||||
const el = document.querySelector('.popup-item-selected') as HTMLElement | null
|
||||
el?.scrollIntoView({ block: 'nearest' })
|
||||
})
|
||||
}
|
||||
|
||||
// ===== 显示辅助 =====
|
||||
const kindIcon = (k: ClipboardKind) => {
|
||||
if (k === 'text') return FileText
|
||||
if (k === 'image') return ImageIcon
|
||||
return Files
|
||||
}
|
||||
const kindLabel = (k: ClipboardKind) => (k === 'text' ? '文本' : k === 'image' ? '图片' : '文件')
|
||||
const kindBadgeClass = (k: ClipboardKind) =>
|
||||
k === 'text'
|
||||
? 'badge-text'
|
||||
: k === 'image'
|
||||
? 'badge-image'
|
||||
: 'badge-files'
|
||||
const formatTime = (ms: number) => {
|
||||
const diff = Date.now() - ms
|
||||
if (diff < 60_000) return '刚刚'
|
||||
if (diff < 3600_000) return `${Math.floor(diff / 60_000)}分钟前`
|
||||
if (diff < 86_400_000) return `${Math.floor(diff / 3600_000)}小时前`
|
||||
const d = new Date(ms)
|
||||
return `${d.getMonth() + 1}/${d.getDate()}`
|
||||
}
|
||||
|
||||
const hasItems = computed(() => items.value.length > 0)
|
||||
|
||||
// ===== 主题应用(与主应用同步) =====
|
||||
/** 从 localStorage 读取主应用的主题设置 */
|
||||
function readMainTheme(): { theme: string; effect: string } {
|
||||
try {
|
||||
const raw = localStorage.getItem('thing_app_settings')
|
||||
if (raw) {
|
||||
const s = JSON.parse(raw)
|
||||
return {
|
||||
theme: s.theme ?? 'system',
|
||||
effect: s.effect ?? 'mica',
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* 忽略 */
|
||||
}
|
||||
return { theme: 'system', effect: 'mica' }
|
||||
}
|
||||
|
||||
/** 判断当前是否应为深色主题。
|
||||
* 弹窗是独立窗口,主应用的 setTheme 不影响弹窗的 matchMedia,
|
||||
* 因此 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
|
||||
}
|
||||
|
||||
/** 应用主题和窗口效果(与主应用同步)。
|
||||
* 关键:先 setTheme 让窗口主题正确,再用通用 Effect.Mica(自动跟随窗口主题深浅)。 */
|
||||
async function applyTheme() {
|
||||
const root = document.documentElement
|
||||
const { theme, effect } = readMainTheme()
|
||||
|
||||
// 1. 先设置窗口原生主题(system → null 跟随系统)
|
||||
try {
|
||||
const tauriWin = getCurrentWindow()
|
||||
if (theme === 'system') {
|
||||
await tauriWin.setTheme(null)
|
||||
} else {
|
||||
await tauriWin.setTheme(theme as 'dark' | 'light')
|
||||
}
|
||||
} catch {
|
||||
/* 非 Tauri 环境忽略 */
|
||||
}
|
||||
|
||||
// 2. 窗口主题已正确,用 matchMedia 判断深浅(弹窗自身不受主应用污染)
|
||||
const isDark = resolveIsDark(theme)
|
||||
|
||||
// 3. 设置 DOM class
|
||||
root.classList.remove('dark', 'effect-mica', 'effect-acrylic', 'effect-normal')
|
||||
root.classList.add(`effect-${effect}`)
|
||||
if (isDark) root.classList.add('dark')
|
||||
|
||||
// 4. 设置窗口效果与背景色
|
||||
// 弹窗窗口创建时 transparent=true,透明窗口下原生背景色不显示,
|
||||
// 需在实际可见的 DOM 元素(.popup-root)上设置背景色。
|
||||
// 用 CSS 变量 --popup-bg 控制,mica/acrylic 模式下保持透明。
|
||||
try {
|
||||
const tauriWin = getCurrentWindow()
|
||||
await tauriWin.clearEffects()
|
||||
if (effect === 'mica') {
|
||||
await tauriWin.setEffects({
|
||||
effects: [Effect.Mica],
|
||||
state: EffectState.FollowsWindowActiveState,
|
||||
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.FollowsWindowActiveState,
|
||||
color: isDark ? [30, 41, 59, 0] : [248, 250, 252, 0],
|
||||
})
|
||||
await tauriWin.setBackgroundColor('#00000000')
|
||||
root.style.setProperty('--popup-bg', 'transparent')
|
||||
} else {
|
||||
// 普通模式:透明窗口下原生背景不显示,由 DOM 提供背景色
|
||||
await tauriWin.setBackgroundColor(isDark ? '#0f172a' : '#ffffff')
|
||||
root.style.setProperty('--popup-bg', isDark ? '#0f172a' : '#ffffff')
|
||||
}
|
||||
} catch {
|
||||
/* 非 Tauri 环境忽略 */
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
// 先应用主题(含窗口效果)
|
||||
await applyTheme()
|
||||
|
||||
// 监听系统主题变化(仅在 system 模式下有意义)
|
||||
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('clipboard-popup-show', async () => {
|
||||
// 主应用可能切换了主题,每次显示前重新应用
|
||||
await applyTheme()
|
||||
searchQuery.value = ''
|
||||
currentPage.value = 1
|
||||
await loadData()
|
||||
await nextTick()
|
||||
searchInputRef.value?.focus()
|
||||
}))
|
||||
|
||||
// 加载初始数据
|
||||
await loadData()
|
||||
await nextTick()
|
||||
searchInputRef.value?.focus()
|
||||
|
||||
// 主题和数据都就绪后,调用 Rust 端显示窗口
|
||||
try {
|
||||
await invoke('clipboard_show_window')
|
||||
} catch {
|
||||
/* 忽略 */
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
unlistenFns.forEach((fn) => fn())
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="popup-root flex flex-col h-screen w-screen" @keydown="onKeydown">
|
||||
<!-- 搜索栏 -->
|
||||
<div class="popup-header shrink-0">
|
||||
<div class="search-wrap">
|
||||
<Search class="h-4 w-4 text-muted-foreground" />
|
||||
<input
|
||||
ref="searchInputRef"
|
||||
v-model="searchQuery"
|
||||
placeholder="搜索剪贴板历史..."
|
||||
class="search-input"
|
||||
spellcheck="false"
|
||||
/>
|
||||
</div>
|
||||
<span class="popup-count">{{ total }} 条</span>
|
||||
</div>
|
||||
|
||||
<!-- 列表 -->
|
||||
<ScrollArea class="popup-list flex-1 min-h-0">
|
||||
<div v-if="loading && !hasItems" class="popup-empty">
|
||||
<Loader2 class="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
<div v-else-if="!hasItems" class="popup-empty">
|
||||
<ClipboardList class="h-10 w-10 mb-2 opacity-40" />
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{{ searchQuery ? '无匹配结果' : '暂无历史记录' }}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
v-for="(item, idx) in items"
|
||||
:key="item.id"
|
||||
class="popup-item"
|
||||
:class="{ 'popup-item-selected': idx === selectedIndex }"
|
||||
@click="selectAndPaste(item)"
|
||||
@mouseenter="selectedIndex = idx"
|
||||
>
|
||||
<component :is="kindIcon(item.kind)" class="popup-item-icon" />
|
||||
<div class="popup-item-body">
|
||||
<p class="popup-item-preview">{{ item.preview }}</p>
|
||||
<div class="popup-item-meta">
|
||||
<span class="popup-item-kind" :class="kindBadgeClass(item.kind)">{{ kindLabel(item.kind) }}</span>
|
||||
<span>{{ formatTime(item.createdAt) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="popup-item-actions">
|
||||
<button class="popup-action-btn" title="固定" @click="togglePin(item, $event)">
|
||||
<component :is="item.pinned ? PinOff : Pin" class="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button class="popup-action-btn hover:text-destructive" title="删除" @click="deleteItem(item, $event)">
|
||||
<Trash2 class="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div v-if="totalPages > 1" class="popup-pagination">
|
||||
<button class="popup-page-btn" :disabled="currentPage <= 1" @click="gotoPage(currentPage - 1)">
|
||||
<ChevronLeft class="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<span class="popup-page-info">{{ currentPage }} / {{ totalPages }}</span>
|
||||
<button class="popup-page-btn" :disabled="currentPage >= totalPages" @click="gotoPage(currentPage + 1)">
|
||||
<ChevronRight class="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 底部提示 -->
|
||||
<div class="popup-footer shrink-0">
|
||||
<span><kbd>↑</kbd><kbd>↓</kbd> 导航</span>
|
||||
<span><kbd>Enter</kbd> 粘贴</span>
|
||||
<span><kbd>Esc</kbd> 关闭</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 弹窗根容器:背景色由 --popup-bg 控制(mica/acrylic 透明,普通模式不透明) */
|
||||
.popup-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;
|
||||
}
|
||||
|
||||
/* 搜索栏 */
|
||||
.popup-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.search-wrap {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: var(--input);
|
||||
border-radius: 6px;
|
||||
padding: 6px 10px;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
flex: 1;
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
color: var(--foreground);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.search-input::placeholder {
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.popup-count {
|
||||
font-size: 11px;
|
||||
color: var(--muted-foreground);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 列表 */
|
||||
.popup-list {
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
/* reka-ui ScrollAreaViewport 内部会出现一个 div,需保证高度撑满 */
|
||||
.popup-list :deep([data-slot="scroll-area-viewport"] > div) {
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.popup-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
/* 预留边框空间,避免 hover/选中时布局抖动 */
|
||||
border: 1px solid transparent;
|
||||
/* 统一 hover 和 selected 为同一高亮效果,避免错乱 */
|
||||
transition: background-color 0.1s, border-color 0.1s;
|
||||
}
|
||||
|
||||
/* 鼠标悬停和键盘选中统一使用 accent 高亮 + 反色边框增强提醒 */
|
||||
.popup-item:hover,
|
||||
.popup-item-selected {
|
||||
background: var(--accent);
|
||||
border-color: var(--foreground);
|
||||
}
|
||||
|
||||
.popup-item-icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
margin-top: 1px;
|
||||
color: var(--muted-foreground);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.popup-item-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.popup-item-preview {
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.popup-item-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 3px;
|
||||
font-size: 11px;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.popup-item-kind {
|
||||
background: var(--secondary);
|
||||
color: var(--secondary-foreground);
|
||||
padding: 0 5px;
|
||||
border-radius: 3px;
|
||||
font-size: 10px;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
/* 类型 badge 配色(与主界面一致) */
|
||||
.popup-item-kind.badge-text {
|
||||
background: rgba(59, 130, 246, 0.12);
|
||||
color: rgb(37, 99, 235);
|
||||
border-color: rgba(59, 130, 246, 0.4);
|
||||
}
|
||||
.popup-item-kind.badge-image {
|
||||
background: rgba(16, 185, 129, 0.12);
|
||||
color: rgb(5, 150, 105);
|
||||
border-color: rgba(16, 185, 129, 0.4);
|
||||
}
|
||||
.popup-item-kind.badge-files {
|
||||
background: rgba(245, 158, 11, 0.12);
|
||||
color: rgb(217, 119, 6);
|
||||
border-color: rgba(245, 158, 11, 0.4);
|
||||
}
|
||||
|
||||
/* 深色主题下调整 badge 文字色 */
|
||||
.dark .popup-item-kind.badge-text {
|
||||
color: rgb(96, 165, 250);
|
||||
}
|
||||
.dark .popup-item-kind.badge-image {
|
||||
color: rgb(52, 211, 153);
|
||||
}
|
||||
.dark .popup-item-kind.badge-files {
|
||||
color: rgb(251, 191, 36);
|
||||
}
|
||||
|
||||
.popup-item-actions {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.1s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 选中项和悬停项都显示操作按钮 */
|
||||
.popup-item:hover .popup-item-actions,
|
||||
.popup-item-selected .popup-item-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.popup-action-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
border-radius: 4px;
|
||||
color: var(--muted-foreground);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.popup-action-btn:hover {
|
||||
background: var(--muted);
|
||||
}
|
||||
|
||||
/* 空状态 */
|
||||
.popup-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
min-height: 200px;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
/* 底部 */
|
||||
.popup-footer {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
padding: 6px 12px;
|
||||
border-top: 1px solid var(--border);
|
||||
font-size: 11px;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
/* 分页 */
|
||||
.popup-pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 4px 12px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.popup-page-btn {
|
||||
background: transparent;
|
||||
border: 1px solid var(--border);
|
||||
cursor: pointer;
|
||||
padding: 3px;
|
||||
border-radius: 4px;
|
||||
color: var(--foreground);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.popup-page-btn:hover:not(:disabled) {
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.popup-page-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.popup-page-info {
|
||||
font-size: 11px;
|
||||
color: var(--muted-foreground);
|
||||
font-variant-numeric: tabular-nums;
|
||||
min-width: 50px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.popup-footer kbd {
|
||||
background: var(--muted);
|
||||
color: var(--foreground);
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
font-size: 10px;
|
||||
margin-right: 2px;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
/* 滚动条 */
|
||||
.popup-list::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.popup-list::-webkit-scrollbar-thumb {
|
||||
background: var(--muted-foreground);
|
||||
opacity: 0.3;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.popup-list::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
</style>
|
||||
@@ -11,9 +11,10 @@ import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { toast } from 'vue-sonner'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { open as openDialog } from '@tauri-apps/plugin-dialog'
|
||||
import { useDownloaderStore, type DownloadTask, type TaskStatus } from '@/stores/downloaderStore'
|
||||
import { useDownloaderStore, type DownloadTask, type TaskStatus, type CheckUrlResult } from '@/stores/downloaderStore'
|
||||
import { useModuleTabs } from '@/lib/useModuleTabs'
|
||||
import { createLogger } from '@/lib/logger'
|
||||
import { pendingNewDownload } from '@/lib/trayEvents'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
@@ -257,6 +258,15 @@ const handleOpenDir = async (task: DownloadTask) => {
|
||||
}
|
||||
|
||||
// ===== 添加下载 =====
|
||||
/** 重复确认对话框状态 */
|
||||
const duplicateDialogState = ref<{
|
||||
open: boolean
|
||||
url: string
|
||||
result: CheckUrlResult | null
|
||||
pendingUrls: string[]
|
||||
currentIndex: number
|
||||
}>({ open: false, url: '', result: null, pendingUrls: [], currentIndex: 0 })
|
||||
|
||||
const handleAddDownload = async () => {
|
||||
const text = addUriText.value.trim()
|
||||
if (!text) {
|
||||
@@ -272,23 +282,23 @@ const handleAddDownload = async () => {
|
||||
addingTask.value = true
|
||||
try {
|
||||
const dir = addDir.value.trim() || undefined
|
||||
let successCount = 0
|
||||
for (const uri of uris) {
|
||||
try {
|
||||
await store.addTask(uri, undefined, dir)
|
||||
successCount++
|
||||
} catch (e) {
|
||||
logger.error(`添加 ${uri} 失败: ` + e)
|
||||
}
|
||||
}
|
||||
if (successCount > 0) {
|
||||
toast.success(`已添加 ${successCount} 个下载任务`)
|
||||
addUriText.value = ''
|
||||
addDir.value = ''
|
||||
addDialogOpen.value = false
|
||||
activeTab.value = 'tasks'
|
||||
// 检查是否启用重复检查
|
||||
const checkEnabled = store.settings?.checkDuplicate ?? true
|
||||
if (checkEnabled) {
|
||||
// 逐个检查重复
|
||||
await processUrlsWithCheck(uris, dir)
|
||||
} else {
|
||||
toast.error('添加任务失败')
|
||||
// 直接添加
|
||||
let successCount = 0
|
||||
for (const uri of uris) {
|
||||
try {
|
||||
await store.addTask(uri, undefined, dir, undefined, true)
|
||||
successCount++
|
||||
} catch (e) {
|
||||
logger.error(`添加 ${uri} 失败: ` + e)
|
||||
}
|
||||
}
|
||||
finishAdd(successCount)
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error('添加失败: ' + e)
|
||||
@@ -297,6 +307,146 @@ const handleAddDownload = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
/** 逐个检查 URL 重复性,发现重复时弹出确认对话框 */
|
||||
async function processUrlsWithCheck(urls: string[], dir: string | undefined) {
|
||||
let successCount = 0
|
||||
for (let i = 0; i < urls.length; i++) {
|
||||
const uri = urls[i]
|
||||
try {
|
||||
const result = await store.checkUrl(uri, dir)
|
||||
if (!result.ok) {
|
||||
// 探测失败:直接添加(后端会标记为 error)
|
||||
try {
|
||||
await store.addTask(uri, undefined, dir, undefined, true)
|
||||
successCount++
|
||||
} catch (e) {
|
||||
logger.error(`添加 ${uri} 失败: ` + e)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (result.duplicate === 'none') {
|
||||
// 无重复:直接添加(自动重命名以防磁盘文件冲突)
|
||||
try {
|
||||
await store.addTask(uri, undefined, dir, undefined, true)
|
||||
successCount++
|
||||
} catch (e) {
|
||||
logger.error(`添加 ${uri} 失败: ` + e)
|
||||
}
|
||||
continue
|
||||
}
|
||||
// 发现重复:弹出确认对话框,暂停处理
|
||||
duplicateDialogState.value = {
|
||||
open: true,
|
||||
url: uri,
|
||||
result,
|
||||
pendingUrls: urls,
|
||||
currentIndex: i,
|
||||
}
|
||||
return // 等待用户确认后继续
|
||||
} catch (e) {
|
||||
logger.error(`检查 ${uri} 失败: ` + e)
|
||||
// 检查失败:直接添加
|
||||
try {
|
||||
await store.addTask(uri, undefined, dir, undefined, true)
|
||||
successCount++
|
||||
} catch (e2) {
|
||||
logger.error(`添加 ${uri} 失败: ` + e2)
|
||||
}
|
||||
}
|
||||
}
|
||||
finishAdd(successCount)
|
||||
}
|
||||
|
||||
/** 完成添加:显示提示并关闭对话框 */
|
||||
function finishAdd(successCount: number) {
|
||||
if (successCount > 0) {
|
||||
toast.success(`已添加 ${successCount} 个下载任务`)
|
||||
addUriText.value = ''
|
||||
addDir.value = ''
|
||||
addDialogOpen.value = false
|
||||
activeTab.value = 'tasks'
|
||||
} else {
|
||||
toast.error('添加任务失败')
|
||||
}
|
||||
}
|
||||
|
||||
/** 重复确认对话框:用户选择"仍然下载"(自动重命名) */
|
||||
const onDuplicateConfirm = async () => {
|
||||
const { url, pendingUrls, currentIndex } = duplicateDialogState.value
|
||||
const dir = addDir.value.trim() || undefined
|
||||
duplicateDialogState.value.open = false
|
||||
|
||||
let successCount = 0
|
||||
try {
|
||||
await store.addTask(url, undefined, dir, undefined, true) // autoRename=true
|
||||
successCount++
|
||||
} catch (e) {
|
||||
logger.error(`添加 ${url} 失败: ` + e)
|
||||
}
|
||||
|
||||
// 继续处理剩余 URL
|
||||
const remaining = pendingUrls.slice(currentIndex + 1)
|
||||
for (const uri of remaining) {
|
||||
try {
|
||||
const result = await store.checkUrl(uri, dir)
|
||||
if (result.ok && result.duplicate !== 'none') {
|
||||
// 又发现重复,再次弹出确认
|
||||
duplicateDialogState.value = {
|
||||
open: true,
|
||||
url: uri,
|
||||
result,
|
||||
pendingUrls,
|
||||
currentIndex: pendingUrls.indexOf(uri),
|
||||
}
|
||||
// 已添加的成功数通过闭包传递不太方便,直接在这里 finish 后再继续
|
||||
// 简化处理:保存 successCount 到 state,下一次确认时累加
|
||||
duplicateSuccessCount.value = successCount
|
||||
return
|
||||
}
|
||||
await store.addTask(uri, undefined, dir, undefined, true)
|
||||
successCount++
|
||||
} catch (e) {
|
||||
logger.error(`添加 ${uri} 失败: ` + e)
|
||||
}
|
||||
}
|
||||
finishAdd(successCount)
|
||||
}
|
||||
|
||||
/** 累积的成功计数(跨多次重复确认) */
|
||||
const duplicateSuccessCount = ref(0)
|
||||
|
||||
/** 重复确认对话框:用户选择"跳过" */
|
||||
const onDuplicateSkip = () => {
|
||||
const { pendingUrls, currentIndex } = duplicateDialogState.value
|
||||
duplicateDialogState.value.open = false
|
||||
const remaining = pendingUrls.slice(currentIndex + 1)
|
||||
if (remaining.length > 0) {
|
||||
// 继续处理剩余 URL(不添加当前这个)
|
||||
const dir = addDir.value.trim() || undefined
|
||||
processUrlsWithCheck(remaining, dir).then(() => {})
|
||||
} else {
|
||||
finishAdd(duplicateSuccessCount.value)
|
||||
duplicateSuccessCount.value = 0
|
||||
}
|
||||
}
|
||||
|
||||
/** 重复确认对话框:用户取消整个添加流程 */
|
||||
const onDuplicateCancel = () => {
|
||||
duplicateDialogState.value.open = false
|
||||
finishAdd(duplicateSuccessCount.value)
|
||||
duplicateSuccessCount.value = 0
|
||||
}
|
||||
|
||||
/** 重复类型文本 */
|
||||
const duplicateKindText = (kind: string): string => {
|
||||
switch (kind) {
|
||||
case 'url': return 'URL 已存在下载任务'
|
||||
case 'filename': return '同名文件已在下载列表中'
|
||||
case 'fileExists': return '目标目录已存在同名文件'
|
||||
default: return '重复'
|
||||
}
|
||||
}
|
||||
|
||||
const handleSelectDir = async () => {
|
||||
try {
|
||||
const selected = await openDialog({ directory: true, multiple: false })
|
||||
@@ -367,6 +517,11 @@ const handleCopy = async (text: string, label: string) => {
|
||||
|
||||
onMounted(async () => {
|
||||
await store.init()
|
||||
// 消费托盘菜单"新建下载"标志位
|
||||
if (pendingNewDownload.value) {
|
||||
pendingNewDownload.value = false
|
||||
addDialogOpen.value = true
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
@@ -458,8 +613,8 @@ const toggleSortOrder = () => {
|
||||
<TabsContent value="tasks" class="flex-1 mt-4 min-h-0 tab-animate">
|
||||
<div class="h-full flex flex-col gap-4">
|
||||
<!-- 状态栏 -->
|
||||
<Card class="shrink-0">
|
||||
<CardContent class="pl-4 pr-4">
|
||||
<Card class="shrink-0 !py-0 !gap-0">
|
||||
<CardContent class="pl-4 pr-4 py-3">
|
||||
<div class="flex items-center justify-between flex-wrap gap-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex items-center gap-2">
|
||||
@@ -611,8 +766,8 @@ const toggleSortOrder = () => {
|
||||
</Button>
|
||||
</div>
|
||||
<div v-else key="task-list" class="flex flex-col gap-3 pb-4">
|
||||
<Card v-for="task in pagedTasks" :key="task.id" class="overflow-hidden">
|
||||
<CardContent class="pl-4 pr-4">
|
||||
<Card v-for="task in pagedTasks" :key="task.id" class="overflow-hidden !py-0 !gap-0">
|
||||
<CardContent class="pl-4 pr-4 py-3">
|
||||
<div class="flex items-start justify-between gap-3 mb-3">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
@@ -893,6 +1048,17 @@ const toggleSortOrder = () => {
|
||||
@update:model-value="(v: boolean) => store.settings && (store.settings.deleteFilesOnRemove = v)"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<Label for="check-duplicate" class="cursor-pointer">重复下载检查</Label>
|
||||
<span class="text-xs text-muted-foreground">添加下载前检查 URL 和文件名重复,发现重复时询问</span>
|
||||
</div>
|
||||
<Switch
|
||||
id="check-duplicate"
|
||||
:model-value="store.settings?.checkDuplicate ?? true"
|
||||
@update:model-value="(v: boolean) => store.settings && (store.settings.checkDuplicate = v)"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -1218,6 +1384,39 @@ const toggleSortOrder = () => {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<!-- ===== 重复下载确认弹窗 ===== -->
|
||||
<AlertDialog :open="duplicateDialogState.open">
|
||||
<AlertDialogContent class="max-w-md">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle class="flex items-center gap-2">
|
||||
<AlertCircle class="h-4 w-4 text-amber-500" />
|
||||
下载重复提醒
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription class="text-xs">
|
||||
<span class="block mb-2">
|
||||
{{ duplicateKindText(duplicateDialogState.result?.duplicate ?? '') }}
|
||||
</span>
|
||||
<span class="block font-mono text-xs break-all">{{ duplicateDialogState.url }}</span>
|
||||
<span v-if="duplicateDialogState.result?.existing" class="block mt-2 text-xs">
|
||||
已存在:
|
||||
<span class="font-medium">{{ duplicateDialogState.result.existing.filename }}</span>
|
||||
</span>
|
||||
<span class="block mt-2 text-muted-foreground">
|
||||
选择"仍然下载"将自动重命名(追加序号),选择"跳过"将不下载此链接。
|
||||
</span>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel @click="onDuplicateCancel">取消全部</AlertDialogCancel>
|
||||
<Button variant="outline" @click="onDuplicateSkip">跳过此链接</Button>
|
||||
<AlertDialogAction @click="onDuplicateConfirm">
|
||||
<Download class="h-4 w-4" />
|
||||
仍然下载
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
<!-- ===== 删除任务专用对话框(带"同时删除文件"开关) ===== -->
|
||||
<AlertDialog :open="removeDialogState.open" @update:open="onRemoveOpenChange">
|
||||
<AlertDialogContent>
|
||||
|
||||
@@ -13,7 +13,7 @@ import { VueDraggable } from 'vue-draggable-plus'
|
||||
import { appDataDir } from '@tauri-apps/api/path'
|
||||
import { revealItemInDir } from '@tauri-apps/plugin-opener'
|
||||
import { WebviewWindow } from '@tauri-apps/api/webviewWindow'
|
||||
import { emit, type UnlistenFn } from '@tauri-apps/api/event'
|
||||
import { emit, listen, type UnlistenFn } from '@tauri-apps/api/event'
|
||||
import { currentMonitor, LogicalPosition, LogicalSize } from '@tauri-apps/api/window'
|
||||
import { useMonitorStore, type SensorEntry, type SensorGroup, type ConnectionState } from '@/stores/monitorStore'
|
||||
import { useModuleTabs } from '@/lib/useModuleTabs'
|
||||
@@ -1493,20 +1493,12 @@ async function resetOverlayPosition() {
|
||||
} catch { /* 忽略 */ }
|
||||
}
|
||||
|
||||
/** 关闭所有 OSD 窗口(组件卸载时调用) */
|
||||
async function closeAllOsdWindows() {
|
||||
try {
|
||||
const w = await WebviewWindow.getByLabel(OSD_OVERLAY_LABEL)
|
||||
if (w) await w.close()
|
||||
} catch {
|
||||
/* 忽略 */
|
||||
}
|
||||
}
|
||||
|
||||
// ===== OSD 窗口事件监听 =====
|
||||
let osdEventUnlisteners: UnlistenFn[] = []
|
||||
|
||||
async function setupOsdEventListeners() {
|
||||
// 守卫:避免重复注册(MonitorModule 可能因预渲染多次挂载)
|
||||
if (osdEventUnlisteners.length) return
|
||||
const { listen: tauriListen } = await import('@tauri-apps/api/event')
|
||||
// 监听悬浮窗上报的实际内容尺寸,按内容调整窗口大小(替代不准确的估算)
|
||||
// 仅当尺寸变化超过 1px 时才 setSize,避免无意义的频繁调用
|
||||
@@ -1611,15 +1603,36 @@ onMounted(async () => {
|
||||
if (osdConfig.value.overlayEnabled) {
|
||||
ensureOverlayWindow().catch(e => console.error('[OSD] 初始化悬浮窗失败:', e))
|
||||
}
|
||||
|
||||
// 监听托盘菜单"切换 OSD"事件
|
||||
try {
|
||||
osdEventUnlisteners.push(
|
||||
await listen('tray:toggle-osd', () => {
|
||||
osdConfig.value.overlayEnabled = !osdConfig.value.overlayEnabled
|
||||
saveOsdConfig(osdConfig.value)
|
||||
if (osdConfig.value.overlayEnabled) {
|
||||
if (osdConfig.value.overlayItems.length === 0) {
|
||||
toast.warning('OSD 显示项为空,已开启但未创建窗口')
|
||||
} else {
|
||||
ensureOverlayWindow().catch(e => console.error('[OSD] 托盘开启悬浮窗失败:', e))
|
||||
}
|
||||
} else {
|
||||
hideOverlayWindow().catch(e => console.error('[OSD] 托盘关闭悬浮窗失败:', e))
|
||||
}
|
||||
})
|
||||
)
|
||||
} catch (e) {
|
||||
console.error('[OSD] 注册 tray:toggle-osd 监听失败:', e)
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
store.dispose()
|
||||
// 清理 OSD 事件监听
|
||||
// 不 dispose store:SSE 订阅保持,确保切走监控模块后 OSD 仍有数据
|
||||
// store.subscribe() 内部有守卫(if unlistenFns.length return),重复 init 不会重复订阅
|
||||
// 不关闭 OSD 窗口:切走监控模块时保持 OSD 显示,由模块禁用或应用退出时统一清理
|
||||
// 仅清理组件级 OSD 事件监听(下次挂载会重新注册,setupOsdEventListeners 有守卫)
|
||||
osdEventUnlisteners.forEach(fn => fn())
|
||||
osdEventUnlisteners = []
|
||||
// 关闭悬浮窗(切走监控模块时释放 OSD 窗口)
|
||||
closeAllOsdWindows().catch(e => console.error('[OSD] 关闭窗口失败:', e))
|
||||
})
|
||||
|
||||
// 当 Kernel 从未就绪变成就绪时,主动拉一次快照填充 UI
|
||||
|
||||
@@ -44,6 +44,14 @@ export const moduleConfig: ModuleConfig = {
|
||||
} catch {
|
||||
/* 忽略:可能 Kernel 未运行 */
|
||||
}
|
||||
// 关闭 OSD 窗口(MonitorModule onUnmounted 不再自动关闭,需在禁用时手动关闭)
|
||||
try {
|
||||
const { WebviewWindow } = await import('@tauri-apps/api/webviewWindow')
|
||||
const osd = await WebviewWindow.getByLabel('osd-overlay')
|
||||
if (osd) await osd.close()
|
||||
} catch {
|
||||
/* 忽略 */
|
||||
}
|
||||
}
|
||||
},
|
||||
order: 40
|
||||
|
||||
@@ -391,6 +391,14 @@ export const useAppStore = defineStore('app', () => {
|
||||
} catch (e) {
|
||||
// 非 Tauri 环境下忽略
|
||||
}
|
||||
|
||||
// 切换效果后修复 snap-layout 子窗口背景(clearEffects 触发重绘会导致白色背景显现)
|
||||
try {
|
||||
const { invoke } = await import('@tauri-apps/api/core')
|
||||
invoke('fix_snap_background').catch(() => {})
|
||||
} catch {
|
||||
/* 非 Tauri 环境忽略 */
|
||||
}
|
||||
}
|
||||
|
||||
// 系统主题变化时,若当前为"跟随系统"模式,同步更新 DOM 和窗口效果。
|
||||
|
||||
@@ -0,0 +1,293 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { listen, type UnlistenFn } from '@tauri-apps/api/event'
|
||||
import { createLogger } from '@/lib/logger'
|
||||
|
||||
const logger = createLogger('clipboard')
|
||||
|
||||
// ===== 与 Rust 端对应的数据结构(camelCase) =====
|
||||
|
||||
export type ClipboardKind = 'text' | 'image' | 'files'
|
||||
|
||||
export interface ClipboardItem {
|
||||
id: number
|
||||
kind: ClipboardKind
|
||||
preview: string
|
||||
size: number
|
||||
pinned: boolean
|
||||
pinnedOrder: number | null
|
||||
createdAt: number
|
||||
}
|
||||
|
||||
/** 历史分页结果(与 Rust 端 HistoryPage 对应) */
|
||||
export interface HistoryPage {
|
||||
items: ClipboardItem[]
|
||||
total: number
|
||||
}
|
||||
|
||||
export interface ClipboardItemDetail extends ClipboardItem {
|
||||
content: string | null
|
||||
imageBase64: string | null
|
||||
}
|
||||
|
||||
export interface ClipboardSettings {
|
||||
enabled: boolean
|
||||
maxItems: number
|
||||
maxImageKb: number
|
||||
recordText: boolean
|
||||
recordImage: boolean
|
||||
recordFiles: boolean
|
||||
dedup: boolean
|
||||
shortcut: string
|
||||
}
|
||||
|
||||
export interface ClipboardStatus {
|
||||
running: boolean
|
||||
count: number
|
||||
}
|
||||
|
||||
const DEFAULT_SETTINGS: ClipboardSettings = {
|
||||
enabled: true,
|
||||
maxItems: 500,
|
||||
maxImageKb: 5120,
|
||||
recordText: true,
|
||||
recordImage: true,
|
||||
recordFiles: true,
|
||||
dedup: true,
|
||||
shortcut: 'Alt+V',
|
||||
}
|
||||
|
||||
export const useClipboardStore = defineStore('clipboard', () => {
|
||||
const history = ref<ClipboardItem[]>([])
|
||||
const historyTotal = ref(0)
|
||||
const pinned = ref<ClipboardItem[]>([])
|
||||
const settings = ref<ClipboardSettings>({ ...DEFAULT_SETTINGS })
|
||||
const status = ref<ClipboardStatus>({ running: false, count: 0 })
|
||||
const loading = ref(false)
|
||||
|
||||
// 事件监听(应用级单例,只注册一次)
|
||||
let changedUnlisten: UnlistenFn | null = null
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
/** 初始化:加载状态/设置,注册事件监听 */
|
||||
const init = async () => {
|
||||
try {
|
||||
const [s, st] = await Promise.all([
|
||||
invoke<ClipboardSettings>('clipboard_get_settings'),
|
||||
invoke<ClipboardStatus>('clipboard_status'),
|
||||
])
|
||||
settings.value = { ...DEFAULT_SETTINGS, ...s }
|
||||
status.value = st
|
||||
} catch (e) {
|
||||
logger.error('初始化失败: ' + e)
|
||||
}
|
||||
if (!changedUnlisten) {
|
||||
changedUnlisten = await listen('clipboard-changed', () => {
|
||||
// 防抖:短时间内多次复制只刷新一次
|
||||
if (debounceTimer) clearTimeout(debounceTimer)
|
||||
debounceTimer = setTimeout(() => {
|
||||
refreshHistory()
|
||||
refreshStatus()
|
||||
}, 250)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const dispose = () => {
|
||||
if (changedUnlisten) {
|
||||
changedUnlisten()
|
||||
changedUnlisten = null
|
||||
}
|
||||
if (debounceTimer) {
|
||||
clearTimeout(debounceTimer)
|
||||
debounceTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 查询 =====
|
||||
/** 拉取指定页的历史数据。pageSize 默认 50。 */
|
||||
const fetchHistoryPage = async (opts: {
|
||||
kind?: string
|
||||
page?: number
|
||||
pageSize?: number
|
||||
} = {}) => {
|
||||
const kind = opts.kind ?? 'all'
|
||||
const pageSize = opts.pageSize ?? 50
|
||||
const page = Math.max(1, opts.page ?? 1)
|
||||
const offset = (page - 1) * pageSize
|
||||
try {
|
||||
const res = await invoke<HistoryPage>('clipboard_get_history', {
|
||||
limit: pageSize,
|
||||
offset,
|
||||
kind,
|
||||
})
|
||||
history.value = res.items
|
||||
historyTotal.value = res.total
|
||||
} catch (e) {
|
||||
logger.error('获取历史失败: ' + e)
|
||||
}
|
||||
return history.value
|
||||
}
|
||||
|
||||
/** 兼容旧调用:拉取第一页 */
|
||||
const refreshHistory = async (kind: string = 'all') => fetchHistoryPage({ kind, page: 1 })
|
||||
|
||||
const refreshPinned = async () => {
|
||||
try {
|
||||
pinned.value = await invoke<ClipboardItem[]>('clipboard_get_pinned')
|
||||
} catch (e) {
|
||||
logger.error('获取固定条目失败: ' + e)
|
||||
}
|
||||
return pinned.value
|
||||
}
|
||||
|
||||
/** 搜索(分页)。pageSize 默认 50。 */
|
||||
const searchPage = async (query: string, page: number = 1, pageSize: number = 50) => {
|
||||
if (!query.trim()) {
|
||||
return fetchHistoryPage({ page, pageSize })
|
||||
}
|
||||
try {
|
||||
const res = await invoke<HistoryPage>('clipboard_search', {
|
||||
query,
|
||||
limit: pageSize,
|
||||
offset: (page - 1) * pageSize,
|
||||
})
|
||||
history.value = res.items
|
||||
historyTotal.value = res.total
|
||||
} catch (e) {
|
||||
logger.error('搜索失败: ' + e)
|
||||
}
|
||||
return history.value
|
||||
}
|
||||
|
||||
/** 兼容旧调用:搜索第一页 */
|
||||
const search = async (query: string) => searchPage(query, 1)
|
||||
|
||||
const getItem = async (id: number) => {
|
||||
try {
|
||||
return await invoke<ClipboardItemDetail | null>('clipboard_get_item', { id })
|
||||
} catch (e) {
|
||||
logger.error('获取详情失败: ' + e)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const refreshStatus = async () => {
|
||||
try {
|
||||
status.value = await invoke<ClipboardStatus>('clipboard_status')
|
||||
} catch (e) {
|
||||
logger.error('获取状态失败: ' + e)
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 操作 =====
|
||||
const setPinned = async (id: number, pinned: boolean) => {
|
||||
try {
|
||||
await invoke('clipboard_set_pinned', { id, pinned })
|
||||
// 固定/取消后刷新两个列表
|
||||
await Promise.all([refreshHistory(), refreshPinned()])
|
||||
} catch (e) {
|
||||
logger.error('切换固定失败: ' + e)
|
||||
}
|
||||
}
|
||||
|
||||
const remove = async (id: number) => {
|
||||
try {
|
||||
await invoke('clipboard_delete', { id })
|
||||
history.value = history.value.filter((i) => i.id !== id)
|
||||
pinned.value = pinned.value.filter((i) => i.id !== id)
|
||||
status.value.count = Math.max(0, status.value.count - 1)
|
||||
} catch (e) {
|
||||
logger.error('删除失败: ' + e)
|
||||
}
|
||||
}
|
||||
|
||||
const clear = async () => {
|
||||
try {
|
||||
await invoke('clipboard_clear')
|
||||
history.value = []
|
||||
await refreshStatus()
|
||||
} catch (e) {
|
||||
logger.error('清空失败: ' + e)
|
||||
}
|
||||
}
|
||||
|
||||
const copyBack = async (id: number) => {
|
||||
await invoke('clipboard_copy_back', { id })
|
||||
// copy_back 会触发 suppress,不会产生 clipboard-changed 事件
|
||||
}
|
||||
|
||||
const saveSettings = async (s: ClipboardSettings) => {
|
||||
try {
|
||||
await invoke('clipboard_save_settings', { settings: s })
|
||||
settings.value = { ...s }
|
||||
await refreshStatus()
|
||||
} catch (e) {
|
||||
logger.error('保存设置失败: ' + e)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
const start = async () => {
|
||||
await invoke('clipboard_start')
|
||||
await refreshStatus()
|
||||
}
|
||||
|
||||
const stop = async () => {
|
||||
await invoke('clipboard_stop')
|
||||
await refreshStatus()
|
||||
}
|
||||
|
||||
// ===== 快捷弹窗 =====
|
||||
const showPopup = async () => {
|
||||
await invoke('clipboard_show_popup')
|
||||
}
|
||||
|
||||
const hidePopup = async () => {
|
||||
await invoke('clipboard_hide_popup')
|
||||
}
|
||||
|
||||
/// 隐藏弹窗并模拟 Ctrl+V 粘贴到原窗口
|
||||
const pasteToTarget = async () => {
|
||||
await invoke('clipboard_paste_to_target')
|
||||
}
|
||||
|
||||
const registerShortcut = async (shortcut: string) => {
|
||||
await invoke('clipboard_register_shortcut', { shortcut })
|
||||
}
|
||||
|
||||
const unregisterShortcut = async () => {
|
||||
await invoke('clipboard_unregister_shortcut')
|
||||
}
|
||||
|
||||
return {
|
||||
history,
|
||||
historyTotal,
|
||||
pinned,
|
||||
settings,
|
||||
status,
|
||||
loading,
|
||||
init,
|
||||
dispose,
|
||||
fetchHistoryPage,
|
||||
refreshHistory,
|
||||
refreshPinned,
|
||||
searchPage,
|
||||
search,
|
||||
getItem,
|
||||
refreshStatus,
|
||||
setPinned,
|
||||
remove,
|
||||
clear,
|
||||
copyBack,
|
||||
saveSettings,
|
||||
start,
|
||||
stop,
|
||||
showPopup,
|
||||
hidePopup,
|
||||
pasteToTarget,
|
||||
registerShortcut,
|
||||
unregisterShortcut,
|
||||
}
|
||||
})
|
||||
@@ -42,6 +42,25 @@ export interface DownloaderSettings {
|
||||
extensionPort: number
|
||||
extensionSecret: string
|
||||
deleteFilesOnRemove: boolean
|
||||
checkDuplicate: boolean
|
||||
}
|
||||
|
||||
/** 重复类型 */
|
||||
export type DuplicateKind = 'none' | 'url' | 'filename' | 'fileExists'
|
||||
|
||||
/** check_url 返回的结果 */
|
||||
export interface CheckUrlResult {
|
||||
ok: boolean
|
||||
error: string | null
|
||||
filename: string | null
|
||||
totalSize: number | null
|
||||
supportsResume: boolean
|
||||
duplicate: DuplicateKind
|
||||
existing: {
|
||||
id: string
|
||||
filename: string
|
||||
status: TaskStatus
|
||||
} | null
|
||||
}
|
||||
|
||||
export interface DownloaderStatus {
|
||||
@@ -115,18 +134,33 @@ export const useDownloaderStore = defineStore('downloader', () => {
|
||||
url: string,
|
||||
filename?: string,
|
||||
dir?: string,
|
||||
headers?: Record<string, string>
|
||||
headers?: Record<string, string>,
|
||||
autoRename = false
|
||||
): Promise<string> => {
|
||||
const id = await invoke<string>('downloader_add_task', {
|
||||
url,
|
||||
filename: filename || null,
|
||||
dir: dir || null,
|
||||
headers: headers || null
|
||||
headers: headers || null,
|
||||
autoRename
|
||||
})
|
||||
await refreshTasks()
|
||||
return id
|
||||
}
|
||||
|
||||
/** 检查 URL 重复性并探测文件信息 */
|
||||
const checkUrl = async (
|
||||
url: string,
|
||||
dir?: string,
|
||||
headers?: Record<string, string>
|
||||
): Promise<CheckUrlResult> => {
|
||||
return await invoke<CheckUrlResult>('downloader_check_url', {
|
||||
url,
|
||||
dir: dir || null,
|
||||
headers: headers || null
|
||||
})
|
||||
}
|
||||
|
||||
const pauseTask = async (id: string) => {
|
||||
await invoke('downloader_pause_task', { id })
|
||||
await refreshTasks()
|
||||
@@ -223,6 +257,7 @@ export const useDownloaderStore = defineStore('downloader', () => {
|
||||
// tasks
|
||||
refreshTasks,
|
||||
addTask,
|
||||
checkUrl,
|
||||
pauseTask,
|
||||
resumeTask,
|
||||
removeTask,
|
||||
|
||||
Reference in New Issue
Block a user