feat: unify snapshot and checkpoint timeline

This commit is contained in:
Codex Agent 2025-11-03 11:30:38 +08:00
parent ec8adf98f1
commit 406fca7363
1 changed files with 833 additions and 63 deletions

View File

@ -296,6 +296,261 @@
font-size: 20px;
}
.checkpoint-section.snapshot-section h3::before {
content: '🕘';
}
.snapshot-description {
font-size: 13px;
color: #555;
margin-bottom: 12px;
line-height: 1.6;
}
.timeline-list {
display: flex;
flex-direction: column;
gap: 12px;
margin-top: 12px;
}
.timeline-item {
display: flex;
gap: 14px;
background: white;
border: 1px solid #dde1ff;
border-radius: 10px;
padding: 14px 18px;
position: relative;
box-shadow: 0 4px 18px rgba(102, 126, 234, 0.08);
}
.timeline-item-snapshot {
border-left: 4px solid #667eea;
}
.timeline-item-checkpoint {
border-left: 4px solid #ff9800;
}
.timeline-icon {
width: 40px;
height: 40px;
border-radius: 50%;
background: #eef2ff;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
flex-shrink: 0;
}
.timeline-item-checkpoint .timeline-icon {
background: #fff4e5;
}
.timeline-body {
flex: 1;
display: flex;
flex-direction: column;
gap: 8px;
}
.timeline-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
}
.timeline-title {
font-weight: 600;
color: #2f3e9e;
font-size: 15px;
}
.timeline-item-checkpoint .timeline-title {
color: #e65100;
}
.timeline-subtitle {
font-size: 14px;
color: #444;
font-weight: 500;
}
.timeline-time {
font-size: 12px;
color: #888;
white-space: nowrap;
}
.timeline-content {
font-size: 13px;
color: #333;
line-height: 1.6;
}
.timeline-meta {
font-size: 12px;
color: #666;
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.timeline-note {
font-size: 12px;
color: #555;
background: #f7f9ff;
padding: 8px 10px;
border-radius: 6px;
}
.timeline-stats {
font-size: 12px;
color: #333;
display: flex;
gap: 16px;
flex-wrap: wrap;
}
.timeline-actions {
display: flex;
gap: 8px;
margin-top: 6px;
}
.timeline-footer {
margin-top: 14px;
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 10px;
}
.snapshot-filters {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-bottom: 15px;
align-items: center;
}
.snapshot-filters input,
.snapshot-filters select {
flex: 1 1 200px;
padding: 8px 10px;
border: 1px solid #d0d5ff;
border-radius: 6px;
font-size: 13px;
}
.snapshot-filters select:disabled {
background: #f0f2ff;
color: #aaa;
}
.snapshot-filters .filter-actions {
display: flex;
gap: 8px;
}
.snapshot-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.snapshot-table th,
.snapshot-table td {
border-bottom: 1px solid #eceffb;
padding: 10px;
text-align: left;
vertical-align: top;
}
.snapshot-table th {
background: #f4f6ff;
color: #334;
font-weight: 600;
}
.snapshot-permit-name {
font-weight: 600;
color: #333;
}
.snapshot-permit-region {
font-size: 12px;
color: #666;
margin-top: 4px;
}
.snapshot-risk-text {
color: #333;
line-height: 1.5;
}
.snapshot-risk-meta {
margin-top: 6px;
font-size: 12px;
color: #777;
}
.snapshot-version-badge {
display: inline-flex;
align-items: center;
gap: 4px;
background: rgba(102, 126, 234, 0.12);
color: #4c51bf;
padding: 4px 8px;
border-radius: 12px;
font-size: 12px;
margin-bottom: 6px;
}
.snapshot-status-tag {
display: inline-block;
font-size: 12px;
padding: 4px 8px;
border-radius: 12px;
background: #e8f5e9;
color: #2e7d32;
}
.snapshot-editor {
font-weight: 600;
color: #333;
}
.snapshot-note {
font-size: 12px;
color: #555;
margin-top: 6px;
}
.snapshot-time {
font-size: 12px;
color: #888;
margin-top: 4px;
}
.snapshot-footer {
margin-top: 12px;
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 10px;
}
.snapshot-pagination button {
min-width: 90px;
}
.checkpoint-form {
margin-bottom: 20px;
}
@ -866,8 +1121,29 @@
let historyStack = []; // 历史记录栈
let currentRegion = null;
let currentTheme = null;
let currentPermit = null;
let pendingDangerOperation = null; // 待执行的危险操作
let currentPermit = null;
let pendingDangerOperation = null; // 待执行的危险操作
const permitRiskSnapshotState = {
limit: 10,
offset: 0,
total: 0,
regionFilter: '',
permitFilter: '',
editorFilter: '',
snapshots: [],
loading: false,
error: '',
regionOptions: [],
regionLoading: false,
regionError: '',
permitOptions: [],
permitLoading: false,
permitOptionsError: ''
};
let checkpointListCache = [];
let checkpointListLoading = false;
let checkpointListError = '';
// 步骤配置
const steps = {
@ -1266,23 +1542,36 @@
// 打开检查点模态窗口
async function openCheckpointModal() {
const modal = document.getElementById('checkpointModal');
const modalBody = document.getElementById('checkpointModalBody');
modalBody.innerHTML = '<div class="loading"></div>加载检查点列表...';
modal.classList.add('show');
try {
// 添加时间戳参数避免缓存
const response = await fetch(`/fs-ai-asistant/api/workflow/lawrisk/admin/checkpoints?t=${Date.now()}`);
const data = await response.json();
// 重置并显示加载状态
permitRiskSnapshotState.limit = 10;
permitRiskSnapshotState.offset = 0;
permitRiskSnapshotState.total = 0;
permitRiskSnapshotState.snapshots = [];
permitRiskSnapshotState.regionFilter = '';
permitRiskSnapshotState.permitFilter = '';
permitRiskSnapshotState.editorFilter = '';
permitRiskSnapshotState.loading = true;
permitRiskSnapshotState.error = '';
permitRiskSnapshotState.regionOptions = [];
permitRiskSnapshotState.permitOptions = [];
permitRiskSnapshotState.regionLoading = true;
permitRiskSnapshotState.regionError = '';
permitRiskSnapshotState.permitLoading = false;
permitRiskSnapshotState.permitOptionsError = '';
if (data.success) {
renderCheckpointManager(data.data.checkpoints);
} else {
modalBody.innerHTML = `<div class="error">加载检查点失败:${data.message}</div>`;
}
} catch (error) {
modalBody.innerHTML = `<div class="error">网络错误:${error.message}</div>`;
}
checkpointListCache = [];
checkpointListLoading = true;
checkpointListError = '';
renderCheckpointManager(checkpointListCache);
await Promise.all([
ensureRegionFilterOptions(false),
refreshPermitRiskSnapshots(false),
refreshCheckpointList(false)
]);
}
// 关闭检查点模态窗口
@ -1305,20 +1594,216 @@
// 渲染检查点管理界面
function renderCheckpointManager(checkpoints) {
if (Array.isArray(checkpoints)) {
checkpointListCache = checkpoints;
}
const modalBody = document.getElementById('checkpointModalBody');
if (!modalBody) return;
const snapshotState = permitRiskSnapshotState;
const checkpointList = checkpointListCache || [];
let html = '<div class="checkpoint-content">';
// 信息说明
html += `
<div class="checkpoint-section snapshot-section">
<h3>许可风险快照</h3>
<div class="snapshot-description">
每当编辑<strong>地区 + 许可事项 + 风险提示</strong>组合时,系统都会自动记录一条快照,方便追踪历史变更、责任人及备注。
可通过下方筛选快速定位特定许可的修改记录。
</div>
`;
const regionSelectDisabled = snapshotState.regionLoading ? 'disabled' : '';
const permitSelectDisabled = snapshotState.permitLoading || !snapshotState.regionFilter ? 'disabled' : '';
let regionOptionsHtml = '<option value="">全部地区</option>';
if (snapshotState.regionLoading) {
regionOptionsHtml += '<option value="__loading" disabled>正在加载地区...</option>';
} else {
snapshotState.regionOptions.forEach(option => {
const selected = option.id === snapshotState.regionFilter ? 'selected' : '';
regionOptionsHtml += `<option value="${escapeHtml(option.id)}" ${selected}>${escapeHtml(option.name)}</option>`;
});
}
let permitOptionsHtml = '<option value="">全部许可</option>';
if (!snapshotState.regionFilter) {
permitOptionsHtml += '<option value="__placeholder" disabled>请选择地区后再选择许可</option>';
} else if (snapshotState.permitLoading) {
permitOptionsHtml += '<option value="__loading" disabled>正在加载许可...</option>';
} else {
snapshotState.permitOptions.forEach(option => {
const selected = option.id === snapshotState.permitFilter ? 'selected' : '';
permitOptionsHtml += `<option value="${escapeHtml(option.id)}" ${selected}>${escapeHtml(option.name)}</option>`;
});
}
html += `
<form class="snapshot-filters" onsubmit="return applySnapshotFilters(event)">
<select id="snapshotFilterRegion" ${regionSelectDisabled} onchange="handleSnapshotRegionChange(this.value)">
${regionOptionsHtml}
</select>
<select id="snapshotFilterPermit" ${permitSelectDisabled} onchange="handleSnapshotPermitChange(this.value)">
${permitOptionsHtml}
</select>
<input type="text" id="snapshotFilterEditor" placeholder="编辑人(可选)" value="${escapeHtml(snapshotState.editorFilter)}">
<div class="filter-actions">
<button type="submit" class="btn btn-primary btn-sm"><span>🔍</span> 筛选</button>
<button type="button" class="btn btn-warning btn-sm" onclick="resetSnapshotFilters()">重置</button>
<button type="button" class="btn btn-primary btn-sm" onclick="refreshPermitRiskSnapshots()"><span>🔄</span> 刷新</button>
</div>
</form>
`;
if (snapshotState.regionError) {
html += `<div class="error" style="margin-top: 8px;">地区列表加载失败:${escapeHtml(snapshotState.regionError)}</div>`;
}
if (snapshotState.permitOptionsError) {
html += `<div class="error" style="margin-top: 8px;">许可列表加载失败:${escapeHtml(snapshotState.permitOptionsError)}</div>`;
}
const timelineItems = buildTimelineItems(snapshotState.snapshots, checkpointList);
const totalSnapshots = snapshotState.total || snapshotState.snapshots.length;
const hasTimelineData = timelineItems.length > 0;
const loadingInProgress = (snapshotState.loading && snapshotState.snapshots.length === 0) && (checkpointListLoading && checkpointList.length === 0);
if (loadingInProgress) {
html += '<div class="loading"></div>加载许可风险快照与检查点...';
} else if (!hasTimelineData && (snapshotState.error || checkpointListError)) {
const errorMsg = snapshotState.error || checkpointListError || '暂无数据';
html += `<div class="error">${escapeHtml(errorMsg)}</div>`;
} else if (!hasTimelineData) {
html += '<p style="color: #999; text-align: center; padding: 20px;">暂无快照或检查点记录</p>';
} else {
const snapshotStart = totalSnapshots ? snapshotState.offset + 1 : 0;
const snapshotEnd = totalSnapshots ? Math.min(snapshotState.offset + snapshotState.limit, totalSnapshots) : 0;
const disablePrev = snapshotState.offset === 0 ? 'disabled' : '';
const disableNext = snapshotState.offset + snapshotState.limit >= totalSnapshots ? 'disabled' : '';
html += '<div class="timeline-list">';
timelineItems.forEach(entry => {
if (entry.type === 'snapshot') {
const item = entry.raw;
const permitName = escapeHtml(item.permit_name || '-');
const regionName = escapeHtml(item.region_name || '-');
const riskFull = item.risk_content || '';
const riskPreview = escapeHtml(truncateText(riskFull, 160));
const legalSegments = [];
if (item.legal_basis) {
legalSegments.push(`📕 ${escapeHtml(item.legal_basis)}`);
}
if (item.document_no) {
legalSegments.push(`📄 ${escapeHtml(item.document_no)}`);
}
const legalHtml = legalSegments.length ? `<div class="timeline-meta" style="gap:6px;">${legalSegments.join('<span>|</span>')}</div>` : '';
const statusHtml = item.permit_status ? `<span class="snapshot-status-tag">${escapeHtml(item.permit_status)}</span>` : '';
const changeSummary = item.change_summary ? `<div class="timeline-note">备注:${escapeHtml(item.change_summary)}</div>` : '';
const editorName = escapeHtml(item.edited_by || '—');
const timeDisplay = escapeHtml(entry.timeText || '');
html += `
<div class="timeline-item timeline-item-snapshot">
<div class="timeline-icon">📝</div>
<div class="timeline-body">
<div class="timeline-header">
<div class="timeline-title">风险快照 · 版本 ${escapeHtml(String(item.version || 0))}</div>
<div class="timeline-time">${timeDisplay}</div>
</div>
<div class="timeline-subtitle">${permitName}<span style="margin-left: 6px; color: #666;">${regionName}</span></div>
<div class="timeline-content" title="${escapeHtml(riskFull)}">${riskPreview || '—'}</div>
${legalHtml}
<div class="timeline-meta">
${statusHtml}
<span>编辑人:${editorName}</span>
<span>风险ID${escapeHtml(item.risk_id || '-')}</span>
</div>
${changeSummary}
</div>
</div>
`;
} else if (entry.type === 'checkpoint') {
const item = entry.raw;
const timeDisplay = escapeHtml(entry.timeText || '');
const description = item.description ? escapeHtml(item.description) : '无描述';
const tableCount = Object.keys(item.table_counts || {}).length;
const totalRows = item.total_rows || 0;
const checkpointIdRaw = item.checkpoint_id || '';
const checkpointIdDisplay = escapeHtml(checkpointIdRaw);
const checkpointIdJs = checkpointIdRaw.replace(/'/g, "\\'");
html += `
<div class="timeline-item timeline-item-checkpoint">
<div class="timeline-icon">🗂️</div>
<div class="timeline-body">
<div class="timeline-header">
<div class="timeline-title">数据库检查点</div>
<div class="timeline-time">${timeDisplay}</div>
</div>
<div class="timeline-subtitle">${checkpointIdDisplay}</div>
<div class="timeline-content">
<div class="timeline-note">${description}</div>
<div class="timeline-stats">
<span>📊 行数:<strong>${totalRows}</strong></span>
<span>📋 表数:<strong>${tableCount}</strong></span>
</div>
</div>
<div class="timeline-actions">
<button class="btn btn-danger btn-sm" onclick="confirmRestoreCheckpoint('${checkpointIdJs}')">
<span>🔄</span> 恢复
</button>
<button class="btn btn-warning btn-sm" onclick="deleteCheckpoint('${checkpointIdJs}')">
<span>🗑️</span> 删除
</button>
</div>
</div>
</div>
`;
}
});
html += '</div>';
const warningMessages = [];
if (snapshotState.error) {
warningMessages.push(`快照加载提示:${escapeHtml(snapshotState.error)}`);
}
if (checkpointListError) {
warningMessages.push(`检查点加载提示:${escapeHtml(checkpointListError)}`);
}
if (warningMessages.length) {
html += `<div class="error" style="margin-top: 10px;">${warningMessages.join('<br>')}</div>`;
}
const snapshotSummaryText = totalSnapshots
? `快照 ${snapshotStart}-${snapshotEnd} / ${totalSnapshots}`
: '快照 0 / 0';
const checkpointSummaryText = `检查点 ${checkpointList.length} 个`;
html += `
<div class="timeline-footer">
<div class="snapshot-count">${snapshotSummaryText}${checkpointSummaryText}</div>
<div class="snapshot-pagination">
<button class="btn btn-warning btn-sm" onclick="changeSnapshotPage(-1)" ${disablePrev}>上一页</button>
<button class="btn btn-warning btn-sm" onclick="changeSnapshotPage(1)" ${disableNext}>下一页</button>
</div>
</div>
`;
}
html += '</div>';
html += `
<div class="checkpoint-info" style="background: #e3f2fd; padding: 15px; border-radius: 6px; margin-bottom: 20px; border-left: 4px solid #2196f3;">
<div style="color: #1976d2; font-size: 13px; line-height: 1.6;">
<strong>💡 说明:</strong>检查点文件保存在服务器的 data/checkpoints/ 目录中,重启应用后检查点不会丢失。
每个检查点包含完整的数据库备份,可以随时恢复。
<strong>💡 说明:</strong>数据库检查点文件保存在服务器的 data/checkpoints/ 目录中,重启应用后不会丢失。
每个检查点包含完整的数据库备份,可用于快速回滚
</div>
</div>
`;
// 创建检查点表单
html += `
<div class="checkpoint-section">
<h3>创建新检查点</h3>
@ -1334,47 +1819,247 @@
</div>
`;
// 检查点列表
html += '<div class="checkpoint-section">';
html += '<h3>已有检查点</h3>';
if (checkpoints.length === 0) {
html += '<p style="color: #999; text-align: center; padding: 20px;">暂无检查点</p>';
} else {
html += '<div class="checkpoint-list">';
checkpoints.forEach(cp => {
const formattedTime = formatTimestamp(cp.timestamp);
html += `
<div class="checkpoint-item">
<div class="checkpoint-header">
<span class="checkpoint-id">${cp.checkpoint_id}</span>
<span class="checkpoint-timestamp">${formattedTime}</span>
</div>
${cp.description ? `<div class="checkpoint-description">${cp.description}</div>` : ''}
<div class="checkpoint-stats">
<span>📊 总行数:<strong>${cp.total_rows}</strong></span>
<span>📋 表数:<strong>${Object.keys(cp.table_counts).length}</strong></span>
</div>
<div class="checkpoint-actions">
<button class="btn btn-danger btn-sm" onclick="confirmRestoreCheckpoint('${cp.checkpoint_id}')">
<span>🔄</span> 恢复
</button>
<button class="btn btn-warning btn-sm" onclick="deleteCheckpoint('${cp.checkpoint_id}')">
<span>🗑️</span> 删除
</button>
</div>
</div>
`;
});
html += '</div>';
}
html += '</div>';
html += '</div>';
modalBody.innerHTML = html;
}
async function ensureRegionFilterOptions(showSpinner = true) {
permitRiskSnapshotState.regionLoading = true;
permitRiskSnapshotState.regionError = '';
if (showSpinner) {
renderCheckpointManager(checkpointListCache);
}
try {
const response = await fetch(`/fs-ai-asistant/api/workflow/lawrisk/admin/regions?t=${Date.now()}`);
const data = await parseJsonResponse(response);
if (data && data.success) {
permitRiskSnapshotState.regionOptions = (data.data.regions || []).map(region => ({
id: region.id,
name: region.name
}));
permitRiskSnapshotState.regionError = '';
} else {
permitRiskSnapshotState.regionOptions = [];
permitRiskSnapshotState.regionError = data && data.message ? data.message : `地区列表加载失败HTTP ${response.status}`;
}
} catch (error) {
permitRiskSnapshotState.regionOptions = [];
permitRiskSnapshotState.regionError = error.message || '地区列表加载失败';
} finally {
permitRiskSnapshotState.regionLoading = false;
renderCheckpointManager(checkpointListCache);
}
}
async function fetchPermitOptionsForRegion(regionId, showSpinner = true) {
permitRiskSnapshotState.permitLoading = true;
permitRiskSnapshotState.permitOptions = [];
permitRiskSnapshotState.permitOptionsError = '';
if (showSpinner) {
renderCheckpointManager(checkpointListCache);
}
if (!regionId) {
permitRiskSnapshotState.permitLoading = false;
renderCheckpointManager(checkpointListCache);
return;
}
try {
const response = await fetch(`/fs-ai-asistant/api/workflow/lawrisk/getPermits?region=${regionId}&t=${Date.now()}`);
const data = await parseJsonResponse(response);
if (data && data.success) {
const permits = data.data && data.data.permits ? data.data.permits : [];
permitRiskSnapshotState.permitOptions = permits.map(permit => ({
id: permit.id,
name: permit.name
}));
permitRiskSnapshotState.permitOptionsError = '';
} else {
permitRiskSnapshotState.permitOptions = [];
permitRiskSnapshotState.permitOptionsError = data && data.message ? data.message : `许可列表加载失败HTTP ${response.status}`;
}
} catch (error) {
permitRiskSnapshotState.permitOptions = [];
permitRiskSnapshotState.permitOptionsError = error.message || '许可列表加载失败';
} finally {
permitRiskSnapshotState.permitLoading = false;
renderCheckpointManager(checkpointListCache);
}
}
async function handleSnapshotRegionChange(value) {
let normalizedValue = (value || '').trim();
if (normalizedValue === '__loading') {
normalizedValue = '';
}
permitRiskSnapshotState.regionFilter = normalizedValue;
permitRiskSnapshotState.permitFilter = '';
permitRiskSnapshotState.offset = 0;
permitRiskSnapshotState.permitOptions = [];
permitRiskSnapshotState.permitOptionsError = '';
renderCheckpointManager(checkpointListCache);
if (normalizedValue) {
await fetchPermitOptionsForRegion(normalizedValue, true);
}
}
function handleSnapshotPermitChange(value) {
let normalizedValue = (value || '').trim();
if (normalizedValue === '__placeholder' || normalizedValue === '__loading') {
normalizedValue = '';
}
permitRiskSnapshotState.permitFilter = normalizedValue;
}
async function refreshPermitRiskSnapshots(showSpinner = true) {
if (showSpinner) {
permitRiskSnapshotState.loading = true;
permitRiskSnapshotState.error = '';
renderCheckpointManager(checkpointListCache);
} else {
permitRiskSnapshotState.loading = true;
permitRiskSnapshotState.error = '';
}
const params = new URLSearchParams({
limit: permitRiskSnapshotState.limit,
offset: permitRiskSnapshotState.offset
});
if (permitRiskSnapshotState.regionFilter) {
params.append('region_id', permitRiskSnapshotState.regionFilter);
}
if (permitRiskSnapshotState.permitFilter) {
params.append('permit_id', permitRiskSnapshotState.permitFilter);
}
if (permitRiskSnapshotState.editorFilter) {
params.append('edited_by', permitRiskSnapshotState.editorFilter);
}
params.append('t', Date.now());
try {
const response = await fetch(`/fs-ai-asistant/api/workflow/lawrisk/admin/permit-risk-snapshots?${params.toString()}`);
const data = await parseJsonResponse(response);
if (data && data.success) {
permitRiskSnapshotState.snapshots = data.data.snapshots || [];
const pagination = data.data.pagination || {};
permitRiskSnapshotState.total = pagination.total ?? permitRiskSnapshotState.snapshots.length;
permitRiskSnapshotState.limit = pagination.limit ?? permitRiskSnapshotState.limit;
permitRiskSnapshotState.offset = pagination.offset ?? permitRiskSnapshotState.offset;
permitRiskSnapshotState.error = '';
} else {
permitRiskSnapshotState.snapshots = [];
permitRiskSnapshotState.total = 0;
permitRiskSnapshotState.error = data && data.message ? data.message : `加载失败HTTP ${response.status}`;
}
} catch (error) {
permitRiskSnapshotState.snapshots = [];
permitRiskSnapshotState.total = 0;
permitRiskSnapshotState.error = error.message || '网络错误';
} finally {
permitRiskSnapshotState.loading = false;
renderCheckpointManager(checkpointListCache);
}
}
async function refreshCheckpointList(showSpinner = true) {
if (showSpinner) {
checkpointListLoading = true;
checkpointListError = '';
renderCheckpointManager(checkpointListCache);
} else {
checkpointListLoading = true;
checkpointListError = '';
}
try {
const response = await fetch(`/fs-ai-asistant/api/workflow/lawrisk/admin/checkpoints?t=${Date.now()}`);
const data = await parseJsonResponse(response);
if (data && data.success) {
checkpointListCache = data.data.checkpoints || [];
checkpointListError = '';
} else {
checkpointListError = data && data.message ? data.message : `加载失败HTTP ${response.status}`;
}
} catch (error) {
checkpointListError = error.message || '网络错误';
} finally {
checkpointListLoading = false;
renderCheckpointManager(checkpointListCache);
}
}
function applySnapshotFilters(event) {
if (event) {
event.preventDefault();
}
const regionInput = document.getElementById('snapshotFilterRegion');
const permitInput = document.getElementById('snapshotFilterPermit');
const editorInput = document.getElementById('snapshotFilterEditor');
let regionValue = regionInput ? regionInput.value.trim() : '';
if (regionValue === '__loading') {
regionValue = '';
}
let permitValue = permitInput ? permitInput.value.trim() : '';
if (permitValue === '__placeholder' || permitValue === '__loading') {
permitValue = '';
}
permitRiskSnapshotState.regionFilter = regionValue;
permitRiskSnapshotState.permitFilter = permitValue;
permitRiskSnapshotState.editorFilter = editorInput ? editorInput.value.trim() : '';
permitRiskSnapshotState.offset = 0;
refreshPermitRiskSnapshots();
return false;
}
function resetSnapshotFilters() {
permitRiskSnapshotState.regionFilter = '';
permitRiskSnapshotState.permitFilter = '';
permitRiskSnapshotState.editorFilter = '';
permitRiskSnapshotState.offset = 0;
permitRiskSnapshotState.permitOptions = [];
permitRiskSnapshotState.permitOptionsError = '';
const regionInput = document.getElementById('snapshotFilterRegion');
const permitInput = document.getElementById('snapshotFilterPermit');
const editorInput = document.getElementById('snapshotFilterEditor');
if (regionInput) regionInput.value = '';
if (permitInput) permitInput.value = '';
if (editorInput) editorInput.value = '';
refreshPermitRiskSnapshots();
}
async function changeSnapshotPage(direction) {
if (permitRiskSnapshotState.loading) {
return;
}
const step = direction > 0 ? permitRiskSnapshotState.limit : -permitRiskSnapshotState.limit;
let nextOffset = permitRiskSnapshotState.offset + step;
if (direction < 0) {
nextOffset = Math.max(0, nextOffset);
} else if (permitRiskSnapshotState.offset + permitRiskSnapshotState.limit >= permitRiskSnapshotState.total) {
return;
}
permitRiskSnapshotState.offset = Math.max(0, nextOffset);
await refreshPermitRiskSnapshots();
}
// 创建检查点
async function createCheckpoint() {
const description = document.getElementById('checkpointDescription').value;
@ -1395,8 +2080,7 @@
if (data.success) {
document.getElementById('checkpointDescription').value = '';
// 重新加载检查点列表
await openCheckpointModal();
await refreshCheckpointList();
// 显示成功消息
setTimeout(() => {
alert(`✅ 检查点创建成功!\n\nID: ${data.data.checkpoint_id}\n备份了 ${data.data.total_rows} 行数据`);
@ -1443,11 +2127,13 @@
closeRestoreProgressModal();
if (data.success) {
// 显示成功消息
await Promise.all([
refreshCheckpointList(),
refreshPermitRiskSnapshots()
]);
setTimeout(() => {
alert(`✅ 检查点恢复成功!\n\n恢复了 ${data.data.total_rows_restored} 行数据,覆盖了 ${data.data.tables_restored} 个表。`);
}, 100);
await openCheckpointModal();
} else {
setTimeout(() => {
alert(`❌ 恢复失败:${data.message}`);
@ -1502,10 +2188,10 @@
const response = await fetch(`/fs-ai-asistant/api/workflow/lawrisk/admin/checkpoints/${checkpointId}`, {
method: 'DELETE'
});
const data = await response.json();
const data = await parseJsonResponse(response);
if (data.success) {
await openCheckpointModal();
await refreshCheckpointList();
setTimeout(() => {
alert(`✅ 检查点已删除`);
}, 100);
@ -1521,9 +2207,93 @@
}
}
function truncateText(text, maxLength = 120) {
if (text === null || text === undefined) return '';
const str = String(text);
return str.length > maxLength ? `${str.slice(0, maxLength)}…` : str;
}
function formatIsoDatetime(value) {
if (!value) return '';
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return value;
}
const pad = (num) => String(num).padStart(2, '0');
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
}
function escapeHtml(str) {
if (str === null || str === undefined) return '';
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
async function parseJsonResponse(response) {
const contentType = response.headers.get('content-type') || '';
if (!contentType.includes('application/json')) {
await response.text();
return {
success: false,
message: `服务器返回非 JSON 格式HTTP ${response.status}`
};
}
try {
return await response.json();
} catch (err) {
return {
success: false,
message: err.message || '响应解析失败'
};
}
}
function checkpointTimestampToIso(timestamp) {
if (!timestamp) return '';
const match = String(timestamp).match(/(\d{4})(\d{2})(\d{2})_(\d{2})(\d{2})(\d{2})/);
if (!match) return '';
return `${match[1]}-${match[2]}-${match[3]}T${match[4]}:${match[5]}:${match[6]}`;
}
function buildTimelineItems(snapshotItems, checkpointItems) {
const items = [];
(snapshotItems || []).forEach(item => {
const iso = item.created_at || '';
const timeValue = Date.parse(iso) || 0;
items.push({
type: 'snapshot',
timeValue,
timeText: iso ? formatIsoDatetime(iso) : '',
raw: item
});
});
(checkpointItems || []).forEach(item => {
const iso = checkpointTimestampToIso(item.timestamp);
const timeValue = iso ? Date.parse(iso) : 0;
items.push({
type: 'checkpoint',
timeValue,
timeText: formatTimestamp(item.timestamp),
raw: item,
iso
});
});
items.sort((a, b) => b.timeValue - a.timeValue);
return items;
}
// 格式化时间戳
function formatTimestamp(timestamp) {
if (!timestamp) return '';
if (!timestamp || timestamp.length < 15) {
return timestamp || '';
}
const year = timestamp.substring(0, 4);
const month = timestamp.substring(4, 6);
const day = timestamp.substring(6, 8);