feat: support permit file uploads
This commit is contained in:
parent
5b86bd8799
commit
772354bd01
|
|
@ -1,10 +1,14 @@
|
||||||
"""V2 API routes - Enhanced implementation with structured results."""
|
"""V2 API routes - Enhanced implementation with structured results."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
import time
|
import time
|
||||||
from flask import Blueprint, jsonify, request
|
from io import BytesIO
|
||||||
|
from flask import Blueprint, jsonify, request, send_file
|
||||||
from concurrent.futures import ThreadPoolExecutor
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
from typing import Any, Dict
|
||||||
|
|
||||||
|
from lawrisk.api.auth import login_required, get_current_user
|
||||||
from lawrisk.services.lawrisk_v2_service import search_v2, list_regions
|
from lawrisk.services.lawrisk_v2_service import search_v2, list_regions
|
||||||
from lawrisk.services.licensing_repo import (
|
from lawrisk.services.licensing_repo import (
|
||||||
list_permits_for_region,
|
list_permits_for_region,
|
||||||
|
|
@ -15,6 +19,13 @@ from lawrisk.services.licensing_repo import (
|
||||||
list_checkpoints,
|
list_checkpoints,
|
||||||
restore_checkpoint,
|
restore_checkpoint,
|
||||||
delete_checkpoint,
|
delete_checkpoint,
|
||||||
|
list_permit_risk_snapshot_summaries,
|
||||||
|
count_permit_risk_snapshots,
|
||||||
|
delete_region_permit,
|
||||||
|
restore_permit_risk_snapshot_batch,
|
||||||
|
start_permit_import_session,
|
||||||
|
commit_permit_import_session,
|
||||||
|
fetch_permit_file,
|
||||||
)
|
)
|
||||||
from lawrisk.services.lawrisk_service import suggest_questions_embed
|
from lawrisk.services.lawrisk_service import suggest_questions_embed
|
||||||
|
|
||||||
|
|
@ -147,6 +158,18 @@ def test_simple():
|
||||||
"""Very simple test."""
|
"""Very simple test."""
|
||||||
return jsonify({"status": "ok"})
|
return jsonify({"status": "ok"})
|
||||||
|
|
||||||
|
@v2_bp.route('/db_admin', methods=['GET'])
|
||||||
|
@login_required
|
||||||
|
def db_admin_page():
|
||||||
|
"""Serve the database administration UI."""
|
||||||
|
project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..'))
|
||||||
|
html_path = os.path.join(project_root, 'static', 'db_admin.html')
|
||||||
|
|
||||||
|
if not os.path.exists(html_path):
|
||||||
|
return jsonify({"success": False, "message": "DB admin page not found"}), 404
|
||||||
|
|
||||||
|
return send_file(html_path, mimetype='text/html')
|
||||||
|
|
||||||
|
|
||||||
@v2_bp.route('/admin/regions', methods=['GET'])
|
@v2_bp.route('/admin/regions', methods=['GET'])
|
||||||
def admin_regions():
|
def admin_regions():
|
||||||
|
|
@ -223,6 +246,119 @@ def admin_permits():
|
||||||
return jsonify({"success": False, "message": str(exc)}), 500
|
return jsonify({"success": False, "message": str(exc)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@v2_bp.route('/admin/permit-import/upload', methods=['POST'])
|
||||||
|
def admin_permit_import_upload():
|
||||||
|
"""Upload Excel workbook and start an import session."""
|
||||||
|
if 'file' not in request.files:
|
||||||
|
return jsonify({"success": False, "message": "请选择要上传的Excel文件"}), 400
|
||||||
|
|
||||||
|
file_storage = request.files['file']
|
||||||
|
filename = file_storage.filename or 'import.xlsx'
|
||||||
|
file_bytes = file_storage.read()
|
||||||
|
user = get_current_user() or {}
|
||||||
|
uploaded_by = user.get("display_name") or user.get("username") or user.get("id")
|
||||||
|
content_type = file_storage.mimetype or "application/octet-stream"
|
||||||
|
|
||||||
|
if not file_bytes:
|
||||||
|
return jsonify({"success": False, "message": "上传的文件为空"}), 400
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = start_permit_import_session(
|
||||||
|
file_bytes,
|
||||||
|
filename,
|
||||||
|
content_type=content_type,
|
||||||
|
uploaded_by=str(uploaded_by) if uploaded_by else None,
|
||||||
|
)
|
||||||
|
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_upload error: {exc}")
|
||||||
|
return jsonify({"success": False, "message": str(exc)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@v2_bp.route('/admin/permit-import/template', methods=['GET'])
|
||||||
|
def admin_permit_import_template():
|
||||||
|
"""Provide the Excel import template for download."""
|
||||||
|
project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..'))
|
||||||
|
template_path = os.path.join(project_root, 'data', 'template', '风险提示表 模板.xlsx')
|
||||||
|
|
||||||
|
if not os.path.exists(template_path):
|
||||||
|
return jsonify({"success": False, "message": "模板文件不存在,请联系管理员"}), 404
|
||||||
|
|
||||||
|
try:
|
||||||
|
return send_file(
|
||||||
|
template_path,
|
||||||
|
as_attachment=True,
|
||||||
|
download_name='风险提示表 模板.xlsx',
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"admin_permit_import_template error: {exc}")
|
||||||
|
return jsonify({"success": False, "message": "模板文件暂时无法下载"}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@v2_bp.route('/admin/permit-import/commit', methods=['POST'])
|
||||||
|
def admin_permit_import_commit():
|
||||||
|
"""Commit an import session with selected sheets."""
|
||||||
|
payload = request.get_json(silent=True) or {}
|
||||||
|
session_id = payload.get('session_id') or payload.get('sessionId')
|
||||||
|
sheet_names = payload.get('sheet_names') or payload.get('sheets') or payload.get('selectedSheets')
|
||||||
|
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')
|
||||||
|
|
||||||
|
if isinstance(sheet_names, str):
|
||||||
|
sheet_names = [sheet_names]
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = commit_permit_import_session(
|
||||||
|
session_id,
|
||||||
|
sheet_names or [],
|
||||||
|
overrides=overrides,
|
||||||
|
edited_by=edited_by,
|
||||||
|
change_summary=change_summary,
|
||||||
|
)
|
||||||
|
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_commit error: {exc}")
|
||||||
|
return jsonify({"success": False, "message": str(exc)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@v2_bp.route('/admin/permit-file/download', methods=['GET'])
|
||||||
|
@login_required
|
||||||
|
def admin_permit_file_download():
|
||||||
|
"""Download the original Excel file associated with a permit."""
|
||||||
|
region_value = request.args.get("region") or request.args.get("region_id")
|
||||||
|
permit_value = request.args.get("permit") or request.args.get("permit_id")
|
||||||
|
|
||||||
|
if not region_value or not permit_value:
|
||||||
|
return jsonify({"success": False, "message": "region 和 permit 参数必填"}), 400
|
||||||
|
|
||||||
|
region_token = region_value.strip() if isinstance(region_value, str) else str(region_value)
|
||||||
|
permit_token = permit_value.strip() if isinstance(permit_value, str) else str(permit_value)
|
||||||
|
|
||||||
|
try:
|
||||||
|
file_payload = fetch_permit_file(region_token, permit_token)
|
||||||
|
if not file_payload:
|
||||||
|
return jsonify({"success": False, "message": "当前许可没有关联的原始文件"}), 404
|
||||||
|
|
||||||
|
buffer = BytesIO(file_payload["file_data"])
|
||||||
|
buffer.seek(0)
|
||||||
|
download_name = file_payload.get("filename") or "许可导入.xlsx"
|
||||||
|
mimetype = file_payload.get("content_type") or "application/octet-stream"
|
||||||
|
return send_file(
|
||||||
|
buffer,
|
||||||
|
as_attachment=True,
|
||||||
|
download_name=download_name,
|
||||||
|
mimetype=mimetype,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"admin_permit_file_download error: {exc}")
|
||||||
|
return jsonify({"success": False, "message": "暂无法下载原始文件"}), 500
|
||||||
|
|
||||||
|
|
||||||
@v2_bp.route('/admin/permit-details', methods=['GET'])
|
@v2_bp.route('/admin/permit-details', methods=['GET'])
|
||||||
def admin_permit_details():
|
def admin_permit_details():
|
||||||
"""Get detailed information for a specific permit."""
|
"""Get detailed information for a specific permit."""
|
||||||
|
|
@ -256,6 +392,63 @@ def admin_permit_details():
|
||||||
return jsonify({"success": False, "message": str(exc)}), 500
|
return jsonify({"success": False, "message": str(exc)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@v2_bp.route('/admin/permits', methods=['DELETE'])
|
||||||
|
def admin_delete_permit():
|
||||||
|
"""Delete a permit for a specific region-theme combination after snapshotting risks."""
|
||||||
|
payload: Dict[str, Any] = {}
|
||||||
|
if request.is_json:
|
||||||
|
payload = request.get_json(silent=True) or {}
|
||||||
|
elif request.form:
|
||||||
|
payload = request.form.to_dict(flat=True)
|
||||||
|
|
||||||
|
region_value = (
|
||||||
|
payload.get("region_id")
|
||||||
|
or payload.get("region")
|
||||||
|
or request.args.get("region_id")
|
||||||
|
or request.args.get("region")
|
||||||
|
)
|
||||||
|
theme_value = (
|
||||||
|
payload.get("theme_id")
|
||||||
|
or payload.get("theme")
|
||||||
|
or request.args.get("theme_id")
|
||||||
|
or request.args.get("theme")
|
||||||
|
)
|
||||||
|
permit_value = (
|
||||||
|
payload.get("permit_id")
|
||||||
|
or payload.get("permit")
|
||||||
|
or request.args.get("permit_id")
|
||||||
|
or request.args.get("permit")
|
||||||
|
)
|
||||||
|
edited_by = (payload.get("edited_by") or request.args.get("edited_by") or "").strip() or None
|
||||||
|
change_summary = payload.get("change_summary") or request.args.get("change_summary")
|
||||||
|
if change_summary is not None:
|
||||||
|
change_summary = str(change_summary).strip()
|
||||||
|
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
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = delete_region_permit(
|
||||||
|
region_id,
|
||||||
|
theme_id,
|
||||||
|
permit_id,
|
||||||
|
edited_by=edited_by,
|
||||||
|
change_summary=change_summary,
|
||||||
|
)
|
||||||
|
return jsonify({"success": True, "data": result})
|
||||||
|
except ValueError as exc:
|
||||||
|
return jsonify({"success": False, "message": str(exc)}), 404
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"admin_delete_permit error: {exc}")
|
||||||
|
return jsonify({"success": False, "message": str(exc)}), 500
|
||||||
|
|
||||||
|
|
||||||
@v2_bp.route('/admin/checkpoints', methods=['GET'])
|
@v2_bp.route('/admin/checkpoints', methods=['GET'])
|
||||||
def admin_list_checkpoints():
|
def admin_list_checkpoints():
|
||||||
"""List all available checkpoints."""
|
"""List all available checkpoints."""
|
||||||
|
|
@ -267,6 +460,88 @@ def admin_list_checkpoints():
|
||||||
return jsonify({"success": False, "message": str(exc)}), 500
|
return jsonify({"success": False, "message": str(exc)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@v2_bp.route('/admin/permit-risk-snapshots', methods=['GET'])
|
||||||
|
def admin_permit_risk_snapshots():
|
||||||
|
"""List permit risk checkpoint history entries for the management UI."""
|
||||||
|
try:
|
||||||
|
args = request.args
|
||||||
|
region_id = args.get("region_id") or args.get("region")
|
||||||
|
permit_id = args.get("permit_id") or args.get("permit")
|
||||||
|
edited_by = args.get("edited_by")
|
||||||
|
|
||||||
|
try:
|
||||||
|
limit = int(args.get("limit", 20))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
limit = 20
|
||||||
|
limit = max(1, min(limit, 200))
|
||||||
|
|
||||||
|
try:
|
||||||
|
offset = int(args.get("offset", 0))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
offset = 0
|
||||||
|
offset = max(0, offset)
|
||||||
|
|
||||||
|
snapshots = list_permit_risk_snapshot_summaries(
|
||||||
|
region_id=region_id,
|
||||||
|
permit_id=permit_id,
|
||||||
|
edited_by=edited_by,
|
||||||
|
limit=limit,
|
||||||
|
offset=offset,
|
||||||
|
)
|
||||||
|
total = count_permit_risk_snapshots(
|
||||||
|
region_id=region_id,
|
||||||
|
permit_id=permit_id,
|
||||||
|
edited_by=edited_by,
|
||||||
|
)
|
||||||
|
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"success": True,
|
||||||
|
"data": {
|
||||||
|
"snapshots": snapshots,
|
||||||
|
"pagination": {
|
||||||
|
"limit": limit,
|
||||||
|
"offset": offset,
|
||||||
|
"total": total,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"admin_permit_risk_snapshots error: {exc}")
|
||||||
|
return jsonify({"success": False, "message": str(exc)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@v2_bp.route('/admin/permit-risk-snapshots/<batch_id>/restore', methods=['POST'])
|
||||||
|
def admin_restore_permit_risk_snapshot(batch_id):
|
||||||
|
"""Restore permit risk relations from a snapshot batch."""
|
||||||
|
payload: Dict[str, Any] = {}
|
||||||
|
if request.is_json:
|
||||||
|
payload = request.get_json(silent=True) or {}
|
||||||
|
elif request.form:
|
||||||
|
payload = request.form.to_dict(flat=True)
|
||||||
|
|
||||||
|
edited_by = (payload.get("edited_by") or "").strip() or None
|
||||||
|
change_summary = payload.get("change_summary")
|
||||||
|
if change_summary is not None:
|
||||||
|
change_summary = str(change_summary).strip()
|
||||||
|
if not change_summary:
|
||||||
|
change_summary = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = restore_permit_risk_snapshot_batch(
|
||||||
|
batch_id,
|
||||||
|
edited_by=edited_by,
|
||||||
|
change_summary=change_summary,
|
||||||
|
)
|
||||||
|
return jsonify({"success": True, "data": result})
|
||||||
|
except ValueError as exc:
|
||||||
|
return jsonify({"success": False, "message": str(exc)}), 404
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"admin_restore_permit_risk_snapshot error: {exc}")
|
||||||
|
return jsonify({"success": False, "message": str(exc)}), 500
|
||||||
|
|
||||||
|
|
||||||
@v2_bp.route('/admin/checkpoints', methods=['POST'])
|
@v2_bp.route('/admin/checkpoints', methods=['POST'])
|
||||||
def admin_create_checkpoint():
|
def admin_create_checkpoint():
|
||||||
"""Create a new checkpoint."""
|
"""Create a new checkpoint."""
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import logging
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
from collections import OrderedDict, defaultdict
|
from collections import OrderedDict, defaultdict
|
||||||
|
import hashlib
|
||||||
from datetime import datetime, date
|
from datetime import datetime, date
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
|
|
@ -47,6 +48,7 @@ EXTRA_NEWLINES_RE = re.compile(r"\n{3,}")
|
||||||
TEXT_SPLIT_PATTERN = re.compile(r"[,\uff0c;\uff1b、\n\r]+")
|
TEXT_SPLIT_PATTERN = re.compile(r"[,\uff0c;\uff1b、\n\r]+")
|
||||||
|
|
||||||
PERMIT_IMPORT_TTL_SECONDS = 1800
|
PERMIT_IMPORT_TTL_SECONDS = 1800
|
||||||
|
MAX_PERMIT_FILE_SIZE_BYTES = 500 * 1024 # 500 KB limit for uploaded Excel files
|
||||||
_PERMIT_IMPORT_SESSIONS: Dict[str, Dict[str, Any]] = {}
|
_PERMIT_IMPORT_SESSIONS: Dict[str, Dict[str, Any]] = {}
|
||||||
_PERMIT_IMPORT_LOCK = threading.Lock()
|
_PERMIT_IMPORT_LOCK = threading.Lock()
|
||||||
|
|
||||||
|
|
@ -154,6 +156,9 @@ _IMPORT_HEADER_KEYWORDS: List[Tuple[str, Tuple[str, ...]]] = [
|
||||||
_PERMIT_SOURCES_TABLE_PRESENT: Optional[bool] = None
|
_PERMIT_SOURCES_TABLE_PRESENT: Optional[bool] = None
|
||||||
_PERMIT_SOURCES_TABLE_LOCK = threading.Lock()
|
_PERMIT_SOURCES_TABLE_LOCK = threading.Lock()
|
||||||
|
|
||||||
|
_PERMIT_FILE_SCHEMA_READY: Optional[bool] = None
|
||||||
|
_PERMIT_FILE_SCHEMA_LOCK = threading.Lock()
|
||||||
|
|
||||||
_CANONICAL_REGION_KEYWORDS: Dict[str, Tuple[str, ...]] = {
|
_CANONICAL_REGION_KEYWORDS: Dict[str, Tuple[str, ...]] = {
|
||||||
"市级": ("市级", "全市", "佛山市本级", "佛山市市级"),
|
"市级": ("市级", "全市", "佛山市本级", "佛山市市级"),
|
||||||
"禅城区": ("禅城区", "禅城"),
|
"禅城区": ("禅城区", "禅城"),
|
||||||
|
|
@ -552,8 +557,19 @@ def _cleanup_expired_import_sessions() -> None:
|
||||||
_PERMIT_IMPORT_SESSIONS.pop(session_id, None)
|
_PERMIT_IMPORT_SESSIONS.pop(session_id, None)
|
||||||
|
|
||||||
|
|
||||||
def start_permit_import_session(file_bytes: bytes, filename: str) -> Dict[str, Any]:
|
def start_permit_import_session(
|
||||||
|
file_bytes: bytes,
|
||||||
|
filename: str,
|
||||||
|
*,
|
||||||
|
content_type: Optional[str] = None,
|
||||||
|
uploaded_by: Optional[str] = None,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
"""Parse the uploaded workbook and create an import session."""
|
"""Parse the uploaded workbook and create an import session."""
|
||||||
|
if not file_bytes:
|
||||||
|
raise ValueError("上传的文件为空")
|
||||||
|
if len(file_bytes) > MAX_PERMIT_FILE_SIZE_BYTES:
|
||||||
|
raise ValueError("上传的文件超过 500KB 限制,请拆分或压缩内容后重试")
|
||||||
|
|
||||||
parsed = _parse_import_workbook(file_bytes, filename)
|
parsed = _parse_import_workbook(file_bytes, filename)
|
||||||
workbook_filename = parsed.get("filename") or os.path.basename(filename or "")
|
workbook_filename = parsed.get("filename") or os.path.basename(filename or "")
|
||||||
raw_sheet_payloads: Dict[str, Dict[str, Any]] = parsed.get("sheets", {})
|
raw_sheet_payloads: Dict[str, Dict[str, Any]] = parsed.get("sheets", {})
|
||||||
|
|
@ -702,6 +718,10 @@ def start_permit_import_session(file_bytes: bytes, filename: str) -> Dict[str, A
|
||||||
"filename": workbook_filename,
|
"filename": workbook_filename,
|
||||||
"created_at": time.time(),
|
"created_at": time.time(),
|
||||||
"sheets": session_sheets,
|
"sheets": session_sheets,
|
||||||
|
"file_bytes": bytes(file_bytes),
|
||||||
|
"file_size": len(file_bytes),
|
||||||
|
"content_type": content_type or "application/octet-stream",
|
||||||
|
"uploaded_by": uploaded_by,
|
||||||
}
|
}
|
||||||
|
|
||||||
with _PERMIT_IMPORT_LOCK:
|
with _PERMIT_IMPORT_LOCK:
|
||||||
|
|
@ -713,6 +733,8 @@ def start_permit_import_session(file_bytes: bytes, filename: str) -> Dict[str, A
|
||||||
"sheet_summaries": sheet_summaries,
|
"sheet_summaries": sheet_summaries,
|
||||||
"total_rows": parsed.get("total_rows", 0),
|
"total_rows": parsed.get("total_rows", 0),
|
||||||
"expires_in": PERMIT_IMPORT_TTL_SECONDS,
|
"expires_in": PERMIT_IMPORT_TTL_SECONDS,
|
||||||
|
"file_size": len(file_bytes),
|
||||||
|
"content_type": content_type or "application/octet-stream",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -959,6 +981,10 @@ def commit_permit_import_session(
|
||||||
if not session_payload:
|
if not session_payload:
|
||||||
raise ValueError("导入会话不存在或已过期,请重新上传Excel文件")
|
raise ValueError("导入会话不存在或已过期,请重新上传Excel文件")
|
||||||
|
|
||||||
|
session_file_bytes: Optional[bytes] = session_payload.get("file_bytes")
|
||||||
|
session_file_content_type: str = session_payload.get("content_type") or "application/octet-stream"
|
||||||
|
session_uploaded_by: Optional[str] = session_payload.get("uploaded_by")
|
||||||
|
|
||||||
session_sheets: Dict[str, Dict[str, Any]] = session_payload.get("sheets", {})
|
session_sheets: Dict[str, Dict[str, Any]] = session_payload.get("sheets", {})
|
||||||
workbook_filename = session_payload.get("filename") or ""
|
workbook_filename = session_payload.get("filename") or ""
|
||||||
|
|
||||||
|
|
@ -992,9 +1018,22 @@ def commit_permit_import_session(
|
||||||
", ".join(selected_sheets),
|
", ".join(selected_sheets),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
stored_file_meta: Optional[Dict[str, Any]] = None
|
||||||
|
stored_file_id: Optional[str] = None
|
||||||
|
|
||||||
with _lic_pg_conn(autocommit=False) as conn:
|
with _lic_pg_conn(autocommit=False) as conn:
|
||||||
try:
|
try:
|
||||||
_ensure_permit_sources_table(conn)
|
_ensure_permit_sources_table(conn)
|
||||||
|
if session_file_bytes:
|
||||||
|
_ensure_permit_file_schema(conn)
|
||||||
|
stored_file_meta = _insert_permit_file_record(
|
||||||
|
conn,
|
||||||
|
file_bytes=session_file_bytes,
|
||||||
|
filename=workbook_filename or "许可导入.xlsx",
|
||||||
|
content_type=session_file_content_type,
|
||||||
|
uploaded_by=session_uploaded_by,
|
||||||
|
)
|
||||||
|
stored_file_id = stored_file_meta.get("file_id")
|
||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
|
|
||||||
for sheet_name in selected_sheets:
|
for sheet_name in selected_sheets:
|
||||||
|
|
@ -1030,6 +1069,7 @@ def commit_permit_import_session(
|
||||||
|
|
||||||
permit_id = existing_permits.get(canonical_permit_name)
|
permit_id = existing_permits.get(canonical_permit_name)
|
||||||
should_override = canonical_permit_name in override_set
|
should_override = canonical_permit_name in override_set
|
||||||
|
permit_modified = False
|
||||||
|
|
||||||
if permit_id and not should_override:
|
if permit_id and not should_override:
|
||||||
sheet_skipped.append(canonical_permit_name)
|
sheet_skipped.append(canonical_permit_name)
|
||||||
|
|
@ -1067,6 +1107,7 @@ def commit_permit_import_session(
|
||||||
"snapshot_batch_id": backup_info.get("batch_id", ""),
|
"snapshot_batch_id": backup_info.get("batch_id", ""),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
permit_modified = True
|
||||||
else:
|
else:
|
||||||
permit_id = _ensure_permit(conn, canonical_permit_name)
|
permit_id = _ensure_permit(conn, canonical_permit_name)
|
||||||
existing_permits[canonical_permit_name] = permit_id
|
existing_permits[canonical_permit_name] = permit_id
|
||||||
|
|
@ -1078,6 +1119,7 @@ def commit_permit_import_session(
|
||||||
"region_id": region_id,
|
"region_id": region_id,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
permit_modified = True
|
||||||
|
|
||||||
theme_names: Set[str] = set()
|
theme_names: Set[str] = set()
|
||||||
scope_descriptions: Set[str] = set()
|
scope_descriptions: Set[str] = set()
|
||||||
|
|
@ -1231,6 +1273,14 @@ def commit_permit_import_session(
|
||||||
json.dumps(source_detail_payload, ensure_ascii=False),
|
json.dumps(source_detail_payload, ensure_ascii=False),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
if stored_file_id and permit_modified:
|
||||||
|
_link_file_to_permit(
|
||||||
|
conn,
|
||||||
|
file_id=stored_file_id,
|
||||||
|
region_id=region_id,
|
||||||
|
permit_id=permit_id,
|
||||||
|
created_by=session_uploaded_by or edited_by,
|
||||||
|
)
|
||||||
|
|
||||||
result["processed_sheets"].append(
|
result["processed_sheets"].append(
|
||||||
{
|
{
|
||||||
|
|
@ -1261,6 +1311,8 @@ def commit_permit_import_session(
|
||||||
conn.rollback()
|
conn.rollback()
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
result["file_attachment"] = stored_file_meta
|
||||||
|
|
||||||
with _PERMIT_IMPORT_LOCK:
|
with _PERMIT_IMPORT_LOCK:
|
||||||
_PERMIT_IMPORT_SESSIONS.pop(session_id, None)
|
_PERMIT_IMPORT_SESSIONS.pop(session_id, None)
|
||||||
|
|
||||||
|
|
@ -1353,6 +1405,220 @@ def _ensure_permit_sources_table(conn: Optional[pg.Connection] = None) -> None:
|
||||||
_PERMIT_SOURCES_TABLE_PRESENT = True
|
_PERMIT_SOURCES_TABLE_PRESENT = True
|
||||||
|
|
||||||
|
|
||||||
|
def _create_permit_file_schema(conn: pg.Connection) -> None:
|
||||||
|
"""Create permit file storage tables on demand."""
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS permit_files (
|
||||||
|
id uuid PRIMARY KEY,
|
||||||
|
filename text NOT NULL,
|
||||||
|
content_type text,
|
||||||
|
file_size integer NOT NULL,
|
||||||
|
file_data bytea NOT NULL,
|
||||||
|
checksum text,
|
||||||
|
uploaded_by text,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS permit_file_links (
|
||||||
|
id uuid PRIMARY KEY,
|
||||||
|
region_id uuid NOT NULL REFERENCES regions(id) ON DELETE CASCADE,
|
||||||
|
permit_id uuid NOT NULL REFERENCES permits(id) ON DELETE CASCADE,
|
||||||
|
file_id uuid NOT NULL REFERENCES permit_files(id) ON DELETE CASCADE,
|
||||||
|
created_by text,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS permit_file_links_region_permit_idx
|
||||||
|
ON permit_file_links (region_id, permit_id)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
CREATE INDEX IF NOT EXISTS permit_file_links_permit_idx
|
||||||
|
ON permit_file_links (permit_id)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_permit_file_schema(conn: Optional[pg.Connection] = None) -> None:
|
||||||
|
"""Ensure permit file tables exist (lazy creation, thread safe)."""
|
||||||
|
global _PERMIT_FILE_SCHEMA_READY
|
||||||
|
if _PERMIT_FILE_SCHEMA_READY:
|
||||||
|
return
|
||||||
|
|
||||||
|
with _PERMIT_FILE_SCHEMA_LOCK:
|
||||||
|
if _PERMIT_FILE_SCHEMA_READY:
|
||||||
|
return
|
||||||
|
if conn is not None:
|
||||||
|
original_autocommit = conn.autocommit
|
||||||
|
try:
|
||||||
|
conn.autocommit = True
|
||||||
|
_create_permit_file_schema(conn)
|
||||||
|
finally:
|
||||||
|
conn.autocommit = original_autocommit
|
||||||
|
else:
|
||||||
|
with _lic_pg_conn(autocommit=True) as ensure_conn:
|
||||||
|
_create_permit_file_schema(ensure_conn)
|
||||||
|
_PERMIT_FILE_SCHEMA_READY = True
|
||||||
|
|
||||||
|
|
||||||
|
def _insert_permit_file_record(
|
||||||
|
conn: pg.Connection,
|
||||||
|
*,
|
||||||
|
file_bytes: bytes,
|
||||||
|
filename: str,
|
||||||
|
content_type: Optional[str],
|
||||||
|
uploaded_by: Optional[str],
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Persist an uploaded file and return its metadata."""
|
||||||
|
normalized_name = filename or "许可导入.xlsx"
|
||||||
|
content_type = content_type or "application/octet-stream"
|
||||||
|
file_id = uuid.uuid4()
|
||||||
|
checksum = hashlib.sha256(file_bytes).hexdigest()
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO permit_files (
|
||||||
|
id,
|
||||||
|
filename,
|
||||||
|
content_type,
|
||||||
|
file_size,
|
||||||
|
file_data,
|
||||||
|
checksum,
|
||||||
|
uploaded_by
|
||||||
|
)
|
||||||
|
VALUES (%s, %s, %s, %s, %s, %s, %s)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
file_id,
|
||||||
|
normalized_name,
|
||||||
|
content_type,
|
||||||
|
len(file_bytes),
|
||||||
|
file_bytes,
|
||||||
|
checksum,
|
||||||
|
uploaded_by,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"file_id": str(file_id),
|
||||||
|
"filename": normalized_name,
|
||||||
|
"content_type": content_type,
|
||||||
|
"file_size": len(file_bytes),
|
||||||
|
"checksum": checksum,
|
||||||
|
"uploaded_by": uploaded_by,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _link_file_to_permit(
|
||||||
|
conn: pg.Connection,
|
||||||
|
*,
|
||||||
|
file_id: str,
|
||||||
|
region_id: str,
|
||||||
|
permit_id: str,
|
||||||
|
created_by: Optional[str],
|
||||||
|
) -> None:
|
||||||
|
"""Associate a stored file with a region-permit pair."""
|
||||||
|
if not (file_id and region_id and permit_id):
|
||||||
|
return
|
||||||
|
rid = str(region_id)
|
||||||
|
pid = str(permit_id)
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO permit_file_links (id, region_id, permit_id, file_id, created_by)
|
||||||
|
VALUES (%s, %s, %s, %s, %s)
|
||||||
|
ON CONFLICT (region_id, permit_id)
|
||||||
|
DO UPDATE SET
|
||||||
|
file_id = EXCLUDED.file_id,
|
||||||
|
created_by = EXCLUDED.created_by,
|
||||||
|
created_at = now()
|
||||||
|
""",
|
||||||
|
(uuid.uuid4(), rid, pid, file_id, created_by),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _load_permit_file_metadata(
|
||||||
|
conn: pg.Connection,
|
||||||
|
region_id: str,
|
||||||
|
permit_ids: Iterable[str],
|
||||||
|
) -> Dict[str, Dict[str, Any]]:
|
||||||
|
"""Load file metadata for a batch of permits."""
|
||||||
|
ids = [str(pid) for pid in permit_ids if pid]
|
||||||
|
if not ids:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
_ensure_permit_file_schema(conn)
|
||||||
|
rows = _select_permit_files(conn, region_id, ids)
|
||||||
|
out: Dict[str, Dict[str, Any]] = {}
|
||||||
|
for row in rows:
|
||||||
|
(
|
||||||
|
permit_id,
|
||||||
|
file_id,
|
||||||
|
filename,
|
||||||
|
content_type,
|
||||||
|
file_size,
|
||||||
|
created_at,
|
||||||
|
uploaded_by,
|
||||||
|
) = row
|
||||||
|
out[str(permit_id)] = {
|
||||||
|
"file_id": str(file_id),
|
||||||
|
"filename": filename or "",
|
||||||
|
"content_type": content_type or "",
|
||||||
|
"file_size": int(file_size or 0),
|
||||||
|
"created_at": created_at.isoformat() if created_at else None,
|
||||||
|
"uploaded_by": uploaded_by or "",
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _select_permit_files(conn: pg.Connection, region_id: str, permit_ids: Iterable[str]):
|
||||||
|
"""Execute the permit file metadata query, recreating tables if missing."""
|
||||||
|
sql = """
|
||||||
|
SELECT
|
||||||
|
pfl.permit_id,
|
||||||
|
pf.id,
|
||||||
|
pf.filename,
|
||||||
|
pf.content_type,
|
||||||
|
pf.file_size,
|
||||||
|
pf.created_at,
|
||||||
|
pf.uploaded_by
|
||||||
|
FROM permit_file_links pfl
|
||||||
|
JOIN permit_files pf ON pf.id = pfl.file_id
|
||||||
|
WHERE pfl.region_id = %s
|
||||||
|
AND pfl.permit_id = ANY(%s)
|
||||||
|
"""
|
||||||
|
attempts = 0
|
||||||
|
while attempts < 2:
|
||||||
|
try:
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute(sql, (region_id, permit_ids))
|
||||||
|
return cur.fetchall()
|
||||||
|
except pg.DatabaseError as exc: # type: ignore[attr-defined]
|
||||||
|
sqlstate = getattr(exc, "sqlstate", "")
|
||||||
|
if sqlstate != "42P01":
|
||||||
|
raise
|
||||||
|
attempts += 1
|
||||||
|
try:
|
||||||
|
conn.rollback()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
global _PERMIT_FILE_SCHEMA_READY
|
||||||
|
_PERMIT_FILE_SCHEMA_READY = None
|
||||||
|
_ensure_permit_file_schema(conn)
|
||||||
|
if attempts >= 2:
|
||||||
|
logger.warning("[PERMIT-FILES] permit_file_links table missing after recreate attempt, skipping metadata fetch")
|
||||||
|
return []
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
def _lic_pg_conn(autocommit: bool = False) -> pg.Connection:
|
def _lic_pg_conn(autocommit: bool = False) -> pg.Connection:
|
||||||
host = os.getenv("LIC_PG_HOST", "172.24.240.1")
|
host = os.getenv("LIC_PG_HOST", "172.24.240.1")
|
||||||
port = int(os.getenv("LIC_PG_PORT", os.getenv("PG_PORT", "5432")))
|
port = int(os.getenv("LIC_PG_PORT", os.getenv("PG_PORT", "5432")))
|
||||||
|
|
@ -1503,6 +1769,11 @@ def load_permits_and_risks(
|
||||||
region_id: str, theme_id: str, permit_id: Optional[str] = None
|
region_id: str, theme_id: str, permit_id: Optional[str] = None
|
||||||
) -> List[Dict[str, object]]:
|
) -> List[Dict[str, object]]:
|
||||||
"""Return permits with attached risk entries for a region-theme pair."""
|
"""Return permits with attached risk entries for a region-theme pair."""
|
||||||
|
# Ensure optional permit file tables exist before running user queries.
|
||||||
|
try:
|
||||||
|
_ensure_permit_file_schema()
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("[PERMIT-FILES] Failed to ensure permit file schema before loading permits: %s", exc)
|
||||||
sql = """
|
sql = """
|
||||||
SELECT
|
SELECT
|
||||||
p.id AS permit_id,
|
p.id AS permit_id,
|
||||||
|
|
@ -1590,6 +1861,18 @@ def load_permits_and_risks(
|
||||||
permit_ids = list(permits.keys())
|
permit_ids = list(permits.keys())
|
||||||
scope_map = _load_permit_scopes_for_region(conn, region_id, permit_ids)
|
scope_map = _load_permit_scopes_for_region(conn, region_id, permit_ids)
|
||||||
source_map = _load_permit_sources_for_region(conn, region_id, permit_ids)
|
source_map = _load_permit_sources_for_region(conn, region_id, permit_ids)
|
||||||
|
try:
|
||||||
|
file_meta_map = _load_permit_file_metadata(conn, region_id, permit_ids)
|
||||||
|
except pg.DatabaseError as exc: # type: ignore[attr-defined]
|
||||||
|
sqlstate = getattr(exc, "sqlstate", "")
|
||||||
|
if sqlstate == "42P01":
|
||||||
|
logger.warning("[PERMIT-FILES] permit_file_links missing while loading permits, recreating schema lazily")
|
||||||
|
global _PERMIT_FILE_SCHEMA_READY
|
||||||
|
_PERMIT_FILE_SCHEMA_READY = None
|
||||||
|
_ensure_permit_file_schema()
|
||||||
|
file_meta_map = {}
|
||||||
|
else:
|
||||||
|
raise
|
||||||
for pid in permit_ids:
|
for pid in permit_ids:
|
||||||
permits[pid]["business_scopes"] = scope_map.get(pid, [])
|
permits[pid]["business_scopes"] = scope_map.get(pid, [])
|
||||||
if pid in source_map:
|
if pid in source_map:
|
||||||
|
|
@ -1601,9 +1884,110 @@ def load_permits_and_risks(
|
||||||
"source_detail": "",
|
"source_detail": "",
|
||||||
"updated_at": None,
|
"updated_at": None,
|
||||||
}
|
}
|
||||||
|
if pid in file_meta_map:
|
||||||
|
permits[pid]["permit_file"] = file_meta_map[pid]
|
||||||
|
else:
|
||||||
|
permits[pid]["permit_file"] = {
|
||||||
|
"file_id": "",
|
||||||
|
"filename": "",
|
||||||
|
"content_type": "",
|
||||||
|
"file_size": 0,
|
||||||
|
"created_at": None,
|
||||||
|
"uploaded_by": "",
|
||||||
|
}
|
||||||
return list(permits.values())
|
return list(permits.values())
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_permit_file(region_id: str, permit_id: str) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Return file payload for a region-permit pair if available."""
|
||||||
|
if not region_id or not permit_id:
|
||||||
|
return None
|
||||||
|
|
||||||
|
with _lic_pg_conn() as conn:
|
||||||
|
_ensure_permit_file_schema(conn)
|
||||||
|
try:
|
||||||
|
row = _select_permit_file_blob(conn, region_id, permit_id)
|
||||||
|
except pg.DatabaseError as exc: # type: ignore[attr-defined]
|
||||||
|
sqlstate = getattr(exc, "sqlstate", "")
|
||||||
|
if sqlstate == "42P01":
|
||||||
|
logger.warning(
|
||||||
|
"[PERMIT-FILES] permit_file_links missing when downloading file (region=%s permit=%s); recreating schema",
|
||||||
|
region_id,
|
||||||
|
permit_id,
|
||||||
|
)
|
||||||
|
global _PERMIT_FILE_SCHEMA_READY
|
||||||
|
_PERMIT_FILE_SCHEMA_READY = None
|
||||||
|
_ensure_permit_file_schema()
|
||||||
|
return None
|
||||||
|
raise
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
|
||||||
|
(
|
||||||
|
file_id,
|
||||||
|
filename,
|
||||||
|
content_type,
|
||||||
|
file_size,
|
||||||
|
file_data,
|
||||||
|
created_at,
|
||||||
|
uploaded_by,
|
||||||
|
) = row
|
||||||
|
return {
|
||||||
|
"file_id": str(file_id),
|
||||||
|
"filename": filename or "",
|
||||||
|
"content_type": content_type or "application/octet-stream",
|
||||||
|
"file_size": int(file_size or 0),
|
||||||
|
"file_data": bytes(file_data) if file_data is not None else b"",
|
||||||
|
"created_at": created_at.isoformat() if created_at else None,
|
||||||
|
"uploaded_by": uploaded_by or "",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _select_permit_file_blob(conn: pg.Connection, region_id: str, permit_id: str):
|
||||||
|
"""Fetch a single permit file with binary content, recreating tables if needed."""
|
||||||
|
sql = """
|
||||||
|
SELECT
|
||||||
|
pf.id,
|
||||||
|
pf.filename,
|
||||||
|
pf.content_type,
|
||||||
|
pf.file_size,
|
||||||
|
pf.file_data,
|
||||||
|
pf.created_at,
|
||||||
|
pf.uploaded_by
|
||||||
|
FROM permit_file_links pfl
|
||||||
|
JOIN permit_files pf ON pf.id = pfl.file_id
|
||||||
|
WHERE pfl.region_id = %s
|
||||||
|
AND pfl.permit_id = %s
|
||||||
|
LIMIT 1
|
||||||
|
"""
|
||||||
|
attempts = 0
|
||||||
|
while attempts < 2:
|
||||||
|
try:
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute(sql, (region_id, permit_id))
|
||||||
|
return cur.fetchone()
|
||||||
|
except pg.DatabaseError as exc: # type: ignore[attr-defined]
|
||||||
|
sqlstate = getattr(exc, "sqlstate", "")
|
||||||
|
if sqlstate != "42P01":
|
||||||
|
raise
|
||||||
|
attempts += 1
|
||||||
|
try:
|
||||||
|
conn.rollback()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
global _PERMIT_FILE_SCHEMA_READY
|
||||||
|
_PERMIT_FILE_SCHEMA_READY = None
|
||||||
|
_ensure_permit_file_schema(conn)
|
||||||
|
if attempts >= 2:
|
||||||
|
logger.warning(
|
||||||
|
"[PERMIT-FILES] permit_file_links table missing after recreate attempt when downloading file (region=%s permit=%s)",
|
||||||
|
region_id,
|
||||||
|
permit_id,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def find_permit_contexts_by_name(permit_name: str) -> List[Dict[str, str]]:
|
def find_permit_contexts_by_name(permit_name: str) -> List[Dict[str, str]]:
|
||||||
"""Return region/theme contexts for permits with an exact name match."""
|
"""Return region/theme contexts for permits with an exact name match."""
|
||||||
if not permit_name:
|
if not permit_name:
|
||||||
|
|
|
||||||
|
|
@ -28,12 +28,18 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.header {
|
.header {
|
||||||
|
position: relative;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
margin-bottom: 30px;
|
margin-bottom: 30px;
|
||||||
padding-bottom: 20px;
|
padding-bottom: 24px;
|
||||||
border-bottom: 3px solid #667eea;
|
border-bottom: 3px solid #667eea;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.header-info {
|
||||||
|
max-width: 720px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
.header h1 {
|
.header h1 {
|
||||||
color: #333;
|
color: #333;
|
||||||
font-size: 28px;
|
font-size: 28px;
|
||||||
|
|
@ -45,6 +51,115 @@
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.user-bar {
|
||||||
|
position: absolute;
|
||||||
|
right: 24px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
padding: 12px 16px;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 16px;
|
||||||
|
background: rgba(255, 255, 255, 0.98);
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-bar:hover {
|
||||||
|
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.16);
|
||||||
|
border-color: #d1d5db;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-avatar {
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: white;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 18px;
|
||||||
|
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 3px;
|
||||||
|
color: #1f2937;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-name {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 15px;
|
||||||
|
color: #111827;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-role {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
background: linear-gradient(135deg, #e0e7ff 0%, #ddd6fe 100%);
|
||||||
|
color: #4f46e5;
|
||||||
|
padding: 3px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-time {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #6b7280;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-alert {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #f59e0b;
|
||||||
|
margin-top: 2px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-actions button {
|
||||||
|
border: none;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 8px 14px;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.25s ease;
|
||||||
|
background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%);
|
||||||
|
color: white;
|
||||||
|
box-shadow: 0 4px 12px rgba(239, 68, 68, 0.3);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-actions button:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 6px 16px rgba(239, 68, 68, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-actions button:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
.step-indicator {
|
.step-indicator {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|
@ -801,6 +916,76 @@
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.import-template-wrapper {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-start;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-template-button {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px 18px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: linear-gradient(135deg, #5c6bc0, #3949ab);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-decoration: none;
|
||||||
|
box-shadow: 0 8px 22px rgba(57, 73, 171, 0.35);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||||
|
transform: translateY(0);
|
||||||
|
transition: transform 0.2s ease, box-shadow 0.3s ease, background 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-template-button:hover {
|
||||||
|
background: linear-gradient(135deg, #283593, #1a237e);
|
||||||
|
text-decoration: none;
|
||||||
|
box-shadow: 0 10px 26px rgba(26, 35, 126, 0.45);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-template-icon {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 38px;
|
||||||
|
height: 38px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(255, 255, 255, 0.25);
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-template-text {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-template-title {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-template-subtitle {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 400;
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-template-badge {
|
||||||
|
margin-left: auto;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(255, 255, 255, 0.25);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
.import-upload-area input[type="file"] {
|
.import-upload-area input[type="file"] {
|
||||||
background: #fff;
|
background: #fff;
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
|
|
@ -1219,6 +1404,22 @@
|
||||||
color: #c62828;
|
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 {
|
.loading {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
width: 20px;
|
width: 20px;
|
||||||
|
|
@ -1253,14 +1454,55 @@
|
||||||
max-height: 300px;
|
max-height: 300px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.user-bar {
|
||||||
|
position: static;
|
||||||
|
transform: none;
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 14px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-info {
|
||||||
|
align-items: center;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-actions {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-time {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="header">
|
<div class="header">
|
||||||
<h1>🗃️ 数据库维护系统</h1>
|
<div class="header-info">
|
||||||
<p>LawRisk 法律风险提示系统 - 数据库维护与查询工具</p>
|
<h1>🗃️ 数据库维护系统</h1>
|
||||||
|
<p>LawRisk 法律风险提示系统 - 数据库维护与查询工具</p>
|
||||||
|
</div>
|
||||||
|
<div class="user-bar" id="userBar">
|
||||||
|
<div class="user-avatar" id="userAvatar">U</div>
|
||||||
|
<div class="user-info">
|
||||||
|
<div class="user-name" id="userDisplayName">--</div>
|
||||||
|
<span class="user-role" id="userRole">UNAUTH</span>
|
||||||
|
<div class="user-time" id="userTime" style="display: none;">
|
||||||
|
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" style="flex-shrink: 0;">
|
||||||
|
<circle cx="6" cy="6" r="5.5" stroke="#9ca3af" stroke-width="1"/>
|
||||||
|
<path d="M6 3.5V6.5L8 8" stroke="#9ca3af" stroke-width="1" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
<span id="loginTime">--</span>
|
||||||
|
</div>
|
||||||
|
<div class="user-alert" id="userStatus"></div>
|
||||||
|
</div>
|
||||||
|
<div class="user-actions">
|
||||||
|
<button type="button" class="btn-logout" id="logoutBtn">退出登录</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="step-indicator">
|
<div class="step-indicator">
|
||||||
|
|
@ -1368,6 +1610,96 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<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=许可, 4=详情
|
let currentStep = 1; // 1=区域, 2=主题, 3=许可, 4=详情
|
||||||
let historyStack = []; // 历史记录栈
|
let historyStack = []; // 历史记录栈
|
||||||
|
|
@ -1400,6 +1732,8 @@
|
||||||
let checkpointListError = '';
|
let checkpointListError = '';
|
||||||
let expandedSnapshotGroups = new Set();
|
let expandedSnapshotGroups = new Set();
|
||||||
|
|
||||||
|
const PERMIT_FILE_MAX_BYTES = 500 * 1024; // 500KB
|
||||||
|
|
||||||
const permitImportState = {
|
const permitImportState = {
|
||||||
uploading: false,
|
uploading: false,
|
||||||
sessionId: '',
|
sessionId: '',
|
||||||
|
|
@ -1412,9 +1746,49 @@
|
||||||
success: '',
|
success: '',
|
||||||
commitLoading: false,
|
commitLoading: false,
|
||||||
editedBy: '',
|
editedBy: '',
|
||||||
changeSummary: ''
|
changeSummary: '',
|
||||||
|
fileSize: 0
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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 = {
|
const steps = {
|
||||||
1: { title: '选择区域', loadData: loadRegions },
|
1: { title: '选择区域', loadData: loadRegions },
|
||||||
|
|
@ -1751,6 +2125,22 @@
|
||||||
}
|
}
|
||||||
const deleteButtonLabel = isDeletingPermit ? '删除中...' : '删除许可';
|
const deleteButtonLabel = isDeletingPermit ? '删除中...' : '删除许可';
|
||||||
const deleteButtonDisabled = isDeletingPermit ? 'disabled' : '';
|
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>';
|
||||||
|
|
||||||
let html = '<div class="details-content">';
|
let html = '<div class="details-content">';
|
||||||
html += `
|
html += `
|
||||||
|
|
@ -1759,6 +2149,7 @@
|
||||||
风险条目:<strong>${riskCount}</strong> 个
|
风险条目:<strong>${riskCount}</strong> 个
|
||||||
</div>
|
</div>
|
||||||
<div class="detail-actions">
|
<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>
|
<button class="btn btn-danger" id="deletePermitBtn" ${deleteButtonDisabled} onclick="confirmDeleteCurrentPermit()">${deleteButtonLabel}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1769,55 +2160,65 @@
|
||||||
<div class="detail-section">
|
<div class="detail-section">
|
||||||
<h3>许可信息</h3>
|
<h3>许可信息</h3>
|
||||||
<div class="detail-content">
|
<div class="detail-content">
|
||||||
<p><strong>许可名称:</strong>${permit.name}</p>
|
<p><strong>许可名称:</strong>${formatNullableText(permit.name, '(未命名)')}</p>
|
||||||
${permit.permit_source && permit.permit_source.source_name ? `<p style="margin-top: 10px;"><strong>数据来源:</strong>${escapeHtml(permit.permit_source.source_name)}</p>` : ''}
|
<p style="margin-top: 10px;"><strong>数据来源:</strong>${permitSourceDisplay}</p>
|
||||||
${permit.permit_status ? `<p style="margin-top: 10px;"><strong>许可状态:</strong><span class="permit-status ${permit.permit_status === 'active' ? 'status-active' : 'status-inactive'}">${permit.permit_status}</span></p>` : ''}
|
<p style="margin-top: 10px;"><strong>许可状态:</strong><span class="permit-status ${permitStatusClass}">${permitStatusLabel}</span></p>
|
||||||
${permit.subitem_summary ? `<p style="margin-top: 10px;"><strong>子项说明:</strong>${permit.subitem_summary}</p>` : ''}
|
<p style="margin-top: 10px;"><strong>原始文件:</strong>${fileInfoText}</p>
|
||||||
${permit.responsible_contact ? `<p style="margin-top: 10px;"><strong>负责部门:</strong>${permit.responsible_contact}</p>` : ''}
|
<p style="margin-top: 10px;"><strong>子项说明:</strong>${formatNullableText(permit.subitem_summary)}</p>
|
||||||
${permit.jurisdiction_scope ? `<p style="margin-top: 10px;"><strong>权限划分:</strong>${permit.jurisdiction_scope}</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>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// 经营范围
|
// 经营范围
|
||||||
|
html += '<div class="detail-section"><h3>经营范围</h3><div class="detail-content">';
|
||||||
if (permit.business_scopes && permit.business_scopes.length > 0) {
|
if (permit.business_scopes && permit.business_scopes.length > 0) {
|
||||||
html += '<div class="detail-section"><h3>经营范围</h3><div class="detail-content">';
|
|
||||||
permit.business_scopes.forEach(scope => {
|
permit.business_scopes.forEach(scope => {
|
||||||
html += `<div class="scope-item">${scope.description}</div>`;
|
html += `<div class="scope-item">${formatNullableText(scope.description)}</div>`;
|
||||||
});
|
});
|
||||||
html += '</div></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) {
|
if (permit.risks && permit.risks.length > 0) {
|
||||||
html += '<div class="detail-section"><h3>法律风险</h3><div class="detail-content">';
|
permit.risks.forEach((risk, index) => {
|
||||||
permit.risks.forEach(risk => {
|
const riskIdentifier = formatNullableText(risk.id, index + 1);
|
||||||
html += `
|
html += `
|
||||||
<div class="risk-item">
|
<div class="risk-item">
|
||||||
<h4>风险 ${risk.id}</h4>
|
<h4>风险 ${riskIdentifier}</h4>
|
||||||
${risk.risk_content ? `<div class="risk-field"><strong>风险内容:</strong><p>${risk.risk_content}</p></div>` : ''}
|
<div class="risk-field"><strong>风险内容:</strong><p>${formatNullableText(risk.risk_content)}</p></div>
|
||||||
${risk.legal_basis ? `<div class="risk-field"><strong>法律依据:</strong><p>${risk.legal_basis}</p></div>` : ''}
|
<div class="risk-field"><strong>法律依据:</strong><p>${formatNullableText(risk.legal_basis)}</p></div>
|
||||||
${risk.document_no ? `<div class="risk-field"><strong>文件编号:</strong><p>${risk.document_no}</p></div>` : ''}
|
<div class="risk-field"><strong>文件编号:</strong><p>${formatNullableText(risk.document_no)}</p></div>
|
||||||
${risk.summary ? `<div class="risk-field"><strong>摘要:</strong><div style="margin-top: 5px;">${risk.summary}</div></div>` : ''}
|
<div class="risk-field"><strong>摘要:</strong><div style="margin-top: 5px;">${formatNullableText(risk.summary)}</div></div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
});
|
});
|
||||||
html += '</div></div>';
|
|
||||||
} else {
|
} else {
|
||||||
html += `
|
html += '<p class="muted-text">暂无法律风险记录</p>';
|
||||||
<div class="detail-section">
|
|
||||||
<h3>法律风险</h3>
|
|
||||||
<div class="detail-content">
|
|
||||||
<p style="color: #999;">暂无法律风险信息</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
}
|
||||||
|
html += '</div></div>';
|
||||||
|
|
||||||
html += '</div>';
|
html += '</div>';
|
||||||
detailsArea.innerHTML = html;
|
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() {
|
function confirmDeleteCurrentPermit() {
|
||||||
if (isDeletingPermit) {
|
if (isDeletingPermit) {
|
||||||
return;
|
return;
|
||||||
|
|
@ -1953,6 +2354,9 @@
|
||||||
permitImportState.overrides = new Map();
|
permitImportState.overrides = new Map();
|
||||||
permitImportState.error = '';
|
permitImportState.error = '';
|
||||||
permitImportState.success = '';
|
permitImportState.success = '';
|
||||||
|
permitImportState.filename = '';
|
||||||
|
permitImportState.totalRows = 0;
|
||||||
|
permitImportState.fileSize = 0;
|
||||||
}
|
}
|
||||||
renderImportModal();
|
renderImportModal();
|
||||||
}
|
}
|
||||||
|
|
@ -2021,6 +2425,17 @@
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const file = input.files[0];
|
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();
|
const formData = new FormData();
|
||||||
formData.append('file', file);
|
formData.append('file', file);
|
||||||
|
|
||||||
|
|
@ -2041,6 +2456,7 @@
|
||||||
permitImportState.filename = payload.filename || (file && file.name) || '';
|
permitImportState.filename = payload.filename || (file && file.name) || '';
|
||||||
permitImportState.totalRows = payload.total_rows || payload.totalRows || 0;
|
permitImportState.totalRows = payload.total_rows || payload.totalRows || 0;
|
||||||
permitImportState.sheetSummaries = payload.sheet_summaries || payload.sheetSummaries || [];
|
permitImportState.sheetSummaries = payload.sheet_summaries || payload.sheetSummaries || [];
|
||||||
|
permitImportState.fileSize = payload.file_size || payload.fileSize || file.size || 0;
|
||||||
permitImportState.selectedSheets = new Set(
|
permitImportState.selectedSheets = new Set(
|
||||||
(permitImportState.sheetSummaries || []).map(item => item.sheet_name)
|
(permitImportState.sheetSummaries || []).map(item => item.sheet_name)
|
||||||
);
|
);
|
||||||
|
|
@ -2050,6 +2466,7 @@
|
||||||
permitImportState.error = data.message || '解析失败,请检查Excel格式';
|
permitImportState.error = data.message || '解析失败,请检查Excel格式';
|
||||||
permitImportState.sessionId = '';
|
permitImportState.sessionId = '';
|
||||||
permitImportState.sheetSummaries = [];
|
permitImportState.sheetSummaries = [];
|
||||||
|
permitImportState.fileSize = 0;
|
||||||
permitImportState.selectedSheets = new Set();
|
permitImportState.selectedSheets = new Set();
|
||||||
permitImportState.overrides = new Map();
|
permitImportState.overrides = new Map();
|
||||||
}
|
}
|
||||||
|
|
@ -2059,6 +2476,7 @@
|
||||||
permitImportState.sheetSummaries = [];
|
permitImportState.sheetSummaries = [];
|
||||||
permitImportState.selectedSheets = new Set();
|
permitImportState.selectedSheets = new Set();
|
||||||
permitImportState.overrides = new Map();
|
permitImportState.overrides = new Map();
|
||||||
|
permitImportState.fileSize = 0;
|
||||||
} finally {
|
} finally {
|
||||||
permitImportState.uploading = false;
|
permitImportState.uploading = false;
|
||||||
renderImportModal();
|
renderImportModal();
|
||||||
|
|
@ -2118,6 +2536,9 @@
|
||||||
permitImportState.sheetSummaries = [];
|
permitImportState.sheetSummaries = [];
|
||||||
permitImportState.selectedSheets = new Set();
|
permitImportState.selectedSheets = new Set();
|
||||||
permitImportState.overrides = new Map();
|
permitImportState.overrides = new Map();
|
||||||
|
permitImportState.filename = '';
|
||||||
|
permitImportState.totalRows = 0;
|
||||||
|
permitImportState.fileSize = 0;
|
||||||
|
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
refreshPermitRiskSnapshots(false),
|
refreshPermitRiskSnapshots(false),
|
||||||
|
|
@ -2148,12 +2569,21 @@
|
||||||
html += '<h3><span>📄</span> 上传 Excel</h3>';
|
html += '<h3><span>📄</span> 上传 Excel</h3>';
|
||||||
html += '<div class="import-upload-area">';
|
html += '<div class="import-upload-area">';
|
||||||
html += '<input type="file" accept=".xlsx,.xlsm" onchange="handleImportFile(this)">';
|
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) {
|
if (state.sessionId) {
|
||||||
const sheetCount = state.sheetSummaries ? state.sheetSummaries.length : 0;
|
const sheetCount = state.sheetSummaries ? state.sheetSummaries.length : 0;
|
||||||
html += `<div class="import-meta">当前会话:${escapeHtml(state.filename || '(未命名)')} | Sheet ${sheetCount} 个 | 风险 ${state.totalRows || 0} 条</div>`;
|
html += `<div class="import-meta">当前会话:${escapeHtml(state.filename || '(未命名)')} | Sheet ${sheetCount} 个 | 风险 ${state.totalRows || 0} 条</div>`;
|
||||||
} else {
|
} else {
|
||||||
html += '<div class="import-meta">请选择包含许可数据的 Excel 文件,系统会自动解析所有 Sheet,并以区划为单位生成导入任务。</div>';
|
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>';
|
html += '</div></div>';
|
||||||
|
|
||||||
if (state.error) {
|
if (state.error) {
|
||||||
|
|
@ -3233,7 +3663,8 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
// 页面加载时初始化
|
// 页面加载时初始化
|
||||||
window.addEventListener('DOMContentLoaded', () => {
|
window.addEventListener('DOMContentLoaded', async () => {
|
||||||
|
await fetchCurrentUser(true);
|
||||||
goToStep(1);
|
goToStep(1);
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue