fix: complete retention archive pagination and validation

This commit is contained in:
2026-09-23 09:46:36 +08:00
parent 2f95d9c45c
commit 2d8467e50c
9 changed files with 343 additions and 47 deletions

View File

@@ -63,9 +63,29 @@
// 配置只是已登录会话的默认队列,失败时业务 API 会返回明确错误。
}
}
const retentionUI = { busy: false, dirty: false, cursor: '', batch: 0, count: 0, done: false, download: '', pendingSave: null };
function syncRetentionControls() {
document.querySelectorAll('#retention-form input, #retention-form textarea, #retention-form select, #retention-form button').forEach(el => { el.disabled = retentionUI.busy; });
$('retention-preview-button').disabled = retentionUI.busy || retentionUI.dirty || !state.retention;
$('retention-export-button').disabled = retentionUI.busy || retentionUI.dirty || !state.retention?.enabled || retentionUI.done;
$('retention-export-button').textContent = retentionUI.cursor ? '导出下一批 ↓' : '导出首批 ↓';
$('retention-restart-button').disabled = retentionUI.busy || retentionUI.batch === 0;
text('retention-export-hint', retentionUI.dirty ? '有未保存的修改,请先保存策略后再预览或导出。' : !state.retention?.enabled ? '可预览候选;导出前请开启只读归档并保存策略。' : '每批最多 20 条根记录及关联历史;生成后可下载下一批。源数据始终保留。');
}
function resetRetentionExport() {
if (retentionUI.download) URL.revokeObjectURL(retentionUI.download);
Object.assign(retentionUI, { cursor: '', batch: 0, count: 0, done: false, download: '' });
$('retention-download').hidden = true;
$('retention-download').removeAttribute('href');
text('retention-export-status', '尚未导出');
text('retention-export-sha', '');
syncRetentionControls();
}
function renderRetention(policy) {
if (!policy) return;
const changed = !state.retention || String(state.retention.revision) !== String(policy.revision);
state.retention = policy;
retentionUI.dirty = false;
$('retention-enabled').checked = Boolean(policy.enabled);
$('retention-idempotency').value = policy.idempotency_days ?? '';
$('retention-task').value = policy.task_history_days ?? '';
@@ -75,16 +95,23 @@
$('retention-inbox').value = policy.inbox_history_days ?? '';
$('retention-audit').value = policy.audit_log_days ?? '';
text('retention-source', policy.source === 'database' ? '已保存配置' : '默认策略');
text('retention-revision', policy.revision ? String(policy.revision) : '默认');
text('retention-updated', policy.updated_at ? formatTime(policy.updated_at) : '尚未保存');
text('retention-revision', String(policy.revision || '0') === '0' ? '默认' : String(policy.revision));
text('retention-updated', policy.source === 'database' ? formatTime(policy.updated_at) : '尚未保存');
if (changed) {
resetRetentionExport();
text('retention-preview', '点击预览,查看当前已保存策略下的候选样本。');
}
syncRetentionControls();
}
async function loadRetention() {
if (retentionUI.busy || retentionUI.dirty) return;
retentionUI.busy = true; syncRetentionControls();
try {
const response = await request('/console/retention');
renderRetention(extractData(response));
} catch (error) {
$('retention-error').textContent = error.message || '保留策略读取失败';
}
text('retention-error', '');
} catch (error) { text('retention-error', error.message || '保留策略读取失败'); }
finally { retentionUI.busy = false; syncRetentionControls(); }
}
function retentionNumber(id) {
const value = Number($(id).value);
@@ -92,50 +119,72 @@
}
async function submitRetention(event) {
event.preventDefault();
if (retentionUI.busy || !state.retention) return;
const fields = { idempotency: 'retention-idempotency', task: 'retention-task', workflow: 'retention-workflow', event: 'retention-event', delivery: 'retention-delivery', inbox: 'retention-inbox', audit: 'retention-audit' };
const values = Object.fromEntries(Object.entries(fields).map(([key, id]) => [key, retentionNumber(id)]));
if (Object.values(values).some(value => !value)) { $('retention-error').textContent = '所有保留天数必须是 1 到 3650 的整数。'; return; }
if (Object.values(values).some(value => !value) || values.idempotency < 90) { text('retention-error', '幂等记录至少保留 90 天;其他窗口为 1 到 3650 的整数。'); return; }
const reason = $('retention-reason').value.trim();
if (!reason) { $('retention-error').textContent = '请填写变更原因。'; return; }
const button = $('retention-submit'); button.disabled = true; $('retention-error').textContent = '';
const current = state.retention || {};
if (!reason || /[\r\n]/.test(reason) || new TextEncoder().encode(reason).length > 1024) { text('retention-error', '请填写单行变更原因,最多 1024 字节(中文约 341 字)。'); return; }
const body = JSON.stringify({ expected_revision: String(state.retention.revision || 0), enabled: $('retention-enabled').checked, idempotency_days: values.idempotency, task_history_days: values.task, workflow_history_days: values.workflow, event_log_days: values.event, delivery_history_days: values.delivery, inbox_history_days: values.inbox, audit_log_days: values.audit, reason });
// 网络响应不明时,原样重试必须复用同一个操作键。
if (retentionUI.pendingSave?.body !== body) retentionUI.pendingSave = { body, key: operationKey('retention-policy') };
retentionUI.busy = true; syncRetentionControls(); text('retention-error', '');
try {
const response = await request('/console/retention', { method: 'PUT', headers: { 'Content-Type': 'application/json', 'Idempotency-Key': operationKey('retention-policy') }, body: JSON.stringify({ expected_revision: String(current.revision || 0), enabled: $('retention-enabled').checked, idempotency_days: values.idempotency, task_history_days: values.task, workflow_history_days: values.workflow, event_log_days: values.event, delivery_history_days: values.delivery, inbox_history_days: values.inbox, audit_log_days: values.audit, reason }) });
renderRetention(extractData(response)); $('retention-reason').value = ''; showToast('保留策略已保存,已写入管理审计');
} catch (error) { $('retention-error').textContent = error.message || '保留策略保存失败'; }
finally { button.disabled = false; }
const response = await request('/console/retention', { method: 'PUT', headers: { 'Content-Type': 'application/json', 'Idempotency-Key': retentionUI.pendingSave.key }, body });
renderRetention(extractData(response)); retentionUI.pendingSave = null;
$('retention-reason').value = ''; showToast('保留策略已保存,已写入管理审计');
} catch (error) { text('retention-error', `${error.message || '保留策略保存失败'}。网络故障可直接重试;版本冲突请刷新页面后核对。`); }
finally { retentionUI.busy = false; syncRetentionControls(); }
}
function retentionCutoff(value) {
const date = new Date(value);
return Number.isNaN(date.getTime()) ? String(value) : new Intl.DateTimeFormat('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', timeZone: 'UTC' }).format(date) + ' UTC';
}
function renderRetentionPreview(preview) {
const el = $('retention-preview');
const items = Array.isArray(preview?.items) ? preview.items : [];
if (!items.length) { el.innerHTML = '<span>当前策略没有可预览的候选数据。</span>'; return; }
if (!items.length) { el.textContent = '当前策略没有可预览的候选数据。'; return; }
const labels = { tasks: '任务历史', workflows: '流程历史', events: '事件日志', audit: '管理审计', deliveries: '交付历史', inbox: 'Inbox 历史', idempotency: '幂等记录' };
el.innerHTML = `<div class="retention-preview-head"><b>候选预览</b><small>仅展示样本,不会删除数据</small></div>${items.map(item => `<div class="retention-preview-row"><span>${escapeHTML(labels[item.kind] || item.kind)}</span><b>${escapeHTML(String(item.count || 0))} 条</b><small>截止 ${escapeHTML(formatTime(item.cutoff))}${item.more ? ' · 还有更多' : ''}</small></div>`).join('')}`;
el.innerHTML = `<div class="retention-preview-head"><b>候选预览 · 版本 ${escapeHTML(String(preview.policy?.revision || '0'))}</b><small>每类最多 ${escapeHTML(String(preview.sample_limit))} 条样本,不是全库总数</small></div>${items.map(item => `<div class="retention-preview-row"><span>${escapeHTML(labels[item.kind] || item.kind)}</span><b>${escapeHTML(String(item.count || 0))}${item.more ? '+' : ''} 条</b><small>截止 ${escapeHTML(retentionCutoff(item.cutoff))}${item.more ? ' · 还有更多' : ''}</small></div>`).join('')}`;
}
async function previewRetention() {
const button = $('retention-preview-button'); button.disabled = true; $('retention-error').textContent = '';
if (retentionUI.busy || retentionUI.dirty) return;
retentionUI.busy = true; syncRetentionControls(); text('retention-error', '');
try {
const response = await request('/console/retention/preview');
renderRetentionPreview(extractData(response));
} catch (error) { $('retention-error').textContent = error.message || '候选预览失败'; }
finally { button.disabled = false; }
const preview = extractData(response);
if (String(preview.policy?.revision) !== String(state.retention?.revision)) throw new Error('策略已被更新,请刷新页面核对后再预览');
renderRetentionPreview(preview);
} catch (error) { text('retention-error', error.message || '候选预览失败'); }
finally { retentionUI.busy = false; syncRetentionControls(); }
}
async function exportRetention() {
const button = $('retention-export-button'); button.disabled = true; $('retention-error').textContent = '';
if (retentionUI.busy || retentionUI.dirty || !state.retention?.enabled || retentionUI.done) return;
retentionUI.busy = true; syncRetentionControls(); text('retention-error', '');
try {
if (!window.crypto?.subtle) throw new Error('完整性校验需要 HTTPS 或本机 localhost 地址');
const kind = $('retention-kind').value;
const current = state.retention || {};
const response = await request('/console/retention/export', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ kind, limit: 20, expected_revision: String(current.revision || 0) }) });
const revision = String(state.retention.revision || 0);
const response = await request('/console/retention/export', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ kind, limit: 20, cursor: retentionUI.cursor, expected_revision: revision }) });
const snapshot = extractData(response);
const content = String(snapshot.content || '');
if (!content) throw new Error('服务端返回了空的归档快照');
const blob = new Blob([content], { type: 'application/json;charset=utf-8' });
const url = URL.createObjectURL(blob); const anchor = document.createElement('a');
anchor.href = url; anchor.download = `orbit-retention-${kind}-${new Date().toISOString().replace(/[:.]/g, '-')}.json`; anchor.click();
setTimeout(() => URL.revokeObjectURL(url), 1000);
showToast(`快照已下载 · SHA256 ${String(snapshot.sha256 || '').slice(0, 12)}…`);
} catch (error) { $('retention-error').textContent = error.message || '归档快照导出失败'; }
finally { button.disabled = false; }
if (snapshot.format_version !== 1 || typeof snapshot.content !== 'string' || !snapshot.content) throw new Error('服务端返回了无效的归档快照');
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(snapshot.content));
const sha = Array.from(new Uint8Array(digest), b => b.toString(16).padStart(2, '0')).join('');
if (sha !== snapshot.sha256) throw new Error('快照完整性校验失败,请重新导出本批次');
const data = JSON.parse(snapshot.content);
if (data.kind !== kind || String(data.policy?.revision) !== revision || data.source_retained !== true || !Number.isSafeInteger(data.root_count) || data.root_count < 0 || data.root_count > 20 || (data.next_cursor != null && typeof data.next_cursor !== 'string')) throw new Error('快照与当前导出请求不一致');
// 下载信封同时保留原始正文和摘要,离线校验时不要重新序列化 content。
const url = URL.createObjectURL(new Blob([JSON.stringify(snapshot, null, 2)], { type: 'application/json;charset=utf-8' }));
if (retentionUI.download) URL.revokeObjectURL(retentionUI.download);
Object.assign(retentionUI, { download: url, cursor: data.next_cursor || '', batch: retentionUI.batch + 1, count: retentionUI.count + data.root_count, done: !data.next_cursor });
const anchor = $('retention-download');
anchor.href = url; anchor.download = `orbit-retention-${kind}-r${revision}-batch${retentionUI.batch}-${new Date().toISOString().replace(/[:.]/g, '-')}.json`;
anchor.hidden = false; anchor.click();
text('retention-export-status', `第 ${retentionUI.batch} 批 · 本批 ${data.root_count} 条 · 累计 ${retentionUI.count} 条根记录 · ${retentionUI.done ? '已到末批' : '可继续下一批'} · SHA-256 校验通过`);
text('retention-export-sha', sha);
showToast('快照已生成,源数据保留');
} catch (error) { text('retention-error', error.message || '归档快照导出失败'); }
finally { retentionUI.busy = false; syncRetentionControls(); }
}
function renderConnectionSummary() {
text('summary-api', state.base || location.origin);
@@ -548,6 +597,9 @@
finally { state.loading = false; $('refresh-button').classList.remove('spinning'); }
}
async function logout() { await fetch('/console/auth/logout', { method: 'POST', credentials: 'same-origin' }); redirectToLogin(); }
$('retention-kind').addEventListener('change', resetRetentionExport);
$('retention-restart-button').addEventListener('click', resetRetentionExport);
$('retention-form').addEventListener('input', event => { if (event.target.matches('input, textarea')) { retentionUI.dirty = true; syncRetentionControls(); } });
$('logout-hero').addEventListener('click', logout); $('logout-button').addEventListener('click', logout); $('logout-inline').addEventListener('click', logout); $('refresh-button').addEventListener('click', refresh); $('retention-form').addEventListener('submit', submitRetention); $('retention-preview-button').addEventListener('click', previewRetention); $('retention-export-button').addEventListener('click', exportRetention);
$('close-task-drawer').addEventListener('click', closeTaskDrawer); $('task-drawer-backdrop').addEventListener('click', event => { if (event.target.id === 'task-drawer-backdrop') closeTaskDrawer(); }); $('action-cancel').addEventListener('click', closeActionForm); $('task-action-form').addEventListener('submit', submitAction); $('task-cancel-button').addEventListener('click', () => openActionForm('cancel')); $('task-retry-button').addEventListener('click', () => openActionForm('retry')); $('close-schedule-drawer').addEventListener('click', closeScheduleDrawer); $('schedule-drawer-backdrop').addEventListener('click', event => { if (event.target.id === 'schedule-drawer-backdrop') closeScheduleDrawer(); }); $('schedule-control-button').addEventListener('click', openScheduleAction); $('schedule-action-cancel').addEventListener('click', closeScheduleAction); $('schedule-action-form').addEventListener('submit', submitScheduleAction); $('close-workflow-drawer').addEventListener('click', closeWorkflowDrawer); $('workflow-drawer-backdrop').addEventListener('click', event => { if (event.target.id === 'workflow-drawer-backdrop') closeWorkflowDrawer(); }); $('workflow-control-button').addEventListener('click', () => openWorkflowAction(state.selectedWorkflow?.paused ? 'RESUME' : 'PAUSE')); $('workflow-cancel-button').addEventListener('click', () => openWorkflowAction('CANCEL')); $('workflow-action-cancel').addEventListener('click', closeWorkflowAction); $('workflow-action-form').addEventListener('submit', submitWorkflowAction); $('close-subscription-drawer').addEventListener('click', closeSubscriptionDrawer); $('subscription-drawer-backdrop').addEventListener('click', event => { if (event.target.id === 'subscription-drawer-backdrop') closeSubscriptionDrawer(); }); $('subscription-control-button').addEventListener('click', openSubscriptionAction); $('subscription-action-cancel').addEventListener('click', closeSubscriptionAction); $('subscription-action-form').addEventListener('submit', submitSubscriptionAction); $('subscription-retry-cancel').addEventListener('click', closeSubscriptionRetry); $('subscription-retry-form').addEventListener('submit', submitSubscriptionRetry); document.addEventListener('keydown', event => { if (event.key === 'Escape') { if (!$('task-action-form').hidden) closeActionForm(); else if (!$('schedule-action-form').hidden) closeScheduleAction(); else if (!$('workflow-action-form').hidden) closeWorkflowAction(); else if (!$('subscription-action-form').hidden) closeSubscriptionAction(); else if (!$('subscription-retry-form').hidden) closeSubscriptionRetry(); else if (!$('task-drawer-backdrop').hidden) closeTaskDrawer(); else if (!$('schedule-drawer-backdrop').hidden) closeScheduleDrawer(); else if (!$('workflow-drawer-backdrop').hidden) closeWorkflowDrawer(); else if (!$('subscription-drawer-backdrop').hidden) closeSubscriptionDrawer(); } });
$('workflow-recovery-actions').addEventListener('click', event => { const button = event.target.closest('[data-recovery-action]'); if (!button) return; let context = {}; try { context = JSON.parse(button.dataset.recoveryContext || '{}'); } catch { showToast('恢复按钮上下文无效,请重新读取流程'); return; } openWorkflowRecovery(button.dataset.recoveryAction, context); }); $('workflow-recovery-cancel').addEventListener('click', closeWorkflowRecovery); $('workflow-recovery-form').addEventListener('submit', submitWorkflowRecovery);