From fd82b757fe676616eb638908b9a5d6e8bc8ece40 Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Thu, 13 Nov 2025 17:01:37 +0800 Subject: [PATCH] feat: show permit themes in db admin --- lawrisk/services/licensing_repo.py | 139 +++++++++++++-- static/db_admin.html | 271 ++++++++++++++++------------- 2 files changed, 276 insertions(+), 134 deletions(-) diff --git a/lawrisk/services/licensing_repo.py b/lawrisk/services/licensing_repo.py index be78160..2a1ae7f 100644 --- a/lawrisk/services/licensing_repo.py +++ b/lawrisk/services/licensing_repo.py @@ -45,7 +45,8 @@ COLON_NEWLINE_RE = re.compile(r":\s*\n") TRAILING_SPACE_RE = re.compile(r"[ \t]+\n") 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]+") +TEXT_SPLIT_PATTERN_WITH_DUNHAO = 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 @@ -260,12 +261,19 @@ def _score_import_header(canonical: str, cell_text: str, col_idx: int) -> float: return score -def _split_multi_value(value: Any) -> List[str]: - """Split multi-value cells using common Chinese punctuation.""" +def _split_multi_value(value: Any, *, allow_dunhao: bool = False) -> List[str]: + """Split multi-value cells using common punctuation characters. + + 默认不把中文顿号(、)视作分隔符,以避免误拆“文化、旅游”等合法的 + 许可名称。对于确实需要用顿号分隔的字段(如主题、经营范围等),调用 + 方可以显式传入 allow_dunhao=True。 + """ + text = _clean_text(value) if not text: return [] - return [item.strip() for item in TEXT_SPLIT_PATTERN.split(text) if item.strip()] + pattern = TEXT_SPLIT_PATTERN_WITH_DUNHAO if allow_dunhao else TEXT_SPLIT_PATTERN + return [item.strip() for item in pattern.split(text) if item.strip()] def _clean_empty(value: Any) -> Optional[str]: @@ -311,9 +319,15 @@ def _normalize_import_row( jurisdiction_scope = _clean_empty( raw_row.get("jurisdiction_scope") or sheet_defaults.get("jurisdiction_scope") ) - theme_names = _split_multi_value(raw_row.get("theme_names") or sheet_defaults.get("theme_names")) - scope_descriptions = _split_multi_value(raw_row.get("scope_text") or sheet_defaults.get("scope_text")) - subitem_names = _split_multi_value(raw_row.get("subitem_text") or sheet_defaults.get("subitem_text")) + theme_names = _split_multi_value( + raw_row.get("theme_names") or sheet_defaults.get("theme_names"), allow_dunhao=True + ) + scope_descriptions = _split_multi_value( + raw_row.get("scope_text") or sheet_defaults.get("scope_text"), allow_dunhao=True + ) + subitem_names = _split_multi_value( + raw_row.get("subitem_text") or sheet_defaults.get("subitem_text"), allow_dunhao=True + ) return { "row_index": int(row_index) if isinstance(row_index, int) else row_index, @@ -1700,6 +1714,68 @@ def list_permits_for_region(region: str) -> List[Dict[str, str]]: return permits +def list_region_permit_catalog(region_id: str) -> List[Dict[str, Any]]: + """Return permit entries for a region, including owning theme and risk count.""" + sql = """ + SELECT + rtp.permit_id, + p.name AS permit_name, + rtp.theme_id, + COALESCE(t.name, '') AS theme_name, + COUNT(rpr.risk_id) AS risk_count + 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_risks rpr + ON rpr.region_id = rtp.region_id + AND rpr.permit_id = rtp.permit_id + WHERE rtp.region_id = %s + GROUP BY rtp.permit_id, p.name, rtp.theme_id, t.name + ORDER BY LOWER(p.name), LOWER(COALESCE(t.name, '')) + """ + catalog: List[Dict[str, Any]] = [] + with _lic_pg_conn() as conn: + cur = conn.cursor() + cur.execute(sql, (region_id,)) + for permit_id, permit_name, theme_id, theme_name, risk_count in cur.fetchall(): + catalog.append( + { + "id": str(permit_id), + "name": str(permit_name), + "theme": { + "id": str(theme_id) if theme_id else "", + "name": str(theme_name) if theme_name else "", + }, + "risk_count": int(risk_count or 0), + } + ) + return catalog + + +def resolve_region_permit_theme(region_id: str, permit_id: str) -> Optional[Dict[str, str]]: + """Return the first theme associated with a region-permit pair (if any).""" + sql = """ + SELECT rtp.theme_id, t.name + FROM region_theme_permits rtp + LEFT JOIN themes t ON t.id = rtp.theme_id + WHERE rtp.region_id = %s + AND rtp.permit_id = %s + ORDER BY t.name NULLS LAST + LIMIT 1 + """ + with _lic_pg_conn() as conn: + cur = conn.cursor() + cur.execute(sql, (region_id, permit_id)) + row = cur.fetchone() + if not row: + return None + theme_id, theme_name = row + return { + "id": str(theme_id) if theme_id else "", + "name": str(theme_name) if theme_name else "", + } + + def _load_permit_scopes_for_region( conn: pg.Connection, region_id: str, permit_ids: List[str] ) -> Dict[str, List[Dict[str, str]]]: @@ -1766,9 +1842,9 @@ def _load_permit_sources_for_region( def load_permits_and_risks( - region_id: str, theme_id: str, permit_id: Optional[str] = None + region_id: str, theme_id: Optional[str] = None, permit_id: Optional[str] = None ) -> List[Dict[str, object]]: - """Return permits with attached risk entries for a region-theme pair.""" + """Return permits with attached risk entries for a region (optionally filtered by theme).""" # Ensure optional permit file tables exist before running user queries. try: _ensure_permit_file_schema() @@ -1776,6 +1852,8 @@ def load_permits_and_risks( logger.warning("[PERMIT-FILES] Failed to ensure permit file schema before loading permits: %s", exc) sql = """ SELECT + rtp.theme_id, + t.name AS theme_name, p.id AS permit_id, p.name AS permit_name, rk.id AS risk_id, @@ -1789,6 +1867,7 @@ def load_permits_and_risks( rpd.jurisdiction_scope 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_risks rpr ON rpr.region_id = rtp.region_id AND rpr.permit_id = rtp.permit_id @@ -1796,9 +1875,12 @@ def load_permits_and_risks( LEFT JOIN region_permit_details rpd ON rpd.region_id = rtp.region_id AND rpd.permit_id = rtp.permit_id - WHERE rtp.region_id = %s AND rtp.theme_id = %s + WHERE rtp.region_id = %s """ - params: List[Any] = [region_id, theme_id] + params: List[Any] = [region_id] + if theme_id: + sql += " AND rtp.theme_id = %s" + params.append(theme_id) if permit_id is not None: sql += " AND rtp.permit_id = %s" params.append(permit_id) @@ -1812,6 +1894,8 @@ def load_permits_and_risks( cur.execute(sql, tuple(params)) for row in cur.fetchall(): ( + row_theme_id, + row_theme_name, permit_id, permit_name, risk_id, @@ -1825,6 +1909,8 @@ def load_permits_and_risks( jurisdiction_scope, ) = row pid = str(permit_id) + theme_id_value = str(row_theme_id) if row_theme_id else "" + theme_name_value = str(row_theme_name) if row_theme_name else "" entry = permits.setdefault( pid, { @@ -1836,8 +1922,37 @@ def load_permits_and_risks( "subitem_summary": None, "responsible_contact": None, "jurisdiction_scope": None, + "theme": { + "id": theme_id_value, + "name": theme_name_value, + }, + "themes": [], }, ) + if theme_id_value and not entry["theme"].get("id"): + entry["theme"]["id"] = theme_id_value + if theme_name_value and not entry["theme"].get("name"): + entry["theme"]["name"] = theme_name_value + if theme_id_value or theme_name_value: + theme_list = entry.get("themes") or [] + duplicate = False + for theme_entry in theme_list: + if theme_id_value: + if theme_entry.get("id") == theme_id_value: + duplicate = True + break + else: + if not theme_entry.get("id") and theme_entry.get("name") == theme_name_value: + duplicate = True + break + if not duplicate: + theme_list.append( + { + "id": theme_id_value, + "name": theme_name_value, + } + ) + entry["themes"] = theme_list if entry["permit_status"] is None and permit_status: entry["permit_status"] = permit_status.strip() or None if entry["subitem_summary"] is None and subitem_summary: @@ -1895,6 +2010,8 @@ def load_permits_and_risks( "created_at": None, "uploaded_by": "", } + if "themes" not in permits[pid] or permits[pid]["themes"] is None: + permits[pid]["themes"] = [] return list(permits.values()) diff --git a/static/db_admin.html b/static/db_admin.html index 4d60a35..6420c5b 100644 --- a/static/db_admin.html +++ b/static/db_admin.html @@ -366,6 +366,23 @@ font-weight: 500; } + .item-tag { + display: inline-flex; + align-items: center; + margin-left: 8px; + padding: 2px 8px; + border-radius: 999px; + background: rgba(102, 126, 234, 0.15); + font-size: 11px; + color: #4f46e5; + } + + .theme-tags { + display: flex; + flex-wrap: wrap; + gap: 6px; + } + .item-count { font-size: 12px; background: rgba(102, 126, 234, 0.1); @@ -1508,21 +1525,16 @@
1
-
选择地区
+
选择区划
2
-
选择主题
+
选择事项
3
-
选择许可
-
-
-
-
4
查看详情
@@ -1540,7 +1552,7 @@
@@ -1701,10 +1713,9 @@ } // 导航状态管理 - let currentStep = 1; // 1=区域, 2=主题, 3=许可, 4=详情 + let currentStep = 1; // 1=区划, 2=事项, 3=详情 let historyStack = []; // 历史记录栈 let currentRegion = null; - let currentTheme = null; let currentPermit = null; let currentPermitDetails = null; let pendingDangerOperation = null; // 待执行的危险操作 @@ -1791,16 +1802,15 @@ // 步骤配置 const steps = { - 1: { title: '选择区域', loadData: loadRegions }, - 2: { title: '选择主题', loadData: (region) => loadThemes(region.id, region.name) }, - 3: { title: '选择许可', loadData: (theme) => loadPermits(theme.id, theme.name) }, - 4: { title: '许可详情', loadData: null } + 1: { title: '选择区划' }, + 2: { title: '选择事项' }, + 3: { title: '事项详情' } }; - // 加载地区列表 + // 加载区划列表 async function loadRegions() { const navList = document.getElementById('navList'); - navList.innerHTML = '
加载地区列表...'; + navList.innerHTML = '
加载区划列表...'; try { const response = await fetch('/fs-ai-asistant/api/workflow/lawrisk/admin/regions'); @@ -1810,7 +1820,7 @@ navList.innerHTML = ''; if (data.data.regions.length === 0) { - navList.innerHTML = '
未找到地区数据
'; + navList.innerHTML = '
未找到区划数据
'; return; } @@ -1824,72 +1834,54 @@ navList.appendChild(li); }); } else { - navList.innerHTML = `
加载地区失败:${data.message}
`; + navList.innerHTML = `
加载区划失败:${data.message}
`; } } catch (error) { navList.innerHTML = `
网络错误:${error.message}
`; } } - // 加载主题列表 - async function loadThemes(regionId, regionName) { - const navList = document.getElementById('navList'); - navList.innerHTML = '
加载主题列表...'; - - try { - const response = await fetch(`/fs-ai-asistant/api/workflow/lawrisk/admin/themes?region=${regionId}`); - const data = await response.json(); - - if (data.success) { - const themes = data.data.themes; - - if (themes.length === 0) { - navList.innerHTML = `
地区 "${regionName}" 下没有可用的主题
`; - return; - } - - let html = '
'; - themes.forEach(theme => { - html += ` -
  • - ${theme.name} - 点击选择 -
  • - `; - }); - html += '
    '; - navList.innerHTML = html; - } else { - navList.innerHTML = `
    加载主题失败:${data.message}
    `; - } - } catch (error) { - navList.innerHTML = `
    网络错误:${error.message}
    `; + // 加载许可列表(直接按区划聚合事项) + async function loadPermitsForRegion() { + if (!currentRegion) { + return; } - } - - // 加载许可列表 - async function loadPermits(themeId, themeName) { const navList = document.getElementById('navList'); - navList.innerHTML = '
    加载许可列表...'; + navList.innerHTML = '
    加载事项列表...'; try { - const response = await fetch(`/fs-ai-asistant/api/workflow/lawrisk/admin/permits?region=${currentRegion.id}&theme=${themeId}`); + const response = await fetch(`/fs-ai-asistant/api/workflow/lawrisk/admin/permits?region=${currentRegion.id}`); const data = await response.json(); if (data.success) { const permits = data.data.permits; if (permits.length === 0) { - navList.innerHTML = `
    主题 "${themeName}" 下没有可用的许可
    `; + navList.innerHTML = `
    区划 "${currentRegion.name}" 暂无可维护的事项
    `; return; } let html = '
    '; permits.forEach(permit => { - const riskCount = permit.risks ? permit.risks.length : 0; + const rawRiskCount = typeof permit.risk_count === 'number' + ? permit.risk_count + : (Array.isArray(permit.risks) ? permit.risks.length : 0); + const riskCount = Number.isFinite(rawRiskCount) ? rawRiskCount : 0; + const themeId = permit.theme && permit.theme.id + ? permit.theme.id + : (permit.theme_id || ''); + const themeName = permit.theme && permit.theme.name + ? permit.theme.name + : (permit.theme_name || ''); + const themeBadge = themeName + ? `${themeName}` + : ''; + const escapedName = permit.name ? permit.name.replace(/'/g, "\\'") : ''; + const escapedTheme = themeName ? themeName.replace(/'/g, "\\'") : ''; + const escapedThemeId = themeId ? themeId.replace(/'/g, "\\'") : ''; html += ` -
  • - ${permit.name} +
  • + ${permit.name}${themeBadge} ${riskCount} 个风险
  • `; @@ -1897,7 +1889,7 @@ html += '
    '; navList.innerHTML = html; } else { - navList.innerHTML = `
    加载许可失败:${data.message}
    `; + navList.innerHTML = `
    加载事项失败:${data.message}
    `; } } catch (error) { navList.innerHTML = `
    网络错误:${error.message}
    `; @@ -1910,7 +1902,6 @@ historyStack.push({ step: currentStep, region: currentRegion }); currentRegion = { id: regionId, name: regionName }; - currentTheme = null; currentPermit = null; currentPermitDetails = null; @@ -1918,31 +1909,24 @@ goToStep(2); } - // 选择主题 - async function selectTheme(themeId, themeName) { + // 选择许可 + async function selectPermit(permitId, permitName, themeId, themeName, riskCount = 0) { // 保存到历史栈 - historyStack.push({ step: currentStep, theme: currentTheme }); + historyStack.push({ step: currentStep, permit: currentPermit }); - currentTheme = { id: themeId, name: themeName }; - currentPermit = null; + currentPermit = { + id: permitId, + name: permitName, + themeId: themeId || '', + themeName: themeName || '', + riskCount: typeof riskCount === 'number' ? riskCount : 0 + }; currentPermitDetails = null; // 更新步骤 goToStep(3); } - // 选择许可 - async function selectPermit(permitId, permitName, themeId) { - // 保存到历史栈 - historyStack.push({ step: currentStep, permit: currentPermit }); - - currentPermit = { id: permitId, name: permitName, themeId: themeId }; - currentPermitDetails = null; - - // 更新步骤 - goToStep(4); - } - // 跳转到指定步骤 async function goToStep(step) { currentStep = step; @@ -1977,10 +1961,8 @@ if (step === 1) { await loadRegions(); } else if (step === 2) { - await loadThemes(currentRegion.id, currentRegion.name); + await loadPermitsForRegion(); } else if (step === 3) { - await loadPermits(currentTheme.id, currentTheme.name); - } else if (step === 4) { await showPermitDetails(); } } @@ -1988,16 +1970,12 @@ // 更新面包屑导航 function updateBreadcrumb() { const breadcrumb = document.getElementById('breadcrumb'); - let html = ''; - - // 总是显示"首页" - html += ` + let html = ` 首页 `; - // 显示当前选择的路径 if (currentRegion) { html += ''; if (currentStep > 2) { @@ -2015,35 +1993,21 @@ } } - if (currentTheme) { - html += ''; - if (currentStep > 3) { - html += ` - - ${currentTheme.name} - - `; - } else { - html += ` - - ${currentTheme.name} - - `; - } - } - if (currentPermit) { + const permitLabel = currentPermit.themeName + ? `${currentPermit.themeName} · ${currentPermit.name}` + : currentPermit.name; html += ''; - if (currentStep > 4) { + if (currentStep >= 3) { html += ` - ${currentPermit.name} + ${permitLabel} `; } else { html += ` - ${currentPermit.name} + ${permitLabel} `; } @@ -2061,9 +2025,6 @@ // 清理后续状态 if (targetStep <= 2) { - currentTheme = null; - } - if (targetStep <= 3) { currentPermit = null; currentPermitDetails = null; } @@ -2074,7 +2035,6 @@ // 返回首页 function goHome() { currentRegion = null; - currentTheme = null; currentPermit = null; currentPermitDetails = null; historyStack = []; @@ -2087,7 +2047,14 @@ detailsArea.innerHTML = '
    加载许可详情...'; try { - const response = await fetch(`/fs-ai-asistant/api/workflow/lawrisk/admin/permit-details?region=${currentRegion.id}&theme=${currentPermit.themeId}&permit=${currentPermit.id}`); + if (!currentRegion || !currentPermit) { + detailsArea.innerHTML = '
    请选择区划和事项后查看详情
    '; + return; + } + const themePart = currentPermit.themeId + ? `&theme=${encodeURIComponent(currentPermit.themeId)}` + : ''; + const response = await fetch(`/fs-ai-asistant/api/workflow/lawrisk/admin/permit-details?region=${currentRegion.id}&permit=${currentPermit.id}${themePart}`); const data = await response.json(); if (data.success) { @@ -2108,7 +2075,6 @@ // 恢复状态 if (prev.region) currentRegion = prev.region; - if (prev.theme) currentTheme = prev.theme; if (prev.permit) currentPermit = prev.permit; // 跳转到上一步 @@ -2120,8 +2086,14 @@ const detailsArea = document.querySelector('.details-area'); currentPermitDetails = permit; const riskCount = Array.isArray(permit.risks) ? permit.risks.length : 0; + const permitTheme = permit && permit.theme ? permit.theme : {}; if (currentPermit) { - currentPermit = { ...currentPermit, riskCount }; + currentPermit = { + ...currentPermit, + riskCount, + themeId: permitTheme.id || currentPermit.themeId || '', + themeName: permitTheme.name || currentPermit.themeName || '' + }; } const deleteButtonLabel = isDeletingPermit ? '删除中...' : '删除许可'; const deleteButtonDisabled = isDeletingPermit ? 'disabled' : ''; @@ -2141,6 +2113,40 @@ const fileInfoText = hasPermitFile ? `${escapeHtml(permitFile.filename || '原始文件')}(${formatFileSize(permitFile.file_size)}${fileUploadedAt ? ` | ${fileUploadedAt}` : ''}${fileUploadedBy ? ` | 上传:${fileUploadedBy}` : ''})` : '暂无关联文件'; + const rawThemeList = Array.isArray(permit.themes) ? permit.themes.filter(Boolean) : []; + if (permitTheme && (permitTheme.id || permitTheme.name)) { + rawThemeList.push(permitTheme); + } + if (currentPermit && (currentPermit.themeId || currentPermit.themeName)) { + rawThemeList.push({ + id: currentPermit.themeId || '', + name: currentPermit.themeName || '', + }); + } + const themeList = []; + const seenThemeKeys = new Set(); + rawThemeList.forEach(themeItem => { + if (!themeItem) { + return; + } + const id = typeof themeItem.id === 'string' ? themeItem.id.trim() : (themeItem.id ? String(themeItem.id) : ''); + const name = typeof themeItem.name === 'string' ? themeItem.name.trim() : (themeItem.name ? String(themeItem.name) : ''); + if (!id && !name) { + return; + } + const key = id || `name:${name}`; + if (seenThemeKeys.has(key)) { + return; + } + seenThemeKeys.add(key); + themeList.push({ + id, + name: name || id || '未命名主题', + }); + }); + const themeListDisplay = themeList.length + ? `
    ${themeList.map(themeItem => `${escapeHtml(themeItem.name)}`).join('')}
    ` + : '

    暂无主题关联

    '; let html = '
    '; html += ` @@ -2171,6 +2177,15 @@
    `; + html += ` +
    +

    所属主题

    +
    + ${themeListDisplay} +
    +
    + `; + // 经营范围 html += '

    经营范围

    '; if (permit.business_scopes && permit.business_scopes.length > 0) { @@ -2223,7 +2238,7 @@ if (isDeletingPermit) { return; } - if (!currentRegion || !currentTheme || !currentPermit) { + if (!currentRegion || !currentPermit) { alert('请先选择要删除的许可'); return; } @@ -2231,7 +2246,12 @@ const riskCount = currentPermit.riskCount !== undefined ? currentPermit.riskCount : (currentPermitDetails && Array.isArray(currentPermitDetails.risks) ? currentPermitDetails.risks.length : 0); - const confirmMessage = `确定要删除「${currentRegion.name} › ${currentTheme.name} › ${currentPermit.name}」吗?\n\n` + + const pathParts = [currentRegion.name]; + if (currentPermit.themeName) { + pathParts.push(currentPermit.themeName); + } + pathParts.push(currentPermit.name); + const confirmMessage = `确定要删除「${pathParts.join(' › ')}」吗?\n\n` + `此操作会删除 ${riskCount} 条风险关联(如存在)。系统会备份当前风险快照,但删除后需通过快照管理页面手动恢复。`; if (!confirm(confirmMessage)) { @@ -2251,7 +2271,7 @@ if (isDeletingPermit) { return; } - if (!currentRegion || !currentTheme || !currentPermit) { + if (!currentRegion || !currentPermit) { alert('当前上下文缺失,无法删除'); return; } @@ -2262,9 +2282,11 @@ try { const payload = { region_id: currentRegion.id, - theme_id: currentTheme.id, permit_id: currentPermit.id }; + if (currentPermit.themeId) { + payload.theme_id = currentPermit.themeId; + } if (changeSummary) { payload.change_summary = changeSummary; } @@ -2333,8 +2355,11 @@ // 更新步骤指示器 function updateStepIndicator(step) { - for (let i = 1; i <= 4; i++) { + for (let i = 1; i <= 3; i++) { const stepElement = document.getElementById(`step${i}`); + if (!stepElement) { + continue; + } if (i <= step) { stepElement.classList.add('active'); } else { @@ -3526,8 +3551,8 @@ const targetRegionId = groupItems[0].region_id; const targetPermitId = groupItems[0].permit_id; if (currentRegion && currentRegion.id === targetRegionId) { - if (currentTheme) { - await loadPermits(currentTheme.id, currentTheme.name); + if (currentStep >= 2) { + await loadPermitsForRegion(); } if (currentPermit && currentPermit.id === targetPermitId) { await showPermitDetails();