|
@@ -18,6 +18,7 @@ const FALLBACK_SETTINGS = {
|
|
|
githubRepos: [{url: 'https://github.com/wailsapp/wails', desc: 'Wails 框架'}],
|
|
githubRepos: [{url: 'https://github.com/wailsapp/wails', desc: 'Wails 框架'}],
|
|
|
customTabs: [], // 用户自定义 Tab 页面(名字 + 地址;http/https 为网页,其余为本地文件)
|
|
customTabs: [], // 用户自定义 Tab 页面(名字 + 地址;http/https 为网页,其余为本地文件)
|
|
|
apiAddr: '127.0.0.1:17890',
|
|
apiAddr: '127.0.0.1:17890',
|
|
|
|
|
+ apiAutoStart: true, // 应用启动时自动启动内置 API 服务(默认开启)
|
|
|
todoPath: '', // 待办路径:无默认值,空值提示用户到设置页配置
|
|
todoPath: '', // 待办路径:无默认值,空值提示用户到设置页配置
|
|
|
recordPath: '', // 记录路径:无默认值,空值提示用户到设置页配置(保留兼容,记录 Tab 实际使用数据库)
|
|
recordPath: '', // 记录路径:无默认值,空值提示用户到设置页配置(保留兼容,记录 Tab 实际使用数据库)
|
|
|
recordDBPath: '', // 记录数据库路径:无默认值,空值提示用户到设置页配置
|
|
recordDBPath: '', // 记录数据库路径:无默认值,空值提示用户到设置页配置
|
|
@@ -322,19 +323,21 @@ function applyTabOrder(order) {
|
|
|
if (!grouped[g]) grouped[g] = [];
|
|
if (!grouped[g]) grouped[g] = [];
|
|
|
grouped[g].push(row);
|
|
grouped[g].push(row);
|
|
|
});
|
|
});
|
|
|
- // 分组标题:系统在前,其后按 Tab 顺序
|
|
|
|
|
|
|
+ // 分组容器:系统在前,其后按 Tab 顺序;每组一个带边框的卡片,明显区分各页设置
|
|
|
const groupOrder = ['system', ...ordered];
|
|
const groupOrder = ['system', ...ordered];
|
|
|
- settingList.querySelectorAll('.setting-group-title').forEach(el => el.remove());
|
|
|
|
|
- settingList.querySelectorAll('.setting-row').forEach(row => row.remove());
|
|
|
|
|
|
|
+ settingList.querySelectorAll('.setting-group').forEach(el => el.remove());
|
|
|
const frag = document.createDocumentFragment();
|
|
const frag = document.createDocumentFragment();
|
|
|
groupOrder.forEach(g => {
|
|
groupOrder.forEach(g => {
|
|
|
const gRows = grouped[g];
|
|
const gRows = grouped[g];
|
|
|
- if (!gRows || !gRows.length) return; // 该组无设置项则不显示标题
|
|
|
|
|
|
|
+ if (!gRows || !gRows.length) return; // 该组无设置项则不显示
|
|
|
|
|
+ const group = document.createElement('section');
|
|
|
|
|
+ group.className = 'setting-group';
|
|
|
const title = document.createElement('div');
|
|
const title = document.createElement('div');
|
|
|
title.className = 'setting-group-title';
|
|
title.className = 'setting-group-title';
|
|
|
title.textContent = g === 'system' ? '系统' : getTabName(g); // 内置或自定义 Tab 名
|
|
title.textContent = g === 'system' ? '系统' : getTabName(g); // 内置或自定义 Tab 名
|
|
|
- frag.appendChild(title);
|
|
|
|
|
- gRows.forEach(row => frag.appendChild(row));
|
|
|
|
|
|
|
+ group.appendChild(title);
|
|
|
|
|
+ gRows.forEach(row => group.appendChild(row));
|
|
|
|
|
+ frag.appendChild(group);
|
|
|
});
|
|
});
|
|
|
settingList.appendChild(frag);
|
|
settingList.appendChild(frag);
|
|
|
}
|
|
}
|
|
@@ -425,6 +428,10 @@ const newsKeywordsInput = document.getElementById('news-keywords'); // 新闻关
|
|
|
const biliCookieInput = document.getElementById('bili-cookie'); // B站登录 cookie
|
|
const biliCookieInput = document.getElementById('bili-cookie'); // B站登录 cookie
|
|
|
const githubReposText = document.getElementById('github-repos'); // Github 项目(每行 地址|说明)
|
|
const githubReposText = document.getElementById('github-repos'); // Github 项目(每行 地址|说明)
|
|
|
const apiAddrInput = document.getElementById('api-addr'); // API 监听地址
|
|
const apiAddrInput = document.getElementById('api-addr'); // API 监听地址
|
|
|
|
|
+const apiAutoStartToggle = document.getElementById('api-auto-start'); // 服务自动启动开关
|
|
|
|
|
+const uploadPasswordInput = document.getElementById('upload-password'); // 上传密码
|
|
|
|
|
+const uploadDirInput = document.getElementById('upload-dir'); // 上传保存目录
|
|
|
|
|
+const uploadMaxSizeInput = document.getElementById('upload-max-size'); // 上传大小上限(MB)
|
|
|
const todoPathInput = document.getElementById('todo-path'); // 待办(TODO)文件路径
|
|
const todoPathInput = document.getElementById('todo-path'); // 待办(TODO)文件路径
|
|
|
const recordDBPathInput = document.getElementById('record-db-path'); // 记录(Record)数据库路径
|
|
const recordDBPathInput = document.getElementById('record-db-path'); // 记录(Record)数据库路径
|
|
|
const backupDirInput = document.getElementById('backup-dir'); // 文本备份目录(文件类自定义 Tab「备份」按钮使用)
|
|
const backupDirInput = document.getElementById('backup-dir'); // 文本备份目录(文件类自定义 Tab「备份」按钮使用)
|
|
@@ -551,6 +558,10 @@ async function syncForm(settings) {
|
|
|
githubReposText.value = (settings.githubRepos || []).map(r => `${r.url}|${r.desc}|${r.version || ''}`).join('\n');
|
|
githubReposText.value = (settings.githubRepos || []).map(r => `${r.url}|${r.desc}|${r.version || ''}`).join('\n');
|
|
|
customTabsText.value = (settings.customTabs || []).map(t => `${t.name}|${t.url}${t.desc ? '|' + t.desc : ''}`).join('\n');
|
|
customTabsText.value = (settings.customTabs || []).map(t => `${t.name}|${t.url}${t.desc ? '|' + t.desc : ''}`).join('\n');
|
|
|
apiAddrInput.value = settings.apiAddr;
|
|
apiAddrInput.value = settings.apiAddr;
|
|
|
|
|
+ apiAutoStartToggle.checked = settings.apiAutoStart !== false; // 默认开启(兼容旧配置无该字段)
|
|
|
|
|
+ uploadPasswordInput.value = settings.uploadPassword || ''; // 上传密码
|
|
|
|
|
+ uploadDirInput.value = settings.uploadDir || ''; // 上传保存目录
|
|
|
|
|
+ uploadMaxSizeInput.value = settings.uploadMaxSize || ''; // 上传大小上限(MB)
|
|
|
todoPathInput.value = settings.todoPath;
|
|
todoPathInput.value = settings.todoPath;
|
|
|
recordDBPathInput.value = settings.recordDBPath;
|
|
recordDBPathInput.value = settings.recordDBPath;
|
|
|
backupDirInput.value = settings.backupDir;
|
|
backupDirInput.value = settings.backupDir;
|
|
@@ -747,6 +758,10 @@ function collectForm() {
|
|
|
githubRepos: parsePipeLines3(fallbackArr(githubReposText, 'githubRepos'), 'url', 'desc', 'version').filter(r => r.url),
|
|
githubRepos: parsePipeLines3(fallbackArr(githubReposText, 'githubRepos'), 'url', 'desc', 'version').filter(r => r.url),
|
|
|
customTabs: parsePipeLines3(fallbackArr(customTabsText, 'customTabs'), 'name', 'url', 'desc').filter(t => t.name && t.url),
|
|
customTabs: parsePipeLines3(fallbackArr(customTabsText, 'customTabs'), 'name', 'url', 'desc').filter(t => t.name && t.url),
|
|
|
apiAddr: apiAddrInput.value.trim(),
|
|
apiAddr: apiAddrInput.value.trim(),
|
|
|
|
|
+ apiAutoStart: apiAutoStartToggle.checked,
|
|
|
|
|
+ uploadPassword: uploadPasswordInput.value.trim(), // 上传密码(空=不启用)
|
|
|
|
|
+ uploadDir: uploadDirInput.value.trim(), // 上传保存目录(空=默认目录)
|
|
|
|
|
+ uploadMaxSize: parseInt(uploadMaxSizeInput.value, 10) || 512, // 上传大小上限(MB,默认 512)
|
|
|
todoPath: todoPathInput.value.trim(),
|
|
todoPath: todoPathInput.value.trim(),
|
|
|
recordDBPath: recordDBPathInput.value.trim(),
|
|
recordDBPath: recordDBPathInput.value.trim(),
|
|
|
backupDir: backupDirInput.value.trim(),
|
|
backupDir: backupDirInput.value.trim(),
|
|
@@ -881,11 +896,13 @@ function setFooterText(text, opts = {}) {
|
|
|
updateFooterMarquee();
|
|
updateFooterMarquee();
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-// 古诗朗读(TTS);再点停止
|
|
|
|
|
|
|
+// 古诗朗读(Edge-TTS);再点停止
|
|
|
|
|
+const poemAudio = new Audio(); // 隐藏 audio 播放 Edge-TTS mp3
|
|
|
function togglePoemRead() {
|
|
function togglePoemRead() {
|
|
|
if (!poemReadBtn) return;
|
|
if (!poemReadBtn) return;
|
|
|
if (poemSpeaking) {
|
|
if (poemSpeaking) {
|
|
|
- speechSynthesis.cancel();
|
|
|
|
|
|
|
+ poemAudio.pause();
|
|
|
|
|
+ poemAudio.src = '';
|
|
|
poemSpeaking = false;
|
|
poemSpeaking = false;
|
|
|
poemReadBtn.classList.remove('is-active');
|
|
poemReadBtn.classList.remove('is-active');
|
|
|
return;
|
|
return;
|
|
@@ -894,20 +911,32 @@ function togglePoemRead() {
|
|
|
const lines = [currentPoem.title, currentPoem.author, ...(currentPoem.content || [])]
|
|
const lines = [currentPoem.title, currentPoem.author, ...(currentPoem.content || [])]
|
|
|
.filter(s => s && s.trim())
|
|
.filter(s => s && s.trim())
|
|
|
.join(',');
|
|
.join(',');
|
|
|
- const utter = new SpeechSynthesisUtterance(lines);
|
|
|
|
|
- utter.lang = 'zh-CN';
|
|
|
|
|
- utter.rate = 0.9;
|
|
|
|
|
- utter.onend = () => {
|
|
|
|
|
|
|
+ poemReadBtn.classList.add('is-active');
|
|
|
|
|
+ // Edge 神经网络语音(云希:沉稳男声,适合古诗吟诵)
|
|
|
|
|
+ window.SettingsService.TTS(lines, 'yunxi')
|
|
|
|
|
+ .then(src => {
|
|
|
|
|
+ if (!src) throw new Error('无音频');
|
|
|
|
|
+ poemAudio.src = src;
|
|
|
|
|
+ poemAudio.play().then(() => {
|
|
|
|
|
+ poemSpeaking = true;
|
|
|
|
|
+ }).catch(() => {
|
|
|
|
|
+ poemSpeaking = false;
|
|
|
|
|
+ poemReadBtn.classList.remove('is-active');
|
|
|
|
|
+ });
|
|
|
|
|
+ })
|
|
|
|
|
+ .catch(err => {
|
|
|
|
|
+ console.error('古诗 TTS 失败:', err);
|
|
|
|
|
+ poemSpeaking = false;
|
|
|
|
|
+ poemReadBtn.classList.remove('is-active');
|
|
|
|
|
+ });
|
|
|
|
|
+ poemAudio.onended = () => {
|
|
|
poemSpeaking = false;
|
|
poemSpeaking = false;
|
|
|
poemReadBtn.classList.remove('is-active');
|
|
poemReadBtn.classList.remove('is-active');
|
|
|
};
|
|
};
|
|
|
- utter.onerror = () => {
|
|
|
|
|
|
|
+ poemAudio.onerror = () => {
|
|
|
poemSpeaking = false;
|
|
poemSpeaking = false;
|
|
|
poemReadBtn.classList.remove('is-active');
|
|
poemReadBtn.classList.remove('is-active');
|
|
|
};
|
|
};
|
|
|
- speechSynthesis.speak(utter);
|
|
|
|
|
- poemSpeaking = true;
|
|
|
|
|
- poemReadBtn.classList.add('is-active');
|
|
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
// 把一首古诗渲染到 footer 说明区(滚动显示,带朗读按钮)
|
|
// 把一首古诗渲染到 footer 说明区(滚动显示,带朗读按钮)
|
|
@@ -1686,11 +1715,16 @@ const recordStatus = document.getElementById('record-status'); //
|
|
|
const recordPathDesc = document.getElementById('record-path-desc'); // 显示当前记录文件路径
|
|
const recordPathDesc = document.getElementById('record-path-desc'); // 显示当前记录文件路径
|
|
|
const recordNameFilter = document.getElementById('record-name-filter'); // 姓名筛选下拉
|
|
const recordNameFilter = document.getElementById('record-name-filter'); // 姓名筛选下拉
|
|
|
const recordItemFilter = document.getElementById('record-item-filter'); // 名称筛选下拉
|
|
const recordItemFilter = document.getElementById('record-item-filter'); // 名称筛选下拉
|
|
|
|
|
+const recordDateFrom = document.getElementById('record-date-from'); // 开始日期过滤
|
|
|
|
|
+const recordDateTo = document.getElementById('record-date-to'); // 结束日期过滤
|
|
|
|
|
+const recordFilterClear = document.getElementById('record-filter-clear'); // 清除时间过滤
|
|
|
const recordCount = document.getElementById('record-count'); // 匹配记录数提示
|
|
const recordCount = document.getElementById('record-count'); // 匹配记录数提示
|
|
|
const recordChart = document.getElementById('record-chart'); // 柱状图画布
|
|
const recordChart = document.getElementById('record-chart'); // 柱状图画布
|
|
|
const recordChartEmpty = document.getElementById('record-chart-empty'); // 柱状图空提示
|
|
const recordChartEmpty = document.getElementById('record-chart-empty'); // 柱状图空提示
|
|
|
|
|
+const recordChartTip = document.getElementById('record-chart-tooltip'); // 鼠标悬停提示
|
|
|
let recordDirty = false; // 是否有未保存修改
|
|
let recordDirty = false; // 是否有未保存修改
|
|
|
let recordData = []; // 当前记录数组 [{name, item, value}]
|
|
let recordData = []; // 当前记录数组 [{name, item, value}]
|
|
|
|
|
+let chartHitAreas = []; // 柱状图命中区域(x/宽/数据),用于鼠标悬停提示
|
|
|
|
|
|
|
|
// 自动补全历史值(来自数据库,去重):姓名 / 名称
|
|
// 自动补全历史值(来自数据库,去重):姓名 / 名称
|
|
|
// 用自定义下拉替代 <datalist>:WebView2 的 datalist 在输入框为空、仅聚焦点击时不会弹出候选,
|
|
// 用自定义下拉替代 <datalist>:WebView2 的 datalist 在输入框为空、仅聚焦点击时不会弹出候选,
|
|
@@ -1753,8 +1787,8 @@ function attachAutocomplete(input, optionsRef, onChange) {
|
|
|
input._ac = dl; // 供销毁时清理
|
|
input._ac = dl; // 供销毁时清理
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-// 渲染一行记录:姓名输入(自动补全)+ 名称输入(自动补全)+ 值输入 + 时间 + 删除按钮
|
|
|
|
|
-function renderRecordRow(name, item, value, createdAt) {
|
|
|
|
|
|
|
+// 渲染一行记录:姓名输入(自动补全)+ 名称输入(自动补全)+ 值输入 + 备注输入 + 时间 + 删除按钮
|
|
|
|
|
+function renderRecordRow(name, item, value, remark, createdAt) {
|
|
|
const row = document.createElement('div');
|
|
const row = document.createElement('div');
|
|
|
row.className = 'record-row';
|
|
row.className = 'record-row';
|
|
|
|
|
|
|
@@ -1765,6 +1799,11 @@ function renderRecordRow(name, item, value, createdAt) {
|
|
|
nameInput.placeholder = '姓名';
|
|
nameInput.placeholder = '姓名';
|
|
|
nameInput.spellcheck = false;
|
|
nameInput.spellcheck = false;
|
|
|
nameInput.setAttribute('list', 'record-names-dl'); // 保留 list 属性作为备用(自定义下拉为主)
|
|
nameInput.setAttribute('list', 'record-names-dl'); // 保留 list 属性作为备用(自定义下拉为主)
|
|
|
|
|
+ // 编辑期间标记行:筛选逻辑对正在编辑的行保持可见,避免修改后行因不再匹配而消失
|
|
|
|
|
+ const markEditing = () => { if (!row.dataset.new) row.dataset.editing = '1'; };
|
|
|
|
|
+ const unmarkEditing = () => { if (row.dataset.editing) { delete row.dataset.editing; applyRecordFilter(); } };
|
|
|
|
|
+ nameInput.addEventListener('focus', markEditing);
|
|
|
|
|
+ nameInput.addEventListener('blur', unmarkEditing);
|
|
|
nameInput.addEventListener('input', () => { recordDirty = true; refreshRecordFilterOptions(); });
|
|
nameInput.addEventListener('input', () => { recordDirty = true; refreshRecordFilterOptions(); });
|
|
|
nameInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') addRecordRow(); });
|
|
nameInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') addRecordRow(); });
|
|
|
attachAutocomplete(nameInput, () => mergeRecordCandidates(recordNameOptions, '.record-name'), () => { recordDirty = true; refreshRecordFilterOptions(); });
|
|
attachAutocomplete(nameInput, () => mergeRecordCandidates(recordNameOptions, '.record-name'), () => { recordDirty = true; refreshRecordFilterOptions(); });
|
|
@@ -1776,6 +1815,8 @@ function renderRecordRow(name, item, value, createdAt) {
|
|
|
itemInput.placeholder = '名称';
|
|
itemInput.placeholder = '名称';
|
|
|
itemInput.spellcheck = false;
|
|
itemInput.spellcheck = false;
|
|
|
itemInput.setAttribute('list', 'record-items-dl'); // 保留 list 属性作为备用(自定义下拉为主)
|
|
itemInput.setAttribute('list', 'record-items-dl'); // 保留 list 属性作为备用(自定义下拉为主)
|
|
|
|
|
+ itemInput.addEventListener('focus', markEditing);
|
|
|
|
|
+ itemInput.addEventListener('blur', unmarkEditing);
|
|
|
itemInput.addEventListener('input', () => { recordDirty = true; refreshRecordFilterOptions(); });
|
|
itemInput.addEventListener('input', () => { recordDirty = true; refreshRecordFilterOptions(); });
|
|
|
itemInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') addRecordRow(); });
|
|
itemInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') addRecordRow(); });
|
|
|
attachAutocomplete(itemInput, () => mergeRecordCandidates(recordItemOptions, '.record-item'), () => { recordDirty = true; refreshRecordFilterOptions(); });
|
|
attachAutocomplete(itemInput, () => mergeRecordCandidates(recordItemOptions, '.record-item'), () => { recordDirty = true; refreshRecordFilterOptions(); });
|
|
@@ -1785,8 +1826,22 @@ function renderRecordRow(name, item, value, createdAt) {
|
|
|
valueInput.className = 'record-input record-value';
|
|
valueInput.className = 'record-input record-value';
|
|
|
valueInput.value = value;
|
|
valueInput.value = value;
|
|
|
valueInput.placeholder = '值';
|
|
valueInput.placeholder = '值';
|
|
|
|
|
+ valueInput.addEventListener('focus', markEditing);
|
|
|
|
|
+ valueInput.addEventListener('blur', unmarkEditing);
|
|
|
valueInput.addEventListener('input', () => { recordDirty = true; redrawRecordChart(); });
|
|
valueInput.addEventListener('input', () => { recordDirty = true; redrawRecordChart(); });
|
|
|
|
|
|
|
|
|
|
+ // 备注:可选说明文字,不参与统计与柱状图
|
|
|
|
|
+ const remarkInput = document.createElement('input');
|
|
|
|
|
+ remarkInput.type = 'text';
|
|
|
|
|
+ remarkInput.className = 'record-input record-remark';
|
|
|
|
|
+ remarkInput.value = remark || '';
|
|
|
|
|
+ remarkInput.placeholder = '备注';
|
|
|
|
|
+ remarkInput.spellcheck = false;
|
|
|
|
|
+ remarkInput.title = '备注(可选)';
|
|
|
|
|
+ remarkInput.addEventListener('focus', markEditing);
|
|
|
|
|
+ remarkInput.addEventListener('blur', unmarkEditing);
|
|
|
|
|
+ remarkInput.addEventListener('input', () => { recordDirty = true; });
|
|
|
|
|
+
|
|
|
// 录入时间:新增时自动填当前时间(可编辑,便于回溯)
|
|
// 录入时间:新增时自动填当前时间(可编辑,便于回溯)
|
|
|
const timeInput = document.createElement('input');
|
|
const timeInput = document.createElement('input');
|
|
|
timeInput.type = 'text';
|
|
timeInput.type = 'text';
|
|
@@ -1812,6 +1867,7 @@ function renderRecordRow(name, item, value, createdAt) {
|
|
|
row.appendChild(nameInput);
|
|
row.appendChild(nameInput);
|
|
|
row.appendChild(itemInput);
|
|
row.appendChild(itemInput);
|
|
|
row.appendChild(valueInput);
|
|
row.appendChild(valueInput);
|
|
|
|
|
+ row.appendChild(remarkInput);
|
|
|
row.appendChild(timeInput);
|
|
row.appendChild(timeInput);
|
|
|
row.appendChild(del);
|
|
row.appendChild(del);
|
|
|
return row;
|
|
return row;
|
|
@@ -1826,10 +1882,12 @@ function formatNow() {
|
|
|
|
|
|
|
|
// 添加一行空的记录(自动记录当前时间,聚焦姓名输入框)
|
|
// 添加一行空的记录(自动记录当前时间,聚焦姓名输入框)
|
|
|
function addRecordRow() {
|
|
function addRecordRow() {
|
|
|
- const row = renderRecordRow('', '', '', '');
|
|
|
|
|
|
|
+ const row = renderRecordRow('', '', '', '', '');
|
|
|
|
|
+ row.dataset.new = '1'; // 标记为新增行:未选姓名+名称时也保持可见,不被筛选隐藏
|
|
|
recordList.appendChild(row);
|
|
recordList.appendChild(row);
|
|
|
row.querySelector('.record-name').focus();
|
|
row.querySelector('.record-name').focus();
|
|
|
recordDirty = true;
|
|
recordDirty = true;
|
|
|
|
|
+ applyRecordFilter();
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
// 收集所有记录行的数据(过滤全空行)
|
|
// 收集所有记录行的数据(过滤全空行)
|
|
@@ -1839,8 +1897,9 @@ function collectRecords() {
|
|
|
const name = row.querySelector('.record-name').value.trim();
|
|
const name = row.querySelector('.record-name').value.trim();
|
|
|
const item = row.querySelector('.record-item').value.trim();
|
|
const item = row.querySelector('.record-item').value.trim();
|
|
|
const value = row.querySelector('.record-value').value.trim();
|
|
const value = row.querySelector('.record-value').value.trim();
|
|
|
|
|
+ const remark = row.querySelector('.record-remark').value.trim();
|
|
|
if (!name && !item && !value) return; // 全空行跳过
|
|
if (!name && !item && !value) return; // 全空行跳过
|
|
|
- rows.push({ name, item, value: parseFloat(value) || 0, createdAt: row.querySelector('.record-time').value.trim() });
|
|
|
|
|
|
|
+ rows.push({ name, item, value: parseFloat(value) || 0, remark, createdAt: row.querySelector('.record-time').value.trim() });
|
|
|
});
|
|
});
|
|
|
return rows;
|
|
return rows;
|
|
|
}
|
|
}
|
|
@@ -1848,12 +1907,15 @@ function collectRecords() {
|
|
|
// 刷新姓名/名称下拉的候选(来自当前所有记录行,去重)
|
|
// 刷新姓名/名称下拉的候选(来自当前所有记录行,去重)
|
|
|
function refreshRecordFilterOptions() {
|
|
function refreshRecordFilterOptions() {
|
|
|
const names = new Set();
|
|
const names = new Set();
|
|
|
- const items = new Set();
|
|
|
|
|
|
|
+ const nameItems = new Map(); // name -> Set(item):供「选择姓名后名称只显示该姓名的名称」过滤
|
|
|
recordList.querySelectorAll('.record-row').forEach(row => {
|
|
recordList.querySelectorAll('.record-row').forEach(row => {
|
|
|
const n = row.querySelector('.record-name').value.trim();
|
|
const n = row.querySelector('.record-name').value.trim();
|
|
|
const it = row.querySelector('.record-item').value.trim();
|
|
const it = row.querySelector('.record-item').value.trim();
|
|
|
if (n) names.add(n);
|
|
if (n) names.add(n);
|
|
|
- if (it) items.add(it);
|
|
|
|
|
|
|
+ if (n && it) {
|
|
|
|
|
+ if (!nameItems.has(n)) nameItems.set(n, new Set());
|
|
|
|
|
+ nameItems.get(n).add(it);
|
|
|
|
|
+ }
|
|
|
});
|
|
});
|
|
|
// 保留当前选中值
|
|
// 保留当前选中值
|
|
|
const curName = recordNameFilter.value;
|
|
const curName = recordNameFilter.value;
|
|
@@ -1865,14 +1927,23 @@ function refreshRecordFilterOptions() {
|
|
|
recordNameFilter.appendChild(o);
|
|
recordNameFilter.appendChild(o);
|
|
|
});
|
|
});
|
|
|
if (curName && names.has(curName)) recordNameFilter.value = curName;
|
|
if (curName && names.has(curName)) recordNameFilter.value = curName;
|
|
|
|
|
+ // 名称下拉:仅显示「当前选中姓名」下存在的名称;未选姓名时显示全部
|
|
|
|
|
+ const selName = recordNameFilter.value;
|
|
|
|
|
+ const itemSet = selName ? (nameItems.get(selName) || new Set()) : new Set([...nameItems.values()].flatMap(s => [...s]));
|
|
|
recordItemFilter.innerHTML = '<option value="">全部</option>';
|
|
recordItemFilter.innerHTML = '<option value="">全部</option>';
|
|
|
- [...items].sort().forEach(it => {
|
|
|
|
|
|
|
+ [...itemSet].sort().forEach(it => {
|
|
|
const o = document.createElement('option');
|
|
const o = document.createElement('option');
|
|
|
o.value = it; o.textContent = it;
|
|
o.value = it; o.textContent = it;
|
|
|
recordItemFilter.appendChild(o);
|
|
recordItemFilter.appendChild(o);
|
|
|
});
|
|
});
|
|
|
- if (curItem && items.has(curItem)) recordItemFilter.value = curItem;
|
|
|
|
|
- redrawRecordChart();
|
|
|
|
|
|
|
+ // 若当前名称不属于所选姓名(或未选姓名时已不在候选集),清空名称选择
|
|
|
|
|
+ if (curItem && itemSet.has(curItem)) {
|
|
|
|
|
+ recordItemFilter.value = curItem;
|
|
|
|
|
+ } else {
|
|
|
|
|
+ recordItemFilter.value = '';
|
|
|
|
|
+ }
|
|
|
|
|
+ // 行内容/选项变化后重新应用筛选显示状态(内部含柱状图重绘)
|
|
|
|
|
+ applyRecordFilter();
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
// 刷新姓名/名称自动补全候选:从数据库拉取历史值(去重),供自定义下拉使用
|
|
// 刷新姓名/名称自动补全候选:从数据库拉取历史值(去重),供自定义下拉使用
|
|
@@ -1922,12 +1993,13 @@ async function loadRecords() {
|
|
|
recordStatus.textContent = '';
|
|
recordStatus.textContent = '';
|
|
|
refreshRecordFilterOptions();
|
|
refreshRecordFilterOptions();
|
|
|
loadRecordAutocomplete();
|
|
loadRecordAutocomplete();
|
|
|
|
|
+ applyRecordFilter(); // 初始隐藏数据列表(未选组合时)
|
|
|
return;
|
|
return;
|
|
|
}
|
|
}
|
|
|
try {
|
|
try {
|
|
|
const items = await SettingsService.ReadRecords();
|
|
const items = await SettingsService.ReadRecords();
|
|
|
if (items.length) {
|
|
if (items.length) {
|
|
|
- items.forEach(it => recordList.appendChild(renderRecordRow(it.name, it.item, String(it.value), it.createdAt)));
|
|
|
|
|
|
|
+ items.forEach(it => recordList.appendChild(renderRecordRow(it.name, it.item, String(it.value), it.remark || '', it.createdAt)));
|
|
|
} else {
|
|
} else {
|
|
|
addRecordRow();
|
|
addRecordRow();
|
|
|
}
|
|
}
|
|
@@ -1935,6 +2007,7 @@ async function loadRecords() {
|
|
|
recordStatus.textContent = '';
|
|
recordStatus.textContent = '';
|
|
|
refreshRecordFilterOptions();
|
|
refreshRecordFilterOptions();
|
|
|
loadRecordAutocomplete();
|
|
loadRecordAutocomplete();
|
|
|
|
|
+ applyRecordFilter(); // 初始隐藏数据列表(未选组合时)
|
|
|
} catch (err) {
|
|
} catch (err) {
|
|
|
recordStatus.textContent = '读取失败:' + (err?.message || err);
|
|
recordStatus.textContent = '读取失败:' + (err?.message || err);
|
|
|
}
|
|
}
|
|
@@ -1947,14 +2020,20 @@ async function saveRecords() {
|
|
|
const name = row.querySelector('.record-name').value.trim();
|
|
const name = row.querySelector('.record-name').value.trim();
|
|
|
const item = row.querySelector('.record-item').value.trim();
|
|
const item = row.querySelector('.record-item').value.trim();
|
|
|
const value = row.querySelector('.record-value').value.trim();
|
|
const value = row.querySelector('.record-value').value.trim();
|
|
|
|
|
+ const remark = row.querySelector('.record-remark').value.trim();
|
|
|
if (!name && !item && !value) return; // 空行不保存
|
|
if (!name && !item && !value) return; // 空行不保存
|
|
|
- items.push({ name, item, value: parseFloat(value) || 0, createdAt: row.querySelector('.record-time').value.trim() || formatNow() });
|
|
|
|
|
|
|
+ items.push({ name, item, value: parseFloat(value) || 0, remark, createdAt: row.querySelector('.record-time').value.trim() || formatNow() });
|
|
|
});
|
|
});
|
|
|
recordStatus.textContent = '保存中…';
|
|
recordStatus.textContent = '保存中…';
|
|
|
try {
|
|
try {
|
|
|
await SettingsService.WriteRecords(items);
|
|
await SettingsService.WriteRecords(items);
|
|
|
recordStatus.textContent = '已保存 ' + new Date().toLocaleTimeString('zh-CN');
|
|
recordStatus.textContent = '已保存 ' + new Date().toLocaleTimeString('zh-CN');
|
|
|
recordDirty = false;
|
|
recordDirty = false;
|
|
|
|
|
+ // 保存后所有行转为历史行:移除新增/编辑标记,按当前筛选重新显示
|
|
|
|
|
+ recordList.querySelectorAll('.record-row').forEach(row => {
|
|
|
|
|
+ delete row.dataset.new;
|
|
|
|
|
+ delete row.dataset.editing;
|
|
|
|
|
+ });
|
|
|
refreshRecordFilterOptions();
|
|
refreshRecordFilterOptions();
|
|
|
loadRecordAutocomplete(); // 保存后刷新自动补全候选(新姓名/名称加入历史)
|
|
loadRecordAutocomplete(); // 保存后刷新自动补全候选(新姓名/名称加入历史)
|
|
|
} catch (err) {
|
|
} catch (err) {
|
|
@@ -1962,16 +2041,79 @@ async function saveRecords() {
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-// 计算柱状图数据:必须同时选择姓名与名称,才按该组合过滤并绘图。
|
|
|
|
|
-// 不同姓名、不同名称绝不混在同一张图上——只有同时选定姓名和名称,
|
|
|
|
|
-// 才显示该组合下的全部记录(每行一根柱,展示其随时间的值变化)。
|
|
|
|
|
|
|
+// 判断记录时间(createdAt)是否在选定的日期区间内(含边界,按日期部分比较)
|
|
|
|
|
+function recordInDateRange(createdAt) {
|
|
|
|
|
+ const from = recordDateFrom.value;
|
|
|
|
|
+ const to = recordDateTo.value;
|
|
|
|
|
+ if (!from && !to) return true; // 未选时间:不过滤
|
|
|
|
|
+ const t = String(createdAt || '').trim();
|
|
|
|
|
+ if (!t) return true; // 无时间的记录不因时间过滤隐藏(避免误删)
|
|
|
|
|
+ const d = t.slice(0, 10); // 取 YYYY-MM-DD
|
|
|
|
|
+ if (from && d < from) return false;
|
|
|
|
|
+ if (to && d > to) return false;
|
|
|
|
|
+ return true;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// 计算当前筛选后的统计:{count, sum, avg}
|
|
|
|
|
+function recordFilterStats() {
|
|
|
|
|
+ let count = 0, sum = 0;
|
|
|
|
|
+ recordList.querySelectorAll('.record-row').forEach(row => {
|
|
|
|
|
+ if (row.style.display === 'none') return;
|
|
|
|
|
+ count++;
|
|
|
|
|
+ sum += parseFloat(row.querySelector('.record-value').value) || 0;
|
|
|
|
|
+ });
|
|
|
|
|
+ const avg = count ? sum / count : 0;
|
|
|
|
|
+ return { count, sum, avg };
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// 应用筛选:选择姓名+名称后,才显示该组合的数据(上图下数据);否则隐藏数据列表
|
|
|
|
|
+function applyRecordFilter() {
|
|
|
|
|
+ const selName = recordNameFilter.value;
|
|
|
|
|
+ const selItem = recordItemFilter.value;
|
|
|
|
|
+ const active = selName; // 选定姓名即可显示该姓名的历史数据(名称可选)
|
|
|
|
|
+ recordList.querySelectorAll('.record-row').forEach(row => {
|
|
|
|
|
+ // 新增行(尚未保存)始终可见:添加记录/输入姓名时不被筛选隐藏
|
|
|
|
|
+ // 正在编辑的历史行也保持可见:修改姓名/名称后行不会因不再匹配当前筛选而消失
|
|
|
|
|
+ if (row.dataset.new || row.dataset.editing) {
|
|
|
|
|
+ row.style.display = '';
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ const name = row.querySelector('.record-name').value.trim();
|
|
|
|
|
+ const item = row.querySelector('.record-item').value.trim();
|
|
|
|
|
+ const createdAt = row.querySelector('.record-time').value.trim();
|
|
|
|
|
+ // 历史数据行:选定姓名后显示其记录;若再选名称则进一步限定该名称
|
|
|
|
|
+ let show = active && name === selName && (!selItem || item === selItem);
|
|
|
|
|
+ if (show && !recordInDateRange(createdAt)) show = false;
|
|
|
|
|
+ row.style.display = show ? '' : 'none';
|
|
|
|
|
+ });
|
|
|
|
|
+ // 柱状图(含时间过滤)+ 统计一起刷新
|
|
|
|
|
+ redrawRecordChart();
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// 更新统计提示:XX条记录,和xxx,平均xxxx
|
|
|
|
|
+function updateRecordStats() {
|
|
|
|
|
+ const { count, sum, avg } = recordFilterStats();
|
|
|
|
|
+ const fmt = (n) => {
|
|
|
|
|
+ if (!isFinite(n)) return '0';
|
|
|
|
|
+ return Number(n).toLocaleString('zh-CN', { maximumFractionDigits: 2 });
|
|
|
|
|
+ };
|
|
|
|
|
+ recordCount.textContent = count
|
|
|
|
|
+ ? `${count} 条记录,和 ${fmt(sum)},平均 ${fmt(avg)}`
|
|
|
|
|
+ : '0 条记录';
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// 计算柱状图数据:选定姓名即可出图(名称可选)。
|
|
|
|
|
+// 无论是否选定名称,每条记录都对应一根柱(与列表行一一对应),
|
|
|
|
|
+// 保证「列表有几行,图就有几根柱」,不按名称合并。
|
|
|
function recordChartData() {
|
|
function recordChartData() {
|
|
|
const selName = recordNameFilter.value;
|
|
const selName = recordNameFilter.value;
|
|
|
const selItem = recordItemFilter.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 }));
|
|
|
|
|
|
|
+ if (!selName) return []; // 未选定姓名:不出图
|
|
|
|
|
+ let rows = collectRecords().filter(r => r.name === selName && recordInDateRange(r.createdAt));
|
|
|
|
|
+ if (selItem) {
|
|
|
|
|
+ rows = rows.filter(r => r.item === selItem);
|
|
|
|
|
+ }
|
|
|
|
|
+ return rows.map(r => ({ label: r.item || '(未命名)', value: r.value, createdAt: r.createdAt }));
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
// 绘制柱状图(canvas),无数据时显示空提示
|
|
// 绘制柱状图(canvas),无数据时显示空提示
|
|
@@ -1981,12 +2123,14 @@ function redrawRecordChart() {
|
|
|
if (!data.length) {
|
|
if (!data.length) {
|
|
|
recordChartEmpty.style.display = 'flex'; // 空提示居中(CSS 默认为 flex)
|
|
recordChartEmpty.style.display = 'flex'; // 空提示居中(CSS 默认为 flex)
|
|
|
recordChart.style.display = 'none';
|
|
recordChart.style.display = 'none';
|
|
|
- recordCount.textContent = '0 条记录';
|
|
|
|
|
|
|
+ chartHitAreas = []; // 无数据:清空命中区并隐藏提示
|
|
|
|
|
+ if (recordChartTip) recordChartTip.hidden = true;
|
|
|
|
|
+ updateRecordStats();
|
|
|
return;
|
|
return;
|
|
|
}
|
|
}
|
|
|
recordChartEmpty.style.display = 'none';
|
|
recordChartEmpty.style.display = 'none';
|
|
|
recordChart.style.display = 'block'; // canvas 用块级显示,否则 CSS 的 display:none 使其永不出现
|
|
recordChart.style.display = 'block'; // canvas 用块级显示,否则 CSS 的 display:none 使其永不出现
|
|
|
- recordCount.textContent = `${data.length} 条记录`;
|
|
|
|
|
|
|
+ updateRecordStats();
|
|
|
|
|
|
|
|
const dpr = window.devicePixelRatio || 1;
|
|
const dpr = window.devicePixelRatio || 1;
|
|
|
const rect = recordChart.getBoundingClientRect();
|
|
const rect = recordChart.getBoundingClientRect();
|
|
@@ -1997,6 +2141,10 @@ function redrawRecordChart() {
|
|
|
const ctx = recordChart.getContext('2d');
|
|
const ctx = recordChart.getContext('2d');
|
|
|
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
|
ctx.clearRect(0, 0, w, h);
|
|
ctx.clearRect(0, 0, w, h);
|
|
|
|
|
+ // 文字颜色跟随主题:取当前 data-theme 下的 --ink / --muted,保证亮色主题下可读
|
|
|
|
|
+ const isLight = document.documentElement.dataset.theme === 'light';
|
|
|
|
|
+ const cs = getComputedStyle(document.documentElement);
|
|
|
|
|
+ const mutedColor = cs.getPropertyValue('--muted').trim(); // 轴标签/刻度文字色
|
|
|
|
|
|
|
|
const padL = 44, padR = 12, padT = 14, padB = 30;
|
|
const padL = 44, padR = 12, padT = 14, padB = 30;
|
|
|
const plotW = w - padL - padR, plotH = h - padT - padB;
|
|
const plotW = w - padL - padR, plotH = h - padT - padB;
|
|
@@ -2006,7 +2154,7 @@ function redrawRecordChart() {
|
|
|
|
|
|
|
|
// 背景网格线 + Y 轴刻度
|
|
// 背景网格线 + Y 轴刻度
|
|
|
ctx.strokeStyle = 'rgba(127,127,127,0.18)';
|
|
ctx.strokeStyle = 'rgba(127,127,127,0.18)';
|
|
|
- ctx.fillStyle = 'rgba(127,127,127,0.7)';
|
|
|
|
|
|
|
+ ctx.fillStyle = mutedColor || (isLight ? '#5d6678' : '#8b93a7');
|
|
|
ctx.lineWidth = 1;
|
|
ctx.lineWidth = 1;
|
|
|
ctx.font = '10px sans-serif';
|
|
ctx.font = '10px sans-serif';
|
|
|
ctx.textAlign = 'right';
|
|
ctx.textAlign = 'right';
|
|
@@ -2023,25 +2171,34 @@ function redrawRecordChart() {
|
|
|
// 柱
|
|
// 柱
|
|
|
const barColor = 'rgba(255,90,90,0.85)';
|
|
const barColor = 'rgba(255,90,90,0.85)';
|
|
|
ctx.textAlign = 'center';
|
|
ctx.textAlign = 'center';
|
|
|
|
|
+ chartHitAreas = []; // 重建命中区域
|
|
|
data.forEach((d, i) => {
|
|
data.forEach((d, i) => {
|
|
|
const cx = padL + gap * i + gap / 2;
|
|
const cx = padL + gap * i + gap / 2;
|
|
|
const bh = Math.abs(d.value) / maxVal * plotH;
|
|
const bh = Math.abs(d.value) / maxVal * plotH;
|
|
|
const y0 = padT + plotH - bh;
|
|
const y0 = padT + plotH - bh;
|
|
|
ctx.fillStyle = barColor;
|
|
ctx.fillStyle = barColor;
|
|
|
ctx.fillRect(cx - barW / 2, y0, barW, bh);
|
|
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 轴标签:姓名/名称(长标签截断,避免重叠)
|
|
|
|
|
|
|
+ // 命中区域:以柱为中心、略加宽,便于鼠标悬停
|
|
|
|
|
+ chartHitAreas.push({ x0: cx - barW / 2 - 4, x1: cx + barW / 2 + 4, y0, y1: padT + plotH, d });
|
|
|
|
|
+ // x 轴标签:名称(长标签截断,避免重叠)。数值与名称只在鼠标悬停提示中显示
|
|
|
const lbl = d.label || '·';
|
|
const lbl = d.label || '·';
|
|
|
|
|
+ ctx.fillStyle = mutedColor || (isLight ? '#5d6678' : '#8b93a7');
|
|
|
ctx.fillText(lbl.length > 10 ? lbl.slice(0, 10) + '…' : lbl, cx, h - 10);
|
|
ctx.fillText(lbl.length > 10 ? lbl.slice(0, 10) + '…' : lbl, cx, h - 10);
|
|
|
});
|
|
});
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
recordSaveBtn.addEventListener('click', saveRecords);
|
|
recordSaveBtn.addEventListener('click', saveRecords);
|
|
|
recordAddBtn.addEventListener('click', addRecordRow);
|
|
recordAddBtn.addEventListener('click', addRecordRow);
|
|
|
-recordNameFilter.addEventListener('change', redrawRecordChart);
|
|
|
|
|
-recordItemFilter.addEventListener('change', redrawRecordChart);
|
|
|
|
|
|
|
+// 姓名筛选变化:联动刷新名称下拉(只显示该姓名下的名称),并应用筛选
|
|
|
|
|
+recordNameFilter.addEventListener('change', () => { refreshRecordFilterOptions(); });
|
|
|
|
|
+recordItemFilter.addEventListener('change', applyRecordFilter);
|
|
|
|
|
+recordDateFrom.addEventListener('change', applyRecordFilter);
|
|
|
|
|
+recordDateTo.addEventListener('change', applyRecordFilter);
|
|
|
|
|
+recordFilterClear.addEventListener('click', () => {
|
|
|
|
|
+ recordDateFrom.value = '';
|
|
|
|
|
+ recordDateTo.value = '';
|
|
|
|
|
+ applyRecordFilter();
|
|
|
|
|
+});
|
|
|
|
|
|
|
|
// 记录页 DOM 变化时自动刷新下拉与图表(例如删除行)
|
|
// 记录页 DOM 变化时自动刷新下拉与图表(例如删除行)
|
|
|
const recordPanel = document.getElementById('tab-record');
|
|
const recordPanel = document.getElementById('tab-record');
|
|
@@ -2053,6 +2210,57 @@ if (recordPanel) {
|
|
|
// 窗口尺寸变化时重绘柱状图
|
|
// 窗口尺寸变化时重绘柱状图
|
|
|
window.addEventListener('resize', () => { redrawRecordChart(); });
|
|
window.addEventListener('resize', () => { redrawRecordChart(); });
|
|
|
|
|
|
|
|
|
|
+// ------------------------------------------------------------------------
|
|
|
|
|
+// 柱状图鼠标悬停提示(tooltip):根据命中区域找到对应柱,显示该柱完整信息
|
|
|
|
|
+// ------------------------------------------------------------------------
|
|
|
|
|
+function showRecordTip(hit, e) {
|
|
|
|
|
+ if (!hit || !recordChartTip) return;
|
|
|
|
|
+ const d = hit.d;
|
|
|
|
|
+ const name = recordNameFilter.value || '';
|
|
|
|
|
+ const timeStr = d.createdAt || '';
|
|
|
|
|
+ // 提示内容:名称、值、姓名、时间
|
|
|
|
|
+ recordChartTip.innerHTML = '';
|
|
|
|
|
+ const lines = [
|
|
|
|
|
+ `名称:${d.label || '(未命名)'}`,
|
|
|
|
|
+ `值:${d.value.toFixed(1)}`,
|
|
|
|
|
+ ];
|
|
|
|
|
+ if (name) lines.push(`姓名:${name}`);
|
|
|
|
|
+ if (timeStr) lines.push(`时间:${timeStr}`);
|
|
|
|
|
+ lines.forEach((t, i) => {
|
|
|
|
|
+ const div = document.createElement('div');
|
|
|
|
|
+ div.className = i === 0 ? 'tip-name' : '';
|
|
|
|
|
+ div.textContent = t;
|
|
|
|
|
+ recordChartTip.appendChild(div);
|
|
|
|
|
+ });
|
|
|
|
|
+ recordChartTip.hidden = false;
|
|
|
|
|
+ // 定位:跟随鼠标,超出画布右/下边缘时翻转到另一侧
|
|
|
|
|
+ const wrapRect = recordChart.getBoundingClientRect();
|
|
|
|
|
+ const tipRect = recordChartTip.getBoundingClientRect();
|
|
|
|
|
+ let left = e.clientX - wrapRect.left + 14;
|
|
|
|
|
+ if (left + tipRect.width > wrapRect.width - 4) left = e.clientX - wrapRect.left - tipRect.width - 14;
|
|
|
|
|
+ let top = e.clientY - wrapRect.top - tipRect.height - 10;
|
|
|
|
|
+ if (top < 2) top = e.clientY - wrapRect.top + 16;
|
|
|
|
|
+ recordChartTip.style.left = Math.max(2, left) + 'px';
|
|
|
|
|
+ recordChartTip.style.top = Math.max(2, top) + 'px';
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function hideRecordTip() {
|
|
|
|
|
+ if (recordChartTip) recordChartTip.hidden = true;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+if (recordChart) {
|
|
|
|
|
+ recordChart.addEventListener('mousemove', (e) => {
|
|
|
|
|
+ const rect = recordChart.getBoundingClientRect();
|
|
|
|
|
+ const x = e.clientX - rect.left;
|
|
|
|
|
+ const y = e.clientY - rect.top;
|
|
|
|
|
+ const hit = chartHitAreas.find(h => x >= h.x0 && x <= h.x1 && y >= h.y0 && y <= h.y1);
|
|
|
|
|
+ recordChart.style.cursor = hit ? 'pointer' : 'default';
|
|
|
|
|
+ if (hit) showRecordTip(hit, e);
|
|
|
|
|
+ else hideRecordTip();
|
|
|
|
|
+ });
|
|
|
|
|
+ recordChart.addEventListener('mouseleave', hideRecordTip);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
// ------------------------------------------------------------------------
|
|
// ------------------------------------------------------------------------
|
|
|
// 主机:API 服务状态、电源命令状态与 Gin 日志
|
|
// 主机:API 服务状态、电源命令状态与 Gin 日志
|
|
|
// ------------------------------------------------------------------------
|
|
// ------------------------------------------------------------------------
|
|
@@ -2180,12 +2388,42 @@ async function loadHostPanel() {
|
|
|
const logs = await SettingsService.GetAPILogs();
|
|
const logs = await SettingsService.GetAPILogs();
|
|
|
logView.textContent = logs && logs.length ? logs.join('\n') : '暂无日志';
|
|
logView.textContent = logs && logs.length ? logs.join('\n') : '暂无日志';
|
|
|
logView.scrollTop = logView.scrollHeight; // 滚动到最新一行
|
|
logView.scrollTop = logView.scrollHeight; // 滚动到最新一行
|
|
|
|
|
+
|
|
|
|
|
+ await renderClientUploadQR(status); // 刷新客户端上传页二维码
|
|
|
} catch (err) {
|
|
} catch (err) {
|
|
|
apiStatusEl.textContent = '状态获取失败(浏览器预览模式无后端)';
|
|
apiStatusEl.textContent = '状态获取失败(浏览器预览模式无后端)';
|
|
|
console.warn('主机面板加载失败:', err);
|
|
console.warn('主机面板加载失败:', err);
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+// 渲染「客户端上传页」二维码:扫码打开 http://<addr>/upload,供手机等设备上传文件
|
|
|
|
|
+const clientUploadQrBox = document.getElementById('client-upload-qr');
|
|
|
|
|
+const clientUploadUrlEl = document.getElementById('client-upload-url');
|
|
|
|
|
+async function renderClientUploadQR(status) {
|
|
|
|
|
+ if (!clientUploadQrBox) return;
|
|
|
|
|
+ clientUploadQrBox.innerHTML = '';
|
|
|
|
|
+ if (clientUploadUrlEl) clientUploadUrlEl.textContent = '';
|
|
|
|
|
+ if (!status || !status.running) return; // 未运行则不显示二维码
|
|
|
|
|
+
|
|
|
|
|
+ // 本机展示用 127.0.0.1;通配地址 0.0.0.0/[::] 无法在浏览器中访问
|
|
|
|
|
+ let addr = String(status.addr || '').trim();
|
|
|
|
|
+ if (addr.startsWith('0.0.0.0') || addr.startsWith('[::]')) {
|
|
|
|
|
+ addr = addr.replace(/^0\.0\.0\.0/, '127.0.0.1').replace(/^\[::\]/, '127.0.0.1');
|
|
|
|
|
+ }
|
|
|
|
|
+ const pageUrl = `http://${addr}/upload`;
|
|
|
|
|
+ try {
|
|
|
|
|
+ const qrUri = await SettingsService.GenerateQRCode(pageUrl, 0);
|
|
|
|
|
+ const img = document.createElement('img');
|
|
|
|
|
+ img.src = qrUri;
|
|
|
|
|
+ img.alt = '客户端上传页二维码';
|
|
|
|
|
+ img.title = '扫码打开客户端上传页';
|
|
|
|
|
+ clientUploadQrBox.appendChild(img);
|
|
|
|
|
+ } catch (err) {
|
|
|
|
|
+ console.warn('客户端上传页二维码生成失败:', err);
|
|
|
|
|
+ }
|
|
|
|
|
+ if (clientUploadUrlEl) clientUploadUrlEl.textContent = pageUrl;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
// 乒乓切换 API 服务:运行中→停止,未运行→启动
|
|
// 乒乓切换 API 服务:运行中→停止,未运行→启动
|
|
|
async function toggleAPIService() {
|
|
async function toggleAPIService() {
|
|
|
apiToggleBtn.disabled = true;
|
|
apiToggleBtn.disabled = true;
|
|
@@ -2207,6 +2445,108 @@ async function toggleAPIService() {
|
|
|
|
|
|
|
|
apiToggleBtn.addEventListener('click', toggleAPIService);
|
|
apiToggleBtn.addEventListener('click', toggleAPIService);
|
|
|
|
|
|
|
|
|
|
+// ------------------------------------------------------------------------
|
|
|
|
|
+// 主机:文件上传——本机选择文件直接上传(无需密码,走 Wails 绑定,不暴露给网络)
|
|
|
|
|
+// ------------------------------------------------------------------------
|
|
|
|
|
+const uploadBtn = document.getElementById('upload-btn'); // 上传按钮
|
|
|
|
|
+const uploadProgressBox = document.getElementById('upload-progress'); // 进度条容器
|
|
|
|
|
+const uploadBar = document.querySelector('.upload-bar'); // 进度条填充
|
|
|
|
|
+const uploadResultBox = document.getElementById('upload-result'); // 结果容器
|
|
|
|
|
+const uploadQrBox = document.getElementById('upload-qr'); // 二维码容器
|
|
|
|
|
+const uploadNameEl = document.getElementById('upload-name'); // 文件名(选择提示)
|
|
|
|
|
+const uploadName2El = document.getElementById('upload-name2'); // 文件名(结果)
|
|
|
|
|
+const uploadLinkEl = document.getElementById('upload-link'); // 下载链接
|
|
|
|
|
+
|
|
|
|
|
+// 获取当前 API 服务地址(http://host:port),用于拼接上传/下载 URL。
|
|
|
|
|
+// 本机访问时把通配地址 0.0.0.0 / [::] 换成 127.0.0.1,否则浏览器无法连接。
|
|
|
|
|
+async function apiBaseURL() {
|
|
|
|
|
+ const status = await SettingsService.GetAPIStatus();
|
|
|
|
|
+ if (!status.running) throw new Error('API 服务未运行,请先启动服务');
|
|
|
|
|
+ let addr = String(status.addr || '').trim();
|
|
|
|
|
+ if (addr.startsWith('0.0.0.0') || addr.startsWith('[::]')) {
|
|
|
|
|
+ addr = addr.replace(/^0\.0\.0\.0/, '127.0.0.1').replace(/^\[::\]/, '127.0.0.1');
|
|
|
|
|
+ }
|
|
|
|
|
+ return 'http://' + addr;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// 上传文件:本机选择文件,走 Wails 绑定 UploadFile(无需密码),成功后显示下载二维码。
|
|
|
|
|
+// 下载地址需要密码(供他人扫码下载),这里把设置的上传密码拼进 URL。
|
|
|
|
|
+async function doUploadFile() {
|
|
|
|
|
+ uploadBtn.disabled = true;
|
|
|
|
|
+ uploadBtn.classList.add('loading');
|
|
|
|
|
+ uploadProgressBox.hidden = false;
|
|
|
|
|
+ uploadBar.style.width = '8%';
|
|
|
|
|
+
|
|
|
|
|
+ try {
|
|
|
|
|
+ // 1) 弹出文件选择对话框
|
|
|
|
|
+ const path = await SettingsService.PickUploadFile();
|
|
|
|
|
+ if (!path) {
|
|
|
|
|
+ // 用户取消
|
|
|
|
|
+ uploadNameEl.textContent = '';
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ uploadNameEl.textContent = path.split(/[\\/]/).pop() || path;
|
|
|
|
|
+ uploadBar.style.width = '30%';
|
|
|
|
|
+
|
|
|
|
|
+ // 2) 直接上传(本机,无密码)
|
|
|
|
|
+ const result = await SettingsService.UploadFile(path);
|
|
|
|
|
+ uploadBar.style.width = '70%';
|
|
|
|
|
+
|
|
|
|
|
+ // 3) 拼完整下载地址(带上传密码,供他人扫码下载)
|
|
|
|
|
+ let fullUrl = '';
|
|
|
|
|
+ try {
|
|
|
|
|
+ const base = await apiBaseURL();
|
|
|
|
|
+ const settings = await SettingsService.GetSettings();
|
|
|
|
|
+ const pwd = (settings && settings.uploadPassword) || '';
|
|
|
|
|
+ fullUrl = `${base}${result.downloadUrl}${pwd ? '?password=' + encodeURIComponent(pwd) : ''}`;
|
|
|
|
|
+ } catch (e) {
|
|
|
|
|
+ // API 服务未运行:仍显示相对下载路径
|
|
|
|
|
+ fullUrl = result.downloadUrl || '';
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ uploadName2El.textContent = `${result.filename}(${formatBytes(result.size)})`;
|
|
|
|
|
+ uploadLinkEl.href = fullUrl;
|
|
|
|
|
+ uploadLinkEl.textContent = fullUrl ? '下载地址' : '下载地址(需启动 API 服务)';
|
|
|
|
|
+ uploadBar.style.width = '88%';
|
|
|
|
|
+
|
|
|
|
|
+ // 4) 生成二维码
|
|
|
|
|
+ if (fullUrl) {
|
|
|
|
|
+ const qrUri = await SettingsService.GenerateQRCode(fullUrl, 0);
|
|
|
|
|
+ uploadQrBox.innerHTML = '';
|
|
|
|
|
+ const img = document.createElement('img');
|
|
|
|
|
+ img.src = qrUri;
|
|
|
|
|
+ img.alt = '下载二维码';
|
|
|
|
|
+ uploadQrBox.appendChild(img);
|
|
|
|
|
+ } else {
|
|
|
|
|
+ uploadQrBox.innerHTML = '';
|
|
|
|
|
+ }
|
|
|
|
|
+ uploadBar.style.width = '100%';
|
|
|
|
|
+ uploadResultBox.hidden = false;
|
|
|
|
|
+ } catch (err) {
|
|
|
|
|
+ console.warn('上传失败:', err);
|
|
|
|
|
+ uploadResultBox.hidden = false;
|
|
|
|
|
+ uploadQrBox.innerHTML = '';
|
|
|
|
|
+ uploadName2El.textContent = '上传失败:' + (err?.message || String(err));
|
|
|
|
|
+ uploadLinkEl.textContent = '';
|
|
|
|
|
+ uploadLinkEl.removeAttribute('href');
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ uploadBtn.disabled = false;
|
|
|
|
|
+ uploadBtn.classList.remove('loading');
|
|
|
|
|
+ setTimeout(() => { uploadProgressBox.hidden = true; }, 600);
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// 字节数格式化
|
|
|
|
|
+function formatBytes(n) {
|
|
|
|
|
+ if (n == null || n <= 0) return '0 B';
|
|
|
|
|
+ const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
|
|
|
|
+ let i = 0, v = n;
|
|
|
|
|
+ while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; }
|
|
|
|
|
+ return v.toFixed(v >= 10 || i === 0 ? 0 : 1) + ' ' + units[i];
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+if (uploadBtn) uploadBtn.addEventListener('click', doUploadFile);
|
|
|
|
|
+
|
|
|
// 后端实时推送的日志行:追加显示,保持滚动到底部
|
|
// 后端实时推送的日志行:追加显示,保持滚动到底部
|
|
|
Events.On('apilog', (event) => {
|
|
Events.On('apilog', (event) => {
|
|
|
if (logView.textContent === '暂无日志') logView.textContent = '';
|
|
if (logView.textContent === '暂无日志') logView.textContent = '';
|