diff --git a/plugins/ztools-download/README.md b/plugins/ztools-download/README.md
new file mode 100644
index 000000000..f5c51bb45
--- /dev/null
+++ b/plugins/ztools-download/README.md
@@ -0,0 +1,57 @@
+# 🚀 Aria2 多线程下载器 (ztools-download)
+
+[](plugin.json)
+[](plugin.json)
+[](bin/aria2c.exe)
+
+基于 **Aria2** 引擎打造的高性能、极轻量、多线程加速下载工具插件。内置暗黑玻璃拟态 (Dark Glassmorphism) 现代 UI 设计,支持最高 16 线程分片加速、BT 磁力链解析、断点续传、系统代理继承与全自动持久化存储。
+
+---
+
+## 🌟 核心特性 (Key Features)
+
+### 1. ⚡ 16 线程并发分片加速
+* 采用开源高性能 Aria2c 守护进程作为底层引擎,支持 HTTP / HTTPS / FTP / BT 等多协议。
+* 动态分片并发下载,最高支持 **16 线程**同时发包请求,轻松突破服务器单连接限速,跑满宽带上限。
+
+### 2. 🧲 BT 种子与 Magnet 磁力链原生支持
+* 支持 `magnet:?xt=...` 磁力链接解析与 BT 种子文件下载。
+* 内置 **DHT 网络**、**PEX 节点交换**、**LPD 本地发现**,并自动注入全球高可用 **Public BT Trackers** 服务器列表,大幅提升磁力链节点寻址与下载速度。
+
+### 3. 💾 任务记录全自动持久化 (Resumable & Persistent)
+* **引擎层会话保存**:配置 `--force-save=true` 与 `--save-session`,即使下载完成或取消,任务元数据依然永久保留在 `.aria2.session` 会话文件中。
+* **本地 DB 深度持久化**:结合 ztools 本地数据库 (`services.dbSet` / `services.dbGet`) 实施双向同步,即使重启电脑、重启插件或强制关闭进程,所有历史下载记录与进度均 100% 永久留存。
+
+### 4. 📋 智能剪贴板识别与文件名清洗
+* 自动感知剪贴板中的 HTTP/HTTPS/FTP/磁力链接,唤出即用。
+* 自动剥离 URL 尾部的冗余查询参数(如 `?utm_source=...`),自动进行 URL 编码解码(如 `%20` 转标准空格),保证界面显示的名称与磁盘落地的文件名完全一致。
+
+---
+
+## 🚀 快捷指令 (Plugin Commands)
+
+在 ztools / utools 搜索框输入以下关键字即可快速唤起插件:
+
+* `download`
+* `aria2`
+* `下载`
+* `下载器`
+* `多线程下载`
+* `xz`
+
+---
+
+## 📖 使用指南 (Usage Guide)
+
+1. **新建下载任务**:
+ * 点击右上角 **“新建任务”** 按钮。
+ * 粘贴单个或多个 HTTP/HTTPS/FTP 下载链接,或粘贴磁力链接 (`magnet:?xt=...`)。
+ * 选择保存目录及线程数(默认 16 线程),点击 **“开始下载”**。
+
+2. **打开保存目录与定位文件**:
+ * 点击任务卡片右侧的 **文件夹图标**,系统将自动打开 Windows 资源管理器并高亮选中该文件。
+
+3. **使用排障诊断面板**:
+ * 若遇到连接断开或下载异常,点击顶部 Navbar 的 **“连接状态”** 标识。
+ * 在弹出的诊断面板中,可查看详细的 Aria2 运行参数与后台控制台输出日志,并可点击 **“复制日志”** 快捷反馈。
+
diff --git a/plugins/ztools-download/bin/aria2c.exe b/plugins/ztools-download/bin/aria2c.exe
new file mode 100644
index 000000000..5004e1034
Binary files /dev/null and b/plugins/ztools-download/bin/aria2c.exe differ
diff --git a/plugins/ztools-download/css/style.css b/plugins/ztools-download/css/style.css
new file mode 100644
index 000000000..4b0307fc3
--- /dev/null
+++ b/plugins/ztools-download/css/style.css
@@ -0,0 +1,783 @@
+/* ==========================================================================
+ ztools-download High-End Dark Glassmorphism CSS Design System
+ ========================================================================== */
+
+:root {
+ --bg-primary: #0b0f19;
+ --bg-secondary: #111827;
+ --glass-bg: rgba(17, 24, 39, 0.72);
+ --glass-card: rgba(30, 41, 59, 0.55);
+ --glass-border: rgba(255, 255, 255, 0.08);
+ --glass-border-hover: rgba(99, 102, 241, 0.4);
+
+ --accent-primary: #6366f1;
+ --accent-secondary: #8b5cf6;
+ --accent-cyan: #06b6d4;
+ --accent-green: #10b981;
+ --accent-yellow: #f59e0b;
+ --accent-red: #ef4444;
+
+ --gradient-accent: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
+ --gradient-speed: linear-gradient(135deg, #06b6d4 0%, #10b981 100%);
+ --gradient-card: linear-gradient(145deg, rgba(30, 41, 59, 0.6), rgba(15, 23, 42, 0.7));
+
+ --text-main: #f8fafc;
+ --text-muted: #94a3b8;
+ --text-dim: #64748b;
+
+ --radius-sm: 8px;
+ --radius-md: 12px;
+ --radius-lg: 16px;
+ --radius-full: 9999px;
+
+ --shadow-glow: 0 0 20px rgba(99, 102, 241, 0.25);
+ --shadow-card: 0 10px 25px -5px rgba(0, 0, 0, 0.3), 0 8px 10px -6px rgba(0, 0, 0, 0.3);
+
+ --font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
+}
+
+* {
+ box-sizing: border-box;
+ margin: 0;
+ padding: 0;
+ user-select: none;
+}
+
+/* Allow text selection for modals, inputs, text, logs, and diagnostic panel */
+input, textarea, select,
+.modal, .confirm-message, .confirm-modal,
+#diagFallbackModal, #diagFallbackModal *,
+pre, code,
+.selectable-text, .file-name, .file-path {
+ user-select: text !important;
+ -webkit-user-select: text !important;
+}
+
+body {
+ font-family: var(--font-family);
+ background-color: var(--bg-primary);
+ color: var(--text-main);
+ background-image:
+ radial-gradient(circle at 15% 15%, rgba(99, 102, 241, 0.08) 0%, transparent 40%),
+ radial-gradient(circle at 85% 85%, rgba(6, 182, 212, 0.06) 0%, transparent 40%);
+ height: 100vh;
+ overflow: hidden;
+ display: flex;
+ flex-direction: column;
+}
+
+/* Custom Scrollbar */
+::-webkit-scrollbar {
+ width: 6px;
+ height: 6px;
+}
+::-webkit-scrollbar-track {
+ background: rgba(0, 0, 0, 0.1);
+}
+::-webkit-scrollbar-thumb {
+ background: rgba(255, 255, 255, 0.15);
+ border-radius: var(--radius-full);
+}
+::-webkit-scrollbar-thumb:hover {
+ background: rgba(255, 255, 255, 0.3);
+}
+
+/* ==========================================================================
+ App Layout
+ ========================================================================== */
+
+#app {
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ padding: 14px 18px;
+ gap: 12px;
+}
+
+/* Header Navbar */
+.header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ background: var(--glass-bg);
+ backdrop-filter: blur(16px);
+ -webkit-backdrop-filter: blur(16px);
+ border: 1px solid var(--glass-border);
+ border-radius: var(--radius-lg);
+ padding: 10px 16px;
+ box-shadow: var(--shadow-card);
+}
+
+.brand {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+}
+
+.brand-icon {
+ width: 32px;
+ height: 32px;
+ border-radius: var(--radius-sm);
+ background: var(--gradient-accent);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ box-shadow: var(--shadow-glow);
+}
+
+.brand-icon svg {
+ width: 18px;
+ height: 18px;
+ fill: #fff;
+}
+
+.brand-title {
+ font-size: 15px;
+ font-weight: 700;
+ letter-spacing: -0.2px;
+ background: linear-gradient(135deg, #ffffff 0%, #cbd5e1 100%);
+ -webkit-background-clip: text;
+ -webkit-text-fill-color: transparent;
+}
+
+.status-badge {
+ font-size: 11px;
+ padding: 2px 8px;
+ border-radius: var(--radius-full);
+ display: flex;
+ align-items: center;
+ gap: 5px;
+ font-weight: 600;
+ cursor: pointer;
+ user-select: none;
+ transition: all 0.2s ease;
+}
+.status-badge * {
+ pointer-events: none;
+}
+.status-badge:hover {
+ opacity: 0.85;
+ transform: translateY(-1px);
+}
+.status-badge.connected {
+ background: rgba(16, 185, 129, 0.15);
+ color: var(--accent-green);
+ border: 1px solid rgba(16, 185, 129, 0.3);
+}
+.status-badge.disconnected {
+ background: rgba(239, 68, 68, 0.15);
+ color: var(--accent-red);
+ border: 1px solid rgba(239, 68, 68, 0.3);
+}
+.status-dot {
+ width: 6px;
+ height: 6px;
+ border-radius: 50%;
+ background: currentColor;
+}
+
+.header-stats {
+ display: flex;
+ align-items: center;
+ gap: 16px;
+}
+
+.stat-chip {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ font-size: 12px;
+ font-weight: 600;
+ background: rgba(15, 23, 42, 0.6);
+ padding: 6px 12px;
+ border-radius: var(--radius-full);
+ border: 1px solid var(--glass-border);
+}
+
+.stat-chip.dl {
+ color: var(--accent-cyan);
+}
+.stat-chip.ul {
+ color: var(--accent-secondary);
+}
+
+.header-actions {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+/* Buttons */
+.btn {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ padding: 8px 14px;
+ border-radius: var(--radius-md);
+ font-size: 13px;
+ font-weight: 600;
+ cursor: pointer;
+ border: 1px solid transparent;
+ transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
+ outline: none;
+}
+
+.btn-primary {
+ background: var(--gradient-accent);
+ color: #fff;
+ box-shadow: 0 4px 14px rgba(99, 102, 241, 0.35);
+}
+.btn-primary:hover {
+ transform: translateY(-1px);
+ box-shadow: 0 6px 20px rgba(99, 102, 241, 0.5);
+ filter: brightness(1.1);
+}
+.btn-primary:active {
+ transform: translateY(0);
+}
+
+.btn-secondary {
+ background: rgba(30, 41, 59, 0.8);
+ color: var(--text-main);
+ border: 1px solid var(--glass-border);
+}
+.btn-secondary:hover {
+ background: rgba(51, 65, 85, 0.9);
+ border-color: var(--glass-border-hover);
+}
+
+.btn-icon-only {
+ padding: 8px;
+ border-radius: var(--radius-md);
+ background: rgba(30, 41, 59, 0.6);
+ color: var(--text-muted);
+ border: 1px solid var(--glass-border);
+}
+.btn-icon-only:hover {
+ color: var(--text-main);
+ background: rgba(51, 65, 85, 0.8);
+ border-color: var(--glass-border-hover);
+}
+
+/* Toolbar & Filter Tabs */
+.toolbar {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+}
+
+.tabs {
+ display: flex;
+ align-items: center;
+ background: var(--glass-bg);
+ padding: 4px;
+ border-radius: var(--radius-md);
+ border: 1px solid var(--glass-border);
+}
+
+.tab-btn {
+ padding: 6px 14px;
+ font-size: 12px;
+ font-weight: 600;
+ color: var(--text-muted);
+ border-radius: var(--radius-sm);
+ cursor: pointer;
+ transition: all 0.2s ease;
+ border: none;
+ background: transparent;
+}
+
+.tab-btn:hover {
+ color: var(--text-main);
+}
+
+.tab-btn.active {
+ background: rgba(99, 102, 241, 0.2);
+ color: #fff;
+ border: 1px solid rgba(99, 102, 241, 0.4);
+}
+
+.search-box {
+ position: relative;
+ width: 220px;
+}
+
+.search-box input {
+ width: 100%;
+ padding: 7px 12px 7px 32px;
+ font-size: 12px;
+ background: var(--glass-bg);
+ border: 1px solid var(--glass-border);
+ border-radius: var(--radius-md);
+ color: var(--text-main);
+ outline: none;
+ transition: all 0.2s ease;
+}
+
+.search-box input:focus {
+ border-color: var(--accent-primary);
+ box-shadow: 0 0 12px rgba(99, 102, 241, 0.25);
+}
+
+.search-icon {
+ position: absolute;
+ left: 10px;
+ top: 50%;
+ transform: translateY(-50%);
+ width: 14px;
+ height: 14px;
+ fill: var(--text-dim);
+ pointer-events: none;
+}
+
+/* Main Content Area */
+.main-content {
+ flex: 1;
+ overflow-y: auto;
+ padding-right: 4px;
+}
+
+/* Empty State */
+.empty-state {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ height: 100%;
+ min-height: 320px;
+ color: var(--text-dim);
+ text-align: center;
+ gap: 12px;
+}
+
+.empty-icon {
+ width: 64px;
+ height: 64px;
+ border-radius: 50%;
+ background: rgba(30, 41, 59, 0.4);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border: 1px dashed var(--glass-border);
+}
+
+.empty-icon svg {
+ width: 32px;
+ height: 32px;
+ fill: var(--text-dim);
+}
+
+.empty-title {
+ font-size: 15px;
+ font-weight: 600;
+ color: var(--text-muted);
+}
+
+.empty-subtitle {
+ font-size: 12px;
+ max-width: 280px;
+}
+
+/* Task List */
+.task-list {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+}
+
+.task-card {
+ background: var(--gradient-card);
+ backdrop-filter: blur(12px);
+ border: 1px solid var(--glass-border);
+ border-radius: var(--radius-lg);
+ padding: 14px 16px;
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ transition: border-color 0.2s ease, box-shadow 0.2s ease, background-color 0.2s ease;
+ box-shadow: var(--shadow-card);
+ position: relative;
+ overflow: hidden;
+}
+
+.task-card:hover {
+ border-color: var(--glass-border-hover);
+ box-shadow: 0 10px 30px -5px rgba(99, 102, 241, 0.2), 0 0 15px rgba(99, 102, 241, 0.15);
+}
+
+.task-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+}
+
+.task-info {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ flex: 1;
+ min-width: 0;
+}
+
+.file-icon {
+ width: 38px;
+ height: 38px;
+ border-radius: var(--radius-md);
+ background: rgba(99, 102, 241, 0.12);
+ border: 1px solid rgba(99, 102, 241, 0.25);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+}
+
+.file-icon svg {
+ width: 20px;
+ height: 20px;
+ fill: var(--accent-primary);
+}
+
+.file-meta {
+ display: flex;
+ flex-direction: column;
+ gap: 3px;
+ flex: 1;
+ min-width: 0;
+}
+
+.file-name {
+ font-size: 13px;
+ font-weight: 600;
+ color: var(--text-main);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.file-path {
+ font-size: 11px;
+ color: var(--text-dim);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.task-actions {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ flex-shrink: 0;
+}
+
+.action-btn {
+ width: 30px;
+ height: 30px;
+ border-radius: var(--radius-sm);
+ background: rgba(30, 41, 59, 0.6);
+ border: 1px solid var(--glass-border);
+ color: var(--text-muted);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ cursor: pointer;
+ transition: all 0.2s ease;
+}
+
+.action-btn:hover {
+ color: var(--text-main);
+ background: rgba(51, 65, 85, 0.9);
+ border-color: var(--glass-border-hover);
+}
+
+.action-btn.danger:hover {
+ color: #fff;
+ background: rgba(239, 68, 68, 0.8);
+ border-color: var(--accent-red);
+}
+
+.action-btn.warning:hover {
+ color: #fff;
+ background: rgba(245, 158, 11, 0.8);
+ border-color: #f59e0b;
+}
+
+.action-btn svg {
+ width: 14px;
+ height: 14px;
+ fill: currentColor;
+ pointer-events: none;
+}
+
+.action-btn * {
+ pointer-events: none !important;
+}
+
+/* Progress Bar */
+.progress-container {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+
+.progress-bar-bg {
+ width: 100%;
+ height: 6px;
+ background: rgba(15, 23, 42, 0.8);
+ border-radius: var(--radius-full);
+ overflow: hidden;
+ position: relative;
+}
+
+.progress-bar-fill {
+ height: 100%;
+ border-radius: var(--radius-full);
+ background: var(--gradient-accent);
+ transition: width 0.3s ease;
+ position: relative;
+}
+
+.task-card.active .progress-bar-fill {
+ background: var(--gradient-speed);
+}
+
+.task-card.paused .progress-bar-fill {
+ background: var(--accent-yellow);
+}
+
+.task-card.error .progress-bar-fill {
+ background: var(--accent-red);
+}
+
+.task-stats {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ font-size: 11px;
+ color: var(--text-muted);
+}
+
+.task-stats-left {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+}
+
+.speed-tag {
+ color: var(--accent-cyan);
+ font-weight: 700;
+}
+
+.eta-tag {
+ color: var(--text-dim);
+}
+
+.threads-tag {
+ background: rgba(99, 102, 241, 0.15);
+ color: var(--accent-primary);
+ padding: 1px 6px;
+ border-radius: var(--radius-sm);
+ font-size: 10px;
+ font-weight: 600;
+}
+
+/* Modals */
+.modal-overlay {
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ width: 100%;
+ height: 100%;
+ background: rgba(0, 0, 0, 0.65);
+ backdrop-filter: blur(8px);
+ -webkit-backdrop-filter: blur(8px);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 10090;
+ opacity: 0;
+ pointer-events: none;
+ transition: opacity 0.25s ease;
+}
+
+.modal-overlay.active {
+ opacity: 1;
+ pointer-events: auto;
+}
+
+.modal {
+ width: 90%;
+ max-width: 480px;
+ background: var(--bg-secondary);
+ border: 1px solid var(--glass-border-hover);
+ border-radius: var(--radius-lg);
+ padding: 20px 24px;
+ box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.6), var(--shadow-glow);
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+ transform: scale(0.95);
+ transition: transform 0.25s cubic-bezier(0.4, 0, 0.2, 1);
+}
+
+.modal-overlay.active .modal {
+ transform: scale(1);
+}
+
+.modal-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+}
+
+.modal-title {
+ font-size: 16px;
+ font-weight: 700;
+ color: var(--text-main);
+}
+
+.modal-close {
+ background: transparent;
+ border: none;
+ color: var(--text-dim);
+ cursor: pointer;
+ padding: 4px;
+ border-radius: var(--radius-sm);
+}
+
+.modal-close:hover {
+ color: var(--text-main);
+ background: rgba(255, 255, 255, 0.05);
+}
+
+.modal-close svg {
+ width: 18px;
+ height: 18px;
+ fill: currentColor;
+}
+
+.form-group {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+
+.form-label {
+ font-size: 12px;
+ font-weight: 600;
+ color: var(--text-muted);
+}
+
+.form-input {
+ width: 100%;
+ padding: 9px 12px;
+ background: rgba(15, 23, 42, 0.8);
+ border: 1px solid var(--glass-border);
+ border-radius: var(--radius-md);
+ color: var(--text-main);
+ font-size: 12px;
+ outline: none;
+ transition: border-color 0.2s ease;
+}
+
+.form-input:focus {
+ border-color: var(--accent-primary);
+ box-shadow: 0 0 10px rgba(99, 102, 241, 0.2);
+}
+
+textarea.form-input {
+ resize: vertical;
+ min-height: 70px;
+ font-family: inherit;
+}
+
+select.form-input {
+ appearance: none;
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%2394a3b8' stroke-width='2'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
+ background-repeat: no-repeat;
+ background-position: right 12px center;
+ padding-right: 32px;
+ cursor: pointer;
+}
+
+select.form-input option {
+ background-color: var(--bg-secondary);
+ color: var(--text-main);
+}
+
+.row-checkbox {
+ flex-direction: row;
+ align-items: center;
+ margin-top: 4px;
+}
+
+.checkbox-container {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ cursor: pointer;
+ font-size: 12px;
+ color: var(--text-muted);
+ user-select: none;
+}
+
+.checkbox-container input[type="checkbox"] {
+ width: 16px;
+ height: 16px;
+ accent-color: var(--accent-primary);
+ cursor: pointer;
+}
+
+.input-group {
+ display: flex;
+ gap: 8px;
+}
+
+.input-group .form-input {
+ flex: 1;
+}
+
+.modal-footer {
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+ gap: 10px;
+ margin-top: 6px;
+}
+
+/* Confirm & Alert Modal */
+#confirmModal {
+ z-index: 10090;
+}
+
+.confirm-modal {
+ max-width: 520px;
+}
+
+.confirm-message {
+ font-size: 12px;
+ color: var(--text-muted);
+ line-height: 1.6;
+ padding: 8px 0;
+ white-space: pre-wrap;
+ word-break: break-all;
+ max-height: 360px;
+ overflow-y: auto;
+ user-select: text !important;
+ -webkit-user-select: text !important;
+}
+
+.btn-danger-glow {
+ background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%) !important;
+ color: #fff !important;
+ border: 1px solid rgba(239, 68, 68, 0.4) !important;
+ box-shadow: 0 4px 14px rgba(239, 68, 68, 0.35) !important;
+}
+
+.btn-danger-glow:hover {
+ background: linear-gradient(135deg, #f87171 0%, #ef4444 100%) !important;
+ box-shadow: 0 6px 20px rgba(239, 68, 68, 0.5) !important;
+}
diff --git a/plugins/ztools-download/index.html b/plugins/ztools-download/index.html
new file mode 100644
index 000000000..11db1c339
--- /dev/null
+++ b/plugins/ztools-download/index.html
@@ -0,0 +1,187 @@
+
+
+
+
+
+ Aria2 多线程下载器 - ztools
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
暂无下载任务
+
点击右上角“新建任务”或直接复制下载链接发起多线程下载
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 确定要执行此操作吗?
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/plugins/ztools-download/js/app.js b/plugins/ztools-download/js/app.js
new file mode 100644
index 000000000..4ca929f8b
--- /dev/null
+++ b/plugins/ztools-download/js/app.js
@@ -0,0 +1,1309 @@
+/**
+ * ztools Aria2 Downloader Application Logic
+ */
+
+// 顶层挂载全局排障诊断函数(确保任何初始化阶段点击均可稳定触发)
+window.showAria2Diagnostics = function() {
+ try {
+ const services = window.services || {};
+ let logs = (services && typeof services.getAria2Logs === 'function')
+ ? services.getAria2Logs()
+ : '暂无相关异常日志 (services未就绪)';
+
+ // 清理日志中连续的大范围空白空行
+ if (logs) {
+ logs = logs.replace(/\r\n/g, '\n').replace(/\r/g, '\n').replace(/\n{3,}/g, '\n\n').trim();
+ }
+
+ const settings = window.currentSettings || { rpcPort: 6800, saveDir: (services.getDefaultDownloadDir ? services.getDefaultDownloadDir() : 'C:\\Downloads') };
+ const isRunning = services.isAria2Running ? services.isAria2Running() : window.isAria2Connected;
+ const stateStr = isRunning ? '✅ Aria2 引擎正在运行 (PID active)' : '❌ RPC 连接未建立 / 已停止';
+ const binInfo = (services.getAria2BinaryStatus && services.getAria2BinaryStatus()) || {};
+ const binStr = binInfo.found ? `✅ 已找到 → ${binInfo.path}` : '❌ 未找到 (请检查插件 bin 目录)';
+
+ const msg = `【Aria2 守护进程诊断信息】\n- 运行状态: ${stateStr}\n- aria2c 二进制: ${binStr}\n- RPC 监听端口: ${settings.rpcPort}\n- RPC 密钥 Token: ${settings.rpcSecret ? '已配置' : '未设置'}\n- 默认保存目录: ${settings.saveDir}\n\n【后台日志输出】\n${logs}`;
+
+ // 优先尝试使用标准 UI 弹窗
+ if (typeof window.showAlert === 'function') {
+ window.showAlert('Aria2 引擎诊断面板', msg);
+ return;
+ }
+
+ // 保底:若 UI 弹窗组件尚未挂载,使用自定义绝对全屏浮层
+ let diagBox = document.getElementById('diagFallbackModal');
+ if (!diagBox) {
+ diagBox = document.createElement('div');
+ diagBox.id = 'diagFallbackModal';
+ diagBox.style.cssText = 'position:fixed;top:0;left:0;width:100vw;height:100vh;background:rgba(15,23,42,0.92);z-index:9999999;display:flex;align-items:center;justify-content:center;padding:20px;box-sizing:border-box;color:#fff;font-family:system-ui,-apple-system,sans-serif;backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px);user-select:text;-webkit-user-select:text;';
+ document.body.appendChild(diagBox);
+ }
+
+ const safeMsg = msg.replace(/&/g, '&').replace(//g, '>');
+ diagBox.innerHTML = `
+
+
+ 🩺 Aria2 引擎诊断面板
+
+
+
${safeMsg}
+
+
+
+
+
+ `;
+ diagBox.style.display = 'flex';
+ } catch (err) {
+ alert(`【Aria2 引擎诊断信息】\n\n获取日志异常: ${err.message}`);
+ }
+};
+
+async function startApp() {
+ const services = window.services || {
+ startAria2: () => ({ success: true }),
+ stopAria2: () => true,
+ getDefaultDownloadDir: () => 'C:\\Downloads',
+ openInExplorer: () => false,
+ selectFolder: () => null,
+ getClipboardText: () => '',
+ showNotification: (t, b) => console.log(t, b),
+ dbGet: () => null,
+ dbSet: () => true
+ };
+
+ // 默认配置
+ let settings = {
+ saveDir: services.getDefaultDownloadDir(),
+ maxThreads: 16,
+ rpcPort: 6800,
+ rpcSecret: '',
+ fileAlloc: 'none'
+ };
+
+ // 从本地存储读取设置
+ try {
+ const saved = services.dbGet('aria2_settings');
+ if (saved) {
+ const parsed = typeof saved === 'string' ? JSON.parse(saved) : saved;
+ settings = { ...settings, ...parsed };
+ }
+ } catch (e) {}
+ if (settings.fileAlloc === 'fallocate' || settings.fileAlloc === 'falloc') {
+ settings.fileAlloc = 'none';
+ }
+ window.currentSettings = settings;
+
+ // RPC 客户端
+ const aria2 = new Aria2Client({
+ host: '127.0.0.1',
+ port: settings.rpcPort,
+ secret: settings.rpcSecret
+ });
+
+ // UI 元素引用
+ const connectionStatus = document.getElementById('connectionStatus');
+ const statusText = document.getElementById('statusText');
+ const headerStats = document.getElementById('headerStats');
+ const globalDlSpeed = document.getElementById('globalDlSpeed');
+ const globalUlSpeed = document.getElementById('globalUlSpeed');
+ const taskListEl = document.getElementById('taskList');
+ const emptyStateEl = document.getElementById('emptyState');
+ const searchInput = document.getElementById('searchInput');
+
+ // Modal 元素
+ const newTaskModal = document.getElementById('newTaskModal');
+ const settingsModal = document.getElementById('settingsModal');
+
+ // 自定义玻璃拟态弹窗控制 (含防假死保底与选项支持)
+ function showConfirm(title, message, options = {}) {
+ return new Promise((resolve) => {
+ const modalEl = document.getElementById('confirmModal');
+ const titleEl = document.getElementById('confirmTitle');
+ const msgEl = document.getElementById('confirmMessage');
+ const okBtn = document.getElementById('btnConfirmOk');
+ const cancelBtn = document.getElementById('btnConfirmCancel');
+ const optContainer = document.getElementById('confirmOptionContainer');
+ const chkBox = document.getElementById('chkDeleteFile');
+ const chkLabel = document.getElementById('chkDeleteFileLabel');
+
+ if (!modalEl || !titleEl || !msgEl || !okBtn || !cancelBtn) {
+ // 保底:原生 confirm 机制
+ const ok = window.confirm(`${title ? title + '\n\n' : ''}${message || '确定要继续吗?'}`);
+ return resolve(options.showCheckbox ? { ok, deleteFile: false } : ok);
+ }
+
+ titleEl.textContent = title || '确认操作';
+ msgEl.textContent = message || '确定要继续吗?';
+
+ cancelBtn.style.display = options.hideCancel ? 'none' : 'inline-flex';
+ okBtn.textContent = options.okText || '确定';
+ if (options.danger) {
+ okBtn.className = 'btn btn-primary btn-danger-glow';
+ } else {
+ okBtn.className = 'btn btn-primary';
+ }
+
+ if (options.showCheckbox && optContainer && chkBox) {
+ optContainer.style.display = 'block';
+ chkBox.checked = !!options.defaultChecked;
+ if (chkLabel && options.checkboxLabel) {
+ chkLabel.textContent = options.checkboxLabel;
+ }
+ } else if (optContainer) {
+ optContainer.style.display = 'none';
+ }
+
+ modalEl.style.display = 'flex';
+ modalEl.style.zIndex = '99999';
+ modalEl.classList.add('active');
+
+ let isResolved = false;
+ const cleanup = (result) => {
+ if (isResolved) return;
+ isResolved = true;
+ modalEl.classList.remove('active');
+ modalEl.style.display = 'none';
+ okBtn.removeEventListener('click', onOk);
+ cancelBtn.removeEventListener('click', onCancel);
+ modalEl.removeEventListener('click', onBackdrop);
+ document.querySelectorAll('.closeConfirmModal').forEach(el => el.removeEventListener('click', onCancel));
+
+ if (options.showCheckbox) {
+ const deleteFile = chkBox ? chkBox.checked : false;
+ resolve({ ok: result, deleteFile });
+ } else {
+ resolve(result);
+ }
+ };
+
+ const onOk = () => cleanup(true);
+ const onCancel = () => cleanup(false);
+ const onBackdrop = (e) => {
+ if (e.target === modalEl) cleanup(false);
+ };
+
+ okBtn.addEventListener('click', onOk);
+ cancelBtn.addEventListener('click', onCancel);
+ modalEl.addEventListener('click', onBackdrop);
+ document.querySelectorAll('.closeConfirmModal').forEach(el => el.addEventListener('click', onCancel));
+ });
+ }
+
+ function showAlert(title, message) {
+ return showConfirm(title, message, { hideCancel: true, okText: '知道了' });
+ }
+ window.showAlert = showAlert;
+
+ // 点击遮罩背景自动关闭新建任务与设置弹窗,防止界面假死
+ [newTaskModal, settingsModal].forEach(modal => {
+ if (modal) {
+ modal.addEventListener('click', (e) => {
+ if (e.target === modal) {
+ modal.classList.remove('active');
+ }
+ });
+ }
+ });
+
+ if (connectionStatus) {
+ connectionStatus.title = '点击查看 Aria2 引擎诊断日志';
+ connectionStatus.addEventListener('click', () => {
+ if (typeof window.showAria2Diagnostics === 'function') {
+ window.showAria2Diagnostics();
+ }
+ });
+ }
+
+ // 计数器
+ const countAll = document.getElementById('countAll');
+ const countActive = document.getElementById('countActive');
+ const countPaused = document.getElementById('countPaused');
+ const countStopped = document.getElementById('countStopped');
+
+ // 状态变量
+ let currentFilter = 'all';
+ let pollTimer = null;
+ let isConnected = false;
+ let allTasks = [];
+ const deletedGids = new Set();
+
+ // 从持久化存储读取已被用户删除的 GID 列表
+ try {
+ const savedDeleted = services.dbGet('aria2_deleted_gids');
+ if (savedDeleted) {
+ const arr = typeof savedDeleted === 'string' ? JSON.parse(savedDeleted) : savedDeleted;
+ if (Array.isArray(arr)) arr.forEach(g => deletedGids.add(String(g)));
+ }
+ } catch (e) {}
+
+ // ---------- 1. 初始化 Aria2 守护进程 ----------
+ async function initAria2Engine() {
+ if (statusText) statusText.innerText = '正在启动 Aria2 引擎...';
+
+ // 启动二进制进程
+ const launchRes = await services.startAria2({
+ port: settings.rpcPort,
+ secret: settings.rpcSecret,
+ dir: settings.saveDir,
+ fileAlloc: settings.fileAlloc || 'none'
+ });
+
+ if (!launchRes.success) {
+ if (connectionStatus) connectionStatus.className = 'status-badge disconnected';
+ if (launchRes.error === 'missing_binary') {
+ if (statusText) statusText.innerText = '未检测到 bin/aria2c.exe';
+ } else {
+ // 引擎启动阶段即失败(被拦截/端口占用/崩溃),直接展示真实原因与日志摘要
+ const logTail = launchRes.log
+ ? String(launchRes.log).split(/\r?\n/).filter(Boolean).slice(-6).join(' | ')
+ : '';
+ if (statusText) {
+ statusText.innerText = `引擎启动失败${launchRes.message ? ': ' + launchRes.message : ''}${logTail ? `(${logTail})` : ''} — 点击排障`;
+ }
+ }
+ return;
+ }
+
+ // 轮询检查 RPC 连通性 (最多尝试 10 次)
+ let lastErr = null;
+ for (let i = 0; i < 10; i++) {
+ try {
+ await aria2.getVersion();
+ isConnected = true;
+ window.isAria2Connected = true;
+ window.__aria2AutoRetryCount = 0;
+ if (connectionStatus) connectionStatus.className = 'status-badge connected';
+ if (statusText) statusText.innerText = '引擎运行中';
+
+ // 同步全局选项
+ try {
+ await aria2.changeGlobalOption({
+ 'file-allocation': settings.fileAlloc || 'none',
+ 'max-connection-per-server': String(settings.maxThreads || 16),
+ 'split': String(settings.maxThreads || 16)
+ });
+ } catch (err) {}
+
+ startPolling();
+ return;
+ } catch (e) {
+ lastErr = e;
+ await new Promise(r => setTimeout(r, 500));
+ }
+ }
+
+ isConnected = false;
+ window.isAria2Connected = false;
+ if (headerStats) headerStats.style.display = 'none';
+ if (connectionStatus) connectionStatus.className = 'status-badge disconnected';
+ const errDetail = lastErr ? (lastErr.message || String(lastErr)) : '超时';
+ if (statusText) statusText.innerText = `RPC 连接失败 (${errDetail}) (点击排障)`;
+
+ // 自动重试自愈(最多 3 次,间隔 3 秒):应对端口 TIME_WAIT / 杀软瞬时拦截 / 进程瞬间退出等瞬时故障
+ if (typeof window.__aria2AutoRetryCount !== 'number') window.__aria2AutoRetryCount = 0;
+ if (window.__aria2AutoRetryCount < 3) {
+ window.__aria2AutoRetryCount++;
+ setTimeout(initAria2Engine, 3000);
+ } else {
+ window.__aria2AutoRetryCount = 0;
+ }
+ }
+
+ // 立即启动 Aria2 引擎,确保不被后续 DOM 事件监听延迟或阻塞
+ initAria2Engine();
+
+ // 暴露全局诊断方法,方便用户在连接失败时点击排障
+ window.showAria2Diagnostics = async function() {
+ let logs = services.getAria2Logs ? services.getAria2Logs() : '无法获取日志';
+ if (logs) {
+ logs = logs.replace(/\r\n/g, '\n').replace(/\r/g, '\n').replace(/\n{3,}/g, '\n\n').trim();
+ }
+ const status = services.isAria2Running ? services.isAria2Running() : window.isAria2Connected;
+ const binInfo = (services.getAria2BinaryStatus && services.getAria2BinaryStatus()) || {};
+ const binStr = binInfo.found ? `✅ 已找到 → ${binInfo.path}` : '❌ 未找到 (检查插件 bin 目录)';
+ const msg = `【Aria2 守护进程诊断信息】\n- 进程运行状态: ${status ? '正在运行 (PID active)' : '未运行 / 已停止'}\n- aria2c 二进制: ${binStr}\n- RPC 监听端口: ${settings.rpcPort}\n- RPC 密钥 Token: ${settings.rpcSecret ? '已配置' : '未设置'}\n- 默认保存目录: ${settings.saveDir}\n\n【后台日志输出】\n${logs}`;
+ await showAlert('Aria2 引擎诊断面板', msg);
+ };
+
+ // ---------- 2. 定时刷新与数据拉取 ----------
+ function startPolling() {
+ if (pollTimer) clearInterval(pollTimer);
+ fetchTaskData();
+ pollTimer = setInterval(fetchTaskData, 800);
+ }
+
+ async function fetchTaskData() {
+ if (!isConnected) return;
+
+ try {
+ // 1. 全局数据
+ const stat = await aria2.getGlobalStat();
+ globalDlSpeed.innerText = formatSpeed(stat.downloadSpeed);
+ if (typeof globalUlSpeed !== 'undefined' && globalUlSpeed) {
+ globalUlSpeed.innerText = formatSpeed(stat.uploadSpeed);
+ }
+
+ // 2. 列表数据
+ const [active, waiting, stopped] = await Promise.all([
+ aria2.tellActive(),
+ aria2.tellWaiting(0, 100),
+ aria2.tellStopped(0, 100)
+ ]);
+
+ // 标记状态
+ const activeList = active.map(t => ({ ...t, uiStatus: 'active' }));
+ const waitingList = waiting.map(t => ({
+ ...t,
+ uiStatus: t.status === 'paused' ? 'paused' : 'waiting'
+ }));
+ const stoppedList = stopped.map(t => ({
+ ...t,
+ uiStatus: t.status === 'complete' ? 'completed' : 'error'
+ }));
+
+ // 读取本地持久化任务缓存(以防进程重启后未完结或已完成历史记录暂时丢失)
+ let cachedTasks = [];
+ try {
+ const rawCache = services.dbGet('aria2_task_cache');
+ if (rawCache) {
+ cachedTasks = typeof rawCache === 'string' ? JSON.parse(rawCache) : rawCache;
+ }
+ } catch (e) {}
+
+ const taskMap = new Map();
+ if (Array.isArray(cachedTasks)) {
+ cachedTasks.forEach(t => {
+ if (t && t.gid && !deletedGids.has(String(t.gid))) {
+ taskMap.set(String(t.gid), t);
+ }
+ });
+ }
+
+ // 用最新实时 RPC 传输层数据覆盖/更新
+ [...activeList, ...waitingList, ...stoppedList].forEach(t => {
+ if (t && t.gid && !deletedGids.has(String(t.gid))) {
+ taskMap.set(String(t.gid), t);
+ }
+ });
+
+ allTasks = Array.from(taskMap.values());
+
+ // 定期同步持久化保存所有下载记录
+ try {
+ const toSave = allTasks.map(t => ({
+ gid: t.gid,
+ status: t.status,
+ uiStatus: t.uiStatus,
+ totalLength: t.totalLength,
+ completedLength: t.completedLength,
+ downloadSpeed: '0',
+ dir: t.dir,
+ files: (t.files || []).map(f => ({ path: f.path, uris: f.uris })),
+ bittorrent: t.bittorrent ? { info: t.bittorrent.info } : undefined,
+ errorCode: t.errorCode
+ }));
+ services.dbSet('aria2_task_cache', JSON.stringify(toSave));
+ } catch (e) {}
+
+ // 更新计数
+ const downloadingCount = allTasks.filter(t => t.uiStatus === 'active' || t.uiStatus === 'waiting').length;
+ const pausedCount = allTasks.filter(t => t.uiStatus === 'paused').length;
+ const stoppedCount = allTasks.filter(t => t.uiStatus === 'completed' || t.uiStatus === 'error').length;
+
+ countAll.innerText = allTasks.length;
+ countActive.innerText = downloadingCount;
+ countPaused.innerText = pausedCount;
+ countStopped.innerText = stoppedCount;
+
+ // 动态控制全局下载速度卡片显隐:没有进行中/下载任务时隐藏速度
+ if (headerStats) {
+ if (activeList.length > 0) {
+ headerStats.style.display = 'flex';
+ } else {
+ headerStats.style.display = 'none';
+ }
+ }
+
+ renderTaskList();
+ checkTasksDiskSpace(allTasks);
+ } catch (e) {
+ console.warn('刷新任务异常:', e);
+ }
+ }
+
+ // 磁盘空间智能检测与自动停止机制
+ const spaceNotifiedGids = new Set();
+ async function checkTasksDiskSpace(tasks) {
+ if (!services.getDiskFreeSpace) return;
+ for (const t of tasks) {
+ if (spaceNotifiedGids.has(t.gid)) continue;
+
+ const fileName = getFileName(t);
+ const taskDir = t.dir || settings.saveDir;
+ const diskInfo = services.getDiskFreeSpace(taskDir);
+
+ // 1. 捕获 Aria2 抛出的磁盘不足错误 (errorCode 14 或 errorMessage 包含 space/disk)
+ const isDiskFullError = (t.status === 'error' && t.errorCode === '14') ||
+ (t.status === 'error' && t.errorMessage && /space|disk|full/i.test(t.errorMessage));
+ if (isDiskFullError) {
+ spaceNotifiedGids.add(t.gid);
+ showAlert('磁盘空间不足,下载已停止', `下载任务 "${fileName}" 因保存目标磁盘存储空间不足已被强行中断!请清理磁盘空间后重试。`);
+ continue;
+ }
+
+ // 2. 下载中或排队任务:如果已获取到文件总大小,且总大小大于磁盘剩余空间,立即停止任务并弹窗提醒
+ const total = parseInt(t.totalLength || 0, 10);
+ if ((t.uiStatus === 'active' || t.uiStatus === 'waiting') && total > 0 && diskInfo.success && total > diskInfo.freeBytes) {
+ spaceNotifiedGids.add(t.gid);
+ try {
+ await aria2.forcePause(t.gid);
+ } catch (e) {}
+ showAlert('磁盘空间不足,下载已自动停止', `检测到要下载的文件 "${fileName}" 体积大小为 ${formatBytes(total)},但保存路径磁盘(${taskDir.substring(0, 3)})剩余空间仅为 ${formatBytes(diskInfo.freeBytes)}!系统已为您自动停止该任务。`);
+ }
+ }
+ }
+
+ // ---------- 3. 任务列表增量渲染(避免频繁重绘 DOM 导致 :hover 闪烁抖动) ----------
+ // 绑定全局事件委托 (双重兼容 Element.closest 与 parentNode 循环向溯)
+ taskListEl.addEventListener('click', async (e) => {
+ let btn = (e.target && e.target.closest) ? e.target.closest('.action-btn') : null;
+ if (!btn) {
+ let curr = e.target;
+ while (curr && curr !== taskListEl && curr !== document.body) {
+ if (curr.classList && curr.classList.contains('action-btn')) {
+ btn = curr;
+ break;
+ }
+ curr = curr.parentElement || curr.parentNode;
+ }
+ }
+ if (!btn) return;
+
+ let card = (btn.closest) ? btn.closest('.task-card') : null;
+ if (!card) {
+ let curr = btn;
+ while (curr && curr !== taskListEl && curr !== document.body) {
+ if (curr.classList && curr.classList.contains('task-card')) {
+ card = curr;
+ break;
+ }
+ curr = curr.parentElement || curr.parentNode;
+ }
+ }
+ if (!card) return;
+
+ const gid = String(card.dataset.gid || '').trim();
+ const task = allTasks.find(item => String(item.gid).trim() === gid);
+ if (!task) return;
+
+ if (btn.classList.contains('btn-toggle-pause')) {
+ e.stopPropagation();
+ await togglePauseTask(task);
+ } else if (btn.classList.contains('btn-retry-task')) {
+ e.stopPropagation();
+ await retryTask(task);
+ } else if (btn.classList.contains('btn-open-folder')) {
+ e.stopPropagation();
+ openTaskFolder(task);
+ } else if (btn.classList.contains('btn-copy-link')) {
+ e.stopPropagation();
+ copyTaskLink(task);
+ } else if (btn.classList.contains('btn-delete-task')) {
+ e.stopPropagation();
+ await deleteTask(task);
+ }
+ });
+
+ function renderTaskList() {
+ let filtered = allTasks.filter(t => {
+ if (currentFilter === 'active') return t.uiStatus === 'active' || t.uiStatus === 'waiting';
+ if (currentFilter === 'paused') return t.uiStatus === 'paused';
+ if (currentFilter === 'stopped') return t.uiStatus === 'completed' || t.uiStatus === 'error';
+ return true;
+ });
+
+ if (filtered.length === 0) {
+ taskListEl.style.display = 'none';
+ emptyStateEl.style.display = 'flex';
+ taskListEl.innerHTML = '';
+ return;
+ }
+
+ taskListEl.style.display = 'flex';
+ emptyStateEl.style.display = 'none';
+
+ // 获取现有的 DOM 卡片 Map
+ const existingMap = new Map();
+ Array.from(taskListEl.children).forEach(card => {
+ if (card.dataset && card.dataset.gid) {
+ existingMap.set(card.dataset.gid, card);
+ }
+ });
+
+ const currentGids = new Set(filtered.map(t => t.gid));
+
+ // 移除已不存在的任务卡片
+ existingMap.forEach((card, gid) => {
+ if (!currentGids.has(gid)) {
+ card.remove();
+ }
+ });
+
+ // 增量更新或追加卡片
+ filtered.forEach((task, index) => {
+ let card = existingMap.get(task.gid);
+ if (!card) {
+ // 创建新 DOM 节点
+ const temp = document.createElement('div');
+ temp.innerHTML = buildTaskCardHtml(task);
+ card = temp.firstElementChild;
+ taskListEl.appendChild(card);
+ } else {
+ // 原位刷新数据,保证 DOM 节点持久不销毁
+ updateTaskCardContent(card, task);
+ }
+
+ // 保持 DOM 节点排序一致
+ if (taskListEl.children[index] !== card) {
+ taskListEl.insertBefore(card, taskListEl.children[index] || null);
+ }
+ });
+ }
+
+ function updateTaskCardContent(card, t) {
+ card.className = `task-card ${t.uiStatus}`;
+
+ const fileName = getFileName(t);
+ const filePath = getFilePath(t);
+
+ const nameEl = card.querySelector('.file-name');
+ if (nameEl && nameEl.textContent !== fileName) {
+ nameEl.textContent = fileName;
+ nameEl.title = fileName;
+ }
+
+ const pathEl = card.querySelector('.file-path');
+ if (pathEl && pathEl.textContent !== filePath) {
+ pathEl.textContent = filePath;
+ pathEl.title = filePath;
+ }
+
+ const completed = parseInt(t.completedLength || 0, 10);
+ const total = parseInt(t.totalLength || 0, 10);
+ const speed = parseInt(t.downloadSpeed || 0, 10);
+ const percent = total > 0 ? Math.min(100, Math.max(0, ((completed / total) * 100))).toFixed(1) : 0;
+
+ const isCompleted = t.uiStatus === 'completed';
+ const isPaused = t.uiStatus === 'paused';
+ const isActive = t.uiStatus === 'active';
+ const isError = t.uiStatus === 'error';
+ const connCount = parseInt(t.connections, 10);
+ const threadDisplay = connCount > 0 ? connCount : (parseInt(t.split || settings.maxThreads || 16, 10));
+
+ const etaText = speed > 0 && total > completed
+ ? formatETA((total - completed) / speed)
+ : (isCompleted ? '已完成' : (isError ? '下载失败' : '--:--'));
+
+ const progressBar = card.querySelector('.progress-bar-fill');
+ if (progressBar) progressBar.style.width = percent + '%';
+
+ const sizeTag = card.querySelector('.size-tag');
+ if (sizeTag) {
+ sizeTag.textContent = isError
+ ? `下载异常 (错误码: ${t.errorCode || 'ERR'})`
+ : `${formatBytes(completed)} / ${total > 0 ? formatBytes(total) : '未知大小'} (${percent}%)`;
+ }
+
+ const etaTag = card.querySelector('.eta-tag');
+ if (etaTag) {
+ etaTag.textContent = `剩余: ${etaText}`;
+ }
+
+ const threadsTag = card.querySelector('.threads-tag');
+ if (threadsTag) {
+ threadsTag.textContent = `${threadDisplay} 线程`;
+ }
+
+ const toggleBtn = card.querySelector('.btn-toggle-pause');
+ if (toggleBtn) {
+ if (isCompleted || isError) {
+ toggleBtn.style.display = 'none';
+ } else {
+ toggleBtn.style.display = 'inline-flex';
+ toggleBtn.title = isPaused ? '恢复下载' : '暂停下载';
+ toggleBtn.innerHTML = isPaused
+ ? ``
+ : ``;
+ }
+ }
+
+ const retryBtn = card.querySelector('.btn-retry-task');
+ if (retryBtn) {
+ retryBtn.style.display = isError ? 'inline-flex' : 'none';
+ }
+
+ const statsLeft = card.querySelector('.task-stats-left');
+ if (statsLeft) {
+ let speedTag = statsLeft.querySelector('.speed-tag');
+ if (isActive && speed > 0) {
+ if (!speedTag) {
+ speedTag = document.createElement('span');
+ speedTag.className = 'speed-tag';
+ statsLeft.insertBefore(speedTag, statsLeft.firstChild);
+ }
+ speedTag.textContent = `⚡ ${formatSpeed(speed)}`;
+ } else if (speedTag) {
+ speedTag.remove();
+ }
+ }
+ }
+
+ function buildTaskCardHtml(t) {
+ const fileName = getFileName(t);
+ const filePath = getFilePath(t);
+ const completed = parseInt(t.completedLength || 0, 10);
+ const total = parseInt(t.totalLength || 0, 10);
+ const speed = parseInt(t.downloadSpeed || 0, 10);
+ const percent = total > 0 ? Math.min(100, Math.max(0, ((completed / total) * 100))).toFixed(1) : 0;
+
+ const isCompleted = t.uiStatus === 'completed';
+ const isPaused = t.uiStatus === 'paused';
+ const isActive = t.uiStatus === 'active';
+ const isError = t.uiStatus === 'error';
+ const connCount = parseInt(t.connections, 10);
+ const threadDisplay = connCount > 0 ? connCount : (parseInt(t.split || settings.maxThreads || 16, 10));
+
+ const etaText = speed > 0 && total > completed
+ ? formatETA((total - completed) / speed)
+ : (isCompleted ? '已完成' : (isError ? '下载失败' : '--:--'));
+
+ return `
+
+
+
+
+
+
+
+
+ ${isActive ? `⚡ ${formatSpeed(speed)}` : ''}
+ ${isError ? `下载异常 (错误码: ${t.errorCode || 'ERR'})` : `${formatBytes(completed)} / ${total > 0 ? formatBytes(total) : '未知大小'} (${percent}%)`}
+ 剩余: ${etaText}
+
+
${threadDisplay} 线程
+
+
+ `;
+ }
+
+ // ---------- 4. 任务交互动作 ----------
+ async function togglePauseTask(task) {
+ try {
+ if (task.status === 'paused' || task.uiStatus === 'paused') {
+ await aria2.unpause(task.gid);
+ } else {
+ // 先尝试优雅暂停,失败则强制暂停
+ try {
+ await aria2.pause(task.gid);
+ } catch (err) {
+ await aria2.forcePause(task.gid);
+ }
+ }
+ setTimeout(fetchTaskData, 200);
+ } catch (e) {
+ console.error('切换暂停/恢复失败:', e);
+ }
+ }
+
+ async function retryTask(task) {
+ try {
+ // 提取原始 URIs
+ let uris = [];
+ if (task.files && task.files.length > 0) {
+ uris = task.files.flatMap(f => (f.uris || []).map(u => u.uri)).filter(Boolean);
+ }
+
+ const saveDir = task.dir || settings.saveDir;
+ const threads = parseInt(settings.maxThreads || 16, 10);
+ const options = {
+ dir: saveDir,
+ 'max-connection-per-server': String(threads),
+ split: String(threads),
+ 'file-allocation': 'none',
+ 'async-dns': 'false',
+ 'enable-async-dns6': 'false',
+ 'check-certificate': 'false',
+ 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36',
+ header: [
+ 'Accept: */*',
+ 'Accept-Language: zh-CN,zh;q=0.9,en;q=0.8'
+ ],
+ 'allow-overwrite': 'true',
+ 'auto-file-renaming': 'true',
+ 'continue': 'true'
+ };
+
+ if (task.files && task.files[0] && task.files[0].path) {
+ const parts = task.files[0].path.split(/[/\\]/);
+ const baseName = parts[parts.length - 1];
+ if (baseName && !baseName.startsWith('[METADATA]')) {
+ options.out = baseName;
+ }
+ }
+
+ // 1. 清除当前失败记录
+ try {
+ await aria2.removeDownloadResult(task.gid);
+ } catch (err) {}
+
+ // 2. 重新提交任务
+ if (uris.length > 0) {
+ await aria2.addUri(uris, options);
+ } else {
+ try {
+ await aria2.unpause(task.gid);
+ } catch (err) {}
+ }
+
+ services.showNotification('重新发起下载', `已重试任务“${getFileName(task)}”`);
+ setTimeout(fetchTaskData, 200);
+ } catch (e) {
+ await showAlert('重试失败', e.message || String(e));
+ }
+ }
+
+ function openTaskFolder(task) {
+ const p = getFilePath(task);
+ const saveDir = (task && task.dir) ? task.dir : settings.saveDir;
+ services.openInExplorer(p, saveDir);
+ }
+
+ function copyTaskLink(task) {
+ const url = getTaskUrl(task);
+ if (url) {
+ const cleanUrl = url.trim().replace(/^['"]+|['"]+$/g, '');
+ navigator.clipboard.writeText(cleanUrl);
+ services.showNotification('已复制链接', cleanUrl);
+ }
+ }
+
+ // ---------- 模态框与弹窗辅助函数 ----------
+ function showConfirm(title, message, options = {}) {
+ return new Promise((resolve) => {
+ const confirmModal = document.getElementById('confirmModal');
+ const confirmTitle = document.getElementById('confirmTitle');
+ const confirmMessage = document.getElementById('confirmMessage');
+ const confirmOptionContainer = document.getElementById('confirmOptionContainer');
+ const chkDeleteFile = document.getElementById('chkDeleteFile');
+ const chkDeleteFileLabel = document.getElementById('chkDeleteFileLabel');
+ const btnConfirmCancel = document.getElementById('btnConfirmCancel');
+ const btnConfirmOk = document.getElementById('btnConfirmOk');
+
+ if (!confirmModal) {
+ const isOk = window.confirm(`${title}\n\n${message}`);
+ resolve(isOk);
+ return;
+ }
+
+ confirmTitle.innerText = title || '确认操作';
+ confirmMessage.innerText = message || '';
+
+ if (options.showCheckbox) {
+ confirmOptionContainer.style.display = 'block';
+ chkDeleteFile.checked = false;
+ chkDeleteFileLabel.innerText = options.checkboxLabel || '同时删除本地已下载的文件';
+ } else {
+ confirmOptionContainer.style.display = 'none';
+ }
+
+ btnConfirmOk.innerText = options.okText || '确定';
+ if (options.danger) {
+ btnConfirmOk.classList.add('btn-danger-glow');
+ } else {
+ btnConfirmOk.classList.remove('btn-danger-glow');
+ }
+
+ confirmModal.classList.add('active');
+
+ const cleanup = () => {
+ confirmModal.classList.remove('active');
+ btnConfirmOk.removeEventListener('click', onOk);
+ btnConfirmCancel.removeEventListener('click', onCancel);
+ const closeBtn = confirmModal.querySelector('.closeConfirmModal');
+ if (closeBtn) closeBtn.removeEventListener('click', onCancel);
+ };
+
+ const onOk = () => {
+ cleanup();
+ if (options.showCheckbox) {
+ resolve({ ok: true, deleteFile: chkDeleteFile.checked });
+ } else {
+ resolve(true);
+ }
+ };
+
+ const onCancel = () => {
+ cleanup();
+ if (options.showCheckbox) {
+ resolve({ ok: false, deleteFile: false });
+ } else {
+ resolve(false);
+ }
+ };
+
+ btnConfirmOk.addEventListener('click', onOk);
+ btnConfirmCancel.addEventListener('click', onCancel);
+ const closeBtn = confirmModal.querySelector('.closeConfirmModal');
+ if (closeBtn) closeBtn.addEventListener('click', onCancel);
+ });
+ }
+
+ function showAlert(title, message) {
+ return showConfirm(title, message, { okText: '我知道了', showCheckbox: false });
+ }
+
+ async function deleteTask(task) {
+ const fileName = getFileName(task);
+ const filePath = getFilePath(task);
+
+ const res = await showConfirm(
+ '🗑️ 删除下载任务',
+ `确定要删除任务“${fileName}”吗?`,
+ {
+ danger: true,
+ okText: '确定删除',
+ showCheckbox: true,
+ checkboxLabel: '同时删除本地已下载的文件'
+ }
+ );
+
+ let ok = false;
+ let shouldDeleteFile = false;
+ if (typeof res === 'object' && res !== null) {
+ ok = res.ok;
+ shouldDeleteFile = res.deleteFile;
+ } else {
+ ok = !!res;
+ }
+
+ if (!ok) return;
+
+ // 1. 记录删除 ID,立即从前端视图列表中移除并刷新 DOM,给予用户零延迟的即时反馈
+ const gidStr = String(task.gid);
+ deletedGids.add(gidStr);
+ try {
+ services.dbSet('aria2_deleted_gids', JSON.stringify(Array.from(deletedGids)));
+ } catch (e) {}
+ allTasks = allTasks.filter(t => String(t.gid) !== gidStr);
+ try {
+ services.dbSet('aria2_task_cache', JSON.stringify(allTasks));
+ } catch (e) {}
+ renderTaskList();
+
+ // 2. 向 Aria2 引擎发送删除与清理指令
+ try {
+ if (task.status === 'active' || task.status === 'waiting' || task.status === 'paused') {
+ try { await aria2.forceRemove(task.gid); } catch (err) {}
+ try { await aria2.remove(task.gid); } catch (err) {}
+ }
+ } catch (e) {}
+
+ // 稍做延时确保 Aria2 状态平滑流转到 stopped,然后仅清理当前 GID 的历史结果与保存 Session
+ setTimeout(async () => {
+ try {
+ try { await aria2.removeDownloadResult(task.gid); } catch (err) {}
+ try { await aria2.saveSession(); } catch (err) {}
+ } catch (e) {}
+ }, 150);
+
+ // 3. 删除本地已下载文件(若用户勾选)
+ if (shouldDeleteFile && filePath) {
+ try {
+ services.removeLocalFile(filePath);
+ services.removeLocalFile(filePath + '.aria2');
+ services.showNotification('删除成功', `已同步删除本地文件“${fileName}”`);
+ } catch (e) {}
+ } else {
+ services.showNotification('删除成功', `已移除下载任务“${fileName}”`);
+ }
+
+ setTimeout(fetchTaskData, 400);
+ }
+
+ // 暴露全局 Window 方法,双重兜底确保内联 onclick 直接生效
+ window.deleteTaskByGid = function(gid) {
+ const task = allTasks.find(item => String(item.gid).trim() === String(gid).trim());
+ if (task) deleteTask(task);
+ };
+ window.toggleTaskByGid = function(gid) {
+ const task = allTasks.find(item => String(item.gid).trim() === String(gid).trim());
+ if (task) togglePauseTask(task);
+ };
+ window.retryTaskByGid = function(gid) {
+ const task = allTasks.find(item => String(item.gid).trim() === String(gid).trim());
+ if (task) retryTask(task);
+ };
+ window.openTaskFolderByGid = function(gid) {
+ const task = allTasks.find(item => String(item.gid).trim() === String(gid).trim());
+ if (task) openTaskFolder(task);
+ };
+ window.copyTaskLinkByGid = function(gid) {
+ const task = allTasks.find(item => String(item.gid).trim() === String(gid).trim());
+ if (task) copyTaskLink(task);
+ };
+
+ // ---------- 5. 模态框与新建任务 ----------
+ const btnNewTask = document.getElementById('btnNewTask');
+ const btnOpenSettings = document.getElementById('btnOpenSettings');
+ const btnSubmitNewTask = document.getElementById('btnSubmitNewTask');
+ const btnSaveSettings = document.getElementById('btnSaveSettings');
+ const inputUrl = document.getElementById('inputUrl');
+ const inputSaveDir = document.getElementById('inputSaveDir');
+ const inputOutName = document.getElementById('inputOutName');
+ const inputThreads = document.getElementById('inputThreads');
+ const btnBrowseDir = document.getElementById('btnBrowseDir');
+
+ if (btnNewTask) {
+ btnNewTask.addEventListener('click', () => {
+ // 自动侦测剪贴板
+ const clipText = services.getClipboardText() || '';
+ if (clipText && (clipText.startsWith('http://') || clipText.startsWith('https://') || clipText.startsWith('ftp://') || clipText.startsWith('magnet:?'))) {
+ if (inputUrl) inputUrl.value = clipText;
+ } else if (inputUrl && !inputUrl.value) {
+ inputUrl.value = '';
+ }
+
+ if (inputSaveDir) inputSaveDir.value = settings.saveDir;
+ if (inputThreads) inputThreads.value = settings.maxThreads;
+ if (newTaskModal) newTaskModal.classList.add('active');
+ });
+ }
+
+ if (btnBrowseDir) {
+ btnBrowseDir.addEventListener('click', () => {
+ const chosen = services.selectFolder(inputSaveDir ? inputSaveDir.value : '');
+ if (chosen && inputSaveDir) inputSaveDir.value = chosen;
+ });
+ }
+
+ if (btnSubmitNewTask) {
+ btnSubmitNewTask.addEventListener('click', async () => {
+ const rawUrl = inputUrl ? inputUrl.value.trim() : '';
+ if (!rawUrl) {
+ await showAlert('提示', '请填入要下载的 URL 链接!');
+ return;
+ }
+
+ const urls = rawUrl.split('\n')
+ .map(u => {
+ let cleaned = u.trim().replace(/^['"]+|['"]+$/g, '').trim();
+ cleaned = cleaned.replace(/[\u200B-\u200D\uFEFF]/g, '');
+ if (!cleaned) return null;
+ try {
+ if (cleaned.includes(' ') || /[\u4e00-\u9fa5]/.test(cleaned)) {
+ cleaned = encodeURI(decodeURI(cleaned));
+ }
+ } catch (e) {
+ if (cleaned.includes(' ')) {
+ cleaned = cleaned.replace(/ /g, '%20');
+ }
+ }
+ return cleaned;
+ })
+ .filter(Boolean);
+
+ if (urls.length === 0) {
+ await showAlert('提示', '请填入有效的 URL 下载链接!');
+ return;
+ }
+
+ const saveDir = (inputSaveDir && inputSaveDir.value.trim()) || settings.saveDir;
+ const threads = parseInt(inputThreads ? inputThreads.value : '16', 10) || 16;
+ const outName = inputOutName ? inputOutName.value.trim() : '';
+
+ // 磁盘空间前置拦截检查
+ if (services.getDiskFreeSpace) {
+ const diskInfo = services.getDiskFreeSpace(saveDir);
+ if (diskInfo && diskInfo.success && diskInfo.freeBytes < 50 * 1024 * 1024) {
+ await showAlert('磁盘空间不足', `目标保存路径所在磁盘(${saveDir.substring(0, 3)})可用空间仅剩 ${formatBytes(diskInfo.freeBytes)}!存储空间极度不足,无法新建下载任务,请清理磁盘后重试。`);
+ return;
+ }
+ }
+
+ try {
+ const options = {
+ dir: saveDir,
+ 'max-connection-per-server': String(threads),
+ split: String(threads),
+ 'file-allocation': 'none',
+ 'async-dns': 'false',
+ 'enable-async-dns6': 'false',
+ 'check-certificate': 'false',
+ 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36',
+ header: [
+ 'Accept: */*',
+ 'Accept-Language: zh-CN,zh;q=0.9,en;q=0.8'
+ ],
+ 'allow-overwrite': 'true',
+ 'auto-file-renaming': 'true',
+ 'continue': 'true'
+ };
+ if (outName && urls.length === 1) {
+ options.out = outName;
+ }
+
+ await aria2.addUri(urls, options);
+
+ if (newTaskModal) newTaskModal.classList.remove('active');
+ if (inputUrl) inputUrl.value = '';
+ if (inputOutName) inputOutName.value = '';
+ services.showNotification('已添加下载任务', `解析成功,开始多线程下载...`);
+ fetchTaskData();
+ } catch (e) {
+ await showAlert('创建任务失败', e.message || String(e));
+ }
+ });
+ }
+
+ // ---------- 6. 设置模态框 ----------
+ const settingSaveDir = document.getElementById('settingSaveDir');
+ const settingMaxThreads = document.getElementById('settingMaxThreads');
+ const settingRpcPort = document.getElementById('settingRpcPort');
+ const settingRpcSecret = document.getElementById('settingRpcSecret');
+ const btnSettingBrowseDir = document.getElementById('btnSettingBrowseDir');
+
+ if (btnOpenSettings) {
+ btnOpenSettings.addEventListener('click', () => {
+ if (settingSaveDir) settingSaveDir.value = settings.saveDir;
+ if (settingMaxThreads) settingMaxThreads.value = settings.maxThreads;
+ if (settingRpcPort) settingRpcPort.value = settings.rpcPort;
+ if (settingRpcSecret) settingRpcSecret.value = settings.rpcSecret;
+ if (settingsModal) settingsModal.classList.add('active');
+ });
+ }
+
+ if (btnSettingBrowseDir) {
+ btnSettingBrowseDir.addEventListener('click', () => {
+ const chosen = services.selectFolder(settingSaveDir ? settingSaveDir.value : '');
+ if (chosen && settingSaveDir) settingSaveDir.value = chosen;
+ });
+ }
+
+ if (btnSaveSettings) {
+ btnSaveSettings.addEventListener('click', async () => {
+ settings.saveDir = (settingSaveDir && settingSaveDir.value) || services.getDefaultDownloadDir();
+ settings.maxThreads = parseInt(settingMaxThreads ? settingMaxThreads.value : '16', 10) || 16;
+ settings.fileAlloc = 'none';
+ settings.rpcPort = parseInt(settingRpcPort ? settingRpcPort.value : '6800', 10) || 6800;
+ settings.rpcSecret = settingRpcSecret ? settingRpcSecret.value.trim() : '';
+
+ services.dbSet('aria2_settings', JSON.stringify(settings));
+ aria2.updateConfig({ port: settings.rpcPort, secret: settings.rpcSecret });
+
+ // 重置/重启后台进程使新的 CLI 参数生效
+ if (services.restartAria2) {
+ services.restartAria2({
+ port: settings.rpcPort,
+ secret: settings.rpcSecret,
+ dir: settings.saveDir,
+ fileAlloc: settings.fileAlloc
+ });
+ }
+
+ if (settingsModal) settingsModal.classList.remove('active');
+ initAria2Engine();
+ });
+ }
+
+ // 关闭 Modal
+ document.querySelectorAll('.closeModal').forEach(btn => {
+ btn.addEventListener('click', () => {
+ if (newTaskModal) newTaskModal.classList.remove('active');
+ if (settingsModal) settingsModal.classList.remove('active');
+ const cModal = document.getElementById('confirmModal');
+ if (cModal) {
+ cModal.classList.remove('active');
+ cModal.style.display = 'none';
+ }
+ });
+ });
+
+ // 标签过滤
+ document.querySelectorAll('.tab-btn').forEach(btn => {
+ btn.addEventListener('click', (e) => {
+ document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
+ e.target.classList.add('active');
+ currentFilter = e.target.dataset.filter;
+ renderTaskList();
+ });
+ });
+
+ function getFileName(t) {
+ if (!t) return '未命名下载任务';
+
+ // 1. 优先获取 Aria2 在磁盘上落地的真实文件名
+ if (t.files && t.files.length > 0 && t.files[0].path) {
+ let raw = t.files[0].path.split(/[/\\]/).pop();
+ if (raw) {
+ raw = raw.split('?')[0].split('#')[0];
+ try {
+ raw = decodeURIComponent(raw);
+ } catch (e) {}
+
+ if (raw && !raw.startsWith('[METADATA]')) {
+ return raw;
+ }
+ }
+ }
+
+ // 2. 若是 BT / 磁力任务且已解析出种子信息
+ if (t.bittorrent && t.bittorrent.info && t.bittorrent.info.name) {
+ return t.bittorrent.info.name;
+ }
+
+ // 3. 从原始 URL 解析文件名
+ const url = getTaskUrl(t);
+ if (url) {
+ try {
+ const u = new URL(url);
+ let name = u.pathname.split('/').pop();
+ if (name) {
+ name = name.split('?')[0].split('#')[0];
+ try {
+ name = decodeURIComponent(name);
+ } catch (e) {}
+ if (name && name.length > 0) return name;
+ }
+ } catch (e) {}
+ }
+
+ // 4. 磁力链解析中状态
+ if (t.files && t.files[0] && t.files[0].path && t.files[0].path.includes('[METADATA]')) {
+ return '正在解析磁力种子信息...';
+ }
+
+ return '未命名下载任务_' + t.gid;
+ }
+
+ function getFilePath(t) {
+ const saveDir = (t && t.dir) ? t.dir : (settings ? settings.saveDir : 'C:\\Downloads');
+ let rawPath = '';
+ if (t && t.files && t.files.length > 0 && t.files[0].path) {
+ rawPath = t.files[0].path;
+ }
+
+ const fileName = getFileName(t);
+
+ if (rawPath) {
+ // 判断是否包含 Windows 绝对盘符 (如 C:\)
+ if (/^[a-zA-Z]:[\\/]|^\\\\/.test(rawPath)) {
+ return rawPath.replace(/\//g, '\\');
+ }
+ // 相对路径:拼接到保存目录 saveDir 下
+ const cleanDir = saveDir.replace(/[\\/]+$/, '');
+ const cleanRaw = rawPath.replace(/^[\\/]+/, '');
+ return (cleanDir + '\\' + cleanRaw).replace(/\//g, '\\');
+ }
+
+ const cleanDir = saveDir.replace(/[\\/]+$/, '');
+ return (cleanDir + '\\' + fileName).replace(/\//g, '\\');
+ }
+
+ function getTaskUrl(t) {
+ if (t.files && t.files.length > 0 && t.files[0].uris && t.files[0].uris.length > 0) {
+ return t.files[0].uris[0].uri;
+ }
+ return '';
+ }
+
+ function formatBytes(bytes) {
+ const b = parseInt(bytes || 0, 10);
+ if (b === 0) return '0 B';
+ const k = 1024;
+ const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
+ const i = Math.floor(Math.log(b) / Math.log(k));
+ return parseFloat((b / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
+ }
+
+ function formatSpeed(bytesPerSec) {
+ return formatBytes(bytesPerSec) + '/s';
+ }
+
+ function formatETA(seconds) {
+ const s = Math.max(0, Math.floor(seconds));
+ if (s > 86400) return '> 1 天';
+ const h = Math.floor(s / 3600).toString().padStart(2, '0');
+ const m = Math.floor((s % 3600) / 60).toString().padStart(2, '0');
+ const sec = (s % 60).toString().padStart(2, '0');
+ return `${h}:${m}:${sec}`;
+ }
+
+ function escapeHtml(str) {
+ return String(str || '').replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"');
+ }
+
+ function getFileTypeSvg(name) {
+ const ext = (name.split('.').pop() || '').toLowerCase();
+ if (['zip', 'rar', '7z', 'tar', 'gz'].includes(ext)) {
+ return `
`;
+ }
+ if (['mp4', 'mkv', 'avi', 'mov', 'flv'].includes(ext)) {
+ return `
`;
+ }
+ if (['png', 'jpg', 'jpeg', 'gif', 'webp'].includes(ext)) {
+ return `
`;
+ }
+ if (['exe', 'msi', 'iso', 'dmg'].includes(ext)) {
+ return `
`;
+ }
+ return `
`;
+ }
+}
+
+if (document.readyState === 'loading') {
+ document.addEventListener('DOMContentLoaded', startApp);
+} else {
+ startApp();
+}
diff --git a/plugins/ztools-download/js/aria2-rpc.js b/plugins/ztools-download/js/aria2-rpc.js
new file mode 100644
index 000000000..45ca845e8
--- /dev/null
+++ b/plugins/ztools-download/js/aria2-rpc.js
@@ -0,0 +1,166 @@
+/**
+ * Aria2 JSON-RPC 客户端封装库
+ */
+class Aria2Client {
+ constructor(options = {}) {
+ this.host = options.host || '127.0.0.1';
+ this.port = options.port || 6800;
+ this.secret = options.secret || '';
+ this.protocol = options.protocol || 'http';
+ }
+
+ updateConfig(options = {}) {
+ if (options.host) this.host = options.host;
+ if (options.port) this.port = options.port;
+ if (options.secret !== undefined) this.secret = options.secret;
+ }
+
+ get rpcUrl() {
+ return `${this.protocol}://${this.host}:${this.port}/jsonrpc`;
+ }
+
+ async call(method, params = []) {
+ const finalParams = [];
+ if (this.secret) {
+ finalParams.push(`token:${this.secret}`);
+ }
+ if (Array.isArray(params)) {
+ finalParams.push(...params);
+ } else if (params !== undefined) {
+ finalParams.push(params);
+ }
+
+ const payload = {
+ jsonrpc: '2.0',
+ id: `ztools_${Date.now()}_${Math.random().toString(36).substring(2, 6)}`,
+ method: `aria2.${method}`,
+ params: finalParams
+ };
+
+ // 优先使用 window.services.aria2Rpc (Node.js 原生 http 通信,规避 Web Fetch 的 CORS/PNA 限制)
+ if (window.services && typeof window.services.aria2Rpc === 'function') {
+ return await window.services.aria2Rpc(payload, this.port);
+ }
+
+ try {
+ const response = await fetch(this.rpcUrl, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json'
+ },
+ body: JSON.stringify(payload)
+ });
+
+ if (!response.ok) {
+ throw new Error(`HTTP Error: ${response.status} ${response.statusText}`);
+ }
+
+ const json = await response.json();
+ if (json.error) {
+ throw new Error(json.error.message || 'RPC Error');
+ }
+ return json.result;
+ } catch (err) {
+ throw err;
+ }
+ }
+
+ // ---------------- Aria2 RPC 常用 API 方法 ----------------
+
+ /** 获取 Aria2 版本及功能信息 */
+ async getVersion() {
+ return await this.call('getVersion');
+ }
+
+ /** 获取全局统计(实时下载速度、上传速度、活动任务数等) */
+ async getGlobalStat() {
+ return await this.call('getGlobalStat');
+ }
+
+ /**
+ * 添加下载任务
+ * @param {string[]} uris 下载链接数组 [url1, url2]
+ * @param {Object} options 下载参数 (dir, out, max-connection-per-server, split 等)
+ */
+ async addUri(uris, options = {}) {
+ const uriList = Array.isArray(uris) ? uris : [uris];
+ return await this.call('addUri', [uriList, options]);
+ }
+
+ /** 获取活动中(正在下载)的任务列表 */
+ async tellActive(keys = []) {
+ return await this.call('tellActive', [keys]);
+ }
+
+ /** 获取等待中/暂停的任务列表 */
+ async tellWaiting(offset = 0, num = 100, keys = []) {
+ return await this.call('tellWaiting', [offset, num, keys]);
+ }
+
+ /** 获取已停止/已完成/已失败的任务列表 */
+ async tellStopped(offset = 0, num = 100, keys = []) {
+ return await this.call('tellStopped', [offset, num, keys]);
+ }
+
+ /** 获取指定 GID 任务状态信息 */
+ async tellStatus(gid, keys = []) {
+ return await this.call('tellStatus', [gid, keys]);
+ }
+
+ /** 暂停任务 */
+ async pause(gid) {
+ return await this.call('pause', [gid]);
+ }
+
+ /** 强制暂停任务 */
+ async forcePause(gid) {
+ return await this.call('forcePause', [gid]);
+ }
+
+ /** 恢复暂停的任务 */
+ async unpause(gid) {
+ return await this.call('unpause', [gid]);
+ }
+
+ /** 取消/移除正在进行或等待中的任务 */
+ async remove(gid) {
+ return await this.call('remove', [gid]);
+ }
+
+ /** 强制移除任务 */
+ async forceRemove(gid) {
+ return await this.call('forceRemove', [gid]);
+ }
+
+ /** 从已停止列表中清理/移除下载结果记录 */
+ async removeDownloadResult(gid) {
+ return await this.call('removeDownloadResult', [gid]);
+ }
+
+ /** 清理所有已完成/已停止的记录 */
+ async purgeDownloadResult() {
+ return await this.call('purgeDownloadResult');
+ }
+
+ /** 改变单个任务的配置参数 */
+ async changeOption(gid, options) {
+ return await this.call('changeOption', [gid, options]);
+ }
+
+ /** 获取全局默认参数 */
+ async getGlobalOption() {
+ return await this.call('getGlobalOption');
+ }
+
+ /** 修改全局参数 */
+ async changeGlobalOption(options) {
+ return await this.call('changeGlobalOption', [options]);
+ }
+
+ /** 保存当前会话记录到 session 文件 */
+ async saveSession() {
+ return await this.call('saveSession');
+ }
+}
+
+window.Aria2Client = Aria2Client;
diff --git a/plugins/ztools-download/logo.png b/plugins/ztools-download/logo.png
new file mode 100644
index 000000000..5daf9358c
Binary files /dev/null and b/plugins/ztools-download/logo.png differ
diff --git a/plugins/ztools-download/plugin.json b/plugins/ztools-download/plugin.json
new file mode 100644
index 000000000..4b8b56885
--- /dev/null
+++ b/plugins/ztools-download/plugin.json
@@ -0,0 +1,28 @@
+{
+ "name": "ztools-download",
+ "title": "Aria2 多线程下载器",
+ "pluginName": "Aria2 多线程下载器",
+ "description": "基于 Aria2 的高性能多线程下载工具,支持最高16线程分片加速、断点续传与剪贴板快捷吸附",
+ "version": "1.0.0",
+ "author": "zhanglei",
+ "main": "index.html",
+ "preload": "preload.js",
+ "logo": "logo.png",
+ "features": [
+ {
+ "code": "download",
+ "explain": "打开 Aria2 多线程下载器",
+ "cmds": [
+ "download",
+ "aria2",
+ "下载",
+ "下载器",
+ "多线程下载",
+ "xz"
+ ]
+ }
+ ],
+ "platform": [
+ "win32"
+ ]
+}
\ No newline at end of file
diff --git a/plugins/ztools-download/preload.js b/plugins/ztools-download/preload.js
new file mode 100644
index 000000000..02c458a4d
--- /dev/null
+++ b/plugins/ztools-download/preload.js
@@ -0,0 +1,617 @@
+console.log('ztools-download preload.js loaded!');
+
+const { spawn, exec, execSync } = require('child_process');
+const fs = require('fs');
+const path = require('path');
+const os = require('os');
+const http = require('http');
+
+const ztools = window.ztools || window.utools || {};
+let aria2Process = null;
+let currentRpcPort = 6800;
+let currentFileAlloc = 'none';
+let lastAria2Log = '';
+
+// ztools 插件入口钩子
+window.exports = {
+ 'download': {
+ mode: 'none',
+ args: {
+ enter() {
+ if (ztools.setExpendHeight) ztools.setExpendHeight(660);
+ if (ztools.showMainWindow) ztools.showMainWindow();
+ },
+ leave() {
+ // 插件隐藏/切出切回时不关停 aria2c,保证后台下载持续进行
+ }
+ }
+ }
+};
+
+function killAria2Process() {
+ if (aria2Process) {
+ try {
+ aria2Process.kill('SIGKILL');
+ if (aria2Process.pid) {
+ execSync(`taskkill /F /PID ${aria2Process.pid}`, { stdio: 'ignore' });
+ }
+ } catch (e) {}
+ aria2Process = null;
+ }
+ // 强制清理残余的 aria2c.exe 进程
+ try {
+ execSync('taskkill /F /IM aria2c.exe', { stdio: 'ignore' });
+ } catch (e) {}
+}
+
+function getSystemProxy() {
+ try {
+ const envProxy = process.env.HTTP_PROXY || process.env.HTTPS_PROXY || process.env.http_proxy || process.env.https_proxy;
+ if (envProxy) return envProxy;
+
+ // 自动检测 Windows 系统代理设置 (注册表查询 ProxyEnable 与 ProxyServer)
+ const regRes = execSync('reg query "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings" /v ProxyEnable', { encoding: 'utf8', stdio: 'pipe' });
+ if (regRes && regRes.includes('0x1')) {
+ const serverRes = execSync('reg query "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings" /v ProxyServer', { encoding: 'utf8', stdio: 'pipe' });
+ const match = serverRes.match(/ProxyServer\s+REG_SZ\s+(.+)/);
+ if (match && match[1]) {
+ let p = match[1].trim();
+ if (p.includes(';')) p = p.split(';')[0];
+ if (p.includes('=')) p = p.split('=')[1];
+ if (!p.startsWith('http://') && !p.startsWith('https://')) p = 'http://' + p;
+ return p;
+ }
+ }
+ } catch (e) {}
+ return null;
+}
+
+// 仅在进程真正退出 (SIGINT/SIGTERM/process exit) 时关闭 Aria2 守护进程
+// 切勿在 unload/beforeunload 钩子中杀死进程,确保切出插件或隐藏窗口时后台下载不中断
+
+// 查找/验证 aria2c 可执行文件路径
+function resolveAria2Path() {
+ const pluginRoot = __dirname;
+ const cwd = process.cwd();
+
+ const candidates = [
+ path.join(pluginRoot, 'bin', 'aria2c.exe'),
+ path.join(pluginRoot, 'aria2c.exe'),
+ path.join(cwd, 'bin', 'aria2c.exe'),
+ path.join(cwd, 'aria2c.exe'),
+ path.join(pluginRoot, '..', 'bin', 'aria2c.exe')
+ ];
+
+ let foundPath = null;
+
+ for (const p of candidates) {
+ try {
+ if (fs.existsSync(p)) {
+ foundPath = p;
+ break;
+ }
+ } catch (e) {}
+ }
+
+ // 查系统 PATH
+ if (!foundPath) {
+ try {
+ const whichRes = execSync('where aria2c.exe', { encoding: 'utf8', stdio: 'pipe' });
+ if (whichRes && whichRes.trim()) {
+ foundPath = whichRes.trim().split('\r\n')[0].trim();
+ }
+ } catch (e) {}
+ }
+
+ if (!foundPath) {
+ return null;
+ }
+
+ // 特殊处理 Electron / zTools .asar 包机制:
+ // child_process.spawn 无法直接执行 asar 内部的二进制 .exe 文件 (会报 ENOENT 错误)
+ // 如果路径包含 .asar,必须解压提取至系统真实临时目录 (os.tmpdir)
+ if (foundPath.includes('.asar')) {
+ try {
+ const tempDir = path.join(os.tmpdir(), 'ztools-download-bin');
+ if (!fs.existsSync(tempDir)) {
+ fs.mkdirSync(tempDir, { recursive: true });
+ }
+ const tempExe = path.join(tempDir, 'aria2c.exe');
+
+ const sourceBuf = fs.readFileSync(foundPath);
+ let needWrite = true;
+ if (fs.existsSync(tempExe)) {
+ try {
+ const stat = fs.statSync(tempExe);
+ if (stat.size === sourceBuf.length) {
+ needWrite = false;
+ }
+ } catch (e) {}
+ }
+
+ if (needWrite) {
+ fs.writeFileSync(tempExe, sourceBuf);
+ }
+ return tempExe;
+ } catch (err) {
+ console.error('解压 asar 内部 aria2c.exe 失败:', err);
+ }
+ }
+
+ return foundPath;
+}
+
+window.services = {
+ // ---------- 1. Aria2 守护进程管理 ----------
+ aria2Rpc(payload, port = 6800) {
+ return new Promise((resolve, reject) => {
+ const data = JSON.stringify(payload);
+ const req = http.request({
+ hostname: '127.0.0.1',
+ port: port,
+ path: '/jsonrpc',
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Content-Length': Buffer.byteLength(data)
+ },
+ timeout: 5000
+ }, (res) => {
+ let body = '';
+ res.on('data', chunk => { body += chunk; });
+ res.on('end', () => {
+ try {
+ const json = JSON.parse(body);
+ if (json.error) {
+ reject(new Error(json.error.message || 'RPC Error'));
+ } else {
+ resolve(json.result);
+ }
+ } catch (e) {
+ reject(new Error('Aria2 RPC 响应数据解析异常'));
+ }
+ });
+ });
+
+ req.on('error', (err) => {
+ reject(err);
+ });
+
+ req.on('timeout', () => {
+ req.destroy();
+ reject(new Error('RPC 通信超时'));
+ });
+
+ req.write(data);
+ req.end();
+ });
+ },
+
+ async startAria2(options = {}) {
+ const port = options.port || 6800;
+ const secret = options.secret || '';
+ let saveDir = options.dir || path.join(os.homedir(), 'Downloads');
+ // 默认使用 none 文件预分配,防止 non-NTFS / FAT32 / exFAT 磁盘或权限受限电脑报 falloc error
+ let fileAlloc = options.fileAlloc || 'none';
+ if (fileAlloc === 'fallocate' || fileAlloc === 'falloc') fileAlloc = 'none';
+
+ // 1. 如果已有进程引用且正常运行,直接复用
+ if (aria2Process && !aria2Process.killed && currentFileAlloc === fileAlloc && currentRpcPort === port) {
+ return { success: true, pid: aria2Process.pid, port: port, status: 'running' };
+ }
+
+ // 2. 先做健康检查:如果端口 6800 上已存在能回应 RPC 的 Aria2 进程,直接复用
+ try {
+ const checkRes = await this.aria2Rpc({
+ jsonrpc: '2.0',
+ id: 'healthcheck',
+ method: 'aria2.getVersion',
+ params: secret ? [`token:${secret}`] : []
+ }, port);
+ if (checkRes && checkRes.version) {
+ currentRpcPort = port;
+ currentFileAlloc = fileAlloc;
+ return { success: true, port: port, status: 'already_running' };
+ }
+ } catch (e) {}
+
+ // 3. 清理已有残留进程并给 Windows socket 释放留出缓冲时间
+ killAria2Process();
+ await new Promise(r => setTimeout(r, 200));
+
+ currentRpcPort = port;
+ currentFileAlloc = fileAlloc;
+
+ const aria2Path = resolveAria2Path();
+ if (!aria2Path) {
+ lastAria2Log = `[错误] 未找到 aria2c.exe 二进制文件!\n插件期望路径: ${path.join(__dirname, 'bin', 'aria2c.exe')}\n请确认已放置 aria2c.exe 文件。`;
+ return {
+ success: false,
+ error: 'missing_binary',
+ message: '未在 bin 目录下找到 aria2c.exe,请确保放置二进制文件。'
+ };
+ }
+
+ // 尝试确保下载目录存在(无法创建时自动降级回退到系统默认 User/Downloads 目录)
+ try {
+ if (!fs.existsSync(saveDir)) {
+ fs.mkdirSync(saveDir, { recursive: true });
+ }
+ } catch (e) {
+ console.warn('指定保存路径不存在或无权限创建,自动降级回退至系统 User Downloads:', saveDir);
+ saveDir = path.join(os.homedir(), 'Downloads');
+ try {
+ if (!fs.existsSync(saveDir)) fs.mkdirSync(saveDir, { recursive: true });
+ } catch (err) {}
+ }
+
+ // 确保 session 任务保存文件存在,防止 aria2c 加载失败报错崩溃退出
+ const sessionDir = path.join(os.homedir(), '.ztools-download');
+ try {
+ if (!fs.existsSync(sessionDir)) {
+ fs.mkdirSync(sessionDir, { recursive: true });
+ }
+ } catch (e) {}
+ const sessionFilePath = path.join(sessionDir, 'aria2.session');
+ try {
+ if (!fs.existsSync(sessionFilePath)) {
+ fs.writeFileSync(sessionFilePath, '');
+ }
+ } catch (e) {}
+
+ const args = [
+ '--enable-rpc=true',
+ `--rpc-listen-port=${port}`,
+ '--rpc-listen-all=true',
+ '--rpc-allow-origin-all=true',
+ '--async-dns=false',
+ '--enable-async-dns6=false',
+ '--max-connection-per-server=16',
+ '--split=16',
+ '--min-split-size=1M',
+ '--continue=true',
+ '--allow-overwrite=true',
+ '--auto-file-renaming=true',
+ '--check-certificate=false',
+ '--user-agent=Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36',
+ '--enable-dht=true',
+ '--enable-dht6=true',
+ '--enable-peer-exchange=true',
+ '--bt-enable-lpd=true',
+ '--listen-port=6881-6999',
+ '--dht-listen-port=6881-6999',
+ '--bt-max-peers=128',
+ '--bt-tracker=udp://tracker.opentrackr.org:1337/announce,udp://open.stealth.si:80/announce,udp://tracker.torrent.eu.org:451/announce,udp://tracker.bittorrent.jp:8880/announce,udp://tracker.open-internet.nl:6969/announce,udp://tracker.zerobytes.xyz:1337/announce,udp://tracker.moeking.me:6969/announce,http://tracker.ipv6tracker.ru:80/announce',
+ '--file-allocation=' + (fileAlloc || 'none'),
+ `--dir=${saveDir}`,
+ '--force-save=true',
+ `--input-file=${sessionFilePath}`,
+ `--save-session=${sessionFilePath}`,
+ '--save-session-interval=2'
+ ];
+
+ if (secret) {
+ args.push(`--rpc-secret=${secret}`);
+ }
+
+ const sysProxy = getSystemProxy();
+ if (sysProxy) {
+ args.push(`--all-proxy=${sysProxy}`);
+ }
+
+ lastAria2Log = `[启动准备] 可执行文件: ${aria2Path}\n[启动参数]: ${args.join(' ')}\n----------------------------------------\n`;
+
+ try {
+ aria2Process = spawn(aria2Path, args, {
+ windowsHide: true,
+ detached: false
+ });
+
+ if (aria2Process.stderr) {
+ aria2Process.stderr.on('data', (d) => {
+ lastAria2Log += d.toString();
+ });
+ }
+ if (aria2Process.stdout) {
+ aria2Process.stdout.on('data', (d) => {
+ lastAria2Log += d.toString();
+ });
+ }
+
+ aria2Process.on('error', (err) => {
+ console.error('Aria2 启动异常:', err);
+ const codeStr = (err && err.code) ? `${err.code} ` : '';
+ const errnoStr = (err && err.errno) ? ` (errno=${err.errno})` : '';
+ lastAria2Log += `\n[Error] ${codeStr}${err.message}${errnoStr}`;
+ aria2Process = null;
+ });
+
+ aria2Process.on('exit', (code) => {
+ console.log(`Aria2 进程退出, code: ${code}`);
+ if (code !== 0 && code !== null) {
+ lastAria2Log += `\n[Exit] 进程非正常退出,退出码: ${code}`;
+ }
+ aria2Process = null;
+ });
+
+ // 启动握手:等待 RPC 就绪或进程退出(最长 5 秒),把真实启动结果返回给 UI,
+ // 避免 spawn 成功但进程随即崩溃(杀软拦截/端口被占/依赖缺失)时界面误报「已启动」
+ const spawnStartedAt = Date.now();
+ let spawnReady = false;
+ let spawnLastErr = null;
+ while (Date.now() - spawnStartedAt < 5000) {
+ if (aria2Process === null) break; // 进程已退出或 spawn 失败
+ try {
+ const v = await this.aria2Rpc({
+ jsonrpc: '2.0',
+ id: 'spawncheck',
+ method: 'aria2.getVersion',
+ params: secret ? [`token:${secret}`] : []
+ }, port);
+ if (v && v.version) {
+ spawnReady = true;
+ break;
+ }
+ } catch (e) {
+ spawnLastErr = e;
+ }
+ await new Promise(r => setTimeout(r, 300));
+ }
+
+ if (!spawnReady) {
+ // 进程启动失败或已退出,清理并返回带日志的明确错误
+ if (aria2Process) {
+ try { aria2Process.kill('SIGKILL'); } catch (e) {}
+ aria2Process = null;
+ }
+ const briefLog = lastAria2Log.split(/\r?\n/).filter(Boolean).slice(-10).join('\n');
+ return {
+ success: false,
+ error: 'startup_failed',
+ message: spawnLastErr ? spawnLastErr.message : '进程已退出但 RPC 未就绪',
+ log: briefLog || lastAria2Log
+ };
+ }
+
+ return {
+ success: true,
+ pid: aria2Process.pid,
+ port: port,
+ status: 'started'
+ };
+ } catch (err) {
+ lastAria2Log = err.message;
+ return { success: false, error: err.message };
+ }
+ },
+
+ getAria2Logs() {
+ if (!lastAria2Log) return '暂无后台控制台日志';
+ // 规范换行并消除连续 3 个以上的空白空行,防止诊断弹窗中出现大范围空白
+ let cleaned = lastAria2Log.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
+ cleaned = cleaned.replace(/\n{3,}/g, '\n\n').trim();
+ if (cleaned.endsWith('----------------------------------------')) {
+ cleaned += '\n(引擎已正常就绪,暂无最新错误或控制台输出日志)';
+ }
+ return cleaned || '暂无后台控制台日志';
+ },
+
+ stopAria2() {
+ killAria2Process();
+ return true;
+ },
+
+ restartAria2(options = {}) {
+ killAria2Process();
+ return this.startAria2(options);
+ },
+
+ isAria2Running() {
+ return !!(aria2Process && !aria2Process.killed);
+ },
+
+ getAria2BinaryStatus() {
+ const aria2Path = resolveAria2Path();
+ return {
+ found: !!aria2Path,
+ path: aria2Path || ''
+ };
+ },
+
+ // ---------- 2. 数据库读写 (持久化设置与任务) ----------
+ dbGet(key) {
+ try {
+ if (ztools.dbStorage) {
+ return ztools.dbStorage.getItem(key);
+ }
+ return localStorage.getItem(key);
+ } catch (e) {
+ return null;
+ }
+ },
+
+ dbSet(key, val) {
+ try {
+ if (ztools.dbStorage) {
+ ztools.dbStorage.setItem(key, val);
+ } else {
+ localStorage.setItem(key, val);
+ }
+ return true;
+ } catch (e) {
+ return false;
+ }
+ },
+
+ // ---------- 3. 系统路径与文件操作 ----------
+ getDefaultDownloadDir() {
+ return path.join(os.homedir(), 'Downloads');
+ },
+
+ // 打开所在文件夹或定位文件 (精确高亮选中目标文件)
+ openInExplorer(filePath, defaultSaveDir) {
+ if (!filePath) return false;
+ try {
+ let targetPath = String(filePath).trim();
+ const baseDir = defaultSaveDir || path.join(os.homedir(), 'Downloads');
+
+ // 强制绝对路径解析:若路径不是带盘符的绝对路径 (如 C:\),自动与目标下载目录拼接
+ if (!/^[a-zA-Z]:[\\/]|^\\\\/.test(targetPath)) {
+ targetPath = path.join(baseDir, targetPath);
+ }
+
+ targetPath = path.normalize(targetPath).replace(/\//g, '\\');
+
+ // 如果目标未下载完成,但存在 .aria2 临时文件,则精确定位至 .aria2 临时文件
+ let checkPath = targetPath;
+ if (!fs.existsSync(checkPath) && fs.existsSync(checkPath + '.aria2')) {
+ checkPath = checkPath + '.aria2';
+ }
+
+ // 优先使用 Electron 原生 shell.showItemInFolder API (最精准高亮)
+ try {
+ const { shell } = require('electron');
+ if (shell && typeof shell.showItemInFolder === 'function') {
+ if (fs.existsSync(checkPath)) {
+ shell.showItemInFolder(checkPath);
+ return true;
+ }
+ }
+ } catch (e) {}
+
+ // CMD 命令降级备用
+ if (fs.existsSync(checkPath)) {
+ const stat = fs.statSync(checkPath);
+ if (stat.isDirectory()) {
+ exec(`explorer.exe "${checkPath}"`);
+ } else {
+ exec(`explorer.exe /select,"${checkPath}"`);
+ }
+ return true;
+ } else {
+ let parentDir = path.dirname(targetPath);
+ if (!fs.existsSync(parentDir)) {
+ parentDir = baseDir;
+ }
+ if (fs.existsSync(parentDir)) {
+ exec(`explorer.exe "${parentDir.replace(/\//g, '\\')}"`);
+ return true;
+ }
+ }
+ } catch (e) {
+ console.error('打开 Explorer 异常:', e);
+ }
+ return false;
+ },
+
+ // 选择文件夹
+ selectFolder(defaultPath) {
+ if (ztools.showOpenDialog) {
+ const result = ztools.showOpenDialog({
+ properties: ['openDirectory'],
+ defaultPath: defaultPath || this.getDefaultDownloadDir()
+ });
+ if (result && result.length > 0) return result[0];
+ return null;
+ }
+ if (typeof utools !== 'undefined' && utools.showOpenDialog) {
+ const result = utools.showOpenDialog({
+ properties: ['openDirectory'],
+ defaultPath: defaultPath || this.getDefaultDownloadDir()
+ });
+ if (result && result.length > 0) return result[0];
+ return null;
+ }
+ try {
+ const { dialog } = require('electron');
+ if (dialog && typeof dialog.showOpenDialogSync === 'function') {
+ const result = dialog.showOpenDialogSync({
+ properties: ['openDirectory'],
+ defaultPath: defaultPath || this.getDefaultDownloadDir()
+ });
+ if (result && result.length > 0) return result[0];
+ return null;
+ }
+ } catch (e) {}
+
+ // PowerShell 目录选择框降级备用
+ try {
+ const psScript = `
+ Add-Type -AssemblyName System.Windows.Forms
+ $dialog = New-Object System.Windows.Forms.FolderBrowserDialog
+ $dialog.SelectedPath = '${(defaultPath || '').replace(/'/g, "''")}'
+ if ($dialog.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) {
+ Write-Output $dialog.SelectedPath
+ }
+ `;
+ const res = execSync(`powershell -NoProfile -ExecutionPolicy Bypass -Command "${psScript.replace(/\n/g, ' ')}"`, { encoding: 'utf8' });
+ return res ? res.trim() : null;
+ } catch (e) {
+ return null;
+ }
+ },
+
+ // 删除本地文件
+ removeLocalFile(filePath) {
+ try {
+ if (fs.existsSync(filePath)) {
+ fs.unlinkSync(filePath);
+ return true;
+ }
+ } catch (e) {
+ console.error('删除文件失败:', e);
+ }
+ return false;
+ },
+
+ // 获取磁盘剩余空闲空间 (字节)
+ getDiskFreeSpace(dirPath) {
+ try {
+ const targetPath = dirPath || os.homedir();
+ let checkDir = targetPath;
+ while (checkDir && !fs.existsSync(checkDir)) {
+ const parent = path.dirname(checkDir);
+ if (parent === checkDir) break;
+ checkDir = parent;
+ }
+ const stats = fs.statfsSync(checkDir || 'C:\\');
+ const freeBytes = Number(BigInt(stats.bsize) * BigInt(stats.bavail));
+ return { success: true, freeBytes, dir: checkDir };
+ } catch (e) {
+ return { success: false, error: e.message, freeBytes: Infinity };
+ }
+ },
+
+ // ---------- 4. 剪贴板与系统通知 ----------
+ getClipboardText() {
+ try {
+ if (ztools.readClipboardText) {
+ return ztools.readClipboardText();
+ }
+ if (typeof utools !== 'undefined' && utools.readClipboardText) {
+ return utools.readClipboardText();
+ }
+ // Electron 原生剪贴板读取 (极速 0 延迟)
+ try {
+ const { clipboard } = require('electron');
+ if (clipboard && typeof clipboard.readText === 'function') {
+ return clipboard.readText();
+ }
+ } catch (e) {}
+
+ return '';
+ } catch (e) {
+ return '';
+ }
+ },
+
+ showNotification(title, body) {
+ try {
+ if (ztools.showNotification) {
+ ztools.showNotification(body, title);
+ } else if (window.Notification && Notification.permission === 'granted') {
+ new Notification(title, { body });
+ }
+ } catch (e) {}
+ }
+};