feat: unify snapshot and checkpoint timeline
This commit is contained in:
parent
ec8adf98f1
commit
406fca7363
|
|
@ -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, '&')
|
||||
.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);
|
||||
|
|
|
|||
Loading…
Reference in New Issue