aps更新

This commit is contained in:
ray
2026-06-18 13:03:21 +08:00
parent 4af439a6c9
commit 9bb5ab35af
13 changed files with 2623 additions and 104 deletions

View File

@@ -0,0 +1,465 @@
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 = `<strong>${item.classification}</strong><span>${item.cnt}</span>`;
messageClassificationStats.appendChild(row);
}
for (const item of payload.topAccounts || []) {
const row = document.createElement('div');
row.className = 'analytics-item';
row.innerHTML = `<strong>${item.account_id}</strong><span>${item.cnt}</span>`;
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 = `<div class="label">${label}</div><div class="value">${value || '-'}</div>`;
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 = `<div class="label">${event.track} · ${event.status}</div><div class="value">${new Date(event.at).toLocaleString()}<br />${detail}</div>`;
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 = `
<div>
<strong>${title}</strong>
<div>${subtitle}</div>
</div>
<input id="${id}" type="checkbox" />
`;
return wrapper;
}
function buildInputField(id, title, placeholder) {
const wrapper = document.createElement('label');
wrapper.className = 'field-label';
wrapper.innerHTML = `
<strong>${title}</strong>
<input id="${id}" type="text" placeholder="${placeholder}" />
`;
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 = `<h4>${groupName}</h4>`;
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 = `
<div>
<strong>${field.label}</strong>
<div>${field.key}</div>
</div>
<input data-env-key="${field.key}" type="checkbox" ${String(fieldValue(field.key)).toLowerCase() === 'true' ? 'checked' : ''} />
`;
grid.appendChild(wrapper);
continue;
}
const wrapper = document.createElement('label');
wrapper.className = 'field-label';
wrapper.innerHTML = `<strong>${field.label}</strong>`;
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 = `
<div class="label">${card.label}</div>
<div class="value">${card.value}</div>
<div class="meta">${card.meta}</div>
`;
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();

View File

@@ -0,0 +1,355 @@
:root {
--bg: #07111f;
--panel: rgba(10, 20, 36, 0.84);
--panel-strong: #0f1d33;
--line: rgba(148, 163, 184, 0.18);
--text: #edf4ff;
--muted: #90a3bf;
--brand: #61dafb;
--brand-strong: #2dd4bf;
--danger: #f87171;
--success: #34d399;
--warning: #fbbf24;
--shadow: 0 32px 80px rgba(0, 0, 0, 0.35);
--radius-xl: 28px;
--radius-lg: 20px;
--radius-md: 14px;
--font-display: 'Georgia', 'Times New Roman', serif;
--font-body: 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif;
}
* { box-sizing: border-box; }
html, body { margin: 0; min-height: 100%; background: radial-gradient(circle at top, #123456 0%, #07111f 42%, #020611 100%); color: var(--text); font-family: var(--font-body); }
button, input, select, textarea { font: inherit; }
button { cursor: pointer; }
.shell {
display: grid;
grid-template-columns: 280px minmax(0, 1fr);
min-height: 100vh;
}
.sidebar {
padding: 28px;
border-right: 1px solid var(--line);
background: linear-gradient(180deg, rgba(6, 11, 23, 0.96), rgba(6, 11, 23, 0.74));
backdrop-filter: blur(18px);
}
.brand { display: flex; gap: 16px; align-items: center; margin-bottom: 36px; }
.brand-badge {
width: 56px; height: 56px; border-radius: 18px;
display: grid; place-items: center;
background: linear-gradient(135deg, var(--brand), var(--brand-strong));
color: #02111d; font-weight: 800; letter-spacing: 0.1em;
box-shadow: 0 18px 40px rgba(45, 212, 191, 0.25);
}
.brand h1 { margin: 0; font-family: var(--font-display); font-size: 1.5rem; }
.brand p { margin: 4px 0 0; color: var(--muted); font-size: 0.92rem; }
.sidebar-nav { display: flex; flex-direction: column; gap: 10px; }
.nav-link {
text-align: left; border: 1px solid transparent; border-radius: 14px;
padding: 14px 16px; background: transparent; color: var(--muted);
transition: 160ms ease;
}
.nav-link.active,
.nav-link:hover {
background: rgba(97, 218, 251, 0.12);
border-color: rgba(97, 218, 251, 0.22);
color: var(--text);
}
.workspace { padding: 28px; display: flex; flex-direction: column; gap: 22px; }
.hero-card,
.stats-card,
.panel,
.log-card,
.command-card,
.history-item,
.config-group {
background: var(--panel);
border: 1px solid var(--line);
border-radius: var(--radius-xl);
box-shadow: var(--shadow);
backdrop-filter: blur(18px);
}
.hero-card {
padding: 28px 30px;
display: flex; justify-content: space-between; gap: 24px; align-items: center;
}
.eyebrow { color: var(--brand); font-size: 0.78rem; text-transform: uppercase; letter-spacing: 0.2em; }
.hero-card h2 { margin: 10px 0 12px; font-family: var(--font-display); font-size: 2rem; max-width: 760px; }
.hero-card p { margin: 0; color: var(--muted); max-width: 760px; line-height: 1.7; }
.hero-status {
min-width: 220px; align-self: stretch; border-radius: 22px;
background: linear-gradient(160deg, rgba(45, 212, 191, 0.18), rgba(96, 165, 250, 0.08));
border: 1px solid rgba(45, 212, 191, 0.2); padding: 20px; display: flex; align-items: center;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
gap: 16px;
}
.stats-card {
padding: 18px 20px;
border-radius: var(--radius-lg);
}
.stats-card .label {
color: var(--muted);
font-size: 0.82rem;
text-transform: uppercase;
letter-spacing: 0.16em;
}
.stats-card .value {
margin-top: 10px;
font-family: var(--font-display);
font-size: 1.9rem;
}
.stats-card .meta {
margin-top: 8px;
color: var(--muted);
font-size: 0.92rem;
}
.utility-strip {
display: flex;
gap: 12px;
align-items: center;
flex-wrap: wrap;
}
.analytics-strip {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: 16px;
}
.scheduler-strip {
display: flex;
}
.scheduler-card {
width: 100%;
padding: 20px;
border-radius: var(--radius-lg);
background: var(--panel);
border: 1px solid var(--line);
box-shadow: var(--shadow);
}
.scheduler-meta {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 12px;
margin-top: 16px;
}
.scheduler-meta .item {
padding: 12px 14px;
border-radius: 14px;
background: rgba(3, 10, 20, 0.72);
border: 1px solid rgba(148, 163, 184, 0.12);
}
.scheduler-meta .label {
color: var(--muted);
font-size: 0.78rem;
text-transform: uppercase;
letter-spacing: 0.12em;
}
.scheduler-meta .value {
margin-top: 8px;
color: var(--text);
line-height: 1.5;
word-break: break-word;
}
.analytics-card {
padding: 20px;
border-radius: var(--radius-lg);
background: var(--panel);
border: 1px solid var(--line);
box-shadow: var(--shadow);
}
.analytics-header h3 {
margin: 0;
font-family: var(--font-display);
}
.analytics-header p {
margin: 8px 0 0;
color: var(--muted);
}
.analytics-list {
display: grid;
gap: 10px;
margin-top: 18px;
}
.analytics-item {
display: flex;
justify-content: space-between;
gap: 12px;
padding: 12px 14px;
border-radius: 14px;
background: rgba(3, 10, 20, 0.72);
border: 1px solid rgba(148, 163, 184, 0.12);
}
.analytics-item strong {
color: var(--text);
}
.analytics-item span {
color: var(--muted);
font-variant-numeric: tabular-nums;
}
.utility-feedback {
color: var(--muted);
min-height: 22px;
}
.result-strip {
display: flex;
}
.result-card {
width: 100%;
padding: 16px 18px;
border-radius: var(--radius-lg);
background: rgba(3, 10, 20, 0.72);
border: 1px solid rgba(148, 163, 184, 0.14);
color: var(--text);
}
.result-card.hidden {
display: none;
}
.result-card.success {
border-color: rgba(52, 211, 153, 0.24);
background: rgba(6, 78, 59, 0.2);
}
.result-card.error {
border-color: rgba(248, 113, 113, 0.24);
background: rgba(127, 29, 29, 0.2);
}
.log-tools {
display: flex;
gap: 10px;
align-items: center;
flex-wrap: wrap;
}
.mini-toggle {
display: inline-flex;
gap: 8px;
align-items: center;
color: var(--muted);
font-size: 0.88rem;
}
.log-filter-input {
width: 220px;
}
.panel { display: none; padding: 24px; }
.panel.active { display: block; }
.panel-header { display: flex; justify-content: space-between; gap: 16px; align-items: center; margin-bottom: 20px; }
.panel-header h3 { margin: 0; font-family: var(--font-display); font-size: 1.5rem; }
.panel-header p { margin: 6px 0 0; color: var(--muted); }
.command-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(270px, 1fr)); gap: 18px; margin-bottom: 20px; }
.command-card { padding: 20px; border-radius: var(--radius-lg); }
.command-card-head { display: flex; justify-content: space-between; gap: 12px; margin-bottom: 16px; }
.command-title { margin: 0; font-size: 1.1rem; }
.command-description { margin: 8px 0 0; color: var(--muted); line-height: 1.6; min-height: 46px; }
.command-key { font-size: 0.78rem; color: var(--brand); text-transform: uppercase; letter-spacing: 0.16em; }
.command-options { display: grid; gap: 10px; margin-bottom: 16px; }
.field-label { display: grid; gap: 6px; color: var(--muted); font-size: 0.88rem; }
.field-label strong { color: var(--text); font-size: 0.94rem; }
input, select, textarea {
width: 100%; border-radius: 14px; border: 1px solid rgba(148, 163, 184, 0.2);
background: rgba(3, 10, 20, 0.72); color: var(--text); padding: 12px 14px;
}
.primary, .ghost {
border-radius: 14px; border: 1px solid transparent; padding: 12px 16px;
transition: 160ms ease; font-weight: 600;
}
.primary {
background: linear-gradient(135deg, var(--brand), var(--brand-strong));
color: #07111f;
}
.primary:hover { transform: translateY(-1px); }
.ghost {
background: rgba(148, 163, 184, 0.08);
border-color: rgba(148, 163, 184, 0.18);
color: var(--text);
}
.ghost.danger { color: #ffd7d7; border-color: rgba(248, 113, 113, 0.24); background: rgba(127, 29, 29, 0.2); }
.log-card { padding: 18px; border-radius: var(--radius-lg); }
.log-card-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
.log-output {
margin: 0; min-height: 280px; max-height: 460px; overflow: auto;
padding: 18px; border-radius: 18px; background: rgba(1, 6, 14, 0.85);
border: 1px solid rgba(148, 163, 184, 0.12); line-height: 1.55; white-space: pre-wrap;
}
.pill {
display: inline-flex; align-items: center; justify-content: center;
min-width: 92px; border-radius: 999px; padding: 8px 12px; font-size: 0.82rem; font-weight: 700;
text-transform: uppercase; letter-spacing: 0.08em;
}
.pill.idle { background: rgba(148, 163, 184, 0.16); color: var(--muted); }
.pill.running { background: rgba(251, 191, 36, 0.18); color: #fde68a; }
.pill.completed { background: rgba(52, 211, 153, 0.18); color: #bbf7d0; }
.pill.failed, .pill.stopped { background: rgba(248, 113, 113, 0.16); color: #fecaca; }
.config-groups { display: grid; gap: 18px; }
.config-group { padding: 20px; border-radius: var(--radius-lg); }
.config-group h4 { margin: 0 0 14px; font-size: 1.08rem; }
.config-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 14px; }
.checkbox-field {
display: flex; align-items: center; justify-content: space-between;
gap: 10px; padding: 14px 16px; border-radius: 14px;
background: rgba(3, 10, 20, 0.72); border: 1px solid rgba(148, 163, 184, 0.14);
}
.history-list { display: grid; gap: 12px; }
.history-item { padding: 16px 18px; border-radius: var(--radius-lg); display: flex; justify-content: space-between; gap: 16px; align-items: center; }
.history-title { margin: 0; font-size: 1rem; }
.history-meta { margin: 8px 0 0; color: var(--muted); font-size: 0.9rem; }
@media (max-width: 1100px) {
.shell { grid-template-columns: 1fr; }
.sidebar { border-right: none; border-bottom: 1px solid var(--line); }
.sidebar-nav { flex-direction: row; flex-wrap: wrap; }
.hero-card { flex-direction: column; align-items: flex-start; }
.hero-status { min-width: 0; width: 100%; }
}
@media (max-width: 720px) {
.workspace { padding: 18px; }
.panel, .hero-card, .sidebar { padding: 18px; }
.hero-card h2 { font-size: 1.5rem; }
.panel-header { flex-direction: column; align-items: flex-start; }
}

View File

@@ -0,0 +1,152 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>APS Console</title>
<link rel="stylesheet" href="/assets/styles.css" />
</head>
<body>
<div class="shell">
<aside class="sidebar">
<div class="brand">
<div class="brand-badge">APS</div>
<div>
<h1>Control Console</h1>
<p>配置、命令与日志统一管理</p>
</div>
</div>
<nav class="sidebar-nav">
<button data-tab="commands" class="nav-link active">运行命令</button>
<button data-tab="config" class="nav-link">环境配置</button>
<button data-tab="history" class="nav-link">运行记录</button>
</nav>
</aside>
<main class="workspace">
<section class="hero-card">
<div>
<span class="eyebrow">Local orchestration</span>
<h2>把 `.env`、启动命令和运行日志整合进一个页面</h2>
<p>用于本地管理 APS 抓取工具:保存配置、执行常用命令、实时查看输出,不再来回切换命令行和配置文件。</p>
</div>
<div class="hero-status" id="activeRunSummary">当前无运行任务</div>
</section>
<section class="stats-grid" id="summaryCards"></section>
<section class="scheduler-strip">
<article class="scheduler-card">
<div class="analytics-header">
<h3>定时任务专区</h3>
<p>专门展示 bills 与 hot 双轨调度的运行策略和配置。</p>
</div>
<div id="schedulerMeta" class="scheduler-meta"></div>
</article>
</section>
<section class="analytics-strip">
<article class="analytics-card">
<div class="analytics-header">
<h3>消息分类统计</h3>
<p>最近数据库中的消息分类分布</p>
</div>
<div id="messageClassificationStats" class="analytics-list"></div>
</article>
<article class="analytics-card">
<div class="analytics-header">
<h3>账号 ID 命中统计</h3>
<p>释放/预警类消息中出现最多的账号 ID</p>
</div>
<div id="messageAccountStats" class="analytics-list"></div>
</article>
</section>
<section class="utility-strip">
<button id="testDbBtn" class="ghost">测试数据库连接</button>
<button id="checkLoginBtn" class="ghost">检查登录态</button>
<button id="clearCheckpointBtn" class="ghost">清消息 Checkpoint</button>
<button id="clearMessageDataBtn" class="ghost danger">清消息数据库</button>
<div id="utilityFeedback" class="utility-feedback">等待检测…</div>
</section>
<section class="result-strip">
<div id="maintenanceResultCard" class="result-card hidden"></div>
</section>
<section class="panel active" data-panel="commands">
<div class="panel-header">
<div>
<h3>命令控制台</h3>
<p>常用同步任务一键启动,可选 resume / incremental 参数。</p>
</div>
<button id="stopRunBtn" class="ghost danger">停止当前任务</button>
</div>
<div class="command-grid" id="commandGrid"></div>
<div class="log-card">
<div class="log-card-header">
<h4>运行输出</h4>
<div class="log-tools">
<label class="mini-toggle">
<input id="autoScrollToggle" type="checkbox" checked />
<span>自动滚动</span>
</label>
<input id="logFilterInput" class="log-filter-input" type="text" placeholder="过滤日志关键词" />
<button id="exportLogBtn" class="ghost">导出日志</button>
<span id="runStatusPill" class="pill idle">idle</span>
</div>
</div>
<pre id="runOutput" class="log-output">等待任务启动…</pre>
</div>
</section>
<section class="panel" data-panel="config">
<div class="panel-header">
<div>
<h3>环境变量配置</h3>
<p>按分组查看和编辑 `.env`,保存后下次启动任务会读取新值。</p>
</div>
<button id="saveEnvBtn" class="primary">保存配置</button>
</div>
<div id="configGroups" class="config-groups"></div>
</section>
<section class="panel" data-panel="history">
<div class="panel-header">
<div>
<h3>运行历史</h3>
<p>保留最近任务状态与命令参数,便于快速回看。</p>
</div>
</div>
<div id="historyList" class="history-list"></div>
</section>
</main>
</div>
<template id="commandCardTemplate">
<article class="command-card">
<div class="command-card-head">
<div>
<h4 class="command-title"></h4>
<p class="command-description"></p>
</div>
<span class="command-key"></span>
</div>
<div class="command-options"></div>
<button class="primary run-command-btn">启动</button>
</article>
</template>
<template id="historyItemTemplate">
<article class="history-item">
<div>
<h4 class="history-title"></h4>
<p class="history-meta"></p>
</div>
<span class="pill history-status"></span>
</article>
</template>
<script type="module" src="/assets/app.js"></script>
</body>
</html>