const state = { envSchema: [], envValues: {}, commands: [], runState: { activeRun: null, history: [] }, }; const commandGrid = document.getElementById('commandGrid'); const configGroups = document.getElementById('configGroups'); const historyList = document.getElementById('historyList'); const runOutput = document.getElementById('runOutput'); const runStatusPill = document.getElementById('runStatusPill'); const activeRunSummary = document.getElementById('activeRunSummary'); const saveEnvBtn = document.getElementById('saveEnvBtn'); const stopRunBtn = document.getElementById('stopRunBtn'); const summaryCards = document.getElementById('summaryCards'); const testDbBtn = document.getElementById('testDbBtn'); const checkLoginBtn = document.getElementById('checkLoginBtn'); const utilityFeedback = document.getElementById('utilityFeedback'); const clearCheckpointBtn = document.getElementById('clearCheckpointBtn'); const clearMessageDataBtn = document.getElementById('clearMessageDataBtn'); const messageClassificationStats = document.getElementById('messageClassificationStats'); const messageAccountStats = document.getElementById('messageAccountStats'); const maintenanceResultCard = document.getElementById('maintenanceResultCard'); const autoScrollToggle = document.getElementById('autoScrollToggle'); const logFilterInput = document.getElementById('logFilterInput'); const exportLogBtn = document.getElementById('exportLogBtn'); const schedulerMeta = document.getElementById('schedulerMeta'); let eventSource = null; document.querySelectorAll('.nav-link').forEach((button) => { button.addEventListener('click', () => { document.querySelectorAll('.nav-link').forEach((item) => item.classList.remove('active')); document.querySelectorAll('.panel').forEach((panel) => panel.classList.remove('active')); button.classList.add('active'); document.querySelector(`[data-panel="${button.dataset.tab}"]`)?.classList.add('active'); }); }); function setRunStatus(status) { runStatusPill.textContent = status || 'idle'; runStatusPill.className = `pill ${status || 'idle'}`; } function renderRunState() { const run = state.runState.activeRun; if (!run) { activeRunSummary.textContent = '当前无运行任务'; setRunStatus('idle'); runOutput.textContent = '等待任务启动…'; return; } activeRunSummary.textContent = `${run.label} · ${run.status} · ${new Date(run.startedAt).toLocaleString()}`; setRunStatus(run.status); const filter = logFilterInput?.value?.trim(); const lines = run.output || []; const filtered = filter ? lines.filter((line) => line.toLowerCase().includes(filter.toLowerCase())) : lines; runOutput.textContent = filtered.join('\n') || '暂无输出'; if (autoScrollToggle?.checked) { runOutput.scrollTop = runOutput.scrollHeight; } } function showMaintenanceResult(kind, text) { maintenanceResultCard.classList.remove('hidden', 'success', 'error'); maintenanceResultCard.classList.add(kind); maintenanceResultCard.textContent = text; } function renderAnalytics(payload) { messageClassificationStats.innerHTML = ''; messageAccountStats.innerHTML = ''; for (const item of payload.classifications || []) { const row = document.createElement('div'); row.className = 'analytics-item'; row.innerHTML = `${item.classification}${item.cnt}`; messageClassificationStats.appendChild(row); } for (const item of payload.topAccounts || []) { const row = document.createElement('div'); row.className = 'analytics-item'; row.innerHTML = `${item.account_id}${item.cnt}`; messageAccountStats.appendChild(row); } } function renderSchedulerMeta(meta) { schedulerMeta.innerHTML = ''; const items = [ ['调度模式', meta.mode], ['账单 Cron', meta.billsCron], ['高频 Cron', meta.hotCron], ['时区', meta.timezone], ['浏览器关闭策略', meta.browserClose === 'true' ? '默认关闭' : '常驻' ], ['运行状态', meta.running ? `运行中:${meta.activeRunLabel}` : '当前未运行'], ['调度策略', meta.strategy], ]; for (const [label, value] of items) { const node = document.createElement('div'); node.className = 'item'; node.innerHTML = `
${label}
${value || '-'}
`; schedulerMeta.appendChild(node); } if ((meta.recentEvents || []).length > 0) { for (const event of meta.recentEvents) { const node = document.createElement('div'); node.className = 'item'; const detail = event.error || event.reason || event.mode || '-'; node.innerHTML = `
${event.track} · ${event.status}
${new Date(event.at).toLocaleString()}
${detail}
`; schedulerMeta.appendChild(node); } } } function fieldValue(key) { return state.envValues[key] ?? ''; } function renderCommands() { commandGrid.innerHTML = ''; const template = document.getElementById('commandCardTemplate'); for (const command of state.commands) { const fragment = template.content.cloneNode(true); fragment.querySelector('.command-title').textContent = command.label; fragment.querySelector('.command-description').textContent = command.description; fragment.querySelector('.command-key').textContent = command.key; const optionContainer = fragment.querySelector('.command-options'); if (['sync', 'bills', 'orders', 'messages', 'hot'].includes(command.key)) { optionContainer.appendChild(buildCheckboxField(`${command.key}-resume`, '继续上次进度', 'resume')); } if (['orders', 'messages'].includes(command.key)) { optionContainer.appendChild(buildCheckboxField(`${command.key}-incremental`, '增量参数', 'incremental')); } if (command.key === 'orders') { optionContainer.appendChild(buildInputField(`${command.key}-incremental-order-start-date`, '增量订单起始日期', '例如 2026-01-01')); } fragment.querySelector('.run-command-btn').addEventListener('click', async () => { const payload = { commandKey: command.key, options: { resume: document.getElementById(`${command.key}-resume`)?.checked || false, incremental: document.getElementById(`${command.key}-incremental`)?.checked || false, incrementalOrderStartDate: document.getElementById(`${command.key}-incremental-order-start-date`)?.value?.trim() || '', }, }; await fetchJson('/api/run', { method: 'POST', body: JSON.stringify(payload) }); await refreshRuns(); }); commandGrid.appendChild(fragment); } } function buildCheckboxField(id, title, subtitle) { const wrapper = document.createElement('label'); wrapper.className = 'checkbox-field'; wrapper.innerHTML = `
${title}
${subtitle}
`; return wrapper; } function buildInputField(id, title, placeholder) { const wrapper = document.createElement('label'); wrapper.className = 'field-label'; wrapper.innerHTML = ` ${title} `; return wrapper; } function renderConfig() { configGroups.innerHTML = ''; const groups = new Map(); for (const field of state.envSchema) { if (!groups.has(field.group)) groups.set(field.group, []); groups.get(field.group).push(field); } for (const [groupName, fields] of groups) { const group = document.createElement('section'); group.className = 'config-group'; group.innerHTML = `

${groupName}

`; const grid = document.createElement('div'); grid.className = 'config-grid'; for (const field of fields) { if (field.type === 'boolean') { const wrapper = document.createElement('label'); wrapper.className = 'checkbox-field'; wrapper.innerHTML = `
${field.label}
${field.key}
`; grid.appendChild(wrapper); continue; } const wrapper = document.createElement('label'); wrapper.className = 'field-label'; wrapper.innerHTML = `${field.label}`; let input; if (field.type === 'select') { input = document.createElement('select'); input.dataset.envKey = field.key; for (const option of field.options || []) { const item = document.createElement('option'); item.value = option; item.textContent = option; if (fieldValue(field.key) === option) item.selected = true; input.appendChild(item); } } else { input = document.createElement('input'); input.dataset.envKey = field.key; input.type = field.type === 'password' ? 'password' : field.type === 'number' ? 'number' : 'text'; input.value = fieldValue(field.key); input.placeholder = field.key; } wrapper.appendChild(input); grid.appendChild(wrapper); } group.appendChild(grid); configGroups.appendChild(group); } } function renderHistory() { historyList.innerHTML = ''; const template = document.getElementById('historyItemTemplate'); for (const item of state.runState.history) { const fragment = template.content.cloneNode(true); fragment.querySelector('.history-title').textContent = item.label; fragment.querySelector('.history-meta').textContent = `${item.commandKey} · ${new Date(item.startedAt).toLocaleString()}`; const pill = fragment.querySelector('.history-status'); pill.textContent = item.status; pill.className = `pill ${item.status}`; historyList.appendChild(fragment); } } function renderSummaryCards(summary) { summaryCards.innerHTML = ''; const cards = [ { label: '最近运行数', value: String(summary.runCount || 0), meta: summary.latestFinishedAt ? `最后完成:${new Date(summary.latestFinishedAt).toLocaleString()}` : '暂无运行记录', }, ]; for (const item of summary.datasetTotals || []) { cards.push({ label: item.key, value: String(item.total ?? 0), meta: `新增 ${item.added ?? 0} · 更新 ${item.updated ?? 0} · 删除 ${item.removed ?? 0}`, }); } for (const card of cards) { const element = document.createElement('article'); element.className = 'stats-card'; element.innerHTML = `
${card.label}
${card.value}
${card.meta}
`; summaryCards.appendChild(element); } } async function fetchJson(url, init = {}) { const response = await fetch(url, { headers: { 'Content-Type': 'application/json' }, ...init, }); const payload = await response.json(); if (!response.ok || payload.ok === false) { throw new Error(payload.error || 'request_failed'); } return payload; } function collectEnvValues() { const values = { ...state.envValues }; document.querySelectorAll('[data-env-key]').forEach((input) => { const key = input.dataset.envKey; if (input.type === 'checkbox') { values[key] = input.checked ? 'true' : 'false'; } else { values[key] = input.value; } }); return values; } async function bootstrap() { const payload = await fetchJson('/api/bootstrap'); state.envSchema = payload.envSchema; state.envValues = payload.envValues; state.commands = payload.commands; state.runState = payload.runState; state.summaryCards = payload.summaryCards; state.schedulerMeta = payload.schedulerMeta; renderCommands(); renderConfig(); renderHistory(); renderRunState(); renderSummaryCards(payload.summaryCards || { datasetTotals: [], runCount: 0 }); renderSchedulerMeta(payload.schedulerMeta || {}); await refreshAnalytics(); startRunStream(); } async function refreshRuns() { state.runState = await fetchJson('/api/runs'); renderHistory(); renderRunState(); } async function refreshSummary() { const summary = await fetchJson('/api/summary'); state.summaryCards = summary; renderSummaryCards(summary); } async function refreshSchedulerMeta() { const payload = await fetchJson('/api/scheduler-meta'); state.schedulerMeta = payload; renderSchedulerMeta(payload); } async function refreshAnalytics() { const payload = await fetchJson('/api/analytics/messages'); renderAnalytics(payload); } function startRunStream() { if (eventSource) { eventSource.close(); } eventSource = new EventSource('/api/events'); eventSource.addEventListener('runState', (event) => { state.runState = JSON.parse(event.data); renderHistory(); renderRunState(); }); eventSource.onerror = () => { utilityFeedback.textContent = '日志流连接断开,正在等待重连…'; }; } saveEnvBtn.addEventListener('click', async () => { const values = collectEnvValues(); await fetchJson('/api/env', { method: 'POST', body: JSON.stringify({ values }) }); state.envValues = values; saveEnvBtn.textContent = '已保存'; setTimeout(() => { saveEnvBtn.textContent = '保存配置'; }, 1600); }); stopRunBtn.addEventListener('click', async () => { await fetchJson('/api/stop', { method: 'POST', body: '{}' }); await refreshRuns(); }); testDbBtn.addEventListener('click', async () => { utilityFeedback.textContent = '正在测试数据库连接…'; try { const result = await fetchJson('/api/test-db', { method: 'POST', body: '{}' }); if (!result.ok) { utilityFeedback.textContent = `数据库连接失败:${result.code || ''} ${result.message}`.trim(); showMaintenanceResult('error', `数据库连接失败\ncode: ${result.code || 'N/A'}\nmessage: ${result.message}\nhost: ${result.host || '-'}\ndatabase: ${result.database || '-'}`); return; } utilityFeedback.textContent = `数据库连接成功 · 服务时间 ${result.now}`; showMaintenanceResult('success', `数据库连接成功\n服务器时间:${result.now}`); } catch (error) { utilityFeedback.textContent = `数据库连接失败:${error.message}`; showMaintenanceResult('error', `数据库连接失败\n${error.message}`); } }); checkLoginBtn.addEventListener('click', async () => { utilityFeedback.textContent = '正在检查登录态…'; try { const result = await fetchJson('/api/login-state'); utilityFeedback.textContent = result.ok ? `登录态可用 · cookies=${result.cookies}` : `登录态异常:${result.reason || 'unknown'}`; showMaintenanceResult(result.ok ? 'success' : 'error', result.ok ? `登录态可用\nCookies 数量:${result.cookies}\n文件:${result.statePath}` : `登录态异常\n原因:${result.reason || 'unknown'}`); } catch (error) { utilityFeedback.textContent = `登录态检查失败:${error.message}`; showMaintenanceResult('error', `登录态检查失败\n${error.message}`); } }); clearCheckpointBtn.addEventListener('click', async () => { const confirmed = window.confirm('确定要清理消息的 checkpoint / history / delta / current 痕迹吗?'); if (!confirmed) return; utilityFeedback.textContent = '正在清理消息 checkpoint…'; try { const result = await fetchJson('/api/maintenance/clear-message-checkpoints', { method: 'POST', body: '{}' }); utilityFeedback.textContent = `已清理消息本地痕迹 · currentFileRemoved=${result.currentFileRemoved}`; showMaintenanceResult('success', `消息本地痕迹已清理\ncurrentFileRemoved=${result.currentFileRemoved}`); } catch (error) { utilityFeedback.textContent = `清理 checkpoint 失败:${error.message}`; showMaintenanceResult('error', `清理消息 checkpoint 失败\n${error.message}`); } }); clearMessageDataBtn.addEventListener('click', async () => { const confirmed = window.confirm('确定要清空当前 source_id 下的消息主表和账号明细表数据吗?'); if (!confirmed) return; utilityFeedback.textContent = '正在清空消息数据库…'; try { const result = await fetchJson('/api/maintenance/clear-message-data', { method: 'POST', body: '{}' }); utilityFeedback.textContent = `已删除主表 ${result.mainDeleted} 行,明细表 ${result.childDeleted} 行`; showMaintenanceResult('success', `消息数据库已清理\nsource_id=${result.sourceId}\n主表删除 ${result.mainDeleted} 行\n明细表删除 ${result.childDeleted} 行`); await refreshAnalytics(); await refreshSummary(); } catch (error) { utilityFeedback.textContent = `清空消息数据失败:${error.message}`; showMaintenanceResult('error', `清空消息数据库失败\n${error.message}`); } }); logFilterInput?.addEventListener('input', () => { renderRunState(); }); exportLogBtn?.addEventListener('click', () => { const text = runOutput.textContent || ''; const blob = new Blob([text], { type: 'text/plain;charset=utf-8' }); const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; link.download = `aps-console-log-${Date.now()}.txt`; link.click(); URL.revokeObjectURL(url); }); setInterval(() => { void refreshSummary(); void refreshAnalytics(); void refreshSchedulerMeta(); }, 10000); void bootstrap();