aps更新
This commit is contained in:
465
aliyun-sync/aliyun-aps-sync/web-console/assets/app.js
Normal file
465
aliyun-sync/aliyun-aps-sync/web-console/assets/app.js
Normal 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();
|
||||
Reference in New Issue
Block a user