fs-lawrisk/static/v2_admin_debug.html

1152 lines
46 KiB
HTML
Raw Normal View History

2025-11-24 15:18:49 +08:00
<!doctype html>
<html lang="zh-CN">
2025-11-24 15:18:49 +08:00
<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;
}
2025-11-24 15:18:49 +08:00
body {
margin: 0;
padding: 0;
background: linear-gradient(135deg, #eef2ff, #f8fafc);
color: #0f172a;
}
2025-11-24 15:18:49 +08:00
.page {
max-width: 1400px;
margin: 0 auto;
padding: 28px 18px 60px;
}
2025-11-24 15:18:49 +08:00
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);
}
2025-11-24 15:18:49 +08:00
header h1 {
margin: 0;
font-size: 24px;
letter-spacing: 0.5px;
}
2025-11-24 15:18:49 +08:00
header .meta {
display: flex;
gap: 12px;
flex-wrap: wrap;
align-items: center;
}
2025-11-24 15:18:49 +08:00
header .badge {
padding: 6px 10px;
border-radius: 10px;
font-size: 13px;
background: #22d3ee;
color: #0f172a;
font-weight: 700;
}
2025-11-24 15:18:49 +08:00
.card {
background: #fff;
border-radius: 16px;
padding: 20px;
margin-top: 18px;
box-shadow: 0 16px 45px rgba(148, 163, 184, 0.25);
}
2025-11-24 15:18:49 +08:00
.card h2 {
margin: 0 0 12px;
font-size: 18px;
color: #0f172a;
}
.muted {
color: #475569;
font-size: 14px;
}
2025-11-24 15:18:49 +08:00
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: 14px;
}
2025-11-24 15:18:49 +08:00
label {
display: block;
font-size: 13px;
font-weight: 600;
color: #1e293b;
margin-bottom: 6px;
}
2025-11-24 15:18:49 +08:00
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 {
2025-11-24 15:18:49 +08:00
outline: none;
border-color: #6366f1;
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.18);
background: #fff;
}
2025-11-24 15:18:49 +08:00
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;
}
2025-11-24 15:18:49 +08:00
.btn-primary {
background: linear-gradient(120deg, #6366f1, #22d3ee);
color: #0f172a;
}
2025-11-24 15:18:49 +08:00
.btn-secondary {
background: #e2e8f0;
color: #0f172a;
}
2025-11-24 15:18:49 +08:00
.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;
}
2025-11-24 15:18:49 +08:00
.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;
}
2025-11-24 15:18:49 +08:00
.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;
}
2025-11-24 15:18:49 +08:00
table {
width: 100%;
border-collapse: collapse;
margin-top: 10px;
font-size: 13px;
}
th,
td {
2025-11-24 15:18:49 +08:00
border: 1px solid #e2e8f0;
padding: 10px 8px;
text-align: left;
vertical-align: top;
}
2025-11-24 15:18:49 +08:00
th {
background: #f1f5f9;
color: #0f172a;
font-weight: 700;
}
td ul {
margin: 0;
padding-left: 16px;
}
2025-11-24 15:18:49 +08:00
.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;
}
2025-11-24 15:18:49 +08:00
.empty {
text-align: center;
padding: 32px 12px;
color: #94a3b8;
font-size: 14px;
}
2025-11-24 15:18:49 +08:00
.tag {
display: inline-block;
padding: 4px 6px;
border-radius: 6px;
background: #fef3c7;
color: #92400e;
font-size: 12px;
}
2025-11-24 15:18:49 +08:00
.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);
}
2025-11-24 15:18:49 +08:00
.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;
}
2025-11-24 15:18:49 +08:00
.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;
}
2025-11-24 15:18:49 +08:00
.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;
}
2025-11-24 15:18:49 +08:00
.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;
}
2025-11-24 15:18:49 +08:00
.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);
}
}
2025-11-24 15:18:49 +08:00
@media (max-width: 720px) {
header {
flex-direction: column;
align-items: flex-start;
}
.panel {
width: 100%;
}
2025-11-24 15:18:49 +08:00
}
</style>
</head>
<body>
<div class="page">
<header>
2025-11-24 15:18:49 +08:00
<div>
<h1>LawRisk V2 超级管理员调试台</h1>
<div class="muted">面向 V2 问答与许可筛选的调试工具,需已登录并具备管理员权限。</div>
2025-11-24 15:18:49 +08:00
</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>
2025-11-24 15:18:49 +08:00
</div>
</header>
2025-11-24 15:18:49 +08:00
<section class="card">
<div
style="display: flex; justify-content: space-between; align-items: flex-start; gap: 12px; flex-wrap: wrap;">
2025-11-24 15:18:49 +08:00
<div>
<h2>V2 问答接口调试</h2>
<div class="muted">选择地区 + 输入问题,可切换 debug 与 top 参数,右侧可直达许可详情。</div>
2025-11-24 15:18:49 +08:00
</div>
<div class="actions">
<button id="toggle-search-raw" class="btn-text" style="display:none;">显示原始 JSON</button>
2025-11-24 15:18:49 +08:00
</div>
</div>
<form id="search-form" style="margin-top: 10px;">
<div class="grid">
2025-11-24 15:18:49 +08:00
<div>
<label for="search-query">问题</label>
<input id="search-query" type="text" placeholder="例如:开办电影院需要哪些许可事项" required>
2025-11-24 15:18:49 +08:00
</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>
2025-11-24 15:18:49 +08:00
</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>
2025-11-24 15:18:49 +08:00
</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;">
2025-11-24 15:18:49 +08:00
<div>
<h2>许可筛选与查询</h2>
<div class="muted">多维筛选(地区/主题/部门/关键词),点击行可查看详情。</div>
2025-11-24 15:18:49 +08:00
</div>
<div class="actions">
<button id="toggle-filter-raw" class="btn-text" style="display:none;">显示筛选 JSON</button>
2025-11-24 15:18:49 +08:00
</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>
2025-11-24 15:18:49 +08:00
<div>
<label>部门(多选)</label>
<div id="filter-dept-box" class="checkbox-list"></div>
2025-11-24 15:18:49 +08:00
</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>
2025-11-24 15:18:49 +08:00
</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>
2025-11-24 15:18:49 +08:00
</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();
2025-11-24 15:18:49 +08:00
});
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 = `
2025-11-24 15:18:49 +08:00
<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 = `
2025-11-24 15:18:49 +08:00
<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 = `
2025-11-24 15:18:49 +08:00
<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 `
2025-11-24 15:18:49 +08:00
<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) {
2025-11-24 15:18:49 +08:00
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>
2025-11-24 15:18:49 +08:00
</body>
</html>