fs-lawrisk/static/db_admin.html

5342 lines
199 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>数据库维护页面 - LawRisk</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Microsoft YaHei', Arial, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
padding: 20px;
}
.container {
max-width: 1400px;
margin: 0 auto;
background: white;
border-radius: 12px;
box-shadow: 0 10px 40px rgba(0,0,0,0.2);
padding: 30px;
}
.header {
position: relative;
text-align: center;
margin-bottom: 30px;
padding-bottom: 24px;
border-bottom: 3px solid #667eea;
}
.header-info {
max-width: 720px;
margin: 0 auto;
}
.header h1 {
color: #333;
font-size: 28px;
margin-bottom: 10px;
}
.header p {
color: #666;
font-size: 14px;
}
.user-bar {
position: absolute;
right: 24px;
top: 50%;
transform: translateY(-50%);
padding: 12px 16px;
border: 1px solid #e5e7eb;
border-radius: 16px;
background: rgba(255, 255, 255, 0.98);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
display: flex;
align-items: center;
gap: 14px;
transition: all 0.3s ease;
}
.user-bar:hover {
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.16);
border-color: #d1d5db;
}
.user-avatar {
width: 44px;
height: 44px;
border-radius: 50%;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
display: flex;
align-items: center;
justify-content: center;
color: white;
font-weight: 600;
font-size: 18px;
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
flex-shrink: 0;
}
.user-info {
display: flex;
flex-direction: column;
gap: 3px;
color: #1f2937;
font-size: 14px;
}
.user-name {
font-weight: 600;
font-size: 15px;
color: #111827;
display: flex;
align-items: center;
gap: 8px;
}
.user-role {
display: inline-flex;
align-items: center;
gap: 6px;
background: linear-gradient(135deg, #e0e7ff 0%, #ddd6fe 100%);
color: #4f46e5;
padding: 3px 10px;
border-radius: 999px;
font-size: 11px;
font-weight: 500;
letter-spacing: 0.02em;
}
.user-time {
font-size: 11px;
color: #6b7280;
display: flex;
align-items: center;
gap: 4px;
}
.user-alert {
font-size: 12px;
color: #f59e0b;
margin-top: 2px;
font-weight: 500;
}
.user-actions {
display: flex;
align-items: center;
gap: 8px;
}
.user-actions button {
border: none;
border-radius: 10px;
padding: 8px 14px;
font-size: 13px;
cursor: pointer;
transition: all 0.25s ease;
background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%);
color: white;
box-shadow: 0 4px 12px rgba(239, 68, 68, 0.3);
font-weight: 500;
}
.user-actions button:hover {
transform: translateY(-1px);
box-shadow: 0 6px 16px rgba(239, 68, 68, 0.4);
}
.user-actions button:active {
transform: translateY(0);
}
.step-indicator {
display: flex;
justify-content: center;
align-items: center;
margin-bottom: 30px;
gap: 10px;
}
.step {
display: flex;
align-items: center;
gap: 10px;
}
.step-number {
width: 36px;
height: 36px;
border-radius: 50%;
background: #e0e7ff;
color: #667eea;
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
font-size: 16px;
}
.step.active .step-number {
background: #667eea;
color: white;
}
.step-label {
font-size: 14px;
color: #666;
font-weight: 500;
}
.step.active .step-label {
color: #667eea;
font-weight: bold;
}
.arrow {
color: #ccc;
font-size: 24px;
}
.content-area {
display: grid;
grid-template-columns: 350px 1fr;
gap: 30px;
min-height: 600px;
}
.panel {
background: #f8f9fa;
border-radius: 8px;
padding: 20px;
border: 1px solid #e0e0e0;
}
.panel h2 {
color: #333;
font-size: 18px;
margin-bottom: 15px;
padding-bottom: 10px;
border-bottom: 2px solid #667eea;
display: flex;
justify-content: space-between;
align-items: center;
}
.nav-controls {
display: flex;
gap: 8px;
}
.back-button {
padding: 4px 12px;
background: #f0f0f0;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 13px;
color: #666;
cursor: pointer;
transition: all 0.3s;
}
.back-button:hover:not(:disabled) {
background: #e0e0e0;
border-color: #999;
}
.back-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.breadcrumb {
background: white;
border-radius: 6px;
padding: 12px 16px;
margin-bottom: 15px;
border: 1px solid #e0e0e0;
font-size: 14px;
color: #666;
}
.breadcrumb-item {
display: inline-flex;
align-items: center;
gap: 6px;
}
.breadcrumb-item a {
color: #667eea;
text-decoration: none;
cursor: pointer;
transition: color 0.3s;
}
.breadcrumb-item a:hover {
color: #5568d3;
text-decoration: underline;
}
.breadcrumb-separator {
color: #ccc;
margin: 0 4px;
}
.breadcrumb-current {
color: #333;
font-weight: 500;
}
.selection-area {
background: white;
border-radius: 8px;
padding: 20px;
max-height: 600px;
overflow-y: auto;
overflow-x: hidden;
position: relative;
}
/* 自定义滚动条样式 */
.selection-area::-webkit-scrollbar {
width: 8px;
}
.selection-area::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 4px;
}
.selection-area::-webkit-scrollbar-thumb {
background: #c0c0c0;
border-radius: 4px;
transition: background 0.3s;
}
.selection-area::-webkit-scrollbar-thumb:hover {
background: #a0a0a0;
}
.item-list {
list-style: none;
margin-top: 10px;
}
.item-list li {
padding: 12px 16px;
margin-bottom: 8px;
background: #f8f9fa;
border: 1px solid #e0e0e0;
border-radius: 6px;
cursor: pointer;
transition: all 0.3s;
display: flex;
justify-content: space-between;
align-items: center;
}
.item-list li:hover {
background: #e0e7ff;
border-color: #667eea;
transform: translateX(5px);
}
.item-list li.active {
background: #667eea;
color: white;
border-color: #667eea;
}
.item-list li.active:hover {
background: #5568d3;
}
.item-name {
font-size: 15px;
font-weight: 500;
}
.item-tag {
display: inline-flex;
align-items: center;
margin-left: 8px;
padding: 2px 8px;
border-radius: 999px;
background: rgba(102, 126, 234, 0.15);
font-size: 11px;
color: #4f46e5;
}
.theme-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.item-count {
font-size: 12px;
background: rgba(102, 126, 234, 0.1);
padding: 4px 10px;
border-radius: 12px;
color: #667eea;
}
.item-list li.active .item-count {
background: rgba(255, 255, 255, 0.2);
color: white;
}
.import-preview-sheet {
border: 1px solid #e5e7eb;
border-radius: 12px;
padding: 16px;
margin-top: 16px;
background: #fdfdfd;
}
.import-preview-sheet.disabled {
opacity: 0.6;
}
.preview-sheet-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.preview-sheet-title {
font-size: 16px;
font-weight: 600;
color: #1f2937;
}
.preview-sheet-meta {
font-size: 13px;
color: #6b7280;
}
.preview-permit-card {
border: 1px solid #e0e7ff;
border-radius: 10px;
padding: 12px;
margin-top: 12px;
background: #f8faff;
}
.preview-permit-card.warning {
border-color: #fbbf24;
background: #fff8e1;
}
.preview-permit-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 10px;
}
.preview-permit-name {
font-weight: 600;
color: #111827;
}
.preview-badge {
display: inline-flex;
align-items: center;
padding: 2px 8px;
border-radius: 999px;
font-size: 11px;
background: #eef2ff;
color: #4c1d95;
}
.preview-badge.duplicate {
background: #fee2e2;
color: #b91c1c;
}
.preview-permit-meta {
font-size: 12px;
color: #4b5563;
margin-top: 6px;
}
.theme-options {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 10px;
}
.theme-checkbox {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 13px;
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 999px;
padding: 4px 10px;
}
.theme-checkbox input {
width: 14px;
height: 14px;
}
.theme-source-badge {
font-size: 11px;
color: #6b7280;
}
.preview-empty {
padding: 12px;
border-radius: 8px;
background: #f3f4f6;
color: #6b7280;
font-size: 13px;
}
.checkpoint-nav-item {
background: #fff3cd;
border: 2px solid #ffc107;
}
.checkpoint-nav-item:hover {
background: #ffe69c;
border-color: #ff9800;
}
.checkpoint-section {
background: white;
padding: 20px;
border-radius: 8px;
margin-bottom: 20px;
border: 1px solid #e0e0e0;
}
.checkpoint-section h3 {
color: #667eea;
font-size: 18px;
margin-bottom: 15px;
display: flex;
align-items: center;
gap: 10px;
}
.checkpoint-section h3::before {
content: '🔒';
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;
}
.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;
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;
}
.form-group {
margin-bottom: 15px;
}
.form-group label {
display: block;
margin-bottom: 8px;
color: #333;
font-weight: 500;
}
.form-group input[type="text"] {
width: 100%;
padding: 10px 12px;
border: 1px solid #ddd;
border-radius: 6px;
font-size: 14px;
transition: border-color 0.3s;
}
.form-group input[type="text"]:focus {
outline: none;
border-color: #667eea;
}
.btn {
padding: 10px 20px;
border: none;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.3s;
display: inline-flex;
align-items: center;
gap: 8px;
}
.btn-primary {
background: #667eea;
color: white;
}
.btn-primary:hover:not(:disabled) {
background: #5568d3;
}
.btn-secondary {
background: #e2e8f0;
color: #334155;
}
.btn-secondary:hover:not(:disabled) {
background: #cbd5f5;
}
.btn-danger {
background: #dc3545;
color: white;
}
.btn-danger:hover:not(:disabled) {
background: #c82333;
}
.btn-warning {
background: #ffc107;
color: #333;
}
.btn-warning:hover:not(:disabled) {
background: #e0a800;
}
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.checkpoint-list {
margin-top: 20px;
}
.checkpoint-item {
background: #f8f9fa;
padding: 15px;
margin-bottom: 12px;
border-radius: 6px;
border: 1px solid #e0e0e0;
transition: all 0.3s;
}
.checkpoint-item:hover {
background: #e9ecef;
border-color: #667eea;
}
.checkpoint-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
}
.checkpoint-id {
font-weight: bold;
color: #333;
font-size: 15px;
}
.checkpoint-timestamp {
color: #666;
font-size: 13px;
}
.checkpoint-description {
color: #555;
margin-bottom: 10px;
line-height: 1.6;
}
.checkpoint-stats {
display: flex;
gap: 15px;
margin-bottom: 10px;
font-size: 13px;
color: #666;
}
.checkpoint-stats span {
display: inline-flex;
align-items: center;
gap: 5px;
}
.checkpoint-actions {
display: flex;
gap: 10px;
margin-top: 10px;
}
.btn-sm {
padding: 6px 12px;
font-size: 13px;
}
.modal {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
z-index: 9999;
align-items: center;
justify-content: center;
}
.modal.show {
display: flex;
}
.modal-content {
background: white;
border-radius: 12px;
padding: 30px;
max-width: 500px;
width: 90%;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.3);
animation: modalFadeIn 0.3s;
}
.import-modal-content {
width: 760px;
max-height: 90vh;
overflow-y: auto;
}
@keyframes modalFadeIn {
from {
opacity: 0;
transform: translateY(-20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.import-section {
margin-bottom: 18px;
}
.import-section h3 {
font-size: 16px;
margin-bottom: 8px;
color: #333;
display: flex;
align-items: center;
gap: 6px;
}
.import-upload-area {
border: 1px dashed #9fa8da;
padding: 16px;
border-radius: 8px;
background: #f5f7ff;
display: flex;
flex-direction: column;
gap: 8px;
}
.import-template-wrapper {
display: flex;
justify-content: flex-start;
margin-top: 4px;
}
.import-template-button {
display: inline-flex;
align-items: center;
gap: 12px;
padding: 12px 18px;
border-radius: 10px;
background: linear-gradient(135deg, #5c6bc0, #3949ab);
color: #fff;
font-size: 14px;
font-weight: 600;
text-decoration: none;
box-shadow: 0 8px 22px rgba(57, 73, 171, 0.35);
border: 1px solid rgba(255, 255, 255, 0.3);
transform: translateY(0);
transition: transform 0.2s ease, box-shadow 0.3s ease, background 0.3s ease;
}
.import-template-button:hover {
background: linear-gradient(135deg, #283593, #1a237e);
text-decoration: none;
box-shadow: 0 10px 26px rgba(26, 35, 126, 0.45);
transform: translateY(-1px);
}
.import-template-icon {
display: flex;
align-items: center;
justify-content: center;
width: 38px;
height: 38px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.25);
font-size: 18px;
}
.import-template-text {
display: flex;
flex-direction: column;
gap: 4px;
line-height: 1.2;
}
.import-template-title {
font-size: 15px;
font-weight: 700;
}
.import-template-subtitle {
font-size: 12px;
font-weight: 400;
opacity: 0.85;
}
.import-template-badge {
margin-left: auto;
padding: 4px 10px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.25);
color: #fff;
font-size: 12px;
font-weight: 600;
letter-spacing: 1px;
}
.import-upload-area input[type="file"] {
background: #fff;
padding: 10px;
border-radius: 6px;
border: 1px solid #c5cae9;
}
.import-meta {
font-size: 13px;
color: #555;
line-height: 1.5;
}
.import-sheet-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.import-sheet-card {
border: 1px solid #e0e0e0;
border-radius: 8px;
padding: 12px 14px;
background: #fafafa;
}
.import-sheet-card.selected {
border-color: #667eea;
background: #eef2ff;
}
.import-sheet-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
margin-bottom: 10px;
}
.import-sheet-title {
display: flex;
align-items: center;
gap: 8px;
font-weight: 600;
color: #3949ab;
}
.import-sheet-meta {
font-size: 12px;
color: #666;
}
.import-duplicate-panel {
background: #fff;
border-radius: 6px;
border: 1px solid #e0e0e0;
padding: 10px;
display: flex;
flex-direction: column;
gap: 6px;
}
.import-duplicate-title {
font-size: 12px;
color: #d84315;
font-weight: 600;
}
.import-checkbox {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: #333;
}
.import-checkbox input[type="checkbox"] {
transform: scale(1.05);
}
.import-messages {
font-size: 13px;
line-height: 1.6;
}
.import-success {
color: #256029;
background: #e3f2e1;
padding: 8px 10px;
border-radius: 6px;
margin-bottom: 10px;
}
.import-error {
color: #c62828;
background: #ffebee;
padding: 8px 10px;
border-radius: 6px;
margin-bottom: 10px;
}
.import-form-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
margin-top: 10px;
}
.import-form-grid textarea {
grid-column: span 2;
resize: vertical;
min-height: 68px;
}
.import-form-grid input,
.import-form-grid textarea {
width: 100%;
padding: 8px 10px;
border: 1px solid #c5cae9;
border-radius: 6px;
font-size: 13px;
font-family: inherit;
}
.import-actions {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 16px;
gap: 16px;
}
.import-hint {
font-size: 12px;
color: #666;
line-height: 1.4;
}
.import-modal-content.import-modal-wide {
width: 92vw;
max-width: 1500px;
}
.import-stage-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
margin-bottom: 16px;
}
.import-stage-header h3 {
margin: 0;
color: #1e293b;
display: flex;
align-items: center;
gap: 8px;
}
.import-preview-summary {
display: flex;
flex-wrap: wrap;
gap: 12px;
font-size: 13px;
color: #475569;
}
.import-preview-tabs {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin: 12px 0 18px;
}
.preview-tab {
padding: 8px 14px;
border-radius: 999px;
background: #f1f5f9;
color: #475569;
font-size: 13px;
border: 1px solid transparent;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 6px;
transition: all 0.2s ease;
}
.preview-tab:hover {
background: #e2e8f0;
}
.preview-tab.active {
background: #eef2ff;
color: #4338ca;
border-color: #a5b4fc;
}
.preview-tab-badge {
background: rgba(67, 56, 202, 0.15);
color: #4338ca;
padding: 2px 8px;
border-radius: 999px;
font-size: 11px;
}
.preview-toolbar {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 12px;
margin-bottom: 16px;
}
.preview-toolbar label {
display: flex;
flex-direction: column;
gap: 6px;
font-size: 13px;
color: #475569;
}
.preview-toolbar input {
padding: 10px 12px;
border-radius: 10px;
border: 1px solid #dbeafe;
background: #fff;
font-size: 14px;
transition: border-color 0.2s ease;
}
.preview-toolbar input:focus {
outline: none;
border-color: #6366f1;
}
.preview-panels {
display: grid;
grid-template-columns: 1fr;
gap: 16px;
}
.preview-permit-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 16px;
}
.preview-permit-card {
border: 1px solid #e2e8f0;
border-radius: 14px;
padding: 16px;
background: #fff;
display: flex;
flex-direction: column;
gap: 10px;
min-height: 220px;
}
.preview-permit-card.duplicate {
border-color: #fcd34d;
background: #fffbeb;
}
.preview-permit-card.new {
border-color: #bbf7d0;
background: #f0fdf4;
}
.preview-permit-header {
display: flex;
flex-direction: column;
gap: 6px;
}
.permit-badge {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 12px;
padding: 2px 8px;
border-radius: 999px;
}
.permit-badge.duplicate {
background: rgba(249, 115, 22, 0.15);
color: #c2410c;
}
.permit-badge.new {
background: rgba(37, 99, 235, 0.12);
color: #1d4ed8;
}
.theme-chip-grid {
display: flex;
flex-wrap: wrap;
gap: 8px;
min-height: 40px;
padding: 6px 0;
}
.theme-chip {
border: 1px solid #cbd5f5;
border-radius: 999px;
padding: 6px 12px;
font-size: 13px;
color: #4338ca;
background: #eef2ff;
cursor: pointer;
user-select: none;
display: inline-flex;
align-items: center;
gap: 6px;
}
.theme-chip.all-theme {
border-color: #fcd34d;
background: #fffbeb;
color: #92400e;
font-weight: 600;
}
.theme-chip.selected {
background: linear-gradient(135deg, #818cf8, #6366f1);
color: #fff;
border-color: transparent;
}
.theme-chip.drag-preview {
opacity: 0.7;
}
.theme-chip-actions {
display: flex;
gap: 8px;
margin-top: 8px;
}
.preview-empty-state {
padding: 24px;
border: 1px dashed #cbd5f5;
border-radius: 12px;
text-align: center;
background: #f8fafc;
color: #64748b;
}
.import-sheet-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 12px;
margin-bottom: 12px;
font-size: 13px;
color: #475569;
}
.import-sheet-toolbar .toolbar-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.import-stage {
display: flex;
flex-direction: column;
gap: 16px;
}
.preview-stage-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
justify-content: flex-end;
}
.modal-header {
text-align: center;
margin-bottom: 20px;
}
.modal-header h3 {
color: #dc3545;
font-size: 20px;
margin-bottom: 10px;
}
.modal-header .warning-icon {
font-size: 48px;
margin-bottom: 15px;
}
.modal-body {
margin-bottom: 20px;
}
.modal-body p {
color: #555;
line-height: 1.6;
margin-bottom: 10px;
}
.modal-footer {
display: flex;
gap: 10px;
justify-content: flex-end;
}
.danger-text {
color: #dc3545;
font-weight: bold;
}
.success-text {
color: #28a745;
font-weight: bold;
}
.checkpoint-toolbar {
display: flex;
justify-content: flex-end;
margin-bottom: 20px;
}
.checkpoint-toolbar .btn {
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
.checkpoint-modal {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
z-index: 1000;
align-items: center;
justify-content: center;
padding: 20px;
}
.checkpoint-modal.show {
display: flex;
}
.checkpoint-modal-content {
background: white;
border-radius: 12px;
padding: 0;
max-width: 900px;
width: 100%;
max-height: 90vh;
display: flex;
flex-direction: column;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.3);
animation: modalFadeIn 0.3s;
}
.checkpoint-modal-header {
padding: 20px 30px;
border-bottom: 2px solid #667eea;
display: flex;
justify-content: space-between;
align-items: center;
}
.checkpoint-modal-header h2 {
color: #333;
font-size: 20px;
margin: 0;
}
.checkpoint-modal-close {
background: none;
border: none;
font-size: 28px;
color: #999;
cursor: pointer;
padding: 0;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
transition: all 0.3s;
}
.checkpoint-modal-close:hover {
background: #f0f0f0;
color: #333;
}
.checkpoint-modal-body {
padding: 30px;
overflow-y: auto;
flex: 1;
}
.file-manager-modal-content {
max-width: 960px;
width: 92%;
}
.file-manager-toolbar {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: center;
margin-bottom: 16px;
}
.file-manager-search {
flex: 1;
min-width: 220px;
}
.file-manager-search input {
width: 100%;
padding: 10px 12px;
border: 1px solid #d1d5db;
border-radius: 8px;
font-size: 14px;
}
.file-manager-search input:focus {
outline: none;
border-color: #6366f1;
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
}
.file-manager-table {
width: 100%;
border-collapse: collapse;
}
.file-manager-table th,
.file-manager-table td {
padding: 12px 10px;
border-bottom: 1px solid #f1f5f9;
text-align: left;
vertical-align: top;
}
.file-manager-table th {
font-size: 13px;
color: #6b7280;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.file-manager-file-name {
font-weight: 600;
color: #111827;
font-size: 15px;
}
.file-manager-file-meta {
font-size: 12px;
color: #6b7280;
margin-top: 4px;
}
.file-manager-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.file-manager-tag {
background: #eef2ff;
color: #4338ca;
padding: 4px 10px;
border-radius: 999px;
font-size: 12px;
}
.file-manager-tag-muted {
background: #f3f4f6;
color: #6b7280;
}
.file-manager-empty {
text-align: center;
padding: 40px 0;
color: #6b7280;
}
.file-manager-pagination {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 18px;
font-size: 13px;
color: #4b5563;
}
.file-manager-pagination button {
border: none;
background: #f3f4f6;
color: #374151;
padding: 8px 14px;
border-radius: 8px;
cursor: pointer;
transition: background 0.2s ease;
}
.file-manager-pagination button:hover:not(:disabled) {
background: #e5e7eb;
}
.file-manager-pagination button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.file-manager-actions {
display: flex;
flex-direction: column;
gap: 8px;
}
.file-manager-toolbar button {
white-space: nowrap;
}
.details-area {
background: white;
border-radius: 8px;
padding: 20px;
min-height: 500px;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: #999;
}
.empty-state svg {
width: 80px;
height: 80px;
margin-bottom: 15px;
opacity: 0.3;
}
.empty-state p {
font-size: 16px;
}
.details-content {
animation: fadeIn 0.3s;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.detail-section {
margin-bottom: 25px;
}
.detail-section h3 {
color: #667eea;
font-size: 16px;
margin-bottom: 10px;
display: flex;
align-items: center;
gap: 8px;
}
.detail-section h3::before {
content: '';
width: 4px;
height: 16px;
background: #667eea;
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;
border-radius: 6px;
border-left: 3px solid #667eea;
line-height: 1.8;
color: #444;
}
.risk-item {
background: white;
padding: 15px;
margin-bottom: 12px;
border-radius: 6px;
border: 1px solid #e0e0e0;
}
.risk-item h4 {
color: #d32f2f;
font-size: 15px;
margin-bottom: 10px;
}
.risk-field {
margin-bottom: 10px;
line-height: 1.6;
}
.risk-field strong {
color: #333;
display: inline-block;
min-width: 80px;
}
.risk-field p {
color: #555;
display: inline;
}
.scope-item {
background: white;
padding: 10px 15px;
margin-bottom: 8px;
border-radius: 4px;
border-left: 3px solid #667eea;
}
.permit-status {
display: inline-block;
padding: 4px 12px;
border-radius: 4px;
font-size: 13px;
font-weight: 500;
margin-right: 8px;
}
.status-active {
background: #e8f5e9;
color: #2e7d32;
}
.status-inactive {
background: #ffebee;
color: #c62828;
}
.status-unknown {
background: #f1f5f9;
color: #475569;
}
.permit-file-name {
font-weight: 600;
color: #1d4ed8;
}
.muted-text {
color: #94a3b8;
font-size: 14px;
margin: 4px 0;
}
.loading {
display: inline-flex;
align-items: center;
gap: 8px;
color: #475569;
font-size: 14px;
}
.loading-icon {
width: 20px;
height: 20px;
border: 3px solid #f3f3f3;
border-top: 3px solid #667eea;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.error {
background: #ffebee;
color: #c62828;
padding: 15px;
border-radius: 6px;
margin: 15px 0;
border-left: 4px solid #c62828;
}
@media (max-width: 1024px) {
.content-area {
grid-template-columns: 1fr;
}
.panel:first-child {
max-height: 300px;
overflow-y: auto;
}
.user-bar {
position: static;
transform: none;
width: 100%;
justify-content: center;
padding: 14px 16px;
}
.user-info {
align-items: center;
text-align: center;
}
.user-actions {
justify-content: center;
}
.user-time {
justify-content: center;
}
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<div class="header-info">
<h1>🗃️ 数据库维护系统</h1>
<p>LawRisk 法律风险提示系统 - 数据库维护与查询工具</p>
</div>
<div class="user-bar" id="userBar">
<div class="user-avatar" id="userAvatar">U</div>
<div class="user-info">
<div class="user-name" id="userDisplayName">--</div>
<span class="user-role" id="userRole">UNAUTH</span>
<div class="user-time" id="userTime" style="display: none;">
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" style="flex-shrink: 0;">
<circle cx="6" cy="6" r="5.5" stroke="#9ca3af" stroke-width="1"/>
<path d="M6 3.5V6.5L8 8" stroke="#9ca3af" stroke-width="1" stroke-linecap="round"/>
</svg>
<span id="loginTime">--</span>
</div>
<div class="user-alert" id="userStatus"></div>
</div>
<div class="user-actions">
<button type="button" class="btn-logout" id="logoutBtn">退出登录</button>
</div>
</div>
</div>
<div class="step-indicator">
<div class="step active" id="step1">
<div class="step-number">1</div>
<div class="step-label">选择区划</div>
</div>
<div class="arrow"></div>
<div class="step" id="step2">
<div class="step-number">2</div>
<div class="step-label">选择事项</div>
</div>
<div class="arrow"></div>
<div class="step" id="step3">
<div class="step-number">3</div>
<div class="step-label">查看详情</div>
</div>
</div>
<!-- 检查点管理按钮 -->
<div class="checkpoint-toolbar">
<button class="btn btn-primary" onclick="openImportModal()">
<span>📥</span> 许可导入
</button>
<button class="btn btn-secondary" onclick="openFileManagerModal()">
<span>🗂️</span> 文件管理
</button>
<button class="btn btn-warning" onclick="openCheckpointModal()">
<span>🔒</span> 检查点管理
</button>
</div>
<div class="content-area">
<div class="panel">
<h2 id="navTitle">
<span>选择区划</span>
<div class="nav-controls">
<button class="back-button" id="backButton" onclick="goBack()" disabled>← 上一步</button>
</div>
</h2>
<div class="breadcrumb" id="breadcrumb"></div>
<div class="selection-area">
<div id="navList" class="item-list"></div>
</div>
</div>
<div class="panel">
<h2 id="detailsTitle">详情内容</h2>
<div class="details-area" id="detailsArea">
<div class="empty-state">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"/>
</svg>
<p id="emptyMessage">请选择区划开始导航</p>
</div>
</div>
</div>
</div>
</div>
<!-- 危险操作确认模态框 -->
<div class="modal" id="dangerModal">
<div class="modal-content">
<div class="modal-header">
<div class="warning-icon">⚠️</div>
<h3 id="dangerModalTitle">危险操作确认</h3>
</div>
<div class="modal-body">
<p id="dangerModalMessage"></p>
<p class="danger-text" id="dangerModalWarning"></p>
</div>
<div class="modal-footer">
<button class="btn" onclick="closeDangerModal()">取消</button>
<button class="btn btn-danger" id="dangerModalConfirmBtn" onclick="confirmDangerOperation()">确认执行</button>
</div>
</div>
</div>
<!-- 检查点管理模态窗口 -->
<div class="checkpoint-modal" id="checkpointModal">
<div class="checkpoint-modal-content">
<div class="checkpoint-modal-header">
<h2>🔒 数据库检查点管理</h2>
<button class="checkpoint-modal-close" onclick="closeCheckpointModal()">&times;</button>
</div>
<div class="checkpoint-modal-body" id="checkpointModalBody">
<!-- 检查点管理内容将在这里动态加载 -->
</div>
</div>
</div>
<!-- 许可导入模态窗口 -->
<div class="modal" id="importModal">
<div class="modal-content import-modal-content">
<div class="modal-header" style="display:flex; justify-content: space-between; align-items: center;">
<h3 style="color:#3949ab; margin:0; display:flex; align-items:center; gap:8px;">
<span>📥</span> 许可导入Excel
</h3>
<button class="btn btn-warning btn-sm" onclick="closeImportModal()">关闭</button>
</div>
<div class="modal-body" id="importModalBody"></div>
</div>
</div>
<!-- 文件管理模态窗口 -->
<div class="modal" id="fileManagerModal">
<div class="modal-content file-manager-modal-content">
<div class="modal-header" style="display:flex; justify-content: space-between; align-items: center;">
<h3 style="color:#111827; margin:0; display:flex; align-items:center; gap:8px;">
<span>🗂️</span> 文件管理
</h3>
<button class="checkpoint-modal-close" onclick="closeFileManagerModal()">&times;</button>
</div>
<div class="modal-body" id="fileManagerModalBody"></div>
</div>
</div>
<script>
const LOGIN_PATH = '/fs-ai-asistant/lawrisk/login';
let currentUserProfile = null;
let loginTime = null;
const userNameEl = document.getElementById('userDisplayName');
const userRoleEl = document.getElementById('userRole');
const userStatusEl = document.getElementById('userStatus');
const userAvatarEl = document.getElementById('userAvatar');
const userTimeEl = document.getElementById('userTime');
const loginTimeEl = document.getElementById('loginTime');
const logoutBtn = document.getElementById('logoutBtn');
function buildLoginRedirectUrl() {
const next = encodeURIComponent(window.location.pathname + window.location.search);
return `${LOGIN_PATH}?next=${next || '%2F'}`;
}
function updateUserBanner(user, message = '') {
if (!user) {
userNameEl.textContent = '访客';
userRoleEl.textContent = 'LOGIN';
userStatusEl.textContent = message || '登录状态失效,请重新登录';
userAvatarEl.textContent = '?';
userTimeEl.style.display = 'none';
return;
}
const displayName = user.display_name || user.username;
userNameEl.textContent = displayName || user.username;
userRoleEl.textContent = (user.role || 'user').toUpperCase();
userStatusEl.textContent = message || '';
// Update avatar with first letter of username
const initial = (displayName || user.username || '?').charAt(0).toUpperCase();
userAvatarEl.textContent = initial;
// Set login time if not already set
if (!loginTime) {
loginTime = new Date();
const timeStr = loginTime.toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit'
});
loginTimeEl.textContent = `登录于 ${timeStr}`;
userTimeEl.style.display = 'flex';
}
}
async function fetchCurrentUser(redirectOnFail = true) {
try {
const resp = await fetch('/auth/me', { headers: { Accept: 'application/json' } });
if (!resp.ok) {
throw new Error('未登录或登录已过期');
}
const data = await resp.json();
if (!data.authenticated) {
throw new Error('未登录或登录已过期');
}
currentUserProfile = data.user;
updateUserBanner(currentUserProfile);
return currentUserProfile;
} catch (error) {
currentUserProfile = null;
updateUserBanner(null, error.message);
if (redirectOnFail) {
window.location.href = buildLoginRedirectUrl();
}
return null;
}
}
async function handleLogout() {
try {
await fetch('/auth/logout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ reason: 'user_action' }),
});
} finally {
loginTime = null; // Reset login time
window.location.href = buildLoginRedirectUrl();
}
}
if (logoutBtn) {
logoutBtn.addEventListener('click', (event) => {
event.preventDefault();
handleLogout();
});
}
// 导航状态管理
let currentStep = 1; // 1=区划, 2=事项, 3=详情
let historyStack = []; // 历史记录栈
let currentRegion = null;
let currentPermit = null;
let currentPermitDetails = null;
let pendingDangerOperation = null; // 待执行的危险操作
let isDeletingPermit = false;
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 = '';
let expandedSnapshotGroups = new Set();
const PERMIT_FILE_MAX_BYTES = 500 * 1024; // 500KB
const ALL_THEMES_SENTINEL = '__ALL_THEMES__';
const ALL_THEMES_LABEL = '所有主题';
const permitImportState = {
uploading: false,
sessionId: '',
filename: '',
totalRows: 0,
sheetSummaries: [],
selectedSheets: new Set(),
overrides: new Map(),
error: '',
success: '',
commitLoading: false,
editedBy: '',
changeSummary: '',
fileSize: 0,
previewLoading: false,
previewError: '',
previewData: null,
themeBindings: new Map(),
stage: 'upload',
activePreviewSheet: '',
previewThemeKeyword: '',
};
const fileManagerState = {
files: [],
loading: false,
error: '',
keyword: '',
keywordInput: '',
limit: 15,
offset: 0,
total: 0,
deleting: new Set(),
reimporting: new Set(),
};
let fileManagerSearchTimer = null;
const themeDragState = {
active: false,
sheetName: '',
permitName: '',
shouldSelect: true,
};
let themeSearchRenderFrame = null;
window.addEventListener('mouseup', endThemeDragSelection);
window.addEventListener('blur', endThemeDragSelection);
window.addEventListener('mouseleave', endThemeDragSelection);
const EMPTY_PLACEHOLDER = '(未填写)';
function formatNullableText(value, placeholder = EMPTY_PLACEHOLDER) {
if (value === null || value === undefined) {
return placeholder;
}
if (typeof value === 'string') {
const trimmed = value.trim();
return trimmed === '' ? placeholder : trimmed;
}
if (typeof value === 'number') {
return Number.isNaN(value) ? placeholder : String(value);
}
if (typeof value === 'object') {
try {
return JSON.stringify(value);
} catch (error) {
return placeholder;
}
}
return String(value);
}
function formatFileSize(bytes) {
const size = Number(bytes);
if (!Number.isFinite(size) || size <= 0) {
return '0 B';
}
if (size < 1024) {
return `${size.toFixed(0)} B`;
}
if (size < 1024 * 1024) {
const kb = size / 1024;
return `${Number.isInteger(kb) ? kb.toFixed(0) : kb.toFixed(1)} KB`;
}
const mb = size / (1024 * 1024);
return `${Number.isInteger(mb) ? mb.toFixed(0) : mb.toFixed(2)} MB`;
}
// 步骤配置
const steps = {
1: { title: '选择区划' },
2: { title: '选择事项' },
3: { title: '事项详情' }
};
// 加载区划列表
async function loadRegions() {
const navList = document.getElementById('navList');
navList.innerHTML = '<div class="loading"><span class="loading-icon"></span>加载区划列表...</div>';
try {
const response = await fetch('/fs-ai-asistant/api/workflow/lawrisk/admin/regions');
const data = await response.json();
if (data.success) {
navList.innerHTML = '';
if (data.data.regions.length === 0) {
navList.innerHTML = '<div class="error">未找到区划数据</div>';
return;
}
data.data.regions.forEach(region => {
const li = document.createElement('li');
li.innerHTML = `
<span class="item-name">${region.name}</span>
<span class="item-count">点击选择</span>
`;
li.onclick = () => selectRegion(region.id, region.name);
navList.appendChild(li);
});
} else {
navList.innerHTML = `<div class="error">加载区划失败:${data.message}</div>`;
}
} catch (error) {
navList.innerHTML = `<div class="error">网络错误:${error.message}</div>`;
}
}
// 加载许可列表(直接按区划聚合事项)
async function loadPermitsForRegion() {
if (!currentRegion) {
return;
}
const navList = document.getElementById('navList');
navList.innerHTML = '<div class="loading"><span class="loading-icon"></span>加载事项列表...</div>';
try {
const response = await fetch(`/fs-ai-asistant/api/workflow/lawrisk/admin/permits?region=${currentRegion.id}`);
const data = await response.json();
if (data.success) {
const permits = data.data.permits;
if (permits.length === 0) {
navList.innerHTML = `<div class="error">区划 "${currentRegion.name}" 暂无可维护的事项</div>`;
return;
}
let html = '<div class="item-list">';
permits.forEach(permit => {
const rawRiskCount = typeof permit.risk_count === 'number'
? permit.risk_count
: (Array.isArray(permit.risks) ? permit.risks.length : 0);
const riskCount = Number.isFinite(rawRiskCount) ? rawRiskCount : 0;
const themeId = permit.theme && permit.theme.id
? permit.theme.id
: (permit.theme_id || '');
const themeName = permit.theme && permit.theme.name
? permit.theme.name
: (permit.theme_name || '');
const escapedName = permit.name ? permit.name.replace(/'/g, "\\'") : '';
const escapedTheme = themeName ? themeName.replace(/'/g, "\\'") : '';
const escapedThemeId = themeId ? themeId.replace(/'/g, "\\'") : '';
html += `
<li onclick="selectPermit('${permit.id}', '${escapedName}', '${escapedThemeId}', '${escapedTheme}', ${riskCount})">
<span class="item-name">${permit.name}</span>
<span class="item-count">${riskCount} 个风险</span>
</li>
`;
});
html += '</div>';
navList.innerHTML = html;
} else {
navList.innerHTML = `<div class="error">加载事项失败:${data.message}</div>`;
}
} catch (error) {
navList.innerHTML = `<div class="error">网络错误:${error.message}</div>`;
}
}
// 选择地区
async function selectRegion(regionId, regionName) {
// 保存到历史栈
historyStack.push({ step: currentStep, region: currentRegion });
currentRegion = { id: regionId, name: regionName };
currentPermit = null;
currentPermitDetails = null;
// 更新步骤
goToStep(2);
}
// 选择许可
async function selectPermit(permitId, permitName, themeId, themeName, riskCount = 0) {
// 保存到历史栈
historyStack.push({ step: currentStep, permit: currentPermit });
currentPermit = {
id: permitId,
name: permitName,
themeId: themeId || '',
themeName: themeName || '',
riskCount: typeof riskCount === 'number' ? riskCount : 0
};
currentPermitDetails = null;
// 更新步骤
goToStep(3);
}
// 跳转到指定步骤
async function goToStep(step) {
currentStep = step;
// 更新导航标题
document.getElementById('navTitle').querySelector('span').textContent = steps[step].title;
// 更新上一步按钮
const backButton = document.getElementById('backButton');
backButton.disabled = historyStack.length === 0;
// 更新步骤指示器
updateStepIndicator(step);
// 更新面包屑导航
updateBreadcrumb();
// 清空详情区域
const detailsArea = document.getElementById('detailsArea');
if (step === 1) {
detailsArea.innerHTML = `
<div class="empty-state">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"/>
</svg>
<p id="emptyMessage">请选择区域开始导航</p>
</div>
`;
}
// 加载数据
if (step === 1) {
await loadRegions();
} else if (step === 2) {
await loadPermitsForRegion();
} else if (step === 3) {
await showPermitDetails();
}
}
// 更新面包屑导航
function updateBreadcrumb() {
const breadcrumb = document.getElementById('breadcrumb');
let html = `
<span class="breadcrumb-item">
<a onclick="goHome()">首页</a>
</span>
`;
if (currentRegion) {
html += '<span class="breadcrumb-separator"></span>';
if (currentStep > 2) {
html += `
<span class="breadcrumb-item">
<a onclick="quickJump(2)">${currentRegion.name}</a>
</span>
`;
} else {
html += `
<span class="breadcrumb-item">
<span class="breadcrumb-current">${currentRegion.name}</span>
</span>
`;
}
}
if (currentPermit) {
const permitLabel = currentPermit.themeName
? `${currentPermit.themeName} · ${currentPermit.name}`
: currentPermit.name;
html += '<span class="breadcrumb-separator"></span>';
if (currentStep >= 3) {
html += `
<span class="breadcrumb-item">
<span class="breadcrumb-current">${permitLabel}</span>
</span>
`;
} else {
html += `
<span class="breadcrumb-item">
<a onclick="quickJump(3)">${permitLabel}</a>
</span>
`;
}
}
breadcrumb.innerHTML = html;
}
// 快速跳转到指定步骤
function quickJump(targetStep) {
// 清空历史栈中比目标步骤更晚的记录
while (historyStack.length > 0 && historyStack[historyStack.length - 1].step >= targetStep) {
historyStack.pop();
}
// 清理后续状态
if (targetStep <= 2) {
currentPermit = null;
currentPermitDetails = null;
}
goToStep(targetStep);
}
// 返回首页
function goHome() {
currentRegion = null;
currentPermit = null;
currentPermitDetails = null;
historyStack = [];
goToStep(1);
}
// 显示许可详情
async function showPermitDetails() {
const detailsArea = document.getElementById('detailsArea');
detailsArea.innerHTML = '<div class="loading"><span class="loading-icon"></span>加载许可详情...</div>';
try {
if (!currentRegion || !currentPermit) {
detailsArea.innerHTML = '<div class="error">请选择区划和事项后查看详情</div>';
return;
}
const themePart = currentPermit.themeId
? `&theme=${encodeURIComponent(currentPermit.themeId)}`
: '';
const response = await fetch(`/fs-ai-asistant/api/workflow/lawrisk/admin/permit-details?region=${currentRegion.id}&permit=${currentPermit.id}${themePart}`);
const data = await response.json();
if (data.success) {
renderPermitDetails(data.data.permit);
} else {
detailsArea.innerHTML = `<div class="error">加载详情失败:${data.message}</div>`;
}
} catch (error) {
detailsArea.innerHTML = `<div class="error">网络错误:${error.message}</div>`;
}
}
// 回退到上一步
function goBack() {
if (historyStack.length === 0) return;
const prev = historyStack.pop();
// 恢复状态
if (prev.region) currentRegion = prev.region;
if (prev.permit) currentPermit = prev.permit;
// 跳转到上一步
goToStep(prev.step);
}
// 渲染许可详情
function renderPermitDetails(permit) {
const detailsArea = document.querySelector('.details-area');
currentPermitDetails = permit;
const riskCount = Array.isArray(permit.risks) ? permit.risks.length : 0;
const permitTheme = permit && permit.theme ? permit.theme : {};
if (currentPermit) {
currentPermit = {
...currentPermit,
riskCount,
themeId: permitTheme.id || currentPermit.themeId || '',
themeName: permitTheme.name || currentPermit.themeName || ''
};
}
const deleteButtonLabel = isDeletingPermit ? '删除中...' : '删除许可';
const deleteButtonDisabled = isDeletingPermit ? 'disabled' : '';
const permitSourceName = permit && permit.permit_source && permit.permit_source.source_name
? permit.permit_source.source_name
: '';
const permitSourceDisplay = permitSourceName ? escapeHtml(permitSourceName) : EMPTY_PLACEHOLDER;
const permitStatusClass = permit && permit.permit_status
? (permit.permit_status === 'active' ? 'status-active' : 'status-inactive')
: 'status-unknown';
const permitStatusLabel = formatNullableText(permit.permit_status, '未设置');
const permitFile = (permit && permit.permit_file) ? permit.permit_file : {};
const hasPermitFile = Boolean(permitFile.file_id);
const fileUploadedAt = permitFile.created_at ? formatIsoDatetime(permitFile.created_at) : '';
const fileUploadedBy = permitFile.uploaded_by ? escapeHtml(permitFile.uploaded_by) : '';
const downloadDisabledAttr = hasPermitFile ? '' : 'disabled';
const fileInfoText = hasPermitFile
? `<span class="permit-file-name">${escapeHtml(permitFile.filename || '原始文件')}</span>${formatFileSize(permitFile.file_size)}${fileUploadedAt ? ` ${fileUploadedAt}` : ''}${fileUploadedBy ? ` 上传:${fileUploadedBy}` : ''}`
: '<span class="muted-text">暂无关联文件</span>';
const rawThemeList = Array.isArray(permit.themes) ? permit.themes.filter(Boolean) : [];
if (permitTheme && (permitTheme.id || permitTheme.name)) {
rawThemeList.push(permitTheme);
}
if (currentPermit && (currentPermit.themeId || currentPermit.themeName)) {
rawThemeList.push({
id: currentPermit.themeId || '',
name: currentPermit.themeName || '',
});
}
const themeList = [];
const seenThemeKeys = new Set();
rawThemeList.forEach(themeItem => {
if (!themeItem) {
return;
}
const id = typeof themeItem.id === 'string' ? themeItem.id.trim() : (themeItem.id ? String(themeItem.id) : '');
const name = typeof themeItem.name === 'string' ? themeItem.name.trim() : (themeItem.name ? String(themeItem.name) : '');
if (!id && !name) {
return;
}
const key = id || `name:${name}`;
if (seenThemeKeys.has(key)) {
return;
}
seenThemeKeys.add(key);
themeList.push({
id,
name: name || id || '未命名主题',
});
});
const themeListDisplay = themeList.length
? `<div class="theme-tags">${themeList.map(themeItem => `<span class="item-tag">${escapeHtml(themeItem.name)}</span>`).join('')}</div>`
: '<p class="muted-text">暂无主题关联</p>';
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" id="downloadPermitFileBtn" ${downloadDisabledAttr} onclick="downloadPermitFile()" title="${hasPermitFile ? '下载原文件' : '暂无原始文件'}">下载原文件</button>
<button class="btn btn-danger" id="deletePermitBtn" ${deleteButtonDisabled} onclick="confirmDeleteCurrentPermit()">${deleteButtonLabel}</button>
</div>
</div>
`;
// 许可基本信息
html += `
<div class="detail-section">
<h3>许可信息</h3>
<div class="detail-content">
<p><strong>许可名称:</strong>${formatNullableText(permit.name, '(未命名)')}</p>
<p style="margin-top: 10px;"><strong>数据来源:</strong>${permitSourceDisplay}</p>
<p style="margin-top: 10px;"><strong>许可状态:</strong><span class="permit-status ${permitStatusClass}">${permitStatusLabel}</span></p>
<p style="margin-top: 10px;"><strong>原始文件:</strong>${fileInfoText}</p>
<p style="margin-top: 10px;"><strong>子项说明:</strong>${formatNullableText(permit.subitem_summary)}</p>
<p style="margin-top: 10px;"><strong>负责部门:</strong>${formatNullableText(permit.responsible_contact)}</p>
<p style="margin-top: 10px;"><strong>权限划分:</strong>${formatNullableText(permit.jurisdiction_scope)}</p>
</div>
</div>
`;
html += `
<div class="detail-section">
<h3>所属主题</h3>
<div class="detail-content">
${themeListDisplay}
</div>
</div>
`;
// 经营范围
html += '<div class="detail-section"><h3>经营范围</h3><div class="detail-content">';
if (permit.business_scopes && permit.business_scopes.length > 0) {
permit.business_scopes.forEach(scope => {
html += `<div class="scope-item">${formatNullableText(scope.description)}</div>`;
});
} else {
html += '<p class="muted-text">暂无经营范围信息</p>';
}
html += '</div></div>';
// 法律风险
html += '<div class="detail-section"><h3>法律风险</h3><div class="detail-content">';
if (permit.risks && permit.risks.length > 0) {
permit.risks.forEach((risk, index) => {
const riskIdentifier = formatNullableText(risk.id, index + 1);
html += `
<div class="risk-item">
<h4>风险 ${riskIdentifier}</h4>
<div class="risk-field"><strong>风险内容:</strong><p>${formatNullableText(risk.risk_content)}</p></div>
<div class="risk-field"><strong>法律依据:</strong><p>${formatNullableText(risk.legal_basis)}</p></div>
<div class="risk-field"><strong>文件编号:</strong><p>${formatNullableText(risk.document_no)}</p></div>
<div class="risk-field"><strong>摘要:</strong><div style="margin-top: 5px;">${formatNullableText(risk.summary)}</div></div>
</div>
`;
});
} else {
html += '<p class="muted-text">暂无法律风险记录</p>';
}
html += '</div></div>';
html += '</div>';
detailsArea.innerHTML = html;
}
function downloadPermitFile() {
if (!currentRegion || !currentPermit || !currentPermitDetails) {
alert('请先选择许可');
return;
}
if (!currentPermitDetails.permit_file || !currentPermitDetails.permit_file.file_id) {
alert('当前许可没有可下载的原始文件');
return;
}
const url = `/fs-ai-asistant/api/workflow/lawrisk/admin/permit-file/download?region=${encodeURIComponent(currentRegion.id)}&permit=${encodeURIComponent(currentPermit.id)}`;
window.open(url, '_blank');
}
function confirmDeleteCurrentPermit() {
if (isDeletingPermit) {
return;
}
if (!currentRegion || !currentPermit) {
alert('请先选择要删除的许可');
return;
}
const riskCount = currentPermit.riskCount !== undefined
? currentPermit.riskCount
: (currentPermitDetails && Array.isArray(currentPermitDetails.risks) ? currentPermitDetails.risks.length : 0);
const pathParts = [currentRegion.name];
if (currentPermit.themeName) {
pathParts.push(currentPermit.themeName);
}
pathParts.push(currentPermit.name);
const confirmMessage = `确定要删除「${pathParts.join(' ')}」吗?\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 || !currentPermit) {
alert('当前上下文缺失,无法删除');
return;
}
isDeletingPermit = true;
toggleDeletePermitButton(true);
try {
const payload = {
region_id: currentRegion.id,
permit_id: currentPermit.id
};
if (currentPermit.themeId) {
payload.theme_id = currentPermit.themeId;
}
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 <= 3; i++) {
const stepElement = document.getElementById(`step${i}`);
if (!stepElement) {
continue;
}
if (i <= step) {
stepElement.classList.add('active');
} else {
stepElement.classList.remove('active');
}
}
}
// ================ 许可导入功能 ================
async function openImportModal() {
const modal = document.getElementById('importModal');
if (!modal) return;
modal.classList.add('show');
permitImportState.stage = 'upload';
permitImportState.activePreviewSheet = '';
permitImportState.previewThemeKeyword = '';
endThemeDragSelection();
if (!permitImportState.sessionId) {
permitImportState.selectedSheets = new Set();
permitImportState.overrides = new Map();
permitImportState.error = '';
permitImportState.success = '';
permitImportState.filename = '';
permitImportState.totalRows = 0;
permitImportState.fileSize = 0;
}
renderImportModal();
if (permitImportState.sessionId) {
await fetchImportPreview(false);
}
}
function closeImportModal() {
const modal = document.getElementById('importModal');
if (!modal) return;
permitImportState.stage = 'upload';
permitImportState.activePreviewSheet = '';
permitImportState.previewThemeKeyword = '';
endThemeDragSelection();
cancelThemeSearchRender();
modal.classList.remove('show');
}
function toggleSheetSelection(sheetName, forceChecked) {
if (!sheetName) return;
const shouldSelect = typeof forceChecked === 'boolean'
? forceChecked
: !permitImportState.selectedSheets.has(sheetName);
if (shouldSelect) {
permitImportState.selectedSheets.add(sheetName);
} else {
permitImportState.selectedSheets.delete(sheetName);
permitImportState.overrides.delete(sheetName);
permitImportState.themeBindings.delete(sheetName);
}
ensureActivePreviewSheet();
renderImportModal();
}
function toggleSheetSelectionFromEvent(el) {
if (!el || !el.dataset) return;
toggleSheetSelection(el.dataset.sheet || '', el.checked);
}
function getSelectedSheetOrder() {
const summaries = Array.isArray(permitImportState.sheetSummaries)
? permitImportState.sheetSummaries
: [];
return summaries.filter(summary => summary && permitImportState.selectedSheets.has(summary.sheet_name));
}
function ensureActivePreviewSheet() {
if (!permitImportState.selectedSheets || permitImportState.selectedSheets.size === 0) {
permitImportState.activePreviewSheet = '';
return;
}
const previewSheets = new Set(
((permitImportState.previewData && permitImportState.previewData.sheets) || []).map(sheet => sheet.sheet_name)
);
if (
permitImportState.activePreviewSheet &&
permitImportState.selectedSheets.has(permitImportState.activePreviewSheet) &&
(previewSheets.size === 0 || previewSheets.has(permitImportState.activePreviewSheet))
) {
return;
}
const ordered = getSelectedSheetOrder();
for (const summary of ordered) {
if (!summary || !summary.sheet_name) {
continue;
}
if (previewSheets.size === 0 || previewSheets.has(summary.sheet_name)) {
permitImportState.activePreviewSheet = summary.sheet_name;
return;
}
}
permitImportState.activePreviewSheet = ordered.length ? ordered[0].sheet_name : '';
}
function selectAllSheets(selectAll) {
const summaries = Array.isArray(permitImportState.sheetSummaries)
? permitImportState.sheetSummaries
: [];
if (selectAll) {
permitImportState.selectedSheets = new Set(
summaries.map(summary => summary.sheet_name).filter(Boolean)
);
} else {
permitImportState.selectedSheets = new Set();
}
ensureActivePreviewSheet();
renderImportModal();
}
async function enterImportPreviewStage() {
if (!permitImportState.sessionId) {
permitImportState.error = '请先上传并解析Excel文件';
renderImportModal();
return;
}
if (permitImportState.selectedSheets.size === 0) {
permitImportState.error = '请选择至少一个Sheet再进入预览';
renderImportModal();
return;
}
permitImportState.error = '';
permitImportState.stage = 'preview';
permitImportState.previewThemeKeyword = '';
ensureActivePreviewSheet();
renderImportModal();
await fetchImportPreview(false);
ensureActivePreviewSheet();
renderImportModal();
}
function exitImportPreviewStage() {
permitImportState.stage = 'upload';
permitImportState.previewThemeKeyword = '';
permitImportState.activePreviewSheet = '';
endThemeDragSelection();
cancelThemeSearchRender();
renderImportModal();
}
function setActivePreviewSheet(sheetName) {
if (!sheetName || permitImportState.activePreviewSheet === sheetName) {
return;
}
if (!permitImportState.selectedSheets.has(sheetName)) {
return;
}
permitImportState.activePreviewSheet = sheetName;
renderImportModal();
}
function getPreviewSheetByName(sheetName) {
if (!sheetName || !permitImportState.previewData || !Array.isArray(permitImportState.previewData.sheets)) {
return null;
}
return permitImportState.previewData.sheets.find(
sheet => sheet && sheet.sheet_name === sheetName
) || null;
}
function filterPermitsForPreview(sheet) {
if (!sheet) {
return [];
}
return Array.isArray(sheet.permits) ? sheet.permits : [];
}
function filterThemeOptionsForPreview(sheet) {
if (!sheet) {
return [];
}
const options = Array.isArray(sheet.theme_options) ? sheet.theme_options : [];
const keyword = (permitImportState.previewThemeKeyword || '').trim().toLowerCase();
if (!keyword) {
return options;
}
return options.filter(option => {
const name = (option && option.name ? option.name : '').toLowerCase();
return name.includes(keyword);
});
}
function cancelThemeSearchRender() {
if (themeSearchRenderFrame !== null) {
cancelAnimationFrame(themeSearchRenderFrame);
themeSearchRenderFrame = null;
}
}
function scheduleThemeSearchRender() {
if (permitImportState.stage !== 'preview') {
return;
}
if (themeSearchRenderFrame === null) {
themeSearchRenderFrame = requestAnimationFrame(() => {
themeSearchRenderFrame = null;
renderImportModal();
focusThemeSearchInput();
});
}
}
function focusThemeSearchInput() {
if (permitImportState.stage !== 'preview') {
return;
}
const input = document.getElementById('themeSearchInput');
if (input) {
const len = input.value.length;
input.focus();
try {
input.setSelectionRange(len, len);
} catch (err) {
/* ignore selection errors */
}
}
}
function handleThemeSearchInput(event) {
if (!event || !event.target) {
return;
}
permitImportState.previewThemeKeyword = event.target.value || '';
if (event.isComposing) {
return;
}
scheduleThemeSearchRender();
}
function handleThemeSearchCompositionEnd(event) {
if (!event || !event.target) {
return;
}
permitImportState.previewThemeKeyword = event.target.value || '';
scheduleThemeSearchRender();
}
function selectAllThemesForPermit(sheetName, permitName) {
if (!sheetName || !permitName) {
return;
}
const sheet = getPreviewSheetByName(sheetName);
if (!sheet) {
return;
}
const options = Array.isArray(sheet.theme_options) ? sheet.theme_options : [];
if (!options.length) {
return;
}
const bindingSet = getThemeBindingSet(sheetName, permitName, true);
const hasAllThemeOption = options.some(option => resolveThemeOptionNormalized(option) === ALL_THEMES_SENTINEL || option?.is_all);
if (hasAllThemeOption) {
bindingSet.clear();
bindingSet.add(ALL_THEMES_SENTINEL);
renderImportModal();
return;
}
options.forEach(option => {
const normalized = resolveThemeOptionNormalized(option);
if (normalized) {
bindingSet.delete(ALL_THEMES_SENTINEL);
bindingSet.add(normalized);
}
});
renderImportModal();
}
function clearThemesForPermit(sheetName, permitName) {
if (!sheetName || !permitName) {
return;
}
const bindingSet = getThemeBindingSet(sheetName, permitName, true);
if (!bindingSet) {
return;
}
bindingSet.clear();
renderImportModal();
}
function startThemeDragSelection(event, sheetName, permitName, themeName) {
if (event) {
event.preventDefault();
event.stopPropagation();
}
const normalizedTheme = normalizeThemeName(themeName);
if (!sheetName || !permitName || !normalizedTheme) {
return;
}
const bindingSet = getThemeBindingSet(sheetName, permitName, true);
const shouldSelect = !bindingSet.has(normalizedTheme);
themeDragState.active = true;
themeDragState.sheetName = sheetName;
themeDragState.permitName = permitName;
themeDragState.shouldSelect = shouldSelect;
toggleThemeBinding(sheetName, permitName, normalizedTheme, shouldSelect, { render: false });
}
function continueThemeDragSelection(event, sheetName, permitName, themeName) {
if (!themeDragState.active) {
return;
}
if (event) {
event.preventDefault();
event.stopPropagation();
}
if (
sheetName !== themeDragState.sheetName ||
permitName !== themeDragState.permitName
) {
return;
}
toggleThemeBinding(sheetName, permitName, themeName, themeDragState.shouldSelect, { render: false });
}
function endThemeDragSelection() {
if (!themeDragState.active) {
return;
}
themeDragState.active = false;
themeDragState.sheetName = '';
themeDragState.permitName = '';
renderImportModal();
}
function toggleOverridePermit(sheetName, permitName, forceChecked) {
if (!sheetName || !permitName) return;
if (!permitImportState.overrides.has(sheetName)) {
permitImportState.overrides.set(sheetName, new Set());
}
const set = permitImportState.overrides.get(sheetName);
const shouldCheck = typeof forceChecked === 'boolean'
? forceChecked
: !set.has(permitName);
if (shouldCheck) {
set.add(permitName);
} else {
set.delete(permitName);
}
if (set.size === 0) {
permitImportState.overrides.delete(sheetName);
}
renderImportModal();
}
function toggleOverridePermitFromEvent(el) {
if (!el || !el.dataset) return;
toggleOverridePermit(el.dataset.sheet || '', el.dataset.permit || '', el.checked);
}
function updateImportEditedBy(value) {
permitImportState.editedBy = value;
}
function updateImportChangeSummary(value) {
permitImportState.changeSummary = value;
}
function clearImportPreviewState() {
permitImportState.previewData = null;
permitImportState.previewError = '';
permitImportState.previewLoading = false;
permitImportState.themeBindings = new Map();
permitImportState.activePreviewSheet = '';
permitImportState.previewThemeKeyword = '';
cancelThemeSearchRender();
}
function bootstrapImportSessionFromPayload(payload, options = {}) {
if (!payload) {
return false;
}
const sessionId = payload.session_id || payload.sessionId || '';
if (!sessionId) {
return false;
}
const fallbackName = options.filename || '';
const fallbackSize = typeof options.fileSize === 'number' ? options.fileSize : 0;
permitImportState.sessionId = sessionId;
permitImportState.filename = payload.filename || fallbackName;
permitImportState.totalRows = payload.total_rows || payload.totalRows || 0;
permitImportState.sheetSummaries = payload.sheet_summaries || payload.sheetSummaries || [];
permitImportState.fileSize = payload.file_size || payload.fileSize || fallbackSize;
permitImportState.selectedSheets = new Set(
(permitImportState.sheetSummaries || []).map(item => item.sheet_name)
);
permitImportState.overrides = new Map();
permitImportState.success = options.successMessage
|| `解析完成:${permitImportState.sheetSummaries.length} 个 Sheet${permitImportState.totalRows} 条风险记录`;
permitImportState.error = '';
clearImportPreviewState();
return true;
}
function normalizeThemeName(value) {
if (typeof value === 'string') {
const trimmed = value.trim();
if (!trimmed) {
return '';
}
if (trimmed.toLowerCase() === ALL_THEMES_SENTINEL.toLowerCase()) {
return ALL_THEMES_SENTINEL;
}
if (trimmed === ALL_THEMES_LABEL) {
return ALL_THEMES_SENTINEL;
}
return trimmed;
}
if (value === undefined || value === null) {
return '';
}
return String(value).trim();
}
function formatThemeBindingLabel(value) {
return normalizeThemeName(value) === ALL_THEMES_SENTINEL ? ALL_THEMES_LABEL : (value || '');
}
function resolveThemeOptionLabel(option) {
if (!option || typeof option.name !== 'string') {
return '';
}
return option.name.trim();
}
function resolveThemeOptionNormalized(option) {
const label = resolveThemeOptionLabel(option);
if (label) {
const normalizedLabel = normalizeThemeName(label);
if (normalizedLabel) {
return normalizedLabel;
}
}
const identifier = option && option.id ? String(option.id).trim() : '';
return normalizeThemeName(identifier);
}
function resolveThemeOptionBindingValue(option) {
const normalized = resolveThemeOptionNormalized(option);
if (normalized === ALL_THEMES_SENTINEL) {
return ALL_THEMES_LABEL;
}
const label = resolveThemeOptionLabel(option);
if (label) {
return label;
}
return option && option.id ? String(option.id).trim() : '';
}
function hydrateThemeBindingsFromPreview(previewData, previousBindings) {
const nextBindings = new Map();
const sheets = (previewData && previewData.sheets) || [];
sheets.forEach(sheet => {
const sheetName = sheet.sheet_name;
const sheetMap = new Map();
const prevSheetBindings = previousBindings && previousBindings.get(sheetName);
(sheet.permits || []).forEach(permit => {
const permitName = permit.permit_name;
let baseSet = prevSheetBindings && prevSheetBindings.get(permitName);
if (!baseSet || baseSet.size === 0) {
baseSet = new Set(
(permit.default_theme_names || [])
.map(normalizeThemeName)
.filter(Boolean)
);
}
const cleanedSet = new Set();
if (baseSet && baseSet.forEach) {
baseSet.forEach(name => {
const normalized = normalizeThemeName(name);
if (normalized) {
cleanedSet.add(normalized);
}
});
}
if (cleanedSet.size === 0) {
cleanedSet.add('不涉及');
}
sheetMap.set(permitName, cleanedSet);
});
nextBindings.set(sheetName, sheetMap);
});
return nextBindings;
}
function getThemeBindingSet(sheetName, permitName, createIfMissing = false) {
if (!sheetName || !permitName) {
return createIfMissing ? new Set() : null;
}
if (!permitImportState.themeBindings.has(sheetName)) {
if (!createIfMissing) {
return null;
}
permitImportState.themeBindings.set(sheetName, new Map());
}
const sheetMap = permitImportState.themeBindings.get(sheetName);
if (!sheetMap.has(permitName)) {
if (!createIfMissing) {
return null;
}
sheetMap.set(permitName, new Set());
}
return sheetMap.get(permitName);
}
function toggleThemeBinding(sheetName, permitName, themeName, forceChecked, options = {}) {
const normalizedTheme = normalizeThemeName(themeName);
if (!sheetName || !permitName || !normalizedTheme) {
return;
}
const bindingSet = getThemeBindingSet(sheetName, permitName, true);
const shouldAdd = typeof forceChecked === 'boolean'
? forceChecked
: !bindingSet.has(normalizedTheme);
if (shouldAdd) {
if (normalizedTheme === ALL_THEMES_SENTINEL) {
bindingSet.clear();
bindingSet.add(ALL_THEMES_SENTINEL);
} else {
bindingSet.delete(ALL_THEMES_SENTINEL);
bindingSet.add(normalizedTheme);
}
} else {
bindingSet.delete(normalizedTheme);
}
if (options.render === false) {
return;
}
renderImportModal();
}
function toggleThemeBindingFromEvent(el) {
if (!el || !el.dataset) {
return;
}
const sheetName = el.dataset.sheet || '';
const permitName = el.dataset.permit || '';
const themeName = el.dataset.theme || '';
toggleThemeBinding(sheetName, permitName, themeName, el.checked);
}
async function fetchImportPreview(forceReload = false) {
const sessionId = permitImportState.sessionId;
if (!sessionId) {
return;
}
if (!forceReload && permitImportState.previewData && permitImportState.previewData.session_id === sessionId) {
return;
}
permitImportState.previewLoading = true;
permitImportState.previewError = '';
renderImportModal();
try {
const params = new URLSearchParams({ session_id: sessionId, t: Date.now() });
const response = await fetch(`/fs-ai-asistant/api/workflow/lawrisk/admin/permit-import/preview?${params.toString()}`);
const data = await parseJsonResponse(response);
if (data && data.success) {
const preview = data.data || {};
const previousPreview = permitImportState.previewData;
const previousBindings = permitImportState.themeBindings;
permitImportState.previewData = preview;
permitImportState.themeBindings = hydrateThemeBindingsFromPreview(
preview,
previousPreview && previousPreview.session_id === preview.session_id ? previousBindings : null
);
ensureActivePreviewSheet();
} else {
permitImportState.previewData = null;
permitImportState.themeBindings = new Map();
permitImportState.activePreviewSheet = '';
permitImportState.previewError = data && data.message ? data.message : `预览加载失败HTTP ${response.status}`;
}
} catch (error) {
permitImportState.previewData = null;
permitImportState.themeBindings = new Map();
permitImportState.activePreviewSheet = '';
permitImportState.previewError = error.message || '预览加载失败';
} finally {
permitImportState.previewLoading = false;
renderImportModal();
}
}
function buildThemeBindingsPayload() {
const payload = {};
permitImportState.themeBindings.forEach((permitMap, sheetName) => {
if (!permitImportState.selectedSheets.has(sheetName)) {
return;
}
const permitPayload = {};
permitMap.forEach((themeSet, permitName) => {
if (!themeSet || themeSet.size === 0) {
return;
}
permitPayload[permitName] = Array.from(themeSet);
});
if (Object.keys(permitPayload).length > 0) {
payload[sheetName] = permitPayload;
}
});
return payload;
}
function collectMissingThemeBindings() {
const missing = [];
if (!permitImportState.previewData || !Array.isArray(permitImportState.previewData.sheets)) {
return missing;
}
const sheetMap = new Map();
permitImportState.previewData.sheets.forEach(sheet => {
sheetMap.set(sheet.sheet_name, sheet);
});
permitImportState.selectedSheets.forEach(sheetName => {
const sheet = sheetMap.get(sheetName);
if (!sheet || !Array.isArray(sheet.permits)) {
return;
}
sheet.permits.forEach(permit => {
const bindingSet = getThemeBindingSet(sheetName, permit.permit_name, false);
if (!bindingSet || bindingSet.size === 0) {
const regionLabel = sheet.region_name || sheet.sheet_name || '未知地区';
missing.push(`${regionLabel} ${permit.permit_name}`);
}
});
});
return missing;
}
async function handleImportFile(input) {
if (!input || !input.files || !input.files.length) {
return;
}
const file = input.files[0];
if (!file) {
return;
}
if (file.size > PERMIT_FILE_MAX_BYTES) {
permitImportState.error = `文件过大(最大 ${formatFileSize(PERMIT_FILE_MAX_BYTES)}),当前 ${formatFileSize(file.size)}`;
permitImportState.success = '';
permitImportState.fileSize = 0;
input.value = '';
renderImportModal();
return;
}
const formData = new FormData();
formData.append('file', file);
permitImportState.uploading = true;
permitImportState.error = '';
permitImportState.success = '';
renderImportModal();
try {
const response = await fetch('/fs-ai-asistant/api/workflow/lawrisk/admin/permit-import/upload', {
method: 'POST',
body: formData
});
const data = await parseJsonResponse(response);
if (data.success) {
const payload = data.data || {};
const hydrated = bootstrapImportSessionFromPayload(payload, {
filename: (file && file.name) || '',
fileSize: file.size || 0,
});
if (hydrated) {
await fetchImportPreview(true);
} else {
permitImportState.error = '解析结果无效,请重试上传';
permitImportState.sessionId = '';
permitImportState.sheetSummaries = [];
permitImportState.selectedSheets = new Set();
permitImportState.overrides = new Map();
permitImportState.fileSize = 0;
clearImportPreviewState();
}
} else {
permitImportState.error = data.message || '解析失败请检查Excel格式';
permitImportState.sessionId = '';
permitImportState.sheetSummaries = [];
permitImportState.fileSize = 0;
permitImportState.selectedSheets = new Set();
permitImportState.overrides = new Map();
clearImportPreviewState();
}
} catch (error) {
permitImportState.error = error.message || '文件上传失败';
permitImportState.sessionId = '';
permitImportState.sheetSummaries = [];
permitImportState.selectedSheets = new Set();
permitImportState.overrides = new Map();
permitImportState.fileSize = 0;
clearImportPreviewState();
} finally {
permitImportState.uploading = false;
renderImportModal();
}
}
async function submitImport() {
if (!permitImportState.sessionId) {
permitImportState.error = '请先上传并解析Excel文件';
renderImportModal();
return;
}
if (permitImportState.selectedSheets.size === 0) {
permitImportState.error = '请选择至少一个Sheet进行导入';
renderImportModal();
return;
}
if (permitImportState.previewLoading) {
permitImportState.error = '预览数据加载中,请稍候再试';
renderImportModal();
return;
}
if (permitImportState.previewError) {
permitImportState.error = '预览数据加载失败,请重试加载预览后再导入';
renderImportModal();
return;
}
if (!permitImportState.previewData || !Array.isArray(permitImportState.previewData.sheets)) {
permitImportState.error = '预览数据尚未生成请重新上传Excel文件';
renderImportModal();
return;
}
const missingBindings = collectMissingThemeBindings();
if (missingBindings.length > 0) {
const previewText = missingBindings.slice(0, 3).join('、');
const suffix = missingBindings.length > 3 ? ' 等' : '';
permitImportState.error = `请为以下事项选择主题:${previewText}${suffix}`;
renderImportModal();
return;
}
const payload = {
session_id: permitImportState.sessionId,
sheet_names: Array.from(permitImportState.selectedSheets),
overrides: {},
};
permitImportState.overrides.forEach((set, sheet) => {
if (set.size) {
payload.overrides[sheet] = Array.from(set);
}
});
const themeBindingsPayload = buildThemeBindingsPayload();
if (!Object.keys(themeBindingsPayload).length) {
permitImportState.error = '请至少为一个事项选择主题';
renderImportModal();
return;
}
payload.theme_bindings = themeBindingsPayload;
if (permitImportState.editedBy && permitImportState.editedBy.trim()) {
payload.edited_by = permitImportState.editedBy.trim();
}
if (permitImportState.changeSummary && permitImportState.changeSummary.trim()) {
payload.change_summary = permitImportState.changeSummary.trim();
}
permitImportState.commitLoading = true;
permitImportState.error = '';
permitImportState.success = '';
renderImportModal();
try {
const response = await fetch('/fs-ai-asistant/api/workflow/lawrisk/admin/permit-import/commit', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
const data = await parseJsonResponse(response);
if (data.success) {
const result = data.data || {};
const created = (result.created_permits || []).length;
const overwritten = (result.overwritten_permits || []).length;
permitImportState.success = `导入成功:新增 ${created} 个许可,覆盖 ${overwritten} 个许可。`;
permitImportState.sessionId = '';
permitImportState.sheetSummaries = [];
permitImportState.selectedSheets = new Set();
permitImportState.overrides = new Map();
permitImportState.filename = '';
permitImportState.totalRows = 0;
permitImportState.fileSize = 0;
permitImportState.stage = 'upload';
permitImportState.activePreviewSheet = '';
permitImportState.previewThemeKeyword = '';
clearImportPreviewState();
await Promise.all([
refreshPermitRiskSnapshots(false),
refreshCheckpointList(false)
]);
// 如果当前在首页,刷新地区列表,确保新地区可见
if (currentStep === 1) {
await loadRegions();
}
} else {
permitImportState.error = data.message || '导入失败';
}
} catch (error) {
permitImportState.error = error.message || '导入失败';
} finally {
permitImportState.commitLoading = false;
renderImportModal();
}
}
function renderImportModal() {
const container = document.getElementById('importModalBody');
if (!container) return;
const modalContent = document.querySelector('#importModal .import-modal-content');
if (modalContent) {
if (permitImportState.stage === 'preview') {
modalContent.classList.add('import-modal-wide');
} else {
modalContent.classList.remove('import-modal-wide');
}
}
if (permitImportState.stage === 'preview') {
container.innerHTML = renderImportPreviewStage();
} else {
container.innerHTML = renderImportUploadStage();
}
}
function renderImportUploadStage() {
const state = permitImportState;
let html = '<div class="import-section">';
html += '<h3><span>📄</span> 上传 Excel</h3>';
html += '<div class="import-upload-area">';
html += '<input type="file" accept=".xlsx,.xlsm" onchange="handleImportFile(this)">';
html += '<div class="import-template-wrapper">';
html += '<a class="import-template-button" href="/fs-ai-asistant/api/workflow/lawrisk/admin/permit-import/template" download>';
html += '<span class="import-template-icon">📥</span>';
html += '<span class="import-template-text"><span class="import-template-title">下载导入模板</span><span class="import-template-subtitle">包含示例字段与填写说明</span></span>';
html += '<span class="import-template-badge">推荐</span>';
html += '</a>';
html += '</div>';
if (state.sessionId) {
const sheetCount = state.sheetSummaries ? state.sheetSummaries.length : 0;
html += `<div class="import-meta">当前会话:${escapeHtml(state.filename || '(未命名)')} Sheet ${sheetCount} 风险 ${state.totalRows || 0} 条</div>`;
} else {
html += '<div class="import-meta">请选择包含许可数据的 Excel 文件,系统会自动解析所有 Sheet并以区划为单位生成导入任务。</div>';
}
const currentSizeLabel = state.fileSize ? formatFileSize(state.fileSize) : '未选择文件';
html += `<div class="import-meta">文件大小限制:≤ ${formatFileSize(PERMIT_FILE_MAX_BYTES)} 当前:${currentSizeLabel}</div>`;
html += '</div></div>';
if (state.error) {
html += `<div class="import-error">${escapeHtml(state.error)}</div>`;
}
if (state.success && state.stage === 'upload') {
html += `<div class="import-success">${escapeHtml(state.success)}</div>`;
}
if (state.uploading) {
html += '<div class="loading" style="margin: 8px 0;"><span class="loading-icon"></span>正在解析 Excel...</div>';
}
if (state.sessionId && state.sheetSummaries && state.sheetSummaries.length) {
const totalSheets = state.sheetSummaries.length;
html += '<div class="import-section">';
html += '<h3><span>🗂️</span> 选择导入的区划Sheet</h3>';
html += '<div class="import-sheet-toolbar">';
html += `<span>已选择 ${state.selectedSheets.size}/${totalSheets} 个区划,可一次性导入多个区域</span>`;
html += '<div class="toolbar-actions">';
html += `<button class="btn btn-primary btn-sm" onclick="selectAllSheets(true)" ${state.selectedSheets.size === totalSheets ? 'disabled' : ''}>全选区划</button>`;
html += `<button class="btn btn-warning btn-sm" onclick="selectAllSheets(false)" ${state.selectedSheets.size === 0 ? 'disabled' : ''}>清空选择</button>`;
html += '</div></div>';
html += '<div class="import-sheet-list">';
state.sheetSummaries.forEach(summary => {
const sheetName = summary.sheet_name || '';
const selected = state.selectedSheets.has(sheetName);
const duplicates = summary.duplicate_permits || summary.duplicatePermits || [];
const newPermits = summary.new_permits || summary.newPermits || [];
const overrideSet = state.overrides.get(sheetName) || new Set();
const missingRegion = summary.missing_region || summary.missingRegion;
const originalSheets = summary.original_sheet_names || summary.originalSheetNames || [];
const originalLabel = originalSheets.length
? originalSheets.map(name => escapeHtml(name)).join('、')
: escapeHtml(sheetName);
html += `<div class="import-sheet-card ${selected ? 'selected' : ''}">`;
html += '<div class="import-sheet-header">';
html += `<label class="import-checkbox"><input type="checkbox" data-sheet="${escapeHtml(sheetName)}" ${selected ? 'checked' : ''} onchange="toggleSheetSelectionFromEvent(this)"> <span class="import-sheet-title">${escapeHtml(sheetName)}${missingRegion ? '<span style="margin-left:6px;color:#c62828;font-size:12px;">(新地区)</span>' : ''}</span></label>`;
html += `<div class="import-sheet-meta">许可 ${summary.permit_count || 0} 风险 ${summary.risk_count || summary.row_count || 0}</div>`;
html += '</div>';
html += `<div class="import-sheet-submeta">来源 Sheet${originalLabel}</div>`;
if (newPermits.length) {
html += `<div class="import-meta" style="margin-bottom:8px; color:#2e7d32;">新增许可 ${newPermits.length} 项</div>`;
}
if (duplicates.length) {
html += '<div class="import-duplicate-panel">';
html += '<div class="import-duplicate-title">检测到已存在的许可,勾选代表同意覆盖:</div>';
duplicates.forEach(name => {
const checked = overrideSet.has(name);
const disabledAttr = selected ? '' : 'disabled';
html += `<label class="import-checkbox"><input type="checkbox" data-sheet="${escapeHtml(sheetName)}" data-permit="${escapeHtml(name)}" ${checked ? 'checked' : ''} ${disabledAttr} onchange="toggleOverridePermitFromEvent(this)"> <span>${escapeHtml(name)}</span></label>`;
});
html += '</div>';
}
html += '</div>';
});
html += '</div></div>';
}
if (state.sessionId && state.previewLoading) {
html += '<div class="loading" style="margin: 12px 0;"><span class="loading-icon"></span>正在准备预览数据...</div>';
}
html += '<div class="import-section">';
html += '<h3><span>📝</span> 导入说明</h3>';
html += '<div class="import-form-grid">';
html += `<input type="text" placeholder="编辑人(可选)" value="${escapeHtml(state.editedBy)}" oninput="updateImportEditedBy(this.value)">`;
html += `<textarea placeholder="变更摘要 / 导入说明(可选)" oninput="updateImportChangeSummary(this.value)">${escapeHtml(state.changeSummary)}</textarea>`;
html += '</div>';
html += '</div>';
const nextDisabled = !state.sessionId || state.selectedSheets.size === 0 || state.uploading || state.previewLoading;
html += '<div class="import-actions">';
html += '<div class="import-hint">提示:先勾选需要导入的区划,再点击下一步进入“解析预览 & 主题绑定”环节。</div>';
html += `<button class="btn btn-warning" onclick="enterImportPreviewStage()" ${nextDisabled ? 'disabled' : ''}>下一步:预览 & 主题绑定</button>`;
html += '</div>';
return html;
}
function renderImportPreviewStage() {
const state = permitImportState;
const selectedSummaries = getSelectedSheetOrder();
const selectedCount = selectedSummaries.length;
let html = '<div class="import-stage">';
html += '<div class="import-stage-header">';
html += '<h3><span>👀</span> 解析预览 & 主题绑定</h3>';
const commitDisabled = (
state.commitLoading ||
!state.sessionId ||
selectedCount === 0 ||
state.previewLoading ||
!!state.previewError ||
!state.previewData
);
const commitLabel = state.commitLoading ? '导入中...' : '确认并导入';
html += '<div class="preview-stage-actions">';
html += `<button class="btn btn-secondary btn-sm" onclick="exitImportPreviewStage()" ${state.commitLoading ? 'disabled' : ''}>上一步</button>`;
html += `<button class="btn btn-primary btn-sm" onclick="fetchImportPreview(true)" ${state.previewLoading ? 'disabled' : ''}>重新加载预览</button>`;
html += `<button class="btn btn-warning btn-sm" onclick="submitImport()" ${commitDisabled ? 'disabled' : ''}>${commitLabel}</button>`;
html += '</div></div>';
html += `<div class="import-preview-summary"><span>文件:${escapeHtml(state.filename || '(未命名)')}</span><span>已选区划:${selectedCount}</span><span>风险条目:${state.totalRows || 0}</span></div>`;
if (state.error) {
html += `<div class="import-error">${escapeHtml(state.error)}</div>`;
}
if (state.success && state.stage === 'preview') {
html += `<div class="import-success">${escapeHtml(state.success)}</div>`;
}
if (selectedCount === 0) {
html += '<div class="preview-empty-state">请返回上一步,至少勾选一个区划后再进行主题绑定。</div>';
html += '</div>';
return html;
}
html += '<div class="import-preview-tabs">';
selectedSummaries.forEach(summary => {
const sheetName = summary.sheet_name || '';
const isActive = permitImportState.activePreviewSheet === sheetName;
const sheetLabel = summary.region_name || sheetName || '未命名 Sheet';
const safeSheet = sheetName.replace(/'/g, "\\'");
html += `<button class="preview-tab ${isActive ? 'active' : ''}" onclick="setActivePreviewSheet('${safeSheet}')">`;
html += `<span>${escapeHtml(sheetLabel)}</span>`;
html += `<span class="preview-tab-badge">${summary.permit_count || 0} 项</span>`;
html += '</button>';
});
html += '</div>';
if (state.previewLoading && !state.previewData) {
html += '<div class="loading"><span class="loading-icon"></span>正在生成预览...</div>';
html += '</div>';
return html;
}
if (state.previewError) {
html += `<div class="import-error">${escapeHtml(state.previewError)}</div>`;
html += '<div style="margin-top: 12px;"><button class="btn btn-primary btn-sm" onclick="fetchImportPreview(true)">重新加载预览</button></div>';
html += '</div>';
return html;
}
if (!state.previewData || !Array.isArray(state.previewData.sheets) || state.previewData.sheets.length === 0) {
html += '<div class="preview-empty-state">预览数据暂不可用,请重新上传 Excel。</div>';
html += '</div>';
return html;
}
ensureActivePreviewSheet();
if (!permitImportState.activePreviewSheet) {
html += '<div class="preview-empty-state">请选择上方的区划标签查看详情。</div>';
html += '</div>';
return html;
}
const activeSheet = getPreviewSheetByName(permitImportState.activePreviewSheet);
if (!activeSheet) {
html += '<div class="preview-empty-state">当前区划的预览数据不存在,请重新加载。</div>';
html += '</div>';
return html;
}
if (!state.previewLoading) {
html += '<div class="preview-toolbar">';
html += `<label>主题搜索关键字<input type="text" id="themeSearchInput" placeholder="按主题名称过滤" value="${escapeHtml(state.previewThemeKeyword || '')}" oninput="handleThemeSearchInput(event)" oncompositionend="handleThemeSearchCompositionEnd(event)" autocomplete="off"></label>`;
html += '</div>';
}
if (state.previewLoading) {
html += '<div class="loading"><span class="loading-icon"></span>预览刷新中...</div>';
}
html += buildPreviewSheetContent(activeSheet);
if (state.previewLoading) {
html += '<p class="muted-text" style="text-align:center;">预览刷新中,稍后列表将自动更新</p>';
}
html += '</div>';
return html;
}
function buildPreviewSheetContent(sheet) {
if (!sheet) {
return '<div class="preview-empty-state">暂无可展示的数据</div>';
}
const regionLabel = sheet.region_name || sheet.sheet_name || '未知区划';
const filteredPermits = filterPermitsForPreview(sheet);
const totalPermits = Array.isArray(sheet.permits) ? sheet.permits.length : 0;
const themeOptions = filterThemeOptionsForPreview(sheet);
const totalThemes = Array.isArray(sheet.theme_options) ? sheet.theme_options.length : 0;
let html = '<div class="import-preview-summary">';
html += `<span>区划:${escapeHtml(regionLabel)}</span>`;
html += `<span>许可事项:${filteredPermits.length}/${totalPermits}</span>`;
html += `<span>主题候选:${themeOptions.length}/${totalThemes}</span>`;
html += '</div>';
if (sheet.missing_region) {
html += '<div class="import-error">检测到新地区,导入后会自动创建该区划,请确认名称是否正确。</div>';
}
if (!filteredPermits.length) {
html += '<div class="preview-empty-state">当前区划暂无可导入的事项。</div>';
return html;
}
html += '<div class="preview-permit-grid">';
filteredPermits.forEach(permit => {
if (!permit) {
return;
}
const bindingSet = getThemeBindingSet(sheet.sheet_name, permit.permit_name, true);
const selectedThemes = Array.from(bindingSet || []).map(formatThemeBindingLabel);
const defaultThemes = Array.isArray(permit.default_theme_names) ? permit.default_theme_names : [];
const safeSheet = (sheet.sheet_name || '').replace(/'/g, "\\'");
const safePermit = (permit.permit_name || '').replace(/'/g, "\\'");
const badgeClass = permit.is_duplicate ? 'duplicate' : (permit.is_new ? 'new' : '');
const badgeLabel = permit.is_duplicate ? '覆盖现有' : (permit.is_new ? '新增事项' : '已有事项');
html += `<div class="preview-permit-card ${badgeClass}">`;
html += '<div class="preview-permit-header">';
html += `<div style="display:flex;justify-content:space-between;align-items:center;gap:8px;"><strong>${escapeHtml(permit.permit_name)}</strong><span class="permit-badge ${badgeClass}">${badgeLabel}</span></div>`;
html += `<span class="muted-text">风险 ${permit.risk_count || 0} 条</span>`;
if (defaultThemes.length) {
html += `<span class="muted-text">Excel 主题:${defaultThemes.map(escapeHtml).join('、')}</span>`;
}
if (permit.sample_risk) {
html += `<span class="muted-text">示例风险:${escapeHtml(truncateText(permit.sample_risk, 80))}</span>`;
}
html += '</div>';
if (!themeOptions.length) {
html += '<div class="preview-empty-state" style="margin-top:12px;">当前区划暂无可选主题,请先创建主题或清空搜索条件。</div>';
} else {
html += '<div class="theme-chip-grid">';
themeOptions.forEach(option => {
const normalized = resolveThemeOptionNormalized(option);
const isAllThemeOption = normalized === ALL_THEMES_SENTINEL || Boolean(option && option.is_all);
const bindingValue = resolveThemeOptionBindingValue(option);
const label = resolveThemeOptionLabel(option) || bindingValue || '未命名主题';
const selected = normalized && bindingSet.has(normalized);
const workbookBadge = option && option.source === 'workbook' ? '<span class="theme-source-badge">Excel</span>' : '';
const specialBadge = isAllThemeOption ? '<span class="theme-source-badge">ALL</span>' : '';
const safeTheme = bindingValue.replace(/'/g, "\\'");
const displayLabel = isAllThemeOption ? ALL_THEMES_LABEL : label;
const tooltip = isAllThemeOption ? '绑定后保持与全部主题同步(含未来新增)' : displayLabel;
html += `
<div class="theme-chip ${isAllThemeOption ? 'all-theme' : ''} ${selected ? 'selected' : ''}"
title="${escapeHtml(tooltip)}"
onmousedown="startThemeDragSelection(event, '${safeSheet}', '${safePermit}', '${safeTheme}')"
onmouseenter="continueThemeDragSelection(event, '${safeSheet}', '${safePermit}', '${safeTheme}')">
${escapeHtml(displayLabel)}
${specialBadge || workbookBadge}
</div>
`;
});
html += '</div>';
html += `<div class="theme-chip-actions">
<button class="btn btn-primary btn-sm" onclick="selectAllThemesForPermit('${safeSheet}', '${safePermit}')">全选主题</button>
<button class="btn btn-warning btn-sm" onclick="clearThemesForPermit('${safeSheet}', '${safePermit}')">清空</button>
</div>`;
html += '<div class="muted-text" style="margin-top:6px;">若选择“所有主题”,该事项将自动出现在当前及未来新增的主题下。</div>';
}
if (selectedThemes.length) {
html += `<div class="preview-permit-meta">已绑定主题:${selectedThemes.map(escapeHtml).join('、')}</div>`;
} else {
html += '<div class="preview-permit-meta"><span class="muted-text">尚未选择主题,导入前必须为该事项选择至少一个主题。</span></div>';
}
html += '</div>';
});
html += '</div>';
return html;
}
// ================ 文件管理功能 ================
async function openFileManagerModal() {
const modal = document.getElementById('fileManagerModal');
if (!modal) return;
modal.classList.add('show');
renderFileManagerModal();
await fetchFileManagerData();
}
function closeFileManagerModal() {
const modal = document.getElementById('fileManagerModal');
if (!modal) return;
modal.classList.remove('show');
if (fileManagerSearchTimer) {
clearTimeout(fileManagerSearchTimer);
fileManagerSearchTimer = null;
}
}
function renderFileManagerModal() {
const modalBody = document.getElementById('fileManagerModalBody');
if (!modalBody) return;
const keywordValue = fileManagerState.keywordInput || '';
const loading = fileManagerState.loading;
const files = Array.isArray(fileManagerState.files) ? fileManagerState.files : [];
let html = '<div class="file-manager-toolbar">';
html += `
<div class="file-manager-search">
<input
type="text"
placeholder="按文件名或许可名称搜索"
value="${escapeHtml(keywordValue)}"
oninput="handleFileManagerSearchInput(event)"
onkeydown="handleFileManagerSearchKeydown(event)"
>
</div>
`;
html += '<div style="display:flex; gap:8px;">';
html += `<button class="btn btn-primary btn-sm" onclick="triggerFileManagerSearch()" ${loading ? 'disabled' : ''}><span>🔍</span> 搜索</button>`;
html += `<button class="btn btn-warning btn-sm" onclick="fetchFileManagerData()" ${loading ? 'disabled' : ''}><span>🔄</span> 刷新</button>`;
html += '</div></div>';
if (fileManagerState.error) {
html += `<div class="error">${escapeHtml(fileManagerState.error)}</div>`;
}
if (loading && !files.length) {
html += '<div class="loading"><span class="loading-icon"></span>正在加载文件...</div>';
} else if (!files.length) {
html += '<div class="file-manager-empty">暂无上传的许可导入文件</div>';
} else {
html += '<div class="file-manager-table-wrapper"><table class="file-manager-table">';
html += '<thead><tr><th>文件信息</th><th>关联许可</th><th style="width:180px;">操作</th></tr></thead><tbody>';
files.forEach(file => {
if (!file) {
return;
}
const fileId = file.file_id || '';
const safeFileId = fileId.replace(/'/g, "\\'");
const fileName = file.filename || '(未命名文件)';
const uploadedBy = file.uploaded_by || '未知用户';
const createdLabel = formatIsoDatetime(file.created_at) || '--';
const sizeLabel = formatFileSize(file.file_size || 0);
const links = Array.isArray(file.links) ? file.links : [];
let linkHtml = '';
if (!links.length) {
linkHtml = '<span class="muted-text">暂无关联许可</span>';
} else {
const maxDisplay = 4;
const displayLinks = links.slice(0, maxDisplay);
linkHtml = '<div class="file-manager-tags">';
displayLinks.forEach(link => {
const region = link && link.region_name ? link.region_name : '';
const permitName = link && link.permit_name ? link.permit_name : '';
const labelParts = [];
if (region) labelParts.push(region);
if (permitName) labelParts.push(permitName);
const label = labelParts.length ? labelParts.join(' · ') : (permitName || '未命名事项');
linkHtml += `<span class="file-manager-tag">${escapeHtml(label)}</span>`;
});
if (links.length > maxDisplay) {
linkHtml += `<span class="file-manager-tag file-manager-tag-muted">+${links.length - maxDisplay}</span>`;
}
linkHtml += '</div>';
}
const isReimporting = fileManagerState.reimporting.has(fileId);
const isDeleting = fileManagerState.deleting.has(fileId);
html += '<tr>';
html += `<td><div class="file-manager-file-name">${escapeHtml(fileName)}</div>`;
html += `<div class="file-manager-file-meta">大小:${sizeLabel} 上传:${escapeHtml(uploadedBy)} 时间:${escapeHtml(createdLabel)}</div></td>`;
html += `<td>${linkHtml}</td>`;
html += '<td>';
html += '<div class="file-manager-actions">';
html += `<button class="btn btn-primary btn-sm" onclick="triggerFileReimport('${safeFileId}')" ${isReimporting || loading ? 'disabled' : ''}>${isReimporting ? '处理中...' : '重新导入'}</button>`;
html += `<button class="btn btn-danger btn-sm" onclick="confirmDeleteStoredFile('${safeFileId}')" ${isDeleting || loading ? 'disabled' : ''}>${isDeleting ? '删除中...' : '删除文件'}</button>`;
html += '</div>';
html += '</td>';
html += '</tr>';
});
html += '</tbody></table></div>';
}
const limit = Math.max(1, parseInt(fileManagerState.limit, 10) || 1);
const total = Math.max(0, parseInt(fileManagerState.total, 10) || 0);
const currentOffset = Math.max(0, parseInt(fileManagerState.offset, 10) || 0);
const totalPages = Math.max(1, Math.ceil(total / limit || 1));
const currentPage = Math.min(totalPages, Math.floor(currentOffset / limit) + 1);
const disablePrev = currentOffset <= 0;
const disableNext = currentOffset + limit >= total;
html += '<div class="file-manager-pagination">';
html += `<div>共 ${total} 个文件 ${currentPage}/${totalPages} 页</div>`;
html += '<div style="display:flex; gap:8px;">';
html += `<button onclick="changeFileManagerPage(-1)" ${disablePrev ? 'disabled' : ''}>上一页</button>`;
html += `<button onclick="changeFileManagerPage(1)" ${disableNext ? 'disabled' : ''}>下一页</button>`;
html += '</div></div>';
modalBody.innerHTML = html;
}
function handleFileManagerSearchInput(event) {
if (!event || !event.target) return;
const value = event.target.value || '';
fileManagerState.keywordInput = value;
if (fileManagerSearchTimer) {
clearTimeout(fileManagerSearchTimer);
}
fileManagerSearchTimer = setTimeout(() => {
fileManagerState.keyword = value.trim();
fetchFileManagerData({ resetOffset: true });
}, 400);
}
function handleFileManagerSearchKeydown(event) {
if (!event) return;
if (event.key === 'Enter') {
event.preventDefault();
if (fileManagerSearchTimer) {
clearTimeout(fileManagerSearchTimer);
fileManagerSearchTimer = null;
}
fileManagerState.keyword = (event.target.value || '').trim();
fetchFileManagerData({ resetOffset: true });
}
}
function triggerFileManagerSearch() {
if (fileManagerSearchTimer) {
clearTimeout(fileManagerSearchTimer);
fileManagerSearchTimer = null;
}
fileManagerState.keyword = (fileManagerState.keywordInput || '').trim();
fetchFileManagerData({ resetOffset: true });
}
async function fetchFileManagerData(options = {}) {
if (options.resetOffset) {
fileManagerState.offset = 0;
}
const limit = Math.max(1, parseInt(fileManagerState.limit, 10) || 15);
const offset = Math.max(0, parseInt(fileManagerState.offset, 10) || 0);
fileManagerState.limit = limit;
fileManagerState.offset = offset;
fileManagerState.loading = true;
fileManagerState.error = '';
renderFileManagerModal();
const params = new URLSearchParams({
limit: String(limit),
offset: String(offset),
});
const keyword = (fileManagerState.keyword || '').trim();
if (keyword) {
params.set('keyword', keyword);
}
try {
const response = await fetch(`/fs-ai-asistant/api/workflow/lawrisk/admin/permit-files?${params.toString()}`);
const data = await parseJsonResponse(response);
if (data.success) {
const payload = data.data || {};
fileManagerState.files = Array.isArray(payload.files) ? payload.files : [];
const pagination = payload.pagination || {};
const nextLimit = Number(pagination.limit);
const nextOffset = Number(pagination.offset);
const nextTotal = Number(pagination.total);
if (!Number.isNaN(nextLimit) && nextLimit > 0) {
fileManagerState.limit = nextLimit;
}
if (!Number.isNaN(nextOffset) && nextOffset >= 0) {
fileManagerState.offset = nextOffset;
}
fileManagerState.total = Number.isNaN(nextTotal) ? fileManagerState.files.length : Math.max(0, nextTotal);
} else {
fileManagerState.files = [];
fileManagerState.error = data.message || '文件列表加载失败';
fileManagerState.total = 0;
}
} catch (error) {
fileManagerState.files = [];
fileManagerState.error = error.message || '文件列表加载失败';
fileManagerState.total = 0;
} finally {
fileManagerState.loading = false;
renderFileManagerModal();
}
}
function changeFileManagerPage(delta) {
if (!delta) {
return;
}
const limit = Math.max(1, parseInt(fileManagerState.limit, 10) || 1);
const total = Math.max(0, parseInt(fileManagerState.total, 10) || 0);
let nextOffset = fileManagerState.offset + delta * limit;
if (nextOffset < 0) {
nextOffset = 0;
}
if (delta > 0 && nextOffset >= total) {
return;
}
if (nextOffset === fileManagerState.offset) {
return;
}
fileManagerState.offset = nextOffset;
fetchFileManagerData();
}
async function triggerFileReimport(fileId) {
if (!fileId || fileManagerState.reimporting.has(fileId)) {
return;
}
fileManagerState.reimporting.add(fileId);
renderFileManagerModal();
try {
const response = await fetch(`/fs-ai-asistant/api/workflow/lawrisk/admin/permit-files/${fileId}/reimport`, {
method: 'POST'
});
const data = await parseJsonResponse(response);
if (data.success) {
const hydrated = bootstrapImportSessionFromPayload(data.data || {}, {
successMessage: '已载入存档文件,请完成导入流程',
});
if (!hydrated) {
throw new Error('会话数据无效');
}
closeFileManagerModal();
await openImportModal();
} else {
alert(`重新导入失败:${data.message || '服务器错误'}`);
}
} catch (error) {
alert(`重新导入失败:${error.message}`);
} finally {
fileManagerState.reimporting.delete(fileId);
renderFileManagerModal();
}
}
async function confirmDeleteStoredFile(fileId) {
if (!fileId || fileManagerState.deleting.has(fileId)) {
return;
}
const confirmed = confirm('确定要删除该文件吗?删除后关联许可将无法下载原始文件。');
if (!confirmed) {
return;
}
fileManagerState.deleting.add(fileId);
renderFileManagerModal();
try {
const response = await fetch(`/fs-ai-asistant/api/workflow/lawrisk/admin/permit-files/${fileId}`, {
method: 'DELETE'
});
const data = await parseJsonResponse(response);
if (data.success) {
await fetchFileManagerData();
} else {
alert(`删除失败:${data.message || '服务器错误'}`);
}
} catch (error) {
alert(`删除失败:${error.message}`);
} finally {
fileManagerState.deleting.delete(fileId);
renderFileManagerModal();
}
}
// ================ 检查点管理功能 ================
// 打开检查点模态窗口
async function openCheckpointModal() {
const modal = document.getElementById('checkpointModal');
modal.classList.add('show');
// 重置并显示加载状态
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 = '';
checkpointListCache = [];
checkpointListLoading = true;
checkpointListError = '';
expandedSnapshotGroups = new Set();
renderCheckpointManager(checkpointListCache);
await Promise.all([
ensureRegionFilterOptions(false),
refreshPermitRiskSnapshots(false),
refreshCheckpointList(false)
]);
}
// 关闭检查点模态窗口
function closeCheckpointModal() {
const modal = document.getElementById('checkpointModal');
modal.classList.remove('show');
}
// 点击模态窗口外部关闭
document.getElementById('checkpointModal').addEventListener('click', function(e) {
if (e.target === this) {
closeCheckpointModal();
}
});
// 加载检查点列表(保持兼容性)
async function loadCheckpoints() {
await openCheckpointModal();
}
// 渲染检查点管理界面
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"><span class="loading-icon"></span>加载许可风险快照与检查点...</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-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 sourceNameRaw = primary.permit_source_name || group.permit_source_name || '';
const sourceTag = sourceNameRaw ? `<span>来源:${escapeHtml(sourceNameRaw)}</span>` : '';
const legalSegments = [];
if (primary.legal_basis) {
legalSegments.push(`📕 ${escapeHtml(primary.legal_basis)}`);
}
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 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)}`);
}
if (detail.permit_source_name) {
detailMetaParts.push(`来源:${escapeHtml(detail.permit_source_name)}`);
}
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>
${sourceTag}
</div>
`;
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">风险快照 · ${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}
${metaHtml}
${changeSummaryMerged}
<div class="timeline-actions">
${toggleButton}
${restoreButton}
</div>
${detailListHtml}
</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 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">${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>
</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/ 目录中,重启应用后不会丢失。
每个检查点包含完整的数据库备份,可用于快速回滚。
</div>
</div>
`;
html += `
<div class="checkpoint-section">
<h3>创建新检查点</h3>
<div class="checkpoint-form">
<div class="form-group">
<label for="checkpointDescription">检查点描述(可选):</label>
<input type="text" id="checkpointDescription" placeholder="例如:修改风险提示前的备份">
</div>
<button class="btn btn-primary" onclick="createCheckpoint()">
<span>📸</span> 创建检查点
</button>
</div>
</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;
expandedSnapshotGroups = new Set();
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;
const btn = event.target;
const originalText = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = '<div class="loading"><span class="loading-icon"></span>创建中...</div>';
try {
const response = await fetch('/fs-ai-asistant/api/workflow/lawrisk/admin/checkpoints', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ description })
});
const data = await response.json();
if (data.success) {
document.getElementById('checkpointDescription').value = '';
await refreshCheckpointList();
// 显示成功消息
setTimeout(() => {
alert(`✅ 检查点创建成功!\n\nID: ${data.data.checkpoint_id}\n备份了 ${data.data.total_rows} 行数据`);
}, 100);
} else {
setTimeout(() => {
alert(`❌ 创建失败:${data.message}`);
}, 100);
}
} catch (error) {
setTimeout(() => {
alert(`❌ 网络错误:${error.message}`);
}, 100);
} finally {
btn.disabled = false;
btn.innerHTML = originalText;
}
}
// 确认恢复检查点
function confirmRestoreCheckpoint(checkpointId) {
showDangerModal(
'恢复检查点',
`您确定要恢复检查点 "${checkpointId}" 吗?`,
'此操作将覆盖当前数据库中的所有数据,且无法撤销!请确保您已经创建了新的检查点作为备份。',
() => restoreCheckpoint(checkpointId)
);
}
// 恢复检查点
async function restoreCheckpoint(checkpointId) {
closeDangerModal();
// 显示恢复进度模态框
showRestoreProgressModal(checkpointId);
try {
const response = await fetch(`/fs-ai-asistant/api/workflow/lawrisk/admin/checkpoints/${checkpointId}/restore`, {
method: 'POST'
});
const data = await response.json();
// 关闭进度模态框
closeRestoreProgressModal();
if (data.success) {
await Promise.all([
refreshCheckpointList(),
refreshPermitRiskSnapshots()
]);
setTimeout(() => {
alert(`✅ 检查点恢复成功!\n\n恢复了 ${data.data.total_rows_restored} 行数据,覆盖了 ${data.data.tables_restored} 个表。`);
}, 100);
} else {
setTimeout(() => {
alert(`❌ 恢复失败:${data.message}`);
}, 100);
}
} catch (error) {
closeRestoreProgressModal();
setTimeout(() => {
alert(`❌ 网络错误:${error.message}`);
}, 100);
}
}
// 显示恢复进度模态框
function showRestoreProgressModal(checkpointId) {
const modal = document.createElement('div');
modal.id = 'restoreProgressModal';
modal.className = 'modal show';
modal.innerHTML = `
<div class="modal-content" style="max-width: 450px; text-align: center; padding: 40px;">
<div style="margin-bottom: 20px;">
<div class="loading" style="justify-content: center; margin: 0 auto;">
<span class="loading-icon" style="width: 50px; height: 50px; border-width: 4px;"></span>
</div>
</div>
<h3 style="color: #333; margin-bottom: 15px;">正在恢复检查点...</h3>
<p style="color: #666; font-size: 14px; line-height: 1.6;">
正在从备份恢复数据库<br>
<strong>${checkpointId}</strong><br>
<span style="color: #999; font-size: 12px; margin-top: 10px; display: block;">
此操作可能需要几分钟时间,请耐心等待...
</span>
</p>
</div>
`;
document.body.appendChild(modal);
}
// 关闭恢复进度模态框
function closeRestoreProgressModal() {
const modal = document.getElementById('restoreProgressModal');
if (modal) {
modal.remove();
}
}
// 删除检查点
async function deleteCheckpoint(checkpointId) {
if (!confirm(`确定要删除检查点 "${checkpointId}" 吗?此操作无法撤销。`)) {
return;
}
try {
const response = await fetch(`/fs-ai-asistant/api/workflow/lawrisk/admin/checkpoints/${checkpointId}`, {
method: 'DELETE'
});
const data = await parseJsonResponse(response);
if (data.success) {
await refreshCheckpointList();
setTimeout(() => {
alert(`✅ 检查点已删除`);
}, 100);
} else {
setTimeout(() => {
alert(`❌ 删除失败:${data.message}`);
}, 100);
}
} catch (error) {
setTimeout(() => {
alert(`❌ 网络错误:${error.message}`);
}, 100);
}
}
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 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 (currentStep >= 2) {
await loadPermitsForRegion();
}
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})/);
if (!match) return '';
return `${match[1]}-${match[2]}-${match[3]}T${match[4]}:${match[5]}:${match[6]}`;
}
function buildTimelineItems(snapshotItems, checkpointItems) {
const items = [];
const snapshotGroups = new Map();
(snapshotItems || []).forEach(item => {
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-group',
timeValue,
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,
},
});
});
(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 || timestamp.length < 15) {
return timestamp || '';
}
const year = timestamp.substring(0, 4);
const month = timestamp.substring(4, 6);
const day = timestamp.substring(6, 8);
const hour = timestamp.substring(9, 11);
const minute = timestamp.substring(11, 13);
const second = timestamp.substring(13, 15);
return `${year}-${month}-${day} ${hour}:${minute}:${second}`;
}
// ================ 危险操作确认模态框 ================
// 显示危险操作确认模态框
function showDangerModal(title, message, warning, callback) {
pendingDangerOperation = callback;
document.getElementById('dangerModalTitle').textContent = title;
document.getElementById('dangerModalMessage').textContent = message;
document.getElementById('dangerModalWarning').textContent = warning;
document.getElementById('dangerModal').classList.add('show');
}
// 关闭危险操作确认模态框
function closeDangerModal() {
document.getElementById('dangerModal').classList.remove('show');
pendingDangerOperation = null;
}
// 确认执行危险操作
function confirmDangerOperation() {
if (pendingDangerOperation && typeof pendingDangerOperation === 'function') {
pendingDangerOperation();
}
closeDangerModal();
}
// 页面加载时初始化
window.addEventListener('DOMContentLoaded', async () => {
await fetchCurrentUser(true);
goToStep(1);
});
</script>
</body>
</html>