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 = `
`;
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();