fs-lawrisk/static/v2_admin_debug.html

1152 lines
46 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>LawRisk V2 超级管理员调试台</title>
<style>
:root {
color-scheme: light;
font-family: "Noto Sans SC", "Segoe UI", "Helvetica Neue", system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
padding: 0;
background: linear-gradient(135deg, #eef2ff, #f8fafc);
color: #0f172a;
}
.page {
max-width: 1400px;
margin: 0 auto;
padding: 28px 18px 60px;
}
header {
background: #0f172a;
color: #e2e8f0;
border-radius: 18px;
padding: 22px;
display: flex;
flex-wrap: wrap;
gap: 12px;
justify-content: space-between;
align-items: center;
box-shadow: 0 24px 60px rgba(15, 23, 42, 0.24);
}
header h1 {
margin: 0;
font-size: 24px;
letter-spacing: 0.5px;
}
header .meta {
display: flex;
gap: 12px;
flex-wrap: wrap;
align-items: center;
}
header .badge {
padding: 6px 10px;
border-radius: 10px;
font-size: 13px;
background: #22d3ee;
color: #0f172a;
font-weight: 700;
}
.card {
background: #fff;
border-radius: 16px;
padding: 20px;
margin-top: 18px;
box-shadow: 0 16px 45px rgba(148, 163, 184, 0.25);
}
.card h2 {
margin: 0 0 12px;
font-size: 18px;
color: #0f172a;
}
.muted {
color: #475569;
font-size: 14px;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: 14px;
}
label {
display: block;
font-size: 13px;
font-weight: 600;
color: #1e293b;
margin-bottom: 6px;
}
input[type="text"],
input[type="number"],
select,
textarea {
width: 100%;
padding: 10px 12px;
border: 1px solid #cbd5e1;
border-radius: 10px;
font-size: 14px;
background: #f8fafc;
transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
select[multiple] {
height: 120px;
}
input:focus,
select:focus,
textarea:focus {
outline: none;
border-color: #6366f1;
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.18);
background: #fff;
}
button {
border: none;
border-radius: 10px;
padding: 10px 16px;
font-size: 14px;
font-weight: 700;
cursor: pointer;
transition: transform 0.12s ease, box-shadow 0.12s ease;
}
.btn-primary {
background: linear-gradient(120deg, #6366f1, #22d3ee);
color: #0f172a;
}
.btn-secondary {
background: #e2e8f0;
color: #0f172a;
}
.btn-text {
background: transparent;
color: #2563eb;
padding: 6px 10px;
}
button:disabled {
opacity: 0.65;
cursor: not-allowed;
transform: none;
box-shadow: none;
}
button:not(:disabled):hover {
transform: translateY(-1px);
box-shadow: 0 10px 20px rgba(99, 102, 241, 0.25);
}
.actions {
display: flex;
gap: 10px;
flex-wrap: wrap;
align-items: center;
}
.status {
margin-top: 10px;
font-size: 14px;
min-height: 20px;
}
.status.ok {
color: #15803d;
}
.status.warn {
color: #b91c1c;
}
.pill {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
border-radius: 999px;
background: #e0f2fe;
color: #0c4a6e;
font-size: 12px;
margin-right: 8px;
}
.pill.gray {
background: #e2e8f0;
color: #0f172a;
}
.section-title {
font-size: 16px;
margin: 18px 0 10px;
}
.subject {
border: 1px solid #e2e8f0;
border-radius: 14px;
padding: 14px;
margin-top: 12px;
background: #f8fafc;
}
.subject h3 {
margin: 0 0 6px;
font-size: 16px;
}
.subject-meta {
color: #475569;
font-size: 13px;
margin-bottom: 8px;
}
table {
width: 100%;
border-collapse: collapse;
margin-top: 10px;
font-size: 13px;
}
th,
td {
border: 1px solid #e2e8f0;
padding: 10px 8px;
text-align: left;
vertical-align: top;
}
th {
background: #f1f5f9;
color: #0f172a;
font-weight: 700;
}
td ul {
margin: 0;
padding-left: 16px;
}
.raw {
background: #0f172a;
color: #e2e8f0;
border-radius: 12px;
padding: 14px;
overflow: auto;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
font-size: 12px;
max-height: 320px;
}
.raw.collapsed {
display: none;
}
.list-inline {
display: flex;
gap: 8px;
flex-wrap: wrap;
padding: 0;
margin: 0;
list-style: none;
}
.chip {
background: #e2e8f0;
border-radius: 8px;
padding: 4px 8px;
font-size: 12px;
}
.empty {
text-align: center;
padding: 32px 12px;
color: #94a3b8;
font-size: 14px;
}
.tag {
display: inline-block;
padding: 4px 6px;
border-radius: 6px;
background: #fef3c7;
color: #92400e;
font-size: 12px;
}
.panel {
position: fixed;
right: 0;
top: 0;
bottom: 0;
width: 460px;
max-width: 100%;
background: #fff;
box-shadow: -6px 0 30px rgba(15, 23, 42, 0.18);
transform: translateX(100%);
transition: transform 0.25s ease;
z-index: 40;
overflow-y: auto;
}
.panel.open {
transform: translateX(0);
}
.panel-header {
position: sticky;
top: 0;
background: #fff;
padding: 16px 18px;
border-bottom: 1px solid #e2e8f0;
display: flex;
justify-content: space-between;
align-items: center;
gap: 10px;
}
.panel-body {
padding: 16px 18px 60px;
}
.backdrop {
position: fixed;
inset: 0;
background: rgba(15, 23, 42, 0.35);
opacity: 0;
visibility: hidden;
transition: opacity 0.2s ease;
z-index: 30;
}
.backdrop.show {
opacity: 1;
visibility: visible;
}
.loader {
display: inline-block;
width: 18px;
height: 18px;
border: 3px solid #e2e8f0;
border-top-color: #6366f1;
border-radius: 50%;
animation: spin 0.9s linear infinite;
vertical-align: middle;
margin-right: 8px;
}
.checkbox-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
padding: 10px;
border: 1px solid #e2e8f0;
border-radius: 12px;
background: #f8fafc;
max-height: 180px;
overflow: auto;
}
.checkbox-item {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
border-radius: 10px;
background: #fff;
border: 1px solid #e2e8f0;
cursor: pointer;
font-size: 13px;
box-shadow: 0 4px 10px rgba(148, 163, 184, 0.2);
}
.checkbox-item input {
accent-color: #6366f1;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
@media (max-width: 720px) {
header {
flex-direction: column;
align-items: flex-start;
}
.panel {
width: 100%;
}
}
</style>
</head>
<body>
<div class="page">
<header>
<div>
<h1>LawRisk V2 超级管理员调试台</h1>
<div class="muted">面向 V2 问答与许可筛选的调试工具,需已登录并具备管理员权限。</div>
</div>
<div class="meta">
<span class="badge">Super Admin Only</span>
<button class="btn-secondary" id="reload-all-btn">刷新选项缓存</button>
<button class="btn-secondary" id="logout-btn" style="background:#fecdd3;color:#7f1d1d;">退出登录</button>
</div>
</header>
<section class="card">
<div
style="display: flex; justify-content: space-between; align-items: flex-start; gap: 12px; flex-wrap: wrap;">
<div>
<h2>V2 问答接口调试</h2>
<div class="muted">选择地区 + 输入问题,可切换 debug 与 top 参数,右侧可直达许可详情。</div>
</div>
<div class="actions">
<button id="toggle-search-raw" class="btn-text" style="display:none;">显示原始 JSON</button>
</div>
</div>
<form id="search-form" style="margin-top: 10px;">
<div class="grid">
<div>
<label for="search-query">问题</label>
<input id="search-query" type="text" placeholder="例如:开办电影院需要哪些许可事项" required>
</div>
<div>
<label for="search-regions">限定地区(可多选,默认全部)</label>
<select id="search-regions" multiple></select>
</div>
<div class="grid" style="grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 10px;">
<div>
<label for="search-top">top 推荐数量</label>
<input id="search-top" type="number" min="1" max="10" value="5">
</div>
<div>
<label style="visibility:hidden;">调试</label>
<div style="display:flex; align-items:center; gap:8px; padding:10px 0;">
<input id="search-debug" type="checkbox">
<span class="muted">debug=1</span>
</div>
</div>
</div>
</div>
<div class="actions" style="margin-top: 12px;">
<button type="submit" class="btn-primary" id="search-submit">发送请求</button>
<button type="button" class="btn-secondary" id="search-reset">清空</button>
</div>
</form>
<div id="search-status" class="status"></div>
<div id="search-results"></div>
<pre id="search-raw" class="raw collapsed"></pre>
</section>
<section class="card">
<div
style="display: flex; justify-content: space-between; align-items: flex-start; gap: 12px; flex-wrap: wrap;">
<div>
<h2>许可筛选与查询</h2>
<div class="muted">多维筛选(地区/主题/部门/关键词),点击行可查看详情。</div>
</div>
<div class="actions">
<button id="toggle-filter-raw" class="btn-text" style="display:none;">显示筛选 JSON</button>
</div>
</div>
<form id="filter-form" style="margin-top: 10px;">
<div class="grid">
<div>
<label>地区(多选)</label>
<div id="filter-region-box" class="checkbox-list"></div>
</div>
<div>
<label>主题(多选)</label>
<div id="filter-theme-box" class="checkbox-list"></div>
</div>
<div>
<label>部门(多选)</label>
<div id="filter-dept-box" class="checkbox-list"></div>
</div>
<div>
<label for="filter-text">许可名称关键词</label>
<input id="filter-text" type="text" placeholder="输入许可名称片段">
</div>
<div class="grid" style="grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 10px;">
<div>
<label for="filter-limit">单页数量</label>
<input id="filter-limit" type="number" min="1" max="200" value="50">
</div>
<div>
<label for="filter-offset">偏移量</label>
<input id="filter-offset" type="number" min="0" value="0">
</div>
</div>
</div>
<div class="actions" style="margin-top: 12px;">
<button type="button" class="btn-primary" id="filter-apply">应用筛选</button>
<button type="button" class="btn-secondary" id="filter-reset">重置</button>
</div>
</form>
<div id="filter-status" class="status"></div>
<div id="filter-results"></div>
<pre id="filter-raw" class="raw collapsed"></pre>
</section>
</div>
<div class="backdrop" id="backdrop"></div>
<aside class="panel" id="detail-panel" aria-label="许可详情">
<div class="panel-header">
<strong id="detail-title">许可详情</strong>
<div class="actions">
<button class="btn-text" id="detail-raw-toggle" style="display:none;">显示详情 JSON</button>
<button class="btn-secondary" id="detail-close">关闭</button>
</div>
</div>
<div class="panel-body" id="detail-body">
<div class="muted">加载中...</div>
</div>
<pre class="raw collapsed" id="detail-raw"></pre>
</aside>
<script>
(() => {
const endpoints = {
regions: "/fs-ai-asistant/api/workflow/lawrisk/v2/regions",
search: "/fs-ai-asistant/api/workflow/lawrisk/v2",
filterOptions: "/fs-ai-asistant/api/workflow/lawrisk/admin/permits/filter-options",
advancedFilter: "/fs-ai-asistant/api/workflow/lawrisk/admin/permits/advanced-filter",
permitDetails: "/fs-ai-asistant/api/workflow/lawrisk/admin/permit-details",
};
const searchForm = document.getElementById("search-form");
const searchQuery = document.getElementById("search-query");
const searchRegions = document.getElementById("search-regions");
const searchTop = document.getElementById("search-top");
const searchDebug = document.getElementById("search-debug");
const searchStatus = document.getElementById("search-status");
const searchResults = document.getElementById("search-results");
const searchRaw = document.getElementById("search-raw");
const toggleSearchRawBtn = document.getElementById("toggle-search-raw");
const searchReset = document.getElementById("search-reset");
const searchSubmit = document.getElementById("search-submit");
const filterRegionBox = document.getElementById("filter-region-box");
const filterThemeBox = document.getElementById("filter-theme-box");
const filterDeptBox = document.getElementById("filter-dept-box");
const filterText = document.getElementById("filter-text");
const filterLimit = document.getElementById("filter-limit");
const filterOffset = document.getElementById("filter-offset");
const filterStatus = document.getElementById("filter-status");
const filterResults = document.getElementById("filter-results");
const filterRaw = document.getElementById("filter-raw");
const toggleFilterRawBtn = document.getElementById("toggle-filter-raw");
const filterApply = document.getElementById("filter-apply");
const filterReset = document.getElementById("filter-reset");
const reloadAllBtn = document.getElementById("reload-all-btn");
const logoutBtn = document.getElementById("logout-btn");
const detailPanel = document.getElementById("detail-panel");
const detailBody = document.getElementById("detail-body");
const detailTitle = document.getElementById("detail-title");
const detailClose = document.getElementById("detail-close");
const detailRaw = document.getElementById("detail-raw");
const detailRawToggle = document.getElementById("detail-raw-toggle");
const backdrop = document.getElementById("backdrop");
let lastSearchPayload = null;
let lastFilterPayload = null;
const detailCache = new Map();
document.addEventListener("DOMContentLoaded", () => {
loadRegions();
loadFilterOptions();
searchQuery.focus();
});
reloadAllBtn.addEventListener("click", () => {
loadRegions(true);
loadFilterOptions(true);
});
logoutBtn.addEventListener("click", async () => {
try {
setSearchStatus("正在退出...", "ok");
const res = await fetch("/auth/logout", {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" }
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
// 跳转登录页强制重新认证
window.location.href = "/fs-ai-asistant/api/workflow/lawrisk/login?force=1";
} catch (err) {
setSearchStatus(`退出失败:${err.message}`, "warn");
}
});
searchForm.addEventListener("submit", async (e) => {
e.preventDefault();
await runSearch();
});
searchReset.addEventListener("click", () => {
searchQuery.value = "";
Array.from(searchRegions.options).forEach(opt => opt.selected = false);
searchTop.value = 5;
searchDebug.checked = false;
searchStatus.textContent = "";
searchResults.innerHTML = "";
searchRaw.textContent = "";
searchRaw.classList.add("collapsed");
toggleSearchRawBtn.style.display = "none";
});
toggleSearchRawBtn.addEventListener("click", () => {
const hidden = searchRaw.classList.toggle("collapsed");
toggleSearchRawBtn.textContent = hidden ? "显示原始 JSON" : "隐藏原始 JSON";
});
filterApply.addEventListener("click", () => runFilter());
filterReset.addEventListener("click", () => {
[filterRegionBox, filterThemeBox, filterDeptBox].forEach(box => {
box.querySelectorAll("input[type='checkbox']").forEach(input => { input.checked = false; });
});
filterText.value = "";
filterLimit.value = 50;
filterOffset.value = 0;
filterStatus.textContent = "";
filterResults.innerHTML = "";
filterRaw.textContent = "";
filterRaw.classList.add("collapsed");
toggleFilterRawBtn.style.display = "none";
});
toggleFilterRawBtn.addEventListener("click", () => {
const hidden = filterRaw.classList.toggle("collapsed");
toggleFilterRawBtn.textContent = hidden ? "显示筛选 JSON" : "隐藏筛选 JSON";
});
detailClose.addEventListener("click", closeDetail);
backdrop.addEventListener("click", closeDetail);
detailRawToggle.addEventListener("click", () => {
const hidden = detailRaw.classList.toggle("collapsed");
detailRawToggle.textContent = hidden ? "显示详情 JSON" : "隐藏详情 JSON";
});
async function loadRegions(force = false) {
if (searchRegions.options.length > 0 && !force) return;
setSearchStatus("加载地区列表...", "ok");
try {
const res = await fetch(endpoints.regions, { credentials: "include" });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const payload = await res.json();
const regions = (payload && payload.data && Array.isArray(payload.data.regions)) ? payload.data.regions : [];
renderRegionOptions(searchRegions, regions, true);
setSearchStatus("地区列表已加载", "ok");
} catch (err) {
setSearchStatus(`地区列表加载失败:${err.message}`, "warn");
}
}
async function loadFilterOptions(force = false) {
if (filterRegionBox.childElementCount > 0 && !force) return;
setFilterStatus("加载筛选选项...", "ok");
try {
const res = await fetch(endpoints.filterOptions, { credentials: "include" });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const payload = await res.json();
if (!payload.success) throw new Error(payload.message || "接口返回失败");
const data = payload.data || {};
renderCheckboxList(filterRegionBox, data.regions || []);
renderCheckboxList(filterThemeBox, data.themes || []);
renderCheckboxList(filterDeptBox, data.departments || [], true);
setFilterStatus("筛选选项已加载", "ok");
} catch (err) {
setFilterStatus(`筛选选项加载失败:${err.message}`, "warn");
}
}
function renderRegionOptions(selectEl, regions, addAllLabel) {
selectEl.innerHTML = "";
if (addAllLabel) {
const opt = document.createElement("option");
opt.value = "";
opt.textContent = "全部地区";
selectEl.appendChild(opt);
}
regions.forEach(region => {
if (!region || !region.id) return;
const opt = document.createElement("option");
opt.value = region.id;
opt.textContent = region.name || region.id;
selectEl.appendChild(opt);
});
}
function renderCheckboxList(container, items, includeCode = false) {
container.innerHTML = "";
items.forEach(item => {
if (!item) return;
const id = item.id || "";
const name = item.name || id || "未命名";
const label = includeCode && item.code ? `${name} (${item.code})` : name;
const wrapper = document.createElement("label");
wrapper.className = "checkbox-item";
const input = document.createElement("input");
input.type = "checkbox";
input.value = id;
wrapper.appendChild(input);
const span = document.createElement("span");
span.textContent = label;
wrapper.appendChild(span);
container.appendChild(wrapper);
});
}
async function runSearch() {
const query = searchQuery.value.trim();
if (!query) {
setSearchStatus("请输入问题后再查询。", "warn");
return;
}
setSearchStatus("查询中,请稍候...", "ok", true);
searchResults.innerHTML = "";
searchRaw.textContent = "";
const params = new URLSearchParams();
params.set("query", query);
const regions = getSelectedValues(searchRegions);
regions.forEach(r => params.append("region", r));
params.set("top", searchTop.value || "5");
if (searchDebug.checked) params.set("debug", "1");
try {
const started = performance.now();
const res = await fetch(`${endpoints.search}?${params.toString()}`, { credentials: "include" });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const payload = await res.json();
lastSearchPayload = payload;
renderSearchPayload(payload, performance.now() - started);
} catch (err) {
setSearchStatus(`请求失败:${err.message}`, "warn");
} finally {
setLoading(searchSubmit, false);
}
}
function renderSearchPayload(payload, durationMs) {
if (!payload || typeof payload !== "object") {
setSearchStatus("响应格式不正确。", "warn");
return;
}
const success = payload.success;
const data = payload.data || {};
const subjects = Array.isArray(data.risk_subject) ? data.risk_subject : [];
if (!success) {
setSearchStatus(payload.message ? `接口返回错误:${payload.message}` : "接口执行失败。", "warn");
return;
}
const permitCount = subjects.reduce((sum, s) => sum + (Array.isArray(s.permits) ? s.permits.length : 0), 0);
const cost = data.executionTime || Math.round(durationMs || 0);
setSearchStatus(`已匹配 ${subjects.length} 个主题,包含 ${permitCount} 条许可事项(${cost} ms`, subjects.length ? "ok" : "warn");
searchResults.innerHTML = subjects.length ? "" : `<div class="empty">未检索到相关主题或许可事项。</div>`;
subjects.forEach((subject, idx) => {
searchResults.appendChild(renderSubjectCard(subject, idx));
});
searchRaw.textContent = JSON.stringify(payload, null, 2);
searchRaw.classList.add("collapsed");
toggleSearchRawBtn.style.display = "inline-block";
toggleSearchRawBtn.textContent = "显示原始 JSON";
}
function renderSubjectCard(subject, index) {
const wrap = document.createElement("div");
wrap.className = "subject";
const title = document.createElement("h3");
title.textContent = subject.display_name || `主题 ${index + 1}`;
wrap.appendChild(title);
const meta = document.createElement("div");
meta.className = "subject-meta";
const regionName = subject.region?.name || subject.region_name || "未知地区";
const themeName = subject.theme?.name || subject.theme_name || "未知主题";
meta.textContent = `地区:${regionName} 主题事项:${themeName}`;
wrap.appendChild(meta);
const permits = Array.isArray(subject.permits) ? subject.permits : [];
if (!permits.length) {
wrap.appendChild(emptyHint("该主题未关联具体许可事项。"));
return wrap;
}
const table = document.createElement("table");
const thead = document.createElement("thead");
thead.innerHTML = `
<tr>
<th>许可事项</th>
<th>事项属性</th>
<th>子项概述</th>
<th>经营范围</th>
<th>风险提示</th>
<th>操作</th>
</tr>
`;
table.appendChild(thead);
const tbody = document.createElement("tbody");
permits.forEach(permit => {
const tr = document.createElement("tr");
tr.appendChild(cell(permit.name || "未命名许可"));
tr.appendChild(cell(permit.permit_status || "—"));
tr.appendChild(cell(permit.subitem_summary || "—"));
const scopes = Array.isArray(permit.business_scopes)
? permit.business_scopes.map(s => s?.description || "").filter(Boolean).join("、")
: "";
tr.appendChild(cell(scopes || "—"));
tr.appendChild(riskCell(permit.risks));
const op = document.createElement("td");
const btn = document.createElement("button");
btn.className = "btn-primary";
btn.textContent = "查看详情";
btn.addEventListener("click", () => openDetail(
permit.id,
subject.region?.id || subject.region_id || "",
subject.theme?.id || subject.theme_id || ""
));
op.appendChild(btn);
tr.appendChild(op);
tbody.appendChild(tr);
});
table.appendChild(tbody);
wrap.appendChild(table);
return wrap;
}
async function runFilter() {
setFilterStatus("筛选中...", "ok", true);
filterResults.innerHTML = "";
filterRaw.textContent = "";
const selectedRegions = getCheckedValues(filterRegionBox);
const payload = {
regions: selectedRegions,
region: selectedRegions[0] || null,
themes: getCheckedValues(filterThemeBox),
departments: getCheckedValues(filterDeptBox),
search_text: filterText.value.trim() || null,
limit: parseInt(filterLimit.value || "50", 10),
offset: parseInt(filterOffset.value || "0", 10),
};
if (!payload.regions?.length) payload.regions = null, payload.region = null;
if (!payload.themes?.length) payload.themes = null;
if (!payload.departments?.length) payload.departments = null;
try {
const res = await fetch(endpoints.advancedFilter, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
if (!data.success) throw new Error(data.message || "筛选失败");
lastFilterPayload = data;
renderFilterResults(data.data || {});
} catch (err) {
setFilterStatus(`筛选失败:${err.message}`, "warn");
} finally {
setLoading(filterApply, false);
}
}
function renderFilterResults(result) {
let permits = Array.isArray(result.permits) ? result.permits : [];
const selectedRegions = getCheckedValues(filterRegionBox);
if (selectedRegions.length) {
permits = permits.filter(p => selectedRegions.includes(p.region?.id || p.region_id || ""));
}
const pagination = result.pagination || {};
const total = pagination.total ?? permits.length;
const applied = [];
if (selectedRegions.length) applied.push(`地区: ${selectedRegions.join(",")}`);
const selectedThemes = getCheckedValues(filterThemeBox);
if (selectedThemes.length) applied.push(`主题: ${selectedThemes.join(",")}`);
const selectedDepts = getCheckedValues(filterDeptBox);
if (selectedDepts.length) applied.push(`部门: ${selectedDepts.join(",")}`);
if (filterText.value.trim()) applied.push(`关键词: ${filterText.value.trim()}`);
const appliedText = applied.length ? `已用筛选 => ${applied.join(" | ")}` : "未使用筛选条件";
setFilterStatus(`${total} 条,当前展示 ${permits.length} 条。${appliedText}`, permits.length ? "ok" : "warn");
if (!permits.length) {
filterResults.innerHTML = `<div class="empty">未找到符合条件的许可文件</div>`;
} else {
const table = document.createElement("table");
table.innerHTML = `
<thead>
<tr>
<th>许可名称</th>
<th>地区</th>
<th>主题</th>
<th>风险数</th>
<th>操作</th>
</tr>
</thead>
`;
const tbody = document.createElement("tbody");
permits.forEach(item => {
const tr = document.createElement("tr");
tr.appendChild(cell(item.name || "未命名许可"));
tr.appendChild(cell(item.region?.name || item.region_name || "—"));
tr.appendChild(cell(renderThemesText(item.themes)));
tr.appendChild(cell(typeof item.risk_count === "number" ? item.risk_count : "—"));
const op = document.createElement("td");
const btn = document.createElement("button");
btn.className = "btn-primary";
btn.textContent = "查看详情";
btn.addEventListener("click", () => openDetail(
item.id,
item.region?.id || item.region_id || "",
(item.themes && item.themes[0]?.id) || ""
));
op.appendChild(btn);
tr.appendChild(op);
tbody.appendChild(tr);
});
table.appendChild(tbody);
filterResults.innerHTML = "";
filterResults.appendChild(table);
}
filterRaw.textContent = JSON.stringify(result, null, 2);
filterRaw.classList.add("collapsed");
toggleFilterRawBtn.style.display = "inline-block";
toggleFilterRawBtn.textContent = "显示筛选 JSON";
}
async function openDetail(permitId, regionId, themeId) {
detailTitle.textContent = "许可详情";
detailBody.innerHTML = `<div class="muted"><span class="loader"></span>加载中...</div>`;
detailRaw.textContent = "";
detailRaw.classList.add("collapsed");
detailRawToggle.style.display = "none";
showDetailPanel(true);
if (!regionId) {
const selectedRegions = getCheckedValues(filterRegionBox);
regionId = selectedRegions[0] || regionId;
}
const key = `${regionId || ""}:${permitId || ""}:${themeId || ""}`;
if (detailCache.has(key)) {
renderDetail(detailCache.get(key));
return;
}
try {
const params = new URLSearchParams();
params.set("region", regionId);
params.set("permit", permitId);
if (themeId) params.set("theme", themeId);
const res = await fetch(`${endpoints.permitDetails}?${params.toString()}`, { credentials: "include" });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const payload = await res.json();
if (!payload.success) throw new Error(payload.message || "加载详情失败");
detailCache.set(key, payload.data || {});
renderDetail(payload.data || {});
detailRaw.textContent = JSON.stringify(payload, null, 2);
detailRawToggle.style.display = "inline-block";
} catch (err) {
detailBody.innerHTML = `<div class="muted">加载失败:${err.message}</div>`;
}
}
function renderDetail(data) {
if (!data || !data.permit) {
detailBody.innerHTML = `<div class="muted">未找到许可详情。</div>`;
return;
}
const permit = data.permit;
const theme = data.theme_display || permit.theme;
detailTitle.textContent = permit.name || "许可详情";
const risks = Array.isArray(permit.risks) ? permit.risks : [];
const scopes = Array.isArray(permit.business_scopes) ? permit.business_scopes : [];
const themes = Array.isArray(permit.themes) ? permit.themes : [];
const source = permit.permit_source || {};
const fileMeta = permit.permit_file || {};
const themeChips = themes.length
? `<div class="list-inline">${themes.map(t => `<span class="chip">${escapeHtml(t.name || t.id || "")}</span>`).join("")}</div>`
: `<div class="muted">暂无关联主题</div>`;
const contact = permit.responsible_contact || permit.contact || permit.department_contact;
const jurisdiction = permit.jurisdiction_scope || permit.jurisdiction || permit.scope;
const subitem = permit.subitem_summary || permit.subitem || permit.item_summary;
detailBody.innerHTML = `
<div class="section-title">基本信息</div>
<div class="pill">地区:${escapeHtml(data.region || permit.region_id || permit.region?.name || "—")}</div>
<div class="pill gray">主题:${escapeHtml(theme?.name || theme?.id || "—")}</div>
<div style="margin-top:6px;">事项属性:${escapeHtml(permit.permit_status || "—")}</div>
<div style="margin-top:6px;">子项概述:${escapeHtml(subitem || "—")}</div>
<div style="margin-top:6px;">管辖范围:${escapeHtml(jurisdiction || "—")}</div>
<div style="margin-top:6px;">责任联系方式:${escapeHtml(contact || "—")}</div>
<div class="section-title">关联主题</div>
${themeChips}
<div class="section-title">经营范围</div>
${scopes.length ? `<ul>${scopes.map(s => `<li>${escapeHtml(s.description || "")}</li>`).join("")}</ul>` : `<div class="muted">暂无经营范围</div>`}
<div class="section-title">风险提示 (${risks.length})</div>
${risks.length ? risks.map(r => renderRiskItem(r)).join("") : `<div class="muted">暂无风险提示</div>`}
<div class="section-title">来源信息</div>
<div class="muted">来源类型:${escapeHtml(source.source_type || "—")}|来源名称:${escapeHtml(source.source_name || "—")}</div>
${source.source_detail ? `<div style="margin-top:4px;">来源详情:${escapeHtml(source.source_detail)}</div>` : ""}
<div class="section-title">附件</div>
${fileMeta.filename ? `<div class="muted">文件:${escapeHtml(fileMeta.filename)}${fileMeta.file_size || 0} bytes</div>` : `<div class="muted">暂无上传文件</div>`}
`;
}
function renderRiskItem(risk) {
const content = escapeHtml(risk.risk_content || "");
const basis = escapeHtml(risk.legal_basis || "");
const no = escapeHtml(risk.document_no || "");
const summary = escapeMultiline(risk.summary || "");
return `
<div style="padding:8px 10px; border:1px solid #e2e8f0; border-radius:10px; margin-bottom:8px;">
<div><strong>${content || "未填写风险内容"}</strong></div>
${basis ? `<div class="muted">法律依据:${basis}</div>` : ""}
${no ? `<div class="muted">文号:${no}</div>` : ""}
${summary ? `<div style="margin-top:6px; font-size:13px; color:#0f172a;">${summary}</div>` : ""}
</div>
`;
}
function renderThemesText(themes) {
if (!Array.isArray(themes) || !themes.length) return "—";
return themes.map(t => t?.name || t?.id || "").filter(Boolean).join("、");
}
function setSearchStatus(text, tone = "ok", loading = false) {
searchStatus.textContent = text;
searchStatus.className = `status ${tone === "warn" ? "warn" : "ok"}`;
setLoading(searchSubmit, loading);
}
function setFilterStatus(text, tone = "ok", loading = false) {
filterStatus.textContent = text;
filterStatus.className = `status ${tone === "warn" ? "warn" : "ok"}`;
setLoading(filterApply, loading);
}
function setLoading(btn, loading) {
if (!btn) return;
btn.disabled = loading;
if (loading) btn.dataset.original = btn.textContent, btn.textContent = "处理中...";
else if (btn.dataset.original) btn.textContent = btn.dataset.original;
}
function cell(text) {
const td = document.createElement("td");
td.textContent = typeof text === "string" ? text : (text ?? "—");
return td;
}
function riskCell(risks) {
const td = document.createElement("td");
if (!Array.isArray(risks) || !risks.length) {
td.textContent = "—";
return td;
}
const ul = document.createElement("ul");
risks.forEach((risk, idx) => {
const li = document.createElement("li");
const lines = [];
if (risk.risk_content) lines.push(`${idx + 1}. ${risk.risk_content}`);
if (risk.legal_basis) lines.push(`法律依据:${risk.legal_basis}`);
if (risk.document_no) lines.push(`文号:${risk.document_no}`);
lines.forEach(line => {
const div = document.createElement("div");
div.textContent = line;
li.appendChild(div);
});
ul.appendChild(li);
});
td.appendChild(ul);
return td;
}
function emptyHint(text) {
const div = document.createElement("div");
div.className = "empty";
div.textContent = text;
return div;
}
function getSelectedValues(selectEl) {
return Array.from(selectEl.selectedOptions || [])
.map(opt => opt.value)
.filter(v => v && v.trim());
}
function getCheckedValues(container) {
return Array.from(container.querySelectorAll("input[type='checkbox']:checked"))
.map(input => input.value)
.filter(v => v && v.trim());
}
function showDetailPanel(open) {
detailPanel.classList.toggle("open", open);
backdrop.classList.toggle("show", open);
}
function closeDetail() {
showDetailPanel(false);
}
function escapeHtml(text) {
const div = document.createElement("div");
div.textContent = text ?? "";
return div.innerHTML;
}
function escapeMultiline(text) {
if (!text) return "";
return escapeHtml(text).replace(/\n/g, "<br>");
}
})();
</script>
</body>
</html>