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();
|
||||
355
aliyun-sync/aliyun-aps-sync/web-console/assets/styles.css
Normal file
355
aliyun-sync/aliyun-aps-sync/web-console/assets/styles.css
Normal 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; }
|
||||
}
|
||||
152
aliyun-sync/aliyun-aps-sync/web-console/index.html
Normal file
152
aliyun-sync/aliyun-aps-sync/web-console/index.html
Normal 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>
|
||||
Reference in New Issue
Block a user