diff --git a/static/db_admin.html b/static/db_admin.html index fdcea5f..a668576 100644 --- a/static/db_admin.html +++ b/static/db_admin.html @@ -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 = '
加载检查点列表...'; 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 = `
加载检查点失败:${data.message}
`; - } - } catch (error) { - modalBody.innerHTML = `
网络错误:${error.message}
`; - } + 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 = '
'; - // 信息说明 + html += ` +
+

许可风险快照

+
+ 每当编辑地区 + 许可事项 + 风险提示组合时,系统都会自动记录一条快照,方便追踪历史变更、责任人及备注。 + 可通过下方筛选快速定位特定许可的修改记录。 +
+ `; + + const regionSelectDisabled = snapshotState.regionLoading ? 'disabled' : ''; + const permitSelectDisabled = snapshotState.permitLoading || !snapshotState.regionFilter ? 'disabled' : ''; + let regionOptionsHtml = ''; + + if (snapshotState.regionLoading) { + regionOptionsHtml += ''; + } else { + snapshotState.regionOptions.forEach(option => { + const selected = option.id === snapshotState.regionFilter ? 'selected' : ''; + regionOptionsHtml += ``; + }); + } + + let permitOptionsHtml = ''; + if (!snapshotState.regionFilter) { + permitOptionsHtml += ''; + } else if (snapshotState.permitLoading) { + permitOptionsHtml += ''; + } else { + snapshotState.permitOptions.forEach(option => { + const selected = option.id === snapshotState.permitFilter ? 'selected' : ''; + permitOptionsHtml += ``; + }); + } + + html += ` +
+ + + +
+ + + +
+
+ `; + + if (snapshotState.regionError) { + html += `
地区列表加载失败:${escapeHtml(snapshotState.regionError)}
`; + } + if (snapshotState.permitOptionsError) { + html += `
许可列表加载失败:${escapeHtml(snapshotState.permitOptionsError)}
`; + } + + 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 += '
加载许可风险快照与检查点...'; + } else if (!hasTimelineData && (snapshotState.error || checkpointListError)) { + const errorMsg = snapshotState.error || checkpointListError || '暂无数据'; + html += `
${escapeHtml(errorMsg)}
`; + } else if (!hasTimelineData) { + html += '

暂无快照或检查点记录

'; + } 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 += '
'; + + 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 ? `
${legalSegments.join('|')}
` : ''; + const statusHtml = item.permit_status ? `${escapeHtml(item.permit_status)}` : ''; + const changeSummary = item.change_summary ? `
备注:${escapeHtml(item.change_summary)}
` : ''; + const editorName = escapeHtml(item.edited_by || '—'); + const timeDisplay = escapeHtml(entry.timeText || ''); + + html += ` +
+
📝
+
+
+
风险快照 · 版本 ${escapeHtml(String(item.version || 0))}
+
${timeDisplay}
+
+
${permitName}(${regionName})
+
${riskPreview || '—'}
+ ${legalHtml} +
+ ${statusHtml} + 编辑人:${editorName} + 风险ID:${escapeHtml(item.risk_id || '-')} +
+ ${changeSummary} +
+
+ `; + } 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 += ` +
+
🗂️
+
+
+
数据库检查点
+
${timeDisplay}
+
+
${checkpointIdDisplay}
+
+
${description}
+
+ 📊 行数:${totalRows} + 📋 表数:${tableCount} +
+
+
+ + +
+
+
+ `; + } + }); + + html += '
'; + + const warningMessages = []; + if (snapshotState.error) { + warningMessages.push(`快照加载提示:${escapeHtml(snapshotState.error)}`); + } + if (checkpointListError) { + warningMessages.push(`检查点加载提示:${escapeHtml(checkpointListError)}`); + } + if (warningMessages.length) { + html += `
${warningMessages.join('
')}
`; + } + + const snapshotSummaryText = totalSnapshots + ? `快照 ${snapshotStart}-${snapshotEnd} / ${totalSnapshots}` + : '快照 0 / 0'; + const checkpointSummaryText = `检查点 ${checkpointList.length} 个`; + + html += ` + + `; + } + + html += '
'; + html += `
- 💡 说明:检查点文件保存在服务器的 data/checkpoints/ 目录中,重启应用后检查点不会丢失。 - 每个检查点包含完整的数据库备份,可以随时恢复。 + 💡 说明:数据库检查点文件保存在服务器的 data/checkpoints/ 目录中,重启应用后不会丢失。 + 每个检查点包含完整的数据库备份,可用于快速回滚。
`; - // 创建检查点表单 html += `

创建新检查点

@@ -1334,47 +1819,247 @@
`; - // 检查点列表 - html += '
'; - html += '

已有检查点

'; - - if (checkpoints.length === 0) { - html += '

暂无检查点

'; - } else { - html += '
'; - checkpoints.forEach(cp => { - const formattedTime = formatTimestamp(cp.timestamp); - html += ` -
-
- ${cp.checkpoint_id} - ${formattedTime} -
- ${cp.description ? `
${cp.description}
` : ''} -
- 📊 总行数:${cp.total_rows} - 📋 表数:${Object.keys(cp.table_counts).length} -
-
- - -
-
- `; - }); - html += '
'; - } - - html += '
'; html += '
'; 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, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } + + 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);