feat: support permit file uploads

This commit is contained in:
Codex Agent 2025-11-13 15:28:08 +08:00
parent 5b86bd8799
commit 772354bd01
3 changed files with 1122 additions and 32 deletions

View File

@ -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."""

View File

@ -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:

View File

@ -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>