|
|
@@ -10,7 +10,7 @@ const FALLBACK_SETTINGS = {
|
|
|
autostart: true,
|
|
|
alwaysOnTop: false,
|
|
|
hotkey: 'CmdOrCtrl+Shift+G',
|
|
|
- tabOrder: ['links', 'news', 'github', 'stock', 'todo', 'tools', 'host', 'settings'],
|
|
|
+ tabOrder: ['links', 'news', 'github', 'stock', 'todo', 'record', 'tools', 'host', 'settings'],
|
|
|
stockCodes: ['sh000001', 'sh600519', 'sz000001'],
|
|
|
links: [{name: '煎蛋', url: 'https://jandan.net/'}],
|
|
|
newsKeywords: '',
|
|
|
@@ -19,6 +19,8 @@ const FALLBACK_SETTINGS = {
|
|
|
customTabs: [], // 用户自定义 Tab 页面(名字 + 地址;http/https 为网页,其余为本地文件)
|
|
|
apiAddr: '127.0.0.1:17890',
|
|
|
todoPath: '', // 待办路径:无默认值,空值提示用户到设置页配置
|
|
|
+ recordPath: '', // 记录路径:无默认值,空值提示用户到设置页配置(保留兼容,记录 Tab 实际使用数据库)
|
|
|
+ recordDBPath: '', // 记录数据库路径:无默认值,空值提示用户到设置页配置
|
|
|
backupDir: '', // 备份目录:无默认值,空值提示用户到设置页配置
|
|
|
stockUpColor: '#ff5a5a', // 股票上涨颜色(红涨)
|
|
|
stockDownColor: '#4d8dff', // 股票下跌颜色(蓝跌)
|
|
|
@@ -57,7 +59,7 @@ const poemReadBtn = document.getElementById('poem-read'); // 朗读按
|
|
|
// Tab 名称映射:id -> 显示名(用于顺序编辑与 footer 注释)
|
|
|
const TAB_NAMES = {
|
|
|
links: '网址', news: '新闻', github: 'Github', stock: '股市',
|
|
|
- todo: '待办', tools: '工具', host: '服务', settings: '设置',
|
|
|
+ todo: '待办', record: '记录', tools: '工具', host: '服务', settings: '设置',
|
|
|
};
|
|
|
|
|
|
// Tab 注释映射:id -> 在 footer 显示的说明文字(切换时显示,不占页面空间)
|
|
|
@@ -67,6 +69,7 @@ const TAB_DESCS = {
|
|
|
github: '监听仓库提交与 release,本地版本低于最新版提示更新。',
|
|
|
stock: '自选股实时行情,点击行查看分时 / 日K。',
|
|
|
todo: '每行一个待办任务,可勾选完成、编辑、删除,保存后写入本地文件。',
|
|
|
+ record: '每行一条「姓名|名称|值」,保存到 SQLite 数据库;姓名/名称输入自动补全历史值,选择姓名与名称后按值绘柱状图。',
|
|
|
tools: '开发/运维辅助工具:Web 服务(选目录开启浏览)、翻译(中英互译)、二维码生成。',
|
|
|
host: '内置 API 服务状态、电源命令与 Gin 日志。',
|
|
|
settings: '修改后点击保存立即生效,焦点离开时自动保存。',
|
|
|
@@ -357,8 +360,20 @@ function switchTab(tabId) {
|
|
|
if (!loadedOnce.has(tabId)) {
|
|
|
loadedOnce.add(tabId);
|
|
|
if (tabId === 'todo') loadTodo();
|
|
|
+ if (tabId === 'record') loadRecords();
|
|
|
if (tabId === 'host') loadHostPanel();
|
|
|
if (tabId === 'tools') loadToolsPanel();
|
|
|
+ } else if (tabId === 'record') {
|
|
|
+ // 记录依赖「记录数据库路径」设置:路径可能后配置或修改,切回时必须刷新状态。
|
|
|
+ // 有未保存修改时保留列表,仅更新路径说明,避免覆盖用户正在编辑的内容。
|
|
|
+ if (recordDirty) {
|
|
|
+ const path = currentSettings.recordDBPath || '';
|
|
|
+ recordPathDesc.textContent = path
|
|
|
+ ? `记录(姓名|名称|值),当前数据库:${path}`
|
|
|
+ : '记录:未设置「记录数据库路径」,请到设置页配置后再使用';
|
|
|
+ } else {
|
|
|
+ loadRecords();
|
|
|
+ }
|
|
|
}
|
|
|
// 文件类自定义 Tab:首次打开时自动读取并渲染 Markdown
|
|
|
const custom = customTabs.find(t => t.id === tabId);
|
|
|
@@ -410,6 +425,7 @@ const biliCookieInput = document.getElementById('bili-cookie'); // B站登
|
|
|
const githubReposText = document.getElementById('github-repos'); // Github 项目(每行 地址|说明)
|
|
|
const apiAddrInput = document.getElementById('api-addr'); // API 监听地址
|
|
|
const todoPathInput = document.getElementById('todo-path'); // 待办(TODO)文件路径
|
|
|
+const recordDBPathInput = document.getElementById('record-db-path'); // 记录(Record)数据库路径
|
|
|
const backupDirInput = document.getElementById('backup-dir'); // 文本备份目录(文件类自定义 Tab「备份」按钮使用)
|
|
|
const customTabsText = document.getElementById('custom-tabs'); // 自定义 Tab(每行 名称|地址|备注)
|
|
|
const saveButton = document.getElementById('save-settings'); // 保存按钮
|
|
|
@@ -524,6 +540,7 @@ async function syncForm(settings) {
|
|
|
customTabsText.value = (settings.customTabs || []).map(t => `${t.name}|${t.url}${t.desc ? '|' + t.desc : ''}`).join('\n');
|
|
|
apiAddrInput.value = settings.apiAddr;
|
|
|
todoPathInput.value = settings.todoPath;
|
|
|
+ recordDBPathInput.value = settings.recordDBPath;
|
|
|
backupDirInput.value = settings.backupDir;
|
|
|
stockUpColorInput.value = settings.stockUpColor || FALLBACK_SETTINGS.stockUpColor;
|
|
|
stockDownColorInput.value = settings.stockDownColor || FALLBACK_SETTINGS.stockDownColor;
|
|
|
@@ -706,6 +723,7 @@ function collectForm() {
|
|
|
customTabs: parsePipeLines3(fallbackArr(customTabsText, 'customTabs'), 'name', 'url', 'desc').filter(t => t.name && t.url),
|
|
|
apiAddr: apiAddrInput.value.trim(),
|
|
|
todoPath: todoPathInput.value.trim(),
|
|
|
+ recordDBPath: recordDBPathInput.value.trim(),
|
|
|
backupDir: backupDirInput.value.trim(),
|
|
|
stockUpColor: fallbackColor(stockUpColorInput, currentSettings.stockUpColor, FALLBACK_SETTINGS.stockUpColor),
|
|
|
stockDownColor: fallbackColor(stockDownColorInput, currentSettings.stockDownColor, FALLBACK_SETTINGS.stockDownColor),
|
|
|
@@ -1608,6 +1626,383 @@ if (todoPanel) {
|
|
|
}).observe(todoList, { childList: true, subtree: false });
|
|
|
}
|
|
|
|
|
|
+// ------------------------------------------------------------------------
|
|
|
+// 记录:每行一条「姓名|名称|值」,可录入/保存;选择姓名+名称后按值绘柱状图
|
|
|
+// ------------------------------------------------------------------------
|
|
|
+const recordList = document.getElementById('record-list'); // 记录行容器
|
|
|
+const recordSaveBtn = document.getElementById('record-save'); // 保存按钮
|
|
|
+const recordAddBtn = document.getElementById('record-add'); // 添加记录按钮
|
|
|
+const recordStatus = document.getElementById('record-status'); // 保存状态提示
|
|
|
+const recordPathDesc = document.getElementById('record-path-desc'); // 显示当前记录文件路径
|
|
|
+const recordNameFilter = document.getElementById('record-name-filter'); // 姓名筛选下拉
|
|
|
+const recordItemFilter = document.getElementById('record-item-filter'); // 名称筛选下拉
|
|
|
+const recordCount = document.getElementById('record-count'); // 匹配记录数提示
|
|
|
+const recordChart = document.getElementById('record-chart'); // 柱状图画布
|
|
|
+const recordChartEmpty = document.getElementById('record-chart-empty'); // 柱状图空提示
|
|
|
+let recordDirty = false; // 是否有未保存修改
|
|
|
+let recordData = []; // 当前记录数组 [{name, item, value}]
|
|
|
+
|
|
|
+// 自动补全历史值(来自数据库,去重):姓名 / 名称
|
|
|
+// 用自定义下拉替代 <datalist>:WebView2 的 datalist 在输入框为空、仅聚焦点击时不会弹出候选,
|
|
|
+// 用户无法「选择之前输入过的值」。自定义下拉在聚焦时即展示全部历史值,点击即可填充。
|
|
|
+let recordNameOptions = [];
|
|
|
+let recordItemOptions = [];
|
|
|
+
|
|
|
+// 自动补全候选 = 数据库历史值 ∪ 当前页所有行已输入的值(去重,供聚焦时实时读取)。
|
|
|
+// 这样本次「添加记录」中新输入(含未保存)的姓名/名称也能被其他行的输入框选中,减少重复输入。
|
|
|
+function mergeRecordCandidates(dbOptions, selector) {
|
|
|
+ const set = new Set(dbOptions || []);
|
|
|
+ recordList.querySelectorAll(selector).forEach(inp => {
|
|
|
+ const v = inp.value.trim();
|
|
|
+ if (v) set.add(v);
|
|
|
+ });
|
|
|
+ return [...set];
|
|
|
+}
|
|
|
+
|
|
|
+// 创建自动补全下拉并绑定到输入框:聚焦时展示历史值(可按输入内容过滤),点击填充
|
|
|
+// optionsRef 为 getter 函数(() => string[]):每次聚焦实时读取全局候选,避免数组快照失效
|
|
|
+function attachAutocomplete(input, optionsRef, onChange) {
|
|
|
+ const dl = document.createElement('div');
|
|
|
+ dl.className = 'record-ac';
|
|
|
+ dl.style.display = 'none';
|
|
|
+ document.body.appendChild(dl); // 挂到 body,避免被 record-list 的 overflow 裁剪
|
|
|
+
|
|
|
+ const renderList = () => {
|
|
|
+ const kw = input.value.trim();
|
|
|
+ const opts = optionsRef().filter(o => !kw || o.toLowerCase().includes(kw.toLowerCase()));
|
|
|
+ if (!opts.length) { dl.style.display = 'none'; return; }
|
|
|
+ dl.innerHTML = '';
|
|
|
+ opts.forEach(o => {
|
|
|
+ const item = document.createElement('div');
|
|
|
+ item.className = 'record-ac-item';
|
|
|
+ item.textContent = o;
|
|
|
+ item.addEventListener('mousedown', (e) => {
|
|
|
+ e.preventDefault(); // 防止输入框失焦导致列表先关闭
|
|
|
+ input.value = o;
|
|
|
+ dl.style.display = 'none';
|
|
|
+ if (onChange) onChange();
|
|
|
+ });
|
|
|
+ dl.appendChild(item);
|
|
|
+ });
|
|
|
+ dl.style.display = 'block';
|
|
|
+ // 定位到输入框下方
|
|
|
+ const r = input.getBoundingClientRect();
|
|
|
+ dl.style.left = r.left + 'px';
|
|
|
+ dl.style.top = (r.bottom + 2) + 'px';
|
|
|
+ dl.style.width = r.width + 'px';
|
|
|
+ };
|
|
|
+
|
|
|
+ input.addEventListener('focus', renderList);
|
|
|
+ input.addEventListener('input', renderList);
|
|
|
+ input.addEventListener('blur', () => {
|
|
|
+ // 延迟隐藏,给 mousedown 点击选项的时间
|
|
|
+ setTimeout(() => { dl.style.display = 'none'; }, 150);
|
|
|
+ });
|
|
|
+ // 窗口滚动/尺寸变化时关闭,避免定位错位
|
|
|
+ window.addEventListener('resize', () => { dl.style.display = 'none'; }, { passive: true });
|
|
|
+ input._ac = dl; // 供销毁时清理
|
|
|
+}
|
|
|
+
|
|
|
+// 渲染一行记录:姓名输入(自动补全)+ 名称输入(自动补全)+ 值输入 + 时间 + 删除按钮
|
|
|
+function renderRecordRow(name, item, value, createdAt) {
|
|
|
+ const row = document.createElement('div');
|
|
|
+ row.className = 'record-row';
|
|
|
+
|
|
|
+ const nameInput = document.createElement('input');
|
|
|
+ nameInput.type = 'text';
|
|
|
+ nameInput.className = 'record-input record-name';
|
|
|
+ nameInput.value = name;
|
|
|
+ nameInput.placeholder = '姓名';
|
|
|
+ nameInput.spellcheck = false;
|
|
|
+ nameInput.setAttribute('list', 'record-names-dl'); // 保留 list 属性作为备用(自定义下拉为主)
|
|
|
+ nameInput.addEventListener('input', () => { recordDirty = true; refreshRecordFilterOptions(); });
|
|
|
+ nameInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') addRecordRow(); });
|
|
|
+ attachAutocomplete(nameInput, () => mergeRecordCandidates(recordNameOptions, '.record-name'), () => { recordDirty = true; refreshRecordFilterOptions(); });
|
|
|
+
|
|
|
+ const itemInput = document.createElement('input');
|
|
|
+ itemInput.type = 'text';
|
|
|
+ itemInput.className = 'record-input record-item';
|
|
|
+ itemInput.value = item;
|
|
|
+ itemInput.placeholder = '名称';
|
|
|
+ itemInput.spellcheck = false;
|
|
|
+ itemInput.setAttribute('list', 'record-items-dl'); // 保留 list 属性作为备用(自定义下拉为主)
|
|
|
+ itemInput.addEventListener('input', () => { recordDirty = true; refreshRecordFilterOptions(); });
|
|
|
+ itemInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') addRecordRow(); });
|
|
|
+ attachAutocomplete(itemInput, () => mergeRecordCandidates(recordItemOptions, '.record-item'), () => { recordDirty = true; refreshRecordFilterOptions(); });
|
|
|
+
|
|
|
+ const valueInput = document.createElement('input');
|
|
|
+ valueInput.type = 'number';
|
|
|
+ valueInput.className = 'record-input record-value';
|
|
|
+ valueInput.value = value;
|
|
|
+ valueInput.placeholder = '值';
|
|
|
+ valueInput.addEventListener('input', () => { recordDirty = true; redrawRecordChart(); });
|
|
|
+
|
|
|
+ // 录入时间:新增时自动填当前时间(可编辑,便于回溯)
|
|
|
+ const timeInput = document.createElement('input');
|
|
|
+ timeInput.type = 'text';
|
|
|
+ timeInput.className = 'record-input record-time';
|
|
|
+ timeInput.value = createdAt || formatNow();
|
|
|
+ timeInput.placeholder = '时间';
|
|
|
+ timeInput.title = '录入时间(自动记录当前时间,可修改)';
|
|
|
+ timeInput.addEventListener('input', () => { recordDirty = true; });
|
|
|
+
|
|
|
+ const del = document.createElement('button');
|
|
|
+ del.className = 'btn-ghost record-del';
|
|
|
+ del.textContent = '删除';
|
|
|
+ del.title = '删除此行';
|
|
|
+ del.addEventListener('click', () => {
|
|
|
+ row.remove();
|
|
|
+ if (nameInput._ac) nameInput._ac.remove();
|
|
|
+ if (itemInput._ac) itemInput._ac.remove();
|
|
|
+ recordDirty = true;
|
|
|
+ refreshRecordFilterOptions();
|
|
|
+ redrawRecordChart();
|
|
|
+ });
|
|
|
+
|
|
|
+ row.appendChild(nameInput);
|
|
|
+ row.appendChild(itemInput);
|
|
|
+ row.appendChild(valueInput);
|
|
|
+ row.appendChild(timeInput);
|
|
|
+ row.appendChild(del);
|
|
|
+ return row;
|
|
|
+}
|
|
|
+
|
|
|
+// 格式化当前时间为 YYYY-MM-DD HH:MM:SS(与数据库 datetime('now','localtime') 格式一致)
|
|
|
+function formatNow() {
|
|
|
+ const d = new Date();
|
|
|
+ const p = (n) => String(n).padStart(2, '0');
|
|
|
+ return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
|
|
|
+}
|
|
|
+
|
|
|
+// 添加一行空的记录(自动记录当前时间,聚焦姓名输入框)
|
|
|
+function addRecordRow() {
|
|
|
+ const row = renderRecordRow('', '', '', '');
|
|
|
+ recordList.appendChild(row);
|
|
|
+ row.querySelector('.record-name').focus();
|
|
|
+ recordDirty = true;
|
|
|
+}
|
|
|
+
|
|
|
+// 收集所有记录行的数据(过滤全空行)
|
|
|
+function collectRecords() {
|
|
|
+ const rows = [];
|
|
|
+ recordList.querySelectorAll('.record-row').forEach(row => {
|
|
|
+ const name = row.querySelector('.record-name').value.trim();
|
|
|
+ const item = row.querySelector('.record-item').value.trim();
|
|
|
+ const value = row.querySelector('.record-value').value.trim();
|
|
|
+ if (!name && !item && !value) return; // 全空行跳过
|
|
|
+ rows.push({ name, item, value: parseFloat(value) || 0, createdAt: row.querySelector('.record-time').value.trim() });
|
|
|
+ });
|
|
|
+ return rows;
|
|
|
+}
|
|
|
+
|
|
|
+// 刷新姓名/名称下拉的候选(来自当前所有记录行,去重)
|
|
|
+function refreshRecordFilterOptions() {
|
|
|
+ const names = new Set();
|
|
|
+ const items = new Set();
|
|
|
+ recordList.querySelectorAll('.record-row').forEach(row => {
|
|
|
+ const n = row.querySelector('.record-name').value.trim();
|
|
|
+ const it = row.querySelector('.record-item').value.trim();
|
|
|
+ if (n) names.add(n);
|
|
|
+ if (it) items.add(it);
|
|
|
+ });
|
|
|
+ // 保留当前选中值
|
|
|
+ const curName = recordNameFilter.value;
|
|
|
+ const curItem = recordItemFilter.value;
|
|
|
+ recordNameFilter.innerHTML = '<option value="">全部</option>';
|
|
|
+ [...names].sort().forEach(n => {
|
|
|
+ const o = document.createElement('option');
|
|
|
+ o.value = n; o.textContent = n;
|
|
|
+ recordNameFilter.appendChild(o);
|
|
|
+ });
|
|
|
+ if (curName && names.has(curName)) recordNameFilter.value = curName;
|
|
|
+ recordItemFilter.innerHTML = '<option value="">全部</option>';
|
|
|
+ [...items].sort().forEach(it => {
|
|
|
+ const o = document.createElement('option');
|
|
|
+ o.value = it; o.textContent = it;
|
|
|
+ recordItemFilter.appendChild(o);
|
|
|
+ });
|
|
|
+ if (curItem && items.has(curItem)) recordItemFilter.value = curItem;
|
|
|
+ redrawRecordChart();
|
|
|
+}
|
|
|
+
|
|
|
+// 刷新姓名/名称自动补全候选:从数据库拉取历史值(去重),供自定义下拉使用
|
|
|
+async function loadRecordAutocomplete() {
|
|
|
+ try {
|
|
|
+ const [names, items] = await Promise.all([
|
|
|
+ SettingsService.GetRecordNames(),
|
|
|
+ SettingsService.GetRecordItems(),
|
|
|
+ ]);
|
|
|
+ recordNameOptions = names || [];
|
|
|
+ recordItemOptions = items || [];
|
|
|
+ // 同步到 datalist 作为备用(部分场景下浏览器原生补全仍可用)
|
|
|
+ const namesDl = document.getElementById('record-names-dl');
|
|
|
+ const itemsDl = document.getElementById('record-items-dl');
|
|
|
+ if (namesDl) namesDl.innerHTML = recordNameOptions.map(n => `<option value="${escapeHtml(n)}"></option>`).join('');
|
|
|
+ if (itemsDl) itemsDl.innerHTML = recordItemOptions.map(it => `<option value="${escapeHtml(it)}"></option>`).join('');
|
|
|
+ } catch (err) {
|
|
|
+ // 自动补全候选加载失败不阻断主流程(未配置数据库路径时后端返回错误)
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// 从后端读取记录并渲染
|
|
|
+// 清理所有已挂到 body 的自定义补全下拉(列表重建/清空前调用,避免残留隐藏节点累积)
|
|
|
+function clearRecordAutocomplete() {
|
|
|
+ document.querySelectorAll('.record-ac').forEach(el => el.remove());
|
|
|
+}
|
|
|
+
|
|
|
+async function loadRecords() {
|
|
|
+ const path = currentSettings.recordDBPath || '';
|
|
|
+ recordPathDesc.textContent = path
|
|
|
+ ? `记录(姓名|名称|值),当前数据库:${path}`
|
|
|
+ : '记录:未设置「记录数据库路径」,请到设置页配置后再使用';
|
|
|
+ clearRecordAutocomplete();
|
|
|
+ recordList.innerHTML = '';
|
|
|
+ if (!path) {
|
|
|
+ const hint = document.createElement('div');
|
|
|
+ hint.className = 'empty-state';
|
|
|
+ hint.innerHTML = `<div class="empty-mark">⚙</div><div class="empty-title">请先设置记录数据库路径</div><div class="empty-text">前往「设置」页填写「记录数据库路径」后保存即可使用</div>`;
|
|
|
+ const goBtn = document.createElement('button');
|
|
|
+ goBtn.className = 'btn-ghost';
|
|
|
+ goBtn.textContent = '前往设置';
|
|
|
+ goBtn.style.marginTop = '10px';
|
|
|
+ goBtn.addEventListener('click', () => switchTab('settings'));
|
|
|
+ hint.appendChild(goBtn);
|
|
|
+ recordList.appendChild(hint);
|
|
|
+ recordDirty = false;
|
|
|
+ recordStatus.textContent = '';
|
|
|
+ refreshRecordFilterOptions();
|
|
|
+ loadRecordAutocomplete();
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ try {
|
|
|
+ const items = await SettingsService.ReadRecords();
|
|
|
+ if (items.length) {
|
|
|
+ items.forEach(it => recordList.appendChild(renderRecordRow(it.name, it.item, String(it.value), it.createdAt)));
|
|
|
+ } else {
|
|
|
+ addRecordRow();
|
|
|
+ }
|
|
|
+ recordDirty = false;
|
|
|
+ recordStatus.textContent = '';
|
|
|
+ refreshRecordFilterOptions();
|
|
|
+ loadRecordAutocomplete();
|
|
|
+ } catch (err) {
|
|
|
+ recordStatus.textContent = '读取失败:' + (err?.message || err);
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// 保存所有记录:收集行数据,全量写入数据库(跳过空行)
|
|
|
+async function saveRecords() {
|
|
|
+ const items = [];
|
|
|
+ recordList.querySelectorAll('.record-row').forEach(row => {
|
|
|
+ const name = row.querySelector('.record-name').value.trim();
|
|
|
+ const item = row.querySelector('.record-item').value.trim();
|
|
|
+ const value = row.querySelector('.record-value').value.trim();
|
|
|
+ if (!name && !item && !value) return; // 空行不保存
|
|
|
+ items.push({ name, item, value: parseFloat(value) || 0, createdAt: row.querySelector('.record-time').value.trim() || formatNow() });
|
|
|
+ });
|
|
|
+ recordStatus.textContent = '保存中…';
|
|
|
+ try {
|
|
|
+ await SettingsService.WriteRecords(items);
|
|
|
+ recordStatus.textContent = '已保存 ' + new Date().toLocaleTimeString('zh-CN');
|
|
|
+ recordDirty = false;
|
|
|
+ refreshRecordFilterOptions();
|
|
|
+ loadRecordAutocomplete(); // 保存后刷新自动补全候选(新姓名/名称加入历史)
|
|
|
+ } catch (err) {
|
|
|
+ recordStatus.textContent = '保存失败:' + (err?.message || err);
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// 计算柱状图数据:必须同时选择姓名与名称,才按该组合过滤并绘图。
|
|
|
+// 不同姓名、不同名称绝不混在同一张图上——只有同时选定姓名和名称,
|
|
|
+// 才显示该组合下的全部记录(每行一根柱,展示其随时间的值变化)。
|
|
|
+function recordChartData() {
|
|
|
+ const selName = recordNameFilter.value;
|
|
|
+ const selItem = recordItemFilter.value;
|
|
|
+ if (!selName || !selItem) return []; // 未同时选定姓名与名称:不出图
|
|
|
+ return collectRecords()
|
|
|
+ .filter(r => r.name === selName && r.item === selItem)
|
|
|
+ .map(r => ({ label: `${r.name}/${r.item}`, value: r.value }));
|
|
|
+}
|
|
|
+
|
|
|
+// 绘制柱状图(canvas),无数据时显示空提示
|
|
|
+function redrawRecordChart() {
|
|
|
+ const data = recordChartData();
|
|
|
+ if (!recordChart) return;
|
|
|
+ if (!data.length) {
|
|
|
+ recordChartEmpty.style.display = 'flex'; // 空提示居中(CSS 默认为 flex)
|
|
|
+ recordChart.style.display = 'none';
|
|
|
+ recordCount.textContent = '0 条记录';
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ recordChartEmpty.style.display = 'none';
|
|
|
+ recordChart.style.display = 'block'; // canvas 用块级显示,否则 CSS 的 display:none 使其永不出现
|
|
|
+ recordCount.textContent = `${data.length} 条记录`;
|
|
|
+
|
|
|
+ const dpr = window.devicePixelRatio || 1;
|
|
|
+ const rect = recordChart.getBoundingClientRect();
|
|
|
+ const w = Math.max(rect.width, 300);
|
|
|
+ const h = Math.max(rect.height, 220);
|
|
|
+ recordChart.width = w * dpr;
|
|
|
+ recordChart.height = h * dpr;
|
|
|
+ const ctx = recordChart.getContext('2d');
|
|
|
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
|
+ ctx.clearRect(0, 0, w, h);
|
|
|
+
|
|
|
+ const padL = 44, padR = 12, padT = 14, padB = 30;
|
|
|
+ const plotW = w - padL - padR, plotH = h - padT - padB;
|
|
|
+ const maxVal = Math.max(...data.map(d => Math.abs(d.value)), 1);
|
|
|
+ const barW = Math.min(46, Math.max(14, plotW / data.length * 0.62));
|
|
|
+ const gap = plotW / data.length;
|
|
|
+
|
|
|
+ // 背景网格线 + Y 轴刻度
|
|
|
+ ctx.strokeStyle = 'rgba(127,127,127,0.18)';
|
|
|
+ ctx.fillStyle = 'rgba(127,127,127,0.7)';
|
|
|
+ ctx.lineWidth = 1;
|
|
|
+ ctx.font = '10px sans-serif';
|
|
|
+ ctx.textAlign = 'right';
|
|
|
+ for (let i = 0; i <= 4; i++) {
|
|
|
+ const val = maxVal * i / 4;
|
|
|
+ const y = padT + plotH - plotH * (i / 4);
|
|
|
+ ctx.beginPath();
|
|
|
+ ctx.moveTo(padL, y);
|
|
|
+ ctx.lineTo(w - padR, y);
|
|
|
+ ctx.stroke();
|
|
|
+ ctx.fillText(val.toFixed(0), padL - 5, y + 3);
|
|
|
+ }
|
|
|
+
|
|
|
+ // 柱
|
|
|
+ const barColor = 'rgba(255,90,90,0.85)';
|
|
|
+ ctx.textAlign = 'center';
|
|
|
+ data.forEach((d, i) => {
|
|
|
+ const cx = padL + gap * i + gap / 2;
|
|
|
+ const bh = Math.abs(d.value) / maxVal * plotH;
|
|
|
+ const y0 = padT + plotH - bh;
|
|
|
+ ctx.fillStyle = barColor;
|
|
|
+ ctx.fillRect(cx - barW / 2, y0, barW, bh);
|
|
|
+ // 值标注
|
|
|
+ ctx.fillStyle = 'rgba(255,255,255,0.9)';
|
|
|
+ ctx.fillText(d.value.toFixed(1), cx, y0 - 4);
|
|
|
+ // x 轴标签:姓名/名称(长标签截断,避免重叠)
|
|
|
+ const lbl = d.label || '·';
|
|
|
+ ctx.fillText(lbl.length > 10 ? lbl.slice(0, 10) + '…' : lbl, cx, h - 10);
|
|
|
+ });
|
|
|
+}
|
|
|
+
|
|
|
+recordSaveBtn.addEventListener('click', saveRecords);
|
|
|
+recordAddBtn.addEventListener('click', addRecordRow);
|
|
|
+recordNameFilter.addEventListener('change', redrawRecordChart);
|
|
|
+recordItemFilter.addEventListener('change', redrawRecordChart);
|
|
|
+
|
|
|
+// 记录页 DOM 变化时自动刷新下拉与图表(例如删除行)
|
|
|
+const recordPanel = document.getElementById('tab-record');
|
|
|
+if (recordPanel) {
|
|
|
+ new MutationObserver(() => {
|
|
|
+ if (recordDirty) { refreshRecordFilterOptions(); }
|
|
|
+ }).observe(recordList, { childList: true, subtree: false });
|
|
|
+}
|
|
|
+// 窗口尺寸变化时重绘柱状图
|
|
|
+window.addEventListener('resize', () => { redrawRecordChart(); });
|
|
|
+
|
|
|
// ------------------------------------------------------------------------
|
|
|
// 主机:API 服务状态、电源命令状态与 Gin 日志
|
|
|
// ------------------------------------------------------------------------
|