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."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
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 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.licensing_repo import (
|
||||
list_permits_for_region,
|
||||
|
|
@ -15,6 +19,13 @@ from lawrisk.services.licensing_repo import (
|
|||
list_checkpoints,
|
||||
restore_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
|
||||
|
||||
|
|
@ -147,6 +158,18 @@ def test_simple():
|
|||
"""Very simple test."""
|
||||
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'])
|
||||
def admin_regions():
|
||||
|
|
@ -223,6 +246,119 @@ def admin_permits():
|
|||
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'])
|
||||
def admin_permit_details():
|
||||
"""Get detailed information for a specific permit."""
|
||||
|
|
@ -256,6 +392,63 @@ def admin_permit_details():
|
|||
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'])
|
||||
def admin_list_checkpoints():
|
||||
"""List all available checkpoints."""
|
||||
|
|
@ -267,6 +460,88 @@ def admin_list_checkpoints():
|
|||
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'])
|
||||
def admin_create_checkpoint():
|
||||
"""Create a new checkpoint."""
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import logging
|
|||
import os
|
||||
import re
|
||||
from collections import OrderedDict, defaultdict
|
||||
import hashlib
|
||||
from datetime import datetime, date
|
||||
from decimal import Decimal
|
||||
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]+")
|
||||
|
||||
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_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_LOCK = threading.Lock()
|
||||
|
||||
_PERMIT_FILE_SCHEMA_READY: Optional[bool] = None
|
||||
_PERMIT_FILE_SCHEMA_LOCK = threading.Lock()
|
||||
|
||||
_CANONICAL_REGION_KEYWORDS: Dict[str, Tuple[str, ...]] = {
|
||||
"市级": ("市级", "全市", "佛山市本级", "佛山市市级"),
|
||||
"禅城区": ("禅城区", "禅城"),
|
||||
|
|
@ -552,8 +557,19 @@ def _cleanup_expired_import_sessions() -> 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."""
|
||||
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)
|
||||
workbook_filename = parsed.get("filename") or os.path.basename(filename or "")
|
||||
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,
|
||||
"created_at": time.time(),
|
||||
"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:
|
||||
|
|
@ -713,6 +733,8 @@ def start_permit_import_session(file_bytes: bytes, filename: str) -> Dict[str, A
|
|||
"sheet_summaries": sheet_summaries,
|
||||
"total_rows": parsed.get("total_rows", 0),
|
||||
"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:
|
||||
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", {})
|
||||
workbook_filename = session_payload.get("filename") or ""
|
||||
|
||||
|
|
@ -992,9 +1018,22 @@ def commit_permit_import_session(
|
|||
", ".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:
|
||||
try:
|
||||
_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()
|
||||
|
||||
for sheet_name in selected_sheets:
|
||||
|
|
@ -1030,6 +1069,7 @@ def commit_permit_import_session(
|
|||
|
||||
permit_id = existing_permits.get(canonical_permit_name)
|
||||
should_override = canonical_permit_name in override_set
|
||||
permit_modified = False
|
||||
|
||||
if permit_id and not should_override:
|
||||
sheet_skipped.append(canonical_permit_name)
|
||||
|
|
@ -1067,6 +1107,7 @@ def commit_permit_import_session(
|
|||
"snapshot_batch_id": backup_info.get("batch_id", ""),
|
||||
}
|
||||
)
|
||||
permit_modified = True
|
||||
else:
|
||||
permit_id = _ensure_permit(conn, canonical_permit_name)
|
||||
existing_permits[canonical_permit_name] = permit_id
|
||||
|
|
@ -1078,6 +1119,7 @@ def commit_permit_import_session(
|
|||
"region_id": region_id,
|
||||
}
|
||||
)
|
||||
permit_modified = True
|
||||
|
||||
theme_names: 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),
|
||||
),
|
||||
)
|
||||
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(
|
||||
{
|
||||
|
|
@ -1261,6 +1311,8 @@ def commit_permit_import_session(
|
|||
conn.rollback()
|
||||
raise
|
||||
|
||||
result["file_attachment"] = stored_file_meta
|
||||
|
||||
with _PERMIT_IMPORT_LOCK:
|
||||
_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
|
||||
|
||||
|
||||
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:
|
||||
host = os.getenv("LIC_PG_HOST", "172.24.240.1")
|
||||
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
|
||||
) -> List[Dict[str, object]]:
|
||||
"""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 = """
|
||||
SELECT
|
||||
p.id AS permit_id,
|
||||
|
|
@ -1590,6 +1861,18 @@ def load_permits_and_risks(
|
|||
permit_ids = list(permits.keys())
|
||||
scope_map = _load_permit_scopes_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:
|
||||
permits[pid]["business_scopes"] = scope_map.get(pid, [])
|
||||
if pid in source_map:
|
||||
|
|
@ -1601,9 +1884,110 @@ def load_permits_and_risks(
|
|||
"source_detail": "",
|
||||
"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())
|
||||
|
||||
|
||||
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]]:
|
||||
"""Return region/theme contexts for permits with an exact name match."""
|
||||
if not permit_name:
|
||||
|
|
|
|||
|
|
@ -28,12 +28,18 @@
|
|||
}
|
||||
|
||||
.header {
|
||||
position: relative;
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
padding-bottom: 20px;
|
||||
padding-bottom: 24px;
|
||||
border-bottom: 3px solid #667eea;
|
||||
}
|
||||
|
||||
.header-info {
|
||||
max-width: 720px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
color: #333;
|
||||
font-size: 28px;
|
||||
|
|
@ -45,6 +51,115 @@
|
|||
font-size: 14px;
|
||||
}
|
||||
|
||||
.user-bar {
|
||||
position: absolute;
|
||||
right: 24px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
padding: 12px 16px;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 16px;
|
||||
background: rgba(255, 255, 255, 0.98);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.user-bar:hover {
|
||||
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.16);
|
||||
border-color: #d1d5db;
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
font-size: 18px;
|
||||
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
color: #1f2937;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
color: #111827;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.user-role {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
background: linear-gradient(135deg, #e0e7ff 0%, #ddd6fe 100%);
|
||||
color: #4f46e5;
|
||||
padding: 3px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.user-time {
|
||||
font-size: 11px;
|
||||
color: #6b7280;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.user-alert {
|
||||
font-size: 12px;
|
||||
color: #f59e0b;
|
||||
margin-top: 2px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.user-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.user-actions button {
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
padding: 8px 14px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: all 0.25s ease;
|
||||
background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%);
|
||||
color: white;
|
||||
box-shadow: 0 4px 12px rgba(239, 68, 68, 0.3);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.user-actions button:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 6px 16px rgba(239, 68, 68, 0.4);
|
||||
}
|
||||
|
||||
.user-actions button:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.step-indicator {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
|
@ -801,6 +916,76 @@
|
|||
gap: 8px;
|
||||
}
|
||||
|
||||
.import-template-wrapper {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.import-template-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 18px;
|
||||
border-radius: 10px;
|
||||
background: linear-gradient(135deg, #5c6bc0, #3949ab);
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
box-shadow: 0 8px 22px rgba(57, 73, 171, 0.35);
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
transform: translateY(0);
|
||||
transition: transform 0.2s ease, box-shadow 0.3s ease, background 0.3s ease;
|
||||
}
|
||||
|
||||
.import-template-button:hover {
|
||||
background: linear-gradient(135deg, #283593, #1a237e);
|
||||
text-decoration: none;
|
||||
box-shadow: 0 10px 26px rgba(26, 35, 126, 0.45);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.import-template-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.import-template-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.import-template-title {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.import-template-subtitle {
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.import-template-badge {
|
||||
margin-left: auto;
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.import-upload-area input[type="file"] {
|
||||
background: #fff;
|
||||
padding: 10px;
|
||||
|
|
@ -1219,6 +1404,22 @@
|
|||
color: #c62828;
|
||||
}
|
||||
|
||||
.status-unknown {
|
||||
background: #f1f5f9;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.permit-file-name {
|
||||
font-weight: 600;
|
||||
color: #1d4ed8;
|
||||
}
|
||||
|
||||
.muted-text {
|
||||
color: #94a3b8;
|
||||
font-size: 14px;
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
.loading {
|
||||
display: inline-block;
|
||||
width: 20px;
|
||||
|
|
@ -1253,14 +1454,55 @@
|
|||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.user-bar {
|
||||
position: static;
|
||||
transform: none;
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
padding: 14px 16px;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.user-actions {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.user-time {
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>🗃️ 数据库维护系统</h1>
|
||||
<p>LawRisk 法律风险提示系统 - 数据库维护与查询工具</p>
|
||||
<div class="header-info">
|
||||
<h1>🗃️ 数据库维护系统</h1>
|
||||
<p>LawRisk 法律风险提示系统 - 数据库维护与查询工具</p>
|
||||
</div>
|
||||
<div class="user-bar" id="userBar">
|
||||
<div class="user-avatar" id="userAvatar">U</div>
|
||||
<div class="user-info">
|
||||
<div class="user-name" id="userDisplayName">--</div>
|
||||
<span class="user-role" id="userRole">UNAUTH</span>
|
||||
<div class="user-time" id="userTime" style="display: none;">
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" style="flex-shrink: 0;">
|
||||
<circle cx="6" cy="6" r="5.5" stroke="#9ca3af" stroke-width="1"/>
|
||||
<path d="M6 3.5V6.5L8 8" stroke="#9ca3af" stroke-width="1" stroke-linecap="round"/>
|
||||
</svg>
|
||||
<span id="loginTime">--</span>
|
||||
</div>
|
||||
<div class="user-alert" id="userStatus"></div>
|
||||
</div>
|
||||
<div class="user-actions">
|
||||
<button type="button" class="btn-logout" id="logoutBtn">退出登录</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="step-indicator">
|
||||
|
|
@ -1368,6 +1610,96 @@
|
|||
</div>
|
||||
|
||||
<script>
|
||||
const LOGIN_PATH = '/fs-ai-asistant/lawrisk/login';
|
||||
let currentUserProfile = null;
|
||||
let loginTime = null;
|
||||
|
||||
const userNameEl = document.getElementById('userDisplayName');
|
||||
const userRoleEl = document.getElementById('userRole');
|
||||
const userStatusEl = document.getElementById('userStatus');
|
||||
const userAvatarEl = document.getElementById('userAvatar');
|
||||
const userTimeEl = document.getElementById('userTime');
|
||||
const loginTimeEl = document.getElementById('loginTime');
|
||||
const logoutBtn = document.getElementById('logoutBtn');
|
||||
|
||||
function buildLoginRedirectUrl() {
|
||||
const next = encodeURIComponent(window.location.pathname + window.location.search);
|
||||
return `${LOGIN_PATH}?next=${next || '%2F'}`;
|
||||
}
|
||||
|
||||
function updateUserBanner(user, message = '') {
|
||||
if (!user) {
|
||||
userNameEl.textContent = '访客';
|
||||
userRoleEl.textContent = 'LOGIN';
|
||||
userStatusEl.textContent = message || '登录状态失效,请重新登录';
|
||||
userAvatarEl.textContent = '?';
|
||||
userTimeEl.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
const displayName = user.display_name || user.username;
|
||||
userNameEl.textContent = displayName || user.username;
|
||||
userRoleEl.textContent = (user.role || 'user').toUpperCase();
|
||||
userStatusEl.textContent = message || '';
|
||||
|
||||
// Update avatar with first letter of username
|
||||
const initial = (displayName || user.username || '?').charAt(0).toUpperCase();
|
||||
userAvatarEl.textContent = initial;
|
||||
|
||||
// Set login time if not already set
|
||||
if (!loginTime) {
|
||||
loginTime = new Date();
|
||||
const timeStr = loginTime.toLocaleTimeString('zh-CN', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
loginTimeEl.textContent = `登录于 ${timeStr}`;
|
||||
userTimeEl.style.display = 'flex';
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchCurrentUser(redirectOnFail = true) {
|
||||
try {
|
||||
const resp = await fetch('/auth/me', { headers: { Accept: 'application/json' } });
|
||||
if (!resp.ok) {
|
||||
throw new Error('未登录或登录已过期');
|
||||
}
|
||||
const data = await resp.json();
|
||||
if (!data.authenticated) {
|
||||
throw new Error('未登录或登录已过期');
|
||||
}
|
||||
currentUserProfile = data.user;
|
||||
updateUserBanner(currentUserProfile);
|
||||
return currentUserProfile;
|
||||
} catch (error) {
|
||||
currentUserProfile = null;
|
||||
updateUserBanner(null, error.message);
|
||||
if (redirectOnFail) {
|
||||
window.location.href = buildLoginRedirectUrl();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLogout() {
|
||||
try {
|
||||
await fetch('/auth/logout', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ reason: 'user_action' }),
|
||||
});
|
||||
} finally {
|
||||
loginTime = null; // Reset login time
|
||||
window.location.href = buildLoginRedirectUrl();
|
||||
}
|
||||
}
|
||||
|
||||
if (logoutBtn) {
|
||||
logoutBtn.addEventListener('click', (event) => {
|
||||
event.preventDefault();
|
||||
handleLogout();
|
||||
});
|
||||
}
|
||||
|
||||
// 导航状态管理
|
||||
let currentStep = 1; // 1=区域, 2=主题, 3=许可, 4=详情
|
||||
let historyStack = []; // 历史记录栈
|
||||
|
|
@ -1400,6 +1732,8 @@
|
|||
let checkpointListError = '';
|
||||
let expandedSnapshotGroups = new Set();
|
||||
|
||||
const PERMIT_FILE_MAX_BYTES = 500 * 1024; // 500KB
|
||||
|
||||
const permitImportState = {
|
||||
uploading: false,
|
||||
sessionId: '',
|
||||
|
|
@ -1412,9 +1746,49 @@
|
|||
success: '',
|
||||
commitLoading: false,
|
||||
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 = {
|
||||
1: { title: '选择区域', loadData: loadRegions },
|
||||
|
|
@ -1751,6 +2125,22 @@
|
|||
}
|
||||
const deleteButtonLabel = isDeletingPermit ? '删除中...' : '删除许可';
|
||||
const deleteButtonDisabled = isDeletingPermit ? 'disabled' : '';
|
||||
const permitSourceName = permit && permit.permit_source && permit.permit_source.source_name
|
||||
? permit.permit_source.source_name
|
||||
: '';
|
||||
const permitSourceDisplay = permitSourceName ? escapeHtml(permitSourceName) : EMPTY_PLACEHOLDER;
|
||||
const permitStatusClass = permit && permit.permit_status
|
||||
? (permit.permit_status === 'active' ? 'status-active' : 'status-inactive')
|
||||
: 'status-unknown';
|
||||
const permitStatusLabel = formatNullableText(permit.permit_status, '未设置');
|
||||
const permitFile = (permit && permit.permit_file) ? permit.permit_file : {};
|
||||
const hasPermitFile = Boolean(permitFile.file_id);
|
||||
const fileUploadedAt = permitFile.created_at ? formatIsoDatetime(permitFile.created_at) : '';
|
||||
const fileUploadedBy = permitFile.uploaded_by ? escapeHtml(permitFile.uploaded_by) : '';
|
||||
const downloadDisabledAttr = hasPermitFile ? '' : 'disabled';
|
||||
const fileInfoText = hasPermitFile
|
||||
? `<span class="permit-file-name">${escapeHtml(permitFile.filename || '原始文件')}</span>(${formatFileSize(permitFile.file_size)}${fileUploadedAt ? ` | ${fileUploadedAt}` : ''}${fileUploadedBy ? ` | 上传:${fileUploadedBy}` : ''})`
|
||||
: '<span class="muted-text">暂无关联文件</span>';
|
||||
|
||||
let html = '<div class="details-content">';
|
||||
html += `
|
||||
|
|
@ -1759,6 +2149,7 @@
|
|||
风险条目:<strong>${riskCount}</strong> 个
|
||||
</div>
|
||||
<div class="detail-actions">
|
||||
<button class="btn" id="downloadPermitFileBtn" ${downloadDisabledAttr} onclick="downloadPermitFile()" title="${hasPermitFile ? '下载原文件' : '暂无原始文件'}">下载原文件</button>
|
||||
<button class="btn btn-danger" id="deletePermitBtn" ${deleteButtonDisabled} onclick="confirmDeleteCurrentPermit()">${deleteButtonLabel}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1769,55 +2160,65 @@
|
|||
<div class="detail-section">
|
||||
<h3>许可信息</h3>
|
||||
<div class="detail-content">
|
||||
<p><strong>许可名称:</strong>${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>` : ''}
|
||||
${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>` : ''}
|
||||
${permit.subitem_summary ? `<p style="margin-top: 10px;"><strong>子项说明:</strong>${permit.subitem_summary}</p>` : ''}
|
||||
${permit.responsible_contact ? `<p style="margin-top: 10px;"><strong>负责部门:</strong>${permit.responsible_contact}</p>` : ''}
|
||||
${permit.jurisdiction_scope ? `<p style="margin-top: 10px;"><strong>权限划分:</strong>${permit.jurisdiction_scope}</p>` : ''}
|
||||
<p><strong>许可名称:</strong>${formatNullableText(permit.name, '(未命名)')}</p>
|
||||
<p style="margin-top: 10px;"><strong>数据来源:</strong>${permitSourceDisplay}</p>
|
||||
<p style="margin-top: 10px;"><strong>许可状态:</strong><span class="permit-status ${permitStatusClass}">${permitStatusLabel}</span></p>
|
||||
<p style="margin-top: 10px;"><strong>原始文件:</strong>${fileInfoText}</p>
|
||||
<p style="margin-top: 10px;"><strong>子项说明:</strong>${formatNullableText(permit.subitem_summary)}</p>
|
||||
<p style="margin-top: 10px;"><strong>负责部门:</strong>${formatNullableText(permit.responsible_contact)}</p>
|
||||
<p style="margin-top: 10px;"><strong>权限划分:</strong>${formatNullableText(permit.jurisdiction_scope)}</p>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// 经营范围
|
||||
html += '<div class="detail-section"><h3>经营范围</h3><div class="detail-content">';
|
||||
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 => {
|
||||
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) {
|
||||
html += '<div class="detail-section"><h3>法律风险</h3><div class="detail-content">';
|
||||
permit.risks.forEach(risk => {
|
||||
permit.risks.forEach((risk, index) => {
|
||||
const riskIdentifier = formatNullableText(risk.id, index + 1);
|
||||
html += `
|
||||
<div class="risk-item">
|
||||
<h4>风险 ${risk.id}</h4>
|
||||
${risk.risk_content ? `<div class="risk-field"><strong>风险内容:</strong><p>${risk.risk_content}</p></div>` : ''}
|
||||
${risk.legal_basis ? `<div class="risk-field"><strong>法律依据:</strong><p>${risk.legal_basis}</p></div>` : ''}
|
||||
${risk.document_no ? `<div class="risk-field"><strong>文件编号:</strong><p>${risk.document_no}</p></div>` : ''}
|
||||
${risk.summary ? `<div class="risk-field"><strong>摘要:</strong><div style="margin-top: 5px;">${risk.summary}</div></div>` : ''}
|
||||
<h4>风险 ${riskIdentifier}</h4>
|
||||
<div class="risk-field"><strong>风险内容:</strong><p>${formatNullableText(risk.risk_content)}</p></div>
|
||||
<div class="risk-field"><strong>法律依据:</strong><p>${formatNullableText(risk.legal_basis)}</p></div>
|
||||
<div class="risk-field"><strong>文件编号:</strong><p>${formatNullableText(risk.document_no)}</p></div>
|
||||
<div class="risk-field"><strong>摘要:</strong><div style="margin-top: 5px;">${formatNullableText(risk.summary)}</div></div>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
html += '</div></div>';
|
||||
} else {
|
||||
html += `
|
||||
<div class="detail-section">
|
||||
<h3>法律风险</h3>
|
||||
<div class="detail-content">
|
||||
<p style="color: #999;">暂无法律风险信息</p>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
html += '<p class="muted-text">暂无法律风险记录</p>';
|
||||
}
|
||||
html += '</div></div>';
|
||||
|
||||
html += '</div>';
|
||||
detailsArea.innerHTML = html;
|
||||
}
|
||||
|
||||
function downloadPermitFile() {
|
||||
if (!currentRegion || !currentPermit || !currentPermitDetails) {
|
||||
alert('请先选择许可');
|
||||
return;
|
||||
}
|
||||
if (!currentPermitDetails.permit_file || !currentPermitDetails.permit_file.file_id) {
|
||||
alert('当前许可没有可下载的原始文件');
|
||||
return;
|
||||
}
|
||||
const url = `/fs-ai-asistant/api/workflow/lawrisk/admin/permit-file/download?region=${encodeURIComponent(currentRegion.id)}&permit=${encodeURIComponent(currentPermit.id)}`;
|
||||
window.open(url, '_blank');
|
||||
}
|
||||
|
||||
function confirmDeleteCurrentPermit() {
|
||||
if (isDeletingPermit) {
|
||||
return;
|
||||
|
|
@ -1953,6 +2354,9 @@
|
|||
permitImportState.overrides = new Map();
|
||||
permitImportState.error = '';
|
||||
permitImportState.success = '';
|
||||
permitImportState.filename = '';
|
||||
permitImportState.totalRows = 0;
|
||||
permitImportState.fileSize = 0;
|
||||
}
|
||||
renderImportModal();
|
||||
}
|
||||
|
|
@ -2021,6 +2425,17 @@
|
|||
return;
|
||||
}
|
||||
const file = input.files[0];
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
if (file.size > PERMIT_FILE_MAX_BYTES) {
|
||||
permitImportState.error = `文件过大(最大 ${formatFileSize(PERMIT_FILE_MAX_BYTES)}),当前 ${formatFileSize(file.size)}`;
|
||||
permitImportState.success = '';
|
||||
permitImportState.fileSize = 0;
|
||||
input.value = '';
|
||||
renderImportModal();
|
||||
return;
|
||||
}
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
|
|
@ -2041,6 +2456,7 @@
|
|||
permitImportState.filename = payload.filename || (file && file.name) || '';
|
||||
permitImportState.totalRows = payload.total_rows || payload.totalRows || 0;
|
||||
permitImportState.sheetSummaries = payload.sheet_summaries || payload.sheetSummaries || [];
|
||||
permitImportState.fileSize = payload.file_size || payload.fileSize || file.size || 0;
|
||||
permitImportState.selectedSheets = new Set(
|
||||
(permitImportState.sheetSummaries || []).map(item => item.sheet_name)
|
||||
);
|
||||
|
|
@ -2050,6 +2466,7 @@
|
|||
permitImportState.error = data.message || '解析失败,请检查Excel格式';
|
||||
permitImportState.sessionId = '';
|
||||
permitImportState.sheetSummaries = [];
|
||||
permitImportState.fileSize = 0;
|
||||
permitImportState.selectedSheets = new Set();
|
||||
permitImportState.overrides = new Map();
|
||||
}
|
||||
|
|
@ -2059,6 +2476,7 @@
|
|||
permitImportState.sheetSummaries = [];
|
||||
permitImportState.selectedSheets = new Set();
|
||||
permitImportState.overrides = new Map();
|
||||
permitImportState.fileSize = 0;
|
||||
} finally {
|
||||
permitImportState.uploading = false;
|
||||
renderImportModal();
|
||||
|
|
@ -2118,6 +2536,9 @@
|
|||
permitImportState.sheetSummaries = [];
|
||||
permitImportState.selectedSheets = new Set();
|
||||
permitImportState.overrides = new Map();
|
||||
permitImportState.filename = '';
|
||||
permitImportState.totalRows = 0;
|
||||
permitImportState.fileSize = 0;
|
||||
|
||||
await Promise.all([
|
||||
refreshPermitRiskSnapshots(false),
|
||||
|
|
@ -2148,12 +2569,21 @@
|
|||
html += '<h3><span>📄</span> 上传 Excel</h3>';
|
||||
html += '<div class="import-upload-area">';
|
||||
html += '<input type="file" accept=".xlsx,.xlsm" onchange="handleImportFile(this)">';
|
||||
html += '<div class="import-template-wrapper">';
|
||||
html += '<a class="import-template-button" href="/fs-ai-asistant/api/workflow/lawrisk/admin/permit-import/template" download>';
|
||||
html += '<span class="import-template-icon">📥</span>';
|
||||
html += '<span class="import-template-text"><span class="import-template-title">下载导入模板</span><span class="import-template-subtitle">包含示例字段与填写说明</span></span>';
|
||||
html += '<span class="import-template-badge">推荐</span>';
|
||||
html += '</a>';
|
||||
html += '</div>';
|
||||
if (state.sessionId) {
|
||||
const sheetCount = state.sheetSummaries ? state.sheetSummaries.length : 0;
|
||||
html += `<div class="import-meta">当前会话:${escapeHtml(state.filename || '(未命名)')} | Sheet ${sheetCount} 个 | 风险 ${state.totalRows || 0} 条</div>`;
|
||||
} else {
|
||||
html += '<div class="import-meta">请选择包含许可数据的 Excel 文件,系统会自动解析所有 Sheet,并以区划为单位生成导入任务。</div>';
|
||||
}
|
||||
const currentSizeLabel = state.fileSize ? formatFileSize(state.fileSize) : '未选择文件';
|
||||
html += `<div class="import-meta">文件大小限制:≤ ${formatFileSize(PERMIT_FILE_MAX_BYTES)} | 当前:${currentSizeLabel}</div>`;
|
||||
html += '</div></div>';
|
||||
|
||||
if (state.error) {
|
||||
|
|
@ -3233,7 +3663,8 @@
|
|||
}
|
||||
|
||||
// 页面加载时初始化
|
||||
window.addEventListener('DOMContentLoaded', () => {
|
||||
window.addEventListener('DOMContentLoaded', async () => {
|
||||
await fetchCurrentUser(true);
|
||||
goToStep(1);
|
||||
});
|
||||
</script>
|
||||
|
|
|
|||
Loading…
Reference in New Issue