diff --git a/MODULE_DEV_GUIDE.md b/MODULE_DEV_GUIDE.md index 3865d3b..88e9829 100644 --- a/MODULE_DEV_GUIDE.md +++ b/MODULE_DEV_GUIDE.md @@ -490,3 +490,135 @@ const installKernel = async () => { TitleBar 的关闭按钮实际是 `hide()` 到托盘。webview 快速隐藏时浏览器 `mouseleave` 可能不触发,导致从托盘恢复后按钮仍显示 hover 高亮。 **修复**:`hide()` 前调用 `document.activeElement.blur()`,并监听 `onFocusChanged` 在窗口重新获得焦点时再 blur 一次。新模块若有类似的"隐藏窗口"操作(如全局快捷键隐藏),同样需要 blur。 + +### 前端:浮动标签切换器(TabsList 滚动遮挡时在 TitleBar 显示) + +模块详情页内容滚动时,顶部 `TabsList` 会被 `TitleBar` 遮挡,导致用户必须滚回顶部才能切换 Tab。已抽取通用 composable `src/lib/useModuleTabs.ts` 自动处理。 + +**接入方式**(任何使用 Tabs 的模块都可用,代理/下载器模块已接入): + +```ts +// 模块 + + diff --git a/src-tauri/resources/thing-extension/popup.js b/src-tauri/resources/thing-extension/popup.js new file mode 100644 index 0000000..b4eb036 --- /dev/null +++ b/src-tauri/resources/thing-extension/popup.js @@ -0,0 +1,96 @@ +/** + * Thing Extension - Popup 脚本 + * 配置 RPC 地址、密钥等参数,存储到 chrome.storage.local + */ + +const $ = (id) => document.getElementById(id) + +const DEFAULT_CONFIG = { + rpcUrl: 'http://127.0.0.1:6800/jsonrpc', + rpcSecret: '', + interceptDownload: true, + minSize: 0, + excludeDomains: [], + showNotifications: true +} + +function setStatus(state, text) { + const dot = $('statusDot') + const txt = $('statusText') + dot.className = 'dot ' + (state === 'ok' ? 'ok' : state === 'fail' ? 'fail' : '') + txt.textContent = text +} + +async function loadConfig() { + return new Promise((resolve) => { + chrome.runtime.sendMessage({ type: 'getConfig' }, (config) => { + resolve(config || { ...DEFAULT_CONFIG }) + }) + }) +} + +function fillForm(config) { + $('rpcUrl').value = config.rpcUrl || DEFAULT_CONFIG.rpcUrl + $('rpcSecret').value = config.rpcSecret || '' + $('interceptDownload').checked = config.interceptDownload !== false + $('showNotifications').checked = config.showNotifications !== false + $('minSize').value = config.minSize || 0 + $('excludeDomains').value = (config.excludeDomains || []).join(',') +} + +function readForm() { + return { + rpcUrl: $('rpcUrl').value.trim() || DEFAULT_CONFIG.rpcUrl, + rpcSecret: $('rpcSecret').value.trim(), + interceptDownload: $('interceptDownload').checked, + showNotifications: $('showNotifications').checked, + minSize: parseInt($('minSize').value, 10) || 0, + excludeDomains: $('excludeDomains').value + .split(',') + .map(s => s.trim()) + .filter(Boolean) + } +} + +document.getElementById('configForm').addEventListener('submit', async (e) => { + e.preventDefault() + const config = readForm() + chrome.runtime.sendMessage({ type: 'saveConfig', config }, (res) => { + if (res && res.ok) { + setStatus('', '已保存') + setTimeout(() => window.close(), 500) + } else { + setStatus('fail', '保存失败:' + (res?.error || '未知错误')) + } + }) +}) + +$('testBtn').addEventListener('click', () => { + setStatus('', '测试中...') + // 先保存当前表单值,再测试 + const config = readForm() + chrome.runtime.sendMessage({ type: 'saveConfig', config }, () => { + chrome.runtime.sendMessage({ type: 'testConnection' }, (res) => { + if (res && res.ok) { + setStatus('ok', `已连接 · aria2 ${res.version}`) + } else { + setStatus('fail', '连接失败:' + (res?.error || '未知错误')) + } + }) + }) +}) + +// 初始化 +;(async () => { + const config = await loadConfig() + fillForm(config) + // 自动测试一次连接 + setStatus('', '检测中...') + chrome.runtime.sendMessage({ type: 'testConnection' }, (res) => { + if (res && res.ok) { + setStatus('ok', `已连接 · aria2 ${res.version}`) + } else { + setStatus('fail', '未连接') + } + }) +})() diff --git a/src-tauri/src/aria2_manager.rs b/src-tauri/src/aria2_manager.rs new file mode 100644 index 0000000..21c3849 --- /dev/null +++ b/src-tauri/src/aria2_manager.rs @@ -0,0 +1,1147 @@ +use futures_util::StreamExt; +use reqwest::Client; +use serde::{Deserialize, Serialize}; +use std::fs; +use std::io::{Read, Write}; +use std::path::PathBuf; +use std::sync::atomic::{AtomicU64, Ordering}; +use tauri::{AppHandle, Emitter, Manager}; +use tauri::path::BaseDirectory; + +use crate::process_manager::{ + ProcessInfo, ProcessManager, ProcessStatus, StartProcessParams, setup_creation_flags, +}; + +// ===================== 数据结构 ===================== + +#[derive(Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct DownloaderSettings { + /// RPC 端口,默认 6800 + #[serde(default = "default_rpc_port")] + pub rpc_port: u16, + /// RPC 密钥(可空;为空时不启用 secret 鉴权) + #[serde(default)] + pub rpc_secret: String, + /// 下载目录(默认系统 Downloads 目录) + #[serde(default = "default_download_dir")] + pub download_dir: String, + /// 最大并发任务数 + #[serde(default = "default_max_concurrent")] + pub max_concurrent: u32, + /// 单服务器最大连接数 + #[serde(default = "default_max_connection_per_server")] + pub max_connection_per_server: u32, + /// 分片数 + #[serde(default = "default_split")] + pub split: u32, + /// 断点续传 + #[serde(default = "default_true")] + pub continue_download: bool, + /// 随应用启动自动启动 aria2 + #[serde(default)] + pub auto_start: bool, + /// 全局速度限制 KB/s(0=不限) + #[serde(default)] + pub speed_limit: u64, + /// 内核下载镜像源前缀列表(空串=直连 GitHub) + #[serde(default = "default_kernel_mirrors")] + pub kernel_mirrors: Vec, +} + +fn default_rpc_port() -> u16 { 6800 } +fn default_max_concurrent() -> u32 { 5 } +fn default_max_connection_per_server() -> u32 { 16 } +fn default_split() -> u32 { 16 } +fn default_true() -> bool { true } + +/// 默认下载目录:优先系统 Downloads,回退到 %USERPROFILE%\Downloads +fn default_download_dir() -> String { + dirs::download_dir() + .or_else(|| { + std::env::var("USERPROFILE") + .ok() + .map(|p| PathBuf::from(p).join("Downloads")) + }) + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_else(|| "downloads".to_string()) +} + +/// 默认镜像源:空串=直连 GitHub 优先,后续为公益镜像 +fn default_kernel_mirrors() -> Vec { + vec![ + String::new(), + "https://ghproxy.net/".into(), + "https://gh-proxy.com/".into(), + "https://ghfast.top/".into(), + ] +} + +impl Default for DownloaderSettings { + fn default() -> Self { + Self { + rpc_port: 6800, + rpc_secret: String::new(), + download_dir: default_download_dir(), + max_concurrent: 5, + max_connection_per_server: 16, + split: 16, + continue_download: true, + auto_start: false, + speed_limit: 0, + kernel_mirrors: default_kernel_mirrors(), + } + } +} + +#[derive(Serialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct KernelInfo { + pub path: String, + pub exists: bool, + pub version: Option, +} + +#[derive(Serialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct KernelUpdateInfo { + pub current_version: Option, + pub latest_version: String, + pub download_url: String, + pub has_update: bool, +} + +#[derive(Serialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct DownloaderStatus { + pub running: bool, + pub pid: Option, + pub restart_count: u32, +} + +/// 内核安装进度事件载荷 +/// - stage: downloading | extracting | replacing | done | error +#[derive(Serialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct InstallProgress { + pub stage: String, + pub percent: u8, + pub downloaded_bytes: u64, + pub total_bytes: Option, + pub message: String, +} + +/// RPC 连接信息(前端展示/复制用,浏览器扩展对接用) +#[derive(Serialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct RpcInfo { + pub rpc_url: String, + pub rpc_secret: String, + /// 内置扩展所在目录(开发阶段手动加载) + pub extension_path: Option, +} + +// ===================== Aria2Manager ===================== + +pub struct Aria2Manager { + root: PathBuf, + client: Client, + /// JSON-RPC 请求 id 自增器 + rpc_id: AtomicU64, +} + +impl Aria2Manager { + pub fn new(app_data_dir: PathBuf) -> Self { + let root = app_data_dir.join("downloader"); + for d in ["cores", "aria2", "downloads", "logs"] { + fs::create_dir_all(root.join(d)).ok(); + } + Self { + root, + client: Client::builder() + .build() + .unwrap_or_else(|_| Client::new()), + rpc_id: AtomicU64::new(1), + } + } + + fn cores_dir(&self) -> PathBuf { + self.root.join("cores") + } + pub fn kernel_path(&self) -> PathBuf { + self.cores_dir().join("aria2c.exe") + } + fn aria2_dir(&self) -> PathBuf { + self.root.join("aria2") + } + fn config_path(&self) -> PathBuf { + self.aria2_dir().join("aria2.conf") + } + fn session_path(&self) -> PathBuf { + self.aria2_dir().join("aria2.session") + } + #[allow(dead_code)] + fn logs_dir(&self) -> PathBuf { + self.root.join("logs") + } + fn settings_path(&self) -> PathBuf { + self.root.join("settings.json") + } + + // ---------- 设置 ---------- + pub fn load_settings(&self) -> DownloaderSettings { + fs::read_to_string(self.settings_path()) + .ok() + .and_then(|s| serde_json::from_str::(&s).ok()) + .unwrap_or_default() + } + + pub fn save_settings(&self, settings: &DownloaderSettings) -> Result<(), String> { + let s = serde_json::to_string_pretty(settings).map_err(|e| e.to_string())?; + fs::write(self.settings_path(), s).map_err(|e| e.to_string()) + } + + // ---------- 内核 ---------- + pub fn kernel_info(&self) -> KernelInfo { + let path = self.kernel_path(); + let exists = path.exists(); + let version = if exists { + let mut cmd = std::process::Command::new(&path); + cmd.arg("--version"); + // 隐藏控制台窗口 + setup_creation_flags(&mut cmd); + cmd.stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::null()) + .stdin(std::process::Stdio::null()); + cmd.output() + .ok() + .and_then(|o| String::from_utf8(o.stdout).ok()) + .and_then(|s| { + s.lines() + .find(|l| l.contains("aria2")) + .map(|l| l.trim().to_string()) + }) + } else { + None + }; + KernelInfo { + path: path.to_string_lossy().to_string(), + exists, + version, + } + } + + /// 确保内核就位:若 cores/ 无内核,尝试从 resource 目录复制 + pub fn prepare_kernel(&self, app: &AppHandle) -> Result { + let kernel = self.kernel_path(); + if !kernel.exists() { + if let Ok(res) = app.path().resolve("binaries/aria2c.exe", BaseDirectory::Resource) { + if res.exists() { + fs::copy(&res, &kernel).map_err(|e| format!("复制内核失败: {}", e))?; + } + } + } + Ok(self.kernel_info()) + } + + /// 检查 GitHub 上的最新 aria2 版本 + /// 策略:优先用 API,失败时回退到重定向解析(不受 rate limit 限制) + pub async fn check_kernel_update(&self) -> Result { + match self.fetch_latest_via_api().await { + Ok(info) => Ok(info), + Err(api_err) => { + eprintln!("[aria2] API 查询失败,回退到重定向解析: {}", api_err); + self.fetch_latest_via_redirect().await + } + } + } + + /// 通过 GitHub API 查询最新版本(未认证 60次/小时/IP) + async fn fetch_latest_via_api(&self) -> Result { + let resp = self + .client + .get("https://api.github.com/repos/aria2/aria2/releases/latest") + .header("User-Agent", "thing-app") + .timeout(std::time::Duration::from_secs(15)) + .send() + .await + .map_err(|e| format!("请求 GitHub API 失败: {}", e))?; + let status = resp.status(); + if !status.is_success() { + let body = resp.text().await.unwrap_or_default(); + return Err(format!( + "GitHub API 返回 HTTP {}:{}", + status.as_u16(), + if body.len() > 300 { format!("{}...", &body[..300]) } else { body } + )); + } + let resp: serde_json::Value = resp + .json() + .await + .map_err(|e| format!("解析 GitHub 响应失败: {}", e))?; + + let latest_version = resp + .get("tag_name") + .and_then(|v| v.as_str()) + .unwrap_or("unknown") + .to_string(); + + // aria2 资产命名:aria2-{version}-win-64bit-build1.zip + // tag_name 通常是 release-1.37.0,需要转换为 1.37.0 + let ver_no_prefix = latest_version + .strip_prefix("release-") + .unwrap_or(&latest_version) + .to_string(); + + let assets_arr = resp.get("assets").and_then(|a| a.as_array()); + + // 匹配 win-64bit zip(排除 32bit) + let download_url = assets_arr + .and_then(|assets| { + assets.iter().find_map(|asset| { + let name = asset.get("name")?.as_str()?; + let url = asset.get("browser_download_url")?.as_str()?; + if name.contains("win-64bit") && name.ends_with(".zip") { + Some(url.to_string()) + } else { + None + } + }) + }) + .ok_or_else(|| "API 未找到适用的 Windows 64bit 内核资产".to_string())?; + + Ok(self.build_update_info(ver_no_prefix, download_url)) + } + + /// 通过 releases/latest 重定向解析版本号 + async fn fetch_latest_via_redirect(&self) -> Result { + let resp = self + .client + .get("https://github.com/aria2/aria2/releases/latest") + .header("User-Agent", "thing-app") + .timeout(std::time::Duration::from_secs(15)) + .send() + .await + .map_err(|e| format!("请求 GitHub releases 页面失败: {}", e))?; + + let final_url = resp.url().to_string(); + // 重定向 URL 形如 .../tag/release-1.37.0 + let raw_version = final_url + .rsplit('/') + .next() + .ok_or_else(|| format!("无法从重定向 URL 提取版本号: {}", final_url))? + .to_string(); + let latest_version = raw_version + .strip_prefix("release-") + .unwrap_or(&raw_version) + .to_string(); + + // 按稳定命名规则构造下载 URL + let download_url = format!( + "https://github.com/aria2/aria2/releases/download/release-{}/aria2-{}-win-64bit-build1.zip", + latest_version, latest_version + ); + + eprintln!( + "[aria2] 重定向解析成功: version={}, url={}", + latest_version, download_url + ); + Ok(self.build_update_info(latest_version, download_url)) + } + + fn build_update_info(&self, latest_version: String, download_url: String) -> KernelUpdateInfo { + let current = self.kernel_info().version.clone(); + let has_update = match ¤t { + Some(c) => { + // 当前版本字符串形如 "aria2 version 1.37.0" 或 "1.37.0" + let cur_ver = c + .split_whitespace() + .find(|s| s.chars().any(|c| c == '.') && s.chars().any(|c| c.is_ascii_digit())) + .unwrap_or(""); + cur_ver != latest_version && !latest_version.is_empty() + } + None => true, + }; + KernelUpdateInfo { + current_version: current, + latest_version, + download_url, + has_update, + } + } + + /// 下载并安装内核(首次安装与更新共用此方法) + pub async fn install_kernel( + &self, + app: &AppHandle, + mirror_prefix: String, + ) -> Result { + let result = self.install_kernel_inner(app, mirror_prefix).await; + if let Err(ref e) = result { + let _ = app.emit( + "downloader-kernel-install-progress", + InstallProgress { + stage: "error".into(), + percent: 0, + downloaded_bytes: 0, + total_bytes: None, + message: e.clone(), + }, + ); + } + result + } + + async fn install_kernel_inner( + &self, + app: &AppHandle, + mirror_prefix: String, + ) -> Result { + let info = self.check_kernel_update().await?; + let zip_path = self.cores_dir().join("aria2-update.zip"); + let extract_dir = self.cores_dir().join("aria2-update-tmp"); + + let url = if mirror_prefix.is_empty() { + info.download_url.clone() + } else { + format!("{}{}", mirror_prefix, info.download_url) + }; + let label = if mirror_prefix.is_empty() { + "GitHub 直连".to_string() + } else { + mirror_prefix.clone() + }; + let _ = app.emit( + "downloader-kernel-install-progress", + InstallProgress { + stage: "downloading".into(), + percent: 0, + downloaded_bytes: 0, + total_bytes: None, + message: format!("正在下载:{}", label), + }, + ); + + match self.download_with_progress(app, &url, &zip_path).await { + Ok(()) => {} + Err(e) => { + let msg = format!("下载失败({}):{}", label, e); + let _ = app.emit( + "downloader-kernel-install-progress", + InstallProgress { + stage: "error".into(), + percent: 0, + downloaded_bytes: 0, + total_bytes: None, + message: msg.clone(), + }, + ); + let _ = fs::remove_file(&zip_path); + return Err(msg); + } + } + + // 解压阶段 + let _ = app.emit( + "downloader-kernel-install-progress", + InstallProgress { + stage: "extracting".into(), + percent: 92, + downloaded_bytes: 0, + total_bytes: None, + message: "正在解压...".into(), + }, + ); + if extract_dir.exists() { + fs::remove_dir_all(&extract_dir).ok(); + } + fs::create_dir_all(&extract_dir).map_err(|e| e.to_string())?; + if let Err(e) = self.extract_zip(&zip_path, &extract_dir) { + let _ = app.emit( + "downloader-kernel-install-progress", + InstallProgress { + stage: "error".into(), + percent: 0, + downloaded_bytes: 0, + total_bytes: None, + message: format!("解压失败:{}", e), + }, + ); + return Err(e); + } + + // 在解压目录中递归查找 .exe 文件 + let new_exe = self + .find_exe_in_dir(&extract_dir) + .ok_or_else(|| "解压后未找到任何 .exe 文件".to_string())?; + + // 替换阶段 + let _ = app.emit( + "downloader-kernel-install-progress", + InstallProgress { + stage: "replacing".into(), + percent: 96, + downloaded_bytes: 0, + total_bytes: None, + message: "正在安装...".into(), + }, + ); + let kernel = self.kernel_path(); + if kernel.exists() { + let bak = self.cores_dir().join("aria2c.exe.bak"); + fs::remove_file(&bak).ok(); + fs::rename(&kernel, &bak).map_err(|e| format!("备份旧内核失败: {}", e))?; + } + fs::rename(&new_exe, &kernel).map_err(|e| format!("替换内核失败: {}", e))?; + + // 清理临时文件 + fs::remove_file(&zip_path).ok(); + fs::remove_dir_all(&extract_dir).ok(); + + let final_info = self.kernel_info(); + let _ = app.emit( + "downloader-kernel-install-progress", + InstallProgress { + stage: "done".into(), + percent: 100, + downloaded_bytes: 0, + total_bytes: None, + message: format!( + "安装完成 ({})", + final_info.version.as_deref().unwrap_or("unknown") + ), + }, + ); + Ok(final_info) + } + + /// 流式下载并实时推送进度事件 + async fn download_with_progress( + &self, + app: &AppHandle, + url: &str, + dest: &PathBuf, + ) -> Result<(), String> { + let resp = self + .client + .get(url) + .header("User-Agent", "thing-app") + .timeout(std::time::Duration::from_secs(30)) + .send() + .await + .map_err(|e| format!("请求失败: {}", e))?; + if !resp.status().is_success() { + return Err(format!("HTTP {}", resp.status())); + } + let total = resp.content_length(); + let mut stream = resp.bytes_stream(); + let mut file = fs::File::create(dest).map_err(|e| format!("创建文件失败: {}", e))?; + let mut downloaded: u64 = 0; + let mut last_percent: u8 = 0; + while let Some(chunk) = stream.next().await { + let chunk = chunk.map_err(|e| format!("读取流失败: {}", e))?; + file.write_all(&chunk).map_err(|e| format!("写入文件失败: {}", e))?; + downloaded += chunk.len() as u64; + // 下载占总进度的 0-90% + let percent = match total { + Some(t) if t > 0 => ((downloaded as f64 / t as f64) * 90.0) as u8, + _ => 0, + }; + // 仅在变化超过 1% 时 emit,避免事件轰炸 + if percent >= last_percent + 1 { + last_percent = percent; + let _ = app.emit( + "downloader-kernel-install-progress", + InstallProgress { + stage: "downloading".into(), + percent, + downloaded_bytes: downloaded, + total_bytes: total, + message: format!("已下载 {:.2} MB", downloaded as f64 / 1024.0 / 1024.0), + }, + ); + } + } + file.flush().map_err(|e| format!("flush 失败: {}", e))?; + Ok(()) + } + + /// 用 zip crate 解压(纯 Rust,避免 PowerShell 执行策略问题) + fn extract_zip(&self, zip_path: &PathBuf, dest: &PathBuf) -> Result<(), String> { + let file = fs::File::open(zip_path).map_err(|e| format!("打开 zip 失败: {}", e))?; + let mut archive = zip::ZipArchive::new(file).map_err(|e| format!("读取 zip 失败: {}", e))?; + for i in 0..archive.len() { + let mut entry = archive + .by_index(i) + .map_err(|e| format!("读取条目失败: {}", e))?; + let outpath = match entry.enclosed_name() { + Some(p) => dest.join(p), + None => continue, + }; + if entry.is_dir() { + fs::create_dir_all(&outpath).map_err(|e| e.to_string())?; + } else { + if let Some(parent) = outpath.parent() { + fs::create_dir_all(parent).map_err(|e| e.to_string())?; + } + let mut outfile = fs::File::create(&outpath).map_err(|e| e.to_string())?; + let mut buf = [0u8; 8192]; + loop { + let n = entry.read(&mut buf).map_err(|e| e.to_string())?; + if n == 0 { + break; + } + outfile.write_all(&buf[..n]).map_err(|e| e.to_string())?; + } + } + } + Ok(()) + } + + /// 递归查找目录中的 .exe 文件 + /// aria2 zip 内的 exe 名字不固定(可能含版本号),策略: + /// 收集所有 .exe,优先返回名字含 "aria2" 的,否则返回第一个 + fn find_exe_in_dir(&self, dir: &PathBuf) -> Option { + let mut exes: Vec = Vec::new(); + self.collect_exes(dir, &mut exes); + if exes.is_empty() { + return None; + } + exes.iter() + .find(|p| { + p.file_name() + .and_then(|n| n.to_str()) + .map(|s| s.to_lowercase().contains("aria2")) + .unwrap_or(false) + }) + .or_else(|| exes.first()) + .cloned() + } + + fn collect_exes(&self, dir: &PathBuf, out: &mut Vec) { + if let Ok(entries) = fs::read_dir(dir) { + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + self.collect_exes(&path, out); + } else if path + .extension() + .and_then(|e| e.to_str()) + .map(|s| s.eq_ignore_ascii_case("exe")) + .unwrap_or(false) + { + out.push(path); + } + } + } + } + + // ---------- 配置生成 ---------- + /// 生成 aria2.conf + pub fn generate_config(&self) -> Result<(), String> { + let settings = self.load_settings(); + + // 确保下载目录存在 + let download_dir = if settings.download_dir.is_empty() { + default_download_dir() + } else { + settings.download_dir.clone() + }; + fs::create_dir_all(&download_dir).ok(); + + let log_file = self.logs_dir().join("aria2.log"); + // 空字符串的 secret 不写入配置,避免 aria2 启动参数异常 + let secret_line = if settings.rpc_secret.is_empty() { + String::new() + } else { + format!("rpc-secret={}\n", settings.rpc_secret) + }; + // 速度限制:0=不限,否则按 K 作单位 + let speed_limit_line = if settings.speed_limit == 0 { + String::new() + } else { + format!("max-overall-download-limit={}K\n", settings.speed_limit) + }; + + let conf = format!( + "# 由 Thing 自动生成,请勿手动编辑\n\ + enable-rpc=true\n\ + rpc-listen-all=false\n\ + rpc-allow-origin-all=true\n\ + rpc-listen-port={rpc_port}\n\ + {secret_line}\ + dir={download_dir}\n\ + max-concurrent-downloads={max_concurrent}\n\ + max-connection-per-server={max_conn}\n\ + split={split}\n\ + continue={cont}\n\ + min-split-size=1M\n\ + file-allocation=none\n\ + {speed_limit_line}\ + log={log_file}\n\ + log-level=warn\n", + rpc_port = settings.rpc_port, + secret_line = secret_line, + download_dir = download_dir.replace('\\', "/"), + max_concurrent = settings.max_concurrent, + max_conn = settings.max_connection_per_server, + split = settings.split, + cont = if settings.continue_download { "true" } else { "false" }, + speed_limit_line = speed_limit_line, + log_file = log_file.to_string_lossy().replace('\\', "/"), + ); + + fs::write(self.config_path(), conf).map_err(|e| e.to_string())?; + + // 确保 session 文件存在(aria2 --input-file 要求文件存在) + if !self.session_path().exists() { + fs::write(self.session_path(), "").map_err(|e| e.to_string())?; + } + + Ok(()) + } + + /// 构建启动 aria2 所需的进程参数 + pub fn prepare_for_start(&self, app: &AppHandle) -> Result { + let info = self.prepare_kernel(app)?; + if !info.exists { + return Err(format!( + "aria2 内核未安装。请将 aria2c.exe 放置到 src-tauri/binaries/ 后重新运行,或直接放到:\n{}", + self.cores_dir().to_string_lossy() + )); + } + self.generate_config()?; + Ok(StartProcessParams { + id: "downloader".into(), + executable: self.kernel_path().to_string_lossy().to_string(), + args: vec![ + "--conf-path".into(), + self.config_path().to_string_lossy().to_string(), + "--input-file".into(), + self.session_path().to_string_lossy().to_string(), + "--save-session".into(), + self.session_path().to_string_lossy().to_string(), + "--save-session-interval=30".into(), + ], + cwd: Some(self.aria2_dir().to_string_lossy().to_string()), + name: "aria2c".into(), + restart_on_crash: true, + max_restarts: 3, + }) + } + + /// 应用启动时检查是否需要自动启动 aria2 + pub fn auto_start_on_launch(&self, app: &AppHandle, pm: &ProcessManager) { + let settings = self.load_settings(); + if !settings.auto_start { + return; + } + match self.prepare_for_start(app) { + Ok(params) => { + if let Err(e) = pm.start(params) { + eprintln!("[aria2] 自动启动失败: {}", e); + } + } + Err(e) => { + eprintln!("[aria2] 自动启动跳过: {}", e); + } + } + } + + /// 应用退出时清理:尝试优雅关闭(保存 session),失败则强制 kill + pub fn cleanup_on_exit(&self) { + // 尝试通过 RPC 调用 aria2.shutdown 让 aria2 优雅退出(保存 session) + // 使用阻塞的 reqwest::blocking 避免在同步函数中引入 async 复杂度 + // 失败也不影响后续 stop_all + let rt = match tokio::runtime::Handle::try_current() { + Ok(h) => h, + Err(_) => return, + }; + let _ = rt.block_on(async { + // 给一个短超时,避免退出卡住 + let _ = tokio::time::timeout( + std::time::Duration::from_millis(500), + self.rpc_call("aria2.shutdown", vec![]), + ) + .await; + }); + } + + // ---------- RPC 信息 ---------- + pub fn get_rpc_info(&self, app: &AppHandle) -> RpcInfo { + let s = self.load_settings(); + let rpc_url = format!("http://127.0.0.1:{}/jsonrpc", s.rpc_port); + let extension_path = app + .path() + .resolve("resources/thing-extension", BaseDirectory::Resource) + .ok() + .map(|p| p.to_string_lossy().to_string()); + RpcInfo { + rpc_url, + rpc_secret: s.rpc_secret, + extension_path, + } + } + + // ---------- JSON-RPC 封装 ---------- + fn rpc_url(&self) -> String { + let s = self.load_settings(); + format!("http://127.0.0.1:{}/jsonrpc", s.rpc_port) + } + + /// 调用 aria2 JSON-RPC 方法 + /// params[0] 自动插入 "token:"(若有 secret) + pub async fn rpc_call( + &self, + method: &str, + params: Vec, + ) -> Result { + let s = self.load_settings(); + let mut full_params: Vec = Vec::with_capacity(params.len() + 1); + if !s.rpc_secret.is_empty() { + full_params.push(serde_json::json!(format!("token:{}", s.rpc_secret))); + } + full_params.extend(params); + + let id = self.rpc_id.fetch_add(1, Ordering::SeqCst); + let body = serde_json::json!({ + "jsonrpc": "2.0", + "id": id, + "method": method, + "params": full_params, + }); + + let resp = self + .client + .post(self.rpc_url()) + .json(&body) + .timeout(std::time::Duration::from_secs(10)) + .send() + .await + .map_err(|e| format!("请求 aria2 RPC 失败: {}", e))?; + + if !resp.status().is_success() { + return Err(format!("aria2 RPC 错误: HTTP {}", resp.status())); + } + + let v: serde_json::Value = resp + .json() + .await + .map_err(|e| format!("解析 aria2 RPC 响应失败: {}", e))?; + + if let Some(err) = v.get("error") { + return Err(format!( + "aria2 RPC 错误: code={}, message={}", + err.get("code").and_then(|c| c.as_i64()).unwrap_or(0), + err.get("message").and_then(|m| m.as_str()).unwrap_or("") + )); + } + + Ok(v.get("result").cloned().unwrap_or(serde_json::Value::Null)) + } + + pub async fn get_version(&self) -> Result { + self.rpc_call("aria2.getVersion", vec![]).await + } + + pub async fn get_global_stat(&self) -> Result { + self.rpc_call("aria2.getGlobalStat", vec![]).await + } + + pub async fn tell_active(&self) -> Result { + self.rpc_call("aria2.tellActive", vec![serde_json::json!(["gid", "status", "totalLength", "completedLength", "downloadSpeed", "uploadSpeed", "connections", "files", "dir", "bittorrent"])]) + .await + } + + pub async fn tell_waiting(&self, offset: i64, num: u32) -> Result { + self.rpc_call( + "aria2.tellWaiting", + vec![serde_json::json!(offset), serde_json::json!(num), serde_json::json!(["gid", "status", "totalLength", "completedLength", "downloadSpeed", "uploadSpeed", "connections", "files", "dir", "bittorrent"])], + ) + .await + } + + pub async fn tell_stopped(&self, offset: i64, num: u32) -> Result { + self.rpc_call( + "aria2.tellStopped", + vec![serde_json::json!(offset), serde_json::json!(num), serde_json::json!(["gid", "status", "totalLength", "completedLength", "downloadSpeed", "uploadSpeed", "connections", "files", "dir", "bittorrent"])], + ) + .await + } + + pub async fn tell_status(&self, gid: &str) -> Result { + self.rpc_call( + "aria2.tellStatus", + vec![serde_json::json!(gid), serde_json::json!(["gid", "status", "totalLength", "completedLength", "downloadSpeed", "uploadSpeed", "connections", "files", "dir", "bittorrent", "errorCode", "errorMessage"])], + ) + .await + } + + pub async fn add_uri( + &self, + uris: Vec, + options: Option, + ) -> Result { + let uris_arr: Vec = uris.into_iter().map(serde_json::Value::String).collect(); + let mut params: Vec = vec![serde_json::json!(uris_arr)]; + if let Some(o) = options { + params.push(o); + } + let result = self.rpc_call("aria2.addUri", params).await?; + result + .as_str() + .map(|s| s.to_string()) + .ok_or_else(|| "aria2.addUri 返回非字符串 gid".to_string()) + } + + pub async fn pause(&self, gid: &str) -> Result<(), String> { + let _ = self.rpc_call("aria2.pause", vec![serde_json::json!(gid)]).await?; + Ok(()) + } + + pub async fn unpause(&self, gid: &str) -> Result<(), String> { + let _ = self + .rpc_call("aria2.unpause", vec![serde_json::json!(gid)]) + .await?; + Ok(()) + } + + pub async fn remove(&self, gid: &str) -> Result<(), String> { + let _ = self.rpc_call("aria2.remove", vec![serde_json::json!(gid)]).await?; + Ok(()) + } + + pub async fn change_global_option( + &self, + options: serde_json::Value, + ) -> Result<(), String> { + let _ = self + .rpc_call("aria2.changeGlobalOption", vec![options]) + .await?; + Ok(()) + } +} + +// ===================== Tauri 命令 ===================== + +#[tauri::command] +pub fn downloader_get_settings(state: tauri::State<'_, Aria2Manager>) -> DownloaderSettings { + state.load_settings() +} + +#[tauri::command] +pub fn downloader_save_settings( + state: tauri::State<'_, Aria2Manager>, + settings: DownloaderSettings, +) -> Result<(), String> { + state.save_settings(&settings) +} + +#[tauri::command] +pub fn downloader_kernel_info( + state: tauri::State<'_, Aria2Manager>, + app: AppHandle, +) -> Result { + state.prepare_kernel(&app) +} + +#[tauri::command] +pub async fn downloader_check_kernel_update( + state: tauri::State<'_, Aria2Manager>, +) -> Result { + state.check_kernel_update().await +} + +#[tauri::command] +pub async fn downloader_update_kernel( + state: tauri::State<'_, Aria2Manager>, + app: AppHandle, + mirror_prefix: Option, +) -> Result { + state.install_kernel(&app, mirror_prefix.unwrap_or_default()).await +} + +#[tauri::command] +pub async fn downloader_install_kernel( + state: tauri::State<'_, Aria2Manager>, + app: AppHandle, + mirror_prefix: Option, +) -> Result { + state.install_kernel(&app, mirror_prefix.unwrap_or_default()).await +} + +#[tauri::command] +pub fn downloader_status(pm: tauri::State<'_, ProcessManager>) -> DownloaderStatus { + match pm.get_status("downloader") { + Some(p) => DownloaderStatus { + running: matches!(p.status, ProcessStatus::Running), + pid: p.pid, + restart_count: p.restart_count, + }, + None => DownloaderStatus { + running: false, + pid: None, + restart_count: 0, + }, + } +} + +#[tauri::command] +pub fn downloader_start( + state: tauri::State<'_, Aria2Manager>, + pm: tauri::State<'_, ProcessManager>, + app: AppHandle, +) -> Result { + let params = state.prepare_for_start(&app)?; + pm.start(params) +} + +#[tauri::command] +pub fn downloader_stop(pm: tauri::State<'_, ProcessManager>) -> Result<(), String> { + pm.stop("downloader") +} + +#[tauri::command] +pub async fn downloader_restart( + state: tauri::State<'_, Aria2Manager>, + pm: tauri::State<'_, ProcessManager>, + app: AppHandle, +) -> Result { + let _ = pm.stop("downloader"); + // 等待 TCP 端口释放(Windows 上 kill 后端口释放有延迟) + tauri::async_runtime::spawn_blocking(|| { + std::thread::sleep(std::time::Duration::from_millis(800)); + }) + .await + .map_err(|e| format!("sleep 失败: {}", e))?; + let params = state.prepare_for_start(&app)?; + pm.start(params) +} + +#[tauri::command] +pub async fn downloader_version( + state: tauri::State<'_, Aria2Manager>, +) -> Result { + state.get_version().await +} + +#[tauri::command] +pub async fn downloader_get_global_stat( + state: tauri::State<'_, Aria2Manager>, +) -> Result { + state.get_global_stat().await +} + +#[tauri::command] +pub async fn downloader_get_active( + state: tauri::State<'_, Aria2Manager>, +) -> Result { + state.tell_active().await +} + +#[tauri::command] +pub async fn downloader_get_waiting( + state: tauri::State<'_, Aria2Manager>, + offset: Option, + num: Option, +) -> Result { + state.tell_waiting(offset.unwrap_or(0), num.unwrap_or(100)).await +} + +#[tauri::command] +pub async fn downloader_get_stopped( + state: tauri::State<'_, Aria2Manager>, + offset: Option, + num: Option, +) -> Result { + state.tell_stopped(offset.unwrap_or(0), num.unwrap_or(100)).await +} + +#[tauri::command] +pub async fn downloader_get_status( + state: tauri::State<'_, Aria2Manager>, + gid: String, +) -> Result { + state.tell_status(&gid).await +} + +#[tauri::command] +pub async fn downloader_add_uri( + state: tauri::State<'_, Aria2Manager>, + uris: Vec, + options: Option, +) -> Result { + state.add_uri(uris, options).await +} + +#[tauri::command] +pub async fn downloader_pause( + state: tauri::State<'_, Aria2Manager>, + gid: String, +) -> Result<(), String> { + state.pause(&gid).await +} + +#[tauri::command] +pub async fn downloader_unpause( + state: tauri::State<'_, Aria2Manager>, + gid: String, +) -> Result<(), String> { + state.unpause(&gid).await +} + +#[tauri::command] +pub async fn downloader_remove( + state: tauri::State<'_, Aria2Manager>, + gid: String, +) -> Result<(), String> { + state.remove(&gid).await +} + +#[tauri::command] +pub async fn downloader_change_global_option( + state: tauri::State<'_, Aria2Manager>, + options: serde_json::Value, +) -> Result<(), String> { + state.change_global_option(options).await +} + +#[tauri::command] +pub fn downloader_get_rpc_info( + state: tauri::State<'_, Aria2Manager>, + app: AppHandle, +) -> RpcInfo { + state.get_rpc_info(&app) +} + +/// 打开目录(用系统资源管理器)。 +/// +/// 用 `OpenerExt::opener().open_path()` 而非 IPC `open_path` 命令,绕过 opener scope 限制 +/// (capabilities 的 `opener:allow-open-path` 默认无 scope,会拒绝任何路径)。 +#[tauri::command] +pub fn downloader_open_dir(app: AppHandle, path: String) -> Result<(), String> { + let p = std::path::Path::new(&path); + if !p.exists() { + return Err(format!("路径不存在: {}", path)); + } + use tauri_plugin_opener::OpenerExt; + app.opener() + .open_path(path, None::<&str>) + .map_err(|e| e.to_string()) +} + +/// 在系统默认浏览器中打开指定 URL。 +/// +/// 用 `OpenerExt::opener().open_url()` 打开外链,绕过前端 opener scope 限制。 +/// 仅允许 http/https 协议。 +#[tauri::command] +pub fn downloader_open_url(app: AppHandle, url: String) -> Result<(), String> { + // 简单协议校验,防止 file:// 等被误用 + if !url.starts_with("http://") && !url.starts_with("https://") { + return Err(format!("仅支持 http/https 链接: {}", url)); + } + use tauri_plugin_opener::OpenerExt; + app.opener() + .open_url(url, None::<&str>) + .map_err(|e| e.to_string()) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index e9888d5..345d5fd 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,9 +1,18 @@ use tauri::Manager; +mod aria2_manager; mod logger; mod mihomo_manager; mod process_manager; +use aria2_manager::{ + Aria2Manager, downloader_add_uri, downloader_change_global_option, downloader_check_kernel_update, + downloader_get_active, downloader_get_global_stat, downloader_get_rpc_info, downloader_get_settings, + downloader_get_status, downloader_get_stopped, downloader_get_waiting, downloader_install_kernel, + downloader_kernel_info, downloader_open_dir, downloader_open_url, downloader_pause, downloader_remove, downloader_restart, downloader_save_settings, + downloader_start, downloader_status, downloader_stop, downloader_unpause, downloader_update_kernel, + downloader_version, +}; use logger::{ clear_logs, get_log_info, get_logs, log_message, LogManager, }; @@ -28,9 +37,12 @@ fn greet(name: &str) -> String { fn quit_app( state: tauri::State<'_, ProcessManager>, mihomo: tauri::State<'_, MihomoManager>, + aria2: tauri::State<'_, Aria2Manager>, ) { // 退出前清理系统代理,避免遗留导致网络问题 mihomo.cleanup_on_exit(); + // 退出前让 aria2 优雅关闭(保存 session) + aria2.cleanup_on_exit(); // 停止所有子进程 state.stop_all(); std::process::exit(0); @@ -41,6 +53,7 @@ pub fn run() { tauri::Builder::default() .plugin(tauri_plugin_autostart::Builder::new().build()) .plugin(tauri_plugin_opener::init()) + .plugin(tauri_plugin_dialog::init()) .manage(ProcessManager::new()) .invoke_handler(tauri::generate_handler![ greet, @@ -77,7 +90,31 @@ pub fn run() { proxy_activate_profile, proxy_set_system_proxy, proxy_clear_system_proxy, - proxy_get_system_proxy + proxy_get_system_proxy, + downloader_get_settings, + downloader_save_settings, + downloader_kernel_info, + downloader_check_kernel_update, + downloader_update_kernel, + downloader_install_kernel, + downloader_status, + downloader_start, + downloader_stop, + downloader_restart, + downloader_version, + downloader_get_global_stat, + downloader_get_active, + downloader_get_waiting, + downloader_get_stopped, + downloader_get_status, + downloader_add_uri, + downloader_pause, + downloader_unpause, + downloader_remove, + downloader_change_global_option, + downloader_get_rpc_info, + downloader_open_dir, + downloader_open_url ]) .setup(|app| { // 初始化日志系统,日志目录: {app_data_dir}/logs/ @@ -93,9 +130,13 @@ pub fn run() { .path() .app_data_dir() .unwrap_or_else(|_| std::path::PathBuf::from(".")); - let mihomo = MihomoManager::new(app_data_dir); + let mihomo = MihomoManager::new(app_data_dir.clone()); app.manage(mihomo); + // 初始化 Aria2Manager,数据目录: {app_data_dir}/downloader/ + let aria2 = Aria2Manager::new(app_data_dir); + app.manage(aria2); + 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])?; @@ -112,10 +153,13 @@ pub fn run() { } } "quit" => { - // 退出前清理系统代理 + 停止所有子进程 + // 退出前清理系统代理 + 优雅关闭 aria2 + 停止所有子进程 if let Some(mihomo) = app.try_state::() { mihomo.cleanup_on_exit(); } + if let Some(aria2) = app.try_state::() { + aria2.cleanup_on_exit(); + } if let Some(pm) = app.try_state::() { pm.stop_all(); } @@ -148,6 +192,13 @@ pub fn run() { } } + // 应用启动时自动启动 aria2(如果用户在设置中开启了自动启动) + if let Some(aria2) = app.try_state::() { + if let Some(pm) = app.try_state::() { + aria2.auto_start_on_launch(app.handle(), &pm); + } + } + Ok(()) }) .on_window_event(|window, event| { diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index d6bf499..2a14ffe 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -37,6 +37,6 @@ "icons/icon.icns", "icons/icon.ico" ], - "resources": ["binaries/*"] + "resources": ["binaries/*", "resources/thing-extension/**/*"] } } diff --git a/src/components/layout/TitleBar.vue b/src/components/layout/TitleBar.vue index d102f97..6b19af5 100644 --- a/src/components/layout/TitleBar.vue +++ b/src/components/layout/TitleBar.vue @@ -4,6 +4,9 @@ import { Search, Minus, Square, X, Settings, ChevronRight } from '@lucide/vue' import { Input } from '@/components/ui/input' import { getCurrentWindow } from '@tauri-apps/api/window' import { useSearchStore, type SearchItem } from '@/stores/searchStore' +import { useModuleTabsStore } from '@/stores/moduleTabsStore' + +const tabsStore = useModuleTabsStore() const props = defineProps<{ modules: Array<{ id: string; name: string; icon: string }> @@ -119,6 +122,30 @@ const handleBlur = () => { data-tauri-drag-region > Thing + + + +
+ +
+
+
{ .hover-suppressed button:hover { background-color: transparent !important; } + +/* 浮动标签切换器进出动画:淡入 + 从左侧滑入 */ +.floating-tabs-enter-active, +.floating-tabs-leave-active { + transition: opacity 0.25s ease, transform 0.25s ease; +} +.floating-tabs-enter-from, +.floating-tabs-leave-to { + opacity: 0; + transform: translateX(-12px); +} \ No newline at end of file diff --git a/src/components/ui/pagination/Pagination.vue b/src/components/ui/pagination/Pagination.vue new file mode 100644 index 0000000..d410b24 --- /dev/null +++ b/src/components/ui/pagination/Pagination.vue @@ -0,0 +1,26 @@ + + + diff --git a/src/components/ui/pagination/PaginationContent.vue b/src/components/ui/pagination/PaginationContent.vue new file mode 100644 index 0000000..5150b49 --- /dev/null +++ b/src/components/ui/pagination/PaginationContent.vue @@ -0,0 +1,22 @@ + + + diff --git a/src/components/ui/pagination/PaginationEllipsis.vue b/src/components/ui/pagination/PaginationEllipsis.vue new file mode 100644 index 0000000..5d2b15d --- /dev/null +++ b/src/components/ui/pagination/PaginationEllipsis.vue @@ -0,0 +1,25 @@ + + + diff --git a/src/components/ui/pagination/PaginationFirst.vue b/src/components/ui/pagination/PaginationFirst.vue new file mode 100644 index 0000000..7e2a86f --- /dev/null +++ b/src/components/ui/pagination/PaginationFirst.vue @@ -0,0 +1,33 @@ + + + diff --git a/src/components/ui/pagination/PaginationItem.vue b/src/components/ui/pagination/PaginationItem.vue new file mode 100644 index 0000000..bc03720 --- /dev/null +++ b/src/components/ui/pagination/PaginationItem.vue @@ -0,0 +1,34 @@ + + + diff --git a/src/components/ui/pagination/PaginationLast.vue b/src/components/ui/pagination/PaginationLast.vue new file mode 100644 index 0000000..698cdab --- /dev/null +++ b/src/components/ui/pagination/PaginationLast.vue @@ -0,0 +1,33 @@ + + + diff --git a/src/components/ui/pagination/PaginationNext.vue b/src/components/ui/pagination/PaginationNext.vue new file mode 100644 index 0000000..adf9c0a --- /dev/null +++ b/src/components/ui/pagination/PaginationNext.vue @@ -0,0 +1,33 @@ + + + diff --git a/src/components/ui/pagination/PaginationPrevious.vue b/src/components/ui/pagination/PaginationPrevious.vue new file mode 100644 index 0000000..bcf8999 --- /dev/null +++ b/src/components/ui/pagination/PaginationPrevious.vue @@ -0,0 +1,33 @@ + + + diff --git a/src/components/ui/pagination/index.ts b/src/components/ui/pagination/index.ts new file mode 100644 index 0000000..51ae7fd --- /dev/null +++ b/src/components/ui/pagination/index.ts @@ -0,0 +1,8 @@ +export { default as Pagination } from "./Pagination.vue" +export { default as PaginationContent } from "./PaginationContent.vue" +export { default as PaginationEllipsis } from "./PaginationEllipsis.vue" +export { default as PaginationFirst } from "./PaginationFirst.vue" +export { default as PaginationItem } from "./PaginationItem.vue" +export { default as PaginationLast } from "./PaginationLast.vue" +export { default as PaginationNext } from "./PaginationNext.vue" +export { default as PaginationPrevious } from "./PaginationPrevious.vue" diff --git a/src/components/ui/tabs/TabsTrigger.vue b/src/components/ui/tabs/TabsTrigger.vue index 14ebcfb..6f34676 100644 --- a/src/components/ui/tabs/TabsTrigger.vue +++ b/src/components/ui/tabs/TabsTrigger.vue @@ -16,7 +16,7 @@ const forwardedProps = useForwardProps(delegatedProps) 顶部 + * const activeTab = ref('overview') + * const tabsListRef = useModuleTabs(activeTab, [ + * { value: 'overview', label: '概览' }, + * { value: 'settings', label: '设置' } + * ]) + * ``` + * + * ```vue + * + *
+ * ... + *
+ * ``` + * + * ## 工作原理 + * 1. onMounted 时注册标签到 moduleTabsStore,TitleBar 据此渲染浮动切换器 + * 2. 用 IntersectionObserver 监听 TabsList 可见性(rootMargin 裁剪 TitleBar 高度) + * 3. 双向 watch 同步本地 activeTab 与 store.activeTab + * 4. onUnmounted 时清理 observer 并注销标签 + * + * ## 约束 + * - TitleBar 高度固定为 40px (h-10),composable 内部已用 44px 裁剪(含缓冲) + * - 一个模块同一时间只能注册一组标签(store 是单例) + * - 模块卸载时务必让 composable 的 onUnmounted 执行(已自动处理,无需手动调用) + */ +export function useModuleTabs( + activeTab: Ref, + tabs: ModuleTab[] +): Ref { + const tabsStore = useModuleTabsStore() + const tabsListRef = ref(null) + + let observer: IntersectionObserver | null = null + + const setupObserver = () => { + const el = tabsListRef.value + if (!el || observer) return + observer = new IntersectionObserver( + (entries) => { + for (const entry of entries) { + tabsStore.setFloatingVisible(!entry.isIntersecting) + } + }, + { + // root=null 表示视口;顶部裁剪 44px(TitleBar 高度 40px + 4px 缓冲) + rootMargin: '-44px 0px 0px 0px', + threshold: 0 + } + ) + observer.observe(el) + } + + // 本地 activeTab → store(用户点击模块内 TabsTrigger 时同步) + watch(activeTab, (val) => { + tabsStore.setActiveTab(val) + }) + + // store activeTab → 本地(用户点击 TitleBar 浮动切换器时同步) + watch(() => tabsStore.activeTab, (val) => { + if (val && val !== activeTab.value) { + activeTab.value = val + } + }) + + onMounted(async () => { + tabsStore.registerTabs(tabs, activeTab.value) + await nextTick() + setupObserver() + }) + + onUnmounted(() => { + if (observer) { + observer.disconnect() + observer = null + } + tabsStore.unregisterTabs() + }) + + return tabsListRef +} diff --git a/src/modules/downloader/DownloaderModule.vue b/src/modules/downloader/DownloaderModule.vue index 2a0482c..eb34f78 100644 --- a/src/modules/downloader/DownloaderModule.vue +++ b/src/modules/downloader/DownloaderModule.vue @@ -1,24 +1,1800 @@