|
@@ -1,6 +1,9 @@
|
|
|
-import {Events, WML, Window} from "@wailsio/runtime"; // 导入 Wails 运行时的事件系统、WML 工具与窗口控制 API
|
|
|
|
|
|
|
+import {Events, WML, Window, Clipboard} from "@wailsio/runtime"; // 导入 Wails 运行时的事件系统、WML 工具、窗口控制与剪贴板 API
|
|
|
import {SettingsService, AppSettings} from "../bindings/TengTuToolbox/index.js"; // 设置服务与配置模型
|
|
import {SettingsService, AppSettings} from "../bindings/TengTuToolbox/index.js"; // 设置服务与配置模型
|
|
|
import {LocalSendService} from "../bindings/TengTuToolbox/index.js"; // LocalSend 服务(发现设备/接收文件)
|
|
import {LocalSendService} from "../bindings/TengTuToolbox/index.js"; // LocalSend 服务(发现设备/接收文件)
|
|
|
|
|
+import {CrocService} from "../bindings/TengTuToolbox/index.js"; // Croc 服务(命令行加密传输文件)
|
|
|
|
|
+import {MemosService} from "../bindings/TengTuToolbox/index.js"; // Memos 服务(REST API 新建备忘)
|
|
|
|
|
+import {YTDLPService} from "../bindings/TengTuToolbox/index.js"; // yt-dlp 服务(视频下载)
|
|
|
import {getCalendarInfo} from "./lunar.js"; // 农历与节日计算
|
|
import {getCalendarInfo} from "./lunar.js"; // 农历与节日计算
|
|
|
|
|
|
|
|
WML.Enable(); // 为带 data-wml-openURL 属性的链接绑定打开行为(动态内容渲染后需调 WML.Reload())
|
|
WML.Enable(); // 为带 data-wml-openURL 属性的链接绑定打开行为(动态内容渲染后需调 WML.Reload())
|
|
@@ -29,6 +32,7 @@ const FALLBACK_SETTINGS = {
|
|
|
theme: localStorage.getItem('tengtu-theme') || 'dark',
|
|
theme: localStorage.getItem('tengtu-theme') || 'dark',
|
|
|
autostart: true,
|
|
autostart: true,
|
|
|
alwaysOnTop: false,
|
|
alwaysOnTop: false,
|
|
|
|
|
+ startCompact: false, // 启动时自动收拢为圆点
|
|
|
hotkey: 'CmdOrCtrl+Shift+G',
|
|
hotkey: 'CmdOrCtrl+Shift+G',
|
|
|
tabOrder: ['links', 'news', 'github', 'stock', 'todo', 'record', 'tools', 'host', 'settings'],
|
|
tabOrder: ['links', 'news', 'github', 'stock', 'todo', 'record', 'tools', 'host', 'settings'],
|
|
|
stockCodes: ['sh000001', 'sh600519', 'sz000001'],
|
|
stockCodes: ['sh000001', 'sh600519', 'sz000001'],
|
|
@@ -51,7 +55,10 @@ const FALLBACK_SETTINGS = {
|
|
|
newsCount: 30, // 新闻页显示条数
|
|
newsCount: 30, // 新闻页显示条数
|
|
|
githubRefreshMinutes: 120, // Github 自动刷新间隔(分钟)
|
|
githubRefreshMinutes: 120, // Github 自动刷新间隔(分钟)
|
|
|
fontDelta: 0, // 全局字号偏移(px),+-3,默认 0(基准字号已含 +2)
|
|
fontDelta: 0, // 全局字号偏移(px),+-3,默认 0(基准字号已含 +2)
|
|
|
- toolOrder: ['websvc', 'translate', 'qrcode', 'ntfy', 'chat', 'poem', 'screenshot', 'shutdown', 'md5', 'sha256', 'led', 'reminder', 'localsend'], // 工具页卡片默认顺序
|
|
|
|
|
|
|
+ toolOrder: ['websvc', 'translate', 'qrcode', 'ntfy', 'chat', 'poem', 'screenshot', 'shutdown', 'md5', 'sha256', 'led', 'reminder', 'localsend', 'croc', 'ytdlp', 'memos'], // 工具页卡片默认顺序
|
|
|
|
|
+ croc: {password: '', dir: ''}, // Croc 传送:发送/接收共用密码、接收保存目录
|
|
|
|
|
+ ytdlp: {dir: '', mp3Quality: '0'}, // 视频下载:yt-dlp 下载保存目录 + mp3 转换质量
|
|
|
|
|
+ memos: {url: 'https://m.scwy.net', token: ''}, // Memos 速记:实例地址/访问令牌
|
|
|
stockTicker: {enabled: false, width: 1400, fontSize: 14, color: '#ff4d4d', speed: 80, transparent: true, x: -1, y: -1},
|
|
stockTicker: {enabled: false, width: 1400, fontSize: 14, color: '#ff4d4d', speed: 80, transparent: true, x: -1, y: -1},
|
|
|
newsTicker: {enabled: false, width: 1400, fontSize: 14, color: '#3ddc84', speed: 60, transparent: true, x: -1, y: -1},
|
|
newsTicker: {enabled: false, width: 1400, fontSize: 14, color: '#3ddc84', speed: 60, transparent: true, x: -1, y: -1},
|
|
|
poem: {enabled: false, color: '#c98a3a', fontSize: 16, tts: false, speed: 18},
|
|
poem: {enabled: false, color: '#c98a3a', fontSize: 16, tts: false, speed: 18},
|
|
@@ -374,7 +381,7 @@ function applyTabOrder(order) {
|
|
|
label.textContent = named.get(g) || getTabName(g); // 内置/自定义 Tab 名,无 Tab 的组用映射名
|
|
label.textContent = named.get(g) || getTabName(g); // 内置/自定义 Tab 名,无 Tab 的组用映射名
|
|
|
const count = document.createElement('span');
|
|
const count = document.createElement('span');
|
|
|
count.className = 'setting-group-count';
|
|
count.className = 'setting-group-count';
|
|
|
- count.textContent = `${gRows.length} 项`;
|
|
|
|
|
|
|
+ count.textContent = `${gRows.filter(r => r.style.display !== 'none').length} 项`; // 只统计可见设置项(如未安装 croc 时隐藏的项)
|
|
|
title.appendChild(arrow);
|
|
title.appendChild(arrow);
|
|
|
title.appendChild(label);
|
|
title.appendChild(label);
|
|
|
title.appendChild(count);
|
|
title.appendChild(count);
|
|
@@ -399,7 +406,7 @@ function applyTabOrder(order) {
|
|
|
// 未在 toolOrder 中的工具(如新增工具)追加到末尾,保证全部工具都能显示。
|
|
// 未在 toolOrder 中的工具(如新增工具)追加到末尾,保证全部工具都能显示。
|
|
|
const DEFAULT_TOOL_ORDER = [
|
|
const DEFAULT_TOOL_ORDER = [
|
|
|
'websvc', 'translate', 'qrcode', 'ntfy', 'chat', 'poem',
|
|
'websvc', 'translate', 'qrcode', 'ntfy', 'chat', 'poem',
|
|
|
- 'screenshot', 'shutdown', 'md5', 'sha256', 'led', 'reminder', 'localsend',
|
|
|
|
|
|
|
+ 'screenshot', 'shutdown', 'md5', 'sha256', 'led', 'reminder', 'localsend', 'croc', 'ytdlp',
|
|
|
];
|
|
];
|
|
|
|
|
|
|
|
function applyToolOrder(order) {
|
|
function applyToolOrder(order) {
|
|
@@ -557,6 +564,7 @@ document.querySelectorAll('#theme-seg .seg-btn').forEach(btn => {
|
|
|
// ------------------------------------------------------------------------
|
|
// ------------------------------------------------------------------------
|
|
|
const autostartToggle = document.getElementById('autostart'); // 开机启动开关
|
|
const autostartToggle = document.getElementById('autostart'); // 开机启动开关
|
|
|
const topToggle = document.getElementById('always-on-top'); // 窗口置顶开关
|
|
const topToggle = document.getElementById('always-on-top'); // 窗口置顶开关
|
|
|
|
|
+const startCompactToggle = document.getElementById('start-compact'); // 启动时缩小开关
|
|
|
const hotkeyInput = document.getElementById('hotkey'); // 热键输入框
|
|
const hotkeyInput = document.getElementById('hotkey'); // 热键输入框
|
|
|
const tabOrderText = document.getElementById('tab-order'); // Tab 显示顺序(每行一个 id)
|
|
const tabOrderText = document.getElementById('tab-order'); // Tab 显示顺序(每行一个 id)
|
|
|
const stockCodesInput = document.getElementById('stock-codes'); // 股票代码
|
|
const stockCodesInput = document.getElementById('stock-codes'); // 股票代码
|
|
@@ -678,6 +686,7 @@ function markDirty() {
|
|
|
async function syncForm(settings) {
|
|
async function syncForm(settings) {
|
|
|
autostartToggle.checked = settings.autostart;
|
|
autostartToggle.checked = settings.autostart;
|
|
|
topToggle.checked = settings.alwaysOnTop;
|
|
topToggle.checked = settings.alwaysOnTop;
|
|
|
|
|
+ if (startCompactToggle) startCompactToggle.checked = !!settings.startCompact;
|
|
|
hotkeyInput.value = settings.hotkey;
|
|
hotkeyInput.value = settings.hotkey;
|
|
|
// 先重建自定义 Tab(供顺序文本框解析名称与 applyTabOrder 使用)
|
|
// 先重建自定义 Tab(供顺序文本框解析名称与 applyTabOrder 使用)
|
|
|
await applyCustomTabs(settings.customTabs);
|
|
await applyCustomTabs(settings.customTabs);
|
|
@@ -718,6 +727,9 @@ async function syncForm(settings) {
|
|
|
syncPoemForm(settings.poem);
|
|
syncPoemForm(settings.poem);
|
|
|
syncMouseAlertForm(settings.mouseAlert);
|
|
syncMouseAlertForm(settings.mouseAlert);
|
|
|
syncLocalSendForm(settings.localSend);
|
|
syncLocalSendForm(settings.localSend);
|
|
|
|
|
+ syncCrocForm(settings.croc);
|
|
|
|
|
+ syncYTDLPForm(settings.ytdlp);
|
|
|
|
|
+ syncMemosForm(settings.memos);
|
|
|
applyTheme(settings.theme, false);
|
|
applyTheme(settings.theme, false);
|
|
|
applyFontDelta(settings.fontDelta ?? FALLBACK_SETTINGS.fontDelta); // 应用全局字号偏移(默认 +2)
|
|
applyFontDelta(settings.fontDelta ?? FALLBACK_SETTINGS.fontDelta); // 应用全局字号偏移(默认 +2)
|
|
|
applyTabOrder(settings.tabOrder); // 按设置重排 Tab 顺序
|
|
applyTabOrder(settings.tabOrder); // 按设置重排 Tab 顺序
|
|
@@ -837,6 +849,10 @@ async function loadSettings() {
|
|
|
currentSettings = {...FALLBACK_SETTINGS};
|
|
currentSettings = {...FALLBACK_SETTINGS};
|
|
|
}
|
|
}
|
|
|
await syncForm(currentSettings);
|
|
await syncForm(currentSettings);
|
|
|
|
|
+ // 「启动时缩小」:开启则启动后立即收拢为圆点
|
|
|
|
|
+ if (currentSettings.startCompact && typeof SettingsService.ToggleWindowCompact === 'function') {
|
|
|
|
|
+ setCompactUI(!!SettingsService.ToggleWindowCompact());
|
|
|
|
|
+ }
|
|
|
renderLinks(currentSettings.links || []);
|
|
renderLinks(currentSettings.links || []);
|
|
|
initFooterPoem(currentSettings.poem); // footer 古诗按配置显示并加载
|
|
initFooterPoem(currentSettings.poem); // footer 古诗按配置显示并加载
|
|
|
restartAutoTimers(); // 按真实设置的间隔重建自动刷新定时器
|
|
restartAutoTimers(); // 按真实设置的间隔重建自动刷新定时器
|
|
@@ -908,6 +924,7 @@ function collectForm() {
|
|
|
theme: document.documentElement.dataset.theme || 'dark',
|
|
theme: document.documentElement.dataset.theme || 'dark',
|
|
|
autostart: autostartToggle.checked,
|
|
autostart: autostartToggle.checked,
|
|
|
alwaysOnTop: topToggle.checked,
|
|
alwaysOnTop: topToggle.checked,
|
|
|
|
|
+ startCompact: !!startCompactToggle?.checked,
|
|
|
hotkey: hotkeyInput.value.trim() || FALLBACK_SETTINGS.hotkey,
|
|
hotkey: hotkeyInput.value.trim() || FALLBACK_SETTINGS.hotkey,
|
|
|
tabOrder: collectTabOrder(),
|
|
tabOrder: collectTabOrder(),
|
|
|
stockCodes,
|
|
stockCodes,
|
|
@@ -938,6 +955,9 @@ function collectForm() {
|
|
|
poem: collectPoem(),
|
|
poem: collectPoem(),
|
|
|
mouseAlert: collectMouseAlert(),
|
|
mouseAlert: collectMouseAlert(),
|
|
|
localSend: collectLocalSend(),
|
|
localSend: collectLocalSend(),
|
|
|
|
|
+ croc: collectCroc(),
|
|
|
|
|
+ ytdlp: collectYTDLP(),
|
|
|
|
|
+ memos: collectMemos(),
|
|
|
toolOrder: collectToolOrder(), // 工具页卡片顺序(含用户刚调整过的)
|
|
toolOrder: collectToolOrder(), // 工具页卡片顺序(含用户刚调整过的)
|
|
|
};
|
|
};
|
|
|
}
|
|
}
|
|
@@ -972,6 +992,117 @@ function syncLocalSendForm(localSend) {
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+// 从设置页收集 Croc 配置(发送/接收共用密码、接收保存目录)
|
|
|
|
|
+function collectCroc() {
|
|
|
|
|
+ return {
|
|
|
|
|
+ password: (document.getElementById('croc-password')?.value || '').trim(),
|
|
|
|
|
+ dir: (document.getElementById('croc-recvdir')?.value || '').trim(),
|
|
|
|
|
+ };
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// 回填 Croc 配置到设置页表单,并在卡片未提示时给出接收前提说明
|
|
|
|
|
+function syncCrocForm(croc) {
|
|
|
|
|
+ const pwdInput = document.getElementById('croc-password');
|
|
|
|
|
+ if (pwdInput) pwdInput.value = croc?.password || '';
|
|
|
|
|
+ const dirInput = document.getElementById('croc-recvdir');
|
|
|
|
|
+ if (dirInput) dirInput.value = croc?.dir || '';
|
|
|
|
|
+ const hint = document.getElementById('croc-hint');
|
|
|
|
|
+ if (hint && !hint.textContent) {
|
|
|
|
|
+ hint.textContent = croc?.password
|
|
|
|
|
+ ? '接收时将使用设置中的密码作为传输码'
|
|
|
|
|
+ : '接收前请在「设置 → 工具」配置 Croc 传送密码';
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// 选择 Croc 接收保存目录
|
|
|
|
|
+const crocPickDirBtn = document.getElementById('croc-pickdir');
|
|
|
|
|
+if (crocPickDirBtn) crocPickDirBtn.addEventListener('click', async () => {
|
|
|
|
|
+ try {
|
|
|
|
|
+ const dir = await SettingsService.PickWebDir(); // 复用目录选择对话框
|
|
|
|
|
+ if (!dir) return;
|
|
|
|
|
+ const input = document.getElementById('croc-recvdir');
|
|
|
|
|
+ if (!input) return;
|
|
|
|
|
+ input.value = dir;
|
|
|
|
|
+ markDirty();
|
|
|
|
|
+ input.dispatchEvent(new Event('input'));
|
|
|
|
|
+ await autoSaveSettings(); // 选完立即保存,工具卡片提示同步更新
|
|
|
|
|
+ } catch (err) {
|
|
|
|
|
+ console.warn('选择 Croc 接收目录失败:', err);
|
|
|
|
|
+ }
|
|
|
|
|
+});
|
|
|
|
|
+
|
|
|
|
|
+// 从设置页收集视频下载配置(yt-dlp 保存目录 + mp3 质量)
|
|
|
|
|
+function collectYTDLP() {
|
|
|
|
|
+ return {
|
|
|
|
|
+ dir: (document.getElementById('ytdlp-dir')?.value || '').trim(),
|
|
|
|
|
+ mp3Quality: document.getElementById('ytdlp-mp3-quality')?.value || '0',
|
|
|
|
|
+ };
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// 回填视频下载配置到设置页表单
|
|
|
|
|
+function syncYTDLPForm(ytdlp) {
|
|
|
|
|
+ const dirInput = document.getElementById('ytdlp-dir');
|
|
|
|
|
+ if (dirInput) dirInput.value = ytdlp?.dir || '';
|
|
|
|
|
+ const qualitySelect = document.getElementById('ytdlp-mp3-quality');
|
|
|
|
|
+ if (qualitySelect) qualitySelect.value = ytdlp?.mp3Quality || '0';
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// 从设置页收集 Memos 配置(实例地址/访问令牌)
|
|
|
|
|
+function collectMemos() {
|
|
|
|
|
+ return {
|
|
|
|
|
+ url: (document.getElementById('memos-url')?.value || '').trim(),
|
|
|
|
|
+ token: (document.getElementById('memos-token')?.value || '').trim(),
|
|
|
|
|
+ };
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// 回填 Memos 配置到设置页表单,并刷新工具页的可用状态
|
|
|
|
|
+function syncMemosForm(memos) {
|
|
|
|
|
+ const urlInput = document.getElementById('memos-url');
|
|
|
|
|
+ if (urlInput) urlInput.value = memos?.url || 'https://m.scwy.net';
|
|
|
|
|
+ const tokenInput = document.getElementById('memos-token');
|
|
|
|
|
+ if (tokenInput) tokenInput.value = memos?.token || '';
|
|
|
|
|
+ memosApplyReadiness(memos?.url, memos?.token);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// Memos 是否已配置(地址 + 令牌);未配置时禁止在工具页输入并给出提示
|
|
|
|
|
+const memosInput = document.getElementById('memos-input');
|
|
|
|
|
+const memosSendBtn = document.getElementById('memos-send');
|
|
|
|
|
+const memosHint = document.getElementById('memos-hint');
|
|
|
|
|
+let memosReady = false;
|
|
|
|
|
+function memosApplyReadiness(url, token) {
|
|
|
|
|
+ memosReady = !!((url || '').trim() && (token || '').trim());
|
|
|
|
|
+ if (memosInput) {
|
|
|
|
|
+ memosInput.readOnly = !memosReady;
|
|
|
|
|
+ memosInput.placeholder = memosReady
|
|
|
|
|
+ ? '写点什么……支持 Markdown 与 #标签,Ctrl+Enter 发送'
|
|
|
|
|
+ : '请先到「设置 → 工具」填写 Memos 地址与访问令牌';
|
|
|
|
|
+ if (!memosReady) memosInput.value = '';
|
|
|
|
|
+ }
|
|
|
|
|
+ if (memosSendBtn) memosSendBtn.disabled = !memosReady;
|
|
|
|
|
+ if (memosHint && !memosReady) {
|
|
|
|
|
+ memosHint.textContent = '未配置 Memos 地址或访问令牌,暂时无法输入';
|
|
|
|
|
+ } else if (memosHint) {
|
|
|
|
|
+ memosHint.textContent = '';
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// 选择视频下载保存目录
|
|
|
|
|
+const ytdlpPickDirBtn = document.getElementById('ytdlp-pickdir');
|
|
|
|
|
+if (ytdlpPickDirBtn) ytdlpPickDirBtn.addEventListener('click', async () => {
|
|
|
|
|
+ try {
|
|
|
|
|
+ const dir = await SettingsService.PickWebDir();
|
|
|
|
|
+ if (!dir) return;
|
|
|
|
|
+ const input = document.getElementById('ytdlp-dir');
|
|
|
|
|
+ if (!input) return;
|
|
|
|
|
+ input.value = dir;
|
|
|
|
|
+ markDirty();
|
|
|
|
|
+ input.dispatchEvent(new Event('input'));
|
|
|
|
|
+ await autoSaveSettings(); // 选完立即保存,下载时读取最新目录
|
|
|
|
|
+ } catch (err) {
|
|
|
|
|
+ console.warn('选择视频下载目录失败:', err);
|
|
|
|
|
+ }
|
|
|
|
|
+});
|
|
|
|
|
+
|
|
|
// 从控件收集滚动条配置;位置(x/y)由拖动自动保存,表单不修改
|
|
// 从控件收集滚动条配置;位置(x/y)由拖动自动保存,表单不修改
|
|
|
function collectTicker(kind) {
|
|
function collectTicker(kind) {
|
|
|
const inputs = tickerInputs[kind];
|
|
const inputs = tickerInputs[kind];
|
|
@@ -1058,6 +1189,11 @@ function updateFooterMarquee() {
|
|
|
const singleW = inner.scrollWidth;
|
|
const singleW = inner.scrollWidth;
|
|
|
if (singleW > footerMarqueeEl.clientWidth + 4) {
|
|
if (singleW > footerMarqueeEl.clientWidth + 4) {
|
|
|
inner.textContent = single + single; // 双份文本:-50% 位移正好滚完一份并接上下一份
|
|
inner.textContent = single + single; // 双份文本:-50% 位移正好滚完一份并接上下一份
|
|
|
|
|
+ // 动画时长与文本宽度成正比,保证滚动速度(像素/秒)恒定,避免长诗「滚得飞快」
|
|
|
|
|
+ // --poem-speed 为基准秒数(文本宽 = 容器宽时滚一轮的时长,默认 18s)
|
|
|
|
|
+ const base = parseFloat(getComputedStyle(footerMarqueeEl).getPropertyValue('--poem-speed')) || 18;
|
|
|
|
|
+ const dur = singleW / Math.max(footerMarqueeEl.clientWidth, 1) * base;
|
|
|
|
|
+ inner.style.animationDuration = dur.toFixed(2) + 's';
|
|
|
footerMarqueeEl.classList.add('scrolling');
|
|
footerMarqueeEl.classList.add('scrolling');
|
|
|
}
|
|
}
|
|
|
});
|
|
});
|
|
@@ -2979,6 +3115,512 @@ lsOfferReject?.addEventListener('click', async () => {
|
|
|
}
|
|
}
|
|
|
})();
|
|
})();
|
|
|
|
|
|
|
|
|
|
+// ===== 工具页:Croc 传送——自动检测 PATH 中的 croc CLI,点对点加密传输文件 =====
|
|
|
|
|
+const crocCard = document.getElementById('croc-card'); // 工具卡片(croc 不存在时保持隐藏)
|
|
|
|
|
+const crocStatusEl = document.getElementById('croc-status'); // 状态徽标
|
|
|
|
|
+const crocToggleBtn = document.getElementById('croc-toggle'); // 接收开关(启动/停止乒乓按钮)
|
|
|
|
|
+const crocSendBtn = document.getElementById('croc-send'); // 发送(发送中变为停止发送)
|
|
|
|
|
+const crocHintEl = document.getElementById('croc-hint'); // 统一提示
|
|
|
|
|
+const crocLogEl = document.getElementById('croc-log'); // 日志输出
|
|
|
|
|
+
|
|
|
|
|
+let crocSending = false; // 是否正在发送(发送按钮在「发送/停止发送」间切换)
|
|
|
|
|
+let crocReceiving = false; // 是否正在接收(启动/停止乒乓按钮)
|
|
|
|
|
+
|
|
|
|
|
+// 更新 Croc 状态徽标(busy=运行中,高亮显示)
|
|
|
|
|
+function crocSetStatus(text, busy) {
|
|
|
|
|
+ if (!crocStatusEl) return;
|
|
|
|
|
+ crocStatusEl.textContent = text || '';
|
|
|
|
|
+ crocStatusEl.classList.toggle('is-on', !!busy);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// 追加一行日志到输出区域
|
|
|
|
|
+function crocAppendLog(kind, line) {
|
|
|
|
|
+ if (!crocLogEl) return;
|
|
|
|
|
+ crocLogEl.hidden = false;
|
|
|
|
|
+ const prefix = kind === 'send' ? '[发送] ' : '[接收] ';
|
|
|
|
|
+ crocLogEl.textContent += prefix + line + '\n';
|
|
|
|
|
+ crocLogEl.scrollTop = crocLogEl.scrollHeight; // 滚动到最新一行
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// 按当前发送/接收状态刷新徽标(都空闲时显示已就绪)
|
|
|
|
|
+function crocRefreshStatus() {
|
|
|
|
|
+ if (crocSending) crocSetStatus('发送中', true);
|
|
|
|
|
+ else if (crocReceiving) crocSetStatus('接收中', true);
|
|
|
|
|
+ else crocSetStatus('已就绪', false);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// 发送:空闲时选择文件发送;发送中再点一次则停止发送
|
|
|
|
|
+if (crocSendBtn) crocSendBtn.addEventListener('click', async () => {
|
|
|
|
|
+ if (crocSending) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ await CrocService.StopSend();
|
|
|
|
|
+ crocHintEl.textContent = '已停止发送';
|
|
|
|
|
+ } catch (err) {
|
|
|
|
|
+ crocHintEl.textContent = '停止失败:' + (err?.message || err);
|
|
|
|
|
+ }
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ let files = [];
|
|
|
|
|
+ try {
|
|
|
|
|
+ files = await SettingsService.PickSendFiles();
|
|
|
|
|
+ } catch (err) {
|
|
|
|
|
+ crocHintEl.textContent = '选择文件失败:' + (err?.message || err);
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (!files || !files.length) return; // 用户取消
|
|
|
|
|
+
|
|
|
|
|
+ if (crocLogEl) { crocLogEl.textContent = ''; crocLogEl.hidden = false; }
|
|
|
|
|
+ crocHintEl.textContent = `正在发送 ${files.length} 个文件…`;
|
|
|
|
|
+ crocSending = true;
|
|
|
|
|
+ crocSendBtn.textContent = '停止发送';
|
|
|
|
|
+ try {
|
|
|
|
|
+ await CrocService.StartSend(files);
|
|
|
|
|
+ crocRefreshStatus();
|
|
|
|
|
+ } catch (err) {
|
|
|
|
|
+ crocSending = false;
|
|
|
|
|
+ crocSendBtn.textContent = '发送';
|
|
|
|
|
+ crocHintEl.textContent = '发送失败:' + (err?.message || err);
|
|
|
|
|
+ }
|
|
|
|
|
+});
|
|
|
|
|
+
|
|
|
|
|
+// 启动/停止乒乓按钮:接收端用设置中的密码作为传输码
|
|
|
|
|
+if (crocToggleBtn) crocToggleBtn.addEventListener('click', async () => {
|
|
|
|
|
+ if (crocReceiving) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ await CrocService.StopReceive();
|
|
|
|
|
+ crocHintEl.textContent = '已停止接收';
|
|
|
|
|
+ } catch (err) {
|
|
|
|
|
+ crocHintEl.textContent = '停止失败:' + (err?.message || err);
|
|
|
|
|
+ }
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const pwd = (currentSettings?.croc?.password || '').trim();
|
|
|
|
|
+ if (!pwd) {
|
|
|
|
|
+ crocHintEl.textContent = '请先在「设置 → 工具」配置 Croc 传送密码,接收时以该密码作为传输码';
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (crocLogEl) { crocLogEl.textContent = ''; crocLogEl.hidden = false; }
|
|
|
|
|
+ crocHintEl.textContent = '正在接收…(用设置中的密码作为传输码)';
|
|
|
|
|
+ try {
|
|
|
|
|
+ await CrocService.StartReceive('', (currentSettings?.croc?.dir || '').trim());
|
|
|
|
|
+ crocReceiving = true;
|
|
|
|
|
+ crocToggleBtn.textContent = '停止';
|
|
|
|
|
+ crocRefreshStatus();
|
|
|
|
|
+ } catch (err) {
|
|
|
|
|
+ crocHintEl.textContent = '接收失败:' + (err?.message || err);
|
|
|
|
|
+ }
|
|
|
|
|
+});
|
|
|
|
|
+
|
|
|
|
|
+// 后端日志事件
|
|
|
|
|
+Events.On('croc:log', (e) => {
|
|
|
|
|
+ const d = e?.data || {};
|
|
|
|
|
+ crocAppendLog(d.kind || 'send', d.line || '');
|
|
|
|
|
+});
|
|
|
|
|
+Events.On('croc:exit', (e) => {
|
|
|
|
|
+ const d = e?.data || {};
|
|
|
|
|
+ const ok = d.code === 0;
|
|
|
|
|
+ if (d.kind === 'send') {
|
|
|
|
|
+ crocSending = false;
|
|
|
|
|
+ if (crocSendBtn) crocSendBtn.textContent = '发送';
|
|
|
|
|
+ crocHintEl.textContent = ok ? '发送已结束' : `发送已停止(退出码 ${d.code})`;
|
|
|
|
|
+ } else {
|
|
|
|
|
+ crocReceiving = false;
|
|
|
|
|
+ if (crocToggleBtn) crocToggleBtn.textContent = '启动';
|
|
|
|
|
+ crocHintEl.textContent = ok ? '接收已完成' : `接收已停止(退出码 ${d.code})`;
|
|
|
|
|
+ }
|
|
|
|
|
+ crocRefreshStatus();
|
|
|
|
|
+});
|
|
|
|
|
+
|
|
|
|
|
+// 隐藏指定的设置行,并同步刷新各设置分组标题上的「N 项」计数
|
|
|
|
|
+function hideSettingRows(selector) {
|
|
|
|
|
+ document.querySelectorAll(selector).forEach(row => { row.style.display = 'none'; });
|
|
|
|
|
+ document.querySelectorAll('.setting-group').forEach(group => {
|
|
|
|
|
+ const visible = Array.from(group.querySelectorAll('.setting-row'))
|
|
|
|
|
+ .filter(row => row.style.display !== 'none').length;
|
|
|
|
|
+ const count = group.querySelector('.setting-group-count');
|
|
|
|
|
+ if (count) count.textContent = `${visible} 项`;
|
|
|
|
|
+ });
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// 未检测到 croc 时隐藏设置页中的 Croc 配置项(密码 / 接收保存目录)
|
|
|
|
|
+function hideCrocSettings() {
|
|
|
|
|
+ hideSettingRows('.croc-setting-row');
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// 页面加载时检测 croc 是否可用:不可用则隐藏整张卡片与相关设置项
|
|
|
|
|
+(async function initCroc() {
|
|
|
|
|
+ if (!crocCard) return;
|
|
|
|
|
+ try {
|
|
|
|
|
+ const info = await CrocService.Available();
|
|
|
|
|
+ if (!info?.available) {
|
|
|
|
|
+ crocCard.style.display = 'none'; // PATH 中没有 croc:不展示工具
|
|
|
|
|
+ hideCrocSettings(); // 设置页中的 Croc 配置一并隐藏
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ crocCard.style.display = ''; // 可用:正常展示(默认折叠)
|
|
|
|
|
+ crocCard.querySelector('.tool-card-title')?.setAttribute(
|
|
|
|
|
+ 'title',
|
|
|
|
|
+ `通过 croc 命令行工具在任意两台电脑间点对点加密传输文件。已检测到:${info.path || 'croc'}${info.version ? '(' + info.version + ')' : ''}。点击「启动」用设置中的密码作为传输码开始接收,再点一次停止;点击「发送」选择文件后发送。密码与接收目录在「设置 → 工具」中配置。`
|
|
|
|
|
+ );
|
|
|
|
|
+ crocSetStatus('已就绪', false);
|
|
|
|
|
+ } catch (err) {
|
|
|
|
|
+ crocCard.style.display = 'none';
|
|
|
|
|
+ hideCrocSettings();
|
|
|
|
|
+ console.warn('检测 croc 失败:', err);
|
|
|
|
|
+ }
|
|
|
|
|
+})();
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+// ===== 工具页:视频下载——自动检测 PATH 中的 yt-dlp,监视剪贴板中的 B 站链接 =====
|
|
|
|
|
+const ytdlpCard = document.getElementById('ytdlp-card'); // 工具卡片(yt-dlp 不存在时保持隐藏)
|
|
|
|
|
+const ytdlpStatusEl = document.getElementById('ytdlp-status'); // 状态徽标
|
|
|
|
|
+const ytdlpListEl = document.getElementById('ytdlp-list'); // B 站链接列表
|
|
|
|
|
+const ytdlpLogEl = document.getElementById('ytdlp-log'); // 下载日志
|
|
|
|
|
+const ytdlpSelectAll = document.getElementById('ytdlp-select-all'); // 全选
|
|
|
|
|
+const ytdlpBatchBtn = document.getElementById('ytdlp-batch-download'); // 批量下载
|
|
|
|
|
+const ytdlpBatchMp3Btn = document.getElementById('ytdlp-batch-mp3'); // 批量转 mp3(ffmpeg 可用时显示)
|
|
|
|
|
+
|
|
|
|
|
+let ytdlpAvailable = false; // PATH 中是否存在 yt-dlp
|
|
|
|
|
+let ytdlpFFmpeg = false; // PATH 中是否存在 ffmpeg(决定是否显示「下载转 mp3」)
|
|
|
|
|
+let ytdlpDownloading = false; // 是否正在下载(同时只允许一个任务)
|
|
|
|
|
+let ytdlpActiveBtn = null; // 当前下载行的按钮(用于恢复文字/禁用状态)
|
|
|
|
|
+let ytdlpActiveHint = null; // 当前下载行的状态提示
|
|
|
|
|
+let ytdlpActiveLabel = ''; // 当前下载按钮的原始文字(下载 / 下载转 mp3)
|
|
|
|
|
+let ytdlpBatching = false; // 是否处于批量下载流程
|
|
|
|
|
+let ytdlpBatchAudio = false; // 批量流程是否为转 mp3 模式
|
|
|
|
|
+let ytdlpBatchQueue = []; // 批量下载待处理链接队列
|
|
|
|
|
+let ytdlpBatchTotal = 0; // 批量下载总数
|
|
|
|
|
+let ytdlpBatchDone = 0; // 批量下载已完成数
|
|
|
|
|
+const ytdlpSeen = new Set(); // 已加入列表的链接,避免剪贴板反复读取导致重复
|
|
|
|
|
+const YTDLP_MAX_ITEMS = 20; // 列表最多保留的链接条数
|
|
|
|
|
+
|
|
|
|
|
+// 从任意文本中提取第一个 B 站链接(支持 bilibili.com 与 b23.tv 短链)
|
|
|
|
|
+function extractBilibiliUrl(text) {
|
|
|
|
|
+ const m = String(text || '').match(/https?:\/\/(?:[A-Za-z0-9-]+\.)*(?:bilibili\.com|b23\.tv)\/[^\s"'<>]*/i);
|
|
|
|
|
+ if (!m) return '';
|
|
|
|
|
+ return m[0].replace(/[),,。;;!!??]+$/, ''); // 去掉尾随的中文标点/括号
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// 更新视频下载状态徽标
|
|
|
|
|
+function ytdlpSetStatus(text, busy) {
|
|
|
|
|
+ if (!ytdlpStatusEl) return;
|
|
|
|
|
+ ytdlpStatusEl.textContent = text;
|
|
|
|
|
+ ytdlpStatusEl.classList.toggle('is-busy', !!busy);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// 取已勾选的链接(按列表顺序)
|
|
|
|
|
+function ytdlpSelectedUrls() {
|
|
|
|
|
+ if (!ytdlpListEl) return [];
|
|
|
|
|
+ return Array.from(ytdlpListEl.querySelectorAll('.ytdlp-item'))
|
|
|
|
|
+ .filter(item => item.querySelector('.ytdlp-check')?.checked)
|
|
|
|
|
+ .map(item => item.dataset.url)
|
|
|
|
|
+ .filter(Boolean);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// 刷新「全选」与「批量下载」按钮状态
|
|
|
|
|
+function ytdlpUpdateBatchCount() {
|
|
|
|
|
+ if (!ytdlpListEl) return;
|
|
|
|
|
+ const total = ytdlpListEl.querySelectorAll('.ytdlp-item').length;
|
|
|
|
|
+ const selected = ytdlpSelectedUrls().length;
|
|
|
|
|
+ if (ytdlpSelectAll) {
|
|
|
|
|
+ ytdlpSelectAll.checked = total > 0 && selected === total;
|
|
|
|
|
+ ytdlpSelectAll.indeterminate = selected > 0 && selected < total;
|
|
|
|
|
+ ytdlpSelectAll.disabled = total === 0;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (ytdlpBatchBtn) {
|
|
|
|
|
+ ytdlpBatchBtn.disabled = selected === 0 || ytdlpDownloading;
|
|
|
|
|
+ ytdlpBatchBtn.textContent = selected > 0 ? `批量下载(${selected})` : '批量下载';
|
|
|
|
|
+ }
|
|
|
|
|
+ if (ytdlpBatchMp3Btn) {
|
|
|
|
|
+ ytdlpBatchMp3Btn.disabled = selected === 0 || ytdlpDownloading || !ytdlpFFmpeg;
|
|
|
|
|
+ ytdlpBatchMp3Btn.textContent = selected > 0 ? `批量转 mp3(${selected})` : '批量转 mp3';
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// 按链接查找列表行
|
|
|
|
|
+function ytdlpItemByUrl(url) {
|
|
|
|
|
+ return Array.from(ytdlpListEl?.children || []).find(el => el.dataset.url === url) || null;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// 开始批量下载:把勾选的链接放入队列,逐个串行下载(后端同一时间只允许一个任务)
|
|
|
|
|
+// audioOnly 为 true 时批量下载并转 mp3。
|
|
|
|
|
+function ytdlpStartBatch(audioOnly) {
|
|
|
|
|
+ if (ytdlpDownloading) return; // 当前有任务在跑:等它结束再批量
|
|
|
|
|
+ const urls = ytdlpSelectedUrls();
|
|
|
|
|
+ if (!urls.length) return;
|
|
|
|
|
+ ytdlpBatching = true;
|
|
|
|
|
+ ytdlpBatchAudio = !!audioOnly;
|
|
|
|
|
+ ytdlpBatchQueue = urls.slice();
|
|
|
|
|
+ // 点击后自动取消选中
|
|
|
|
|
+ ytdlpListEl?.querySelectorAll('.ytdlp-check').forEach(c => { c.checked = false; });
|
|
|
|
|
+ ytdlpUpdateBatchCount();
|
|
|
|
|
+ ytdlpBatchTotal = urls.length;
|
|
|
|
|
+ ytdlpBatchDone = 0;
|
|
|
|
|
+ if (ytdlpLogEl) { ytdlpLogEl.textContent = ''; ytdlpLogEl.hidden = false; }
|
|
|
|
|
+ ytdlpProcessBatch();
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// 处理批量队列中的下一个链接
|
|
|
|
|
+function ytdlpProcessBatch() {
|
|
|
|
|
+ if (!ytdlpBatching) return;
|
|
|
|
|
+ if (!ytdlpBatchQueue.length) {
|
|
|
|
|
+ ytdlpBatching = false;
|
|
|
|
|
+ ytdlpBatchTotal = 0;
|
|
|
|
|
+ ytdlpSetStatus('已就绪', false);
|
|
|
|
|
+ ytdlpUpdateBatchCount();
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const url = ytdlpBatchQueue.shift();
|
|
|
|
|
+ const item = ytdlpItemByUrl(url);
|
|
|
|
|
+ const buttons = Array.from(item?.querySelectorAll('button') || []);
|
|
|
|
|
+ // 转 mp3 模式选「下载转 mp3」按钮,否则选第一个「下载」按钮
|
|
|
|
|
+ const btn = ytdlpBatchAudio ? (buttons.find(b => b.textContent.includes('mp3')) || buttons[0]) : buttons[0];
|
|
|
|
|
+ const hint = item?.querySelector('.tool-hint');
|
|
|
|
|
+ if (!item || !btn || !hint) { ytdlpProcessBatch(); return; } // 行已被移除则跳过
|
|
|
|
|
+
|
|
|
|
|
+ ytdlpDownloading = true;
|
|
|
|
|
+ ytdlpBatchDone += 1;
|
|
|
|
|
+ ytdlpActiveBtn = btn;
|
|
|
|
|
+ ytdlpActiveHint = hint;
|
|
|
|
|
+ ytdlpActiveLabel = btn.textContent;
|
|
|
|
|
+ btn.textContent = '停止';
|
|
|
|
|
+ item.classList.remove('is-done'); // 重新下载时恢复普通样式
|
|
|
|
|
+ hint.textContent = ytdlpBatchAudio ? '批量转 mp3 中…' : '批量下载中…';
|
|
|
|
|
+ document.querySelectorAll('#ytdlp-list button').forEach(b => { b.disabled = (b !== btn); });
|
|
|
|
|
+ const batchLabel = ytdlpBatchAudio ? '批量转 mp3' : '批量下载';
|
|
|
|
|
+ ytdlpSetStatus(`${batchLabel} ${ytdlpBatchDone}/${ytdlpBatchTotal}`, true);
|
|
|
|
|
+ ytdlpUpdateBatchCount();
|
|
|
|
|
+
|
|
|
|
|
+ YTDLPService.StartDownload(url, ytdlpBatchAudio).catch(() => {
|
|
|
|
|
+ hint.textContent = '下载失败';
|
|
|
|
|
+ ytdlpFinishDownload(false);
|
|
|
|
|
+ });
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// 向列表追加一条 B 站链接(已存在则忽略),返回是否新增
|
|
|
|
|
+function addYTDLPItem(url) {
|
|
|
|
|
+ if (!ytdlpListEl || ytdlpSeen.has(url)) return false;
|
|
|
|
|
+ ytdlpSeen.add(url);
|
|
|
|
|
+
|
|
|
|
|
+ const item = document.createElement('div');
|
|
|
|
|
+ item.className = 'ytdlp-item';
|
|
|
|
|
+ item.dataset.url = url;
|
|
|
|
|
+
|
|
|
|
|
+ // 多选框:支持勾选后批量下载
|
|
|
|
|
+ const check = document.createElement('input');
|
|
|
|
|
+ check.type = 'checkbox';
|
|
|
|
|
+ check.className = 'ytdlp-check';
|
|
|
|
|
+ check.title = '勾选后可批量下载';
|
|
|
|
|
+ check.addEventListener('change', ytdlpUpdateBatchCount);
|
|
|
|
|
+
|
|
|
|
|
+ // 标题 + 链接:标题异步用 yt-dlp 解析,解析失败时回退显示链接
|
|
|
|
|
+ const meta = document.createElement('div');
|
|
|
|
|
+ meta.className = 'ytdlp-meta';
|
|
|
|
|
+
|
|
|
|
|
+ const title = document.createElement('a');
|
|
|
|
|
+ title.className = 'ytdlp-title';
|
|
|
|
|
+ title.href = url;
|
|
|
|
|
+ title.textContent = '获取标题中…';
|
|
|
|
|
+ title.title = '点击在浏览器打开';
|
|
|
|
|
+ title.setAttribute('data-wml-openURL', url); // 点击用默认/设置中的浏览器打开
|
|
|
|
|
+
|
|
|
|
|
+ const link = document.createElement('span');
|
|
|
|
|
+ link.className = 'ytdlp-url';
|
|
|
|
|
+ link.textContent = url;
|
|
|
|
|
+ link.title = url;
|
|
|
|
|
+
|
|
|
|
|
+ meta.appendChild(title);
|
|
|
|
|
+ meta.appendChild(link);
|
|
|
|
|
+
|
|
|
|
|
+ const hint = document.createElement('span');
|
|
|
|
|
+ hint.className = 'tool-hint';
|
|
|
|
|
+
|
|
|
|
|
+ const btn = document.createElement('button');
|
|
|
|
|
+ btn.className = 'btn-primary';
|
|
|
|
|
+ btn.type = 'button';
|
|
|
|
|
+ btn.textContent = '下载';
|
|
|
|
|
+ btn.addEventListener('click', () => ytdlpToggleDownload(url, btn, false, hint));
|
|
|
|
|
+
|
|
|
|
|
+ item.appendChild(check);
|
|
|
|
|
+ item.appendChild(meta);
|
|
|
|
|
+ item.appendChild(hint);
|
|
|
|
|
+ item.appendChild(btn);
|
|
|
|
|
+
|
|
|
|
|
+ // PATH 中存在 ffmpeg 时,追加「下载转 mp3」按钮
|
|
|
|
|
+ if (ytdlpFFmpeg) {
|
|
|
|
|
+ const mp3Btn = document.createElement('button');
|
|
|
|
|
+ mp3Btn.className = 'btn-ghost';
|
|
|
|
|
+ mp3Btn.type = 'button';
|
|
|
|
|
+ mp3Btn.textContent = '下载转 mp3';
|
|
|
|
|
+ mp3Btn.title = '下载音频并转换为 mp3(使用 ffmpeg)';
|
|
|
|
|
+ mp3Btn.addEventListener('click', () => ytdlpToggleDownload(url, mp3Btn, true, hint));
|
|
|
|
|
+ item.appendChild(mp3Btn);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ ytdlpListEl.insertBefore(item, ytdlpListEl.firstChild); // 最新链接放最上面
|
|
|
|
|
+
|
|
|
|
|
+ // 异步解析视频标题(不下载),避免用户分不清多个链接
|
|
|
|
|
+ YTDLPService.FetchTitle(url).then(t => {
|
|
|
|
|
+ title.textContent = t || url;
|
|
|
|
|
+ }).catch(() => {
|
|
|
|
|
+ title.textContent = url;
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ // 超出上限时移除最旧的(跳过正在下载的行)
|
|
|
|
|
+ while (ytdlpListEl.children.length > YTDLP_MAX_ITEMS) {
|
|
|
|
|
+ const last = ytdlpListEl.lastElementChild;
|
|
|
|
|
+ if (last === ytdlpActiveBtn?.closest('.ytdlp-item')) break;
|
|
|
|
|
+ ytdlpSeen.delete(last.dataset.url || '');
|
|
|
|
|
+ last.remove();
|
|
|
|
|
+ }
|
|
|
|
|
+ ytdlpUpdateBatchCount(); // 新链接默认不勾选,刷新全选/批量按钮状态
|
|
|
|
|
+ return true;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// 恢复下载按钮与状态到空闲态
|
|
|
|
|
+function ytdlpFinishDownload(ok) {
|
|
|
|
|
+ ytdlpDownloading = false;
|
|
|
|
|
+ const item = ytdlpActiveBtn?.closest('.ytdlp-item');
|
|
|
|
|
+ if (ytdlpActiveBtn) {
|
|
|
|
|
+ // 已完成的按钮改为「重下载」(mp3 按钮对应「重下载 mp3」),否则恢复原文字
|
|
|
|
|
+ if (ok) ytdlpActiveBtn.textContent = (ytdlpActiveLabel || '').includes('mp3') ? '重下载 mp3' : '重下载';
|
|
|
|
|
+ else ytdlpActiveBtn.textContent = ytdlpActiveLabel || '下载';
|
|
|
|
|
+ }
|
|
|
|
|
+ if (ytdlpActiveHint) ytdlpActiveHint.textContent = '';
|
|
|
|
|
+ if (ok && item) {
|
|
|
|
|
+ // 下载完成:标记为已完成并移到列表末尾,与未下载项区分
|
|
|
|
|
+ item.classList.add('is-done');
|
|
|
|
|
+ if (ytdlpActiveHint) ytdlpActiveHint.textContent = '已下载';
|
|
|
|
|
+ if (ytdlpListEl) ytdlpListEl.appendChild(item);
|
|
|
|
|
+ }
|
|
|
|
|
+ ytdlpActiveBtn = null;
|
|
|
|
|
+ ytdlpActiveHint = null;
|
|
|
|
|
+ ytdlpActiveLabel = '';
|
|
|
|
|
+ document.querySelectorAll('#ytdlp-list button').forEach(b => { b.disabled = false; });
|
|
|
|
|
+ ytdlpSetStatus(ok ? '已就绪' : '已停止', false);
|
|
|
|
|
+ ytdlpUpdateBatchCount();
|
|
|
|
|
+ if (ytdlpBatching) setTimeout(ytdlpProcessBatch, 300); // 批量流程:继续下一条
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// 点击某行的「下载 / 下载转 mp3」:空闲时启动任务,进行中再点同一按钮则停止
|
|
|
|
|
+async function ytdlpToggleDownload(url, btn, audioOnly, hintEl) {
|
|
|
|
|
+ if (ytdlpDownloading) {
|
|
|
|
|
+ if (btn !== ytdlpActiveBtn) return; // 其它按钮已禁用
|
|
|
|
|
+ try {
|
|
|
|
|
+ await YTDLPService.StopDownload();
|
|
|
|
|
+ } catch (err) {
|
|
|
|
|
+ hintEl.textContent = '停止失败';
|
|
|
|
|
+ }
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ ytdlpDownloading = true;
|
|
|
|
|
+ ytdlpActiveBtn = btn;
|
|
|
|
|
+ ytdlpActiveHint = hintEl;
|
|
|
|
|
+ ytdlpActiveLabel = btn.textContent;
|
|
|
|
|
+ btn.textContent = '停止';
|
|
|
|
|
+ btn.closest('.ytdlp-item')?.classList.remove('is-done'); // 重新下载时恢复为普通样式
|
|
|
|
|
+ hintEl.textContent = audioOnly ? '转 mp3 中…' : '下载中…';
|
|
|
|
|
+ document.querySelectorAll('#ytdlp-list button').forEach(b => { b.disabled = (b !== btn); });
|
|
|
|
|
+ if (ytdlpLogEl) { ytdlpLogEl.textContent = ''; ytdlpLogEl.hidden = false; }
|
|
|
|
|
+ ytdlpSetStatus(audioOnly ? '转 mp3 中' : '下载中', true);
|
|
|
|
|
+ ytdlpUpdateBatchCount(); // 单条下载期间禁用批量下载
|
|
|
|
|
+
|
|
|
|
|
+ try {
|
|
|
|
|
+ await YTDLPService.StartDownload(url, audioOnly);
|
|
|
|
|
+ } catch (err) {
|
|
|
|
|
+ hintEl.textContent = '下载失败';
|
|
|
|
|
+ ytdlpFinishDownload(false);
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// 读取剪贴板文本:优先用 Wails 原生剪贴板,浏览器预览时回退 Web Clipboard API
|
|
|
|
|
+async function readClipboardText() {
|
|
|
|
|
+ try {
|
|
|
|
|
+ if (typeof Clipboard?.Text === 'function') return await Clipboard.Text();
|
|
|
|
|
+ } catch (err) {
|
|
|
|
|
+ // 忽略,走下面的浏览器回退
|
|
|
|
|
+ }
|
|
|
|
|
+ try {
|
|
|
|
|
+ return await navigator.clipboard.readText();
|
|
|
|
|
+ } catch (err) {
|
|
|
|
|
+ return '';
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+let ytdlpClipBusy = false; // 防止上一次读取未完成时重入
|
|
|
|
|
+// 轮询剪贴板:发现 B 站链接就加入列表
|
|
|
|
|
+async function pollYTDLPClipboard() {
|
|
|
|
|
+ if (!ytdlpAvailable || document.hidden || ytdlpClipBusy) return;
|
|
|
|
|
+ ytdlpClipBusy = true;
|
|
|
|
|
+ try {
|
|
|
|
|
+ const url = extractBilibiliUrl(await readClipboardText());
|
|
|
|
|
+ if (url) addYTDLPItem(url);
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ ytdlpClipBusy = false;
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// 未检测到 yt-dlp 时隐藏设置页中的下载目录配置
|
|
|
|
|
+function hideYTDLPSettings() {
|
|
|
|
|
+ hideSettingRows('.ytdlp-setting-row');
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// 页面加载时检测 yt-dlp 是否可用:可用则展示卡片并开始监视剪贴板
|
|
|
|
|
+(async function initYTDLP() {
|
|
|
|
|
+ if (!ytdlpCard) return;
|
|
|
|
|
+ try {
|
|
|
|
|
+ const info = await YTDLPService.Available();
|
|
|
|
|
+ if (!info?.available) {
|
|
|
|
|
+ ytdlpCard.style.display = 'none'; // PATH 中没有 yt-dlp:不展示工具
|
|
|
|
|
+ hideYTDLPSettings(); // 设置页中的下载目录一并隐藏
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ ytdlpAvailable = true;
|
|
|
|
|
+ ytdlpFFmpeg = !!info.ffmpeg; // 有 ffmpeg 才显示「下载转 mp3」与「批量转 mp3」
|
|
|
|
|
+ if (ytdlpFFmpeg && ytdlpBatchMp3Btn) ytdlpBatchMp3Btn.style.display = '';
|
|
|
|
|
+ ytdlpUpdateBatchCount();
|
|
|
|
|
+ ytdlpCard.style.display = ''; // 可用:正常展示(默认折叠)
|
|
|
|
|
+ ytdlpCard.querySelector('.tool-card-title')?.setAttribute(
|
|
|
|
|
+ 'title',
|
|
|
|
|
+ `自动检测系统 PATH 中的 yt-dlp(${info.path || 'yt-dlp'}${info.version ? ',版本 ' + info.version : ''})。运行时自动读取剪贴板,发现 B 站链接(bilibili.com / b23.tv)就列在下方,点「下载」即可下载到「设置 → 工具」中配置的目录${info.ffmpeg ? ';检测到 ffmpeg,可点「下载转 mp3」直接提取音频' : ''}。`
|
|
|
|
|
+ );
|
|
|
|
|
+ ytdlpSetStatus('已就绪', false);
|
|
|
|
|
+ pollYTDLPClipboard(); // 启动后先读一次剪贴板
|
|
|
|
|
+ setInterval(pollYTDLPClipboard, 1500); // 之后每 1.5 秒自动检查
|
|
|
|
|
+ } catch (err) {
|
|
|
|
|
+ ytdlpCard.style.display = 'none';
|
|
|
|
|
+ hideYTDLPSettings();
|
|
|
|
|
+ console.warn('检测 yt-dlp 失败:', err);
|
|
|
|
|
+ }
|
|
|
|
|
+})();
|
|
|
|
|
+
|
|
|
|
|
+// 全选 / 批量下载按钮
|
|
|
|
|
+if (ytdlpSelectAll) {
|
|
|
|
|
+ ytdlpSelectAll.addEventListener('change', () => {
|
|
|
|
|
+ const on = ytdlpSelectAll.checked;
|
|
|
|
|
+ ytdlpListEl?.querySelectorAll('.ytdlp-check').forEach(c => { c.checked = on; });
|
|
|
|
|
+ ytdlpUpdateBatchCount();
|
|
|
|
|
+ });
|
|
|
|
|
+}
|
|
|
|
|
+if (ytdlpBatchBtn) ytdlpBatchBtn.addEventListener('click', () => ytdlpStartBatch(false));
|
|
|
|
|
+if (ytdlpBatchMp3Btn) ytdlpBatchMp3Btn.addEventListener('click', () => ytdlpStartBatch(true));
|
|
|
|
|
+
|
|
|
|
|
+// 下载日志与退出事件
|
|
|
|
|
+Events.On('ytdlp:log', (e) => {
|
|
|
|
|
+ const line = String(e?.data?.line || '').trim();
|
|
|
|
|
+ if (!line || !ytdlpLogEl) return;
|
|
|
|
|
+ ytdlpLogEl.textContent += (ytdlpLogEl.textContent ? '\n' : '') + line;
|
|
|
|
|
+ ytdlpLogEl.scrollTop = ytdlpLogEl.scrollHeight; // 自动滚到底部
|
|
|
|
|
+});
|
|
|
|
|
+Events.On('ytdlp:exit', (e) => {
|
|
|
|
|
+ ytdlpFinishDownload(e?.data?.code === 0);
|
|
|
|
|
+});
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
|
|
|
async function loadHostPanel() {
|
|
async function loadHostPanel() {
|
|
|
try {
|
|
try {
|
|
@@ -3919,6 +4561,49 @@ document.getElementById('win-close')?.addEventListener('click', () => {
|
|
|
Window.Close(); // 触发关闭流程,实际效果为最小化到托盘
|
|
Window.Close(); // 触发关闭流程,实际效果为最小化到托盘
|
|
|
});
|
|
});
|
|
|
|
|
|
|
|
|
|
+// ------------------------------------------------------------------------
|
|
|
|
|
+// 左侧拖动手柄:双击在「全尺寸」与「仅手柄大小」之间切换窗口
|
|
|
|
|
+// ------------------------------------------------------------------------
|
|
|
|
|
+const tabDragHandle = document.querySelector('.tab-drag-handle');
|
|
|
|
|
+
|
|
|
|
|
+// 同步「收拢」界面状态:隐藏正文、只保留居中还原圆点,并让窗口背景透明
|
|
|
|
|
+function setCompactUI(isCompact) {
|
|
|
|
|
+ document.body.classList.toggle('window-compact', !!isCompact);
|
|
|
|
|
+ document.documentElement.classList.toggle('window-compact', !!isCompact);
|
|
|
|
|
+ document.title = isCompact ? '腾图工具箱(收拢)' : '腾图工具箱';
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+tabDragHandle?.addEventListener('dblclick', () => {
|
|
|
|
|
+ if (typeof SettingsService.ToggleWindowCompact === 'function') {
|
|
|
|
|
+ const isCompact = SettingsService.ToggleWindowCompact(); // 后端切换窗口尺寸,返回是否紧凑
|
|
|
|
|
+ setCompactUI(!!isCompact);
|
|
|
|
|
+ }
|
|
|
|
|
+});
|
|
|
|
|
+
|
|
|
|
|
+// 居中还原圆点:双击还原窗口大小(紧凑状态下手柄不可见,用此兜底)
|
|
|
|
|
+const compactRestore = document.getElementById('compact-restore');
|
|
|
|
|
+compactRestore?.addEventListener('dblclick', () => {
|
|
|
|
|
+ if (typeof SettingsService.ToggleWindowCompact === 'function') {
|
|
|
|
|
+ SettingsService.ToggleWindowCompact();
|
|
|
|
|
+ setCompactUI(false);
|
|
|
|
|
+ }
|
|
|
|
|
+});
|
|
|
|
|
+
|
|
|
|
|
+// ------------------------------------------------------------------------
|
|
|
|
|
+// 拖动手柄圆点闪烁:API POST /api/handle/flash 触发,颜色+大小变化动画
|
|
|
|
|
+// 后端广播 handleFlash 事件(payload 为闪烁次数),前端播放 keyframes 动画
|
|
|
|
|
+// ------------------------------------------------------------------------
|
|
|
|
|
+const tabDragDot = document.querySelector('.tab-drag-dot');
|
|
|
|
|
+Events.On('handleFlash', (event) => {
|
|
|
|
|
+ if (!tabDragDot) return;
|
|
|
|
|
+ const count = (event && event.data) || 3;
|
|
|
|
|
+ // 移除旧动画后强制重排,允许连续触发
|
|
|
|
|
+ tabDragDot.classList.remove('handle-flashing');
|
|
|
|
|
+ void tabDragDot.offsetWidth; // 强制 reflow 以重启动画
|
|
|
|
|
+ tabDragDot.style.setProperty('--flash-count', String(count));
|
|
|
|
|
+ tabDragDot.classList.add('handle-flashing');
|
|
|
|
|
+});
|
|
|
|
|
+
|
|
|
// ------------------------------------------------------------------------
|
|
// ------------------------------------------------------------------------
|
|
|
// 监听后端 "time" 事件(仅在分钟变化时广播),更新底部时间:
|
|
// 监听后端 "time" 事件(仅在分钟变化时广播),更新底部时间:
|
|
|
// 农历 + 公历年月日时分 + 节日
|
|
// 农历 + 公历年月日时分 + 节日
|
|
@@ -4308,3 +4993,40 @@ window.addEventListener('resize', () => {
|
|
|
clearTimeout(resizeTimer);
|
|
clearTimeout(resizeTimer);
|
|
|
resizeTimer = setTimeout(redrawChart, 150);
|
|
resizeTimer = setTimeout(redrawChart, 150);
|
|
|
});
|
|
});
|
|
|
|
|
+
|
|
|
|
|
+// ------------------------------------------------------------------------
|
|
|
|
|
+// 工具页:Memos 速记
|
|
|
|
|
+// 内容发送到设置中配置的 Memos 实例,固定创建私有备忘;未配置地址/令牌时输入框只读
|
|
|
|
|
+// ------------------------------------------------------------------------
|
|
|
|
|
+async function memosSend() {
|
|
|
|
|
+ if (!memosReady) {
|
|
|
|
|
+ if (memosHint) memosHint.textContent = '未配置 Memos 地址或访问令牌,暂时无法发送';
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ const content = (memosInput?.value || '').trim();
|
|
|
|
|
+ if (!content) {
|
|
|
|
|
+ if (memosHint) memosHint.textContent = '内容不能为空';
|
|
|
|
|
+ memosInput?.focus();
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (memosSendBtn) memosSendBtn.disabled = true;
|
|
|
|
|
+ if (memosHint) memosHint.textContent = '发送中…';
|
|
|
|
|
+ try {
|
|
|
|
|
+ const name = await MemosService.CreateMemo(content);
|
|
|
|
|
+ if (memosHint) memosHint.textContent = `已发送(${name})`;
|
|
|
|
|
+ if (memosInput) memosInput.value = '';
|
|
|
|
|
+ } catch (err) {
|
|
|
|
|
+ if (memosHint) memosHint.textContent = `发送失败:${err?.message || err}`;
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ if (memosSendBtn) memosSendBtn.disabled = !memosReady;
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+memosSendBtn?.addEventListener('click', memosSend);
|
|
|
|
|
+// Ctrl+Enter 快捷发送
|
|
|
|
|
+memosInput?.addEventListener('keydown', (e) => {
|
|
|
|
|
+ if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
|
|
|
|
|
+ e.preventDefault();
|
|
|
|
|
+ memosSend();
|
|
|
|
|
+ }
|
|
|
|
|
+});
|