|
|
@@ -0,0 +1,2808 @@
|
|
|
+import {Events, WML, Window} from "@wailsio/runtime"; // 导入 Wails 运行时的事件系统、WML 工具与窗口控制 API
|
|
|
+import {SettingsService, AppSettings} from "../bindings/TengTuToolbox/index.js"; // 设置服务与配置模型
|
|
|
+import {getCalendarInfo} from "./lunar.js"; // 农历与节日计算
|
|
|
+
|
|
|
+WML.Enable(); // 为带 data-wml-openURL 属性的链接绑定打开行为(动态内容渲染后需调 WML.Reload())
|
|
|
+
|
|
|
+// 浏览器预览(非 Wails 环境)时的兜底配置,保证页面仍可正常浏览
|
|
|
+const FALLBACK_SETTINGS = {
|
|
|
+ theme: localStorage.getItem('tengtu-theme') || 'dark',
|
|
|
+ autostart: true,
|
|
|
+ alwaysOnTop: false,
|
|
|
+ hotkey: 'CmdOrCtrl+Shift+G',
|
|
|
+ notesUrl: 'https://m.scwy.net',
|
|
|
+ controlUrl: 'https://a7.scwy.net/4191',
|
|
|
+ blogUrl: 'https://i.scwy.net',
|
|
|
+ chatUrl: 'https://c.scwy.net',
|
|
|
+ tabOrder: ['notes', 'control', 'blog', 'chat', 'links', 'news', 'github', 'stock', 'record', 'hardware', 'host', 'settings'],
|
|
|
+ stockCodes: ['sh000001', 'sh600519', 'sz000001'],
|
|
|
+ links: [{name: '煎蛋', url: 'https://jandan.net/'}],
|
|
|
+ newsKeywords: '',
|
|
|
+ biliCookie: '', // B站登录 cookie(含 SESSDATA),用于抓取「关注的人最新动态」
|
|
|
+ githubRepos: [{url: 'https://github.com/wailsapp/wails', desc: 'Wails 框架'}],
|
|
|
+ customTabs: [], // 用户自定义 Tab 页面(名字 + 网址)
|
|
|
+ apiAddr: '127.0.0.1:17890',
|
|
|
+ recordPath: 'D:\\My\\腾图\\记录.txt',
|
|
|
+ todoPath: 'D:\\My\\腾图\\待办.txt',
|
|
|
+ hardwareUrl: 'http://192.168.100.190/',
|
|
|
+ stockUpColor: '#ff5a5a', // 股票上涨颜色(红涨)
|
|
|
+ stockDownColor: '#4d8dff', // 股票下跌颜色(蓝跌)
|
|
|
+ stockRefreshSecs: 30, // 股市自动刷新间隔(秒)
|
|
|
+ stockClosedRefreshSecs: 7200, // 股市休市时自动刷新间隔(秒),默认 2 小时
|
|
|
+ newsRefreshMinutes: 120, // 新闻自动刷新间隔(分钟)
|
|
|
+ newsCount: 30, // 新闻页显示条数
|
|
|
+ githubRefreshMinutes: 120, // Github 自动刷新间隔(分钟)
|
|
|
+ fontDelta: 0, // 全局字号偏移(px),+-3,默认 0(基准字号已含 +2)
|
|
|
+ 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},
|
|
|
+ poem: {enabled: false, color: '#c98a3a', fontSize: 16, tts: false, speed: 18},
|
|
|
+};
|
|
|
+
|
|
|
+// HTML 转义:外部来源的新闻 / 提交标题插入页面前先转义,防止注入
|
|
|
+function escapeHtml(text) {
|
|
|
+ const div = document.createElement('div');
|
|
|
+ div.textContent = text ?? '';
|
|
|
+ return div.innerHTML;
|
|
|
+}
|
|
|
+
|
|
|
+// 取 iframe 应加载的地址:直接返回原 URL(已取消本地证书代理,https 直连)。
|
|
|
+// 之前版本对不受信 HTTPS 走本地反向代理绕开证书校验,现已移除该机制。
|
|
|
+function embedSrc(url) {
|
|
|
+ return url;
|
|
|
+}
|
|
|
+
|
|
|
+const timeElement = document.getElementById('time'); // 底部时间显示元素
|
|
|
+let tabButtons = document.querySelectorAll('.tab-btn'); // 所有 tab 按钮(含动态自定义 Tab,创建后刷新)
|
|
|
+let tabPanels = document.querySelectorAll('.tab-panel'); // 所有 tab 面板(含动态自定义 Tab,创建后刷新)
|
|
|
+
|
|
|
+// footer 说明 / 古诗滚动区元素
|
|
|
+const footerMarqueeEl = document.getElementById('footer-marquee'); // 滚动文字(古诗或 Tab 说明)
|
|
|
+const poemReadBtn = document.getElementById('poem-read'); // 朗读按钮(古诗显示时可见)
|
|
|
+
|
|
|
+// Tab 名称映射:id -> 显示名(用于顺序编辑与 footer 注释)
|
|
|
+const TAB_NAMES = {
|
|
|
+ notes: '笔记', control: '控制', blog: '博客', chat: '聊天',
|
|
|
+ links: '网址', news: '新闻', github: 'Github', stock: '股市',
|
|
|
+ record: '记录', todo: '待办', hardware: '硬件', tools: '工具', host: '服务', settings: '设置',
|
|
|
+};
|
|
|
+
|
|
|
+// Tab 注释映射:id -> 在 footer 显示的说明文字(切换时显示,不占页面空间)
|
|
|
+const TAB_DESCS = {
|
|
|
+ notes: '记录灵感与备忘,地址可在设置中修改。',
|
|
|
+ control: '远程控制中心,地址可在设置中修改。',
|
|
|
+ blog: '个人博客,地址可在设置中修改。',
|
|
|
+ chat: '聊天页面,地址可在设置中修改。',
|
|
|
+ links: '常用网址卡片,点击在浏览器打开,列表在设置中维护。',
|
|
|
+ news: '聚合煎蛋与头条热榜,命中关键词置顶高亮。',
|
|
|
+ github: '监听仓库提交与 release,本地版本低于最新版提示更新。',
|
|
|
+ stock: '自选股实时行情,点击行查看分时 / 日K。',
|
|
|
+ record: '记录零碎信息,文件路径可在设置中修改。',
|
|
|
+ todo: '每行一个待办任务,可勾选完成、编辑、删除,保存后写入本地文件。',
|
|
|
+ hardware: '硬件监控页面,地址可在设置中修改。',
|
|
|
+ tools: '开发/运维辅助工具:Web 服务(选目录开启浏览)、翻译(中英互译)、二维码生成。',
|
|
|
+ host: '内置 API 服务状态、电源命令与 Gin 日志。',
|
|
|
+ settings: '修改后点击保存立即生效,焦点离开时自动保存。',
|
|
|
+};
|
|
|
+
|
|
|
+let customTabs = [];
|
|
|
+
|
|
|
+// 取 Tab 显示名:优先内置 TAB_NAMES,其次自定义 Tab
|
|
|
+function getTabName(id) {
|
|
|
+ if (TAB_NAMES[id]) return TAB_NAMES[id];
|
|
|
+ const c = customTabs.find(t => t.id === id);
|
|
|
+ return c ? c.name : id;
|
|
|
+}
|
|
|
+
|
|
|
+// 取 Tab 说明:优先内置 TAB_DESCS,自定义 Tab 用页面地址
|
|
|
+function getTabDesc(id) {
|
|
|
+ if (TAB_DESCS[id]) return TAB_DESCS[id];
|
|
|
+ const c = customTabs.find(t => t.id === id);
|
|
|
+ return c ? `自定义页面:${c.url}` : '';
|
|
|
+}
|
|
|
+
|
|
|
+// 删除旧的自定义 Tab 按钮与面板,再按 settings.customTabs 重建(插入到「设置」Tab 之前)
|
|
|
+async function applyCustomTabs(tabs) {
|
|
|
+ const tabBar = document.querySelector('.tab-buttons');
|
|
|
+ const content = document.querySelector('.tab-content');
|
|
|
+ if (!tabBar || !content) return;
|
|
|
+ // 移除旧的自定义 Tab 按钮与面板
|
|
|
+ tabBar.querySelectorAll('.tab-btn[data-custom]').forEach(b => b.remove());
|
|
|
+ content.querySelectorAll('.tab-panel[data-custom]').forEach(p => p.remove());
|
|
|
+ // 重建自定义 Tab(按钮插入到设置按钮之前,面板插入到设置面板之前)
|
|
|
+ const settingsBtn = tabBar.querySelector('.tab-btn[data-tab="settings"]');
|
|
|
+ const settingsPanel = content.querySelector('#tab-settings');
|
|
|
+ customTabs = (tabs || []).map((t, i) => ({ id: `custom-${i}`, name: t.name, url: t.url }));
|
|
|
+ for (const t of customTabs) {
|
|
|
+ const btn = document.createElement('button');
|
|
|
+ btn.className = 'tab-btn';
|
|
|
+ btn.dataset.tab = t.id;
|
|
|
+ btn.dataset.custom = '1';
|
|
|
+ btn.textContent = t.name;
|
|
|
+ tabBar.insertBefore(btn, settingsBtn);
|
|
|
+ btn.addEventListener('click', () => switchTab(t.id)); // 点击切换
|
|
|
+
|
|
|
+ const panel = document.createElement('div');
|
|
|
+ panel.className = 'tab-panel panel-embed panel-custom';
|
|
|
+ panel.id = `tab-${t.id}`;
|
|
|
+ panel.dataset.custom = '1';
|
|
|
+ // 常驻工具条:在新窗口打开(内网自签名 / 禁止嵌入的站点可兜底打开)
|
|
|
+ const bar = document.createElement('div');
|
|
|
+ bar.className = 'custom-tab-bar';
|
|
|
+ const openBtn = document.createElement('button');
|
|
|
+ openBtn.className = 'btn-ghost';
|
|
|
+ openBtn.textContent = '在新窗口打开';
|
|
|
+ openBtn.title = t.url;
|
|
|
+ openBtn.addEventListener('click', (e) => {
|
|
|
+ e.stopPropagation(); // 不触发 Tab 切换
|
|
|
+ window.open(t.url, '_blank'); // 用系统浏览器打开(浏览器自会提示证书)
|
|
|
+ });
|
|
|
+ bar.appendChild(openBtn);
|
|
|
+ const frame = document.createElement('iframe');
|
|
|
+ frame.id = `frame-${t.id}`;
|
|
|
+ frame.src = embedSrc(t.url); // 直接使用原地址(已取消代理)
|
|
|
+ frame.title = t.name;
|
|
|
+ frame.setAttribute('loading', 'lazy');
|
|
|
+ panel.appendChild(bar);
|
|
|
+ panel.appendChild(frame);
|
|
|
+ content.insertBefore(panel, settingsPanel);
|
|
|
+ }
|
|
|
+ // 刷新按钮与面板引用,供 applyTabOrder / switchTab 使用
|
|
|
+ tabButtons = document.querySelectorAll('.tab-btn');
|
|
|
+ tabPanels = document.querySelectorAll('.tab-panel');
|
|
|
+ return customTabs.length > 0;
|
|
|
+}
|
|
|
+
|
|
|
+// 应用 Tab 显示顺序:按 settings.tabOrder 重排按钮与面板
|
|
|
+function applyTabOrder(order) {
|
|
|
+ const valid = new Set([...Object.keys(TAB_NAMES), ...customTabs.map(t => t.id)]); // 含内置与自定义 Tab
|
|
|
+ const ordered = [...new Set((order || []).filter(id => valid.has(id)))];
|
|
|
+ // 设置 Tab 恒在最后,其余未列出的(含自定义 Tab)追加到设置之前
|
|
|
+ const si = ordered.indexOf('settings');
|
|
|
+ if (si > -1) ordered.splice(si, 1);
|
|
|
+ [...valid].forEach(id => { if (id !== 'settings' && !ordered.includes(id)) ordered.push(id); });
|
|
|
+ ordered.push('settings'); // 设置在最后
|
|
|
+ // 重排按钮:先把右侧的 win-spacer / win-close 临时移出,避免被 appendChild 挤到末尾
|
|
|
+ const parent = tabButtons[0].parentNode;
|
|
|
+ const spacer = parent.querySelector('.win-spacer');
|
|
|
+ const closeBtn = parent.querySelector('.win-close');
|
|
|
+ if (spacer) spacer.remove();
|
|
|
+ if (closeBtn) closeBtn.remove();
|
|
|
+ const btnMap = {};
|
|
|
+ tabButtons.forEach(btn => { btnMap[btn.dataset.tab] = btn; });
|
|
|
+ ordered.forEach(id => { if (btnMap[id]) parent.appendChild(btnMap[id]); });
|
|
|
+ // 关闭按钮与占位空白放回最右(按钮全在左,关闭在右)
|
|
|
+ if (spacer) parent.appendChild(spacer);
|
|
|
+ if (closeBtn) parent.appendChild(closeBtn);
|
|
|
+ // 重排面板(追加顺序即 DOM 顺序,不影响显示)
|
|
|
+ const panelMap = {};
|
|
|
+ tabPanels.forEach(p => { panelMap[p.id.replace('tab-', '')] = p; });
|
|
|
+ const content = tabPanels[0].parentNode;
|
|
|
+ ordered.forEach(id => { if (panelMap[id]) content.appendChild(panelMap[id]); });
|
|
|
+ // 重排设置项:无 data-tab-group 的全局项归入「系统」组排最前,其余按 Tab 顺序分组
|
|
|
+ const settingList = document.querySelector('.setting-list');
|
|
|
+ if (settingList) {
|
|
|
+ const grouped = { system: [] }; // system 组 + 各 Tab 组
|
|
|
+ settingList.querySelectorAll('.setting-row').forEach(row => {
|
|
|
+ const g = row.dataset.tabGroup || 'system';
|
|
|
+ if (!grouped[g]) grouped[g] = [];
|
|
|
+ grouped[g].push(row);
|
|
|
+ });
|
|
|
+ // 分组标题:系统在前,其后按 Tab 顺序
|
|
|
+ const groupOrder = ['system', ...ordered];
|
|
|
+ settingList.querySelectorAll('.setting-group-title').forEach(el => el.remove());
|
|
|
+ settingList.querySelectorAll('.setting-row').forEach(row => row.remove());
|
|
|
+ const frag = document.createDocumentFragment();
|
|
|
+ groupOrder.forEach(g => {
|
|
|
+ const gRows = grouped[g];
|
|
|
+ if (!gRows || !gRows.length) return; // 该组无设置项则不显示标题
|
|
|
+ const title = document.createElement('div');
|
|
|
+ title.className = 'setting-group-title';
|
|
|
+ title.textContent = g === 'system' ? '系统' : getTabName(g); // 内置或自定义 Tab 名
|
|
|
+ frag.appendChild(title);
|
|
|
+ gRows.forEach(row => frag.appendChild(row));
|
|
|
+ });
|
|
|
+ settingList.appendChild(frag);
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// ------------------------------------------------------------------------
|
|
|
+// Tab 切换:点击按钮,仅显示对应的内容面板
|
|
|
+// ------------------------------------------------------------------------
|
|
|
+const loadedOnce = new Set(); // 记录已做过首次加载的面板,避免重复请求
|
|
|
+
|
|
|
+function switchTab(tabId) {
|
|
|
+ const leavingTab = document.querySelector('.tab-btn.active')?.dataset.tab;
|
|
|
+ if (leavingTab === 'settings' && tabId !== 'settings') {
|
|
|
+ autoSaveSettings(); // 从设置页切走时自动保存未保存的修改
|
|
|
+ }
|
|
|
+ tabButtons.forEach(b => b.classList.remove('active'));
|
|
|
+ tabPanels.forEach(p => p.classList.remove('active'));
|
|
|
+
|
|
|
+ document.querySelector(`.tab-btn[data-tab="${tabId}"]`)?.classList.add('active'); // 激活对应按钮
|
|
|
+ document.getElementById(`tab-${tabId}`)?.classList.add('active'); // 显示对应面板
|
|
|
+ showTabDesc(tabId); // 点击 Tab 后 footer 显示该 Tab 的说明(替代古诗)
|
|
|
+
|
|
|
+ // 部分面板首次打开时才加载数据
|
|
|
+ if (!loadedOnce.has(tabId)) {
|
|
|
+ loadedOnce.add(tabId);
|
|
|
+ if (tabId === 'record') loadRecord();
|
|
|
+ if (tabId === 'todo') loadTodo();
|
|
|
+ if (tabId === 'host') loadHostPanel();
|
|
|
+ if (tabId === 'tools') loadToolsPanel();
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+tabButtons.forEach(btn => {
|
|
|
+ btn.addEventListener('click', () => {
|
|
|
+ switchTab(btn.dataset.tab); // 用 data-tab 值定位面板
|
|
|
+ });
|
|
|
+});
|
|
|
+
|
|
|
+// ------------------------------------------------------------------------
|
|
|
+// 主题切换:暗色 / 亮色,立即生效并记录在 localStorage
|
|
|
+// ------------------------------------------------------------------------
|
|
|
+const THEME_KEY = 'tengtu-theme';
|
|
|
+
|
|
|
+function applyTheme(theme, persist = true) {
|
|
|
+ document.documentElement.dataset.theme = theme; // 写入 data-theme,由 CSS 变量接管配色
|
|
|
+ document.querySelectorAll('#theme-seg .seg-btn').forEach(b => {
|
|
|
+ b.classList.toggle('active', b.dataset.themeValue === theme); // 同步分段按钮选中态(仅限主题分段,勿影响图表弹窗的分时/日K分段)
|
|
|
+ });
|
|
|
+ if (persist) {
|
|
|
+ localStorage.setItem(THEME_KEY, theme); // 记住用户选择
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+applyTheme(localStorage.getItem(THEME_KEY) || 'dark', false); // 启动时恢复上次主题(默认暗色)
|
|
|
+
|
|
|
+document.querySelectorAll('#theme-seg .seg-btn').forEach(btn => {
|
|
|
+ btn.addEventListener('click', () => {
|
|
|
+ applyTheme(btn.dataset.themeValue); // 点击即切换并持久化
|
|
|
+ markDirty(); // 标记有未保存改动
|
|
|
+ redrawChart(); // 图表弹窗打开时按新主题重绘
|
|
|
+ });
|
|
|
+});
|
|
|
+
|
|
|
+// ------------------------------------------------------------------------
|
|
|
+// 设置项元素
|
|
|
+// ------------------------------------------------------------------------
|
|
|
+const autostartToggle = document.getElementById('autostart'); // 开机启动开关
|
|
|
+const topToggle = document.getElementById('always-on-top'); // 窗口置顶开关
|
|
|
+const hotkeyInput = document.getElementById('hotkey'); // 热键输入框
|
|
|
+const notesUrlInput = document.getElementById('notes-url'); // 笔记页地址
|
|
|
+const controlUrlInput = document.getElementById('control-url'); // 控制页地址
|
|
|
+const blogUrlInput = document.getElementById('blog-url'); // 博客页地址
|
|
|
+const chatUrlInput = document.getElementById('chat-url'); // 聊天页地址
|
|
|
+const tabOrderText = document.getElementById('tab-order'); // Tab 显示顺序(每行一个 id)
|
|
|
+const hardwareUrlInput = document.getElementById('hardware-url'); // 硬件页地址
|
|
|
+const stockCodesInput = document.getElementById('stock-codes'); // 股票代码
|
|
|
+const linksText = document.getElementById('links-text'); // 常用网址(每行 名称|网址)
|
|
|
+const newsKeywordsInput = document.getElementById('news-keywords'); // 新闻关键词
|
|
|
+const biliCookieInput = document.getElementById('bili-cookie'); // B站登录 cookie
|
|
|
+const githubReposText = document.getElementById('github-repos'); // Github 项目(每行 地址|说明)
|
|
|
+const apiAddrInput = document.getElementById('api-addr'); // API 监听地址
|
|
|
+const recordPathInput = document.getElementById('record-path'); // 记录文件路径
|
|
|
+const todoPathInput = document.getElementById('todo-path'); // 待办(TODO)文件路径
|
|
|
+const customTabsText = document.getElementById('custom-tabs'); // 自定义 Tab(每行 名称|网址)
|
|
|
+const saveButton = document.getElementById('save-settings'); // 保存按钮
|
|
|
+const saveStatus = document.getElementById('save-status'); // 保存状态提示
|
|
|
+const stockList = document.getElementById('stock-list'); // 股市列表容器
|
|
|
+const stockRefreshBtn = document.getElementById('stock-refresh'); // 股市刷新按钮
|
|
|
+const stockRefreshTime = document.getElementById('stock-refresh-time'); // 最后一次成功刷新时间
|
|
|
+
|
|
|
+// 显示最后一次成功刷新的时间(持久化到 localStorage,重启后仍可显示)
|
|
|
+function showStockRefreshTime(ts) {
|
|
|
+ if (!stockRefreshTime) return;
|
|
|
+ localStorage.setItem('tengtu-stock-last-success', String(ts || Date.now()));
|
|
|
+ const d = new Date(ts || Date.now());
|
|
|
+ const p = n => String(n).padStart(2, '0');
|
|
|
+ stockRefreshTime.textContent = `最后刷新 ${d.getMonth() + 1}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
|
|
|
+}
|
|
|
+
|
|
|
+// 初始化:优先从 localStorage 恢复上次成功时间
|
|
|
+(function initStockRefreshTime() {
|
|
|
+ if (!stockRefreshTime) return;
|
|
|
+ const saved = parseInt(localStorage.getItem('tengtu-stock-last-success'), 10);
|
|
|
+ if (saved) showStockRefreshTime(saved);
|
|
|
+})();
|
|
|
+
|
|
|
+// 切换股市刷新按钮的加载状态(转圈动画)
|
|
|
+function setStockLoading(loading) {
|
|
|
+ stockRefreshBtn.classList.toggle('loading', loading);
|
|
|
+ stockRefreshBtn.disabled = loading;
|
|
|
+}
|
|
|
+
|
|
|
+// 界面字号步进器
|
|
|
+const fontMinusBtn = document.getElementById('font-minus'); // 减小字号
|
|
|
+const fontPlusBtn = document.getElementById('font-plus'); // 增大字号
|
|
|
+const fontDeltaValue = document.getElementById('font-delta-value'); // 当前字号偏移显示
|
|
|
+
|
|
|
+// 滚动条配置控件(股票条 / 新闻条共用一组 id 前缀)
|
|
|
+const tickerInputs = {
|
|
|
+ stock: {
|
|
|
+ enabled: document.getElementById('stock-ticker-enabled'),
|
|
|
+ width: document.getElementById('stock-ticker-width'),
|
|
|
+ size: document.getElementById('stock-ticker-size'),
|
|
|
+ color: document.getElementById('stock-ticker-color'),
|
|
|
+ speed: document.getElementById('stock-ticker-speed'),
|
|
|
+ transparent: document.getElementById('stock-ticker-transparent'),
|
|
|
+ },
|
|
|
+ news: {
|
|
|
+ enabled: document.getElementById('news-ticker-enabled'),
|
|
|
+ width: document.getElementById('news-ticker-width'),
|
|
|
+ size: document.getElementById('news-ticker-size'),
|
|
|
+ color: document.getElementById('news-ticker-color'),
|
|
|
+ speed: document.getElementById('news-ticker-speed'),
|
|
|
+ transparent: document.getElementById('news-ticker-transparent'),
|
|
|
+ },
|
|
|
+};
|
|
|
+
|
|
|
+// 古诗配置控件(开关 / 颜色 / 字号 / TTS / 换诗间隔)
|
|
|
+const poemInputs = {
|
|
|
+ enabled: document.getElementById('poem-enabled'),
|
|
|
+ color: document.getElementById('poem-color'),
|
|
|
+ size: document.getElementById('poem-size'),
|
|
|
+ tts: document.getElementById('poem-tts'),
|
|
|
+ speed: document.getElementById('poem-speed'),
|
|
|
+};
|
|
|
+
|
|
|
+// 股市 / 新闻 / Github 刷新间隔与涨跌色控件
|
|
|
+const stockUpColorInput = document.getElementById('stock-up-color');
|
|
|
+const stockDownColorInput = document.getElementById('stock-down-color');
|
|
|
+const stockRefreshSecsInput = document.getElementById('stock-refresh-secs');
|
|
|
+const stockClosedRefreshSecsInput = document.getElementById('stock-closed-refresh-secs');
|
|
|
+const newsRefreshMinsInput = document.getElementById('news-refresh-mins');
|
|
|
+const newsCountInput = document.getElementById('news-count');
|
|
|
+const githubRefreshMinsInput = document.getElementById('github-refresh-mins');
|
|
|
+
|
|
|
+// 各面板自动刷新的定时器句柄(设置保存后按新间隔重建)
|
|
|
+let stockTimer = null;
|
|
|
+let newsTimer = null;
|
|
|
+let githubTimer = null;
|
|
|
+
|
|
|
+let currentSettings = {...FALLBACK_SETTINGS}; // 当前生效的配置
|
|
|
+let isDirty = false; // 设置页是否有未保存的修改
|
|
|
+let isSaving = false; // 是否正在保存,防止自动保存与手动保存并发
|
|
|
+let marketClosed = false; // 当前是否休市(用于切换刷新间隔);提前声明避免模块加载早期访问时的 TDZ 错误
|
|
|
+
|
|
|
+function markDirty() {
|
|
|
+ isDirty = true;
|
|
|
+ saveStatus.textContent = '有未保存的修改';
|
|
|
+ saveStatus.classList.remove('is-error', 'is-ok');
|
|
|
+}
|
|
|
+
|
|
|
+// 把配置回填到界面(iframe 地址仅在变化时更新,避免重复加载)
|
|
|
+async function syncForm(settings) {
|
|
|
+ autostartToggle.checked = settings.autostart;
|
|
|
+ topToggle.checked = settings.alwaysOnTop;
|
|
|
+ hotkeyInput.value = settings.hotkey;
|
|
|
+ notesUrlInput.value = settings.notesUrl;
|
|
|
+ controlUrlInput.value = settings.controlUrl;
|
|
|
+ blogUrlInput.value = settings.blogUrl;
|
|
|
+ chatUrlInput.value = settings.chatUrl;
|
|
|
+ tabOrderText.value = (settings.tabOrder || []).map(id => getTabName(id)).join(', '); // 顺序编辑显示 Tab 名称(逗号分隔)
|
|
|
+ hardwareUrlInput.value = settings.hardwareUrl;
|
|
|
+ stockCodesInput.value = settings.stockCodes.join(', ');
|
|
|
+ linksText.value = (settings.links || []).map(l => `${l.name}|${l.url}`).join('\n');
|
|
|
+ newsKeywordsInput.value = settings.newsKeywords || '';
|
|
|
+ biliCookieInput.value = settings.biliCookie || '';
|
|
|
+ githubReposText.value = (settings.githubRepos || []).map(r => `${r.url}|${r.desc}|${r.version || ''}`).join('\n');
|
|
|
+ customTabsText.value = (settings.customTabs || []).map(t => `${t.name}|${t.url}`).join('\n');
|
|
|
+ apiAddrInput.value = settings.apiAddr;
|
|
|
+ recordPathInput.value = settings.recordPath;
|
|
|
+ todoPathInput.value = settings.todoPath;
|
|
|
+ stockUpColorInput.value = settings.stockUpColor || FALLBACK_SETTINGS.stockUpColor;
|
|
|
+ stockDownColorInput.value = settings.stockDownColor || FALLBACK_SETTINGS.stockDownColor;
|
|
|
+ stockRefreshSecsInput.value = settings.stockRefreshSecs || FALLBACK_SETTINGS.stockRefreshSecs;
|
|
|
+ stockClosedRefreshSecsInput.value = settings.stockClosedRefreshSecs || FALLBACK_SETTINGS.stockClosedRefreshSecs;
|
|
|
+ newsRefreshMinsInput.value = settings.newsRefreshMinutes || FALLBACK_SETTINGS.newsRefreshMinutes;
|
|
|
+ newsCountInput.value = settings.newsCount || FALLBACK_SETTINGS.newsCount;
|
|
|
+ githubRefreshMinsInput.value = settings.githubRefreshMinutes || FALLBACK_SETTINGS.githubRefreshMinutes;
|
|
|
+ syncTickerForm('stock', settings.stockTicker);
|
|
|
+ syncTickerForm('news', settings.newsTicker);
|
|
|
+ syncPoemForm(settings.poem);
|
|
|
+ applyTheme(settings.theme, false);
|
|
|
+ applyFontDelta(settings.fontDelta ?? FALLBACK_SETTINGS.fontDelta); // 应用全局字号偏移(默认 +2)
|
|
|
+ await applyCustomTabs(settings.customTabs); // 重建自定义 Tab(按钮 + 面板)
|
|
|
+ applyTabOrder(settings.tabOrder); // 按设置重排 Tab 顺序
|
|
|
+}
|
|
|
+
|
|
|
+// 应用全局字号偏移:写入 CSS 变量并同步步进器显示
|
|
|
+function applyFontDelta(delta) {
|
|
|
+ const d = Math.max(-3, Math.min(3, Math.round(delta ?? FALLBACK_SETTINGS.fontDelta)));
|
|
|
+ document.documentElement.style.setProperty('--font-delta', d + 'px');
|
|
|
+ if (fontDeltaValue) fontDeltaValue.textContent = String(d);
|
|
|
+ if (fontMinusBtn) fontMinusBtn.disabled = d <= -3;
|
|
|
+ if (fontPlusBtn) fontPlusBtn.disabled = d >= 3;
|
|
|
+}
|
|
|
+
|
|
|
+// 界面字号 +- 步进:立即预览并标记待保存(元素定义后绑定)
|
|
|
+if (fontMinusBtn && fontPlusBtn) {
|
|
|
+ const stepFont = (dir) => {
|
|
|
+ const cur = parseInt(fontDeltaValue.textContent, 10) || 0;
|
|
|
+ applyFontDelta(cur + dir);
|
|
|
+ markDirty(); // 有未保存改动
|
|
|
+ };
|
|
|
+ fontMinusBtn.addEventListener('click', () => stepFont(-1));
|
|
|
+ fontPlusBtn.addEventListener('click', () => stepFont(1));
|
|
|
+}
|
|
|
+
|
|
|
+// 滚动条配置回填到对应控件
|
|
|
+function syncTickerForm(kind, cfg) {
|
|
|
+ const inputs = tickerInputs[kind];
|
|
|
+ cfg = cfg || {};
|
|
|
+ inputs.enabled.checked = !!cfg.enabled;
|
|
|
+ inputs.width.value = cfg.width || 1400;
|
|
|
+ inputs.size.value = cfg.fontSize || 14;
|
|
|
+ inputs.color.value = cfg.color || '#ff4d4d';
|
|
|
+ inputs.speed.value = cfg.speed || 80;
|
|
|
+ if (inputs.transparent) inputs.transparent.checked = cfg.transparent !== false; // 默认透明开启
|
|
|
+}
|
|
|
+
|
|
|
+// 古诗配置回填到对应控件
|
|
|
+function syncPoemForm(cfg) {
|
|
|
+ cfg = cfg || {};
|
|
|
+ poemInputs.enabled.checked = !!cfg.enabled;
|
|
|
+ poemInputs.color.value = cfg.color || '#c98a3a';
|
|
|
+ poemInputs.size.value = cfg.fontSize || 16;
|
|
|
+ poemInputs.tts.checked = !!cfg.tts;
|
|
|
+ poemInputs.speed.value = cfg.speed || 18;
|
|
|
+}
|
|
|
+
|
|
|
+async function applyEmbedUrls(settings) {
|
|
|
+ const frames = [
|
|
|
+ ['frame-notes', settings.notesUrl],
|
|
|
+ ['frame-control', settings.controlUrl],
|
|
|
+ ['frame-blog', settings.blogUrl],
|
|
|
+ ['frame-chat', settings.chatUrl],
|
|
|
+ ['frame-hardware', settings.hardwareUrl],
|
|
|
+ ];
|
|
|
+ await Promise.all(frames.map(async ([id, url]) => {
|
|
|
+ const frame = document.getElementById(id);
|
|
|
+ if (!frame) return;
|
|
|
+ const eff = embedSrc(url); // 直接使用原地址(已取消代理)
|
|
|
+ if (frame.dataset.src !== eff) {
|
|
|
+ frame.dataset.src = eff; // 记录已应用的地址
|
|
|
+ frame.src = eff; // 地址变化才重新加载
|
|
|
+ }
|
|
|
+ }));
|
|
|
+}
|
|
|
+
|
|
|
+// ------------------------------------------------------------------------
|
|
|
+// 热键录入:点击输入框后按下组合键
|
|
|
+// ------------------------------------------------------------------------
|
|
|
+const MODIFIER_KEYS = ['Control', 'Shift', 'Alt', 'Meta'];
|
|
|
+
|
|
|
+hotkeyInput.addEventListener('keydown', (event) => {
|
|
|
+ event.preventDefault(); // 阻止输入框写入原始字符
|
|
|
+
|
|
|
+ if (MODIFIER_KEYS.includes(event.key)) {
|
|
|
+ return; // 只按下修饰键时,等待主键
|
|
|
+ }
|
|
|
+
|
|
|
+ const parts = [];
|
|
|
+ if (event.ctrlKey || event.metaKey) parts.push('CmdOrCtrl'); // Windows 下对应 Ctrl
|
|
|
+ if (event.altKey) parts.push('Alt');
|
|
|
+ if (event.shiftKey) parts.push('Shift');
|
|
|
+ parts.push(event.key.length === 1 ? event.key.toUpperCase() : event.key); // 主键统一大写
|
|
|
+
|
|
|
+ hotkeyInput.value = parts.join('+');
|
|
|
+ markDirty();
|
|
|
+});
|
|
|
+
|
|
|
+hotkeyInput.addEventListener('focus', () => {
|
|
|
+ hotkeyInput.select(); // 聚焦时全选,方便重新录入
|
|
|
+});
|
|
|
+
|
|
|
+// ------------------------------------------------------------------------
|
|
|
+// 读取 / 保存配置
|
|
|
+// ------------------------------------------------------------------------
|
|
|
+async function loadSettings() {
|
|
|
+ try {
|
|
|
+ currentSettings = await SettingsService.GetSettings();
|
|
|
+ } catch (err) {
|
|
|
+ console.warn('读取设置失败,使用默认配置:', err); // 浏览器预览时无后端
|
|
|
+ currentSettings = {...FALLBACK_SETTINGS};
|
|
|
+ }
|
|
|
+ await syncForm(currentSettings);
|
|
|
+ applyEmbedUrls(currentSettings);
|
|
|
+ renderLinks(currentSettings.links || []);
|
|
|
+ initFooterPoem(currentSettings.poem); // footer 古诗按配置显示并加载
|
|
|
+ restartAutoTimers(); // 按真实设置的间隔重建自动刷新定时器
|
|
|
+}
|
|
|
+
|
|
|
+// 解析「每行 键|值」格式的文本域为对象数组
|
|
|
+function parsePipeLines(text, keyName, valueName) {
|
|
|
+ return text.split('\n')
|
|
|
+ .map(line => line.trim())
|
|
|
+ .filter(Boolean)
|
|
|
+ .map(line => {
|
|
|
+ const idx = line.indexOf('|');
|
|
|
+ if (idx < 0) return {[keyName]: line, [valueName]: ''}; // 无分隔符时整行作为第一字段
|
|
|
+ return {
|
|
|
+ [keyName]: line.slice(0, idx).trim(),
|
|
|
+ [valueName]: line.slice(idx + 1).trim(),
|
|
|
+ };
|
|
|
+ });
|
|
|
+}
|
|
|
+
|
|
|
+// 解析 地址|说明|版本 三列行(Github 仓库),支持缺省列
|
|
|
+function parsePipeLines3(text, keyName, valueName, thirdName) {
|
|
|
+ return text.split('\n')
|
|
|
+ .map(line => line.trim())
|
|
|
+ .filter(Boolean)
|
|
|
+ .map(line => {
|
|
|
+ const parts = line.split('|');
|
|
|
+ return {
|
|
|
+ [keyName]: (parts[0] || '').trim(),
|
|
|
+ [valueName]: (parts[1] || '').trim(),
|
|
|
+ [thirdName]: (parts[2] || '').trim(), // 第 3 列为本地版本,可为空
|
|
|
+ };
|
|
|
+ });
|
|
|
+}
|
|
|
+
|
|
|
+function collectForm() {
|
|
|
+ // 数组字段:表单为空时回退到当前生效配置,避免保存时用空值覆盖已有数据。
|
|
|
+ // 用户可能在设置页修改单个字段后保存,此时未触碰的 textarea 若为空不能清空原配置。
|
|
|
+ const fallbackArr = (input, key) => {
|
|
|
+ const v = input.value.trim();
|
|
|
+ if (v) return v; // 表单有内容,用表单值
|
|
|
+ // 表单为空:回退到 currentSettings 的已有值,其次默认值
|
|
|
+ const cur = currentSettings[key];
|
|
|
+ if (Array.isArray(cur) && cur.length) return cur.map(item => {
|
|
|
+ if (typeof item === 'string') return item;
|
|
|
+ // LinkItem / RepoItem / CustomTab 对象:转回 "a|b" 格式行
|
|
|
+ if (key === 'links') return `${item.name}|${item.url}`;
|
|
|
+ if (key === 'githubRepos') return `${item.url}|${item.desc}|${item.version || ''}`;
|
|
|
+ if (key === 'customTabs') return `${item.name}|${item.url}`;
|
|
|
+ return '';
|
|
|
+ }).join('\n');
|
|
|
+ return '';
|
|
|
+ };
|
|
|
+
|
|
|
+ const stockCodes = stockCodesInput.value
|
|
|
+ .split(/[,,\s]+/) // 支持中英文逗号与空格分隔
|
|
|
+ .map(code => code.trim())
|
|
|
+ .filter(Boolean);
|
|
|
+
|
|
|
+ // 颜色字段:回退到 currentSettings 或默认色,避免 color input 默认黑覆盖
|
|
|
+ const fallbackColor = (input, curVal, defVal) => {
|
|
|
+ const v = input.value.trim().toLowerCase();
|
|
|
+ if (v && v !== '#000000') return v; // 有效且非浏览器默认黑
|
|
|
+ if (curVal && curVal.toLowerCase() !== '#000000') return curVal; // 用当前生效值
|
|
|
+ return defVal; // 兜底默认色
|
|
|
+ };
|
|
|
+
|
|
|
+ return {
|
|
|
+ theme: document.documentElement.dataset.theme || 'dark',
|
|
|
+ autostart: autostartToggle.checked,
|
|
|
+ alwaysOnTop: topToggle.checked,
|
|
|
+ hotkey: hotkeyInput.value.trim() || FALLBACK_SETTINGS.hotkey,
|
|
|
+ notesUrl: notesUrlInput.value.trim(),
|
|
|
+ controlUrl: controlUrlInput.value.trim(),
|
|
|
+ blogUrl: blogUrlInput.value.trim(),
|
|
|
+ chatUrl: chatUrlInput.value.trim(),
|
|
|
+ tabOrder: collectTabOrder(),
|
|
|
+ hardwareUrl: hardwareUrlInput.value.trim(),
|
|
|
+ stockCodes,
|
|
|
+ links: parsePipeLines(fallbackArr(linksText, 'links'), 'name', 'url').filter(l => l.name && l.url),
|
|
|
+ newsKeywords: newsKeywordsInput.value.trim(),
|
|
|
+ biliCookie: biliCookieInput.value.trim(),
|
|
|
+ githubRepos: parsePipeLines3(fallbackArr(githubReposText, 'githubRepos'), 'url', 'desc', 'version').filter(r => r.url),
|
|
|
+ customTabs: parsePipeLines(fallbackArr(customTabsText, 'customTabs'), 'name', 'url').filter(t => t.name && t.url),
|
|
|
+ apiAddr: apiAddrInput.value.trim(),
|
|
|
+ recordPath: recordPathInput.value.trim(),
|
|
|
+ todoPath: todoPathInput.value.trim(),
|
|
|
+ stockUpColor: fallbackColor(stockUpColorInput, currentSettings.stockUpColor, FALLBACK_SETTINGS.stockUpColor),
|
|
|
+ stockDownColor: fallbackColor(stockDownColorInput, currentSettings.stockDownColor, FALLBACK_SETTINGS.stockDownColor),
|
|
|
+ stockRefreshSecs: parseInt(stockRefreshSecsInput.value, 10) || FALLBACK_SETTINGS.stockRefreshSecs,
|
|
|
+ stockClosedRefreshSecs: parseInt(stockClosedRefreshSecsInput.value, 10) || FALLBACK_SETTINGS.stockClosedRefreshSecs,
|
|
|
+ newsRefreshMinutes: parseInt(newsRefreshMinsInput.value, 10) || FALLBACK_SETTINGS.newsRefreshMinutes,
|
|
|
+ newsCount: parseInt(newsCountInput.value, 10) || FALLBACK_SETTINGS.newsCount,
|
|
|
+ githubRefreshMinutes: parseInt(githubRefreshMinsInput.value, 10) || FALLBACK_SETTINGS.githubRefreshMinutes,
|
|
|
+ fontDelta: parseInt(fontDeltaValue.textContent, 10) || 0,
|
|
|
+ stockTicker: collectTicker('stock'),
|
|
|
+ newsTicker: collectTicker('news'),
|
|
|
+ poem: collectPoem(),
|
|
|
+ };
|
|
|
+}
|
|
|
+
|
|
|
+// 从控件收集滚动条配置;位置(x/y)由拖动自动保存,表单不修改
|
|
|
+function collectTicker(kind) {
|
|
|
+ const inputs = tickerInputs[kind];
|
|
|
+ const prev = (kind === 'stock' ? currentSettings.stockTicker : currentSettings.newsTicker) || {};
|
|
|
+ // 颜色:color input 若为浏览器默认黑(#000000),回退到当前生效色或默认色,避免覆盖
|
|
|
+ const colorVal = inputs.color.value.trim().toLowerCase();
|
|
|
+ const prevColor = (prev.color || '').toLowerCase();
|
|
|
+ const defColor = kind === 'stock' ? '#ff4d4d' : '#3ddc84';
|
|
|
+ const color = (colorVal && colorVal !== '#000000') ? colorVal
|
|
|
+ : (prevColor && prevColor !== '#000000') ? prevColor : defColor;
|
|
|
+ return {
|
|
|
+ enabled: inputs.enabled.checked,
|
|
|
+ width: parseInt(inputs.width.value, 10) || 1400, // 窗口宽度(px)
|
|
|
+ fontSize: parseInt(inputs.size.value, 10) || 14,
|
|
|
+ color,
|
|
|
+ speed: parseInt(inputs.speed.value, 10) || 80,
|
|
|
+ transparent: inputs.transparent ? inputs.transparent.checked : true, // 背景透明开关
|
|
|
+ x: prev.x ?? -1, // 保留拖动保存的位置
|
|
|
+ y: prev.y ?? -1,
|
|
|
+ };
|
|
|
+}
|
|
|
+
|
|
|
+// 从控件收集古诗配置
|
|
|
+function collectPoem() {
|
|
|
+ const prev = currentSettings.poem || {};
|
|
|
+ // 颜色:color input 若为浏览器默认黑(#000000),回退到当前生效色或默认色,避免覆盖
|
|
|
+ const colorVal = poemInputs.color.value.trim().toLowerCase();
|
|
|
+ const prevColor = (prev.color || '').toLowerCase();
|
|
|
+ const color = (colorVal && colorVal !== '#000000') ? colorVal
|
|
|
+ : (prevColor && prevColor !== '#000000') ? prevColor : '#c98a3a';
|
|
|
+ return {
|
|
|
+ enabled: poemInputs.enabled.checked,
|
|
|
+ color,
|
|
|
+ fontSize: parseInt(poemInputs.size.value, 10) || 16,
|
|
|
+ tts: poemInputs.tts.checked,
|
|
|
+ speed: parseInt(poemInputs.speed.value, 10) || 18,
|
|
|
+ };
|
|
|
+}
|
|
|
+
|
|
|
+// ------------------------------------------------------------------------
|
|
|
+// footer 说明区:启动时在版本后面水平滚动显示一首古诗;
|
|
|
+// 点击任意 Tab 后该区显示当前 Tab 的说明文字;古诗不自动刷新(手动点击换一首)
|
|
|
+// ------------------------------------------------------------------------
|
|
|
+let poemSpeaking = false; // 是否正在朗读
|
|
|
+let currentPoem = null; // 当前显示的古诗数据
|
|
|
+let footerMode = 'desc'; // 当前 footer 显示模式:'poem'(古诗)| 'desc'(Tab 说明)
|
|
|
+let footerScrollRAF = null;
|
|
|
+
|
|
|
+// 检测文字是否超出容器宽度:超出则用双份文本 + 水平滚动动画(无缝衔接)
|
|
|
+function updateFooterMarquee() {
|
|
|
+ if (!footerMarqueeEl) return;
|
|
|
+ if (footerScrollRAF) { cancelAnimationFrame(footerScrollRAF); footerScrollRAF = null; }
|
|
|
+ footerMarqueeEl.classList.remove('scrolling');
|
|
|
+ const inner = footerMarqueeEl._inner;
|
|
|
+ if (!inner || !inner.textContent) return;
|
|
|
+ // 下一帧测量,确保布局完成
|
|
|
+ footerScrollRAF = requestAnimationFrame(() => {
|
|
|
+ const single = inner.dataset.text || '';
|
|
|
+ inner.textContent = single; // 先按单份测量一份文本宽度
|
|
|
+ const singleW = inner.scrollWidth;
|
|
|
+ if (singleW > footerMarqueeEl.clientWidth + 4) {
|
|
|
+ inner.textContent = single + single; // 双份文本:-50% 位移正好滚完一份并接上下一份
|
|
|
+ footerMarqueeEl.classList.add('scrolling');
|
|
|
+ }
|
|
|
+ });
|
|
|
+}
|
|
|
+
|
|
|
+// 设置 footer 说明区内容:文本 + 是否显示朗读按钮;color/fontSize 仅古诗显示时应用
|
|
|
+function setFooterText(text, opts = {}) {
|
|
|
+ if (!footerMarqueeEl) return;
|
|
|
+ const safeText = text || '';
|
|
|
+ footerMarqueeEl.title = safeText;
|
|
|
+ footerMarqueeEl.style.color = opts.color || '';
|
|
|
+ footerMarqueeEl.style.fontSize = opts.fontSize ? opts.fontSize + 'px' : '';
|
|
|
+ // 滚动速度:设置 CSS 变量(秒/轮),供 footerMarquee 动画时长引用
|
|
|
+ if (opts.speed) {
|
|
|
+ footerMarqueeEl.style.setProperty('--poem-speed', opts.speed + 's');
|
|
|
+ }
|
|
|
+ if (poemReadBtn) poemReadBtn.hidden = !opts.showRead;
|
|
|
+ // 内层 span:静态显示单份文本;超宽时再复制一份实现无缝滚动
|
|
|
+ let inner = footerMarqueeEl._inner;
|
|
|
+ if (!inner) {
|
|
|
+ inner = document.createElement('span');
|
|
|
+ inner.className = 'footer-marquee-inner';
|
|
|
+ footerMarqueeEl.appendChild(inner);
|
|
|
+ footerMarqueeEl._inner = inner;
|
|
|
+ }
|
|
|
+ inner.dataset.text = safeText; // 保存原始文本,供测量 / 双份拼接
|
|
|
+ inner.textContent = safeText;
|
|
|
+ updateFooterMarquee();
|
|
|
+}
|
|
|
+
|
|
|
+// 古诗朗读(TTS);再点停止
|
|
|
+function togglePoemRead() {
|
|
|
+ if (!poemReadBtn) return;
|
|
|
+ if (poemSpeaking) {
|
|
|
+ speechSynthesis.cancel();
|
|
|
+ poemSpeaking = false;
|
|
|
+ poemReadBtn.classList.remove('is-active');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ if (!currentPoem) return;
|
|
|
+ const lines = [currentPoem.title, currentPoem.author, ...(currentPoem.content || [])]
|
|
|
+ .filter(s => s && s.trim())
|
|
|
+ .join(',');
|
|
|
+ const utter = new SpeechSynthesisUtterance(lines);
|
|
|
+ utter.lang = 'zh-CN';
|
|
|
+ utter.rate = 0.9;
|
|
|
+ utter.onend = () => {
|
|
|
+ poemSpeaking = false;
|
|
|
+ poemReadBtn.classList.remove('is-active');
|
|
|
+ };
|
|
|
+ utter.onerror = () => {
|
|
|
+ poemSpeaking = false;
|
|
|
+ poemReadBtn.classList.remove('is-active');
|
|
|
+ };
|
|
|
+ speechSynthesis.speak(utter);
|
|
|
+ poemSpeaking = true;
|
|
|
+ poemReadBtn.classList.add('is-active');
|
|
|
+}
|
|
|
+
|
|
|
+// 把一首古诗渲染到 footer 说明区(滚动显示,带朗读按钮)
|
|
|
+function renderPoem(poem) {
|
|
|
+ currentPoem = poem;
|
|
|
+ const title = poem?.title || '';
|
|
|
+ const author = [poem?.dynasty, poem?.author].filter(Boolean).join(' · ');
|
|
|
+ // 完整古诗:题目、作者、全部诗句(单行拼接滚动显示)
|
|
|
+ const lines = (poem?.content || []).filter(s => s && s.trim());
|
|
|
+ const text = [title, author, ...lines].filter(Boolean).join(' ');
|
|
|
+ setFooterText(text || '暂无古诗', {
|
|
|
+ color: currentSettings?.poem?.color || '#c98a3a',
|
|
|
+ fontSize: currentSettings?.poem?.fontSize || 16,
|
|
|
+ showRead: !!currentSettings?.poem?.tts,
|
|
|
+ speed: currentSettings?.poem?.speed || 18,
|
|
|
+ });
|
|
|
+}
|
|
|
+
|
|
|
+// 拉取一首随机古诗并显示(失败时静默,避免清空说明区)
|
|
|
+async function loadFooterPoem(showHint = false) {
|
|
|
+ if (footerMode !== 'poem') return; // 已切到 Tab 说明,不再显示古诗
|
|
|
+ try {
|
|
|
+ const poem = await SettingsService.GetRandomPoem();
|
|
|
+ renderPoem(poem);
|
|
|
+ updatePoemTool(poem); // 同步显示到工具页「古诗词」卡片
|
|
|
+ return poem;
|
|
|
+ } catch (err) {
|
|
|
+ if (showHint) {
|
|
|
+ setFooterText(`古诗加载失败:${err?.message || err}`, { showRead: false });
|
|
|
+ }
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// 启动时进入古诗模式:在版本后面滚动显示一首古诗(不自动刷新)
|
|
|
+function showFooterPoem(cfg) {
|
|
|
+ footerMode = 'poem';
|
|
|
+ const enabled = !!cfg?.enabled;
|
|
|
+ if (!enabled) { // 未启用:回退显示当前 Tab 说明
|
|
|
+ const cur = document.querySelector('.tab-btn.active')?.dataset.tab || 'notes';
|
|
|
+ showTabDesc(cur);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ loadFooterPoem(false); // 加载一首古诗并滚动显示
|
|
|
+}
|
|
|
+
|
|
|
+// 切换到 Tab 说明模式:显示指定 Tab 的说明文字(替代古诗)
|
|
|
+function showTabDesc(tabId) {
|
|
|
+ footerMode = 'desc';
|
|
|
+ if (poemSpeaking) { // 切走时停止朗读
|
|
|
+ speechSynthesis.cancel();
|
|
|
+ poemSpeaking = false;
|
|
|
+ poemReadBtn?.classList.remove('is-active');
|
|
|
+ }
|
|
|
+ setFooterText(getTabDesc(tabId), { showRead: false });
|
|
|
+}
|
|
|
+
|
|
|
+// 初始化 footer 说明区:绑定古诗点击换诗 / 朗读事件
|
|
|
+function initFooterPoem(cfg) {
|
|
|
+ if (poemReadBtn) poemReadBtn.addEventListener('click', togglePoemRead);
|
|
|
+ if (footerMarqueeEl) footerMarqueeEl.addEventListener('click', () => {
|
|
|
+ if (footerMode === 'poem') loadFooterPoem(true); // 古诗模式下点击换一首
|
|
|
+ });
|
|
|
+ window.addEventListener('resize', updateFooterMarquee); // 窗口尺寸变化时重测滚动
|
|
|
+ showFooterPoem(cfg); // 启动默认显示古诗
|
|
|
+}
|
|
|
+
|
|
|
+// 从控件收集 Tab 顺序:逗号(中英文)分隔的名称,转回 tabId(含内置与自定义);未知名称忽略
|
|
|
+function collectTabOrder() {
|
|
|
+ const nameToId = {};
|
|
|
+ Object.entries(TAB_NAMES).forEach(([id, name]) => { nameToId[name] = id; });
|
|
|
+ customTabs.forEach(t => { nameToId[t.name] = t.id; });
|
|
|
+ return tabOrderText.value.split(/[,,\s]+/)
|
|
|
+ .map(s => s.trim())
|
|
|
+ .filter(Boolean)
|
|
|
+ .map(name => nameToId[name])
|
|
|
+ .filter(Boolean);
|
|
|
+}
|
|
|
+
|
|
|
+async function saveSettings() {
|
|
|
+ if (isSaving) return; // 正在保存时跳过重复触发(自动保存与点击可能同时发生)
|
|
|
+ isSaving = true;
|
|
|
+ saveButton.disabled = true;
|
|
|
+ saveStatus.textContent = '保存中…';
|
|
|
+ saveStatus.classList.remove('is-error', 'is-ok');
|
|
|
+
|
|
|
+ try {
|
|
|
+ const saved = await SettingsService.SaveSettings(new AppSettings(collectForm()));
|
|
|
+ currentSettings = saved;
|
|
|
+ await syncForm(saved);
|
|
|
+ applyEmbedUrls(saved);
|
|
|
+ renderLinks(saved.links || []);
|
|
|
+ // 古诗配置变化:重新进入古诗模式(若启用则加载一首,未启用回退 Tab 说明)
|
|
|
+ showFooterPoem(saved.poem);
|
|
|
+ loadStocks(); // 代码变化后立即刷新行情
|
|
|
+ restartAutoTimers(); // 按新设置的间隔重建各面板定时器
|
|
|
+
|
|
|
+ isDirty = false; // 保存成功,清除未保存标记
|
|
|
+ saveStatus.textContent = '已保存并生效';
|
|
|
+ saveStatus.classList.add('is-ok');
|
|
|
+ } catch (err) {
|
|
|
+ saveStatus.textContent = '保存失败:' + (err?.message || err);
|
|
|
+ saveStatus.classList.add('is-error');
|
|
|
+ } finally {
|
|
|
+ isSaving = false;
|
|
|
+ saveButton.disabled = false;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// 焦点离开设置界面时自动保存(仅在有改动时触发)
|
|
|
+async function autoSaveSettings() {
|
|
|
+ if (!isDirty) return;
|
|
|
+ await saveSettings();
|
|
|
+}
|
|
|
+
|
|
|
+saveButton.addEventListener('click', saveSettings);
|
|
|
+
|
|
|
+// 所有设置输入控件统一监听:任何修改都标记未保存
|
|
|
+document.querySelectorAll('#tab-settings .input, #tab-settings input[type="checkbox"], #tab-settings input[type="color"]').forEach(input => {
|
|
|
+ input.addEventListener('input', markDirty); // 文本 / 数字 / 颜色输入
|
|
|
+ input.addEventListener('change', markDirty); // 开关切换
|
|
|
+});
|
|
|
+
|
|
|
+// 焦点移出设置面板(点击面板外区域 / 切换 tab)时自动保存
|
|
|
+const settingsPanel = document.getElementById('tab-settings');
|
|
|
+settingsPanel.addEventListener('focusout', (event) => {
|
|
|
+ if (!settingsPanel.contains(event.relatedTarget)) {
|
|
|
+ autoSaveSettings();
|
|
|
+ }
|
|
|
+});
|
|
|
+
|
|
|
+// ------------------------------------------------------------------------
|
|
|
+// 常用网址:卡片网格,点击在浏览器打开
|
|
|
+// ------------------------------------------------------------------------
|
|
|
+const linksGrid = document.getElementById('links-grid');
|
|
|
+
|
|
|
+function renderLinks(links) {
|
|
|
+ if (!links || links.length === 0) {
|
|
|
+ linksGrid.innerHTML = `
|
|
|
+ <div class="empty-state">
|
|
|
+ <div class="empty-mark">◈</div>
|
|
|
+ <div class="empty-title">暂无常用网址</div>
|
|
|
+ <div class="empty-text">在「设置」中按 名称|网址 每行一条添加</div>
|
|
|
+ </div>`;
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ linksGrid.innerHTML = links.map(link => `
|
|
|
+ <a class="card link-card" href="${escapeHtml(link.url)}" target="_blank" data-wml-openURL="${escapeHtml(link.url)}">
|
|
|
+ <div class="card-title"><span class="dot"></span>${escapeHtml(link.name)}</div>
|
|
|
+ <p>${escapeHtml(link.url)}</p>
|
|
|
+ </a>`).join('');
|
|
|
+ WML.Reload(); // 动态插入的链接需要重新绑定打开行为
|
|
|
+}
|
|
|
+
|
|
|
+// ------------------------------------------------------------------------
|
|
|
+// 新闻:煎蛋 + 头条热榜,命中关键词置顶高亮
|
|
|
+// ------------------------------------------------------------------------
|
|
|
+const newsList = document.getElementById('news-list');
|
|
|
+const newsRefreshBtn = document.getElementById('news-refresh');
|
|
|
+const newsRefreshTime = document.getElementById('news-refresh-time'); // 最后一次成功刷新时间徽标
|
|
|
+const VIEWED_NEWS_KEY = 'tengtu-viewed-news'; // 已查看新闻 URL 集合的存储键
|
|
|
+
|
|
|
+// 显示最后一次成功刷新的时间(持久化到 localStorage,重启后仍可显示)
|
|
|
+function showNewsRefreshTime(ts) {
|
|
|
+ if (!newsRefreshTime) return;
|
|
|
+ localStorage.setItem('tengtu-news-last-success', String(ts || Date.now()));
|
|
|
+ const d = new Date(ts || Date.now());
|
|
|
+ const p = n => String(n).padStart(2, '0');
|
|
|
+ newsRefreshTime.textContent = `最后刷新 ${d.getMonth() + 1}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
|
|
|
+}
|
|
|
+
|
|
|
+// 初始化:优先从 localStorage 恢复上次成功时间
|
|
|
+(function initNewsRefreshTime() {
|
|
|
+ if (!newsRefreshTime) return;
|
|
|
+ const saved = parseInt(localStorage.getItem('tengtu-news-last-success'), 10);
|
|
|
+ if (saved) showNewsRefreshTime(saved);
|
|
|
+})();
|
|
|
+
|
|
|
+// 切换刷新按钮的加载状态(转圈动画)
|
|
|
+function setNewsLoading(loading) {
|
|
|
+ newsRefreshBtn.classList.toggle('loading', loading);
|
|
|
+ newsRefreshBtn.disabled = loading;
|
|
|
+}
|
|
|
+
|
|
|
+// 读取已查看新闻 URL 集合
|
|
|
+function getViewedNews() {
|
|
|
+ try {
|
|
|
+ return new Set(JSON.parse(localStorage.getItem(VIEWED_NEWS_KEY) || '[]'));
|
|
|
+ } catch {
|
|
|
+ return new Set();
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// 记录某条新闻为已查看(最多保留 500 条,防 localStorage 膨胀)
|
|
|
+function markNewsViewed(url) {
|
|
|
+ if (!url) return;
|
|
|
+ const viewed = getViewedNews();
|
|
|
+ viewed.add(url);
|
|
|
+ const arr = [...viewed].slice(-500);
|
|
|
+ try { localStorage.setItem(VIEWED_NEWS_KEY, JSON.stringify(arr)); } catch { /* 存储满则忽略 */ }
|
|
|
+}
|
|
|
+
|
|
|
+let currentNews = []; // 当前显示的新闻列表,供 TTS 阅读使用
|
|
|
+
|
|
|
+async function loadNews() {
|
|
|
+ setNewsLoading(true); // 显示刷新过程(转圈动画,按钮不可再点)
|
|
|
+ try {
|
|
|
+ // 并行抓取:普通新闻 + B站动态(后者失败不阻断新闻展示,单独显示提示)
|
|
|
+ const [news, bili] = await Promise.all([
|
|
|
+ SettingsService.GetNews(),
|
|
|
+ SettingsService.GetBiliDynamic().catch(err => ({ __error: err?.message || String(err) })),
|
|
|
+ ]);
|
|
|
+
|
|
|
+ const viewed = getViewedNews(); // 已查看集合,用于灰显
|
|
|
+ const rowHtml = n => `
|
|
|
+ <a class="news-row ${n.matched ? 'matched' : ''} ${viewed.has(n.url) ? 'viewed' : ''}" href="${escapeHtml(n.url)}" target="_blank" data-wml-openURL="${escapeHtml(n.url)}">
|
|
|
+ <span class="news-src">${escapeHtml(n.source)}</span>
|
|
|
+ <span class="news-title">${escapeHtml(n.title)}</span>
|
|
|
+ </a>`;
|
|
|
+
|
|
|
+ // B站动态区块(放在新闻列表上方,独立分组)
|
|
|
+ let biliHtml = '';
|
|
|
+ if (bili && bili.__error) {
|
|
|
+ biliHtml = `<div class="news-group-label"><span class="news-group-dot"></span>B站动态</div>
|
|
|
+ <div class="news-bili-hint">${escapeHtml(bili.__error)}</div>`;
|
|
|
+ } else if (bili && bili.length) {
|
|
|
+ const biliShown = bili.slice(0, 8); // B站动态最多显示 8 条,避免喧宾夺主
|
|
|
+ biliHtml = `<div class="news-group-label"><span class="news-group-dot"></span>B站关注动态(${biliShown.length})</div>`;
|
|
|
+ biliHtml += biliShown.map(rowHtml).join('');
|
|
|
+ biliHtml += `<div class="news-divider"></div>`;
|
|
|
+ }
|
|
|
+
|
|
|
+ let newsHtml = '';
|
|
|
+ if (!news || news.length === 0) {
|
|
|
+ newsHtml = `<div class="empty-state">
|
|
|
+ <div class="empty-mark">◈</div>
|
|
|
+ <div class="empty-title">暂无新闻</div>
|
|
|
+ <div class="empty-text">请检查网络后点击右上角「刷新」</div>
|
|
|
+ </div>`;
|
|
|
+ } else {
|
|
|
+ const maxCount = Math.max(5, currentSettings.newsCount || FALLBACK_SETTINGS.newsCount);
|
|
|
+ const shown = news.slice(0, maxCount); // 按设置中的条数截断
|
|
|
+ currentNews = shown; // 记录当前列表供 TTS 阅读
|
|
|
+ const matchedRows = shown.filter(n => n.matched);
|
|
|
+ const otherRows = shown.filter(n => !n.matched);
|
|
|
+ if (matchedRows.length) {
|
|
|
+ newsHtml += `<div class="news-group-label"><span class="news-group-dot"></span>关注关键词(${matchedRows.length})</div>`;
|
|
|
+ newsHtml += matchedRows.map(rowHtml).join('');
|
|
|
+ }
|
|
|
+ if (matchedRows.length && otherRows.length) {
|
|
|
+ newsHtml += `<div class="news-divider"></div>`;
|
|
|
+ }
|
|
|
+ if (otherRows.length) {
|
|
|
+ newsHtml += `<div class="news-group-label news-group-other"><span class="news-group-dot"></span>其他(${otherRows.length})</div>`;
|
|
|
+ newsHtml += otherRows.map(rowHtml).join('');
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ newsList.innerHTML = biliHtml + newsHtml;
|
|
|
+ WML.Reload();
|
|
|
+ showNewsRefreshTime(Date.now()); // 刷新成功:更新最后刷新时间
|
|
|
+ } catch (err) {
|
|
|
+ console.warn('获取新闻失败:', err);
|
|
|
+ // 刷新失败:列表保持原样,仅提示
|
|
|
+ newsList.innerHTML = `<div class="empty-state">
|
|
|
+ <div class="empty-mark">◈</div>
|
|
|
+ <div class="empty-title">获取新闻失败</div>
|
|
|
+ <div class="empty-text">${escapeHtml(err?.message || String(err))}</div>
|
|
|
+ </div>`;
|
|
|
+ } finally {
|
|
|
+ setNewsLoading(false); // 结束加载状态
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// 点击新闻链接时标记为已查看(用捕获阶段确保记录后仍正常跳转)
|
|
|
+newsList.addEventListener('click', e => {
|
|
|
+ const row = e.target.closest('a.news-row');
|
|
|
+ if (row) markNewsViewed(row.getAttribute('href'));
|
|
|
+}, true);
|
|
|
+
|
|
|
+newsRefreshBtn.addEventListener('click', loadNews); // 手动刷新
|
|
|
+
|
|
|
+// ------------------------------------------------------------------------
|
|
|
+// 新闻 TTS 阅读:朗读未点击过的新闻标题,当前朗读的行高亮
|
|
|
+// ------------------------------------------------------------------------
|
|
|
+const newsReadBtn = document.getElementById('news-read');
|
|
|
+
|
|
|
+// 高亮当前正在朗读的新闻行(清除其他行的高亮)
|
|
|
+function setReadingRow(html) {
|
|
|
+ newsList.querySelectorAll('.news-row.reading').forEach(el => el.classList.remove('reading'));
|
|
|
+ const row = newsList.querySelector(`.news-row[href="${CSS.escape(html)}"]`);
|
|
|
+ if (row) row.classList.add('reading');
|
|
|
+}
|
|
|
+
|
|
|
+function readUnviewedNews() {
|
|
|
+ if (typeof speechSynthesis === 'undefined' || !('SpeechSynthesisUtterance' in window)) {
|
|
|
+ alert('当前环境不支持语音朗读(TTS)'); // 无 TTS 能力时提示
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ if (speechSynthesis.speaking) {
|
|
|
+ speechSynthesis.cancel(); // 正在朗读则停止(再次点击即停止)
|
|
|
+ newsReadBtn.textContent = '阅读'; // 显式复位按钮文案,避免 cancel 不触发 onend 时停留在「停止」
|
|
|
+ newsReadBtn.classList.remove('is-active');
|
|
|
+ setReadingRow(''); // 清除高亮
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ const viewed = getViewedNews(); // 已查看集合
|
|
|
+ const unread = currentNews.filter(n => !viewed.has(n.url)); // 仅未点击过的
|
|
|
+ if (!unread.length) {
|
|
|
+ alert('没有未阅读的新闻'); // 全部已读
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ // 逐条朗读(每条一句),当前朗读的行高亮显示
|
|
|
+ unread.forEach((n, i) => {
|
|
|
+ const utter = new SpeechSynthesisUtterance(`${n.title}`);
|
|
|
+ utter.lang = 'zh-CN';
|
|
|
+ utter.rate = 1.0;
|
|
|
+ utter.onstart = () => setReadingRow(n.url); // 开始朗读时高亮对应行
|
|
|
+ utter.onend = () => { if (i === unread.length - 1) setReadingRow(''); }; // 最后一条读完清除高亮
|
|
|
+ speechSynthesis.speak(utter);
|
|
|
+ });
|
|
|
+ newsReadBtn.textContent = '停止'; // 朗读中按钮显示「停止」
|
|
|
+ newsReadBtn.classList.add('is-active');
|
|
|
+ speechSynthesis.onend = () => { newsReadBtn.textContent = '阅读'; newsReadBtn.classList.remove('is-active'); setReadingRow(''); };
|
|
|
+}
|
|
|
+
|
|
|
+newsReadBtn.addEventListener('click', readUnviewedNews);
|
|
|
+
|
|
|
+// ------------------------------------------------------------------------
|
|
|
+// Github:监听仓库最新提交(带刷新动画,解析出错 30 秒后自动重试)
|
|
|
+// ------------------------------------------------------------------------
|
|
|
+const repoList = document.getElementById('repo-list');
|
|
|
+const githubRefreshBtn = document.getElementById('github-refresh');
|
|
|
+const githubRefreshTime = document.getElementById('github-refresh-time'); // 最后一次成功刷新时间徽标
|
|
|
+
|
|
|
+// 显示最后一次成功刷新的时间(持久化到 localStorage,重启后仍可显示)
|
|
|
+function showGithubRefreshTime(ts) {
|
|
|
+ if (!githubRefreshTime) return;
|
|
|
+ localStorage.setItem('tengtu-github-last-success', String(ts || Date.now()));
|
|
|
+ const d = new Date(ts || Date.now());
|
|
|
+ const p = n => String(n).padStart(2, '0');
|
|
|
+ githubRefreshTime.textContent = `最后刷新 ${d.getMonth() + 1}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
|
|
|
+}
|
|
|
+
|
|
|
+// 初始化:优先从 localStorage 恢复上次成功时间
|
|
|
+(function initGithubRefreshTime() {
|
|
|
+ if (!githubRefreshTime) return;
|
|
|
+ const saved = parseInt(localStorage.getItem('tengtu-github-last-success'), 10);
|
|
|
+ if (saved) showGithubRefreshTime(saved);
|
|
|
+})();
|
|
|
+
|
|
|
+let githubRetryTimer = null; // 失败 / 无数据后的 30 秒重试定时器
|
|
|
+
|
|
|
+// 调度 30 秒后的自动重试(与设置的刷新间隔无关,仅用于异常恢复)
|
|
|
+function scheduleGithubRetry() {
|
|
|
+ clearTimeout(githubRetryTimer);
|
|
|
+ githubRetryTimer = setTimeout(() => {
|
|
|
+ githubRetryTimer = null;
|
|
|
+ loadGithub();
|
|
|
+ }, 30000);
|
|
|
+}
|
|
|
+
|
|
|
+// 切换刷新按钮的加载状态(转圈动画)
|
|
|
+function setGithubLoading(loading) {
|
|
|
+ githubRefreshBtn.classList.toggle('loading', loading);
|
|
|
+ githubRefreshBtn.disabled = loading;
|
|
|
+}
|
|
|
+
|
|
|
+async function loadGithub() {
|
|
|
+ setGithubLoading(true); // 显示刷新过程
|
|
|
+ try {
|
|
|
+ const feeds = await SettingsService.GetGithubFeeds();
|
|
|
+ clearTimeout(githubRetryTimer); // 先取消可能挂起的重试
|
|
|
+ githubRetryTimer = null;
|
|
|
+
|
|
|
+ if (!feeds || feeds.length === 0) {
|
|
|
+ repoList.innerHTML = `
|
|
|
+ <div class="empty-state">
|
|
|
+ <div class="empty-mark">◈</div>
|
|
|
+ <div class="empty-title">暂无关注项目</div>
|
|
|
+ <div class="empty-text">在「设置」中按 仓库地址|项目说明 每行一条添加</div>
|
|
|
+ </div>`;
|
|
|
+ return; // 未配置仓库不算错误,不重试
|
|
|
+ }
|
|
|
+
|
|
|
+ repoList.innerHTML = feeds.map(feed => {
|
|
|
+ // 版本徽标:有更新则高亮可点击下载;本地已最新则直接勾选本地版本(不再显示「最新」标签)
|
|
|
+ let versionBadge = '';
|
|
|
+ // 「本地 xxx」徽标也做成可点击链接,跳转到仓库版本列表页(releases),
|
|
|
+ // 便于查看本地版本对应与相邻的所有版本;feed.repo 为 owner/repo 短名。
|
|
|
+ const releasesURL = feed.repo
|
|
|
+ ? 'https://github.com/' + feed.repo + '/releases'
|
|
|
+ : (feed.url || '#');
|
|
|
+ if (feed.latest) {
|
|
|
+ if (feed.hasUpdate) {
|
|
|
+ // 发现新版本:高亮可点击下载 + 检视按钮
|
|
|
+ const badgeText = `发现新版本 ${escapeHtml(feed.latest)}`;
|
|
|
+ versionBadge = `<a class="ver-badge ver-update" href="${escapeHtml(feed.releaseURL || feed.url)}" target="_blank" data-wml-openURL="${escapeHtml(feed.releaseURL || feed.url)}" title="点击前往下载">● ${badgeText}</a>`;
|
|
|
+ if (feed.version) versionBadge += `<a class="ver-local" href="${escapeHtml(releasesURL)}" target="_blank" data-wml-openURL="${escapeHtml(releasesURL)}" title="查看版本列表">本地 ${escapeHtml(feed.version)}</a>`;
|
|
|
+ versionBadge += `<button class="btn-ghost repo-check-btn" data-url="${escapeHtml(feed.url)}" data-latest="${escapeHtml(feed.latest)}">检视</button>`;
|
|
|
+ } else if (feed.version) {
|
|
|
+ // 本地已是最新版本:勾选本地版本号,不再显示「最新 xxx」标签
|
|
|
+ versionBadge = `<a class="ver-badge ver-ok" href="${escapeHtml(releasesURL)}" target="_blank" data-wml-openURL="${escapeHtml(releasesURL)}" title="查看版本列表">✓ 本地 ${escapeHtml(feed.version)}</a>`;
|
|
|
+ } else {
|
|
|
+ // 无本地版本可勾选时,仍显示最新版本徽标
|
|
|
+ versionBadge = `<span class="ver-badge ver-ok">✓ 最新 ${escapeHtml(feed.latest)}</span>`;
|
|
|
+ }
|
|
|
+ } else if (feed.version) {
|
|
|
+ versionBadge = `<a class="ver-badge ver-muted" href="${escapeHtml(releasesURL)}" target="_blank" data-wml-openURL="${escapeHtml(releasesURL)}" title="查看版本列表">本地 ${escapeHtml(feed.version)}</a>`;
|
|
|
+ }
|
|
|
+
|
|
|
+ // Latest 之后的版本(Pre-release 等):仅列出展示,不参与更新比较
|
|
|
+ let newerReleases = '';
|
|
|
+ if (feed.newer && feed.newer.length > 0) {
|
|
|
+ newerReleases = `<div class="repo-newer">` +
|
|
|
+ feed.newer.map(r => {
|
|
|
+ // 优先显示 release 名称(标题),无名称时回退到版本标签
|
|
|
+ const displayName = r.name && r.name.trim() ? r.name : r.tagName;
|
|
|
+ return `<a class="repo-newer-item${r.isPrerelease ? ' is-pre' : ''}${r.isDraft ? ' is-draft' : ''}" href="${escapeHtml(r.url)}" target="_blank" data-wml-openURL="${escapeHtml(r.url)}" title="发布于 ${escapeHtml(r.publishedAt || '未知')}">${escapeHtml(displayName)}${r.isPrerelease ? ' · 预发布' : ''}${r.isDraft ? ' · 草稿' : ''}</a>`;
|
|
|
+ }).join('') +
|
|
|
+ `</div>`;
|
|
|
+ }
|
|
|
+
|
|
|
+ return `
|
|
|
+ <div class="repo-card">
|
|
|
+ <div class="repo-head">
|
|
|
+ <a class="repo-name" href="${escapeHtml(feed.url)}" target="_blank" data-wml-openURL="${escapeHtml(feed.url)}">${escapeHtml(feed.repo || feed.url)}</a>
|
|
|
+ ${feed.desc ? `<span class="repo-desc">${escapeHtml(feed.desc)}</span>` : ''}
|
|
|
+ ${versionBadge}
|
|
|
+ </div>
|
|
|
+ ${newerReleases}
|
|
|
+ </div>`;
|
|
|
+ }).join('');
|
|
|
+ WML.Reload();
|
|
|
+
|
|
|
+ // 「检视」按钮:标记已检查,把本地版本更新为最新版,消除更新提示
|
|
|
+ repoList.querySelectorAll('.repo-check-btn').forEach(btn => {
|
|
|
+ btn.addEventListener('click', async () => {
|
|
|
+ btn.disabled = true; // 防止重复点击
|
|
|
+ btn.textContent = '处理中…';
|
|
|
+ try {
|
|
|
+ await SettingsService.MarkGithubRepoChecked(btn.dataset.url, btn.dataset.latest);
|
|
|
+ await loadGithub(); // 重新拉取,更新徽标状态
|
|
|
+ } catch (err) {
|
|
|
+ console.warn('标记检视失败:', err);
|
|
|
+ btn.disabled = false;
|
|
|
+ btn.textContent = '检视';
|
|
|
+ }
|
|
|
+ });
|
|
|
+ });
|
|
|
+
|
|
|
+ // 任一仓库抓取失败 → 30 秒后自动重试(提交列表已不显示,无数据不再触发轮询)
|
|
|
+ const anyError = feeds.some(f => f.error);
|
|
|
+ // 至少一个仓库抓取成功才算一次「成功刷新」,更新最后成功时间
|
|
|
+ if (feeds.some(f => !f.error)) {
|
|
|
+ showGithubRefreshTime(Date.now());
|
|
|
+ }
|
|
|
+ if (anyError) {
|
|
|
+ scheduleGithubRetry();
|
|
|
+ }
|
|
|
+ } catch (err) {
|
|
|
+ console.warn('获取仓库动态失败:', err);
|
|
|
+ scheduleGithubRetry(); // 异常:30 秒后自动重试
|
|
|
+ } finally {
|
|
|
+ setGithubLoading(false);
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+githubRefreshBtn.addEventListener('click', loadGithub); // 手动刷新
|
|
|
+
|
|
|
+// ------------------------------------------------------------------------
|
|
|
+// Github 热门仓库:抓取 github.com/trending,英文简介已由后端翻译为中文
|
|
|
+// ------------------------------------------------------------------------
|
|
|
+const trendingList = document.getElementById('trending-list');
|
|
|
+const trendingRefreshBtn = document.getElementById('trending-refresh');
|
|
|
+const trendingRefreshTime = document.getElementById('trending-refresh-time'); // 最后一次成功刷新时间
|
|
|
+const trendingBlock = document.querySelector('.trending-block'); // 热门仓库整块(失败时整块隐藏)
|
|
|
+
|
|
|
+// 显示最后一次成功刷新的时间(持久化到 localStorage,重启后仍可显示)
|
|
|
+function showTrendingRefreshTime(ts) {
|
|
|
+ if (!trendingRefreshTime) return;
|
|
|
+ localStorage.setItem('tengtu-trending-last-success', String(ts || Date.now()));
|
|
|
+ const d = new Date(ts || Date.now());
|
|
|
+ const p = n => String(n).padStart(2, '0');
|
|
|
+ trendingRefreshTime.textContent = `最后刷新 ${d.getMonth() + 1}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
|
|
|
+}
|
|
|
+
|
|
|
+// 初始化:优先从 localStorage 恢复上次成功时间
|
|
|
+(function initTrendingRefreshTime() {
|
|
|
+ if (!trendingRefreshTime) return;
|
|
|
+ const saved = parseInt(localStorage.getItem('tengtu-trending-last-success'), 10);
|
|
|
+ if (saved) showTrendingRefreshTime(saved);
|
|
|
+})();
|
|
|
+
|
|
|
+// 切换热门仓库刷新按钮的加载状态(转圈动画)
|
|
|
+function setTrendingLoading(loading) {
|
|
|
+ trendingRefreshBtn.classList.toggle('loading', loading);
|
|
|
+ trendingRefreshBtn.disabled = loading;
|
|
|
+}
|
|
|
+
|
|
|
+async function loadTrending() {
|
|
|
+ if (!trendingList) return;
|
|
|
+ setTrendingLoading(true); // 显示刷新过程(转圈动画,按钮不可再点)
|
|
|
+ try {
|
|
|
+ const repos = await SettingsService.GetTrendingRepos();
|
|
|
+ if (!repos || repos.length === 0) {
|
|
|
+ // 获取不到:整块隐藏,不显示占位提示
|
|
|
+ trendingBlock.style.display = 'none';
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ trendingBlock.style.display = ''; // 有数据时恢复显示
|
|
|
+ trendingList.innerHTML = repos.map(r => `
|
|
|
+ <a class="trending-card" href="${escapeHtml(r.url)}" target="_blank" data-wml-openURL="${escapeHtml(r.url)}">
|
|
|
+ <div class="trending-card-head">
|
|
|
+ <span class="trending-repo">${escapeHtml(r.name)}</span>
|
|
|
+ ${r.lang ? `<span class="trending-lang">${escapeHtml(r.lang)}</span>` : ''}
|
|
|
+ </div>
|
|
|
+ ${r.desc ? `<div class="trending-desc">${escapeHtml(r.desc)}</div>` : ''}
|
|
|
+ <div class="trending-meta">
|
|
|
+ ${r.todayStars ? `<span class="trending-stat trending-today">★ 今日 +${escapeHtml(r.todayStars)}</span>` : ''}
|
|
|
+ ${r.stars ? `<span class="trending-stat">★ ${escapeHtml(r.stars)}</span>` : ''}
|
|
|
+ ${r.forks ? `<span class="trending-stat">⑂ ${escapeHtml(r.forks)}</span>` : ''}
|
|
|
+ </div>
|
|
|
+ </a>`).join('');
|
|
|
+ WML.Reload();
|
|
|
+ showTrendingRefreshTime(Date.now()); // 刷新成功:更新最后刷新时间
|
|
|
+ } catch (err) {
|
|
|
+ console.warn('获取热门仓库失败:', err);
|
|
|
+ // 获取失败:整块隐藏(用户要求「无法获取则不显示」)
|
|
|
+ trendingBlock.style.display = 'none';
|
|
|
+ } finally {
|
|
|
+ setTrendingLoading(false); // 结束加载状态
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+trendingRefreshBtn.addEventListener('click', loadTrending); // 手动刷新
|
|
|
+
|
|
|
+// ------------------------------------------------------------------------
|
|
|
+// 记录:编辑记录文件
|
|
|
+// ------------------------------------------------------------------------
|
|
|
+const recordEditor = document.getElementById('record-editor'); // 记录编辑框
|
|
|
+const recordStatus = document.getElementById('record-status'); // 记录状态提示
|
|
|
+const recordSaveBtn = document.getElementById('record-save'); // 记录保存按钮
|
|
|
+const recordPathDesc = document.getElementById('record-path-desc'); // 显示当前记录文件路径
|
|
|
+const recordPreview = document.getElementById('record-preview'); // Markdown 预览容器
|
|
|
+const recordModeEdit = document.getElementById('record-mode-edit'); // 编辑模式按钮
|
|
|
+const recordModePreview = document.getElementById('record-mode-preview'); // 预览模式按钮
|
|
|
+
|
|
|
+// 记录页显示模式:'edit' 源码编辑 / 'preview' Markdown 预览
|
|
|
+let recordMode = 'edit';
|
|
|
+
|
|
|
+async function loadRecord() {
|
|
|
+ recordPathDesc.textContent = `记录零碎信息,当前文件:${currentSettings.recordPath || FALLBACK_SETTINGS.recordPath}`;
|
|
|
+ try {
|
|
|
+ recordEditor.value = await SettingsService.ReadRecord();
|
|
|
+ recordStatus.textContent = '';
|
|
|
+ refreshRecordPreview(); // 首次加载后刷新预览
|
|
|
+ } catch (err) {
|
|
|
+ recordStatus.textContent = '读取失败:' + (err?.message || err);
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// 把当前编辑内容渲染到 Markdown 预览区(调用后端 gomarkdown)
|
|
|
+async function refreshRecordPreview() {
|
|
|
+ const md = recordEditor.value;
|
|
|
+ try {
|
|
|
+ recordPreview.innerHTML = await SettingsService.RenderMarkdown(md);
|
|
|
+ } catch (err) {
|
|
|
+ recordPreview.textContent = '预览失败:' + (err?.message || err);
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// 切换记录页显示模式(编辑 / 预览),并给两个按钮标记激活态
|
|
|
+function setRecordMode(mode) {
|
|
|
+ recordMode = mode;
|
|
|
+ const isEdit = mode === 'edit';
|
|
|
+ recordModeEdit.classList.toggle('is-active', isEdit);
|
|
|
+ recordModePreview.classList.toggle('is-active', !isEdit);
|
|
|
+ recordEditor.style.display = isEdit ? '' : 'none';
|
|
|
+ recordPreview.style.display = isEdit ? 'none' : '';
|
|
|
+ if (!isEdit) refreshRecordPreview(); // 进入预览时渲染一次
|
|
|
+}
|
|
|
+
|
|
|
+recordModeEdit.addEventListener('click', () => setRecordMode('edit'));
|
|
|
+recordModePreview.addEventListener('click', () => setRecordMode('preview'));
|
|
|
+
|
|
|
+// 编辑内容变化时,若正处于预览模式则实时刷新(简单去抖)
|
|
|
+let recordPreviewTimer = null;
|
|
|
+recordEditor.addEventListener('input', () => {
|
|
|
+ if (recordMode !== 'preview') return;
|
|
|
+ clearTimeout(recordPreviewTimer);
|
|
|
+ recordPreviewTimer = setTimeout(refreshRecordPreview, 250);
|
|
|
+});
|
|
|
+
|
|
|
+recordSaveBtn.addEventListener('click', async () => {
|
|
|
+ recordStatus.textContent = '保存中…';
|
|
|
+ try {
|
|
|
+ await SettingsService.WriteRecord(recordEditor.value);
|
|
|
+ recordStatus.textContent = '已保存 ' + new Date().toLocaleTimeString('zh-CN');
|
|
|
+ } catch (err) {
|
|
|
+ recordStatus.textContent = '保存失败:' + (err?.message || err);
|
|
|
+ }
|
|
|
+});
|
|
|
+
|
|
|
+// ------------------------------------------------------------------------
|
|
|
+// 待办:每行记录一个待完成任务,可勾选完成 / 编辑 / 删除,保存到本地文件
|
|
|
+// ------------------------------------------------------------------------
|
|
|
+const todoList = document.getElementById('todo-list'); // 待办任务列表容器
|
|
|
+const todoSaveBtn = document.getElementById('todo-save'); // 保存按钮
|
|
|
+const todoAddBtn = document.getElementById('todo-add'); // 添加任务按钮
|
|
|
+const todoStatus = document.getElementById('todo-status'); // 保存状态提示
|
|
|
+const todoPathDesc = document.getElementById('todo-path-desc'); // 显示当前待办文件路径
|
|
|
+let todoDirty = false; // 是否有未保存修改
|
|
|
+
|
|
|
+// 解析待办行:分离任务文本与提醒时间。
|
|
|
+// 行格式:任务 @ 2026-10-31 17:30 或 任务 @ 17:30(当天)。
|
|
|
+// 返回 {text, reminder},reminder 为 datetime-local 可用的值(YYYY-MM-DDTHH:mm)或空。
|
|
|
+function parseTodoLine(line) {
|
|
|
+ const m = line.match(/\s*@\s*((\d{4}-\d{2}-\d{2})\s+)?(\d{1,2}):(\d{2})\s*$/);
|
|
|
+ if (!m) return { text: line.trim(), reminder: '' };
|
|
|
+ let reminder = '';
|
|
|
+ const hh = m[3].padStart(2, '0');
|
|
|
+ const mm = m[4].padStart(2, '0');
|
|
|
+ if (m[2]) {
|
|
|
+ reminder = `${m[2]}T${hh}:${mm}`; // 带日期
|
|
|
+ } else {
|
|
|
+ // 无日期:默认今天
|
|
|
+ const today = new Date();
|
|
|
+ const ymd = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
|
|
|
+ reminder = `${ymd}T${hh}:${mm}`;
|
|
|
+ }
|
|
|
+ return { text: line.replace(/\s*@\s*((\d{4}-\d{2}-\d{2})\s+)?(\d{1,2}):(\d{2})\s*$/, '').trim(), reminder };
|
|
|
+}
|
|
|
+
|
|
|
+// 把 datetime-local 值转为待办行标记(YYYY-MM-DD HH:MM)
|
|
|
+function todoReminderMark(value) {
|
|
|
+ if (!value) return '';
|
|
|
+ // value 形如 2026-10-31T17:30,转为 2026-10-31 17:30
|
|
|
+ return value.replace('T', ' ');
|
|
|
+}
|
|
|
+
|
|
|
+// 渲染一行待办:复选框(完成状态)+ 文本输入框 + 提醒时间 + 删除按钮
|
|
|
+function renderTodoRow(text, done, reminder) {
|
|
|
+ const row = document.createElement('div');
|
|
|
+ row.className = 'todo-row';
|
|
|
+
|
|
|
+ const check = document.createElement('input');
|
|
|
+ check.type = 'checkbox';
|
|
|
+ check.className = 'todo-check';
|
|
|
+ check.checked = !!done;
|
|
|
+ check.title = '标记完成';
|
|
|
+ check.addEventListener('change', () => {
|
|
|
+ input.classList.toggle('todo-done', check.checked); // 勾选后文字划线
|
|
|
+ todoDirty = true;
|
|
|
+ });
|
|
|
+
|
|
|
+ const input = document.createElement('input');
|
|
|
+ input.type = 'text';
|
|
|
+ input.className = 'todo-input';
|
|
|
+ input.value = text;
|
|
|
+ input.placeholder = '输入待办任务…';
|
|
|
+ input.spellcheck = false;
|
|
|
+ if (done) input.classList.add('todo-done');
|
|
|
+ input.addEventListener('input', () => { todoDirty = true; });
|
|
|
+ input.addEventListener('keydown', (e) => {
|
|
|
+ if (e.key === 'Enter') { e.preventDefault(); addTodoRow(); } // 回车快速添加下一行
|
|
|
+ });
|
|
|
+
|
|
|
+ // 提醒时间选择器(datetime-local):设置后到点弹窗提醒
|
|
|
+ const remind = document.createElement('input');
|
|
|
+ remind.type = 'datetime-local';
|
|
|
+ remind.className = 'todo-remind';
|
|
|
+ remind.value = reminder || '';
|
|
|
+ remind.title = '设置提醒时间,到点弹窗提醒';
|
|
|
+ remind.addEventListener('change', () => { todoDirty = true; });
|
|
|
+ remind.addEventListener('input', () => { todoDirty = true; });
|
|
|
+
|
|
|
+ // 清除提醒时间按钮
|
|
|
+ const clearRemind = document.createElement('button');
|
|
|
+ clearRemind.className = 'btn-ghost todo-remind-clear';
|
|
|
+ clearRemind.textContent = '×';
|
|
|
+ clearRemind.title = '清除提醒时间';
|
|
|
+ clearRemind.disabled = !reminder;
|
|
|
+ clearRemind.addEventListener('click', () => {
|
|
|
+ remind.value = '';
|
|
|
+ clearRemind.disabled = true;
|
|
|
+ todoDirty = true;
|
|
|
+ });
|
|
|
+ remind.addEventListener('change', () => {
|
|
|
+ clearRemind.disabled = !remind.value;
|
|
|
+ });
|
|
|
+
|
|
|
+ const del = document.createElement('button');
|
|
|
+ del.className = 'btn-ghost todo-del';
|
|
|
+ del.textContent = '删除';
|
|
|
+ del.title = '删除此行';
|
|
|
+ del.addEventListener('click', () => {
|
|
|
+ row.remove();
|
|
|
+ todoDirty = true;
|
|
|
+ });
|
|
|
+
|
|
|
+ row.appendChild(check);
|
|
|
+ row.appendChild(input);
|
|
|
+ row.appendChild(remind);
|
|
|
+ row.appendChild(clearRemind);
|
|
|
+ row.appendChild(del);
|
|
|
+ return row;
|
|
|
+}
|
|
|
+
|
|
|
+// 添加一行空的待办(聚焦输入框)
|
|
|
+function addTodoRow() {
|
|
|
+ const row = renderTodoRow('', false);
|
|
|
+ todoList.appendChild(row);
|
|
|
+ row.querySelector('.todo-input').focus();
|
|
|
+ todoDirty = true;
|
|
|
+}
|
|
|
+
|
|
|
+// 从后端读取待办并渲染:每行一个任务,行首 [x] 表示已完成,行尾 @ 时间表示提醒
|
|
|
+async function loadTodo() {
|
|
|
+ todoPathDesc.textContent = `待办任务,当前文件:${currentSettings.todoPath || FALLBACK_SETTINGS.todoPath}`;
|
|
|
+ todoList.innerHTML = '';
|
|
|
+ try {
|
|
|
+ const raw = await SettingsService.ReadTodo();
|
|
|
+ const lines = (raw || '').split(/\r?\n/);
|
|
|
+ let hasContent = false;
|
|
|
+ for (const line of lines) {
|
|
|
+ if (!line.trim()) continue; // 跳过空行
|
|
|
+ hasContent = true;
|
|
|
+ const done = /^\[x\]\s*/i.test(line); // 行首 [x] 标记完成
|
|
|
+ const body = line.replace(/^\[[ x]\]\s*/i, ''); // 去掉 [x] 前缀
|
|
|
+ const parsed = parseTodoLine(body); // 分离文本与提醒时间
|
|
|
+ todoList.appendChild(renderTodoRow(parsed.text, done, parsed.reminder));
|
|
|
+ }
|
|
|
+ if (!hasContent) addTodoRow(); // 无任务时给一行空输入
|
|
|
+ todoDirty = false;
|
|
|
+ todoStatus.textContent = '';
|
|
|
+ } catch (err) {
|
|
|
+ todoStatus.textContent = '读取失败:' + (err?.message || err);
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// 保存所有待办:每行输出 "[x] 任务 @ 提醒时间" 或 "任务 @ 提醒时间",跳过空行
|
|
|
+async function saveTodo() {
|
|
|
+ const lines = [];
|
|
|
+ todoList.querySelectorAll('.todo-row').forEach(row => {
|
|
|
+ const input = row.querySelector('.todo-input');
|
|
|
+ const done = row.querySelector('.todo-check').checked;
|
|
|
+ const remind = row.querySelector('.todo-remind').value;
|
|
|
+ const text = input.value.trim();
|
|
|
+ if (!text) return; // 空行不保存
|
|
|
+ const mark = todoReminderMark(remind); // 提醒标记(YYYY-MM-DD HH:MM)
|
|
|
+ const body = mark ? `${text} @ ${mark}` : text;
|
|
|
+ lines.push(done ? `[x] ${body}` : body);
|
|
|
+ });
|
|
|
+ todoStatus.textContent = '保存中…';
|
|
|
+ try {
|
|
|
+ await SettingsService.WriteTodo(lines.join('\n'));
|
|
|
+ todoStatus.textContent = '已保存 ' + new Date().toLocaleTimeString('zh-CN');
|
|
|
+ todoDirty = false;
|
|
|
+ } catch (err) {
|
|
|
+ todoStatus.textContent = '保存失败:' + (err?.message || err);
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+todoSaveBtn.addEventListener('click', saveTodo); // 保存全部待办
|
|
|
+todoAddBtn.addEventListener('click', addTodoRow); // 添加一行
|
|
|
+
|
|
|
+// ------------------------------------------------------------------------
|
|
|
+// 待办提醒:后端到点广播 todoReminder 事件,前端弹出置顶消息窗
|
|
|
+// ------------------------------------------------------------------------
|
|
|
+const remindModal = document.getElementById('remind-modal');
|
|
|
+const remindText = document.getElementById('remind-text');
|
|
|
+const remindTime = document.getElementById('remind-time');
|
|
|
+const remindOk = document.getElementById('remind-ok');
|
|
|
+const remindMask = document.getElementById('remind-mask'); // 弹窗遮罩,点击可关闭
|
|
|
+let remindOpen = false;
|
|
|
+
|
|
|
+// 显示提醒弹窗:payload 为 "任务文本\nYYYY-MM-DD HH:MM"
|
|
|
+function showTodoReminder(payload) {
|
|
|
+ const lines = String(payload || '').split('\n');
|
|
|
+ remindText.textContent = lines[0] || '';
|
|
|
+ remindTime.textContent = lines[1] ? '提醒时间:' + lines[1] : '';
|
|
|
+ remindModal.hidden = false;
|
|
|
+ remindOpen = true;
|
|
|
+ // 窗口可能隐藏,唤回前台(后端已 showMainWindow,此处确保聚焦)
|
|
|
+ Window.Show();
|
|
|
+ Window.Focus();
|
|
|
+}
|
|
|
+
|
|
|
+// 关闭提醒弹窗
|
|
|
+function closeTodoReminder() {
|
|
|
+ remindModal.hidden = true;
|
|
|
+ remindOpen = false;
|
|
|
+}
|
|
|
+
|
|
|
+remindOk.addEventListener('click', closeTodoReminder);
|
|
|
+remindMask.addEventListener('click', closeTodoReminder);
|
|
|
+
|
|
|
+// 订阅后端待办提醒事件
|
|
|
+Events.On('todoReminder', (event) => {
|
|
|
+ showTodoReminder(event.data);
|
|
|
+});
|
|
|
+
|
|
|
+// 从待办页切走时若有未保存修改,自动保存(与设置页一致)
|
|
|
+const todoPanel = document.getElementById('tab-todo');
|
|
|
+if (todoPanel) {
|
|
|
+ new MutationObserver(() => {
|
|
|
+ if (todoDirty) saveTodo(); // 列表变化后自动保存
|
|
|
+ }).observe(todoList, { childList: true, subtree: false });
|
|
|
+}
|
|
|
+
|
|
|
+// ------------------------------------------------------------------------
|
|
|
+// 主机:API 服务状态、电源命令状态与 Gin 日志
|
|
|
+// ------------------------------------------------------------------------
|
|
|
+const apiStatusEl = document.getElementById('api-status'); // API 服务状态文本
|
|
|
+const apiToggleBtn = document.getElementById('api-toggle'); // API 服务停止/启动按钮
|
|
|
+const pcStatusEl = document.getElementById('pc-status'); // 电源命令状态文本
|
|
|
+const logView = document.getElementById('log-view'); // Gin 日志视图
|
|
|
+const logClearBtn = document.getElementById('log-clear'); // 清空日志按钮
|
|
|
+const screenshotCaptureBtn = document.getElementById('shot-capture'); // 截屏按钮(工具页)
|
|
|
+const screenshotBox = document.getElementById('screenshot-box'); // 截屏预览容器
|
|
|
+const screenshotImg = document.getElementById('screenshot-img'); // 截屏图片
|
|
|
+const screenshotHint = document.getElementById('screenshot-hint'); // 截屏提示(尺寸/时间)
|
|
|
+const shotStatusEl = document.getElementById('shot-status'); // 截屏状态徽标
|
|
|
+const shotHintEl = document.getElementById('shot-hint'); // 截屏提示文字
|
|
|
+
|
|
|
+// 截屏并显示预览:先隐藏本程序窗口,截取全屏后恢复显示
|
|
|
+async function captureScreenshot() {
|
|
|
+ if (!screenshotCaptureBtn) return;
|
|
|
+ screenshotCaptureBtn.classList.add('loading'); // 转圈反馈
|
|
|
+ screenshotCaptureBtn.disabled = true;
|
|
|
+ shotHintEl.textContent = '';
|
|
|
+ shotStatusEl.textContent = '截屏中…';
|
|
|
+ try {
|
|
|
+ // 1) 隐藏本程序窗口,避免截到自身;等窗口真正隐藏后再截
|
|
|
+ Window.Hide();
|
|
|
+ await new Promise(r => setTimeout(r, 400));
|
|
|
+
|
|
|
+ // 2) 截取全部屏幕
|
|
|
+ const dataURI = await SettingsService.CaptureScreen(-1); // -1 全部屏幕
|
|
|
+ if (!dataURI) throw new Error('未返回图片数据');
|
|
|
+
|
|
|
+ // 3) 恢复显示本程序窗口并聚焦
|
|
|
+ Window.Show();
|
|
|
+ Window.Focus();
|
|
|
+
|
|
|
+ // 4) 显示预览
|
|
|
+ screenshotImg.src = dataURI;
|
|
|
+ screenshotBox.hidden = false;
|
|
|
+ // 显示截图尺寸与时间
|
|
|
+ screenshotImg.onload = () => {
|
|
|
+ const d = new Date();
|
|
|
+ const p = n => String(n).padStart(2, '0');
|
|
|
+ screenshotHint.textContent = `${screenshotImg.naturalWidth}×${screenshotImg.naturalHeight} | ${d.getMonth() + 1}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
|
|
|
+ };
|
|
|
+ shotStatusEl.textContent = '已截取';
|
|
|
+ shotHintEl.textContent = '截图已更新(本程序窗口已自动隐藏避免入镜)';
|
|
|
+ } catch (err) {
|
|
|
+ console.warn('截屏失败:', err);
|
|
|
+ // 失败也要恢复窗口,避免窗口保持隐藏
|
|
|
+ Window.Show();
|
|
|
+ Window.Focus();
|
|
|
+ screenshotBox.hidden = false;
|
|
|
+ screenshotImg.removeAttribute('src');
|
|
|
+ screenshotHint.textContent = '截屏失败:' + (err?.message || String(err));
|
|
|
+ shotStatusEl.textContent = '截屏失败';
|
|
|
+ shotHintEl.textContent = '';
|
|
|
+ } finally {
|
|
|
+ screenshotCaptureBtn.classList.remove('loading');
|
|
|
+ screenshotCaptureBtn.disabled = false;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+if (screenshotCaptureBtn) screenshotCaptureBtn.addEventListener('click', captureScreenshot); // 点击截屏
|
|
|
+
|
|
|
+async function loadHostPanel() {
|
|
|
+ try {
|
|
|
+ const status = await SettingsService.GetAPIStatus();
|
|
|
+ apiStatusEl.textContent = status.running
|
|
|
+ ? `运行中 | http://${status.addr} | 启动于 ${status.startedAt}`
|
|
|
+ : `未运行${status.addr ? '(' + status.addr + ')' : ''}${status.error ? ':' + status.error : ''}`;
|
|
|
+ // 按钮乒乓:运行中→「停止服务」,未运行→「启动服务」
|
|
|
+ apiToggleBtn.textContent = status.running ? '停止服务' : '启动服务';
|
|
|
+ apiToggleBtn.classList.toggle('is-active', status.running);
|
|
|
+
|
|
|
+ const pc = await SettingsService.GetPCStatus();
|
|
|
+ pcStatusEl.textContent = pc.requested
|
|
|
+ ? (pc.pending
|
|
|
+ ? `待执行:${pc.command},剩余 ${pc.remainingSecs} 秒(来自 ${pc.clientIp})`
|
|
|
+ : `最近请求:${pc.command || '-'}(${pc.requestTime || '-'},来自 ${pc.clientIp || '-'})`)
|
|
|
+ : '暂无请求';
|
|
|
+
|
|
|
+ const logs = await SettingsService.GetAPILogs();
|
|
|
+ logView.textContent = logs && logs.length ? logs.join('\n') : '暂无日志';
|
|
|
+ logView.scrollTop = logView.scrollHeight; // 滚动到最新一行
|
|
|
+ } catch (err) {
|
|
|
+ apiStatusEl.textContent = '状态获取失败(浏览器预览模式无后端)';
|
|
|
+ console.warn('主机面板加载失败:', err);
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// 乒乓切换 API 服务:运行中→停止,未运行→启动
|
|
|
+async function toggleAPIService() {
|
|
|
+ apiToggleBtn.disabled = true;
|
|
|
+ try {
|
|
|
+ const status = await SettingsService.GetAPIStatus();
|
|
|
+ if (status.running) {
|
|
|
+ await SettingsService.StopAPIService();
|
|
|
+ } else {
|
|
|
+ await SettingsService.StartAPIService();
|
|
|
+ }
|
|
|
+ await loadHostPanel(); // 刷新状态与日志
|
|
|
+ } catch (err) {
|
|
|
+ console.warn('API 服务切换失败:', err);
|
|
|
+ apiStatusEl.textContent = '操作失败:' + (err?.message || String(err));
|
|
|
+ } finally {
|
|
|
+ apiToggleBtn.disabled = false;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+apiToggleBtn.addEventListener('click', toggleAPIService);
|
|
|
+
|
|
|
+// 后端实时推送的日志行:追加显示,保持滚动到底部
|
|
|
+Events.On('apilog', (event) => {
|
|
|
+ if (logView.textContent === '暂无日志') logView.textContent = '';
|
|
|
+ const nearBottom = logView.scrollTop + logView.clientHeight >= logView.scrollHeight - 20;
|
|
|
+ logView.textContent += '\n' + event.data;
|
|
|
+ if (nearBottom) logView.scrollTop = logView.scrollHeight; // 已在底部时跟随滚动
|
|
|
+});
|
|
|
+
|
|
|
+logClearBtn.addEventListener('click', async () => {
|
|
|
+ try {
|
|
|
+ await SettingsService.ClearAPILogs();
|
|
|
+ } catch (err) { /* 预览模式忽略 */ }
|
|
|
+ logView.textContent = '暂无日志';
|
|
|
+});
|
|
|
+
|
|
|
+// ------------------------------------------------------------------------
|
|
|
+// 工具页:功能卡片折叠——点击标题切换展开/收起,默认全部折叠
|
|
|
+// ------------------------------------------------------------------------
|
|
|
+function initToolCollapse() {
|
|
|
+ document.querySelectorAll('.tool-card').forEach(card => {
|
|
|
+ const head = card.querySelector('.tool-card-head');
|
|
|
+ if (!head) return;
|
|
|
+ const toggle = () => {
|
|
|
+ const collapsed = card.classList.toggle('is-collapsed');
|
|
|
+ head.setAttribute('aria-expanded', String(!collapsed));
|
|
|
+ };
|
|
|
+ head.addEventListener('click', toggle);
|
|
|
+ head.addEventListener('keydown', e => {
|
|
|
+ if (e.key === 'Enter' || e.key === ' ') {
|
|
|
+ e.preventDefault();
|
|
|
+ toggle();
|
|
|
+ }
|
|
|
+ });
|
|
|
+ });
|
|
|
+}
|
|
|
+initToolCollapse();
|
|
|
+
|
|
|
+// ------------------------------------------------------------------------
|
|
|
+// 工具页:Web 服务——选择目录,开启本地浏览服务
|
|
|
+// ------------------------------------------------------------------------
|
|
|
+const websvcStatusEl = document.getElementById('websvc-status'); // 运行状态徽标
|
|
|
+const websvcDirInput = document.getElementById('websvc-dir'); // 目录输入框
|
|
|
+const websvcPickBtn = document.getElementById('websvc-pick'); // 选择目录按钮
|
|
|
+const websvcStartBtn = document.getElementById('websvc-start'); // 开启服务按钮
|
|
|
+const websvcStopBtn = document.getElementById('websvc-stop'); // 停止服务按钮
|
|
|
+const websvcHintEl = document.getElementById('websvc-hint'); // 提示文字
|
|
|
+const websvcPreview = document.getElementById('websvc-preview'); // 预览容器
|
|
|
+const websvcFrame = document.getElementById('websvc-frame'); // 预览 iframe
|
|
|
+
|
|
|
+let websvcDir = ''; // 当前选中的目录(持久化于 localStorage)
|
|
|
+
|
|
|
+// 渲染 Web 服务状态(运行中 / 未运行)
|
|
|
+function renderWebSvc(info) {
|
|
|
+ if (info && info.running) {
|
|
|
+ // URL 做成可点击链接:点击用系统浏览器打开
|
|
|
+ websvcStatusEl.innerHTML = `● 运行中 <a class="tool-url" href="${escapeHtml(info.url)}" target="_blank" data-wml-openURL="${escapeHtml(info.url)}" title="点击在浏览器打开">${escapeHtml(info.url)}</a>`;
|
|
|
+ websvcStatusEl.className = 'tool-status is-on';
|
|
|
+ websvcStartBtn.disabled = true;
|
|
|
+ websvcStopBtn.disabled = false;
|
|
|
+ websvcHintEl.textContent = info.indexName
|
|
|
+ ? `默认页 ${info.indexName}|${info.connections} 次请求`
|
|
|
+ : `未发现 index.html 或 readme.md,显示目录列表|${info.connections} 次请求`;
|
|
|
+ // 预览:加载服务首页
|
|
|
+ websvcPreview.hidden = false;
|
|
|
+ websvcFrame.src = info.url;
|
|
|
+ } else {
|
|
|
+ websvcStatusEl.textContent = '未运行';
|
|
|
+ websvcStatusEl.className = 'tool-status';
|
|
|
+ websvcStartBtn.disabled = false;
|
|
|
+ websvcStopBtn.disabled = true;
|
|
|
+ websvcHintEl.textContent = websvcDir ? '点击「开启浏览服务」开始' : '请先选择目录';
|
|
|
+ websvcPreview.hidden = true;
|
|
|
+ websvcFrame.src = 'about:blank';
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// 首次进入工具页:恢复上次目录与运行状态
|
|
|
+async function loadToolsPanel() {
|
|
|
+ websvcDir = localStorage.getItem('tengtu-websvc-dir') || '';
|
|
|
+ if (websvcDir) websvcDirInput.value = websvcDir;
|
|
|
+ try {
|
|
|
+ const info = await SettingsService.WebServiceStatus();
|
|
|
+ if (info && info.running && info.dir) {
|
|
|
+ websvcDir = info.dir;
|
|
|
+ websvcDirInput.value = info.dir;
|
|
|
+ renderWebSvc(info);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ } catch (err) { /* 预览模式忽略 */ }
|
|
|
+ renderWebSvc(null);
|
|
|
+}
|
|
|
+
|
|
|
+// 选择目录
|
|
|
+websvcPickBtn.addEventListener('click', async () => {
|
|
|
+ try {
|
|
|
+ const dir = await SettingsService.PickWebDir();
|
|
|
+ if (!dir) return; // 用户取消
|
|
|
+ websvcDir = dir;
|
|
|
+ websvcDirInput.value = dir;
|
|
|
+ localStorage.setItem('tengtu-websvc-dir', dir);
|
|
|
+ renderWebSvc(null); // 重新渲染(若在运行则提示先停止)
|
|
|
+ const info = await SettingsService.WebServiceStatus();
|
|
|
+ if (info.running) renderWebSvc(info); // 仍运行则恢复状态
|
|
|
+ } catch (err) {
|
|
|
+ console.warn('选择目录失败:', err);
|
|
|
+ websvcHintEl.textContent = '选择目录失败:' + (err?.message || String(err));
|
|
|
+ }
|
|
|
+});
|
|
|
+
|
|
|
+// 开启浏览服务
|
|
|
+websvcStartBtn.addEventListener('click', async () => {
|
|
|
+ if (!websvcDir) {
|
|
|
+ websvcHintEl.textContent = '请先选择目录';
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ websvcStartBtn.classList.add('loading');
|
|
|
+ websvcStartBtn.disabled = true;
|
|
|
+ try {
|
|
|
+ const info = await SettingsService.StartWebService(websvcDir);
|
|
|
+ renderWebSvc(info);
|
|
|
+ } catch (err) {
|
|
|
+ console.warn('开启服务失败:', err);
|
|
|
+ websvcHintEl.textContent = '开启失败:' + (err?.message || String(err));
|
|
|
+ } finally {
|
|
|
+ websvcStartBtn.classList.remove('loading');
|
|
|
+ }
|
|
|
+});
|
|
|
+
|
|
|
+// 停止浏览服务
|
|
|
+websvcStopBtn.addEventListener('click', async () => {
|
|
|
+ websvcStopBtn.classList.add('loading');
|
|
|
+ websvcStopBtn.disabled = true;
|
|
|
+ try {
|
|
|
+ await SettingsService.StopWebService();
|
|
|
+ renderWebSvc(null);
|
|
|
+ } catch (err) {
|
|
|
+ console.warn('停止服务失败:', err);
|
|
|
+ websvcHintEl.textContent = '停止失败:' + (err?.message || String(err));
|
|
|
+ } finally {
|
|
|
+ websvcStopBtn.classList.remove('loading');
|
|
|
+ }
|
|
|
+});
|
|
|
+
|
|
|
+// ------------------------------------------------------------------------
|
|
|
+// 翻译工具:中文→英文 / 英文→中文,自动检测方向(MyMemory 免费接口)
|
|
|
+// ------------------------------------------------------------------------
|
|
|
+const trStatusEl = document.getElementById('tr-status'); // 状态徽标
|
|
|
+const trSourceEl = document.getElementById('tr-source'); // 输入框
|
|
|
+const trGoBtn = document.getElementById('tr-go'); // 翻译按钮
|
|
|
+const trHintEl = document.getElementById('tr-hint'); // 提示文字
|
|
|
+const trResultEl = document.getElementById('tr-result'); // 结果区
|
|
|
+
|
|
|
+// 检测输入是否含中文:含 CJK 字符视为中文(→译英),否则视为英文(→译中)
|
|
|
+function isChineseText(text) {
|
|
|
+ return /[\u4e00-\u9fff]/.test(text);
|
|
|
+}
|
|
|
+
|
|
|
+// 翻译:调用后端 Translate,展示结果或错误
|
|
|
+async function doTranslate() {
|
|
|
+ const text = trSourceEl.value.trim();
|
|
|
+ if (!text) {
|
|
|
+ trHintEl.textContent = '请输入要翻译的内容';
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ trGoBtn.classList.add('loading');
|
|
|
+ trGoBtn.disabled = true;
|
|
|
+ trHintEl.textContent = '';
|
|
|
+ trStatusEl.textContent = '翻译中…';
|
|
|
+ try {
|
|
|
+ const result = await SettingsService.Translate(text);
|
|
|
+ const dir = isChineseText(text) ? '中文 → 英文' : '英文 → 中文';
|
|
|
+ trResultEl.textContent = result.text;
|
|
|
+ trResultEl.hidden = false;
|
|
|
+ trStatusEl.textContent = dir;
|
|
|
+ } catch (err) {
|
|
|
+ console.warn('翻译失败:', err);
|
|
|
+ trHintEl.textContent = '翻译失败:' + (err?.message || String(err));
|
|
|
+ trStatusEl.textContent = '翻译失败';
|
|
|
+ } finally {
|
|
|
+ trGoBtn.classList.remove('loading');
|
|
|
+ trGoBtn.disabled = false;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+trGoBtn.addEventListener('click', doTranslate);
|
|
|
+
|
|
|
+// 输入框内按 Ctrl/Cmd+Enter 触发翻译
|
|
|
+trSourceEl.addEventListener('keydown', (e) => {
|
|
|
+ if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
|
|
|
+ e.preventDefault();
|
|
|
+ doTranslate();
|
|
|
+ }
|
|
|
+});
|
|
|
+
|
|
|
+// ------------------------------------------------------------------------
|
|
|
+// 二维码生成工具:输入字符串生成 PNG 二维码
|
|
|
+// ------------------------------------------------------------------------
|
|
|
+const qrStatusEl = document.getElementById('qr-status'); // 状态徽标
|
|
|
+const qrInputEl = document.getElementById('qr-input'); // 输入框
|
|
|
+const qrGoBtn = document.getElementById('qr-go'); // 生成按钮
|
|
|
+const qrHintEl = document.getElementById('qr-hint'); // 提示文字
|
|
|
+const qrBoxEl = document.getElementById('qr-box'); // 图片容器
|
|
|
+const qrImgEl = document.getElementById('qr-img'); // 二维码图片
|
|
|
+const qrSaveBtn = document.getElementById('qr-save'); // 下载按钮
|
|
|
+
|
|
|
+let qrDataUri = ''; // 最近一次生成的二维码 data URI(供下载)
|
|
|
+
|
|
|
+// 生成二维码:调用后端 GenerateQRCode
|
|
|
+async function doGenerateQR() {
|
|
|
+ const text = qrInputEl.value.trim();
|
|
|
+ if (!text) {
|
|
|
+ qrHintEl.textContent = '请输入要生成二维码的内容';
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ qrGoBtn.classList.add('loading');
|
|
|
+ qrGoBtn.disabled = true;
|
|
|
+ qrHintEl.textContent = '';
|
|
|
+ qrStatusEl.textContent = '生成中…';
|
|
|
+ try {
|
|
|
+ const uri = await SettingsService.GenerateQRCode(text, 0);
|
|
|
+ qrDataUri = uri;
|
|
|
+ qrImgEl.src = uri;
|
|
|
+ qrBoxEl.hidden = false;
|
|
|
+ qrStatusEl.textContent = '已生成';
|
|
|
+ } catch (err) {
|
|
|
+ console.warn('生成二维码失败:', err);
|
|
|
+ qrHintEl.textContent = '生成失败:' + (err?.message || String(err));
|
|
|
+ qrStatusEl.textContent = '生成失败';
|
|
|
+ } finally {
|
|
|
+ qrGoBtn.classList.remove('loading');
|
|
|
+ qrGoBtn.disabled = false;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+qrGoBtn.addEventListener('click', doGenerateQR);
|
|
|
+
|
|
|
+// 输入框内按 Ctrl/Cmd+Enter 触发生成
|
|
|
+qrInputEl.addEventListener('keydown', (e) => {
|
|
|
+ if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
|
|
|
+ e.preventDefault();
|
|
|
+ doGenerateQR();
|
|
|
+ }
|
|
|
+});
|
|
|
+
|
|
|
+// 点击图片或下载按钮:把 data URI 转成 PNG 文件下载
|
|
|
+function downloadQR() {
|
|
|
+ if (!qrDataUri) return;
|
|
|
+ const a = document.createElement('a');
|
|
|
+ a.href = qrDataUri;
|
|
|
+ a.download = 'qrcode.png';
|
|
|
+ document.body.appendChild(a);
|
|
|
+ a.click();
|
|
|
+ a.remove();
|
|
|
+}
|
|
|
+qrImgEl.addEventListener('click', downloadQR);
|
|
|
+qrSaveBtn.addEventListener('click', downloadQR);
|
|
|
+
|
|
|
+// ------------------------------------------------------------------------
|
|
|
+// ntfy 消息推送工具:输入主题 / 标题 / 内容,推送到 ntfy 频道
|
|
|
+// ------------------------------------------------------------------------
|
|
|
+const ntfyStatusEl = document.getElementById('ntfy-status'); // 状态徽标
|
|
|
+const ntfyTopicEl = document.getElementById('ntfy-topic'); // 主题输入框
|
|
|
+const ntfyTitleEl = document.getElementById('ntfy-title'); // 标题输入框
|
|
|
+const ntfyMessageEl = document.getElementById('ntfy-message'); // 内容输入框
|
|
|
+const ntfyGoBtn = document.getElementById('ntfy-go'); // 发送按钮
|
|
|
+const ntfyHintEl = document.getElementById('ntfy-hint'); // 提示文字
|
|
|
+const ntfyTopicList = document.getElementById('ntfy-topic-list'); // 历史主题下拉建议
|
|
|
+
|
|
|
+// 加载已记忆的主题历史,填入 datalist 供输入时选择
|
|
|
+async function loadNtfyTopics() {
|
|
|
+ try {
|
|
|
+ const topics = await SettingsService.GetNtfyTopics();
|
|
|
+ ntfyTopicList.innerHTML = (topics || []).map(t =>
|
|
|
+ `<option value="${t.replace(/&/g, '&').replace(/"/g, '"')}">`).join('');
|
|
|
+ } catch (err) {
|
|
|
+ console.warn('加载 ntfy 主题历史失败:', err);
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// 发送 ntfy 消息:调用后端 SendNtfyMessage,成功后刷新主题历史
|
|
|
+async function doSendNtfy() {
|
|
|
+ const topic = ntfyTopicEl.value.trim();
|
|
|
+ const title = ntfyTitleEl.value.trim();
|
|
|
+ const message = ntfyMessageEl.value.trim();
|
|
|
+ if (!topic) {
|
|
|
+ ntfyHintEl.textContent = '请输入推送主题(Topic)';
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ if (!message) {
|
|
|
+ ntfyHintEl.textContent = '请输入消息内容';
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ ntfyGoBtn.classList.add('loading');
|
|
|
+ ntfyGoBtn.disabled = true;
|
|
|
+ ntfyHintEl.textContent = '';
|
|
|
+ ntfyStatusEl.textContent = '发送中…';
|
|
|
+ try {
|
|
|
+ await SettingsService.SendNtfyMessage(topic, title, message);
|
|
|
+ ntfyStatusEl.textContent = '已发送';
|
|
|
+ ntfyHintEl.textContent = `已推送到 ${topic}`;
|
|
|
+ ntfyMessageEl.value = ''; // 发送成功清空内容(保留主题/标题便于连续发送)
|
|
|
+ await loadNtfyTopics(); // 刷新历史下拉
|
|
|
+ } catch (err) {
|
|
|
+ console.warn('ntfy 发送失败:', err);
|
|
|
+ ntfyHintEl.textContent = '发送失败:' + (err?.message || String(err));
|
|
|
+ ntfyStatusEl.textContent = '发送失败';
|
|
|
+ } finally {
|
|
|
+ ntfyGoBtn.classList.remove('loading');
|
|
|
+ ntfyGoBtn.disabled = false;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+ntfyGoBtn.addEventListener('click', doSendNtfy);
|
|
|
+
|
|
|
+// 内容输入框内按 Ctrl/Cmd+Enter 发送
|
|
|
+ntfyMessageEl.addEventListener('keydown', (e) => {
|
|
|
+ if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
|
|
|
+ e.preventDefault();
|
|
|
+ doSendNtfy();
|
|
|
+ }
|
|
|
+});
|
|
|
+
|
|
|
+loadNtfyTopics(); // 初始化时加载历史主题
|
|
|
+
|
|
|
+// ------------------------------------------------------------------------
|
|
|
+// ntfy 聊天室:输入昵称与主题,订阅并实时收发消息(QQ 气泡风格)
|
|
|
+// ------------------------------------------------------------------------
|
|
|
+const chatNicknameEl = document.getElementById('chat-nickname'); // 昵称输入框
|
|
|
+const chatTopicEl = document.getElementById('chat-topic'); // 主题输入框
|
|
|
+const chatToggleBtn = document.getElementById('chat-toggle'); // 乒乓按钮(开始监听/停止)
|
|
|
+const chatBox = document.getElementById('chat-box'); // 消息列表容器
|
|
|
+const chatListEl = document.getElementById('chat-list'); // 消息列表
|
|
|
+const chatStatusEl = document.getElementById('chat-status'); // 状态徽标
|
|
|
+const chatMessageEl = document.getElementById('chat-message'); // 消息输入框
|
|
|
+const chatSendBtn = document.getElementById('chat-send'); // 发送按钮
|
|
|
+
|
|
|
+let chatListening = false; // 是否正在监听
|
|
|
+let chatAutoScroll = true; // 用户上滚时暂停自动滚动到底部
|
|
|
+let chatNickname = ''; // 当前昵称(发送方标识,用于区分左右气泡)
|
|
|
+
|
|
|
+// 近期本地发送的消息(topic/title/message/at),用于去重 ntfy 的 SSE 回声:
|
|
|
+// 自己发送的消息会被 ntfy 经 SSE 回传一次,若不加去重会在本地显示后再追加一条。
|
|
|
+let chatLocalRecent = [];
|
|
|
+const CHAT_ECHO_WINDOW = 8000; // 8 秒内的同名同内容消息视为回声
|
|
|
+
|
|
|
+// 追加一条聊天消息为 QQ 气泡(自己的消息靠右,他人靠左)
|
|
|
+function appendChatMessage(msg) {
|
|
|
+ const mine = !!msg.title && msg.title === chatNickname && chatNickname !== '';
|
|
|
+ const row = document.createElement('div');
|
|
|
+ row.className = 'chat-msg' + (mine ? ' mine' : '');
|
|
|
+
|
|
|
+ const avatar = document.createElement('div');
|
|
|
+ avatar.className = 'chat-avatar';
|
|
|
+ avatar.textContent = (msg.title || msg.topic || '?').trim().charAt(0).toUpperCase();
|
|
|
+
|
|
|
+ const main = document.createElement('div');
|
|
|
+ main.className = 'chat-msg-main';
|
|
|
+ const head = document.createElement('div');
|
|
|
+ head.className = 'chat-msg-head';
|
|
|
+ const title = document.createElement('span');
|
|
|
+ title.className = 'chat-msg-title';
|
|
|
+ title.textContent = msg.title || msg.topic;
|
|
|
+ const time = document.createElement('span');
|
|
|
+ time.className = 'chat-msg-time';
|
|
|
+ time.textContent = msg.time || '';
|
|
|
+ head.append(title, time);
|
|
|
+
|
|
|
+ const body = document.createElement('div');
|
|
|
+ body.className = 'chat-bubble';
|
|
|
+ body.textContent = msg.message || '';
|
|
|
+
|
|
|
+ main.append(head, body);
|
|
|
+ // 自己的消息:头像在右,内容靠右;他人:头像在左
|
|
|
+ if (mine) {
|
|
|
+ row.append(main, avatar);
|
|
|
+ } else {
|
|
|
+ row.append(avatar, main);
|
|
|
+ }
|
|
|
+ chatListEl.appendChild(row);
|
|
|
+
|
|
|
+ if (chatAutoScroll) {
|
|
|
+ chatListEl.scrollTop = chatListEl.scrollHeight; // 滚动到底部
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// 乒乓切换:未监听→开始监听;监听中→停止
|
|
|
+async function toggleChat() {
|
|
|
+ if (chatListening) {
|
|
|
+ await doStopChat();
|
|
|
+ } else {
|
|
|
+ await doStartChat();
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// 开始监听:调用后端设置主题,成功后显示消息区域并切换按钮为「停止」
|
|
|
+async function doStartChat() {
|
|
|
+ const topic = chatTopicEl.value.trim();
|
|
|
+ if (!topic) {
|
|
|
+ chatStatusEl.textContent = '请输入主题(留空则不监听)';
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ chatNickname = chatNicknameEl.value.trim(); // 记录当前昵称,用于区分左右气泡
|
|
|
+ chatToggleBtn.classList.add('loading');
|
|
|
+ chatToggleBtn.disabled = true;
|
|
|
+ try {
|
|
|
+ await SettingsService.SetNtfyChatTopic(topic);
|
|
|
+ chatListening = true;
|
|
|
+ chatToggleBtn.textContent = '停止监听';
|
|
|
+ chatToggleBtn.classList.add('is-active'); // 激活态:突出显示正在监听
|
|
|
+ chatStatusEl.textContent = '监听中:' + topic;
|
|
|
+ chatBox.hidden = false;
|
|
|
+ chatListEl.innerHTML = ''; // 清空旧消息,开始新会话
|
|
|
+ await loadNtfyTopics(); // 刷新历史下拉
|
|
|
+ } catch (err) {
|
|
|
+ console.warn('ntfy 聊天室启动失败:', err);
|
|
|
+ chatStatusEl.textContent = '启动失败:' + (err?.message || String(err));
|
|
|
+ } finally {
|
|
|
+ chatToggleBtn.classList.remove('loading');
|
|
|
+ chatToggleBtn.disabled = false;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// 停止监听:调用后端清空主题,隐藏消息区域并切换按钮为「开始监听」
|
|
|
+async function doStopChat() {
|
|
|
+ chatToggleBtn.classList.add('loading');
|
|
|
+ chatToggleBtn.disabled = true;
|
|
|
+ try {
|
|
|
+ await SettingsService.SetNtfyChatTopic(''); // 空主题 = 不监听
|
|
|
+ chatListening = false;
|
|
|
+ chatToggleBtn.textContent = '开始监听';
|
|
|
+ chatToggleBtn.classList.remove('is-active');
|
|
|
+ chatStatusEl.textContent = '';
|
|
|
+ chatBox.hidden = true;
|
|
|
+ chatListEl.innerHTML = '';
|
|
|
+ } catch (err) {
|
|
|
+ console.warn('ntfy 聊天室停止失败:', err);
|
|
|
+ chatStatusEl.textContent = '停止失败:' + (err?.message || String(err));
|
|
|
+ } finally {
|
|
|
+ chatToggleBtn.classList.remove('loading');
|
|
|
+ chatToggleBtn.disabled = false;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// 发送一条聊天消息:调用后端 SendNtfyChat(昵称作为标题),成功后追加到列表
|
|
|
+async function doSendChat() {
|
|
|
+ const topic = chatTopicEl.value.trim();
|
|
|
+ const nickname = chatNicknameEl.value.trim();
|
|
|
+ const message = chatMessageEl.value.trim();
|
|
|
+ if (!nickname) {
|
|
|
+ chatStatusEl.textContent = '请输入昵称';
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ if (!topic) {
|
|
|
+ chatStatusEl.textContent = '请输入主题(聊天室名)';
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ if (!message) {
|
|
|
+ chatStatusEl.textContent = '请输入消息内容';
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ chatSendBtn.classList.add('loading');
|
|
|
+ chatSendBtn.disabled = true;
|
|
|
+ try {
|
|
|
+ await SettingsService.SendNtfyChat(topic, nickname, message);
|
|
|
+ chatNickname = nickname; // 更新昵称,用于区分左右气泡
|
|
|
+ chatMessageEl.value = ''; // 清空输入
|
|
|
+ appendChatMessage({ topic, title: nickname, message, time: new Date().toLocaleTimeString('zh-CN', { hour12: false }) });
|
|
|
+ // 记录本地发送,用于去重 ntfy 的 SSE 回声
|
|
|
+ chatLocalRecent.push({ topic, title: nickname, message, at: Date.now() });
|
|
|
+ if (chatLocalRecent.length > 20) chatLocalRecent.shift();
|
|
|
+ await loadNtfyNicknames(); // 刷新昵称历史下拉
|
|
|
+ } catch (err) {
|
|
|
+ console.warn('ntfy 聊天发送失败:', err);
|
|
|
+ chatStatusEl.textContent = '发送失败:' + (err?.message || String(err));
|
|
|
+ } finally {
|
|
|
+ chatSendBtn.classList.remove('loading');
|
|
|
+ chatSendBtn.disabled = false;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// 加载昵称历史到 datalist
|
|
|
+async function loadNtfyNicknames() {
|
|
|
+ try {
|
|
|
+ const nicknames = await SettingsService.GetNtfyNicknames();
|
|
|
+ const list = document.getElementById('ntfy-nickname-list');
|
|
|
+ if (list) {
|
|
|
+ list.innerHTML = '';
|
|
|
+ (nicknames || []).forEach(n => {
|
|
|
+ const opt = document.createElement('option');
|
|
|
+ opt.value = n;
|
|
|
+ list.appendChild(opt);
|
|
|
+ });
|
|
|
+ }
|
|
|
+ // 昵称为空时自动填入最近使用的昵称
|
|
|
+ if (!chatNicknameEl.value.trim() && nicknames && nicknames.length) {
|
|
|
+ chatNicknameEl.value = nicknames[0];
|
|
|
+ chatNickname = nicknames[0];
|
|
|
+ }
|
|
|
+ } catch (err) {
|
|
|
+ console.warn('加载 ntfy 昵称历史失败:', err);
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+chatToggleBtn.addEventListener('click', toggleChat);
|
|
|
+
|
|
|
+// 主题输入框按 Enter 开始监听
|
|
|
+chatTopicEl.addEventListener('keydown', (e) => {
|
|
|
+ if (e.key === 'Enter') {
|
|
|
+ e.preventDefault();
|
|
|
+ doStartChat();
|
|
|
+ }
|
|
|
+});
|
|
|
+
|
|
|
+// 昵称输入框按 Enter 聚焦主题(快捷流转)
|
|
|
+chatNicknameEl.addEventListener('keydown', (e) => {
|
|
|
+ if (e.key === 'Enter') {
|
|
|
+ e.preventDefault();
|
|
|
+ chatTopicEl.focus();
|
|
|
+ }
|
|
|
+});
|
|
|
+
|
|
|
+// 消息输入框按 Enter 发送
|
|
|
+chatMessageEl.addEventListener('keydown', (e) => {
|
|
|
+ if (e.key === 'Enter' && !e.shiftKey) {
|
|
|
+ e.preventDefault();
|
|
|
+ doSendChat();
|
|
|
+ }
|
|
|
+});
|
|
|
+
|
|
|
+// 发送按钮点击发送
|
|
|
+chatSendBtn.addEventListener('click', doSendChat);
|
|
|
+
|
|
|
+// 用户滚动列表时记录是否仍在底部(决定是否自动跟随新消息)
|
|
|
+chatListEl.addEventListener('scroll', () => {
|
|
|
+ chatAutoScroll = chatListEl.scrollHeight - chatListEl.scrollTop - chatListEl.clientHeight < 24;
|
|
|
+});
|
|
|
+
|
|
|
+// 订阅后端 ntfyChat 事件:收到消息时追加到列表
|
|
|
+Events.On('ntfyChat', (event) => {
|
|
|
+ let msg;
|
|
|
+ try {
|
|
|
+ msg = typeof event.data === 'string' ? JSON.parse(event.data) : event.data;
|
|
|
+ } catch (err) {
|
|
|
+ console.warn('ntfyChat 消息解析失败:', event.data, err);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ // 去重 SSE 回声:自己刚发的消息会被 ntfy 回传,若 8 秒内同主题、
|
|
|
+ // 同内容则判定为回声,跳过(本地已显示)。
|
|
|
+ // 注意:只按 topic+message 匹配,不依赖 title——ntfy 回传时标题可能
|
|
|
+ // 缺失或与昵称不一致(后端会回退为主题名),避免去重失效。
|
|
|
+ const now = Date.now();
|
|
|
+ const idx = chatLocalRecent.findIndex(m =>
|
|
|
+ now - m.at < CHAT_ECHO_WINDOW &&
|
|
|
+ m.topic === msg.topic && m.message === msg.message);
|
|
|
+ if (idx > -1) {
|
|
|
+ chatLocalRecent.splice(idx, 1); // 消费该回声记录,避免后续重复命中
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ appendChatMessage(msg);
|
|
|
+});
|
|
|
+
|
|
|
+loadNtfyNicknames(); // 初始化时加载昵称历史并自动填入最近使用的昵称
|
|
|
+
|
|
|
+// ------------------------------------------------------------------------
|
|
|
+// 古诗词工具:显示 footer 最近获取的古诗,可刷新获取新诗
|
|
|
+// ------------------------------------------------------------------------
|
|
|
+const poemToolBox = document.getElementById('poem-tool-box'); // 古诗内容容器
|
|
|
+const poemToolTitleEl = document.getElementById('poem-tool-title'); // 题目
|
|
|
+const poemToolAuthorEl = document.getElementById('poem-tool-author'); // 作者
|
|
|
+const poemToolContentEl = document.getElementById('poem-tool-content'); // 诗句
|
|
|
+const poemToolRefreshBtn = document.getElementById('poem-tool-refresh'); // 刷新按钮
|
|
|
+const poemToolReadBtn = document.getElementById('poem-tool-read'); // 朗读按钮
|
|
|
+const poemToolHintEl = document.getElementById('poem-tool-hint'); // 提示
|
|
|
+const poemToolStatusEl = document.getElementById('poem-status'); // 状态徽标
|
|
|
+
|
|
|
+let toolPoemSpeaking = false; // 工具页古诗是否正在朗读
|
|
|
+let toolCurrentPoem = null; // 工具页当前显示的古诗(供朗读)
|
|
|
+
|
|
|
+// 把一首古诗渲染到工具页卡片(与 footer 同步)
|
|
|
+function updatePoemTool(poem) {
|
|
|
+ if (!poem || !poemToolBox) return;
|
|
|
+ toolCurrentPoem = poem; // 记录当前古诗,供朗读
|
|
|
+ poemToolTitleEl.textContent = poem.title || '未命名';
|
|
|
+ // 标题颜色与 footer 古诗配置联动
|
|
|
+ poemToolTitleEl.style.color = currentSettings?.poem?.color || '#c98a3a';
|
|
|
+ poemToolAuthorEl.textContent = [poem.dynasty, poem.author].filter(Boolean).join(' · ');
|
|
|
+ const lines = (poem.content || []).filter(s => s && s.trim());
|
|
|
+ poemToolContentEl.innerHTML = ''; // 清空后按行渲染,保留换行
|
|
|
+ lines.forEach(line => {
|
|
|
+ const p = document.createElement('p');
|
|
|
+ p.textContent = line;
|
|
|
+ poemToolContentEl.appendChild(p);
|
|
|
+ });
|
|
|
+ poemToolBox.hidden = false;
|
|
|
+}
|
|
|
+
|
|
|
+// 工具页古诗朗读(TTS);再点停止。与 footer 朗读相互独立(各自管理自己的语音状态)
|
|
|
+function toggleToolPoemRead() {
|
|
|
+ if (!poemToolReadBtn) return;
|
|
|
+ if (toolPoemSpeaking) {
|
|
|
+ speechSynthesis.cancel(); // 停止所有语音(footer 与工具页共享 speechSynthesis,取消会同时停掉)
|
|
|
+ toolPoemSpeaking = false;
|
|
|
+ poemToolReadBtn.classList.remove('is-active');
|
|
|
+ poemToolReadBtn.textContent = '朗读';
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ if (!toolCurrentPoem) {
|
|
|
+ poemToolHintEl.textContent = '请先获取一首古诗';
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ const lines = [toolCurrentPoem.title, toolCurrentPoem.author, ...(toolCurrentPoem.content || [])]
|
|
|
+ .filter(s => s && s.trim())
|
|
|
+ .join(',');
|
|
|
+ const utter = new SpeechSynthesisUtterance(lines);
|
|
|
+ utter.lang = 'zh-CN';
|
|
|
+ utter.rate = 0.9;
|
|
|
+ utter.onend = () => {
|
|
|
+ toolPoemSpeaking = false;
|
|
|
+ poemToolReadBtn.classList.remove('is-active');
|
|
|
+ poemToolReadBtn.textContent = '朗读';
|
|
|
+ };
|
|
|
+ utter.onerror = () => {
|
|
|
+ toolPoemSpeaking = false;
|
|
|
+ poemToolReadBtn.classList.remove('is-active');
|
|
|
+ poemToolReadBtn.textContent = '朗读';
|
|
|
+ };
|
|
|
+ speechSynthesis.speak(utter);
|
|
|
+ toolPoemSpeaking = true;
|
|
|
+ poemToolReadBtn.classList.add('is-active');
|
|
|
+ poemToolReadBtn.textContent = '停止';
|
|
|
+}
|
|
|
+
|
|
|
+// 工具页「刷新获取」:拉取新诗,同时同步到 footer(若处于古诗模式)
|
|
|
+async function doRefreshToolPoem() {
|
|
|
+ poemToolRefreshBtn.classList.add('loading');
|
|
|
+ poemToolRefreshBtn.disabled = true;
|
|
|
+ poemToolHintEl.textContent = '';
|
|
|
+ try {
|
|
|
+ const poem = await SettingsService.GetRandomPoem();
|
|
|
+ updatePoemTool(poem);
|
|
|
+ // 同步到 footer:若当前是古诗模式则更新显示,否则仅记录供下次切回
|
|
|
+ if (footerMode === 'poem') {
|
|
|
+ renderPoem(poem);
|
|
|
+ }
|
|
|
+ poemToolHintEl.textContent = `已获取《${poem.title || '未命名'}》`;
|
|
|
+ poemToolStatusEl.textContent = '已刷新';
|
|
|
+ } catch (err) {
|
|
|
+ console.warn('工具页古诗刷新失败:', err);
|
|
|
+ poemToolHintEl.textContent = '获取失败:' + (err?.message || String(err));
|
|
|
+ poemToolStatusEl.textContent = '获取失败';
|
|
|
+ } finally {
|
|
|
+ poemToolRefreshBtn.classList.remove('loading');
|
|
|
+ poemToolRefreshBtn.disabled = false;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+if (poemToolRefreshBtn) poemToolRefreshBtn.addEventListener('click', doRefreshToolPoem);
|
|
|
+if (poemToolReadBtn) poemToolReadBtn.addEventListener('click', toggleToolPoemRead);
|
|
|
+
|
|
|
+// ------------------------------------------------------------------------
|
|
|
+// 股市行情
|
|
|
+// ------------------------------------------------------------------------
|
|
|
+function renderStockEmpty(title, text) {
|
|
|
+ stockList.innerHTML = `
|
|
|
+ <div class="empty-state">
|
|
|
+ <div class="empty-mark">◈</div>
|
|
|
+ <div class="empty-title">${title}</div>
|
|
|
+ <div class="empty-text">${text}</div>
|
|
|
+ </div>`;
|
|
|
+}
|
|
|
+
|
|
|
+let lastQuotes = []; // 最近一次行情快照,点击行打开图表时按代码查找
|
|
|
+
|
|
|
+// 从设置取涨跌色,未配置时回退默认值
|
|
|
+function upColor() { return currentSettings.stockUpColor || FALLBACK_SETTINGS.stockUpColor; }
|
|
|
+function downColor() { return currentSettings.stockDownColor || FALLBACK_SETTINGS.stockDownColor; }
|
|
|
+
|
|
|
+// ------------------------------------------------------------------------
|
|
|
+// 交易 / 休市 状态徽标:按 A 股交易时间实时判定
|
|
|
+// ------------------------------------------------------------------------
|
|
|
+const marketStatusEl = document.getElementById('market-status');
|
|
|
+
|
|
|
+function updateMarketStatus() {
|
|
|
+ const now = new Date();
|
|
|
+ const day = now.getDay();
|
|
|
+ const isWeekday = day >= 1 && day <= 5; // 周一至周五
|
|
|
+ if (!isWeekday) {
|
|
|
+ marketClosed = true;
|
|
|
+ setMarketStatus('closed', '休市 · 周末');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ const minutes = now.getHours() * 60 + now.getMinutes(); // 当天分钟数
|
|
|
+ const inMorning = minutes >= 570 && minutes < 690; // 9:30 ~ 11:30(含 9:15 集合竞价前)
|
|
|
+ const inAfternoon = minutes >= 780 && minutes < 900; // 13:00 ~ 15:00
|
|
|
+ if (inMorning || inAfternoon) {
|
|
|
+ marketClosed = false;
|
|
|
+ setMarketStatus('open', '交易中');
|
|
|
+ } else {
|
|
|
+ marketClosed = true;
|
|
|
+ setMarketStatus('closed', '休市 · 非交易时段');
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function setMarketStatus(state, text) {
|
|
|
+ if (!marketStatusEl) return;
|
|
|
+ marketStatusEl.className = 'market-status' + (state === 'open' ? ' is-open' : state === 'closed' ? ' is-closed' : '');
|
|
|
+ marketStatusEl.innerHTML = `<span class="dot"></span>${text}`;
|
|
|
+}
|
|
|
+
|
|
|
+async function loadStocks() {
|
|
|
+ setStockLoading(true); // 显示刷新过程(转圈动画,按钮不可再点)
|
|
|
+ try {
|
|
|
+ const quotes = await SettingsService.GetStocks();
|
|
|
+ lastQuotes = quotes || [];
|
|
|
+ if (!quotes || quotes.length === 0) {
|
|
|
+ renderStockEmpty('暂无股票', '请在「设置」中添加股票代码');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ stockList.innerHTML = quotes.map(q => {
|
|
|
+ const rising = q.change >= 0; // 涨跌方向
|
|
|
+ const sign = rising ? '+' : ''; // 正数补加号
|
|
|
+ const color = rising ? upColor() : downColor(); // 用设置中的涨跌颜色
|
|
|
+ return `
|
|
|
+ <div class="stock-row" data-code="${q.code}" title="点击查看分时 / 日K">
|
|
|
+ <div class="stock-main">
|
|
|
+ <span class="stock-name">${escapeHtml(q.name)}</span>
|
|
|
+ <span class="stock-code">${q.code.toUpperCase()}</span>
|
|
|
+ </div>
|
|
|
+ <div class="stock-price" style="color:${color}">${q.price.toFixed(2)}</div>
|
|
|
+ <div class="stock-change" style="color:${color}">
|
|
|
+ <span>${sign}${q.change.toFixed(2)}</span>
|
|
|
+ <span>${sign}${q.changePercent.toFixed(2)}%</span>
|
|
|
+ </div>
|
|
|
+ <div class="stock-amount">成交 ${formatAmount(q.amount)}</div>
|
|
|
+ </div>`;
|
|
|
+ }).join('');
|
|
|
+ showStockRefreshTime(Date.now()); // 刷新成功:更新最后刷新时间
|
|
|
+ } catch (err) {
|
|
|
+ console.warn('获取行情失败:', err);
|
|
|
+ renderStockEmpty('无法获取行情', '请确认代码有效且网络可访问行情接口');
|
|
|
+ } finally {
|
|
|
+ setStockLoading(false); // 结束加载状态
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+stockRefreshBtn.addEventListener('click', loadStocks); // 手动刷新
|
|
|
+
|
|
|
+// 成交额格式化:接口单位为万元,超过 1 亿用「亿」表示
|
|
|
+function formatAmount(wan) {
|
|
|
+ if (wan >= 10000) return (wan / 10000).toFixed(2) + '亿';
|
|
|
+ return wan.toFixed(0) + '万';
|
|
|
+}
|
|
|
+
|
|
|
+// 点击股市行:打开该票的分时 / 日K 弹窗
|
|
|
+stockList.addEventListener('click', (event) => {
|
|
|
+ const row = event.target.closest('.stock-row');
|
|
|
+ if (!row) return;
|
|
|
+ const quote = lastQuotes.find(q => q.code === row.dataset.code);
|
|
|
+ if (quote) openChart(quote);
|
|
|
+});
|
|
|
+
|
|
|
+// ------------------------------------------------------------------------
|
|
|
+// 窗口关闭按钮(右上角 ×):Go 端 Hook 拦截后隐藏到系统托盘
|
|
|
+// ------------------------------------------------------------------------
|
|
|
+document.getElementById('win-close')?.addEventListener('click', () => {
|
|
|
+ Window.Close(); // 触发关闭流程,实际效果为最小化到托盘
|
|
|
+});
|
|
|
+
|
|
|
+// ------------------------------------------------------------------------
|
|
|
+// 监听后端 "time" 事件(仅在分钟变化时广播),更新底部时间:
|
|
|
+// 农历 + 公历年月日时分 + 节日
|
|
|
+// ------------------------------------------------------------------------
|
|
|
+// 组装 footer 时间文本:阴历、公历(年月日 时分)、节日(若有)
|
|
|
+function formatFooterTime(now) {
|
|
|
+ const y = now.getFullYear();
|
|
|
+ const mo = String(now.getMonth() + 1).padStart(2, '0');
|
|
|
+ const d = String(now.getDate()).padStart(2, '0');
|
|
|
+ const hh = String(now.getHours()).padStart(2, '0');
|
|
|
+ const mm = String(now.getMinutes()).padStart(2, '0');
|
|
|
+ const {lunarText, festival} = getCalendarInfo(now); // 农历文字与节日
|
|
|
+ let text = `${lunarText} | ${y}年${mo}月${d}日 ${hh}:${mm}`;
|
|
|
+ if (festival) text += ` | ${festival}`; // 有节日则附加
|
|
|
+ return text;
|
|
|
+}
|
|
|
+
|
|
|
+Events.On('time', (time) => {
|
|
|
+ if (!timeElement) return;
|
|
|
+ // 后端仅跨分钟广播一次;从事件时间解析出 Date 后按需组装
|
|
|
+ const now = new Date(time.data);
|
|
|
+ if (isNaN(now)) return;
|
|
|
+ timeElement.innerText = formatFooterTime(now);
|
|
|
+});
|
|
|
+
|
|
|
+// 页面加载后立即显示当前时间(不等下一次分钟变化广播)
|
|
|
+(function () {
|
|
|
+ if (!timeElement) return;
|
|
|
+ timeElement.innerText = formatFooterTime(new Date());
|
|
|
+})();
|
|
|
+
|
|
|
+// ------------------------------------------------------------------------
|
|
|
+// 自动刷新定时器:按设置中的间隔重建(股市秒 / 新闻分钟 / Github 分钟)
|
|
|
+// ------------------------------------------------------------------------
|
|
|
+function restartAutoTimers() {
|
|
|
+ if (stockTimer) clearInterval(stockTimer);
|
|
|
+ if (newsTimer) clearInterval(newsTimer);
|
|
|
+ if (githubTimer) clearInterval(githubTimer);
|
|
|
+
|
|
|
+ const stockSecs = Math.max(5, (marketClosed ? (currentSettings.stockClosedRefreshSecs || FALLBACK_SETTINGS.stockClosedRefreshSecs)
|
|
|
+ : (currentSettings.stockRefreshSecs || FALLBACK_SETTINGS.stockRefreshSecs)));
|
|
|
+ const newsMins = Math.max(5, currentSettings.newsRefreshMinutes || FALLBACK_SETTINGS.newsRefreshMinutes);
|
|
|
+ const githubMins = Math.max(5, currentSettings.githubRefreshMinutes || FALLBACK_SETTINGS.githubRefreshMinutes);
|
|
|
+
|
|
|
+ stockTimer = setInterval(loadStocks, stockSecs * 1000); // 股市(秒,休市用较长间隔)
|
|
|
+ newsTimer = setInterval(loadNews, newsMins * 60000); // 新闻(分钟)
|
|
|
+ githubTimer = setInterval(() => { loadGithub(); loadTrending(); }, githubMins * 60000); // Github(分钟,含热门仓库)
|
|
|
+}
|
|
|
+
|
|
|
+// ------------------------------------------------------------------------
|
|
|
+// 启动:读取配置并首次拉取行情 / 新闻 / 仓库动态
|
|
|
+// ------------------------------------------------------------------------
|
|
|
+// 移动端(窄视口)检测:隐藏主机 Tab 并通知后端禁止滚动条
|
|
|
+const MOBILE_QUERY = window.matchMedia('(max-width: 640px)');
|
|
|
+
|
|
|
+function applyMobileMode() {
|
|
|
+ const mobile = MOBILE_QUERY.matches;
|
|
|
+ // 主机 Tab:移动端隐藏按钮与面板(需求2)
|
|
|
+ document.querySelectorAll('.tab-btn[data-tab="host"]').forEach(b => { b.style.display = mobile ? 'none' : ''; });
|
|
|
+ const hostPanel = document.getElementById('tab-host');
|
|
|
+ if (hostPanel) hostPanel.style.display = mobile ? 'none' : '';
|
|
|
+ // 通知后端:移动端禁止滚动文本(需求2)
|
|
|
+ if (typeof SettingsService.SetMobileMode === 'function') {
|
|
|
+ SettingsService.SetMobileMode(mobile).catch?.(() => {});
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+MOBILE_QUERY.addEventListener?.('change', applyMobileMode); // 视口变化时切换
|
|
|
+
|
|
|
+// 加载应用版本号并显示在 footer:调用后端 AppVersion(),浏览器预览时回退本地构建号
|
|
|
+async function loadVersion() {
|
|
|
+ const el = document.getElementById('footer-version-text');
|
|
|
+ if (!el) return;
|
|
|
+ try {
|
|
|
+ const v = await SettingsService.AppVersion();
|
|
|
+ el.textContent = v ? `v${v}` : '';
|
|
|
+ } catch (err) {
|
|
|
+ // 浏览器预览(无 Wails 后端)时从 index.html 的构建号或留空
|
|
|
+ console.warn('获取版本号失败:', err);
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+loadVersion();
|
|
|
+loadSettings();
|
|
|
+loadStocks();
|
|
|
+loadNews();
|
|
|
+loadGithub();
|
|
|
+loadTrending(); // 首次拉取热门仓库(GitHub Trending)
|
|
|
+applyMobileMode(); // 启动即应用移动端适配
|
|
|
+restartAutoTimers(); // 启动自动刷新定时器
|
|
|
+updateMarketStatus(); // 首次判定交易 / 休市
|
|
|
+setInterval(() => {
|
|
|
+ const prev = marketClosed;
|
|
|
+ updateMarketStatus();
|
|
|
+ if (prev !== marketClosed) restartAutoTimers(); // 交易/休市切换时重建股市定时器
|
|
|
+}, 60000); // 每分钟刷新一次状态(交易时段边界变化)
|
|
|
+
|
|
|
+// Tab 栏横向滚动:滚轮在标签栏上滚动时转为横向滚动(Tab 过多时)
|
|
|
+const tabBarEl = document.querySelector('.tab-buttons');
|
|
|
+if (tabBarEl) {
|
|
|
+ tabBarEl.addEventListener('wheel', (e) => {
|
|
|
+ // 仅当内容横向溢出时接管滚轮,避免干扰窗口滚动
|
|
|
+ if (tabBarEl.scrollWidth > tabBarEl.clientWidth) {
|
|
|
+ e.preventDefault(); // 阻止默认(Wails 拖拽 / 页面滚动)
|
|
|
+ tabBarEl.scrollLeft += (e.deltaY || e.deltaX);
|
|
|
+ }
|
|
|
+ }, {passive: false});
|
|
|
+}
|
|
|
+
|
|
|
+// ------------------------------------------------------------------------
|
|
|
+// 股票图表弹窗:分时图 / 日K 图(canvas 手绘,随主题换色)
|
|
|
+// ------------------------------------------------------------------------
|
|
|
+const chartModal = document.getElementById('chart-modal'); // 弹窗容器
|
|
|
+const chartCanvas = document.getElementById('chart-canvas'); // 绘图画布
|
|
|
+const chartTitle = document.getElementById('chart-title'); // 标题:名称 + 代码
|
|
|
+const chartSub = document.getElementById('chart-sub'); // 副标题:现价与涨跌幅
|
|
|
+const chartLegend = document.getElementById('chart-legend'); // 图例 / 状态行
|
|
|
+
|
|
|
+// 弹窗当前状态:code 股票代码,kind 图表类型(minute/kline),data 已加载的数据
|
|
|
+const chartState = {code: '', kind: 'minute', data: null};
|
|
|
+
|
|
|
+function cssVar(name) {
|
|
|
+ return getComputedStyle(document.documentElement).getPropertyValue(name).trim(); // 读取当前主题的 CSS 变量
|
|
|
+}
|
|
|
+
|
|
|
+// 打开弹窗并默认加载分时图
|
|
|
+function openChart(quote) {
|
|
|
+ chartState.code = quote.code;
|
|
|
+ chartTitle.textContent = `${quote.name} ${quote.code.toUpperCase()}`;
|
|
|
+ const sign = quote.change >= 0 ? '+' : '';
|
|
|
+ chartSub.textContent = `现价 ${quote.price.toFixed(2)} ${sign}${quote.change.toFixed(2)} (${sign}${quote.changePercent.toFixed(2)}%)`;
|
|
|
+ chartModal.hidden = false;
|
|
|
+ setChartKind('minute');
|
|
|
+}
|
|
|
+
|
|
|
+function closeChart() {
|
|
|
+ chartModal.hidden = true;
|
|
|
+ chartState.data = null;
|
|
|
+}
|
|
|
+
|
|
|
+// 切换分时 / 日K 并加载对应数据
|
|
|
+function setChartKind(kind) {
|
|
|
+ chartState.kind = kind;
|
|
|
+ document.querySelectorAll('#chart-kind .seg-btn').forEach(b => {
|
|
|
+ b.classList.toggle('active', b.dataset.chartKind === kind);
|
|
|
+ });
|
|
|
+ loadChartData();
|
|
|
+}
|
|
|
+
|
|
|
+async function loadChartData() {
|
|
|
+ chartLegend.textContent = '加载中…';
|
|
|
+ try {
|
|
|
+ if (chartState.kind === 'minute') {
|
|
|
+ chartState.data = await SettingsService.GetMinuteChart(chartState.code); // 分时数据
|
|
|
+ } else {
|
|
|
+ chartState.data = await SettingsService.GetKLine(chartState.code, 120); // 最近 120 根日K
|
|
|
+ }
|
|
|
+ drawChart();
|
|
|
+ } catch (err) {
|
|
|
+ chartState.data = null;
|
|
|
+ chartLegend.textContent = '加载失败:' + (err?.message || err);
|
|
|
+ clearCanvas();
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// 主题切换或窗口尺寸变化时重绘(仅在弹窗打开且已有数据时)
|
|
|
+function redrawChart() {
|
|
|
+ if (chartModal && !chartModal.hidden && chartState.data) drawChart();
|
|
|
+}
|
|
|
+
|
|
|
+function drawChart() {
|
|
|
+ if (chartState.kind === 'minute') {
|
|
|
+ drawMinute(chartState.data);
|
|
|
+ } else {
|
|
|
+ drawKLine(chartState.data);
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// 按设备像素比初始化画布,返回 2d 上下文与 CSS 尺寸
|
|
|
+function prepareCanvas() {
|
|
|
+ const dpr = window.devicePixelRatio || 1;
|
|
|
+ const rect = chartCanvas.getBoundingClientRect();
|
|
|
+ chartCanvas.width = Math.max(1, Math.round(rect.width * dpr));
|
|
|
+ chartCanvas.height = Math.max(1, Math.round(rect.height * dpr));
|
|
|
+ const ctx = chartCanvas.getContext('2d');
|
|
|
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
|
+ return {ctx, w: rect.width, h: rect.height};
|
|
|
+}
|
|
|
+
|
|
|
+function clearCanvas() {
|
|
|
+ const {ctx, w, h} = prepareCanvas();
|
|
|
+ ctx.clearRect(0, 0, w, h);
|
|
|
+}
|
|
|
+
|
|
|
+// 绘制分时图:价格线 + 均价线 + 昨收虚线
|
|
|
+function drawMinute(chart) {
|
|
|
+ const {ctx, w, h} = prepareCanvas();
|
|
|
+ ctx.clearRect(0, 0, w, h);
|
|
|
+ const points = chart.points || [];
|
|
|
+ if (points.length === 0) {
|
|
|
+ chartLegend.textContent = '暂无分时数据';
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ const padL = 48, padR = 12, padT = 14, padB = 22; // 绘图区内边距
|
|
|
+ const plotW = w - padL - padR;
|
|
|
+ const plotH = h - padT - padB;
|
|
|
+
|
|
|
+ // 价格范围需覆盖价格、均价与昨收,保证基准线可见
|
|
|
+ let min = chart.prevClose, max = chart.prevClose;
|
|
|
+ points.forEach(p => {
|
|
|
+ min = Math.min(min, p.price, p.avg);
|
|
|
+ max = Math.max(max, p.price, p.avg);
|
|
|
+ });
|
|
|
+ if (max - min < 0.01) { max += 0.01; min -= 0.01; } // 全天一字时避免除零
|
|
|
+ const span = max - min;
|
|
|
+
|
|
|
+ const xOf = i => padL + (plotW * i) / (points.length - 1);
|
|
|
+ const yOf = v => padT + plotH * (1 - (v - min) / span);
|
|
|
+
|
|
|
+ const muted = cssVar('--muted');
|
|
|
+ const upColorVal = upColor(); // 用设置中的涨跌色(改名避免遮蔽外层同名函数)
|
|
|
+ const downColorVal = downColor();
|
|
|
+ const lastPrice = points[points.length - 1].price;
|
|
|
+ const priceColor = lastPrice >= chart.prevClose ? upColorVal : downColorVal; // 相对昨收定红绿
|
|
|
+
|
|
|
+ // 水平网格线 + 左侧价格刻度
|
|
|
+ ctx.font = '10px sans-serif';
|
|
|
+ ctx.fillStyle = muted;
|
|
|
+ ctx.strokeStyle = 'rgba(127,127,127,0.18)';
|
|
|
+ ctx.lineWidth = 1;
|
|
|
+ for (let i = 0; i <= 4; i++) {
|
|
|
+ const v = min + (span * i) / 4;
|
|
|
+ const y = yOf(v);
|
|
|
+ ctx.beginPath();
|
|
|
+ ctx.moveTo(padL, y);
|
|
|
+ ctx.lineTo(w - padR, y);
|
|
|
+ ctx.stroke();
|
|
|
+ ctx.textAlign = 'right';
|
|
|
+ ctx.fillText(v.toFixed(2), padL - 6, y + 3);
|
|
|
+ }
|
|
|
+
|
|
|
+ // 昨收基准虚线
|
|
|
+ const yPrev = yOf(chart.prevClose);
|
|
|
+ ctx.save();
|
|
|
+ ctx.setLineDash([5, 4]);
|
|
|
+ ctx.strokeStyle = muted;
|
|
|
+ ctx.beginPath();
|
|
|
+ ctx.moveTo(padL, yPrev);
|
|
|
+ ctx.lineTo(w - padR, yPrev);
|
|
|
+ ctx.stroke();
|
|
|
+ ctx.restore();
|
|
|
+
|
|
|
+ // 底部时间刻度:开盘 / 中午 / 收盘
|
|
|
+ ctx.fillStyle = muted;
|
|
|
+ const timeIdx = [0, Math.floor(points.length / 2), points.length - 1];
|
|
|
+ timeIdx.forEach((idx, i) => {
|
|
|
+ ctx.textAlign = i === 0 ? 'left' : (i === 2 ? 'right' : 'center');
|
|
|
+ const x = i === 0 ? padL : (i === 2 ? w - padR : xOf(idx));
|
|
|
+ ctx.fillText(points[idx].time, x, h - 6);
|
|
|
+ });
|
|
|
+
|
|
|
+ // 均价线
|
|
|
+ ctx.strokeStyle = muted;
|
|
|
+ ctx.lineWidth = 1;
|
|
|
+ ctx.beginPath();
|
|
|
+ points.forEach((p, i) => {
|
|
|
+ const x = xOf(i), y = yOf(p.avg);
|
|
|
+ i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
|
|
|
+ });
|
|
|
+ ctx.stroke();
|
|
|
+
|
|
|
+ // 价格线
|
|
|
+ ctx.strokeStyle = priceColor;
|
|
|
+ ctx.lineWidth = 1.6;
|
|
|
+ ctx.beginPath();
|
|
|
+ points.forEach((p, i) => {
|
|
|
+ const x = xOf(i), y = yOf(p.price);
|
|
|
+ i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
|
|
|
+ });
|
|
|
+ ctx.stroke();
|
|
|
+
|
|
|
+ chartLegend.innerHTML =
|
|
|
+ `<span class="lg" style="color:${priceColor}"><i></i>价格 ${lastPrice.toFixed(2)}</span>` +
|
|
|
+ `<span class="lg" style="color:${muted}"><i></i>均价</span>` +
|
|
|
+ `<span>昨收 ${chart.prevClose.toFixed(2)}</span>`;
|
|
|
+}
|
|
|
+
|
|
|
+// 绘制日 K 图:蜡烛 + 底部成交量
|
|
|
+function drawKLine(bars) {
|
|
|
+ const {ctx, w, h} = prepareCanvas();
|
|
|
+ ctx.clearRect(0, 0, w, h);
|
|
|
+ if (!bars || bars.length === 0) {
|
|
|
+ chartLegend.textContent = '暂无日K数据';
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ const padL = 48, padR = 12, padT = 14, padB = 22;
|
|
|
+ const volH = (h - padT - padB) * 0.18; // 成交量区域高度
|
|
|
+ const plotH = h - padT - padB - volH - 8; // K 线区域高度(底部留 8px 间隔)
|
|
|
+ const plotW = w - padL - padR;
|
|
|
+
|
|
|
+ let min = Infinity, max = -Infinity, maxVol = 0;
|
|
|
+ bars.forEach(b => {
|
|
|
+ min = Math.min(min, b.low);
|
|
|
+ max = Math.max(max, b.high);
|
|
|
+ maxVol = Math.max(maxVol, b.volume);
|
|
|
+ });
|
|
|
+ if (max - min < 0.01) { max += 0.01; min -= 0.01; }
|
|
|
+
|
|
|
+ const step = plotW / bars.length; // 每根 K 线占位宽
|
|
|
+ const bodyW = Math.max(1, step * 0.6); // 蜡烛实体宽
|
|
|
+ const xOf = i => padL + step * (i + 0.5);
|
|
|
+ const yOf = v => padT + plotH * (1 - (v - min) / (max - min));
|
|
|
+
|
|
|
+ const muted = cssVar('--muted');
|
|
|
+ const upColorVal = upColor(); // 用设置中的涨跌色(改名避免遮蔽外层同名函数)
|
|
|
+ const downColorVal = downColor();
|
|
|
+
|
|
|
+ // 价格网格与左侧刻度
|
|
|
+ ctx.font = '10px sans-serif';
|
|
|
+ ctx.fillStyle = muted;
|
|
|
+ ctx.strokeStyle = 'rgba(127,127,127,0.18)';
|
|
|
+ ctx.lineWidth = 1;
|
|
|
+ for (let i = 0; i <= 4; i++) {
|
|
|
+ const v = min + ((max - min) * i) / 4;
|
|
|
+ const y = yOf(v);
|
|
|
+ ctx.beginPath();
|
|
|
+ ctx.moveTo(padL, y);
|
|
|
+ ctx.lineTo(w - padR, y);
|
|
|
+ ctx.stroke();
|
|
|
+ ctx.textAlign = 'right';
|
|
|
+ ctx.fillText(v.toFixed(2), padL - 6, y + 3);
|
|
|
+ }
|
|
|
+
|
|
|
+ // 日期刻度:首 / 中 / 尾(仅显示月-日)
|
|
|
+ const dateIdx = [0, Math.floor(bars.length / 2), bars.length - 1];
|
|
|
+ dateIdx.forEach((idx, i) => {
|
|
|
+ ctx.textAlign = i === 0 ? 'left' : (i === 2 ? 'right' : 'center');
|
|
|
+ const x = i === 0 ? padL : (i === 2 ? w - padR : xOf(idx));
|
|
|
+ ctx.fillText(bars[idx].date.slice(5), x, h - 6);
|
|
|
+ });
|
|
|
+
|
|
|
+ // 蜡烛与成交量柱
|
|
|
+ bars.forEach((b, i) => {
|
|
|
+ const rising = b.close >= b.open; // 红涨绿跌
|
|
|
+ const color = rising ? upColorVal : downColorVal;
|
|
|
+ const x = xOf(i);
|
|
|
+
|
|
|
+ ctx.strokeStyle = color;
|
|
|
+ ctx.beginPath(); // 上下影线
|
|
|
+ ctx.moveTo(x, yOf(b.high));
|
|
|
+ ctx.lineTo(x, yOf(b.low));
|
|
|
+ ctx.stroke();
|
|
|
+
|
|
|
+ ctx.fillStyle = color;
|
|
|
+ const yOpen = yOf(b.open), yClose = yOf(b.close);
|
|
|
+ const top = Math.min(yOpen, yClose);
|
|
|
+ ctx.fillRect(x - bodyW / 2, top, bodyW, Math.max(1, Math.abs(yClose - yOpen))); // 实体
|
|
|
+
|
|
|
+ const barH = maxVol > 0 ? (b.volume / maxVol) * volH : 0;
|
|
|
+ ctx.globalAlpha = 0.5; // 成交量柱半透明,避免喧宾夺主
|
|
|
+ ctx.fillRect(x - bodyW / 2, h - padB - barH, bodyW, barH);
|
|
|
+ ctx.globalAlpha = 1;
|
|
|
+ });
|
|
|
+
|
|
|
+ const lastBar = bars[bars.length - 1];
|
|
|
+ chartLegend.innerHTML =
|
|
|
+ `<span class="lg" style="color:${upColorVal}"><i></i>上涨</span>` +
|
|
|
+ `<span class="lg" style="color:${downColorVal}"><i></i>下跌</span>` +
|
|
|
+ `<span>${bars.length} 根 | 最新 ${lastBar.date} 收 ${lastBar.close.toFixed(2)}</span>`;
|
|
|
+}
|
|
|
+
|
|
|
+// 弹窗交互:分段切换、关闭按钮、遮罩点击、Esc
|
|
|
+document.querySelectorAll('#chart-kind .seg-btn').forEach(btn => {
|
|
|
+ btn.addEventListener('click', () => setChartKind(btn.dataset.chartKind));
|
|
|
+});
|
|
|
+document.getElementById('chart-close').addEventListener('click', closeChart);
|
|
|
+document.getElementById('chart-mask').addEventListener('click', closeChart);
|
|
|
+document.addEventListener('keydown', (event) => {
|
|
|
+ if (event.key === 'Escape' && !chartModal.hidden) closeChart();
|
|
|
+});
|
|
|
+
|
|
|
+// 窗口尺寸变化时重绘图表(防抖 150ms)
|
|
|
+let resizeTimer = null;
|
|
|
+window.addEventListener('resize', () => {
|
|
|
+ clearTimeout(resizeTimer);
|
|
|
+ resizeTimer = setTimeout(redrawChart, 150);
|
|
|
+});
|