diff --git a/lawrisk/api/v2.py b/lawrisk/api/v2.py index e89a297..c14c0fa 100644 --- a/lawrisk/api/v2.py +++ b/lawrisk/api/v2.py @@ -6,7 +6,7 @@ import time from io import BytesIO from flask import Blueprint, jsonify, request, send_file from concurrent.futures import ThreadPoolExecutor -from typing import Any, Dict +from typing import Any, Dict, Optional from lawrisk.api.auth import login_required, get_current_user from lawrisk.services.lawrisk_v2_service import search_v2, list_regions @@ -14,6 +14,7 @@ from lawrisk.services.licensing_repo import ( list_permits_for_region, load_permits_and_risks, list_region_theme_options, + list_region_permit_catalog, load_theme_payload, create_checkpoint, list_checkpoints, @@ -26,6 +27,8 @@ from lawrisk.services.licensing_repo import ( start_permit_import_session, commit_permit_import_session, fetch_permit_file, + describe_permit_import_session, + resolve_region_permit_theme, ) from lawrisk.services.lawrisk_service import suggest_questions_embed @@ -220,27 +223,35 @@ def admin_themes(): @v2_bp.route('/admin/permits', methods=['GET']) def admin_permits(): - """Get permits for a specific region-theme combination.""" + """Get permits for a region. Optional theme filter keeps backward compatibility.""" region_value = request.args.get("region") or request.args.get("region_id") theme_value = request.args.get("theme") or request.args.get("theme_id") - if not region_value or not theme_value: - return jsonify({"success": False, "message": "region and theme are required", "data": {}}), 400 + if not region_value or (isinstance(region_value, str) and not region_value.strip()): + return jsonify({"success": False, "message": "region is required", "data": {}}), 400 region_token = region_value.strip() if isinstance(region_value, str) else str(region_value) - theme_token = theme_value.strip() if isinstance(theme_value, str) else str(theme_value) + theme_token = ( + theme_value.strip() + if theme_value and isinstance(theme_value, str) + else (str(theme_value) if theme_value is not None else None) + ) try: - permits = load_permits_and_risks(region_token, theme_token) - - return jsonify({ - "success": True, - "data": { + if theme_token: + permits = load_permits_and_risks(region_token, theme_token) + data = { "region": region_token, "theme": theme_token, - "permits": permits + "permits": permits, } - }) + else: + catalog = list_region_permit_catalog(region_token) + data = { + "region": region_token, + "permits": catalog, + } + return jsonify({"success": True, "data": data}) except Exception as exc: print(f"admin_permits error: {exc}") return jsonify({"success": False, "message": str(exc)}), 500 @@ -297,6 +308,35 @@ def admin_permit_import_template(): return jsonify({"success": False, "message": "模板文件暂时无法下载"}), 500 +def _build_import_preview_response(session_token: str): + """Internal helper to build preview response JSON.""" + try: + data = describe_permit_import_session(session_token) + return jsonify({"success": True, "data": data}) + except ValueError as exc: + return jsonify({"success": False, "message": str(exc)}), 400 + except Exception as exc: + print(f"admin_permit_import_preview error: {exc}") + return jsonify({"success": False, "message": "无法加载预览数据"}), 500 + + +@v2_bp.route('/admin/permit-import/session//preview', methods=['GET']) +def admin_permit_import_preview(session_id: str): + """Return parsed workbook preview along with theme options (path param).""" + if not session_id: + return jsonify({"success": False, "message": "session_id 不能为空"}), 400 + return _build_import_preview_response(session_id) + + +@v2_bp.route('/admin/permit-import/preview', methods=['GET']) +def admin_permit_import_preview_query(): + """Return preview via query string for compatibility.""" + session_id = request.args.get("session_id") or request.args.get("sessionId") + if not session_id: + return jsonify({"success": False, "message": "session_id 不能为空"}), 400 + return _build_import_preview_response(session_id) + + @v2_bp.route('/admin/permit-import/commit', methods=['POST']) def admin_permit_import_commit(): """Commit an import session with selected sheets.""" @@ -306,6 +346,7 @@ def admin_permit_import_commit(): overrides = payload.get('overrides') or {} edited_by = payload.get('edited_by') or payload.get('editedBy') change_summary = payload.get('change_summary') or payload.get('changeSummary') + theme_bindings = payload.get('theme_bindings') or payload.get('themeBindings') or {} if isinstance(sheet_names, str): sheet_names = [sheet_names] @@ -317,6 +358,7 @@ def admin_permit_import_commit(): overrides=overrides, edited_by=edited_by, change_summary=change_summary, + theme_bindings=theme_bindings, ) return jsonify({"success": True, "data": data}) except ValueError as exc: @@ -366,27 +408,58 @@ def admin_permit_details(): theme_value = request.args.get("theme") or request.args.get("theme_id") permit_value = request.args.get("permit") or request.args.get("permit_id") - if not region_value or not theme_value or not permit_value: - return jsonify({"success": False, "message": "region, theme, and permit are required", "data": {}}), 400 + if not region_value or not permit_value: + return jsonify({"success": False, "message": "region and permit are required", "data": {}}), 400 region_token = region_value.strip() if isinstance(region_value, str) else str(region_value) - theme_token = theme_value.strip() if isinstance(theme_value, str) else str(theme_value) permit_token = permit_value.strip() if isinstance(permit_value, str) else str(permit_value) + theme_token: Optional[str] + theme_display = None + if theme_value is None or (isinstance(theme_value, str) and not theme_value.strip()): + resolved = resolve_region_permit_theme(region_token, permit_token) + if not resolved or not resolved.get("id"): + return jsonify({"success": False, "message": "未找到许可所属主题", "data": {}}), 404 + theme_token = resolved["id"] + theme_display = resolved + else: + theme_token = theme_value.strip() if isinstance(theme_value, str) else str(theme_value) try: - permits = load_permits_and_risks(region_token, theme_token, permit_token) + # 始终加载全部主题,详情页需要展示主题列表并高亮当前主题 + permits = load_permits_and_risks(region_token, None, permit_token) if not permits: return jsonify({"success": False, "message": "Permit not found", "data": {}}), 404 - return jsonify({ - "success": True, - "data": { - "region": region_token, - "theme": theme_token, - "permit": permits[0] - } - }) + permit_payload = permits[0] + payload = { + "region": region_token, + "theme": theme_token, + "permit": permit_payload, + } + + selected_theme_meta = None + theme_list = permit_payload.get("themes") or [] + if theme_token: + for candidate in theme_list: + if candidate.get("id") == theme_token: + selected_theme_meta = candidate + break + if not selected_theme_meta and theme_display: + selected_theme_meta = theme_display + if not selected_theme_meta: + selected_theme_meta = permit_payload.get("theme") + if not selected_theme_meta and theme_list: + selected_theme_meta = theme_list[0] + + if selected_theme_meta: + permit_payload["theme"] = selected_theme_meta + payload["theme_display"] = selected_theme_meta + payload["selected_theme_id"] = selected_theme_meta.get("id") or "" + elif theme_display: + payload["theme_display"] = theme_display + + return jsonify({"success": True, "data": payload}) except Exception as exc: print(f"admin_permit_details error: {exc}") return jsonify({"success": False, "message": str(exc)}), 500 @@ -426,12 +499,18 @@ def admin_delete_permit(): if not change_summary: change_summary = None - if not region_value or not theme_value or not permit_value: - return jsonify({"success": False, "message": "region_id, theme_id, permit_id 均为必填"}), 400 + if not region_value or not permit_value: + return jsonify({"success": False, "message": "region_id 和 permit_id 均为必填"}), 400 region_id = region_value.strip() if isinstance(region_value, str) else str(region_value) - theme_id = theme_value.strip() if isinstance(theme_value, str) else str(theme_value) permit_id = permit_value.strip() if isinstance(permit_value, str) else str(permit_value) + if theme_value: + theme_id = theme_value.strip() if isinstance(theme_value, str) else str(theme_value) + else: + resolved_theme = resolve_region_permit_theme(region_id, permit_id) + if not resolved_theme or not resolved_theme.get("id"): + return jsonify({"success": False, "message": "未找到许可所属主题,无法删除"}), 400 + theme_id = resolved_theme["id"] try: result = delete_region_permit( diff --git a/static/db_admin.html b/static/db_admin.html index 6420c5b..667a55b 100644 --- a/static/db_admin.html +++ b/static/db_admin.html @@ -396,6 +396,118 @@ color: white; } + .import-preview-sheet { + border: 1px solid #e5e7eb; + border-radius: 12px; + padding: 16px; + margin-top: 16px; + background: #fdfdfd; + } + + .import-preview-sheet.disabled { + opacity: 0.6; + } + + .preview-sheet-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 12px; + } + + .preview-sheet-title { + font-size: 16px; + font-weight: 600; + color: #1f2937; + } + + .preview-sheet-meta { + font-size: 13px; + color: #6b7280; + } + + .preview-permit-card { + border: 1px solid #e0e7ff; + border-radius: 10px; + padding: 12px; + margin-top: 12px; + background: #f8faff; + } + + .preview-permit-card.warning { + border-color: #fbbf24; + background: #fff8e1; + } + + .preview-permit-header { + display: flex; + justify-content: space-between; + align-items: center; + gap: 10px; + } + + .preview-permit-name { + font-weight: 600; + color: #111827; + } + + .preview-badge { + display: inline-flex; + align-items: center; + padding: 2px 8px; + border-radius: 999px; + font-size: 11px; + background: #eef2ff; + color: #4c1d95; + } + + .preview-badge.duplicate { + background: #fee2e2; + color: #b91c1c; + } + + .preview-permit-meta { + font-size: 12px; + color: #4b5563; + margin-top: 6px; + } + + .theme-options { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 10px; + } + + .theme-checkbox { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 13px; + background: #fff; + border: 1px solid #e5e7eb; + border-radius: 999px; + padding: 4px 10px; + } + + .theme-checkbox input { + width: 14px; + height: 14px; + } + + .theme-source-badge { + font-size: 11px; + color: #6b7280; + } + + .preview-empty { + padding: 12px; + border-radius: 8px; + background: #f3f4f6; + color: #6b7280; + font-size: 13px; + } + .checkpoint-nav-item { background: #fff3cd; border: 2px solid #ffc107; @@ -776,6 +888,15 @@ background: #5568d3; } + .btn-secondary { + background: #e2e8f0; + color: #334155; + } + + .btn-secondary:hover:not(:disabled) { + background: #cbd5f5; + } + .btn-danger { background: #dc3545; color: white; @@ -1141,6 +1262,238 @@ line-height: 1.4; } + .import-modal-content.import-modal-wide { + width: 92vw; + max-width: 1500px; + } + + .import-stage-header { + display: flex; + justify-content: space-between; + align-items: center; + gap: 16px; + margin-bottom: 16px; + } + + .import-stage-header h3 { + margin: 0; + color: #1e293b; + display: flex; + align-items: center; + gap: 8px; + } + + .import-preview-summary { + display: flex; + flex-wrap: wrap; + gap: 12px; + font-size: 13px; + color: #475569; + } + + .import-preview-tabs { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin: 12px 0 18px; + } + + .preview-tab { + padding: 8px 14px; + border-radius: 999px; + background: #f1f5f9; + color: #475569; + font-size: 13px; + border: 1px solid transparent; + cursor: pointer; + display: inline-flex; + align-items: center; + gap: 6px; + transition: all 0.2s ease; + } + + .preview-tab:hover { + background: #e2e8f0; + } + + .preview-tab.active { + background: #eef2ff; + color: #4338ca; + border-color: #a5b4fc; + } + + .preview-tab-badge { + background: rgba(67, 56, 202, 0.15); + color: #4338ca; + padding: 2px 8px; + border-radius: 999px; + font-size: 11px; + } + + .preview-toolbar { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); + gap: 12px; + margin-bottom: 16px; + } + + .preview-toolbar label { + display: flex; + flex-direction: column; + gap: 6px; + font-size: 13px; + color: #475569; + } + + .preview-toolbar input { + padding: 10px 12px; + border-radius: 10px; + border: 1px solid #dbeafe; + background: #fff; + font-size: 14px; + transition: border-color 0.2s ease; + } + + .preview-toolbar input:focus { + outline: none; + border-color: #6366f1; + } + + .preview-panels { + display: grid; + grid-template-columns: 1fr; + gap: 16px; + } + + .preview-permit-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: 16px; + } + + .preview-permit-card { + border: 1px solid #e2e8f0; + border-radius: 14px; + padding: 16px; + background: #fff; + display: flex; + flex-direction: column; + gap: 10px; + min-height: 220px; + } + + .preview-permit-card.duplicate { + border-color: #fcd34d; + background: #fffbeb; + } + + .preview-permit-card.new { + border-color: #bbf7d0; + background: #f0fdf4; + } + + .preview-permit-header { + display: flex; + flex-direction: column; + gap: 6px; + } + + .permit-badge { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 12px; + padding: 2px 8px; + border-radius: 999px; + } + + .permit-badge.duplicate { + background: rgba(249, 115, 22, 0.15); + color: #c2410c; + } + + .permit-badge.new { + background: rgba(37, 99, 235, 0.12); + color: #1d4ed8; + } + + .theme-chip-grid { + display: flex; + flex-wrap: wrap; + gap: 8px; + min-height: 40px; + padding: 6px 0; + } + + .theme-chip { + border: 1px solid #cbd5f5; + border-radius: 999px; + padding: 6px 12px; + font-size: 13px; + color: #4338ca; + background: #eef2ff; + cursor: pointer; + user-select: none; + display: inline-flex; + align-items: center; + gap: 6px; + } + + .theme-chip.selected { + background: linear-gradient(135deg, #818cf8, #6366f1); + color: #fff; + border-color: transparent; + } + + .theme-chip.drag-preview { + opacity: 0.7; + } + + .theme-chip-actions { + display: flex; + gap: 8px; + margin-top: 8px; + } + + .preview-empty-state { + padding: 24px; + border: 1px dashed #cbd5f5; + border-radius: 12px; + text-align: center; + background: #f8fafc; + color: #64748b; + } + + .import-sheet-toolbar { + display: flex; + justify-content: space-between; + align-items: center; + flex-wrap: wrap; + gap: 12px; + margin-bottom: 12px; + font-size: 13px; + color: #475569; + } + + .import-sheet-toolbar .toolbar-actions { + display: flex; + gap: 8px; + flex-wrap: wrap; + } + + .import-stage { + display: flex; + flex-direction: column; + gap: 16px; + } + + .preview-stage-actions { + display: flex; + gap: 8px; + flex-wrap: wrap; + justify-content: flex-end; + } + .modal-header { text-align: center; margin-bottom: 20px; @@ -1758,9 +2111,28 @@ commitLoading: false, editedBy: '', changeSummary: '', - fileSize: 0 + fileSize: 0, + previewLoading: false, + previewError: '', + previewData: null, + themeBindings: new Map(), + stage: 'upload', + activePreviewSheet: '', + previewPermitKeyword: '', + previewThemeKeyword: '', }; + const themeDragState = { + active: false, + sheetName: '', + permitName: '', + shouldSelect: true, + }; + + window.addEventListener('mouseup', endThemeDragSelection); + window.addEventListener('blur', endThemeDragSelection); + window.addEventListener('mouseleave', endThemeDragSelection); + const EMPTY_PLACEHOLDER = '(未填写)'; function formatNullableText(value, placeholder = EMPTY_PLACEHOLDER) { @@ -2370,10 +2742,15 @@ // ================ 许可导入功能 ================ - function openImportModal() { + async function openImportModal() { const modal = document.getElementById('importModal'); if (!modal) return; modal.classList.add('show'); + permitImportState.stage = 'upload'; + permitImportState.activePreviewSheet = ''; + permitImportState.previewPermitKeyword = ''; + permitImportState.previewThemeKeyword = ''; + endThemeDragSelection(); if (!permitImportState.sessionId) { permitImportState.selectedSheets = new Set(); permitImportState.overrides = new Map(); @@ -2384,11 +2761,19 @@ 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.previewPermitKeyword = ''; + permitImportState.previewThemeKeyword = ''; + endThemeDragSelection(); modal.classList.remove('show'); } @@ -2403,7 +2788,9 @@ } else { permitImportState.selectedSheets.delete(sheetName); permitImportState.overrides.delete(sheetName); + permitImportState.themeBindings.delete(sheetName); } + ensureActivePreviewSheet(); renderImportModal(); } @@ -2412,6 +2799,236 @@ 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.previewPermitKeyword = ''; + permitImportState.previewThemeKeyword = ''; + ensureActivePreviewSheet(); + renderImportModal(); + await fetchImportPreview(false); + ensureActivePreviewSheet(); + renderImportModal(); + } + + function exitImportPreviewStage() { + permitImportState.stage = 'upload'; + permitImportState.previewPermitKeyword = ''; + permitImportState.previewThemeKeyword = ''; + permitImportState.activePreviewSheet = ''; + endThemeDragSelection(); + renderImportModal(); + } + + function setActivePreviewSheet(sheetName) { + if (!sheetName || permitImportState.activePreviewSheet === sheetName) { + return; + } + if (!permitImportState.selectedSheets.has(sheetName)) { + return; + } + permitImportState.activePreviewSheet = sheetName; + renderImportModal(); + } + + function updatePreviewPermitKeyword(value) { + permitImportState.previewPermitKeyword = value || ''; + renderImportModal(); + } + + function updatePreviewThemeKeyword(value) { + permitImportState.previewThemeKeyword = value || ''; + 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 []; + } + const permits = Array.isArray(sheet.permits) ? sheet.permits : []; + const keyword = (permitImportState.previewPermitKeyword || '').trim().toLowerCase(); + if (!keyword) { + return permits; + } + return permits.filter(permit => { + if (!permit) { + return false; + } + const permitName = (permit.permit_name || '').toLowerCase(); + const sampleRisk = (permit.sample_risk || '').toLowerCase(); + const themeNames = (Array.isArray(permit.default_theme_names) ? permit.default_theme_names : []) + .map(name => (name || '').toLowerCase()); + return ( + permitName.includes(keyword) || + sampleRisk.includes(keyword) || + themeNames.some(name => name.includes(keyword)) + ); + }); + } + + 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 selectAllThemesForPermit(sheetName, permitName) { + if (!sheetName || !permitName) { + return; + } + const sheet = getPreviewSheetByName(sheetName); + if (!sheet) { + return; + } + const options = filterThemeOptionsForPreview(sheet); + if (!options.length) { + return; + } + const bindingSet = getThemeBindingSet(sheetName, permitName, true); + options.forEach(option => { + const normalized = normalizeThemeName(option && option.name ? option.name : ''); + if (normalized) { + 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)) { @@ -2445,6 +3062,200 @@ permitImportState.changeSummary = value; } + function clearImportPreviewState() { + permitImportState.previewData = null; + permitImportState.previewError = ''; + permitImportState.previewLoading = false; + permitImportState.themeBindings = new Map(); + permitImportState.activePreviewSheet = ''; + permitImportState.previewPermitKeyword = ''; + permitImportState.previewThemeKeyword = ''; + } + + function normalizeThemeName(value) { + if (typeof value === 'string') { + return value.trim(); + } + if (value === undefined || value === null) { + return ''; + } + return String(value).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) { + 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; @@ -2487,6 +3298,8 @@ ); permitImportState.overrides = new Map(); permitImportState.success = `解析完成:${permitImportState.sheetSummaries.length} 个 Sheet,${permitImportState.totalRows} 条风险记录`; + clearImportPreviewState(); + await fetchImportPreview(true); } else { permitImportState.error = data.message || '解析失败,请检查Excel格式'; permitImportState.sessionId = ''; @@ -2494,6 +3307,7 @@ permitImportState.fileSize = 0; permitImportState.selectedSheets = new Set(); permitImportState.overrides = new Map(); + clearImportPreviewState(); } } catch (error) { permitImportState.error = error.message || '文件上传失败'; @@ -2502,6 +3316,7 @@ permitImportState.selectedSheets = new Set(); permitImportState.overrides = new Map(); permitImportState.fileSize = 0; + clearImportPreviewState(); } finally { permitImportState.uploading = false; renderImportModal(); @@ -2519,6 +3334,30 @@ 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, @@ -2532,6 +3371,14 @@ } }); + 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(); } @@ -2564,6 +3411,11 @@ permitImportState.filename = ''; permitImportState.totalRows = 0; permitImportState.fileSize = 0; + permitImportState.stage = 'upload'; + permitImportState.activePreviewSheet = ''; + permitImportState.previewPermitKeyword = ''; + permitImportState.previewThemeKeyword = ''; + clearImportPreviewState(); await Promise.all([ refreshPermitRiskSnapshots(false), @@ -2588,8 +3440,23 @@ function renderImportModal() { const container = document.getElementById('importModalBody'); if (!container) return; - const state = permitImportState; + 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 = '
'; html += '

📄 上传 Excel

'; html += '
'; @@ -2614,7 +3481,7 @@ if (state.error) { html += `
${escapeHtml(state.error)}
`; } - if (state.success) { + if (state.success && state.stage === 'upload') { html += `
${escapeHtml(state.success)}
`; } if (state.uploading) { @@ -2622,8 +3489,15 @@ } if (state.sessionId && state.sheetSummaries && state.sheetSummaries.length) { + const totalSheets = state.sheetSummaries.length; html += '
'; - html += '

🗂️ 选择导入的 Sheet

'; + html += '

🗂️ 选择导入的区划(Sheet)

'; + html += '
'; + html += `已选择 ${state.selectedSheets.size}/${totalSheets} 个区划,可一次性导入多个区域`; + html += '
'; + html += ``; + html += ``; + html += '
'; html += '
'; state.sheetSummaries.forEach(summary => { @@ -2666,6 +3540,10 @@ html += '
'; } + if (state.sessionId && state.previewLoading) { + html += '
正在准备预览数据...
'; + } + html += '
'; html += '

📝 导入说明

'; html += '
'; @@ -2674,17 +3552,208 @@ html += '
'; html += '
'; - const commitDisabled = !state.sessionId || state.selectedSheets.size === 0 || state.commitLoading; - const commitLabel = state.commitLoading ? '导入中...' : '开始导入'; - + const nextDisabled = !state.sessionId || state.selectedSheets.size === 0 || state.uploading || state.previewLoading; html += '
'; - html += '
导入前会自动创建风险快照,可在“检查点管理”中查看恢复。
'; - html += ``; + html += '
提示:先勾选需要导入的区划,再点击下一步进入“解析预览 & 主题绑定”环节。
'; + html += ``; html += '
'; - container.innerHTML = html; + return html; } + function renderImportPreviewStage() { + const state = permitImportState; + const selectedSummaries = getSelectedSheetOrder(); + const selectedCount = selectedSummaries.length; + let html = '
'; + html += '
'; + html += '

👀 解析预览 & 主题绑定

'; + const commitDisabled = ( + state.commitLoading || + !state.sessionId || + selectedCount === 0 || + state.previewLoading || + !!state.previewError || + !state.previewData + ); + const commitLabel = state.commitLoading ? '导入中...' : '确认并导入'; + html += '
'; + html += ``; + html += ``; + html += ``; + html += '
'; + + html += `
文件:${escapeHtml(state.filename || '(未命名)')}已选区划:${selectedCount}风险条目:${state.totalRows || 0}
`; + + if (state.error) { + html += `
${escapeHtml(state.error)}
`; + } + if (state.success && state.stage === 'preview') { + html += `
${escapeHtml(state.success)}
`; + } + + if (selectedCount === 0) { + html += '
请返回上一步,至少勾选一个区划后再进行主题绑定。
'; + html += '
'; + return html; + } + + html += '
'; + 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 += `'; + }); + html += '
'; + + if (state.previewLoading && !state.previewData) { + html += '
正在生成预览...
'; + html += '
'; + return html; + } + + if (state.previewError) { + html += `
${escapeHtml(state.previewError)}
`; + html += '
'; + html += '
'; + return html; + } + + if (!state.previewData || !Array.isArray(state.previewData.sheets) || state.previewData.sheets.length === 0) { + html += '
预览数据暂不可用,请重新上传 Excel。
'; + html += ''; + return html; + } + + ensureActivePreviewSheet(); + if (!permitImportState.activePreviewSheet) { + html += '
请选择上方的区划标签查看详情。
'; + html += ''; + return html; + } + + const activeSheet = getPreviewSheetByName(permitImportState.activePreviewSheet); + if (!activeSheet) { + html += '
当前区划的预览数据不存在,请重新加载。
'; + html += ''; + return html; + } + + if (!state.previewLoading) { + html += '
'; + html += ``; + html += ``; + html += '
'; + } + + if (state.previewLoading) { + html += '
预览刷新中...
'; + } + + html += buildPreviewSheetContent(activeSheet); + + if (state.previewLoading) { + html += '

预览刷新中,稍后列表将自动更新

'; + } + + html += ''; + return html; + } + + function buildPreviewSheetContent(sheet) { + if (!sheet) { + return '
暂无可展示的数据
'; + } + 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 = '
'; + html += `区划:${escapeHtml(regionLabel)}`; + html += `许可事项:${filteredPermits.length}/${totalPermits}`; + html += `主题候选:${themeOptions.length}/${totalThemes}`; + html += '
'; + + if (sheet.missing_region) { + html += '
检测到新地区,导入后会自动创建该区划,请确认名称是否正确。
'; + } + + if (!filteredPermits.length) { + html += '
当前筛选条件下暂无事项,请调整搜索关键词。
'; + return html; + } + + html += '
'; + filteredPermits.forEach(permit => { + if (!permit) { + return; + } + const bindingSet = getThemeBindingSet(sheet.sheet_name, permit.permit_name, true); + const selectedThemes = Array.from(bindingSet || []); + 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 += `
`; + html += '
'; + html += `
${escapeHtml(permit.permit_name)}${badgeLabel}
`; + html += `风险 ${permit.risk_count || 0} 条`; + if (defaultThemes.length) { + html += `Excel 主题:${defaultThemes.map(escapeHtml).join('、')}`; + } + if (permit.sample_risk) { + html += `示例风险:${escapeHtml(truncateText(permit.sample_risk, 80))}`; + } + html += '
'; + + if (!themeOptions.length) { + html += '
当前区划暂无可选主题,请先创建主题或清空搜索条件。
'; + } else { + html += '
'; + themeOptions.forEach(option => { + const optionName = option && option.name ? option.name : ''; + const normalized = normalizeThemeName(optionName); + const selected = normalized && bindingSet.has(normalized); + const workbookBadge = option && option.source === 'workbook' ? 'Excel' : ''; + const safeTheme = optionName.replace(/'/g, "\\'"); + html += ` +
+ ${escapeHtml(optionName || '未命名主题')} + ${workbookBadge} +
+ `; + }); + html += '
'; + html += `
+ + +
`; + } + + if (selectedThemes.length) { + html += `
已绑定主题:${selectedThemes.map(escapeHtml).join('、')}
`; + } else { + html += '
尚未选择主题,导入前必须为该事项选择至少一个主题。
'; + } + + html += '
'; + }); + html += '
'; + return html; + } + + // ================ 检查点管理功能 ================ // 打开检查点模态窗口