fs-lawrisk/static/db_admin.html

6986 lines
274 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, #2c5282 0%, #1e3a5f 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: 20px;
padding-bottom: 20px;
border-bottom: 3px solid #2c5282;
}
.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;
}
.tabs-container {
margin-bottom: 0;
border-bottom: 2px solid #e5e7eb;
}
.tabs-nav {
display: flex;
gap: 4px;
padding: 0 20px;
list-style: none;
margin: 0;
flex-wrap: wrap;
}
.tab-button {
padding: 12px 24px;
border: none;
background: transparent;
color: #6b7280;
font-size: 14px;
font-weight: 500;
cursor: pointer;
border-bottom: 3px solid transparent;
margin-bottom: -2px;
transition: all 0.3s;
display: flex;
align-items: center;
gap: 8px;
}
.tab-button:hover {
color: #2c5282;
background: #f7fafc;
}
.tab-button.active {
color: #2c5282;
border-bottom-color: #2c5282;
background: #f7fafc;
}
.tab-badge {
background: #e2e8f0;
color: #2c5282;
padding: 2px 8px;
border-radius: 12px;
font-size: 11px;
font-weight: 600;
}
.tab-content {
display: none;
padding: 24px 20px;
animation: fadeIn 0.3s;
}
.tab-content.active {
display: block;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes slideInRight {
from {
opacity: 0;
transform: translateX(100px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@keyframes fadeOut {
from {
opacity: 1;
transform: translateX(0);
}
to {
opacity: 0;
transform: translateX(100px);
}
}
.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, #2c5282 0%, #1e3a5f 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, #f7fafc 0%, #e2e8f0 100%);
color: #2c5282;
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: #f7fafc;
color: #2c5282;
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
font-size: 16px;
}
.step.active .step-number {
background: #2c5282;
color: white;
}
.step-label {
font-size: 14px;
color: #666;
font-weight: 500;
}
.step.active .step-label {
color: #2c5282;
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 #2c5282;
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: #2c5282;
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: #f7fafc;
border-color: #2c5282;
transform: translateX(5px);
}
.item-list li.active {
background: #2c5282;
color: white;
border-color: #2c5282;
}
.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: #2c5282;
}
.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: #2c5282;
}
.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 #f7fafc;
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: #2c5282;
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 #2c5282;
}
.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: #2c5282;
}
.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: #2c5282;
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: #2c5282;
}
.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;
}
.drag-drop-area {
border: 2px dashed #9fa8da;
border-radius: 12px;
padding: 40px;
text-align: center;
background: #f5f7ff;
cursor: pointer;
transition: all 0.3s ease;
position: relative;
overflow: hidden;
}
.drag-drop-area:hover {
border-color: #2c5282;
background: #f0f2ff;
transform: translateY(-2px);
box-shadow: 0 8px 20px rgba(102, 126, 234, 0.15);
}
.drag-drop-area.drag-over {
border-color: #2c5282;
border-style: solid;
background: #e8ebff;
transform: scale(1.02);
}
.drag-drop-area.drag-over::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(135deg, rgba(102, 126, 234, 0.1) 0%, rgba(118, 75, 162, 0.1) 100%);
animation: pulse 1.5s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% {
opacity: 0.3;
}
50% {
opacity: 0.5;
}
}
.drag-drop-icon {
font-size: 48px;
margin-bottom: 16px;
opacity: 0.6;
}
.drag-drop-text {
font-size: 16px;
font-weight: 600;
color: #2c5282;
margin-bottom: 8px;
}
.drag-drop-hint {
font-size: 13px;
color: #666;
margin-bottom: 20px;
}
.drag-drop-or {
font-size: 12px;
color: #999;
margin: 16px 0;
position: relative;
}
.drag-drop-or::before,
.drag-drop-or::after {
content: '';
position: absolute;
top: 50%;
width: 30%;
height: 1px;
background: #ddd;
}
.drag-drop-or::before {
left: 10%;
}
.drag-drop-or::after {
right: 10%;
}
.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: #2c5282;
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: #1e3a5f;
border-color: #a5b4fc;
}
.preview-tab-badge {
background: rgba(67, 56, 202, 0.15);
color: #1e3a5f;
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: #4a5568;
}
.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: #1e3a5f;
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, #718096, #4a5568);
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 #2c5282;
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: #4a5568;
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: #1e3a5f;
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);
}
}
@keyframes slideInRight {
from {
opacity: 0;
transform: translateX(100px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@keyframes fadeOut {
from {
opacity: 1;
transform: translateX(0);
}
to {
opacity: 0;
transform: translateX(100px);
}
}
.detail-section {
margin-bottom: 25px;
}
.detail-section h3 {
color: #2c5282;
font-size: 16px;
margin-bottom: 10px;
display: flex;
align-items: center;
gap: 8px;
}
.detail-section h3::before {
content: '';
width: 4px;
height: 16px;
background: #2c5282;
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 #2c5282;
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 #2c5282;
}
.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 #2c5282;
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;
}
.tabs-nav {
overflow-x: auto;
padding-bottom: 8px;
}
.tab-button {
white-space: nowrap;
}
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<div class="header-info">
<h1 id="pageTitle">🗃️ 管理员控制台</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="tabs-container">
<ul class="tabs-nav" id="tabsNav">
<li><button class="tab-button active" data-tab="permits-tab" onclick="switchTab('permits-tab')">
<span>📋</span> 许可事项管理
</button></li>
<li><button class="tab-button" data-tab="checkpoints-tab" onclick="switchTab('checkpoints-tab')">
<span>🔒</span> 检查点管理
</button></li>
<li><button class="tab-button" data-tab="files-tab" onclick="switchTab('files-tab')">
<span>📁</span> 文件管理
</button></li>
<li><button class="tab-button" data-tab="import-tab" onclick="switchTab('import-tab')">
<span>📥</span> 许可导入
</button></li>
</ul>
</div>
<!-- 标签页内容区域 -->
<!-- 许可事项管理标签页 -->
<div id="permits-tab" class="tab-content active">
<h2 style="color: #333; margin-bottom: 20px; display: flex; align-items: center; gap: 10px;">
<span>📋</span> 许可事项管理
</h2>
<p style="color: #666; margin-bottom: 20px;">使用筛选器快速定位许可事项,支持多维度筛选、分页浏览、风险统计等功能。</p>
<!-- 筛选器区域 -->
<div style="background: #f8f9fa; border: 1px solid #e0e0e0; border-radius: 8px; padding: 20px; margin-bottom: 20px; position: relative;">
<!-- 筛选器加载状态 -->
<div id="filterOptionsLoading" style="display: none; position: absolute; top: 0; left: 0; right: 0; bottom: 0; background: rgba(255, 255, 255, 0.9); border-radius: 8px; z-index: 100; display: flex; flex-direction: column; align-items: center; justify-content: center;">
<div style="width: 50px; height: 50px; border: 4px solid #e0e0e0; border-top-color: #2c5282; border-radius: 50%; animation: spin 0.8s linear infinite; margin-bottom: 16px;"></div>
<div style="font-size: 14px; color: #666; font-weight: 600;">正在加载筛选选项...</div>
</div>
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 16px; margin-bottom: 16px;">
<!-- 行政区域筛选 -->
<div style="display: flex; flex-direction: column; gap: 8px;">
<label style="font-size: 13px; font-weight: 600; color: #555;">行政区域 (可多选)</label>
<div style="position: relative;">
<div id="filterRegion" class="multi-select-dropdown" style="padding: 10px; border: 1px solid #ddd; border-radius: 6px; font-size: 14px; background: white; cursor: pointer;" onclick="toggleMultiSelect('regionOptions')">
<span id="regionSelectedText">全部区域</span>
<span style="float: right; color: #999;"></span>
</div>
<div id="regionOptions" class="multi-select-options" style="position: absolute; top: 100%; left: 0; right: 0; background: white; border: 1px solid #ddd; border-radius: 6px; margin-top: 4px; max-height: 200px; overflow-y: auto; z-index: 1000; display: none; box-shadow: 0 2px 8px rgba(0,0,0,0.1);">
<div style="padding: 8px; border-bottom: 1px solid #eee;">
<label style="display: flex; align-items: center; cursor: pointer;">
<input type="checkbox" id="regionSelectAll" onchange="selectAllRegions()" style="margin-right: 8px;">
<span style="font-weight: 600; color: #2c5282;">全选</span>
</label>
</div>
<div id="regionOptionsList"></div>
</div>
</div>
</div>
<!-- 主题筛选 -->
<div style="display: flex; flex-direction: column; gap: 8px;">
<label style="font-size: 13px; font-weight: 600; color: #555;">主题 (可多选)</label>
<div style="position: relative;">
<div id="filterTheme" class="multi-select-dropdown" style="padding: 10px; border: 1px solid #ddd; border-radius: 6px; font-size: 14px; background: white; cursor: pointer;" onclick="toggleMultiSelect('themeOptions')">
<span id="themeSelectedText">全部主题</span>
<span style="float: right; color: #999;"></span>
</div>
<div id="themeOptions" class="multi-select-options" style="position: absolute; top: 100%; left: 0; right: 0; background: white; border: 1px solid #ddd; border-radius: 6px; margin-top: 4px; max-height: 200px; overflow-y: auto; z-index: 1000; display: none; box-shadow: 0 2px 8px rgba(0,0,0,0.1);">
<div style="padding: 8px; border-bottom: 1px solid #eee;">
<label style="display: flex; align-items: center; cursor: pointer;">
<input type="checkbox" id="themeSelectAll" onchange="selectAllThemes()" style="margin-right: 8px;">
<span style="font-weight: 600; color: #2c5282;">全选</span>
</label>
</div>
<div id="themeOptionsList"></div>
</div>
</div>
</div>
<!-- 关联部门筛选 -->
<div style="display: flex; flex-direction: column; gap: 8px;">
<label style="font-size: 13px; font-weight: 600; color: #555;">关联部门 (可多选)</label>
<div style="position: relative;">
<div id="filterDepartment" class="multi-select-dropdown" style="padding: 10px; border: 1px solid #ddd; border-radius: 6px; font-size: 14px; background: white; cursor: pointer;" onclick="toggleMultiSelect('departmentOptions')">
<span id="departmentSelectedText">全部部门</span>
<span style="float: right; color: #999;"></span>
</div>
<div id="departmentOptions" class="multi-select-options" style="position: absolute; top: 100%; left: 0; right: 0; background: white; border: 1px solid #ddd; border-radius: 6px; margin-top: 4px; max-height: 200px; overflow-y: auto; z-index: 1000; display: none; box-shadow: 0 2px 8px rgba(0,0,0,0.1);">
<div style="padding: 8px; border-bottom: 1px solid #eee;">
<label style="display: flex; align-items: center; cursor: pointer;">
<input type="checkbox" id="departmentSelectAll" onchange="selectAllDepartments()" style="margin-right: 8px;">
<span style="font-weight: 600; color: #2c5282;">全选</span>
</label>
</div>
<div id="departmentOptionsList"></div>
</div>
</div>
</div>
<!-- 搜索关键词 -->
<div style="display: flex; flex-direction: column; gap: 8px;">
<label style="font-size: 13px; font-weight: 600; color: #555;">搜索关键词</label>
<input type="text" id="filterSearchText" placeholder="输入许可名称关键词..." style="padding: 10px; border: 1px solid #ddd; border-radius: 6px; font-size: 14px;">
</div>
</div>
<!-- 筛选按钮 -->
<div style="display: flex; gap: 12px;">
<button id="applyFilterBtn" onclick="applyPermitFilter()" style="padding: 10px 20px; background: #2c5282; color: white; border: none; border-radius: 6px; font-weight: 600; cursor: pointer; font-size: 14px;">
🔍 应用筛选
</button>
<button id="resetFilterBtn" onclick="resetPermitFilter()" style="padding: 10px 20px; background: #6b7280; color: white; border: none; border-radius: 6px; font-weight: 600; cursor: pointer; font-size: 14px;">
↻ 重置筛选
</button>
</div>
</div>
<!-- 筛选结果区域 -->
<div class="panel" style="margin-top: 0;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">
<h3 style="color: #333; margin: 0; font-size: 18px;">筛选结果</h3>
<div id="resultCount" style="color: #666; font-size: 14px;">共找到 <strong style="color: #2c5282;">0</strong> 个许可事项</div>
</div>
<!-- 加载状态 -->
<div id="permitsLoading" style="display: none; text-align: center; padding: 40px; color: #999;">
<div style="display: inline-block; width: 40px; height: 40px; border: 4px solid #f3f4f6; border-top-color: #2c5282; border-radius: 50%; animation: spin 0.8s linear infinite; margin-bottom: 12px;"></div>
<div>正在加载许可数据...</div>
</div>
<!-- 错误信息 -->
<div id="permitsError" style="display: none; background: #fee2e2; border: 1px solid #fecaca; color: #991b1b; padding: 16px; border-radius: 8px; margin-bottom: 20px;">
<strong>加载失败:</strong><span id="permitsErrorMsg"></span>
</div>
<!-- 许可事项列表 -->
<div id="permitsList" style="background: white; border: 1px solid #e0e0e0; border-radius: 8px; overflow: hidden;">
<div style="text-align: center; padding: 60px 20px; color: #999;">
<div style="font-size: 48px; margin-bottom: 16px;">📋</div>
<div>请选择筛选条件并点击"应用筛选"</div>
</div>
</div>
<!-- 分页控制 -->
<div id="permitsPagination" style="display: none; margin-top: 20px; display: flex; justify-content: space-between; align-items: center;">
<div id="paginationInfo" style="color: #666; font-size: 14px;"></div>
<div style="display: flex; gap: 8px;">
<button id="prevPageBtn" onclick="previousPermitPage()" style="padding: 8px 16px; border: 1px solid #ddd; background: white; border-radius: 6px; cursor: pointer; font-size: 14px;">上一页</button>
<button id="nextPageBtn" onclick="nextPermitPage()" style="padding: 8px 16px; border: 1px solid #ddd; background: white; border-radius: 6px; cursor: pointer; font-size: 14px;">下一页</button>
</div>
</div>
</div>
</div>
<!-- 检查点管理标签页 -->
<div id="checkpoints-tab" class="tab-content">
<h2 style="color: #333; margin-bottom: 20px; display: flex; align-items: center; gap: 10px;">
<span>🔒</span> 数据库检查点管理
</h2>
<p style="color: #666; margin-bottom: 20px;">管理系统数据库备份点,创建、恢复、删除检查点。支持许可风险快照查看与管理。</p>
<div style="background: white; border-radius: 8px; padding: 20px; border: 1px solid #e0e0e0;">
<button class="btn btn-warning" onclick="openCheckpointModal()" style="margin-bottom: 20px;">
<span>🔒</span> 打开检查点管理
</button>
<div id="checkpointPreview">
<p style="color: #999; text-align: center; padding: 40px;">点击上方按钮打开检查点管理窗口</p>
</div>
</div>
</div>
<!-- 文件管理标签页 -->
<div id="files-tab" class="tab-content">
<h2 style="color: #333; margin-bottom: 20px; display: flex; align-items: center; gap: 10px;">
<span>📁</span> 文件管理
</h2>
<p style="color: #666; margin-bottom: 20px;">管理许可导入相关文件,查看文件关联、重新导入、删除文件等操作。</p>
<div style="background: white; border-radius: 8px; padding: 20px; border: 1px solid #e0e0e0;">
<div id="fileManagerContainer">
<!-- 文件管理内容将动态加载到这里 -->
<div style="padding: 40px; text-align: center; color: #999;">
<div class="loading">
<span class="loading-icon"></span>
正在加载文件列表...
</div>
</div>
</div>
</div>
</div>
<!-- 许可导入标签页 -->
<div id="import-tab" class="tab-content">
<h2 style="color: #333; margin-bottom: 20px; display: flex; align-items: center; gap: 10px;">
<span>📥</span> 许可导入
</h2>
<p style="color: #666; margin-bottom: 20px;">通过Excel文件批量导入许可数据支持多区划批量处理、主题绑定、预览确认等功能。</p>
<div style="background: white; border-radius: 8px; padding: 20px; border: 1px solid #e0e0e0;">
<div class="drag-drop-area" onclick="triggerFileUpload()" id="dragDropArea">
<div class="drag-drop-icon">📄</div>
<div class="drag-drop-text">拖拽 Excel 文件到这里</div>
<div class="drag-drop-hint">或者点击选择文件(支持 .xlsx, .xlsm 格式)</div>
<div class="drag-drop-or"></div>
<button class="import-template-button" onclick="event.stopPropagation(); openImportModal()">
<span class="import-template-icon">⚙️</span>
<span class="import-template-text">
<span class="import-template-title">使用导入向导</span>
<span class="import-template-subtitle">打开完整导入流程</span>
</span>
</button>
<input type="file" id="dragDropFileInput" accept=".xlsx,.xlsm" style="display: none;" onchange="handleFileSelect(this.files)">
</div>
<div id="importPreview" style="margin-top: 20px;">
<p style="color: #999; text-align: center; padding: 20px;">支持拖拽上传,文件大小限制:≤ 500KB</p>
</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();
}
// 标签页切换功能
function switchTab(tabId) {
// 隐藏所有标签页内容
const tabContents = document.querySelectorAll('.tab-content');
tabContents.forEach(content => {
content.classList.remove('active');
});
// 移除所有标签按钮的激活状态
const tabButtons = document.querySelectorAll('.tab-button');
tabButtons.forEach(button => {
button.classList.remove('active');
});
// 激活选中的标签页
const selectedTab = document.getElementById(tabId);
if (selectedTab) {
selectedTab.classList.add('active');
}
// 激活选中的标签按钮
const selectedButton = document.querySelector(`[data-tab="${tabId}"]`);
if (selectedButton) {
selectedButton.classList.add('active');
}
// 根据标签页执行特定操作
if (tabId === 'permits-tab') {
loadPermitFilterOptions();
} else if (tabId === 'files-tab') {
loadFileManager();
}
// 更新URL不刷新页面
const url = new URL(window.location);
url.searchParams.set('tab', tabId.replace('-tab', ''));
window.history.replaceState({}, '', url);
}
// ============== 许可事项筛选器相关函数 ==============
// 区域-部门映射关系缓存
let regionDepartmentMap = {};
let permitFilterOptions = {
regions: [],
themes: [],
departments: []
};
let permitCurrentPage = 0;
let permitPageSize = 50;
let permitTotalPages = 0;
// 加载筛选选项
async function loadPermitFilterOptions() {
// 显示加载状态
const loadingElement = document.getElementById('filterOptionsLoading');
if (loadingElement) {
loadingElement.style.display = 'flex';
}
try {
const response = await fetch('/fs-ai-asistant/api/workflow/lawrisk/admin/permits/filter-options');
const data = await response.json();
if (!data.success) {
throw new Error(data.message || '加载筛选选项失败');
}
permitFilterOptions = data.data || {};
renderPermitFilterOptions();
} catch (error) {
console.error('加载筛选选项失败:', error);
// 可以在这里显示错误提示
alert('加载筛选选项失败: ' + error.message);
} finally {
// 隐藏加载状态
if (loadingElement) {
loadingElement.style.display = 'none';
}
}
}
// 渲染筛选选项
function renderPermitFilterOptions() {
// 构建区域-部门映射关系
buildRegionDepartmentMapping();
// 渲染区域选项
const regionOptionsList = document.getElementById('regionOptionsList');
if (regionOptionsList) {
regionOptionsList.innerHTML = '';
permitFilterOptions.regions.forEach(region => {
const div = document.createElement('div');
div.style.padding = '6px 12px';
div.innerHTML = `
<label style="display: flex; align-items: center; cursor: pointer;">
<input type="checkbox" name="regionFilter" value="${region.id || region}" onchange="onRegionSelectionChange()" style="margin-right: 8px;">
<span>${region.name || region}</span>
</label>
`;
regionOptionsList.appendChild(div);
});
}
// 渲染主题选项
const themeOptionsList = document.getElementById('themeOptionsList');
if (themeOptionsList) {
themeOptionsList.innerHTML = '';
permitFilterOptions.themes.forEach(theme => {
const div = document.createElement('div');
div.style.padding = '6px 12px';
div.innerHTML = `
<label style="display: flex; align-items: center; cursor: pointer;">
<input type="checkbox" name="themeFilter" value="${theme.id}" onchange="updateSelectedText('theme')" style="margin-right: 8px;">
<span>${theme.name}</span>
</label>
`;
themeOptionsList.appendChild(div);
});
}
// 渲染部门选项(显示所有部门)
renderDepartmentOptions(permitFilterOptions.departments);
// 自动加载权限范围内的许可事项
// 注意不要在渲染过程中直接调用需要延迟执行确保DOM更新完成
setTimeout(() => {
loadAllVisiblePermits();
}, 100);
}
// 构建区域-部门映射关系
function buildRegionDepartmentMapping() {
// 初始化映射对象
regionDepartmentMap = {};
// 获取所有部门数据
const allDepartments = permitFilterOptions.departments || [];
// 为每个区域建立部门映射
permitFilterOptions.regions.forEach(region => {
const regionId = region.id || region;
regionDepartmentMap[regionId] = [];
});
// 将部门分配到对应区域
allDepartments.forEach(dept => {
const regionId = dept.region_id;
if (regionId && regionDepartmentMap.hasOwnProperty(regionId)) {
regionDepartmentMap[regionId].push(dept);
}
});
console.log('区域-部门映射关系构建完成:', regionDepartmentMap);
}
// 自动加载所有可见的许可事项(按权限过滤)
async function loadAllVisiblePermits() {
// 重置分页到第一页
permitCurrentPage = 0;
// 显示加载状态
showPermitsLoading(true);
hidePermitsError();
try {
// 不传递任何筛选参数,直接获取权限范围内的所有许可事项
const filters = {
limit: permitPageSize,
offset: permitCurrentPage * permitPageSize
};
const params = new URLSearchParams();
Object.keys(filters).forEach(key => {
if (filters[key]) {
params.append(key, filters[key]);
}
});
console.log('自动加载许可事项,参数:', params.toString());
const response = await fetch(`/fs-ai-asistant/api/workflow/lawrisk/admin/permits/advanced-filter?${params}`);
const data = await response.json();
if (!data.success) {
throw new Error(data.message || '加载失败');
}
console.log('自动加载结果:', data.data);
renderPermitResults(data.data.permits || []);
updatePermitPagination(data.data.pagination || {});
} catch (error) {
console.error('自动加载失败:', error);
showPermitsError(error.message);
renderPermitResults([]);
updatePermitPagination({});
} finally {
showPermitsLoading(false);
}
}
// 应用筛选
async function applyPermitFilter() {
// 获取选中的区域
const regionCheckboxes = document.querySelectorAll('input[name="regionFilter"]:checked');
const regions = Array.from(regionCheckboxes).map(cb => cb.value);
// 获取选中的主题
const themeCheckboxes = document.querySelectorAll('input[name="themeFilter"]:checked');
const themes = Array.from(themeCheckboxes).map(cb => cb.value);
// 获取选中的部门
const departmentCheckboxes = document.querySelectorAll('input[name="departmentFilter"]:checked');
const departments = Array.from(departmentCheckboxes).map(cb => cb.value);
const searchText = document.getElementById('filterSearchText')?.value || '';
const filters = {
regions: regions.length > 0 ? regions : null,
themes: themes.length > 0 ? themes : null,
departments: departments.length > 0 ? departments : null,
search_text: searchText.trim() || null,
limit: permitPageSize,
offset: permitCurrentPage * permitPageSize
};
// 显示加载状态
showPermitsLoading(true);
hidePermitsError();
try {
const params = new URLSearchParams();
Object.keys(filters).forEach(key => {
if (filters[key]) {
if (Array.isArray(filters[key])) {
// 数组参数regions[]=1&regions[]=2
filters[key].forEach(value => {
params.append(key + '[]', value);
});
} else {
params.append(key, filters[key]);
}
}
});
const response = await fetch(`/fs-ai-asistant/api/workflow/lawrisk/admin/permits/advanced-filter?${params}`);
const data = await response.json();
if (!data.success) {
throw new Error(data.message || '筛选失败');
}
renderPermitResults(data.data.permits || []);
updatePermitPagination(data.data.pagination || {});
} catch (error) {
console.error('筛选失败:', error);
showPermitsError(error.message);
renderPermitResults([]);
updatePermitPagination({});
} finally {
showPermitsLoading(false);
}
}
// 切换多选下拉菜单
function toggleMultiSelect(optionsId) {
const options = document.getElementById(optionsId);
if (options) {
options.style.display = options.style.display === 'none' ? 'block' : 'none';
}
}
// 更新选中文本显示
function updateSelectedText(type) {
const checkboxes = document.querySelectorAll(`input[name="${type}Filter"]:checked`);
const selectedText = document.getElementById(`${type}SelectedText`);
const selectAllCheckbox = document.getElementById(`${type}SelectAll`);
if (selectedText) {
if (checkboxes.length === 0) {
selectedText.textContent = `全部${type === 'region' ? '区域' : type === 'theme' ? '主题' : '部门'}`;
} else if (checkboxes.length === 1) {
const label = checkboxes[0].closest('label');
selectedText.textContent = label ? label.querySelector('span').textContent : '已选择';
} else {
selectedText.textContent = `已选择 ${checkboxes.length} 项`;
}
}
// 更新全选复选框状态
const allCheckboxes = document.querySelectorAll(`input[name="${type}Filter"]`);
if (selectAllCheckbox && allCheckboxes.length > 0) {
const checkedCount = checkboxes.length;
selectAllCheckbox.checked = checkedCount === allCheckboxes.length;
selectAllCheckbox.indeterminate = checkedCount > 0 && checkedCount < allCheckboxes.length;
}
}
// 区域选择变化处理基于前端缓存
function onRegionSelectionChange() {
console.log('onRegionSelectionChange 被调用'); // 调试日志
// 更新区域显示文本
updateSelectedText('region');
// 获取当前选中的区域
const regionCheckboxes = document.querySelectorAll('input[name="regionFilter"]:checked');
const selectedRegions = Array.from(regionCheckboxes).map(cb => cb.value);
console.log('选中的区域:', selectedRegions); // 调试日志
// 根据选中的区域,动态更新部门列表
if (selectedRegions.length === 1) {
// 只选择一个区域时,显示该区域的部门
const regionId = selectedRegions[0];
console.log('显示区域关联部门:', regionId); // 调试日志
const departments = regionDepartmentMap[regionId] || [];
renderDepartmentOptions(departments);
} else if (selectedRegions.length === 0) {
// 没有选择区域时,清空已选部门并显示所有部门
console.log('显示所有部门,清空部门选择'); // 调试日志
// 清空部门选择状态
const departmentCheckboxes = document.querySelectorAll('input[name="departmentFilter"]:checked');
departmentCheckboxes.forEach(cb => cb.checked = false);
updateSelectedText('department');
// 重新加载所有部门
renderDepartmentOptions(permitFilterOptions.departments || []);
} else {
// 选择多个区域时,显示所有选中区域关联的部门(去重)
console.log('显示多个区域关联部门'); // 调试日志
const allDepartments = [];
const departmentIds = new Set();
selectedRegions.forEach(regionId => {
const depts = regionDepartmentMap[regionId] || [];
depts.forEach(dept => {
if (!departmentIds.has(dept.id)) {
departmentIds.add(dept.id);
allDepartments.push(dept);
}
});
});
renderDepartmentOptions(allDepartments);
}
}
// 渲染部门选项(基于传入的部门列表)
function renderDepartmentOptions(departments) {
const departmentOptionsList = document.getElementById('departmentOptionsList');
if (departmentOptionsList) {
departmentOptionsList.innerHTML = '';
if (!departments || departments.length === 0) {
// 如果没有部门,显示提示信息
const div = document.createElement('div');
div.style.padding = '12px';
div.style.textAlign = 'center';
div.style.color = '#999';
div.style.fontSize = '13px';
div.innerHTML = '该区域暂无关联部门';
departmentOptionsList.appendChild(div);
} else {
departments.forEach(dept => {
const div = document.createElement('div');
div.style.padding = '6px 12px';
div.innerHTML = `
<label style="display: flex; align-items: center; cursor: pointer;">
<input type="checkbox" name="departmentFilter" value="${dept.id}" onchange="updateSelectedText('department')" style="margin-right: 8px;">
<span>${dept.name} (${dept.code})</span>
</label>
`;
departmentOptionsList.appendChild(div);
});
}
// 更新部门显示文本
updateSelectedText('department');
}
}
// 全选区域
function selectAllRegions() {
const selectAll = document.getElementById('regionSelectAll');
const checkboxes = document.querySelectorAll('input[name="regionFilter"]');
checkboxes.forEach(cb => cb.checked = selectAll.checked);
updateSelectedText('region');
}
// 全选主题
function selectAllThemes() {
const selectAll = document.getElementById('themeSelectAll');
const checkboxes = document.querySelectorAll('input[name="themeFilter"]');
checkboxes.forEach(cb => cb.checked = selectAll.checked);
updateSelectedText('theme');
}
// 全选部门
function selectAllDepartments() {
const selectAll = document.getElementById('departmentSelectAll');
const checkboxes = document.querySelectorAll('input[name="departmentFilter"]');
checkboxes.forEach(cb => cb.checked = selectAll.checked);
updateSelectedText('department');
}
// 查看许可详情
async function viewPermitDetail(permitId, regionId) {
if (!regionId) {
showAlert('error', '缺少区域信息,无法查看详情');
return;
}
// 显示加载动画
const loadingHtml = `
<div style="display: flex; justify-content: center; align-items: center; min-height: 200px; flex-direction: column;">
<div class="loading">
<span class="loading-icon"></span>
</div>
<p style="color: #666; margin-top: 16px; font-size: 14px;">正在加载许可详情...</p>
</div>
`;
showModal('许可事项详情', loadingHtml);
// 构建请求参数
const params = new URLSearchParams();
params.append('permit_id', permitId);
params.append('region_id', regionId);
try {
const response = await fetch(`/fs-ai-asistant/api/workflow/lawrisk/admin/permit-details?${params}`);
const data = await response.json();
if (!data.success) {
throw new Error(data.message || '获取许可详情失败');
}
const permit = data.data.permit;
const themeDisplay = data.data.theme_display;
// 构建风险列表
const risksHtml = (permit.risks || [])
.map((risk, index) => `
<div style="border: 1px solid #e0e0e0; border-radius: 6px; padding: 20px; margin-bottom: 16px; background: #fff; box-shadow: 0 1px 3px rgba(0,0,0,0.05);">
<div style="margin-bottom: 12px;">
<div style="font-size: 13px; color: #666; font-weight: 600; margin-bottom: 6px;">风险提示内容</div>
<div style="color: #1a202c; font-size: 14px; line-height: 1.8; padding: 16px; background: #f0f9ff; border-left: 4px solid #2c5282; border-radius: 4px; font-weight: 500;">${escapeHtml(risk.risk_content || '')}</div>
</div>
${risk.summary ? `
<div style="margin-bottom: 12px;">
<div style="font-size: 13px; color: #666; font-weight: 600; margin-bottom: 6px;">风险概述</div>
<div style="color: #444; font-size: 14px; line-height: 1.8; padding: 12px; background: #f9fafb; border-radius: 4px;">${escapeHtml(risk.summary || '')}</div>
</div>
` : ''}
${risk.legal_basis ? `
<div style="margin-bottom: 12px;">
<div style="font-size: 13px; color: #666; font-weight: 600; margin-bottom: 6px;">法律依据</div>
<div style="color: #444; font-size: 14px; line-height: 1.8; padding: 12px; background: #f9fafb; border-radius: 4px; font-style: italic;">${escapeHtml(risk.legal_basis)}</div>
</div>
` : ''}
${risk.document_no ? `
<div style="margin-bottom: 12px;">
<div style="font-size: 13px; color: #666; font-weight: 600; margin-bottom: 6px;">文件文号</div>
<div style="color: #444; font-size: 14px; line-height: 1.8; padding: 12px; background: #f9fafb; border-radius: 4px;">${escapeHtml(risk.document_no)}</div>
</div>
` : ''}
${risk.remarks ? `
<div style="margin-bottom: 0;">
<div style="font-size: 13px; color: #666; font-weight: 600; margin-bottom: 6px;">备注</div>
<div style="color: #444; font-size: 14px; line-height: 1.8; padding: 12px; background: #f9fafb; border-radius: 4px;">${escapeHtml(risk.remarks)}</div>
</div>
` : ''}
</div>
`).join('');
// 构建主题列表
const themesList = (permit.themes || [])
.map(t => `<span style="display: inline-block; padding: 2px 8px; background: #eef2ff; color: #4c1d95; border-radius: 4px; font-size: 12px; margin-right: 4px; margin-bottom: 4px;">${escapeHtml(t.name)}</span>`)
.join('');
const detailHtml = `
<div style="max-height: 70vh; overflow-y: auto; padding: 24px;">
<div style="background: #f8f9fa; padding: 24px; border-radius: 8px; margin-bottom: 24px;">
<h3 style="margin: 0 0 12px 0; color: #2c5282; font-size: 20px;">${escapeHtml(permit.name || '未知许可')}</h3>
<div style="display: flex; gap: 20px; flex-wrap: wrap; font-size: 14px;">
<div><strong>行政区域:</strong>${escapeHtml(permit.region?.name || '-')}</div>
<div><strong>风险数量:</strong>${permit.risks?.length || 0}</div>
</div>
</div>
${themesList ? `<div style="margin-bottom: 24px;"><strong style="display: block; margin-bottom: 8px;">关联主题:</strong>${themesList}</div>` : ''}
<!-- 许可详细信息 -->
<div style="background: #fff; border: 1px solid #e0e0e0; border-radius: 8px; padding: 20px; margin-bottom: 24px;">
<h4 style="color: #333; margin-bottom: 16px; border-bottom: 2px solid #2c5282; padding-bottom: 8px;">许可信息</h4>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 16px;">
${permit.permit_status ? `
<div>
<div style="font-size: 13px; color: #666; font-weight: 600; margin-bottom: 6px;">许可情况</div>
<div style="color: #444; font-size: 14px; padding: 10px; background: #f9fafb; border-radius: 4px;">${escapeHtml(permit.permit_status)}</div>
</div>
` : ''}
${permit.subitem_summary ? `
<div>
<div style="font-size: 13px; color: #666; font-weight: 600; margin-bottom: 6px;">许可(备案)事项子项</div>
<div style="color: #444; font-size: 14px; padding: 10px; background: #f9fafb; border-radius: 4px;">${escapeHtml(permit.subitem_summary)}</div>
</div>
` : ''}
${permit.region?.name ? `
<div>
<div style="font-size: 13px; color: #666; font-weight: 600; margin-bottom: 6px;">行政区域</div>
<div style="color: #444; font-size: 14px; padding: 10px; background: #f9fafb; border-radius: 4px;">${escapeHtml(permit.region.name)}</div>
</div>
` : ''}
${permit.responsible_contact ? `
<div>
<div style="font-size: 13px; color: #666; font-weight: 600; margin-bottom: 6px;">负责部门</div>
<div style="color: #444; font-size: 14px; padding: 10px; background: #f9fafb; border-radius: 4px;">${escapeHtml(permit.responsible_contact)}</div>
</div>
` : ''}
${permit.jurisdiction_scope ? `
<div>
<div style="font-size: 13px; color: #666; font-weight: 600; margin-bottom: 6px;">权限划分</div>
<div style="color: #444; font-size: 14px; padding: 10px; background: #f9fafb; border-radius: 4px;">${escapeHtml(permit.jurisdiction_scope)}</div>
</div>
` : ''}
${permit.business_scope ? `
<div style="grid-column: 1 / -1;">
<div style="font-size: 13px; color: #666; font-weight: 600; margin-bottom: 6px;">经营范围</div>
<div style="color: #444; font-size: 14px; padding: 10px; background: #f9fafb; border-radius: 4px; line-height: 1.8;">${escapeHtml(permit.business_scope)}</div>
</div>
` : ''}
</div>
</div>
<div>
<h4 style="color: #333; margin-bottom: 12px; border-bottom: 2px solid #2c5282; padding-bottom: 8px;">风险信息</h4>
${risksHtml || '<div style="color: #999; text-align: center; padding: 20px;">暂无风险信息</div>'}
</div>
</div>
`;
showModal('许可事项详情', detailHtml);
} catch (error) {
console.error('获取许可详情失败:', error);
const errorHtml = `
<div style="display: flex; justify-content: center; align-items: center; min-height: 200px; flex-direction: column;">
<div style="color: #dc2626; font-size: 16px; margin-bottom: 16px;">❌ 获取许可详情失败</div>
<div style="color: #666; font-size: 14px; text-align: center; max-width: 400px;">${escapeHtml(error.message || '未知错误')}</div>
<button class="btn btn-warning btn-sm" style="margin-top: 20px;" onclick="closePermitDetailModal()">关闭</button>
</div>
`;
showModal('许可事项详情', errorHtml);
}
}
// 删除许可事项
async function deletePermit(permitId, regionId) {
if (!confirm('确定要删除该许可事项吗?此操作不可恢复,并且会创建风险快照。')) {
return;
}
try {
const response = await fetch('/fs-ai-asistant/api/workflow/lawrisk/admin/permits', {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json'
},
body: JSON.stringify({
permit_id: permitId,
region_id: regionId,
edited_by: currentUser?.display_name || currentUser?.username || '未知用户',
change_summary: '通过管理界面删除许可事项'
})
});
const data = await response.json();
if (!data.success) {
throw new Error(data.message || '删除失败');
}
showAlert('success', '许可事项删除成功');
// 重新加载当前页面的数据
loadAllVisiblePermits();
} catch (error) {
console.error('删除许可失败:', error);
showAlert('error', '删除许可失败:' + error.message);
}
}
// 渲染筛选结果
function renderPermitResults(permits) {
const permitsList = document.getElementById('permitsList');
const resultCount = document.getElementById('resultCount');
if (!permitsList || !resultCount) return;
if (!permits || permits.length === 0) {
permitsList.innerHTML = `
<div style="text-align: center; padding: 60px 20px; color: #999;">
<div style="font-size: 48px; margin-bottom: 16px;">📂</div>
<div>未找到符合条件的许可事项</div>
</div>
`;
resultCount.innerHTML = `共找到 <strong style="color: #2c5282;">0</strong> 个许可事项`;
return;
}
resultCount.innerHTML = `共找到 <strong style="color: #2c5282;">${permits.length}</strong> 个许可事项`;
let html = `
<table style="width: 100%; border-collapse: collapse;">
<thead>
<tr style="background: #f8f9fa; border-bottom: 2px solid #e0e0e0;">
<th style="padding: 14px 16px; text-align: left; font-weight: 600; color: #555; font-size: 14px;">许可事项</th>
<th style="padding: 14px 16px; text-align: left; font-weight: 600; color: #555; font-size: 14px; width: 180px;">行政区域</th>
<th style="padding: 14px 16px; text-align: left; font-weight: 600; color: #555; font-size: 14px; width: 120px;">主题</th>
<th style="padding: 14px 16px; text-align: left; font-weight: 600; color: #555; font-size: 14px; width: 100px;">风险数</th>
<th style="padding: 14px 16px; text-align: left; font-weight: 600; color: #555; font-size: 14px; width: 180px;">操作</th>
</tr>
</thead>
<tbody>
`;
permits.forEach(permit => {
const themesHtml = (permit.themes || [])
.map(t => `<span style="display: inline-block; padding: 2px 8px; background: #eef2ff; color: #4c1d95; border-radius: 4px; font-size: 12px; margin-right: 4px;">${escapeHtml(t.name)}</span>`)
.join('');
const regionId = permit.region?.id || '';
html += `
<tr style="border-bottom: 1px solid #f0f0f0;">
<td style="padding: 16px;">
<div style="font-weight: 600; color: #333; margin-bottom: 6px;">${escapeHtml(permit.name || '未知许可')}</div>
<div>${themesHtml}</div>
</td>
<td style="padding: 16px; color: #666;">${escapeHtml(permit.region?.name || '-')}</td>
<td style="padding: 16px; color: #666;">${permit.theme_count || 0} 个</td>
<td style="padding: 16px; color: #666;">${permit.risk_count || 0}</td>
<td style="padding: 16px;">
<button onclick="viewPermitDetail('${permit.id}', '${regionId}')" style="padding: 6px 12px; background: #2c5282; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 13px; margin-right: 8px;">
查看
</button>
<button onclick="deletePermit('${permit.id}', '${regionId}')" style="padding: 6px 12px; background: #dc2626; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 13px;">
删除
</button>
</td>
</tr>
`;
});
html += `
</tbody>
</table>
`;
permitsList.innerHTML = html;
}
// 更新分页信息
function updatePermitPagination(pagination) {
const permitsPagination = document.getElementById('permitsPagination');
const paginationInfo = document.getElementById('paginationInfo');
const prevBtn = document.getElementById('prevPageBtn');
const nextBtn = document.getElementById('nextPageBtn');
if (!permitsPagination || !paginationInfo || !prevBtn || !nextBtn) return;
if (!pagination || !pagination.total) {
permitsPagination.style.display = 'none';
return;
}
const total = pagination.total || 0;
permitTotalPages = Math.ceil(total / permitPageSize);
paginationInfo.textContent = `第 ${permitCurrentPage + 1} 页 / 共 ${permitTotalPages} 页`;
prevBtn.disabled = permitCurrentPage === 0;
nextBtn.disabled = permitCurrentPage >= permitTotalPages - 1;
permitsPagination.style.display = 'flex';
}
// 上一页
function previousPermitPage() {
if (permitCurrentPage > 0) {
permitCurrentPage--;
applyPermitFilter();
}
}
// 下一页
function nextPermitPage() {
if (permitCurrentPage < permitTotalPages - 1) {
permitCurrentPage++;
applyPermitFilter();
}
}
// 重置筛选
function resetPermitFilter() {
const regionSelect = document.getElementById('filterRegion');
const themeSelect = document.getElementById('filterTheme');
const departmentSelect = document.getElementById('filterDepartment');
const searchInput = document.getElementById('filterSearchText');
if (regionSelect) regionSelect.value = '';
if (themeSelect) themeSelect.value = '';
if (departmentSelect) departmentSelect.value = '';
if (searchInput) searchInput.value = '';
permitCurrentPage = 0;
// 清空结果
const permitsList = document.getElementById('permitsList');
const resultCount = document.getElementById('resultCount');
const permitsPagination = document.getElementById('permitsPagination');
if (permitsList) {
permitsList.innerHTML = `
<div style="text-align: center; padding: 60px 20px; color: #999;">
<div style="font-size: 48px; margin-bottom: 16px;">📋</div>
<div>请选择筛选条件并点击"应用筛选"</div>
</div>
`;
}
if (resultCount) {
resultCount.innerHTML = `共找到 <strong style="color: #2c5282;">0</strong> 个许可事项`;
}
if (permitsPagination) {
permitsPagination.style.display = 'none';
}
}
// 显示/隐藏加载状态
function showPermitsLoading(show) {
const loading = document.getElementById('permitsLoading');
const applyBtn = document.getElementById('applyFilterBtn');
if (loading) loading.style.display = show ? 'block' : 'none';
if (applyBtn) applyBtn.disabled = show;
}
// 显示错误信息
function showPermitsError(message) {
const error = document.getElementById('permitsError');
const errorMsg = document.getElementById('permitsErrorMsg');
if (error && errorMsg) {
errorMsg.textContent = message;
error.style.display = 'block';
}
}
// 隐藏错误信息
function hidePermitsError() {
const error = document.getElementById('permitsError');
if (error) error.style.display = 'none';
}
// HTML转义
function escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// 显示通用模态框
function showModal(title, contentHtml) {
// 移除已存在的详情模态框
const existingModal = document.getElementById('permitDetailModal');
if (existingModal) {
existingModal.remove();
}
// 创建新的模态框
const modalDiv = document.createElement('div');
modalDiv.className = 'modal';
modalDiv.id = 'permitDetailModal';
modalDiv.innerHTML = `
<div class="modal-content" style="max-width: 900px; width: 90%;">
<div class="modal-header" style="display: flex; justify-content: space-between; align-items: center; padding: 20px; border-bottom: 1px solid #e5e7eb;">
<h3 style="margin: 0; color: #2c5282; font-size: 18px;">${escapeHtml(title)}</h3>
<button class="btn btn-warning btn-sm" onclick="closePermitDetailModal()">关闭</button>
</div>
<div class="modal-body" style="padding: 20px;">
${contentHtml}
</div>
</div>
`;
document.body.appendChild(modalDiv);
modalDiv.classList.add('show');
}
// 关闭许可详情模态框
function closePermitDetailModal() {
const modal = document.getElementById('permitDetailModal');
if (modal) {
modal.classList.remove('show');
setTimeout(() => modal.remove(), 300);
}
}
// 加载文件管理
function loadFileManager() {
const container = document.getElementById('fileManagerContainer');
if (!container || container.dataset.loaded === 'true') {
return;
}
container.innerHTML = `
<div style="padding: 40px; text-align: center; color: #999;">
<div class="loading">
<span class="loading-icon"></span>
正在加载文件列表...
</div>
</div>
`;
fetch('/fs-ai-asistant/api/workflow/lawrisk/admin/permit-files', {
credentials: 'include',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json'
}
})
.then(response => response.json())
.then(data => {
if (data.success && data.data && data.data.files) {
renderFileList(data.data.files);
container.dataset.loaded = 'true';
} else {
container.innerHTML = `
<div style="padding: 40px; text-align: center; color: #999;">
<p>暂无文件数据</p>
</div>
`;
}
})
.catch(error => {
container.innerHTML = `
<div style="padding: 40px; text-align: center; color: #dc2626;">
<p>加载文件列表失败</p>
<p style="font-size: 12px; color: #999;">${error.message}</p>
</div>
`;
});
}
// 渲染文件列表
function renderFileList(files) {
const container = document.getElementById('fileManagerContainer');
if (!files || files.length === 0) {
container.innerHTML = `
<div style="padding: 40px; text-align: center; color: #999;">
<p>暂无许可导入文件</p>
</div>
`;
return;
}
let html = `
<div style="margin-bottom: 20px;">
<h3 style="font-size: 16px; color: #374151; margin: 0 0 16px 0;">文件列表(共 ${files.length} 个)</h3>
<div style="overflow-x: auto;">
<table style="width: 100%; border-collapse: collapse; font-size: 13px;">
<thead>
<tr style="background: #f9fafb; border-bottom: 2px solid #e5e7eb;">
<th style="padding: 12px 8px; text-align: left; color: #6b7280; font-weight: 600;">文件名</th>
<th style="padding: 12px 8px; text-align: left; color: #6b7280; font-weight: 600;">大小</th>
<th style="padding: 12px 8px; text-align: left; color: #6b7280; font-weight: 600;">上传时间</th>
<th style="padding: 12px 8px; text-align: left; color: #6b7280; font-weight: 600;">状态</th>
<th style="padding: 12px 8px; text-align: left; color: #6b7280; font-weight: 600;">操作</th>
</tr>
</thead>
<tbody>
`;
files.forEach(file => {
const fileSize = formatFileSize(file.file_size || 0);
const uploadTime = file.created_at ? new Date(file.created_at).toLocaleString('zh-CN') : '';
const linksCount = file.links ? file.links.length : 0;
const status = linksCount > 0 ? 'processed' : 'warning';
const statusClass = status === 'processed' ? 'success' : (status === 'failed' ? 'danger' : 'warning');
const statusText = status === 'processed' ? '已处理' : (status === 'failed' ? '失败' : '待处理');
html += `
<tr style="border-bottom: 1px solid #f3f4f6;">
<td style="padding: 12px 8px; color: #111827;">${escapeHtml(file.filename || '')}</td>
<td style="padding: 12px 8px; color: #6b7280;">${fileSize}</td>
<td style="padding: 12px 8px; color: #6b7280;">${uploadTime}</td>
<td style="padding: 12px 8px;">
<span style="padding: 4px 12px; border-radius: 999px; font-size: 12px; font-weight: 600; background: ${statusClass === 'success' ? '#d1fae5' : statusClass === 'danger' ? '#fee2e2' : '#fef3c7'}; color: ${statusClass === 'success' ? '#065f46' : statusClass === 'danger' ? '#991b1b' : '#92400e'};">
${statusText}
</span>
</td>
<td style="padding: 12px 8px;">
<button class="btn btn-sm" style="margin-right: 8px; background: #3b82f6; color: white; padding: 4px 12px; border: none; border-radius: 6px; cursor: pointer; font-size: 12px;" onclick="reimportFile('${file.file_id}')">
重新导入
</button>
<button class="btn btn-sm" style="background: #ef4444; color: white; padding: 4px 12px; border: none; border-radius: 6px; cursor: pointer; font-size: 12px;" onclick="deleteFile('${file.file_id}')">
删除
</button>
</td>
</tr>
`;
});
html += `
</tbody>
</table>
</div>
</div>
`;
container.innerHTML = html;
}
// 初始化标签页
function setupTabsByRole(userRole) {
const pageTitle = document.getElementById('pageTitle');
// 更新页面标题
pageTitle.textContent = '🗃️ 管理员控制台';
// 默认激活许可事项管理标签页
const permitsTab = document.getElementById('permits-tab');
const permitsButton = document.querySelector('[data-tab="permits-tab"]');
if (permitsTab) {
permitsTab.classList.add('active');
}
if (permitsButton) {
permitsButton.classList.add('active');
}
// 加载许可数据
goToStep(1);
}
// 触发文件上传
function triggerFileUpload() {
document.getElementById('dragDropFileInput').click();
}
// 处理拖拽文件
function handleDragDropFile(files) {
if (files && files.length > 0) {
// 只处理第一个文件
const file = files[0];
const fileName = file.name;
// 检查文件类型
if (!fileName.match(/\.(xlsx|xlsm)$/i)) {
showAlert('error', '请选择 Excel 文件(.xlsx 或 .xlsm 格式)');
return;
}
// 检查文件大小500KB
if (file.size > 500 * 1024) {
showAlert('error', '文件大小不能超过 500KB');
return;
}
// 文件验证通过,打开导入向导并传递文件
showAlert('success', `已选择文件: ${fileName},正在打开导入向导...`);
// 先打开模态框
openImportModal();
// 等待模态框渲染完成后,设置文件并处理
setTimeout(() => {
const importModalBody = document.getElementById('importModalBody');
if (!importModalBody) {
showAlert('error', '无法打开导入向导,请重试');
return;
}
// 查找文件输入元素
const fileInput = importModalBody.querySelector('input[type="file"]');
if (!fileInput) {
showAlert('error', '导入向导初始化失败,请重试');
return;
}
// 创建 FileList 对象
const dt = new DataTransfer();
dt.items.add(file);
fileInput.files = dt.files;
// 触发 handleImportFile 处理
handleImportFile(fileInput);
showAlert('success', `正在解析文件: ${fileName}`);
}, 100);
}
}
// 处理点击选择文件
function handleFileSelect(files) {
handleDragDropFile(files);
}
// 上传文件到服务器
function uploadFileToServer(fileName) {
const fileInput = document.getElementById('dragDropFileInput');
if (fileInput.files.length === 0) {
showAlert('error', '请先选择文件');
return;
}
const formData = new FormData();
formData.append('file', fileInput.files[0]);
// 显示上传进度
const preview = document.getElementById('importPreview');
preview.innerHTML = `
<div style="padding: 20px; background: #fef3c7; border-radius: 8px; border: 1px solid #fcd34d;">
<div style="display: flex; align-items: center; gap: 12px;">
<span style="font-size: 24px;"></span>
<div>
<div style="font-weight: 600; color: #92400e;">正在上传文件...</div>
<div style="font-size: 12px; color: #78350f; margin-top: 4px;">请稍候,正在处理您的文件</div>
</div>
</div>
</div>
`;
fetch('/fs-ai-asistant/api/workflow/lawrisk/admin/permit-import/upload', {
method: 'POST',
body: formData,
})
.then(response => response.json())
.then(data => {
if (data.success) {
preview.innerHTML = `
<div style="padding: 20px; background: #d1fae5; border-radius: 8px; border: 1px solid #6ee7b7;">
<div style="display: flex; align-items: center; gap: 12px;">
<span style="font-size: 24px;"></span>
<div>
<div style="font-weight: 600; color: #065f46;">文件上传成功!</div>
<div style="font-size: 12px; color: #047857; margin-top: 4px;">请在许可导入模态窗口中查看预览并确认导入</div>
</div>
</div>
<div style="margin-top: 16px;">
<button class="btn btn-primary" onclick="openImportModal()">
<span></span> 进入导入流程
</button>
</div>
</div>
`;
} else {
throw new Error(data.message || '上传失败');
}
})
.catch(error => {
preview.innerHTML = `
<div style="padding: 20px; background: #fee2e2; border-radius: 8px; border: 1px solid #fca5a5;">
<div style="display: flex; align-items: center; gap: 12px;">
<span style="font-size: 24px;"></span>
<div>
<div style="font-weight: 600; color: #991b1b;">上传失败</div>
<div style="font-size: 12px; color: #b91c1c; margin-top: 4px;">${error.message}</div>
</div>
</div>
</div>
`;
});
}
// 清除文件选择
function clearFileSelection() {
document.getElementById('dragDropFileInput').value = '';
document.getElementById('importPreview').innerHTML = `
<p style="color: #999; text-align: center; padding: 20px;">支持拖拽上传,文件大小限制:≤ 500KB</p>
`;
}
// 重新导入文件
function reimportFile(fileId) {
if (!confirm('确定要重新导入该文件吗?')) {
return;
}
const container = document.getElementById('fileManagerContainer');
const originalContent = container.innerHTML;
container.innerHTML = `
<div style="padding: 40px; text-align: center; color: #f59e0b;">
<div class="loading">
<span class="loading-icon"></span>
正在重新导入文件...
</div>
</div>
`;
fetch(`/fs-ai-asistant/api/workflow/lawrisk/admin/permit-files/${fileId}/reimport`, {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json'
}
})
.then(response => response.json())
.then(data => {
if (data.success) {
showAlert('success', '文件重新导入成功');
loadFileManager();
} else {
throw new Error(data.message || '重新导入失败');
}
})
.catch(error => {
showAlert('error', '重新导入失败:' + error.message);
container.innerHTML = originalContent;
});
}
// 删除文件
function deleteFile(fileId) {
if (!confirm('确定要删除该文件吗?此操作不可恢复。')) {
return;
}
const container = document.getElementById('fileManagerContainer');
const originalContent = container.innerHTML;
container.innerHTML = `
<div style="padding: 40px; text-align: center; color: #dc2626;">
<div class="loading">
<span class="loading-icon"></span>
正在删除文件...
</div>
</div>
`;
fetch(`/fs-ai-asistant/api/workflow/lawrisk/admin/permit-files/${fileId}`, {
method: 'DELETE',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json'
}
})
.then(response => response.json())
.then(data => {
if (data.success) {
showAlert('success', '文件删除成功');
// 重新加载文件列表
const container = document.getElementById('fileManagerContainer');
if (container) {
delete container.dataset.loaded;
loadFileManager();
}
} else {
throw new Error(data.message || '删除失败');
}
})
.catch(error => {
showAlert('error', '删除失败:' + error.message);
container.innerHTML = originalContent;
});
}
// 显示提示信息
function showAlert(type, message) {
// 创建提示框元素
const alertDiv = document.createElement('div');
alertDiv.style.cssText = `
position: fixed;
top: 20px;
right: 20px;
padding: 16px 24px;
border-radius: 8px;
color: white;
font-size: 14px;
font-weight: 600;
z-index: 10000;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3);
animation: slideInRight 0.3s ease;
max-width: 400px;
display: flex;
align-items: center;
gap: 10px;
`;
// 设置背景色
if (type === 'success') {
alertDiv.style.background = 'linear-gradient(135deg, #10b981 0%, #059669 100%)';
alertDiv.innerHTML = `✓ ${message}`;
} else if (type === 'error') {
alertDiv.style.background = 'linear-gradient(135deg, #ef4444 0%, #dc2626 100%)';
alertDiv.innerHTML = `✕ ${message}`;
} else if (type === 'warning') {
alertDiv.style.background = 'linear-gradient(135deg, #f59e0b 0%, #d97706 100%)';
alertDiv.innerHTML = `⚠ ${message}`;
} else {
alertDiv.style.background = 'linear-gradient(135deg, #3b82f6 0%, #2563eb 100%)';
alertDiv.innerHTML = ` ${message}`;
}
// 添加到页面
document.body.appendChild(alertDiv);
// 自动消失
setTimeout(() => {
alertDiv.style.animation = 'fadeOut 0.3s ease';
setTimeout(() => {
document.body.removeChild(alertDiv);
}, 300);
}, 3000);
}
// 页面加载时初始化
window.addEventListener('DOMContentLoaded', async () => {
const user = await fetchCurrentUser(true);
// 初始化标签页
setupTabsByRole(user ? user.role : 'user');
// 检查URL参数中的tab
const urlParams = new URLSearchParams(window.location.search);
const tabParam = urlParams.get('tab');
if (tabParam && document.getElementById(`${tabParam}-tab`)) {
switchTab(`${tabParam}-tab`);
}
// 初始化拖拽事件
const dragDropArea = document.getElementById('dragDropArea');
if (dragDropArea) {
// 阻止默认拖拽行为
dragDropArea.addEventListener('dragover', function(e) {
e.preventDefault();
e.stopPropagation();
this.classList.add('drag-over');
});
dragDropArea.addEventListener('dragleave', function(e) {
e.preventDefault();
e.stopPropagation();
// 只有离开拖放区域本身时才移除样式
if (e.target === this) {
this.classList.remove('drag-over');
}
});
dragDropArea.addEventListener('drop', function(e) {
e.preventDefault();
e.stopPropagation();
this.classList.remove('drag-over');
const files = e.dataTransfer.files;
if (files && files.length > 0) {
handleDragDropFile(files);
}
});
}
// 如果默认显示的是文件管理标签页,加载文件列表
const filesTab = document.getElementById('files-tab');
if (filesTab && filesTab.classList.contains('active')) {
loadFileManager();
}
});
</script>
</body>
</html>