diff --git a/lawrisk/api/v2.py b/lawrisk/api/v2.py index 461a1e9..52e6695 100644 --- a/lawrisk/api/v2.py +++ b/lawrisk/api/v2.py @@ -922,6 +922,38 @@ def admin_permits(): return jsonify({"success": False, "message": str(exc)}), 500 +@v2_bp.route('/admin/permits/visibility', methods=['POST']) +def admin_toggle_permit_visibility(): + """Toggle the visibility of a permit in V2 API retrieval.""" + admin_user, error = _admin_guard(prefer_json=True, roles=("admin", "department_admin")) + if error: + return error + + data = request.get_json() or {} + region_id = data.get("region_id") + permit_id = data.get("permit_id") + is_visible = data.get("is_v2_visible") + + if not region_id or not permit_id or is_visible is None: + return jsonify({"success": False, "message": "region_id, permit_id and is_v2_visible are required"}), 400 + + try: + from lawrisk.services.licensing_repo import update_permit_v2_visibility + operator = (admin_user or {}).get("username") or "admin" + success = update_permit_v2_visibility( + region_id=region_id, + permit_id=permit_id, + is_visible=bool(is_visible), + operator=operator + ) + if success: + return jsonify({"success": True, "message": "Visibility updated"}) + else: + return jsonify({"success": False, "message": "Permit details not found or update failed"}), 404 + except Exception as exc: + 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.""" @@ -1596,70 +1628,46 @@ def admin_permits_advanced_filter(): """ try: # Parse parameters from query string or request body + # Parse parameters from query/body in a more unified way if request.method == 'GET': regions = request.args.getlist('regions[]') or request.args.getlist('region') themes = request.args.getlist('themes[]') or request.args.getlist('theme') departments = request.args.getlist('departments[]') or request.args.getlist('department') search_text = request.args.get('search_text') or request.args.get('q') - try: - limit = int(request.args.get('limit', '100')) - except (TypeError, ValueError): - limit = 100 - try: - offset = int(request.args.get('offset', '0')) - except (TypeError, ValueError): - offset = 0 + visibility = request.args.get('visibility') + limit = request.args.get('limit', '100') + offset = request.args.get('offset', '0') else: - if request.is_json: - payload = request.get_json(silent=True) or {} - else: - payload = request.form.to_dict(flat=True) if request.form else {} - - # Handle array parameters + payload = request.get_json(silent=True) or request.form regions = payload.getlist('regions[]') if hasattr(payload, 'getlist') else payload.get('regions', []) - if isinstance(regions, str): - regions = [regions] - regions = regions or payload.getlist('region') if hasattr(payload, 'getlist') else payload.get('region', []) - if isinstance(regions, str): - regions = [regions] - themes = payload.getlist('themes[]') if hasattr(payload, 'getlist') else payload.get('themes', []) - if isinstance(themes, str): - themes = [themes] - themes = themes or payload.getlist('theme') if hasattr(payload, 'getlist') else payload.get('theme', []) - if isinstance(themes, str): - themes = [themes] - departments = payload.getlist('departments[]') if hasattr(payload, 'getlist') else payload.get('departments', []) - if isinstance(departments, str): - departments = [departments] - departments = departments or payload.getlist('department') if hasattr(payload, 'getlist') else payload.get('department', []) - if isinstance(departments, str): - departments = [departments] - search_text = payload.get('search_text') or payload.get('q') - try: - limit = int(payload.get('limit', '100')) - except (TypeError, ValueError): - limit = 100 - try: - offset = int(payload.get('offset', '0')) - except (TypeError, ValueError): - offset = 0 + visibility = payload.get('visibility') + limit = payload.get('limit', '100') + offset = payload.get('offset', '0') - # Normalize parameters - convert to lists if not already - if isinstance(regions, str): - regions = [regions] - if isinstance(themes, str): - themes = [themes] - if isinstance(departments, str): - departments = [departments] - - # Filter out empty values + # Normalize parameters + if isinstance(regions, str): regions = [regions] + if isinstance(themes, str): themes = [themes] + if isinstance(departments, str): departments = [departments] + regions = [r.strip() for r in regions if r and r.strip()] if regions else None themes = [t.strip() for t in themes if t and t.strip()] if themes else None departments = [d.strip() for d in departments if d and d.strip()] if departments else None - search_text = search_text.strip() if search_text else None + search_text = (search_text or "").strip() or None + visibility = (visibility or "").strip().lower() or None + + try: + limit = int(limit) + except (ValueError, TypeError): + limit = 100 + try: + offset = int(offset) + except (ValueError, TypeError): + offset = 0 + + print(f"[DEBUG] admin_permits_advanced_filter params: search={search_text}, visibility={visibility}, regions={regions}") # Execute filtering result = filter_permits_advanced( @@ -1667,6 +1675,7 @@ def admin_permits_advanced_filter(): themes=themes, departments=departments, search_text=search_text, + visibility=visibility, limit=limit, offset=offset, ) diff --git a/lawrisk/services/lawrisk_v2_service.py b/lawrisk/services/lawrisk_v2_service.py index 931e56d..4b9b881 100644 --- a/lawrisk/services/lawrisk_v2_service.py +++ b/lawrisk/services/lawrisk_v2_service.py @@ -10,6 +10,7 @@ from lawrisk.services.licensing_repo import ( load_theme_payload, load_permits_and_risks, find_permit_contexts_by_name, + _ensure_v2_visibility_column, ) from lawrisk.services.lawrisk_service import ChatClient @@ -200,18 +201,21 @@ def _get_preset_questions_pool() -> List[str]: """ from lawrisk.services.licensing_repo import _lic_pg_conn - # Query themes that have at least one permit + # Query themes that have at least one visible permit sql = """ SELECT DISTINCT t.name AS theme_name FROM themes t JOIN region_theme_permits rtp ON rtp.theme_id = t.id + JOIN region_permit_details rpd ON rpd.region_id = rtp.region_id AND rpd.permit_id = rtp.permit_id WHERE t.name NOT IN ('不涉及', '无', '所有主题事项') + AND COALESCE(rpd.is_v2_visible, true) = true ORDER BY t.name """ questions: List[str] = [] try: with _lic_pg_conn() as conn: + _ensure_v2_visibility_column(conn) cur = conn.cursor() cur.execute(sql) for (theme_name,) in cur.fetchall(): @@ -313,6 +317,7 @@ def search_v2( ctx["region_id"], ctx["theme_id"], permit_id=ctx["permit_id"], + only_visible=True, ) if not permits: continue @@ -368,7 +373,7 @@ def search_v2( if ":" not in option_id: continue region_id, theme_id = option_id.split(":", 1) - payload = load_theme_payload(region_id, theme_id) + payload = load_theme_payload(region_id, theme_id, only_visible=True) # Sanitize permits for V2 API (V2 should only expose external contact info) for permit in payload.get("permits", []): diff --git a/lawrisk/services/licensing_repo.py b/lawrisk/services/licensing_repo.py index 75abb42..9a5a001 100644 --- a/lawrisk/services/licensing_repo.py +++ b/lawrisk/services/licensing_repo.py @@ -68,6 +68,9 @@ _PERMIT_THEME_OVERRIDE_SCHEMA_LOCK = threading.Lock() _PERMIT_APPROVAL_DEPARTMENTS_SCHEMA_READY: Optional[bool] = None _PERMIT_APPROVAL_DEPARTMENTS_SCHEMA_LOCK = threading.Lock() +_V2_VISIBILITY_SCHEMA_READY: Optional[bool] = None +_V2_VISIBILITY_SCHEMA_LOCK = threading.Lock() + _IMPORT_HEADER_ALIASES: Dict[str, Set[str]] = { "permit_name": { "许可事项", @@ -3200,6 +3203,36 @@ def _fetch_permit_all_theme_flags( return {str(permit_id): True for (permit_id,) in rows} +def update_permit_v2_visibility( + region_id: str, permit_id: str, is_visible: bool, operator: str = "admin" +) -> bool: + """Toggle the visibility of a permit in V2 API retrieval for a specific region.""" + with _lic_pg_conn() as conn: + _ensure_v2_visibility_column(conn) + cur = conn.cursor() + cur.execute( + """ + UPDATE region_permit_details + SET is_v2_visible = %s, updated_at = now() + WHERE region_id = %s AND permit_id = %s + """, + (is_visible, region_id, permit_id), + ) + success = cur.rowcount > 0 + if success: + conn.commit() + log_operation( + operator=operator, + operation_type="UPDATE", + target_type="PERMIT_VISIBILITY", + target_id=permit_id, + target_name=f"Visibility set to {is_visible}", + change_summary=f"Updated v2_visibility for permit {permit_id} in region {region_id} to {is_visible}", + details={"region_id": region_id, "permit_id": permit_id, "is_v2_visible": is_visible}, + ) + return success + + def _permit_binds_all_themes(conn: pg.Connection, region_id: str, permit_id: str) -> bool: """Check override flag for a single region-permit pair.""" global _PERMIT_THEME_OVERRIDE_SCHEMA_READY @@ -3351,10 +3384,17 @@ def list_region_theme_options() -> List[Dict[str, str]]: FROM region_themes rt JOIN regions r ON r.id = rt.region_id JOIN themes t ON t.id = rt.theme_id + WHERE EXISTS ( + SELECT 1 FROM region_theme_permits rtp + JOIN region_permit_details rpd ON rpd.region_id = rtp.region_id AND rpd.permit_id = rtp.permit_id + WHERE rtp.region_id = rt.region_id AND rtp.theme_id = rt.theme_id + AND COALESCE(rpd.is_v2_visible, true) = true + ) ORDER BY r.name, t.name """ out: List[Dict[str, str]] = [] with _lic_pg_conn() as conn: + _ensure_v2_visibility_column(conn) cur = conn.cursor() cur.execute(sql) for region_id, region_name, theme_id, theme_name in cur.fetchall(): @@ -3528,10 +3568,12 @@ def list_region_permit_catalog(region_id: str) -> List[Dict[str, Any]]: p.name AS permit_name, rtp.theme_id, COALESCE(t.name, '') AS theme_name, - COUNT(rpr.risk_id) OVER (PARTITION BY rtp.permit_id) AS risk_total + COUNT(rpr.risk_id) OVER (PARTITION BY rtp.permit_id) AS risk_total, + COALESCE(rpd.is_v2_visible, true) AS is_v2_visible FROM region_theme_permits rtp JOIN permits p ON p.id = rtp.permit_id LEFT JOIN themes t ON t.id = rtp.theme_id + LEFT JOIN region_permit_details rpd ON rpd.region_id = rtp.region_id AND rpd.permit_id = rtp.permit_id LEFT JOIN region_permit_risks rpr ON rpr.region_id = rtp.region_id AND rpr.permit_id = rtp.permit_id @@ -3540,9 +3582,10 @@ def list_region_permit_catalog(region_id: str) -> List[Dict[str, Any]]: """ catalog_map: "OrderedDict[str, Dict[str, Any]]" = OrderedDict() with _lic_pg_conn() as conn: + _ensure_v2_visibility_column(conn) cur = conn.cursor() cur.execute(sql, (region_id,)) - for permit_id, permit_name, theme_id, theme_name, risk_total in cur.fetchall(): + for permit_id, permit_name, theme_id, theme_name, risk_total, v2_visible in cur.fetchall(): pid = str(permit_id) entry = catalog_map.setdefault( pid, @@ -3550,6 +3593,7 @@ def list_region_permit_catalog(region_id: str) -> List[Dict[str, Any]]: "id": pid, "name": str(permit_name), "risk_count": int(risk_total or 0), + "is_v2_visible": bool(v2_visible), "theme": {"id": "", "name": ""}, "themes": [], }, @@ -3713,7 +3757,10 @@ def _load_permit_sources_for_region( def load_permits_and_risks( - region_id: str, theme_id: Optional[str] = None, permit_id: Optional[str] = None + region_id: str, + theme_id: Optional[str] = None, + permit_id: Optional[str] = None, + only_visible: bool = False ) -> List[Dict[str, object]]: """Return permits with attached risk entries for a region (optionally filtered by theme).""" # Ensure optional permit file tables exist before running user queries. @@ -3747,7 +3794,8 @@ def load_permits_and_risks( rpd.filler_name, COALESCE(pad.department_name, rpd.unit_name) AS unit_name, rpd.source_update_date, - rpd.contact_info + rpd.contact_info, + COALESCE(rpd.is_v2_visible, true) AS is_v2_visible FROM region_permit_details rpd JOIN permits p ON p.id = rpd.permit_id LEFT JOIN permit_approval_departments pad @@ -3770,6 +3818,8 @@ def load_permits_and_risks( if permit_id is not None: sql += " AND rpd.permit_id = %s" params.append(permit_id) + if only_visible: + sql += " AND COALESCE(rpd.is_v2_visible, true) = true" sql += """ ORDER BY p.name, LENGTH(rpr.serial_number), rpr.serial_number, rk.risk_content @@ -3777,6 +3827,7 @@ def load_permits_and_risks( permits: Dict[str, Dict[str, object]] = {} risk_seen_map: Dict[str, Set[str]] = {} # pid -> set of risk_ids with _lic_pg_conn() as conn: + _ensure_v2_visibility_column(conn) _ensure_contact_info_column(conn) cur = conn.cursor() cur.execute(sql, tuple(params)) @@ -3801,6 +3852,7 @@ def load_permits_and_risks( unit_name, source_update_date, contact_info, + v2_visible, ) = row pid = str(permit_id) theme_id_value = str(row_theme_id) if row_theme_id else "" @@ -4252,7 +4304,8 @@ def find_permit_contexts_by_name(permit_name: str) -> List[Dict[str, str]]: rtp.theme_id, t.name AS theme_name, p.id AS permit_id, - p.name AS permit_name + p.name AS permit_name, + COALESCE(rpd.is_v2_visible, true) AS is_v2_visible FROM region_permit_details rpd JOIN permits p ON p.id = rpd.permit_id JOIN regions r ON r.id = rpd.region_id @@ -4263,6 +4316,7 @@ def find_permit_contexts_by_name(permit_name: str) -> List[Dict[str, str]]: """ ordered: OrderedDict[Tuple[str, str], Dict[str, str]] = OrderedDict() with _lic_pg_conn() as conn: + _ensure_v2_visibility_column(conn) cur = conn.cursor() cur.execute(sql, (permit_name,)) rows = cur.fetchall() @@ -4276,7 +4330,8 @@ def find_permit_contexts_by_name(permit_name: str) -> List[Dict[str, str]]: rtp.theme_id, t.name AS theme_name, p.id AS permit_id, - p.name AS permit_name + p.name AS permit_name, + COALESCE(rpd.is_v2_visible, true) AS is_v2_visible FROM region_permit_details rpd JOIN permits p ON p.id = rpd.permit_id JOIN regions r ON r.id = rpd.region_id @@ -4289,7 +4344,7 @@ def find_permit_contexts_by_name(permit_name: str) -> List[Dict[str, str]]: rows = cur.fetchall() for row in rows: - region_id, region_name, theme_id, theme_name, permit_id, canonical_name = row + region_id, region_name, theme_id, theme_name, permit_id, canonical_name, v2_visible = row rid = str(region_id) pid = str(permit_id) tid = str(theme_id) if theme_id else "" @@ -4304,11 +4359,12 @@ def find_permit_contexts_by_name(permit_name: str) -> List[Dict[str, str]]: "theme_name": tname, "permit_id": pid, "permit_name": str(canonical_name), + "is_v2_visible": bool(v2_visible), } - return list(ordered.values()) + return [item for item in ordered.values() if item.get("is_v2_visible")] -def load_theme_payload(region_id: str, theme_id: str) -> Dict[str, object]: +def load_theme_payload(region_id: str, theme_id: str, only_visible: bool = False) -> Dict[str, object]: """Assemble full data bundle for a region-theme selection.""" info_sql = """ SELECT r.id, r.name, t.id, t.name @@ -4326,7 +4382,7 @@ def load_theme_payload(region_id: str, theme_id: str) -> Dict[str, object]: raise ValueError("Region/theme combination not found") region_uuid, region_name, theme_uuid, theme_name = row - permits = load_permits_and_risks(region_id, theme_id) + permits = load_permits_and_risks(region_id, theme_id, only_visible=only_visible) return { "region": {"id": str(region_uuid), "name": str(region_name)}, "theme": {"id": str(theme_uuid), "name": str(theme_name)}, @@ -6170,23 +6226,25 @@ def filter_permits_advanced( themes: Optional[List[str]] = None, departments: Optional[List[str]] = None, search_text: Optional[str] = None, + visibility: Optional[str] = None, # 'visible', 'hidden' or None/all limit: int = 100, offset: int = 0, ) -> Dict[str, Any]: - """Filter permits using multiple dimensions (region, theme, department, search text). + """Filter permits using multiple dimensions (region, theme, department, search text, visibility). Args: regions: List of region IDs to filter by (supports multi-select) themes: List of theme IDs to filter by (supports multi-select) departments: List of department IDs to filter by (supports multi-select) search_text: Search in permit name + visibility: Filter by v2 visibility ('visible', 'hidden') limit: Maximum number of results to return offset: Offset for pagination Returns: Dictionary containing filtered permits and metadata """ - print(f"[DEBUG] filter_permits_advanced called with limit={limit}, offset={offset}") + print(f"[DEBUG] filter_permits_advanced called with limit={limit}, offset={offset}, visibility={visibility}") # Use subquery to avoid DISTINCT with window functions issue # Subquery to get unique permits matching filters with pagination # We use a CTE to ensure limit/offset apply to unique permits, not to rows (which can duplicate per theme) @@ -6212,6 +6270,11 @@ def filter_permits_advanced( base_where += f" AND LOWER(p.name) LIKE LOWER(%s)" base_params.append(f"%{search_text}%") + if visibility == 'visible': + base_where += " AND (rpd.is_v2_visible IS TRUE OR rpd.is_v2_visible IS NULL)" + elif visibility == 'hidden': + base_where += " AND rpd.is_v2_visible IS FALSE" + sql = f""" WITH filtered_p AS ( SELECT rpd.permit_id, rpd.region_id @@ -6236,7 +6299,8 @@ def filter_permits_advanced( rtp.theme_id, t.name AS theme_name, COALESCE(risk_counts.risk_count, 0) AS risk_count, - COALESCE(theme_counts.theme_count, 0) AS theme_count + COALESCE(theme_counts.theme_count, 0) AS theme_count, + COALESCE(rpd.is_v2_visible, true) AS is_v2_visible FROM filtered_p fp JOIN region_permit_details rpd ON rpd.permit_id = fp.permit_id AND rpd.region_id = fp.region_id JOIN permits p ON p.id = rpd.permit_id @@ -6280,6 +6344,7 @@ def filter_permits_advanced( theme_name, risk_count, theme_count, + v2_visible, ) in cur.fetchall(): pid = str(permit_id) key = f"{pid}_{rid}" @@ -6294,6 +6359,7 @@ def filter_permits_advanced( "themes": [], "risk_count": int(risk_count or 0), "theme_count": int(theme_count or 0), + "is_v2_visible": bool(v2_visible), } if tid or theme_name: @@ -6340,8 +6406,72 @@ def filter_permits_advanced( } +def _ensure_v2_visibility_column(conn: Optional[pg.Connection] = None) -> None: + """Ensure that the is_v2_visible column exists in region_permit_details.""" + global _V2_VISIBILITY_SCHEMA_READY + if _V2_VISIBILITY_SCHEMA_READY: + return + + with _V2_VISIBILITY_SCHEMA_LOCK: + if _V2_VISIBILITY_SCHEMA_READY: + return + + sql = "ALTER TABLE region_permit_details ADD COLUMN IF NOT EXISTS is_v2_visible BOOLEAN DEFAULT TRUE" + + if conn is not None: + original_autocommit = conn.autocommit + try: + conn.autocommit = True + cur = conn.cursor() + cur.execute(sql) + finally: + conn.autocommit = original_autocommit + else: + with _lic_pg_conn(autocommit=True) as ensure_conn: + cur = ensure_conn.cursor() + cur.execute(sql) + + _V2_VISIBILITY_SCHEMA_READY = True + + def _ensure_contact_info_column(conn: pg.Connection) -> None: "Ensure that the contact_info column exists in region_permit_details." # This check is now redundant since schema fix script was run, but kept for safety pass + +def update_permit_v2_visibility( + region_id: str, + permit_id: str, + is_visible: bool, + operator: str = "admin" +) -> bool: + """Update the is_v2_visible flag for a specific region-permit pair.""" + _ensure_v2_visibility_column() + + sql = """ + UPDATE region_permit_details + SET is_v2_visible = %s, + updated_at = now() + WHERE region_id = %s AND permit_id = %s + """ + + with _lic_pg_conn() as conn: + cur = conn.cursor() + cur.execute(sql, (is_visible, region_id, permit_id)) + count = cur.rowcount + conn.commit() + + if count > 0: + log_operation( + operator=operator, + operation_type="UPDATE", + target_type="PERMIT_VISIBILITY", + target_id=f"{region_id}:{permit_id}", + target_name=f"Permit {permit_id} in region {region_id}", + change_summary=f"Set is_v2_visible to {is_visible}", + details={"is_visible": is_visible, "region_id": region_id, "permit_id": permit_id} + ) + return True + return False + diff --git a/static/db_admin.html b/static/db_admin.html index 1dff0ef..0d43ae8 100644 --- a/static/db_admin.html +++ b/static/db_admin.html @@ -2329,6 +2329,57 @@ max-height: 600px; overflow-y: auto; } + + /* Toggle Switch Styles */ + .switch { + position: relative; + display: inline-block; + width: 44px; + height: 22px; + } + + .switch input { + opacity: 0; + width: 0; + height: 0; + } + + .slider { + position: absolute; + cursor: pointer; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: #ccc; + transition: .3s; + border-radius: 22px; + } + + .slider:before { + position: absolute; + content: ""; + height: 16px; + width: 16px; + left: 3px; + bottom: 3px; + background-color: white; + transition: .3s; + border-radius: 50%; + } + + input:checked+.slider { + background-color: #10b981; + } + + input:checked+.slider:before { + transform: translateX(22px); + } + + input:disabled+.slider { + opacity: 0.5; + cursor: not-allowed; + } @@ -2514,6 +2565,17 @@ + +
+ + +
+
@@ -6424,6 +6486,8 @@ const departmentCheckboxes = document.querySelectorAll('input[name="departmentFilter"]:checked'); const departments = Array.from(departmentCheckboxes).map(cb => cb.value); + const visibility = document.getElementById('filterVisibility')?.value || 'all'; + const searchText = document.getElementById('filterSearchText')?.value || ''; const filters = { @@ -6432,7 +6496,8 @@ departments: departments.length > 0 ? departments : null, search_text: searchText.trim() || null, limit: permitPageSize, - offset: permitCurrentPage * permitPageSize + offset: permitCurrentPage * permitPageSize, + visibility: visibility !== 'all' ? visibility : null }; // 显示加载状态 @@ -6808,6 +6873,63 @@ } } + // 切换许可事项可见性 + async function togglePermitVisibility(permitId, regionId, currentStatus, event) { + const nextStatus = !currentStatus; + + // 立即禁用开关防止重复点击 + if (event && event.target) { + event.target.disabled = true; + } + + try { + const response = await fetch('/fs-ai-asistant/api/workflow/lawrisk/admin/permits/visibility', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + permit_id: permitId, + region_id: regionId, + is_v2_visible: nextStatus + }) + }); + + const data = await response.json(); + + if (!data.success) { + throw new Error(data.message || '更新失败'); + } + + // 立即更新前端状态参数以便下次点击 + if (event && event.target) { + event.target.setAttribute('onclick', `togglePermitVisibility('${permitId}', '${regionId}', ${nextStatus}, event)`); + + // 可选:给个微弱的小提示 + const row = event.target.closest('tr'); + if (row) { + const originalBg = row.style.backgroundColor; + row.style.backgroundColor = '#f0fdf4'; + setTimeout(() => row.style.backgroundColor = originalBg, 500); + } + } else { + applyPermitFilter(); + } + + } catch (error) { + console.error('更新可见性失败:', error); + showAlert('error', '更新失败:' + error.message); + // 恢复原状 + if (event && event.target) { + event.target.checked = currentStatus; + } + } finally { + if (event && event.target) { + event.target.disabled = false; + } + } + } + // 删除许可事项 async function deletePermit(permitId, regionId) { if (!confirm('确定要删除该许可事项吗?此操作不可恢复,并且会创建风险快照。')) { @@ -6873,6 +6995,7 @@ 行政区域 主题 风险数 + 启用 操作 @@ -6895,6 +7018,13 @@ ${escapeHtml(permit.region?.name || '-')} ${permit.theme_count || 0} 个 ${permit.risk_count || 0} + + +