feat: 优化风险快照批次展示

This commit is contained in:
Codex Agent 2025-11-03 16:41:35 +08:00
parent 406fca7363
commit a5bc3c41b7
1 changed files with 435 additions and 30 deletions

View File

@ -421,6 +421,48 @@
margin-top: 6px;
}
.snapshot-detail-list {
margin-top: 12px;
display: none;
border-left: 2px solid #e0e0e0;
padding-left: 16px;
}
.snapshot-detail-list.expanded {
display: block;
}
.snapshot-detail-item {
background: #ffffff;
border: 1px solid #e5e7eb;
border-radius: 6px;
padding: 10px 12px;
margin-bottom: 10px;
box-shadow: 0 1px 2px rgba(0,0,0,0.05);
}
.snapshot-detail-item:last-child {
margin-bottom: 0;
}
.snapshot-detail-header {
font-size: 13px;
font-weight: 600;
color: #333;
margin-bottom: 6px;
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.snapshot-detail-meta {
font-size: 12px;
color: #666;
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.timeline-footer {
margin-top: 14px;
display: flex;
@ -917,6 +959,33 @@
border-radius: 2px;
}
.detail-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
margin-bottom: 18px;
border-radius: 8px;
background: #eef2ff;
border: 1px solid #d7dbff;
}
.detail-meta {
color: #3f51b5;
font-size: 14px;
}
.detail-meta strong {
font-size: 16px;
margin: 0 4px;
}
.detail-actions {
display: flex;
gap: 12px;
align-items: center;
}
.detail-content {
background: #f8f9fa;
padding: 15px;
@ -1121,8 +1190,10 @@
let historyStack = []; // 历史记录栈
let currentRegion = null;
let currentTheme = null;
let currentPermit = null;
let pendingDangerOperation = null; // 待执行的危险操作
let currentPermit = null;
let currentPermitDetails = null;
let pendingDangerOperation = null; // 待执行的危险操作
let isDeletingPermit = false;
const permitRiskSnapshotState = {
limit: 10,
@ -1144,6 +1215,7 @@
let checkpointListCache = [];
let checkpointListLoading = false;
let checkpointListError = '';
let expandedSnapshotGroups = new Set();
// 步骤配置
const steps = {
@ -1268,6 +1340,7 @@
currentRegion = { id: regionId, name: regionName };
currentTheme = null;
currentPermit = null;
currentPermitDetails = null;
// 更新步骤
goToStep(2);
@ -1280,6 +1353,7 @@
currentTheme = { id: themeId, name: themeName };
currentPermit = null;
currentPermitDetails = null;
// 更新步骤
goToStep(3);
@ -1291,6 +1365,7 @@
historyStack.push({ step: currentStep, permit: currentPermit });
currentPermit = { id: permitId, name: permitName, themeId: themeId };
currentPermitDetails = null;
// 更新步骤
goToStep(4);
@ -1415,10 +1490,10 @@
// 清理后续状态
if (targetStep <= 2) {
currentTheme = null;
currentPermit = null;
}
if (targetStep <= 3) {
currentPermit = null;
currentPermitDetails = null;
}
goToStep(targetStep);
@ -1429,6 +1504,7 @@
currentRegion = null;
currentTheme = null;
currentPermit = null;
currentPermitDetails = null;
historyStack = [];
goToStep(1);
}
@ -1470,7 +1546,25 @@
// 渲染许可详情
function renderPermitDetails(permit) {
const detailsArea = document.querySelector('.details-area');
currentPermitDetails = permit;
const riskCount = Array.isArray(permit.risks) ? permit.risks.length : 0;
if (currentPermit) {
currentPermit = { ...currentPermit, riskCount };
}
const deleteButtonLabel = isDeletingPermit ? '删除中...' : '删除许可';
const deleteButtonDisabled = isDeletingPermit ? 'disabled' : '';
let html = '<div class="details-content">';
html += `
<div class="detail-toolbar">
<div class="detail-meta">
风险条目:<strong>${riskCount}</strong>
</div>
<div class="detail-actions">
<button class="btn btn-danger" id="deletePermitBtn" ${deleteButtonDisabled} onclick="confirmDeleteCurrentPermit()">${deleteButtonLabel}</button>
</div>
</div>
`;
// 许可基本信息
html += `
@ -1525,6 +1619,118 @@
detailsArea.innerHTML = html;
}
function confirmDeleteCurrentPermit() {
if (isDeletingPermit) {
return;
}
if (!currentRegion || !currentTheme || !currentPermit) {
alert('请先选择要删除的许可');
return;
}
const riskCount = currentPermit.riskCount !== undefined
? currentPermit.riskCount
: (currentPermitDetails && Array.isArray(currentPermitDetails.risks) ? currentPermitDetails.risks.length : 0);
const confirmMessage = `确定要删除「${currentRegion.name} ${currentTheme.name} ${currentPermit.name}」吗?\n\n` +
`此操作会删除 ${riskCount} 条风险关联(如存在)。系统会备份当前风险快照,但删除后需通过快照管理页面手动恢复。`;
if (!confirm(confirmMessage)) {
return;
}
const summaryInput = prompt('请输入删除说明(可选,用于快照对比):', '');
if (summaryInput === null) {
return;
}
const changeSummary = summaryInput.trim();
deleteCurrentPermit(changeSummary);
}
async function deleteCurrentPermit(changeSummary) {
if (isDeletingPermit) {
return;
}
if (!currentRegion || !currentTheme || !currentPermit) {
alert('当前上下文缺失,无法删除');
return;
}
isDeletingPermit = true;
toggleDeletePermitButton(true);
try {
const payload = {
region_id: currentRegion.id,
theme_id: currentTheme.id,
permit_id: currentPermit.id
};
if (changeSummary) {
payload.change_summary = changeSummary;
}
const response = await fetch('/fs-ai-asistant/api/workflow/lawrisk/admin/permits', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
const data = await response.json();
if (response.ok && data.success) {
const snapshotCount = data.data && typeof data.data.snapshot_count === 'number'
? data.data.snapshot_count
: 0;
const deletedRisks = data.data && data.data.deleted_rows
? (data.data.deleted_rows.region_permit_risks || 0)
: 0;
const remainingPermits = data.data && typeof data.data.remaining_theme_permits === 'number'
? data.data.remaining_theme_permits
: null;
const themeDetached = !!(data.data && data.data.theme_detached);
const snapshotBatchId = data.data && data.data.snapshot_batch_id;
let successMessage = `✅ 删除成功!\n\n已备份 ${snapshotCount} 条风险快照,并删除 ${deletedRisks} 条风险关联。`;
if (themeDetached) {
successMessage += '\n对应主题已与该地区解除关联。';
} else if (remainingPermits !== null) {
successMessage += `\n该主题在此地区仍剩余 ${remainingPermits} 个许可。`;
}
if (snapshotBatchId) {
successMessage += `\n快照批次${snapshotBatchId}`;
}
alert(successMessage);
const modal = document.getElementById('checkpointModal');
if (modal && modal.classList.contains('show')) {
if ((!permitRiskSnapshotState.regionFilter || permitRiskSnapshotState.regionFilter === currentRegion.id) &&
(!permitRiskSnapshotState.permitFilter || permitRiskSnapshotState.permitFilter === currentPermit.id)) {
await refreshPermitRiskSnapshots(false);
}
}
currentPermitDetails = null;
quickJump(3);
} else {
const message = data && data.message ? data.message : `删除失败HTTP ${response.status}`;
alert(`❌ 删除失败:${message}`);
}
} catch (error) {
alert(`❌ 删除失败:${error.message}`);
} finally {
isDeletingPermit = false;
toggleDeletePermitButton(false);
}
}
function toggleDeletePermitButton(disabled) {
const btn = document.getElementById('deletePermitBtn');
if (!btn) {
return;
}
btn.disabled = disabled;
btn.textContent = disabled ? '删除中...' : '删除许可';
}
// 更新步骤指示器
function updateStepIndicator(step) {
for (let i = 1; i <= 4; i++) {
@ -1564,6 +1770,7 @@
checkpointListCache = [];
checkpointListLoading = true;
checkpointListError = '';
expandedSnapshotGroups = new Set();
renderCheckpointManager(checkpointListCache);
@ -1685,23 +1892,88 @@
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 || '';
if (entry.type === 'snapshot-group') {
const group = entry.raw || {};
const groupKey = group.groupKey || group.snapshot_batch_id || '';
const groupKeyJs = String(groupKey).replace(/'/g, "\\'");
const riskItems = group.items || [];
const riskCount = riskItems.length;
const primary = riskItems[0] || {};
const permitName = escapeHtml(group.permit_name || primary.permit_name || '-');
const regionName = escapeHtml(group.region_name || primary.region_name || '-');
const riskFull = primary.risk_content || '';
const riskPreview = escapeHtml(truncateText(riskFull, 160));
const legalSegments = [];
if (item.legal_basis) {
legalSegments.push(`📕 ${escapeHtml(item.legal_basis)}`);
if (primary.legal_basis) {
legalSegments.push(`📕 ${escapeHtml(primary.legal_basis)}`);
}
if (item.document_no) {
legalSegments.push(`📄 ${escapeHtml(item.document_no)}`);
if (primary.document_no) {
legalSegments.push(`📄 ${escapeHtml(primary.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 editorsText = (group.editors && group.editors.length)
? escapeHtml(group.editors.join('、'))
: '—';
const isExpanded = expandedSnapshotGroups.has(groupKey);
const expandIcon = isExpanded ? '▲' : '▼';
const toggleLabel = isExpanded ? '收起明细' : '展开明细';
const changeSummaryMerged = group.change_summaries && group.change_summaries.length
? `<div class="timeline-note">备注:${escapeHtml(group.change_summaries.join(''))}</div>`
: '';
const statusTag = primary.permit_status ? `<span class="snapshot-status-tag">${escapeHtml(primary.permit_status)}</span>` : '';
const toggleButton = riskCount > 0
? `<button class="btn btn-warning btn-sm" onclick="toggleSnapshotGroup('${groupKeyJs}')">${expandIcon} ${toggleLabel}</button>`
: '';
const restoreButton = `<button class="btn btn-primary btn-sm" onclick="confirmRestoreSnapshotBatch('${groupKeyJs}')"><span>🛠️</span> 恢复</button>`;
const detailItemsHtml = riskItems.map(detail => {
const detailLegal = [];
if (detail.legal_basis) {
detailLegal.push(`📕 ${escapeHtml(detail.legal_basis)}`);
}
if (detail.document_no) {
detailLegal.push(`📄 ${escapeHtml(detail.document_no)}`);
}
const detailMetaParts = [];
if (detail.edited_by) {
detailMetaParts.push(`编辑人:${escapeHtml(detail.edited_by)}`);
}
detailMetaParts.push(...detailLegal);
const detailMetaHtml = detailMetaParts.length
? `<div class="snapshot-detail-meta">${detailMetaParts.join('<span>|</span>')}</div>`
: '';
const detailStatusTag = detail.permit_status ? `<span class="snapshot-status-tag">${escapeHtml(detail.permit_status)}</span>` : '';
const detailNote = detail.change_summary ? `<div class="timeline-note" style="margin-top:6px;">备注:${escapeHtml(detail.change_summary)}</div>` : '';
return `
<div class="snapshot-detail-item">
<div class="snapshot-detail-header">
<span>版本 ${escapeHtml(String(detail.version || 0))}</span>
<span>风险ID${escapeHtml(detail.risk_id || '-')}</span>
${detailStatusTag}
</div>
<div class="timeline-content">${escapeHtml(detail.risk_content || '—')}</div>
${detailMetaHtml}
${detailNote}
</div>
`;
}).join('');
const detailListHtml = (isExpanded && riskCount > 0)
? `
<div class="snapshot-detail-list expanded">
${detailItemsHtml}
</div>
`
: '';
const metaHtml = `
<div class="timeline-meta">
${statusTag}
<span>风险条目:${riskCount} 个</span>
<span>编辑人:${editorsText}</span>
</div>
`;
const timeDisplay = escapeHtml(entry.timeText || '');
html += `
@ -1709,18 +1981,19 @@
<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-title">风险快照 · ${riskCount > 1 ? `批次(${riskCount} 条)` : `版本 ${escapeHtml(String(primary.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>
${metaHtml}
${changeSummaryMerged}
<div class="timeline-actions">
${toggleButton}
${restoreButton}
</div>
${changeSummary}
${detailListHtml}
</div>
</div>
`;
@ -1777,14 +2050,16 @@
html += `<div class="error" style="margin-top: 10px;">${warningMessages.join('<br>')}</div>`;
}
const snapshotSummaryText = totalSnapshots
? `快照 ${snapshotStart}-${snapshotEnd} / ${totalSnapshots}`
: '快照 0 / 0';
const snapshotGroupCount = new Set((snapshotState.snapshots || []).map(item => item.snapshot_batch_id || item.snapshot_id)).size;
const snapshotRangeText = totalSnapshots
? `风险快照 ${snapshotStart}-${snapshotEnd} / ${totalSnapshots}`
: '风险快照 0 / 0';
const snapshotBatchText = `快照批次 ${snapshotGroupCount} 个`;
const checkpointSummaryText = `检查点 ${checkpointList.length} 个`;
html += `
<div class="timeline-footer">
<div class="snapshot-count">${snapshotSummaryText}${checkpointSummaryText}</div>
<div class="snapshot-count">${snapshotBatchText}${snapshotRangeText}${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>
@ -1965,6 +2240,7 @@
permitRiskSnapshotState.error = error.message || '网络错误';
} finally {
permitRiskSnapshotState.loading = false;
expandedSnapshotGroups = new Set();
renderCheckpointManager(checkpointListCache);
}
}
@ -2252,6 +2528,92 @@
}
}
function getSnapshotGroupItems(batchId) {
if (!batchId) return [];
return (permitRiskSnapshotState.snapshots || []).filter(item => {
const key = item.snapshot_batch_id || item.snapshot_id;
return key === batchId;
});
}
function toggleSnapshotGroup(batchId) {
if (!batchId) return;
if (expandedSnapshotGroups.has(batchId)) {
expandedSnapshotGroups.delete(batchId);
} else {
expandedSnapshotGroups.add(batchId);
}
renderCheckpointManager(checkpointListCache);
}
function confirmRestoreSnapshotBatch(batchId) {
if (!batchId) {
alert('未找到对应的快照批次');
return;
}
const groupItems = getSnapshotGroupItems(batchId);
if (groupItems.length === 0) {
alert('未找到对应的快照记录');
return;
}
const primary = groupItems[0];
const regionName = primary.region_name || '未知地区';
const permitName = primary.permit_name || '未知许可';
const riskCount = groupItems.length;
const confirmMessage = `确定要从快照恢复「${regionName} ${permitName}」吗?\n\n` +
`该操作将重新建立 ${riskCount} 条风险关联、许可明细以及相关主题/范围配置。`;
if (!confirm(confirmMessage)) {
return;
}
const summaryInput = prompt('请输入恢复说明(可选):', '');
if (summaryInput === null) {
return;
}
const changeSummary = summaryInput.trim();
restoreSnapshotBatch(batchId, changeSummary, groupItems);
}
async function restoreSnapshotBatch(batchId, changeSummary, groupItems) {
try {
const payload = {};
if (changeSummary) {
payload.change_summary = changeSummary;
}
const response = await fetch(`/fs-ai-asistant/api/workflow/lawrisk/admin/permit-risk-snapshots/${batchId}/restore`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
const data = await parseJsonResponse(response);
if (data && data.success) {
const restoredCount = data.data && typeof data.data.restored_risk_count === 'number'
? data.data.restored_risk_count
: (groupItems ? groupItems.length : 0);
alert(`✅ 恢复成功!已恢复 ${restoredCount} 条风险关联。`);
await refreshPermitRiskSnapshots(false);
if (groupItems && groupItems.length > 0) {
const targetRegionId = groupItems[0].region_id;
const targetPermitId = groupItems[0].permit_id;
if (currentRegion && currentRegion.id === targetRegionId) {
if (currentTheme) {
await loadPermits(currentTheme.id, currentTheme.name);
}
if (currentPermit && currentPermit.id === targetPermitId) {
await showPermitDetails();
}
}
}
} else {
const message = data && data.message ? data.message : `恢复失败HTTP ${response.status}`;
alert(`❌ 恢复失败:${message}`);
}
} catch (error) {
alert(`❌ 恢复失败:${error.message}`);
}
}
function checkpointTimestampToIso(timestamp) {
if (!timestamp) return '';
const match = String(timestamp).match(/(\d{4})(\d{2})(\d{2})_(\d{2})(\d{2})(\d{2})/);
@ -2261,15 +2623,58 @@
function buildTimelineItems(snapshotItems, checkpointItems) {
const items = [];
const snapshotGroups = new Map();
(snapshotItems || []).forEach(item => {
const iso = item.created_at || '';
const timeValue = Date.parse(iso) || 0;
const groupKey = item.snapshot_batch_id || item.snapshot_id;
if (!snapshotGroups.has(groupKey)) {
snapshotGroups.set(groupKey, {
key: groupKey,
items: [],
});
}
snapshotGroups.get(groupKey).items.push(item);
});
snapshotGroups.forEach(group => {
group.items.sort((a, b) => {
const timeA = Date.parse(a.created_at || '') || 0;
const timeB = Date.parse(b.created_at || '') || 0;
return timeB - timeA;
});
let latestIso = '';
let latestValue = 0;
group.items.forEach(item => {
const value = Date.parse(item.created_at || '') || 0;
if (value > latestValue) {
latestValue = value;
latestIso = item.created_at || '';
}
});
const firstItem = group.items[0] || {};
const displayIso = latestIso || firstItem.created_at || '';
const timeValue = displayIso ? Date.parse(displayIso) || 0 : latestValue;
const editors = Array.from(new Set(group.items.map(entry => entry.edited_by).filter(Boolean)));
const summaries = group.items.map(entry => entry.change_summary).filter(Boolean);
items.push({
type: 'snapshot',
type: 'snapshot-group',
timeValue,
timeText: iso ? formatIsoDatetime(iso) : '',
raw: item
timeText: displayIso ? formatIsoDatetime(displayIso) : '',
raw: {
groupKey: group.key,
created_at: displayIso,
items: group.items,
region_name: firstItem.region_name || '',
region_id: firstItem.region_id || '',
permit_name: firstItem.permit_name || '',
permit_id: firstItem.permit_id || '',
editors,
change_summaries: summaries,
snapshot_batch_id: firstItem.snapshot_batch_id || firstItem.snapshot_id || group.key,
},
});
});