feat(定时任务): 内置「模板数据转换器」按 HTML 模板填充 JSON 生成大屏

- 模板填充引擎 template_fill.py:模板注册表 + schema 骨架 + 结构校验 + 行级安全填充;
  模板自带 DATA 即 schema 来源。no.1 可转换填充(含 MANIFEST 更新时间刷新),
  no.2 暂为「只展示」(convertible=False) 原样输出已有数据
- 内置 agent template-json-builder(seed + SOUL),随 lifespan 注入,自动出现在
  定时任务「执行智能体」下拉
- 调度器新增模板填充执行分支 _run_template_fill_attempt:源 JSON→大模型转换→
  结构校验→失败回喂重试;空源/只展示模板直接用模板默认数据,绝不失败
- API:GET /api/scheduled-tasks/html-templates
- 前端:选中该 agent 后条件渲染「HTML模板」下拉 + .json 上传,只展示型模板隐藏 JSON 输入
- 文档同步(前后端 CLAUDE.md)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
董宏凯andClaude Opus 4.8 committed 2026-06-23 18:00:00 +08:00
1 parent 1a2ef0a537
commit cd33aaeded
17 files changed
+1511 -57

No files matched your search

+2 -1
View File
@@ -79,11 +79,12 @@ The frontend (`frontend-web/`) is a React 19 + Vite SPA:
- **Routing**: React Router v7; top-level routes in `src/pages/PageRoutes.tsx`, workspace sub-routes in `src/pages/WorkspaceRoutes.tsx`
- **Pages**: `ChatPage` (thread chat), `AgentChatPage` (custom agent chat), `AgentsPage`/`NewAgentPage` (agent management), `ScheduledTasksPage`/`ScheduledTaskRunDetailPage` (scheduled tasks), `LightAppManagementPage`/`LightAppListPage`/`LightAppIframeView` (轻应用中心 — register/list/embed mini-apps)
- **定时任务「JSON → HTML 模板填充」(模板数据转换器)**: a third scheduled-task execution mode. In the create/edit dialog (`ScheduledTasksPage.tsx`), selecting the built-in agent **「模板数据转换器」** (`execution_agent_name === "template-json-builder"`, `TEMPLATE_BUILDER_AGENT_ID`) reveals a 「HTML模板」 select (`useHtmlTemplates` → `GET /api/scheduled-tasks/html-templates`) + a **.json 上传** button (reads the file's text into the prompt box) and **hides** the 必须生成 HTML/Markdown switches (this mode always outputs HTML). The chosen template persists as `execution_context.template_config.template_id` (`TemplateFillConfig` in `core/scheduled-tasks/types.ts`). At run time the backend converts the source JSON (the task prompt) into that template's `DATA` structure, validates (retry-on-fail), fills the template, and stores an HTML page previewable via the existing run-detail HTML preview. The source JSON is **any** shape — the agent's `SOUL.md` maps it to the template's schema skeleton. See the backend CLAUDE.md「Template-fill mode」for the engine (`deerflow/runtime/scheduler/template_fill.py`), agent seed, and dispatch.
- **轻应用中心 (Light App Center)**: a sibling of 任务管理 in the sidebar with two pages — 应用管理 (admin registers `window_open` or `iframe` mini-apps with a route-param builder: login params resolved from `localStorage.userInfo`, theme params literal) and 应用列表 (card grid of `window_open` apps, `window.open` launch). `iframe` apps are mounted under a chosen parent menu (XK/PG/TY/综合管理) and **dynamically injected into the sidebar** by `core/page-layout/use-sidebar-menu.ts` (`useSidebarMenuItems`), opening in the `LightAppIframeView` host. Client + React Query hooks in `strategy-components/api/light-apps.ts`; URL builder/constants in `strategy-components/lib/light-app.ts`. Backend: `/api/light-apps` (global/shared, admin-gated writes).
- **菜单管理 (Menu Management, admin-only)**: an **overlay** over the static sidebar menu — `MenuManagementPage` (`pages/MenuManagementPage.tsx`, route `/page/strategy/admin/menu-management`, opened from the admin-only 「菜单管理」 item in the **bottom-left settings dropdown** of `components/page-sidebar.tsx`). Admins rename / 停用·启用 / reorder (↑↓ or @dnd-kit drag) / re-parent (horizontal drag **or** the edit dialog's 「移动到」parent selector) any node across 一级/二级/三级, **including injected iframe light-app nodes** (`light-app-<appId>`). The static tree (`core/page-layout/sidebar-menu.ts`) stays the source of icons/routes; only per-node deltas (`label`/`disabled`/`parentId`/`sortOrder`) persist to the backend. Merge logic in `core/page-layout/menu-overrides.ts` (`applyMenuOverrides`, applied in `use-sidebar-menu.ts` after light-app injection, before role filter; disabling a node hides its whole subtree) — strict **3-level cap** (`wouldExceedDepth`). Client `strategy-components/api/menu-overrides.ts`. Backend: `/api/menu-overrides` (global/shared; open read, admin atomic replace-all).
- **敏感词管理 (Sensitive Word Management)**: configures the **展示层脱敏映射** (原始词 → 替换词). **Gated on the username `lqq`** (not the admin role) — `getAccountDisplayName().toLowerCase() === "lqq"` (username = email `@` prefix, else `user_id`, same as the profile page); the dropdown entry, the route guard, and the page-level guard all use this check. `src/lib/desensitize.ts` ships a frozen seed map `SEED_DESENSITIZE_MAP` (the deployment seed) plus a live mutable `DESENSITIZE_MAP` (initialized from the seed so `desensitize()` works before any fetch). `DesensitizeWordsLoader` (mounted in `App.tsx` `AppProviders`) fetches `/api/sensitive-words` on startup and, if non-empty, calls `replaceDesensitizeMap(...)` to make the backend config the source of truth (empty/failed → keep seed). Page `SensitiveWordsPage.tsx` (route `/page/strategy/admin/sensitive-words`, opened from the 「敏感词管理」 item in the **bottom-left settings dropdown** of `components/page-sidebar.tsx`, present in **both** full + collapsed sidebar modes) lists/adds/edits/启停/deletes words and saves the **whole set atomically** (replace-all); a 「导入种子词」 button merges any missing `SEED_DESENSITIZE_MAP` entries into the editor. Client + hooks in `strategy-components/api/sensitive-words.ts` (`wordsToMap` folds enabled rows into the runtime map). Backend: `/api/sensitive-words` (global/shared; open read, write allowed for admin **or** the `lqq` account), mirroring 菜单管理's store shape. **侧边栏 + 导航栏 menu labels are desensitized** at the data source: `useSidebarMenuItems`/`useActiveThirdLevel` (`core/page-layout/use-sidebar-menu.ts`) map every node's `label` through a desensitizer built **directly from the `/api/sensitive-words` query** (`makeDesensitizer(wordsToMap(words))`, falling back to the global seed `desensitize` while empty) — this sidesteps the `replaceDesensitizeMap` effect-timing race. Only `label` is rewritten; `path`/`id`/`icon` are untouched so routing/highlight are unaffected. All nav surfaces (`page-sidebar.tsx`, `page-sidebar-dark.tsx`, `page-sidebar-v1.tsx`, `strategy-components/Header.tsx`, `PageRoutes.tsx`) consume this hook and are covered; `MenuManagementPage` uses the raw tree so admins still edit real labels.
- **技能地址管理 (Skill Address Management, admin-only)**: 扫描运行中的技能(`skills/{public,custom}` 下所有 `.md`/`.py`)里的 **http(s) URL 与 IP/IP:端口**,按唯一地址聚合全部出现位置(跨 .md/.py),支持**批量替换**(如把 UAT 地址一次性改成生产)。主体抽成可复用组件 `SkillAddressesPanel`(`pages/SkillAddressesPage.tsx` 导出,默认导出 `SkillAddressesPage` 仍保留旧路由 `/page/strategy/admin/skill-addresses` 作直链回退),**作为「技能地址管理」标签页嵌入管理员「技能管理」页 `pages/AdminSkillsPage.tsx`**(该页加 Tabs:「技能列表」+「技能地址管理」;入口在「设置和更多」下拉的「技能管理」`workspace-nav-menu.tsx` → `/page/workspace/admin/skills`,不再单列于 `components/page-sidebar.tsx` 左下角设置下拉):打开自动扫描并带**进度条**(扫描逐技能 / 应用逐文件 X/N,应对 150+ 技能),URL/IP 类型过滤 + 搜索 + 「片段批量套用」(如所有 `uat.4.cn` → `prod.4.cn`)、每地址「替换为」输入框(可展开看 `file:line` 片段)、内联**预览**、**应用**、**多关键词检索**(空格分隔全部命中),以及**修改记录**面板——每次应用生成一条记录(显示时间/改了哪些地址 from→to/文件数/处数),可**针对单次记录回退**(二次确认)。client + NDJSON 流式解析在 `strategy-components/api/skill-addresses.ts`。后端 `/api/skill-addresses`(**仅管理员**):所有文件 IO 走 `asyncio.to_thread` 不阻塞事件循环;`GET /scan`(+`/scan/stream`)、`POST /preview`、`POST /apply`(+`/apply/stream`)、`GET /backups`、`POST /rollback/{id}`;**子串安全替换**(基于 token span,不会误伤子串)、`expected_total` 乐观锁(409)、写前自动磁盘备份到 `{skills_root}/.address-edits/`(同时写一份 `{id}.manifest.json` 记录本次替换明细,供修改记录展示)、应用后刷新技能系统提示缓存。纯引擎 `deerflow/skills/address_scan.py`,router `app/gateway/routers/skill_addresses.py`,tests `tests/test_skill_addresses.py`。范围仅运行中技能,不动 `deploy/minimal/skills/`。
- **按钮管理 (Task Button Management, admin-only)**: configures the **可配置跳转按钮** rendered in the left task panel of the task deep-link workspaces (rwfx / 3qfx / xdfx, see `TaskFxWorkspace`). Each button = `{business, label, linkType, target, appendTaskId, idParam, idKind, enabled, sortOrder}`; `linkType` is `url` (外链 `window.open`, optional append of `?{idParam}={id}` — **`idParam` is the editable query-param name**, e.g. `id`/`task_id`), `business` (内部切换到另一业务, reuses `resolveTaskDeeplinkTarget`, always task id), or `purpose` (弹出当前任务的「任务目的」表格 via the shared `TaskPurposeDialog`, no target — configurable for any page incl. xdfx). **`idKind` (`task`/`action`) only matters for xdfx**: its deep-link `?taskId=` is an 行动id and the 任务id is resolved at login (`fetchTaskIdFromAction`) — `TaskContext` now also stashes the raw `actionId`, so a url button can append either (default `task`). Page `TaskButtonsPage.tsx` (route `/page/strategy/admin/task-buttons`, **admin-gated**, entry「按钮管理」in the bottom-left settings dropdown of `components/page-sidebar.tsx`, both full + collapsed modes) has per-business tabs, edits name/type/target/启停/排序/增删, saves the **whole set atomically** (replace-all), plus 「导入默认」 to merge code-seed defaults. **Seed/fallback** (like 敏感词管理): only when the whole table is empty (fresh deploy, never saved) do the workspace + page fall back to `defaultTaskButtons(cfg)` — rwfx's url buttons (编辑/下一步/3q详情) built from the **live** `/api/public/config/task-deeplink` plus cross-business switch buttons for **all three** businesses (each switches to the other two — rwfx→三情/行动, 3qfx→任务/行动, xdfx→任务/三情). Once saved, the store is the sole source of truth. The old hardcoded jump buttons were migrated out: `TaskFxWorkspace` no longer hardcodes 切换到三情分析, `TaskActionsPanel` keeps only the non-jump 清除记录, and `TaskPurposePanel` dropped its 3q详情 button (now configurable) **and defaults the 目的树 table to collapsed** (chevron toggle). Helpers `core/auth/task-buttons.ts` (`TaskButton`, `defaultTaskButtons`, `resolveTaskButtons`, `buttonsForBusiness`, `runTaskButton`), client+hooks `strategy-components/api/task-buttons.ts`. Backend: `/api/task-buttons` (global/shared; open read, admin replace-all), store `deerflow.persistence.task_buttons` (table `task_buttons`, auto-created by `create_all`), wired `app.state.task_button_store`. Tests: `tests/test_task_buttons.py`.
- **按钮管理 (Task Button Management, admin-only)**: configures the **可配置跳转按钮** rendered in the left task panel of the task deep-link workspaces (rwfx / 3qfx / xdfx, see `TaskFxWorkspace`). Each button = `{business, label, linkType, target, appendTaskId, idParam, idKind, enabled, sortOrder}`; `linkType` is `url` (外链 `window.open`, optional append of `?{idParam}={id}` — **`idParam` is the editable query-param name**, e.g. `id`/`task_id`), `business` (内部切换到另一业务, reuses `resolveTaskDeeplinkTarget`, always task id), or `purpose` (弹出当前任务的「任务目的」表格 via the shared `TaskPurposeDialog`, no target — configurable for any page incl. xdfx). **`idKind` (`task`/`action`) only matters for xdfx**: its deep-link `?taskId=` is an 行动id and the 任务id is resolved at login (`fetchTaskIdFromAction`) — `TaskContext` now also stashes the raw `actionId`, so a url button can append either (default `task`). Page `TaskButtonsPage.tsx` (route `/page/strategy/admin/task-buttons`, **admin-gated**, entry「按钮管理」in the bottom-left settings dropdown of `components/page-sidebar.tsx`, both full + collapsed modes) has per-business tabs, edits name/type/target/启停/排序/增删, saves the **whole set atomically** (replace-all), plus 「导入默认」 to merge code-seed defaults. **Seed/fallback** (like 敏感词管理): only when the whole table is empty (fresh deploy, never saved) do the workspace + page fall back to `defaultTaskButtons(cfg)` — rwfx's url buttons (编辑/上一步/下一步/3q详情) built from the **live** `/api/public/config/task-deeplink` plus cross-business switch buttons for **all three** businesses (each switches to the other two — rwfx→三情/行动, 3qfx→任务/行动, xdfx→任务/三情). Once saved, the store is the sole source of truth. **Bottom 快捷跳转 bar groups by prev/next**: `TaskJumpBar` renders only「上一步/下一步」-type buttons in the bottom bar — a `prev` group (id 含 `prev` 或文案「上一步」, 描边次要样式 + ←图标) rendered **左侧**, then a `next` group (id 含 `next` 或文案「下一步」, 实心主色) on the **右侧** (container `justify-end`); everything else goes to the right-edge floating menu. The default 上一步 button (`rwfx-prev`, `url`, target = `DASHBOARD_AGENT_ROUTE` `/page/strategy/dashboard-agent`, `appendTaskId` + `idParam=taskId` → `?taskId=任务id`, `openMode=self`) 默认指向**应用内**「大屏绘制」页,在 **both** 单智能体 (`TaskFxWorkspace`) 与 多智能体会商 (`RoundtablePlanningPage` 深链 rwfx-A) 都显示(共用 `TaskJumpBar`)。**`runTaskButton` 的 `url` 类型支持应用内路由**:target 以单个 `/` 开头时按内部路由处理——`self` 走 react-router 同窗导航(不刷新),`blank` 拼成当前源的 hash 路由新标签打开;外链行为不变。 The old hardcoded jump buttons were migrated out: `TaskFxWorkspace` no longer hardcodes 切换到三情分析, `TaskActionsPanel` keeps only the non-jump 清除记录, and `TaskPurposePanel` dropped its 3q详情 button (now configurable) **and defaults the 目的树 table to collapsed** (chevron toggle). Helpers `core/auth/task-buttons.ts` (`TaskButton`, `defaultTaskButtons`, `resolveTaskButtons`, `buttonsForBusiness`, `runTaskButton`), client+hooks `strategy-components/api/task-buttons.ts`. Backend: `/api/task-buttons` (global/shared; open read, admin replace-all), store `deerflow.persistence.task_buttons` (table `task_buttons`, auto-created by `create_all`), wired `app.state.task_button_store`. Tests: `tests/test_task_buttons.py`.
- **State**: TanStack Query for server state, context providers (`SubtasksProvider`, `ArtifactsProvider`, `PromptInputProvider`) scoped to `WorkspaceRoutes`
- **LLM streaming**: `@langchain/langgraph-sdk` streams messages from the backend via SSE
- **UI**: Radix UI primitives + TailwindCSS v4 + shadcn-style components in `src/components/ui/`
+40 -8
View File
@@ -7,7 +7,8 @@
* 任务深链配置读出 rwfx 的 4 个历史按钮,3qfx/xdfx 为空)——这样真实跳转地址通过实时
* config 进种子,避免写死;一旦保存过,后端即唯一数据源。
*
* 按钮两类(``linkType``):``url`` 外链(``window.open``,可选追加 ``?id=任务id``)/
* 按钮两类(``linkType``):``url``(外链 ``window.open``;**或应用内路由**——target 以 ``/`` 开头
* 时按内部路由处理:``self`` 走 react-router 同窗导航、``blank`` 拼 hash 路由新标签;可选追加 ``?{idParam}=id``)/
* ``business`` 内部切换到另一业务(复用 [[task-deeplink]] 的 ``resolveTaskDeeplinkTarget``)。
*/
@@ -20,6 +21,9 @@ import {
resolveTaskDeeplinkTarget,
} from "@/core/auth/task-deeplink";
/** 「大屏绘制」独立页应用内路由(深链「上一步」默认指向它,跳转追加 ?taskId=)。 */
export const DASHBOARD_AGENT_ROUTE = "/page/strategy/dashboard-agent";
/** 任务工作区业务码(与路由 taskBase 一一对应)。 */
export type TaskBusiness = "rwfx" | "3qfx" | "xdfx";
@@ -94,7 +98,8 @@ export const TASK_BUSINESS_TARGETS: { code: TaskBusiness; label: string }[] = [
/**
* 代码种子默认按钮(仅在后端整张表为空时使用):
* - rwfx:历史 4 个按钮(编辑/下一步/3q详情,地址取自实时任务深链配置)+ 切换到三情/行动分析;
* - rwfx:历史按钮(编辑/下一步/3q详情,地址取自实时任务深链配置)+「上一步」+ 切换到三情/行动分析;
* 「上一步」紧邻「下一步」左侧(底部栏 prev 组在 next 组前),默认指向应用内大屏绘制页(?taskId=任务id);
* - 3qfx / xdfx:互相切换到另外两个业务。
* 即「三个业务都带切换按钮」。已保存过则用页面「导入默认」把缺失项补进来。
*/
@@ -147,6 +152,22 @@ export function defaultTaskButtons(cfg: TaskDeeplinkConfig): TaskButton[] {
enabled: true,
sortOrder: 0,
},
{
// 上一步:放在「下一步」左边(底部快捷跳转栏,prev 组渲染在 next 组之前)。
// 默认指向**应用内**「大屏绘制」页 /page/strategy/dashboard-agent(内部路由,以 / 开头),
// 携带 ?taskId=任务id(大屏页按 taskId 取数);openMode=self → 同窗 react-router 导航。
id: "rwfx-prev",
business: "rwfx",
label: "上一步",
linkType: "url",
target: DASHBOARD_AGENT_ROUTE,
appendTaskId: true,
idParam: "taskId",
idKind: "task",
openMode: "self",
enabled: true,
sortOrder: 1,
},
{
id: "rwfx-next",
business: "rwfx",
@@ -158,7 +179,7 @@ export function defaultTaskButtons(cfg: TaskDeeplinkConfig): TaskButton[] {
idKind: "task",
openMode: "blank",
enabled: true,
sortOrder: 1,
sortOrder: 2,
},
{
id: "rwfx-detail3q",
@@ -171,11 +192,11 @@ export function defaultTaskButtons(cfg: TaskDeeplinkConfig): TaskButton[] {
idKind: "task",
openMode: "blank",
enabled: true,
sortOrder: 2,
sortOrder: 3,
},
sw("rwfx-switch3q", "rwfx", "切换到三情分析", "3qfx", 3),
sw("rwfx-switchxd", "rwfx", "切换到行动分析", "xdfx", 4),
pz("rwfx-purpose", "rwfx", 5),
sw("rwfx-switch3q", "rwfx", "切换到三情分析", "3qfx", 4),
sw("rwfx-switchxd", "rwfx", "切换到行动分析", "xdfx", 5),
pz("rwfx-purpose", "rwfx", 6),
// 3qfx(三情分析):切换到任务/行动分析
sw("3qfx-switchrw", "3qfx", "切换到任务分析", "rwfx", 0),
sw("3qfx-switchxd", "3qfx", "切换到行动分析", "xdfx", 1),
@@ -237,7 +258,18 @@ export async function runTaskButton(btn: TaskButton, ctx: RunTaskButtonCtx): Pro
const id = btn.idKind === "action" ? ctx.actionId || ctx.taskId : ctx.taskId;
const param = (btn.idParam || "id").trim() || "id";
const url = btn.appendTaskId && id ? appendQueryParam(base, param, id) : base;
// 打开方式:self=当前窗口(嵌入态即当前 iframe);否则 blank=新窗口(默认)。
// 应用内部路由(以单个 / 开头,如「上一步」→ 大屏绘制页):
// self → 走 react-router 同窗导航(不刷新、保留 SPA);blank → 拼成当前源的 hash 路由新标签打开。
if (base.startsWith("/") && !base.startsWith("//")) {
if (btn.openMode === "blank") {
const full = `${window.location.origin}${window.location.pathname}#${url}`;
window.open(full, "_blank", "noopener,noreferrer");
} else {
ctx.navigate(url);
}
return;
}
// 外链:self=当前窗口(嵌入态即当前 iframe);否则 blank=新窗口(默认)。
if (btn.openMode === "self") {
window.location.assign(url);
} else {
@@ -3,6 +3,7 @@ import { getBackendBaseURL } from "@/core/config";
import type {
HtmlPageFavorite,
HtmlTemplateOption,
PublishedScheduledTask,
ScheduledTask,
ScheduledTaskDeliveryProfile,
@@ -32,6 +33,14 @@ export async function getSchedulerThread() {
return parseResponse<SchedulerThread>(await apiFetch(`${baseUrl()}/thread`));
}
/** 可选的 HTML 模板清单(模板数据转换器填充用)。 */
export async function listHtmlTemplates() {
const data = await parseResponse<{ templates: HtmlTemplateOption[] }>(
await apiFetch(`${baseUrl()}/html-templates`),
);
return data.templates ?? [];
}
export interface ListScheduledTasksParams {
search?: string;
tagIds?: string[];
@@ -16,6 +16,7 @@ import {
deleteScheduledTask,
deleteScheduledTaskRun,
getSchedulerThread,
listHtmlTemplates,
getScheduledTaskDeliveryProfile,
getScheduledTaskRun,
getScheduledTaskRunHtml,
@@ -45,6 +46,17 @@ export function useSchedulerThread() {
});
}
/** 可选的 HTML 模板清单(模板数据转换器填充用)。模板是后端静态注册,长缓存即可。 */
export function useHtmlTemplates(enabled = true) {
return useQuery({
queryKey: ["scheduled-tasks", "html-templates"],
queryFn: listHtmlTemplates,
enabled,
staleTime: 5 * 60_000,
refetchOnWindowFocus: false,
});
}
/** A task is currently executing — used to drive live polling of the task list. */
function taskIsLive(status?: string | null): boolean {
return status === "running" || status === "pending";
@@ -24,8 +24,27 @@ export type HtmlPageConfig = {
model_name?: string | null;
};
/** 选中内置「模板数据转换器」agent 后,要把源 JSON 填充进的 HTML 模板。
* 持久化在 ``execution_context.template_config`` 里(无独立列)。 */
export type TemplateFillConfig = {
template_id?: string | null;
/** 可选:覆盖转换所用模型(留空走 agent.model / 系统默认)。 */
model_name?: string | null;
};
/** ``GET /api/scheduled-tasks/html-templates`` 返回的一条模板选项。 */
export type HtmlTemplateOption = {
id: string;
name: string;
description?: string | null;
/** false = 暂只「直接展示已有数据」,不需要也不会用源 JSON 转换填充。 */
convertible?: boolean;
};
export type ScheduledTaskExecutionContext = {
require_markdown?: boolean;
/** 模板填充模式:选中「模板数据转换器」+ 选模板后填充进固定 HTML 模板。 */
template_config?: TemplateFillConfig;
/**
* The unified "必须生成 HTML" switch. The task form no longer splits "普通任务 /
* HTML 页面" into separate kinds — instead ``require_html`` and
+169 -40
View File
@@ -1,6 +1,6 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { type ChangeEvent, useEffect, useMemo, useRef, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import {
AlertCircle,
@@ -21,6 +21,7 @@ import {
RefreshCw,
TagsIcon,
Trash2,
Upload,
X,
} from "lucide-react";
import { useSearchParams } from "react-router-dom";
@@ -54,6 +55,7 @@ import {
type ScheduledTaskExecutionContext,
useHtmlPageFavoriteActions,
useHtmlPageFavorites,
useHtmlTemplates,
useScheduledTaskActions,
useScheduledTaskDeliveryProfile,
useScheduledTaskRuns,
@@ -107,6 +109,9 @@ function displayUsername(value?: string | null): string {
return at === -1 ? raw : raw.slice(0, at);
}
/** 内置「模板数据转换器」agent 的稳定 id(后端 seed)。选中它时表单露出 HTML 模板下拉。 */
const TEMPLATE_BUILDER_AGENT_ID = "template-json-builder";
const emptyForm = {
name: "",
description: "",
@@ -114,6 +119,8 @@ const emptyForm = {
timezone: "Asia/Shanghai",
prompt: "",
execution_agent_name: "",
// 模板填充模式:选中「模板数据转换器」后要把源 JSON 填进哪个 HTML 模板。
template_id: "",
// Two independent output switches (may both be on): require a Markdown file
// and/or render an HTML page. The form no longer splits into "普通任务 / HTML
// 页面" tabs.
@@ -172,6 +179,25 @@ export default function ScheduledTasksPage() {
const [editing, setEditing] = useState<ScheduledTask | null>(null);
const [creating, setCreating] = useState(false);
const [form, setForm] = useState(emptyForm);
// 模板填充模式的 .json 上传:读入文件文本填进 prompt 框(不改后端契约)。
const jsonUploadRef = useRef<HTMLInputElement | null>(null);
const handleJsonUpload = async (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
event.target.value = ""; // 允许重复上传同名文件
if (!file) return;
try {
const text = await file.text();
// 校验是 JSON,给即时反馈;非法也照样填入让用户修正。
try {
JSON.parse(text);
} catch {
toast.error("文件不是合法 JSON,已填入文本框请检查", darkToastOpts.error);
}
setForm((value) => ({ ...value, prompt: text }));
} catch {
toast.error("读取文件失败", darkToastOpts.error);
}
};
const dialogOpen = creating || !!editing;
const { data: htmlFavorites = [] } = useHtmlPageFavorites(dialogOpen);
const htmlFavoriteActions = useHtmlPageFavoriteActions();
@@ -285,6 +311,7 @@ export default function ScheduledTasksPage() {
// stored that under ``html_config.page_prompt``, so prefer it on edit.
prompt: html.page_prompt || task.prompt,
execution_agent_name: task.execution_agent_name ?? "",
template_id: task.execution_context?.template_config?.template_id ?? "",
require_markdown: !!task.execution_context?.require_markdown,
// Backward compatible: legacy HTML tasks only carry ``task_kind``.
require_html:
@@ -311,7 +338,15 @@ export default function ScheduledTasksPage() {
setScheduleConfig(DEFAULT_SCHEDULE_CONFIG);
};
const isHtmlTask = form.require_html;
// 模板填充模式:选中内置「模板数据转换器」agent。此时表单露出 HTML 模板下拉,
// 并隐藏「必须生成 HTML/Markdown」开关(本模式必产出 HTML)。
const isTemplateAgent = form.execution_agent_name === TEMPLATE_BUILDER_AGENT_ID;
const { data: htmlTemplates = [] } = useHtmlTemplates(isTemplateAgent);
const selectedTemplate = htmlTemplates.find((t) => t.id === form.template_id);
// 选中的模板是「只展示」型(convertible===false)时,不需要源 JSON,隐藏上传/输入。
const templateDisplayOnly =
isTemplateAgent && selectedTemplate?.convertible === false;
const isHtmlTask = !isTemplateAgent && form.require_html;
// The model actually shown/used: the user's choice, else the first configured
// model. Never a hardcoded GLM-5 — it follows config order and the selection.
const effectiveHtmlModel = form.html_model_name || availableModels[0]?.name || "";
@@ -322,8 +357,22 @@ export default function ScheduledTasksPage() {
// as the page requirement; ``task_kind`` is kept in sync only for backward
// compatibility with older readers.
const buildExecutionContext = (): ScheduledTaskExecutionContext => {
// 模板填充模式:只带 template_config,后端据此走「转换 JSON → 填充模板」分支。
if (isTemplateAgent && form.template_id) {
return {
template_config: { template_id: form.template_id },
require_html: false,
task_kind: "agent",
html_config: undefined,
};
}
if (!isHtmlTask) {
return { require_html: false, task_kind: "agent", html_config: undefined };
return {
require_html: false,
task_kind: "agent",
html_config: undefined,
template_config: undefined,
};
}
return {
require_html: true,
@@ -1110,46 +1159,126 @@ export default function ScheduledTasksPage() {
))}
</SelectContent>
</Select>
<Textarea
className="min-h-40"
value={form.prompt}
onChange={(event) =>
setForm((value) => ({ ...value, prompt: event.target.value }))
}
placeholder={
isHtmlTask
? "任务 / 页面生成要求,例如:检索新能源车最新舆情,生成适合领导快速浏览的态势页"
: "任务 Prompt"
}
/>
<div className="flex items-center justify-between rounded-md border p-3">
<div className="min-w-0 pr-3">
<p className="text-sm font-medium">必须生成 Markdown 文件</p>
<p className="text-muted-foreground text-xs">
开启后会提示大模型生成 Markdown(.md) 文件;系统会校验本次是否生成了 .md 文件且文件正文超过 100 字,未达到则记为失败。
</p>
{isTemplateAgent && (
<div className="flex flex-col gap-2 rounded-md border border-primary/30 bg-primary/5 p-3">
<div className="flex flex-col gap-1">
<p className="text-sm font-medium">HTML 模板</p>
<p className="text-muted-foreground text-xs">
{templateDisplayOnly ? (
<>选择要展示的 HTML 模板。该模板暂为「直接展示已有数据」,执行时将原样输出其现有页面。</>
) : (
<>
选择要把源数据填充进去的 HTML 模板。执行时智能体会把下方源 JSON 转换成该模板所需的结构,校验后填充生成可预览的页面。
<span className="text-foreground/70">未提供源 JSON 时,将直接展示该模板的默认数据。</span>
</>
)}
</p>
</div>
<Select
value={form.template_id || undefined}
onValueChange={(v) =>
setForm((value) => ({ ...value, template_id: v }))
}
>
<SelectTrigger className="w-full min-w-0 max-w-full overflow-hidden [&>span]:min-w-0 [&>span]:flex-1 [&>span]:truncate">
<SelectValue placeholder="选择 HTML 模板" />
</SelectTrigger>
<SelectContent
position="popper"
className="max-h-[40vh] w-[max(var(--radix-select-trigger-width),28rem)] max-w-[min(90vw,40rem)]"
>
{htmlTemplates.map((tpl) => (
<SelectItem key={tpl.id} value={tpl.id}>
<span className="block whitespace-normal break-words">
{tpl.name}
{tpl.description ? ` — ${tpl.description}` : ""}
</span>
</SelectItem>
))}
{htmlTemplates.length === 0 && (
<div className="text-muted-foreground px-2 py-1.5 text-xs">
暂无可用模板
</div>
)}
</SelectContent>
</Select>
{templateDisplayOnly ? (
<p className="text-muted-foreground text-xs">
该模板暂为「直接展示已有数据」,无需填写源 JSON;执行时将原样输出模板的现有页面。
</p>
) : (
<div className="flex items-center gap-2">
<input
ref={jsonUploadRef}
type="file"
accept=".json,application/json"
className="hidden"
onChange={handleJsonUpload}
/>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => jsonUploadRef.current?.click()}
>
<Upload className="mr-1 size-4" />
上传 .json 文件
</Button>
<span className="text-muted-foreground text-xs">
也可直接在下方粘贴源 JSON(上传会填入下方文本框)
</span>
</div>
)}
</div>
<Switch
checked={form.require_markdown}
onCheckedChange={(v) =>
setForm((value) => ({ ...value, require_markdown: v }))
)}
{!templateDisplayOnly && (
<Textarea
className="min-h-40"
value={form.prompt}
onChange={(event) =>
setForm((value) => ({ ...value, prompt: event.target.value }))
}
placeholder={
isTemplateAgent
? "源 JSON 数据(粘贴或上传 .json),将被智能体转换并填充进所选 HTML 模板;留空则展示模板默认数据"
: isHtmlTask
? "任务 / 页面生成要求,例如:检索新能源车最新舆情,生成适合领导快速浏览的态势页"
: "任务 Prompt"
}
/>
</div>
<div className="flex items-center justify-between rounded-md border p-3">
<div className="min-w-0 pr-3">
<p className="text-sm font-medium">必须生成 HTML 页面</p>
<p className="text-muted-foreground text-xs">
开启后会先用智能体检索整理真实数据,再渲染为完整 HTML 页面(可在执行记录中预览)。可与“必须生成 Markdown”同时开启。
</p>
</div>
<Switch
checked={form.require_html}
onCheckedChange={(v) =>
setForm((value) => ({ ...value, require_html: v }))
}
/>
</div>
)}
{!isTemplateAgent && (
<>
<div className="flex items-center justify-between rounded-md border p-3">
<div className="min-w-0 pr-3">
<p className="text-sm font-medium">必须生成 Markdown 文件</p>
<p className="text-muted-foreground text-xs">
开启后会提示大模型生成 Markdown(.md) 文件;系统会校验本次是否生成了 .md 文件且文件正文超过 100 字,未达到则记为失败。
</p>
</div>
<Switch
checked={form.require_markdown}
onCheckedChange={(v) =>
setForm((value) => ({ ...value, require_markdown: v }))
}
/>
</div>
<div className="flex items-center justify-between rounded-md border p-3">
<div className="min-w-0 pr-3">
<p className="text-sm font-medium">必须生成 HTML 页面</p>
<p className="text-muted-foreground text-xs">
开启后会先用智能体检索整理真实数据,再渲染为完整 HTML 页面(可在执行记录中预览)。可与“必须生成 Markdown”同时开启。
</p>
</div>
<Switch
checked={form.require_html}
onCheckedChange={(v) =>
setForm((value) => ({ ...value, require_html: v }))
}
/>
</div>
</>
)}
{isHtmlTask && (
<>
@@ -10,6 +10,7 @@
import { useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import {
ArrowLeft,
ArrowRight,
ExternalLink,
FileSearch,
@@ -95,16 +96,27 @@ export function TaskJumpBar({
});
};
const nextButtons = jumpButtons.filter(isNextTaskButton);
const menuButtons = jumpButtons.filter((btn) => !isNextTaskButton(btn));
// 底部栏只放「上一步 / 下一步」:prev 组渲染在 next 组之前 → 视觉上「上一步」在「下一步」左边
// (容器 justify-end 右对齐,数组先后即左右先后)。其余按钮进右侧悬浮菜单。
const prevButtons = jumpButtons.filter(isPrevTaskButton);
const nextButtons = jumpButtons.filter((btn) => isNextTaskButton(btn) && !isPrevTaskButton(btn));
const barButtons = [...prevButtons, ...nextButtons];
const menuButtons = jumpButtons.filter(
(btn) => !isNextTaskButton(btn) && !isPrevTaskButton(btn),
);
return (
<>
{menuButtons.length > 0 && <TaskSideJumpMenu buttons={menuButtons} onRun={handleRun} />}
{nextButtons.length > 0 && (
{barButtons.length > 0 && (
<div className="border-border/70 bg-background/90 flex shrink-0 flex-wrap items-center justify-end gap-3 border-t px-5 py-3 backdrop-blur-xl dark:border-[#1b477e]/70 dark:bg-[#061b35]/85">
{nextButtons.map((btn) => (
<TaskJumpButton key={btn.id} button={btn} onRun={handleRun} />
{barButtons.map((btn) => (
<TaskJumpButton
key={btn.id}
button={btn}
onRun={handleRun}
variant={isPrevTaskButton(btn) ? "secondary" : "primary"}
/>
))}
</div>
)}
@@ -122,21 +134,35 @@ function isNextTaskButton(btn: TaskButton) {
return btn.id.toLowerCase().includes("next") || btn.label.trim() === "下一步";
}
/** 「上一步」按钮:id 含 prev 或文案为「上一步」——底部栏里渲染到「下一步」左边。 */
function isPrevTaskButton(btn: TaskButton) {
return btn.id.toLowerCase().includes("prev") || btn.label.trim() === "上一步";
}
function TaskJumpButton({
button,
onRun,
variant = "primary",
}: {
button: TaskButton;
onRun: (button: TaskButton) => void;
/** primary=下一步(实心主色)/ secondary=上一步(描边次要,置于下一步左侧)。 */
variant?: "primary" | "secondary";
}) {
const styles =
variant === "secondary"
? "border-[var(--color-main)]/40 bg-transparent text-[var(--color-main)] hover:bg-[var(--color-main)]/10 dark:text-[#7eb6ff] dark:border-[#7eb6ff]/40 dark:hover:bg-[#7eb6ff]/10"
: "border-transparent bg-[var(--color-main)] text-white shadow-sm hover:bg-[var(--brand-950)]";
return (
<Button
className="h-11 gap-2 border-transparent bg-[var(--color-main)] px-6 text-base text-white shadow-sm hover:bg-[var(--brand-950)]"
variant={variant === "secondary" ? "outline" : "default"}
className={cn("h-11 gap-2 px-6 text-base", styles)}
onClick={() => onRun(button)}
title={desensitize(button.label)}
>
{variant === "secondary" && <ArrowLeft className="h-5 w-5 shrink-0" />}
<span className="truncate">{desensitize(button.label)}</span>
{renderTaskJumpIcon(button, "h-5 w-5 shrink-0")}
{variant === "primary" && renderTaskJumpIcon(button, "h-5 w-5 shrink-0")}
</Button>
);
}
@@ -300,6 +300,8 @@ Two on-demand/automatic refresh paths layer on top:
**Unified output switches (`require_html` + `require_markdown`).** The task form no longer splits "???? / HTML ??" into separate kinds. `execution_context` now carries two **independent** booleans that may both be on: `require_html` (???? HTML) and `require_markdown` (???? Markdown). `_task_requires_html()` returns true for `require_html` **or** legacy `task_kind == "html_page"` (so old tasks need no data migration), and `_run_task_attempt()` dispatches to `_run_html_page_attempt` when it is true, else the regular agent attempt. When **both** switches are on, `_gather_html_data()` returns `(text, files)` ? the gather agent run is additionally told to save a `.md` file ? and `_run_html_page_attempt` validates it (`MarkdownRequirementError` on failure) and surfaces it alongside the generated page, so the run delivers both artifacts. The single main `prompt` doubles as the HTML page requirement (`html_config.page_prompt`); there is no separate page-prompt field. Tests: `tests/test_scheduled_task_require_html.py` + the markdown-coexistence cases in `tests/test_html_page_tasks.py`.
**Template-fill mode (JSON → 固定 HTML 模板填充).** A third execution mode, distinct from the free-form `require_html` render pipeline: convert a user-supplied JSON into a **fixed** HTML template's data structure and fill it. Triggered when `execution_context.template_config.template_id` is set (chosen in the task form after selecting the built-in **「模板数据转换器」** agent `template-json-builder`). `_task_template_id()` reads it and `_run_task_attempt()` dispatches to `_run_template_fill_attempt` **before** the `require_html` check. Pipeline: take source JSON (the task `prompt`, pasted or uploaded .json) → load the selected agent's `SOUL.md`+model and run a **direct** `_invoke_conversion_model` call (system=SOUL, human=conversion prompt carrying the template's compact **schema skeleton** + source) → `extract_json_object` → `template_fill.validate_data` (top-level keys + container types) → **on failure feed the structure errors + `field_coverage_gaps` back and retry up to `_MAX_TEMPLATE_FILL_RETRIES`=2** → `template_fill.fill_template` replaces the template's `const DATA={…}` line (and refreshes `MANIFEST.系统.更新时间`) → archive `index.html` + persist a `scheduled_html_pages` row → return `format:"html"` (reusing the existing HTML preview at `/runs/{id}/html`). Any terminal failure raises `TemplateFillError` → run marked failed (no retry, message shown). **Pure engine** `deerflow/runtime/scheduler/template_fill.py` (harness, no `app.*`): the template registry (`TEMPLATES`: id=`no1` name=`no.1` 台湾滨海防卫大屏, and id=`no2` name=`no.2` 台湾飞行部队平台 with **`convertible=False`** — a 「只展示」 template that renders its existing HTML **as-is**, no model/validation/fill, until conversion is wired in later; `_run_template_fill_attempt` short-circuits to `read_template_text` for it, and `list_templates`/`is_convertible` expose the flag so the form hides the JSON input), `list_templates`/`reference_data`/`schema_skeleton_json`/`validate_data`/`field_coverage_gaps`/`fill_template` — **the template's own embedded `DATA` is the schema source of truth** (parsed at load, 1-sample-per-array skeleton fed to the LLM, top-level structure as the validation basis); template HTML files live under `runtime/scheduler/templates/<id>/`, add a `TemplateSpec` to register a no.2. The agent is seeded like the AI-writing ones: `app/gateway/routers/_template_builder_seed.py` + `_template_builder_seed_assets/template-json-builder/{config.yaml,SOUL.md}`, called from the lifespan before `_sync_legacy_agents` (which upserts it into the `agents` table as built-in/published so it shows in `/api/agents` → the task form's 执行智能体 dropdown). Template list API: `GET /api/scheduled-tasks/html-templates` → `{templates:[{id,name,description}]}` (open). Frontend: when the agent dropdown = `template-json-builder` the form hides the HTML/Markdown switches and shows a 「HTML模板」 select (`useHtmlTemplates`) + a .json upload (reads file text into the prompt box). Tests: `tests/test_template_fill.py`, `tests/test_template_fill_attempt.py`, `tests/test_template_builder_seed.py`.
### Per-user Prompts & UI Preferences
**Custom prompts can be scoped to one agent.** `user_custom_prompts` gains a nullable `agent_id` column (migration `20260603_02`): `NULL` = global (the chip shows in every chat), otherwise the prompt only surfaces when that agent is selected. The visibility filter is applied in the frontend `CustomPromptBar` (it receives the current agent id from `ChatPage`'s selector or `AgentChatPage`'s `assistantId`); the backend (`/api/user-prompts` CRUD) just stores/returns/clears `agent_id` (update accepts an explicit `null` to clear). Tests: `tests/test_user_prompts_agent_id.py`.
@@ -960,6 +960,20 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
"AI-writing functional agent seeding failed at startup (non-fatal)"
)
# 内置「模板数据转换器」agent(JSON → HTML 模板填充)。同样必须在
# _sync_legacy_agents **之前**补建目录,后者把它 upsert 进 agents 表,
# 第一次启动 /api/agents 与定时任务下拉框里就有它。
try:
from app.gateway.routers._template_builder_seed import (
ensure_template_builder_agent,
)
ensure_template_builder_agent()
except Exception:
logger.exception(
"Template-builder agent seeding failed at startup (non-fatal)"
)
# ?????????????????3Q/6BF/7BF/8BF??agent/chain ?????
try:
business_mapping_store = getattr(app.state, "business_mapping_store", None)
@@ -0,0 +1,63 @@
"""内置「模板数据转换器」agent 的「缺失自动补建」工具。
``template-json-builder`` 是定时任务「JSON → HTML 模板填充」功能的执行大脑:当用户在
定时任务表单里选中它、再选一个 HTML 模板时,到点执行会用它的 ``SOUL.md`` 把源 JSON
智能映射成模板需要的 ``DATA`` 结构(详见
:mod:`deerflow.runtime.scheduler.template_fill` 与调度器 ``service.py``)。
与 ``_ai_writing_seed.py`` / ``_roundtable_seed.py`` 完全同构:验证版 ``SOUL.md`` +
``config.yaml`` 随 git 发布于 ``_template_builder_seed_assets/<id>/``,seeder 只在
``.deer-flow/agents/<id>/`` 目录**缺失**时字节级复制(绝不覆盖线上手改)。lifespan 在
``_sync_legacy_agents`` 之前调用本 seeder,后者扫描 agents 目录把它 upsert 进 agents 表
(``user_id IS NULL`` 内置、``published=True``),于是 ``/api/agents`` 列表、进而定时任务
「执行智能体」下拉框里第一次启动就有它,admin 也能在智能体管理页编辑 SOUL/模型。
"""
from __future__ import annotations
import logging
from pathlib import Path
from deerflow.config.paths import get_paths
logger = logging.getLogger(__name__)
TEMPLATE_BUILDER_AGENT_ID = "template-json-builder"
# 验证版 SOUL.md + config.yaml 的源码资源目录(随 git 发布)。子目录名即 agent id。
_ASSETS_DIR = Path(__file__).parent / "_template_builder_seed_assets"
# seeder 复制的文件(存在即复制,缺失则跳过)。
_SEED_FILES: tuple[str, ...] = ("config.yaml", "SOUL.md")
def ensure_template_builder_agent() -> bool:
"""若 ``.deer-flow/agents/template-json-builder/`` 缺失,从资源补建。
返回 True 表示本次刚创建;目录已存在(尊重本地手改)或资源缺失则返回 False。
错误只记日志、绝不抛出——seeding 失败不能拖垮启动。
"""
try:
agent_dir = get_paths().agent_dir(TEMPLATE_BUILDER_AGENT_ID)
if agent_dir.exists():
return False
src_dir = _ASSETS_DIR / TEMPLATE_BUILDER_AGENT_ID
if not src_dir.is_dir():
logger.error(
"Missing seed assets for agent '%s' (expected at %s)",
TEMPLATE_BUILDER_AGENT_ID,
src_dir,
)
return False
agent_dir.mkdir(parents=True, exist_ok=True)
for fname in _SEED_FILES:
src = src_dir / fname
if src.exists():
(agent_dir / fname).write_bytes(src.read_bytes())
logger.info("Seeded built-in agent '%s'", TEMPLATE_BUILDER_AGENT_ID)
return True
except Exception:
logger.exception("Failed to seed built-in agent '%s'", TEMPLATE_BUILDER_AGENT_ID)
return False
@@ -0,0 +1,30 @@
# 模板数据转换器
你是一名严谨的「JSON 结构转换专家」。你的唯一职责是:把用户传入的**源 JSON**,
精确映射成给定**目标结构**所要求的 JSON,供后续填充进 HTML 模板渲染成数据大屏。
## 你会收到
1. **目标结构骨架**:一份 JSON,展示了目标格式的**完整字段与类型**(每个数组只给 1 个
样例元素,但该元素已列出全部字段)。这是你必须严格对齐的契约。
2. **源 JSON**:用户提供的原始数据,结构任意、字段名可能与目标不同、可能有多有少。
3. (重试时) **上一轮的校验错误 / 缺失字段清单**:你必须据此修正后重新输出。
## 转换规则
- **顶层键必须齐全且类型一致**:目标结构里的每一个顶层键(及其数组/对象类型)都必须
出现在你的输出里,一个都不能少。
- **逐字段映射,尽力填满**:把源数据里语义对应的内容填进目标字段;字段名不同就按含义
对应(如源 `name`→目标 `名称`)。目标数组的每个元素都要尽量带齐目标样例里出现的全部字段。
- **缺数据时给出合理空值**:源里确实没有的字段,按其类型填空字符串 `""`、空数组 `[]`、
`null` 或合理占位,**绝不删除该字段**,也不要伪造与源无关的事实性内容。
- **保持数据真实**:只搬运/重组源数据,不臆造统计数字与结论。`overview` 里的计数类字段
应与各列表的实际条数自洽。
- **不要输出模板骨架本身**:目标结构骨架只是格式范例,不要把它的样例值原样抄回。
## 输出要求(必须严格遵守)
- **只输出一个 ` ```json ` 代码块**,块内是填充好的完整目标 JSON,**不要任何解释性文字、
前后缀、注释**。
- 输出必须是**合法 JSON**(双引号、无尾逗号、无注释),可被 `json.loads` 直接解析。
- 不要使用工具,不要分多步,直接给出最终 JSON。
@@ -0,0 +1,4 @@
id: template-json-builder
name: 模板数据转换器
description: 把用户传入的任意 JSON 智能映射成所选 HTML 模板需要的数据结构,校验后填充进模板生成可预览的页面(定时任务中选择 HTML 模板后生效)
skills: []
@@ -209,6 +209,18 @@ async def get_scheduler_thread(request: Request) -> dict:
return await service.ensure_scheduler_thread(user_id)
@router.get("/html-templates")
async def list_html_templates() -> dict:
"""可选的 HTML 模板清单(供「模板数据转换器」内置 agent 填充用)。
返回 ``{"templates": [{id, name, description}]}``,前端定时任务表单在选中
「模板数据转换器」后据此渲染「HTML 模板」下拉。开放给任意登录用户。
"""
from deerflow.runtime.scheduler import template_fill
return {"templates": template_fill.list_templates()}
@router.get("")
async def list_scheduled_tasks(
request: Request,
@@ -18,11 +18,12 @@ from zoneinfo import ZoneInfo
from croniter import croniter
from langgraph.checkpoint.base import empty_checkpoint
from deerflow.config.agents_config import AGENT_ID_PATTERN
from deerflow.config.agents_config import AGENT_ID_PATTERN, load_agent_config, load_agent_soul
from deerflow.config.paths import get_paths
from deerflow.persistence.scheduled_tasks import ScheduledTaskStore
from deerflow.runtime import serialize_channel_values
from deerflow.runtime.scheduler import html_page as html_page_utils
from deerflow.runtime.scheduler import template_fill
from deerflow.runtime.scheduler.element import ElementDeliveryClient
from deerflow.runtime.user_context import reset_current_user, set_current_user
from deerflow.utils.time import now_iso
@@ -47,6 +48,10 @@ _MAX_INLINE_RESULT_HTML = 60000
# this threshold — it is enforced silently by the service.
_MARKDOWN_MIN_CHARS = 100
# 模板填充模式:转换结果未通过结构校验时,把错误回喂大模型自动修正重试的最大次数
# (总尝试次数 = 1 + 该值)。
_MAX_TEMPLATE_FILL_RETRIES = 2
# Instruction appended to the prompt when ``require_markdown`` is on. It only
# asks the model to produce a ``.md`` file; the length requirement is enforced
# in code (not surfaced to the model).
@@ -81,6 +86,13 @@ class HtmlGenerationError(Exception):
counts as successful when it is a full, previewable HTML document."""
class TemplateFillError(Exception):
"""Raised when a 模板填充 run could not produce a valid template payload —
the source JSON was empty, the model never returned parseable JSON, or the
converted data failed structure validation after all retries. The run is
marked failed so the user sees why (no template page is archived)."""
class DuplicateFavoriteError(Exception):
"""A user already bookmarked this page. Surfaced as HTTP 409 by the router
so the same page cannot be favorited twice."""
@@ -246,6 +258,23 @@ def _task_requires_html(task: dict[str, Any]) -> bool:
return ctx.get("task_kind") == "html_page"
def _task_template_id(task: dict[str, Any]) -> str | None:
"""The HTML template a 模板填充 task targets, or ``None`` for a normal task.
Stored at ``execution_context.template_config.template_id``. When set, the
run converts the source JSON into that template's ``DATA`` structure and
fills it — a distinct mode from the free-form HTML render pipeline.
"""
ctx = task.get("execution_context")
if not isinstance(ctx, dict):
return None
cfg = ctx.get("template_config")
if not isinstance(cfg, dict):
return None
template_id = cfg.get("template_id")
return str(template_id) if template_id else None
def _has_valid_markdown_file(result_files: list[dict[str, Any]] | None) -> bool:
"""True when the run produced a ``.md`` file whose text exceeds the minimum.
@@ -980,6 +1009,12 @@ class ScheduledTaskService:
needed, and old ``task_kind == "html_page"`` tasks still resolve to the
HTML pipeline via :func:`_task_requires_html`.
"""
# 模板填充模式优先:选中了 HTML 模板就走「转换 JSON → 填充固定模板」分支,
# 与「检索数据 → 自由渲染 HTML」(require_html) 是两套不同流程。
if _task_template_id(task) is not None:
return await self._run_template_fill_attempt(
task, runtime_thread_id=runtime_thread_id, scheduled_for=scheduled_for, run_id=run_id
)
if _task_requires_html(task):
return await self._run_html_page_attempt(
task, runtime_thread_id=runtime_thread_id, scheduled_for=scheduled_for, run_id=run_id
@@ -1339,6 +1374,196 @@ class ScheduledTaskService:
"created_at": datetime.now(UTC).isoformat(),
}
@staticmethod
def _build_conversion_prompt(skeleton_json: str, source: str) -> str:
return (
"请把下面的【源数据】转换为【目标结构】要求的 JSON。\n\n"
"【目标结构】(展示了完整字段与类型,其中数组只示意 1 个样例元素,实际请按源数据填充全部元素):\n"
"```json\n" + skeleton_json + "\n```\n\n"
"【源数据】:\n" + source + "\n\n"
"只输出一个 ```json 代码块,内含填充好的完整目标 JSON,不要任何解释文字。"
)
@staticmethod
def _build_retry_prompt(base_prompt: str, errors: list[str], gaps: list[str]) -> str:
lines = [base_prompt, "\n\n上一轮转换结果未通过校验,请严格修正后重新输出**完整**的目标 JSON:"]
if errors:
lines.append("- 结构错误:" + ";".join(errors[:12]))
if gaps:
lines.append("- 字段缺失(请补齐):" + "、".join(gaps[:30]))
return "\n".join(lines)
async def _invoke_conversion_model(self, model_name: str | None, soul: str | None, user_prompt: str) -> str:
"""Invoke the conversion model once (SOUL as system, prompt as human).
Thinking is disabled for stable JSON output. Returns the model's text.
"""
from langchain_core.messages import HumanMessage, SystemMessage
from deerflow.models import create_chat_model
model = create_chat_model(name=model_name or None, thinking_enabled=False)
messages: list[Any] = []
if soul:
messages.append(SystemMessage(content=soul))
messages.append(HumanMessage(content=user_prompt))
response = await model.ainvoke(messages)
content = getattr(response, "content", response)
if isinstance(content, str):
return content
if isinstance(content, list):
parts = [part.get("text", "") if isinstance(part, dict) else str(part) for part in content]
return "\n".join(p for p in parts if p)
return str(content)
async def _run_template_fill_attempt(
self,
task: dict[str, Any],
*,
runtime_thread_id: str,
scheduled_for: datetime,
run_id: str,
) -> dict[str, Any]:
"""模板填充模式:把源 JSON 转换成所选模板的 DATA 结构,校验后填充产出 HTML。
Pipeline: 取源 JSON(任务 prompt) → 用内置「模板数据转换器」agent 的 SOUL +
模型把它映射成目标结构 → 结构校验(不过则回喂错误重试) → 填充模板 → 归档
``index.html`` + 持久化 ``scheduled_html_pages`` → 返回 ``format: "html"``
结果(复用现有 HTML 预览)。任一步彻底失败抛 :class:`TemplateFillError`,
run 记为 failed。
"""
template_id = _task_template_id(task)
spec = template_fill.get_template(template_id) if template_id else None
if spec is None:
raise TemplateFillError(f"未知的 HTML 模板:{template_id!r}")
source = str(task.get("prompt") or "").strip()
used_default = False
data_obj: dict[str, Any] | None = None
# 「只展示」模板(convertible=False):暂不转换,原样输出模板已有的 HTML。
if not spec.convertible:
html = template_fill.read_template_text(template_id)
used_default = True
elif not source:
# 未提供源数据 → 直接展示模板自带的默认数据(不调模型、不校验)。
data_obj = template_fill.reference_data(template_id)
used_default = True
else:
ctx = task.get("execution_context") or {}
tcfg = ctx.get("template_config") if isinstance(ctx, dict) else {}
tcfg = tcfg if isinstance(tcfg, dict) else {}
# 解析选中的内置 agent 的 SOUL + 模型(template_config.model_name 优先,其次 agent.model)。
agent_ref = task.get("execution_agent_name")
agent_id = await self._resolve_agent_id(agent_ref, task["user_id"]) if agent_ref else None
soul = load_agent_soul(agent_id) if agent_id else None
model_name = str(tcfg.get("model_name") or "").strip() or None
if not model_name and agent_id:
try:
agent_cfg = load_agent_config(agent_id)
model_name = (agent_cfg.model if agent_cfg else None) or None
except Exception:
model_name = None
skeleton = template_fill.schema_skeleton_json(template_id)
base_prompt = self._build_conversion_prompt(skeleton, source)
# 转换 → 校验 → 失败回喂重试。
last_errors: list[str] = []
prompt = base_prompt
for attempt in range(_MAX_TEMPLATE_FILL_RETRIES + 1):
raw = await self._invoke_conversion_model(model_name, soul, prompt)
candidate = html_page_utils.extract_json_object(raw)
if candidate is None:
last_errors = ["模型未返回可解析的 JSON 对象"]
else:
errors = template_fill.validate_data(template_id, candidate)
if not errors:
data_obj = candidate
break
last_errors = errors
gaps = template_fill.field_coverage_gaps(template_id, candidate)
prompt = self._build_retry_prompt(base_prompt, errors, gaps)
logger.info(
"模板填充[run=%s]第 %d 次校验未通过:%s",
run_id,
attempt + 1,
";".join(last_errors[:5]),
)
if data_obj is None:
raise TemplateFillError("转换结果未通过结构校验:" + ";".join(last_errors[:8]))
# 填充模板(顺便把 MANIFEST 更新时间刷成本次计划日期)。「只展示」模板上面已
# 把原始 HTML 放进 html,这里跳过填充。
task_tz = ZoneInfo(task.get("timezone") or "UTC")
local_time = scheduled_for.astimezone(task_tz)
if data_obj is not None:
try:
html = template_fill.fill_template(template_id, data_obj, update_date=local_time.date())
except template_fill.TemplateError as exc:
raise TemplateFillError(f"模板填充失败:{exc}") from exc
# 归档 index.html。
archive_path = self._archive_html_index(run_id, html)
title = str(task.get("name") or spec.name).strip()
files: list[dict[str, Any]] = []
if archive_path is not None:
files.append(
{
"name": "index.html",
"path": str(archive_path),
"virtual_path": "/mnt/user-data/outputs/index.html",
"mime_type": "text/html",
"size": archive_path.stat().st_size if archive_path.exists() else None,
"kind": "html",
}
)
data_snapshot = {"template_id": template_id, "source_chars": len(source), "used_default": used_default}
prompt_snapshot = {"template_id": template_id, "used_default": used_default}
# 持久化页面资产(best-effort,失败不影响 run 成功)。
store = self._html_store
if store is not None:
try:
await store.create_page(
{
"id": str(uuid.uuid4()),
"task_id": task["task_id"],
"run_id": run_id,
"user_id": task["user_id"],
"title": title,
"html_content": html,
"html_file_path": str(archive_path) if archive_path else None,
"data_snapshot": data_snapshot,
"prompt_snapshot": prompt_snapshot,
"reference_favorite_id": None,
"style_summary": None,
"layout_summary": None,
}
)
except Exception:
logger.warning("Failed to persist scheduled_html_pages row for template-fill run %s", run_id, exc_info=True)
inline_html = html if len(html) <= _MAX_INLINE_RESULT_HTML else None
return {
"title": f"{title} - {local_time.strftime('%Y-%m-%d %H:%M')}",
"content": (
f"已展示模板「{spec.name}」的现有数据"
if not spec.convertible
else f"未提供源数据,已用模板「{spec.name}」的默认数据生成页面"
if used_default
else f"已根据源数据填充模板「{spec.name}」生成页面"
),
"format": "html",
"html": inline_html,
"data_snapshot": data_snapshot,
"prompt_snapshot": prompt_snapshot,
"files": files,
"created_at": datetime.now(UTC).isoformat(),
}
async def _run_agent_attempt(
self,
task: dict[str, Any],
@@ -0,0 +1,289 @@
"""HTML 模板「JSON 填充」纯引擎 + 模板注册表。
定时任务的「模板数据转换器」内置 agent 把用户传入的任意 JSON 智能映射成某个
HTML 模板需要的 ``DATA`` 结构后,本模块负责把数据**填充**进模板并产出一份完整、
可预览的 HTML 文档。
设计要点
--------
* **模板自身即 schema 来源**:每个模板的 HTML 里都内嵌一行 ``const DATA={...};``
(台湾滨海防卫展示系统 no.1 还内嵌 ``const MANIFEST={...};``)。我们直接解析这行
拿到「参考数据」,既当**目标格式的范例**(喂给大模型),又当**结构校验**的基准
(顶层键 + 类型)。注册表因此不需要手写一份庞大的 schema 字面量,改模板即改契约。
* **行级安全替换**:模板把每个常量压在**单独一行**(``const DATA=...;`` 以分号结尾、
行内无换行),所以用 ``^const VAR=.*;$`` (MULTILINE) 精确命中该行整体替换,绝不
误伤模板里别处出现的同名子串。替换值用函数回调写入,避开 ``re.sub`` 把 JSON 里的
反斜杠当反向引用的坑。
* **harness 层、零 app.* 依赖**:可被调度器(harness)与 Gateway 路由(app)共同 import。
公开入口:``list_templates`` / ``get_template`` / ``reference_data`` /
``schema_skeleton_json`` / ``validate_data`` / ``field_coverage_gaps`` / ``fill_template``。
"""
from __future__ import annotations
import json
import re
from dataclasses import dataclass
from datetime import date
from functools import lru_cache
from pathlib import Path
from typing import Any
# 模板资源根目录(随 git 发布,与本模块同级 ``templates/`` 子目录)。
_TEMPLATES_ROOT = Path(__file__).parent / "templates"
@dataclass(frozen=True)
class TemplateSpec:
"""一个 HTML 模板的注册信息。
``file`` 相对 :data:`_TEMPLATES_ROOT`;``data_var``/``manifest_var`` 是模板里
待替换的 JS 常量名(``manifest_var`` 为空表示该模板没有 MANIFEST 行)。
``convertible=False`` 表示该模板暂只「直接展示已有数据」——执行时原样输出模板
HTML,不做 JSON 转换/校验/填充(无 ``const DATA=`` 注入行的模板用它,后续接入
转换后改回 True)。
"""
id: str
name: str
description: str
file: str
data_var: str = "DATA"
manifest_var: str = ""
convertible: bool = True
# 已注册模板。新增 no.2/no.3 时在此追加一条即可——前端下拉、调度器填充全自动生效。
TEMPLATES: tuple[TemplateSpec, ...] = (
TemplateSpec(
id="no1",
name="no.1",
description="",
file="no1/taiwan_littoral_ui_redwhite_v2.html",
data_var="DATA",
manifest_var="MANIFEST",
),
TemplateSpec(
id="no2",
name="no.2",
description="",
file="no2/taiwan_air_force_v3_2.html",
# 暂只展示已有数据,不做 JSON 转换/填充(后续接入转换后再开)。
convertible=False,
),
)
_TEMPLATES_BY_ID: dict[str, TemplateSpec] = {t.id: t for t in TEMPLATES}
class TemplateError(Exception):
"""模板缺失 / 解析失败 / 填充失败。"""
def list_templates() -> list[dict[str, Any]]:
"""前端下拉用的轻量清单(只含 id/name/description/convertible,不含庞大数据)。"""
return [
{"id": t.id, "name": t.name, "description": t.description, "convertible": t.convertible}
for t in TEMPLATES
]
def get_template(template_id: str) -> TemplateSpec | None:
return _TEMPLATES_BY_ID.get(template_id)
def is_convertible(template_id: str) -> bool:
spec = _TEMPLATES_BY_ID.get(template_id)
return bool(spec and spec.convertible)
def _require(template_id: str) -> TemplateSpec:
spec = _TEMPLATES_BY_ID.get(template_id)
if spec is None:
raise TemplateError(f"未知模板 id:{template_id!r}")
return spec
def _template_path(spec: TemplateSpec) -> Path:
return _TEMPLATES_ROOT / spec.file
def read_template_text(template_id: str) -> str:
spec = _require(template_id)
path = _template_path(spec)
if not path.is_file():
raise TemplateError(f"模板文件缺失:{path}")
return path.read_text(encoding="utf-8")
def _const_line_re(var: str) -> re.Pattern[str]:
# 单行 ``const VAR={...};`` —— 默认点号不跨行,故 ``.*`` 恰好吃到该物理行的结尾分号。
return re.compile(r"^const " + re.escape(var) + r"=.*;[ \t]*$", re.MULTILINE)
def _extract_const_object(text: str, var: str) -> Any:
"""解析模板里 ``const VAR={...};`` 那一行的 JSON 值。"""
match = _const_line_re(var).search(text)
if match is None:
raise TemplateError(f"模板中未找到 const {var}=… 行")
line = match.group(0).strip()
body = line[len("const ") + len(var) + 1 :] # 去掉 ``const VAR=``
body = body.rstrip().rstrip(";").rstrip()
try:
return json.loads(body)
except json.JSONDecodeError as exc: # pragma: no cover - 模板损坏才会触发
raise TemplateError(f"解析 const {var} 失败:{exc}") from exc
@lru_cache(maxsize=16)
def reference_data(template_id: str) -> dict[str, Any]:
"""模板内嵌的参考 ``DATA`` 对象(目标格式范例 + 校验基准),按模板缓存。"""
spec = _require(template_id)
text = read_template_text(template_id)
obj = _extract_const_object(text, spec.data_var)
if not isinstance(obj, dict):
raise TemplateError(f"模板 {template_id} 的 {spec.data_var} 不是对象")
return obj
def _skeleton(value: Any, *, list_sample: int = 1, depth: int = 0, max_depth: int = 6) -> Any:
"""把一份具体数据压成「结构骨架」:保留全部字段与类型,每个数组只留前 N 个样例。
数组元素同构,留 1 个样例即可暴露该元素的完整字段集,既完整又紧凑——正好做大模型
的「目标格式说明」。
"""
if depth >= max_depth:
return "…"
if isinstance(value, dict):
return {k: _skeleton(v, list_sample=list_sample, depth=depth + 1, max_depth=max_depth) for k, v in value.items()}
if isinstance(value, list):
if not value:
return []
return [_skeleton(v, list_sample=list_sample, depth=depth + 1, max_depth=max_depth) for v in value[:list_sample]]
return value
@lru_cache(maxsize=16)
def schema_skeleton_json(template_id: str) -> str:
"""目标格式的结构骨架(JSON 字符串),注入到大模型的转换 prompt 里。"""
skeleton = _skeleton(reference_data(template_id))
return json.dumps(skeleton, ensure_ascii=False, indent=1)
def top_level_keys(template_id: str) -> list[str]:
return list(reference_data(template_id).keys())
def validate_data(template_id: str, obj: Any) -> list[str]:
"""硬校验:转换结果必须是对象,且顶层键齐全、容器类型与模板一致。
返回错误列表(空 = 通过)。只校验顶层结构——字段级缺失通过
:func:`field_coverage_gaps` 作为软反馈回喂大模型,而不在这里硬卡死。
"""
errors: list[str] = []
if not isinstance(obj, dict):
return ["转换结果不是 JSON 对象(应为 {…})"]
ref = reference_data(template_id)
for key, ref_val in ref.items():
if key not in obj:
errors.append(f"缺少顶层字段「{key}」")
continue
got = obj[key]
if isinstance(ref_val, list) and not isinstance(got, list):
errors.append(f"字段「{key}」应为数组")
elif isinstance(ref_val, dict) and not isinstance(got, dict):
errors.append(f"字段「{key}」应为对象")
return errors
def _element_fields(value: Any) -> set[str]:
"""取一个数组里所有 dict 元素字段名的并集。"""
fields: set[str] = set()
if isinstance(value, list):
for item in value:
if isinstance(item, dict):
fields.update(item.keys())
return fields
def field_coverage_gaps(template_id: str, obj: Any, *, max_items: int = 40) -> list[str]:
"""软反馈:对照参考数据,列出转换结果里缺失的字段(数组元素字段 / 对象子键)。
用于把「字段没填全」回喂大模型让它补齐,不作为硬性失败。
"""
gaps: list[str] = []
if not isinstance(obj, dict):
return gaps
ref = reference_data(template_id)
for key, ref_val in ref.items():
if key not in obj:
continue
got = obj[key]
if isinstance(ref_val, dict) and isinstance(got, dict):
for sub in ref_val:
if sub not in got:
gaps.append(f"{key}.{sub} 缺失")
elif isinstance(ref_val, list) and isinstance(got, list):
want = _element_fields(ref_val)
have = _element_fields(got)
for f in sorted(want - have):
gaps.append(f"{key}[].{f} 缺失")
if len(gaps) >= max_items:
return gaps[:max_items] + ["…(更多字段省略)"]
return gaps
def _dump_const(var: str, obj: Any) -> str:
body = json.dumps(obj, ensure_ascii=False)
return f"const {var}={body};"
def fill_template(
template_id: str,
data_obj: Any,
*,
manifest_overrides: dict[str, Any] | None = None,
update_date: date | None = None,
) -> str:
"""把 ``data_obj`` 填进模板的 ``const DATA=…`` 行,产出完整 HTML 文档。
可选地更新 MANIFEST 的「系统.更新时间」(``update_date``,默认不动) 以及任意
``manifest_overrides``(深合并到解析后的 MANIFEST,如改系统名称)。
"""
spec = _require(template_id)
text = read_template_text(template_id)
data_re = _const_line_re(spec.data_var)
if data_re.search(text) is None:
raise TemplateError(f"模板 {template_id} 中未找到 const {spec.data_var}= 行")
new_data_line = _dump_const(spec.data_var, data_obj)
text = data_re.sub(lambda _m: new_data_line, text, count=1)
if spec.manifest_var and (manifest_overrides or update_date is not None):
manifest_re = _const_line_re(spec.manifest_var)
match = manifest_re.search(text)
if match is not None:
try:
manifest = _extract_const_object(text, spec.manifest_var)
except TemplateError:
manifest = None
if isinstance(manifest, dict):
if update_date is not None:
system = manifest.get("系统")
if isinstance(system, dict):
system["更新时间"] = update_date.isoformat()
if manifest_overrides:
_deep_merge(manifest, manifest_overrides)
new_manifest_line = _dump_const(spec.manifest_var, manifest)
text = manifest_re.sub(lambda _m: new_manifest_line, text, count=1)
return text
def _deep_merge(base: dict[str, Any], overrides: dict[str, Any]) -> None:
for k, v in overrides.items():
if isinstance(v, dict) and isinstance(base.get(k), dict):
_deep_merge(base[k], v)
else:
base[k] = v
@@ -0,0 +1,265 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>台湾滨海防卫知识数据展示系统</title>
<style>
:root{
--red:#c8102e;--red2:#8f1024;--light-red:#fff1f3;--soft-red:#fde7eb;
--bg:#f5f5f6;--card:#fff;--text:#262626;--muted:#737373;--line:#e5e5e5;
--green:#168a4a;--orange:#c56a00;--shadow:0 8px 24px rgba(74,12,24,.08)
}
*{box-sizing:border-box}html{scroll-behavior:smooth}body{margin:0;background:var(--bg);color:var(--text);font-family:"PingFang SC","Microsoft YaHei",Arial,sans-serif}
button,input,select{font:inherit}.header{position:sticky;top:0;z-index:30;background:#fff;border-bottom:1px solid var(--line);box-shadow:0 3px 12px rgba(0,0,0,.04)}
.header-top{height:70px;padding:0 28px;display:flex;align-items:center;gap:24px}.logo{width:42px;height:42px;border-radius:12px;background:linear-gradient(145deg,var(--red),var(--red2));display:grid;place-items:center;color:#fff;font-weight:800}
.title{flex:1}.title h1{font-size:19px;margin:0 0 5px}.title p{margin:0;font-size:12px;color:var(--muted)}.header-state{text-align:right;font-size:12px;color:var(--muted)}
.tabs{display:flex;padding:0 24px;gap:4px;overflow:auto}.tab{border:0;background:transparent;padding:13px 18px;color:#666;cursor:pointer;border-bottom:3px solid transparent;white-space:nowrap}.tab.active{color:var(--red);font-weight:700;border-bottom-color:var(--red);background:linear-gradient(to top,var(--light-red),transparent)}
.main{padding:24px 28px 48px}.hero{background:linear-gradient(135deg,#fff,#fff5f6);border:1px solid #f2d4d9;border-radius:20px;padding:24px;display:flex;justify-content:space-between;align-items:center;gap:20px;margin-bottom:18px;box-shadow:var(--shadow)}
.hero h2{margin:0 0 8px;font-size:26px}.hero p{margin:0;color:var(--muted);line-height:1.7}.hero-mark{width:90px;height:90px;border-radius:50%;background:radial-gradient(circle,#fff 0 30%,#fbd5dc 31% 55%,var(--red) 56%);opacity:.9}
.btn{border:1px solid #d8d8d8;background:#fff;color:#333;border-radius:9px;padding:8px 13px;cursor:pointer}.btn:hover{border-color:var(--red);color:var(--red)}.btn-red{background:var(--red);border-color:var(--red);color:#fff}.btn-red:hover{background:var(--red2);color:#fff}.btn-soft{background:var(--light-red);color:var(--red);border-color:#f0bec8}
.metrics{display:grid;grid-template-columns:repeat(3,1fr);gap:14px;margin-bottom:18px}.metric{background:#fff;border:1px solid var(--line);border-radius:16px;padding:17px;box-shadow:var(--shadow);position:relative;overflow:hidden}.metric:after{content:"";position:absolute;right:-20px;top:-30px;width:90px;height:90px;border-radius:50%;background:var(--light-red)}.metric-label{font-size:13px;color:var(--muted)}.metric-value{font-size:30px;font-weight:800;color:var(--red);margin-top:8px}.metric-unit{font-size:12px;margin-left:4px}.metric-note{font-size:12px;color:#888;margin-top:6px}
.grid2{display:grid;grid-template-columns:1fr 1fr;gap:16px}.panel{background:#fff;border:1px solid var(--line);border-radius:17px;padding:18px;box-shadow:var(--shadow)}.panel-title{font-weight:800;margin-bottom:14px;display:flex;align-items:center;justify-content:space-between}.panel-title:before{content:"";width:5px;height:18px;background:var(--red);border-radius:5px;margin-right:9px}.panel-title span:first-child{display:flex;align-items:center}
.bar-row{display:grid;grid-template-columns:150px 1fr 38px;gap:10px;align-items:center;font-size:12px;margin:10px 0}.bar{height:9px;background:#f1f1f1;border-radius:20px;overflow:hidden}.bar i{display:block;height:100%;background:linear-gradient(90deg,var(--red),#ed6b7e);border-radius:20px}
.entry-grid,.card-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:14px}.entry-card,.data-card{background:#fff;border:1px solid var(--line);border-radius:16px;padding:17px;box-shadow:var(--shadow);cursor:pointer;transition:.18s}.entry-card:hover,.data-card:hover{transform:translateY(-2px);border-color:#e0a0ab}.entry-card b,.data-card h3{display:block;margin:0 0 8px}.entry-num{font-size:25px;color:var(--red);font-weight:800}.entry-card p,.data-card p{font-size:12px;color:var(--muted);line-height:1.65;margin:8px 0 0}
.toolbar{background:#fff;border:1px solid var(--line);border-radius:14px;padding:12px;display:flex;gap:9px;align-items:center;flex-wrap:wrap;margin-bottom:16px;box-shadow:var(--shadow)}.toolbar input,.toolbar select{height:37px;border:1px solid #ddd;border-radius:8px;background:#fff;padding:0 10px;color:#333}.toolbar input{flex:1;min-width:250px}.toolbar-count{margin-left:auto;color:var(--muted);font-size:12px}
.chip{display:inline-flex;align-items:center;border-radius:999px;padding:4px 9px;background:#f3f3f3;color:#555;font-size:11px;margin:2px}.chip-red{background:var(--light-red);color:var(--red)}.chip-green{background:#edf9f2;color:var(--green)}.chip-orange{background:#fff6e8;color:var(--orange)}
.region-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:15px}.region-card{background:#fff;border:1px solid var(--line);border-radius:18px;padding:18px;box-shadow:var(--shadow);cursor:pointer;position:relative;overflow:hidden}.region-card:before{content:"";position:absolute;left:0;top:0;bottom:0;width:5px;background:var(--red)}.region-card h3{margin:0 0 8px;font-size:18px}.region-card p{font-size:12px;color:var(--muted);line-height:1.7}.region-stats{display:flex;gap:18px;margin-top:13px;border-top:1px solid #f0f0f0;padding-top:12px}.region-stats b{color:var(--red);font-size:18px}
.segmented{display:flex;gap:5px;background:#f0f0f0;padding:4px;border-radius:11px;width:max-content;margin-bottom:15px}.seg-btn{border:0;background:transparent;padding:8px 14px;border-radius:8px;color:#666;cursor:pointer}.seg-btn.active{background:#fff;color:var(--red);font-weight:700;box-shadow:0 2px 7px rgba(0,0,0,.08)}
.chart-toolbar{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:16px}.chart-view{padding:9px 14px;border:1px solid #e1c3c9;background:#fff7f8;color:#8c2335;border-radius:9px;cursor:pointer}.chart-view.active{background:var(--red);color:#fff;border-color:var(--red)}
.org-chart-wrap{overflow:auto;background:#fff;border:1px solid var(--line);border-radius:18px;padding:28px;box-shadow:var(--shadow);min-height:420px}
.org-chart{display:flex;justify-content:center;min-width:max-content}.org-chart ul{padding-top:24px;position:relative;display:flex;justify-content:center;margin:0;padding-left:0}.org-chart li{list-style:none;text-align:center;position:relative;padding:24px 8px 0}.org-chart li:before,.org-chart li:after{content:"";position:absolute;top:0;right:50%;border-top:1px solid #c7a8ae;width:50%;height:24px}.org-chart li:after{right:auto;left:50%;border-left:1px solid #c7a8ae}.org-chart li:only-child:after,.org-chart li:only-child:before{display:none}.org-chart li:only-child{padding-top:0}.org-chart li:first-child:before,.org-chart li:last-child:after{border:0}.org-chart li:last-child:before{border-right:1px solid #c7a8ae;border-radius:0 6px 0 0}.org-chart li:first-child:after{border-radius:6px 0 0 0}.org-chart ul ul:before{content:"";position:absolute;top:0;left:50%;border-left:1px solid #c7a8ae;width:0;height:24px}
.org-node{width:168px;min-height:80px;background:#fff;border:1px solid #e3c1c7;border-top:4px solid var(--red);border-radius:12px;padding:10px;display:inline-block;box-shadow:0 5px 14px rgba(96,12,28,.08);cursor:pointer}.org-node:hover{background:#fff6f7;border-color:var(--red)}.org-node b{font-size:13px;display:block;line-height:1.45}.org-node small{font-size:10px;color:var(--muted);display:block;margin-top:5px}.org-node .node-kind{font-size:10px;color:var(--red);margin-top:5px}
.summary-strip{display:grid;grid-template-columns:repeat(4,1fr);gap:12px;margin:15px 0}.summary-card{background:#fff;border:1px solid var(--line);border-radius:13px;padding:14px}.summary-card b{display:block;color:var(--red);font-size:21px}.summary-card span{font-size:12px;color:var(--muted)}
.data-card{display:flex;flex-direction:column;min-height:205px}.card-top{display:flex;justify-content:space-between;gap:10px}.card-type{font-size:11px;color:var(--red);background:var(--light-red);padding:4px 8px;border-radius:999px;height:max-content}.card-sub{font-size:12px;color:#777;margin-bottom:10px}.card-roles{display:grid;gap:6px;margin-top:8px}.role-line{font-size:12px;background:#fafafa;border-left:3px solid #e7a1ad;padding:7px 9px}.card-footer{margin-top:auto;padding-top:12px;border-top:1px solid #eee;display:flex;justify-content:space-between;color:#888;font-size:11px}
.category-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:10px;margin-bottom:16px}.category-card{background:#fff;border:1px solid var(--line);border-radius:13px;padding:13px;cursor:pointer}.category-card:hover{border-color:var(--red)}.category-card b{color:var(--red);font-size:21px;display:block}.category-card span{font-size:11px;color:#666}
.timeline{position:relative;padding-left:26px}.timeline:before{content:"";position:absolute;left:7px;top:4px;bottom:4px;width:2px;background:#efc5cc}.time-card{position:relative;background:#fff;border:1px solid var(--line);border-radius:14px;padding:16px;margin-bottom:14px;box-shadow:var(--shadow);cursor:pointer}.time-card:before{content:"";position:absolute;left:-25px;top:19px;width:12px;height:12px;border:3px solid var(--red);background:#fff;border-radius:50%}.time-date{color:var(--red);font-size:12px;font-weight:700;margin-bottom:5px}
.pagination{display:flex;justify-content:center;align-items:center;gap:9px;margin-top:18px}.page-no{color:#777;font-size:12px}
.overlay{position:fixed;inset:0;background:rgba(36,8,14,.42);z-index:50;display:none}.overlay.show{display:block}.drawer{position:absolute;right:0;top:0;width:min(560px,94vw);height:100%;background:#fff;padding:24px;overflow:auto;box-shadow:-12px 0 35px rgba(0,0,0,.16)}.modal{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);width:min(840px,92vw);max-height:88vh;overflow:auto;background:#fff;border-radius:18px;padding:24px}.close{float:right}
.detail-page{display:none}.detail-page.show{display:block}.detail-hero{background:linear-gradient(135deg,var(--red),var(--red2));color:#fff;border-radius:20px;padding:25px;margin-bottom:16px;box-shadow:var(--shadow)}.detail-hero h2{margin:0 0 10px;font-size:26px}.detail-hero p{margin:0;line-height:1.7;color:#ffe8ed}.detail-tags{margin-top:13px}.detail-tags .chip{background:rgba(255,255,255,.16);color:#fff}
.detail-grid{display:grid;grid-template-columns:2fr 1fr;gap:16px}.detail-section{background:#fff;border:1px solid var(--line);border-radius:16px;padding:18px;margin-bottom:16px;box-shadow:var(--shadow)}.detail-section h3{margin:0 0 14px;font-size:16px;display:flex;align-items:center}.detail-section h3:before{content:"";width:4px;height:17px;background:var(--red);border-radius:5px;margin-right:8px}.info-grid{display:grid;grid-template-columns:repeat(2,1fr);gap:10px}.info-item{background:#fafafa;border-radius:10px;padding:11px}.info-item label{display:block;font-size:11px;color:#888;margin-bottom:5px}.info-item div{font-size:13px;line-height:1.55}.text-list{display:grid;gap:9px}.text-item{padding:10px 12px;border-left:4px solid #e5a6b1;background:#fff8f9;border-radius:7px;font-size:13px;line-height:1.6}
.mini-card-grid{display:grid;grid-template-columns:repeat(2,1fr);gap:10px}.mini-card{border:1px solid #eee;border-radius:11px;padding:12px;background:#fff}.mini-card b{display:block;margin-bottom:6px}.source-line{padding:9px 0;border-bottom:1px dashed #ddd;font-size:12px}.warning-box{background:#fff7e8;border:1px solid #f1d29e;border-radius:11px;padding:13px;color:#7a4d00;font-size:12px;line-height:1.65}.safe-box{background:#f7f7f7;border:1px solid #ddd;border-radius:11px;padding:13px;color:#555;font-size:12px;line-height:1.65}
.query-row{display:grid;grid-template-columns:1fr 120px 1fr 42px;gap:8px;margin-bottom:8px}.query-row select,.query-row input{height:37px;border:1px solid #ddd;border-radius:8px;padding:0 9px}.empty{text-align:center;padding:50px;color:#888}
@media(max-width:1150px){.entry-grid,.card-grid,.region-grid{grid-template-columns:repeat(2,1fr)}.category-grid{grid-template-columns:repeat(3,1fr)}.detail-grid{grid-template-columns:1fr}.metrics{grid-template-columns:repeat(2,1fr)}}
@media(max-width:720px){.header-top{padding:10px 14px;height:auto}.header-state{display:none}.main{padding:14px}.hero-mark{display:none}.hero h2{font-size:21px}.metrics,.grid2,.entry-grid,.card-grid,.region-grid,.summary-strip,.category-grid,.info-grid,.mini-card-grid{grid-template-columns:1fr}.toolbar input{min-width:100%}.query-row{grid-template-columns:1fr}.org-chart-wrap{padding:15px}.org-node{width:145px}}
</style>
</head>
<body>
<header class="header">
<div class="header-top">
<div class="logo">防</div>
<div class="title"><h1>台湾滨海防卫知识数据展示系统</h1><p>红白业务版 · 组织架构与单位档案已合并</p></div>
<div class="header-state">知识库版本 2.0<br>更新日期 2026-06-23</div>
</div>
<nav class="tabs" id="tabs"></nav>
</header>
<main class="main">
<section id="page"></section>
<section id="detailPage" class="detail-page"></section>
</main>
<div id="drawerOverlay" class="overlay"><aside class="drawer"><button class="btn close" onclick="closeDrawer()">关闭</button><div id="drawer"></div></aside></div>
<div id="modalOverlay" class="overlay"><div class="modal"><button class="btn close" onclick="closeModal()">关闭</button><div id="modal"></div></div></div>
<script>
const DATA={"overview": {"核心指标": [{"名称": "综合区域", "数值": 9, "单位": "区", "说明": "覆盖本岛沿岸与主要离岛"}, {"名称": "组织与单位", "数值": 280, "单位": "个", "说明": "已去除组织与单位重复"}, {"名称": "单位深度档案", "数值": 179, "单位": "份", "说明": "已合并到对应组织节点"}, {"名称": "装备条目", "数值": 99, "单位": "项", "说明": "区分规划、采购、测试和列装"}, {"名称": "关键节点", "数值": 141, "单位": "个", "说明": "公共功能与韧性视角"}, {"名称": "演习与项目", "数值": 12, "单位": "项", "说明": "事件时间线与建设项目"}], "组织体系分布": [{"名称": "组织治理体系", "数值": 101}, {"名称": "海巡", "数值": 43}, {"名称": "全民动员", "数值": 29}, {"名称": "国防与联合体系", "数值": 28}, {"名称": "海军", "数值": 21}, {"名称": "陆军", "数值": 18}, {"名称": "空军", "数值": 16}, {"名称": "公共支援", "数值": 16}, {"名称": "海军陆战队", "数值": 8}], "装备类别分布": [{"名称": "无人系统", "数值": 12}, {"名称": "防空", "数值": 11}, {"名称": "海军平台", "数值": 10}, {"名称": "海巡平台", "数值": 9}, {"名称": "航空", "数值": 8}, {"名称": "地面火力", "数值": 7}, {"名称": "装甲", "数值": 5}, {"名称": "雷达与感知", "数值": 5}, {"名称": "反装甲", "数值": 4}, {"名称": "反舰", "数值": 3}, {"名称": "潜舰", "数值": 3}, {"名称": "反水雷", "数值": 3}, {"名称": "舰载防空", "数值": 3}, {"名称": "电子战", "数值": 3}, {"名称": "指挥控制", "数值": 3}, {"名称": "工程保障", "数值": 2}, {"名称": "布雷", "数值": 1}, {"名称": "综合支援", "数值": 1}, {"名称": "反无人机", "数值": 1}, {"名称": "无人水面平台", "数值": 1}, {"名称": "通信", "数值": 1}, {"名称": "机动运输", "数值": 1}, {"名称": "防护", "数值": 1}, {"名称": "医疗保障", "数值": 1}], "节点类别分布": [{"名称": "港口与海运", "数值": 26}, {"名称": "供水", "数值": 22}, {"名称": "能源与燃料", "数值": 18}, {"名称": "机场与航空", "数值": 17}, {"名称": "产业、物流与维修", "数值": 15}, {"名称": "陆路交通", "数值": 13}, {"名称": "医疗与救援", "数值": 11}, {"名称": "通信与数位韧性", "数值": 8}, {"名称": "应变指挥与警报", "数值": 6}, {"名称": "离岛持续保障", "数值": 5}], "业务入口": [{"页面": "regions", "名称": "沿岸区域", "数量": 9, "说明": "九区、县市与海岸分段"}, {"页面": "org_units", "名称": "组织与单位", "数量": 280, "说明": "组织架构图与单位深度档案"}, {"页面": "equipment", "名称": "武器装备", "数量": 99, "说明": "类别、体系、状态和公开作用"}, {"页面": "key_nodes", "名称": "关键节点", "数量": 141, "说明": "公共功能、依赖与替代"}, {"页面": "events_projects", "名称": "演习与项目", "数量": 12, "说明": "事件时间线和建设项目"}], "数据状态": {"知识库版本": "2.0", "页面版本": "2.0", "更新时间": "2026-06-23", "校验状态": "通过"}}, "regions": [{"region_id": "R01", "name": "北部沿岸", "scope": "基隆市、新北市沿岸及台北都会支撑区", "coastal_counties": ["基隆市", "新北市"], "support_areas": ["台北市", "桃园北部"], "coastal_morphology": ["野柳—东北角岬湾岩岸", "基隆港湾与人工港岸", "淡水河口沙岸与河口湿地", "八里—林口台地前缘海岸"], "segment_ids": ["S-R01-01", "S-R01-02", "S-R01-03", "S-R01-04", "S-R01-05"], "source_ids": ["SRC-WRA-TAIPEI-COAST", "SRC-TIPC-PORTS-2026", "SRC-CGA-ORG-2024", "SRC-CAA-AIRPORTS-2024"], "last_verified": "2026-06-23", "safety": "aggregate_only", "编号": "R01", "名称": "北部沿岸", "沿海县市": ["基隆市", "新北市"], "支撑区域": ["台北市", "桃园北部"], "海岸形态": ["野柳—东北角岬湾岩岸", "基隆港湾与人工港岸", "淡水河口沙岸与河口湿地", "八里—林口台地前缘海岸"], "县市子库": [{"county_id": "C-KEELUNG", "name": "基隆市", "region_id": "R01", "coastal_belt": ["基隆港城、外木山及东北角接口"], "dominant_functions": ["港口城市", "国际商港", "都会支撑"], "priority_risks": ["港城交通耦合", "强浪台风", "危险品与港区事故"], "last_verified": "2026-06-23", "safety": "aggregate_only"}, {"county_id": "C-NEWTAIPEI", "name": "新北市", "region_id": "R01", "coastal_belt": ["八里、林口、淡水、三芝、石门、金山、万里、瑞芳、贡寮"], "dominant_functions": ["台北港", "淡水河口", "北海岸观光渔业"], "priority_risks": ["海岸类型差异大", "跨都会交通", "东北角山区通道"], "last_verified": "2026-06-23", "safety": "aggregate_only"}], "海岸分段": [{"segment_id": "S-R01-01", "region_id": "R01", "name": "基隆Line truncated
const MANIFEST={"系统": {"名称": "台湾滨海防卫知识数据展示系统", "版本": "2.0", "知识库版本": "2.0", "更新时间": "2026-06-23", "主题": {"模式": "红白", "主色": "#c8102e", "深红": "#8f1024", "浅红": "#fff1f3", "背景": "#f7f7f8", "卡片": "#ffffff", "文字": "#262626"}}, "页面": [{"编号": "overview", "名称": "总览", "数据源": "data/overview.json"}, {"编号": "regions", "名称": "沿岸区域", "数据源": "data/regions.json"}, {"编号": "org_units", "名称": "组织与单位", "数据源": "data/org_units.json", "架构数据": "data/organization_charts.json"}, {"编号": "equipment", "名称": "武器装备", "数据源": "data/equipment.json"}, {"编号": "key_nodes", "名称": "关键节点", "数据源": "data/key_nodes.json"}, {"编号": "events_projects", "名称": "演习与项目", "数据源": "data/events_projects.json"}], "交互规则": {"默认展示": "卡片与图形化面板", "列表限制": "不超过六个核心字段", "快速预览": "右侧抽屉", "复杂档案": "二级详情页", "长数组": "分组卡片或弹框", "查询": "各页面独立关键词、分类筛选和组合条件查询", "禁止展示": ["精确坐标", "实时部署", "兵力库存", "通信拓扑", "行动路线", "脆弱点排序"]}};
const QUERY_SCHEMA={"版本": "2.0", "说明": "前端以中文条件构造器生成内部查询对象,不设置独立全库检索页面。", "查询域": {"沿岸区域": {"数据源": "regions", "字段": [{"字段": "名称", "显示名": "区域名称", "操作": ["包含", "等于"]}, {"字段": "沿海县市", "显示名": "沿海县市", "操作": ["包含"]}, {"字段": "海岸形态", "显示名": "海岸形态", "操作": ["包含"]}]}, "组织与单位": {"数据源": "org_units", "字段": [{"字段": "名称", "显示名": "名称", "操作": ["包含", "等于"]}, {"字段": "记录类别", "显示名": "记录类别", "操作": ["等于"]}, {"字段": "体系", "显示名": "体系", "操作": ["等于", "属于"]}, {"字段": "架构层级", "显示名": "架构层级", "操作": ["等于"]}, {"字段": "区域关联", "显示名": "区域关联", "操作": ["包含"]}, {"字段": "当前状态", "显示名": "当前状态", "操作": ["等于"]}]}, "武器装备": {"数据源": "equipment", "字段": [{"字段": "名称", "显示名": "装备名称", "操作": ["包含"]}, {"字段": "类别中文", "显示名": "装备类别", "操作": ["等于"]}, {"字段": "使用体系中文", "显示名": "使用体系", "操作": ["包含"]}, {"字段": "状态中文", "显示名": "公开状态", "操作": ["等于"]}]}, "关键节点": {"数据源": "key_nodes", "字段": [{"字段": "名称", "显示名": "节点名称", "操作": ["包含"]}, {"字段": "类别中文", "显示名": "节点类别", "操作": ["等于"]}, {"字段": "区域中文", "显示名": "所属区域", "操作": ["包含"]}, {"字段": "状态中文", "显示名": "运行状态", "操作": ["等于"]}]}, "演习与项目": {"数据源": "events_projects", "字段": [{"字段": "名称", "显示名": "名称", "操作": ["包含"]}, {"字段": "类型中文", "显示名": "事件类型", "操作": ["等于"]}, {"字段": "类别中文", "显示名": "项目类别", "操作": ["等于"]}, {"字段": "状态中文", "显示名": "状态", "操作": ["等于"]}]}}, "内部查询对象": {"数据域": "页面数据源编号", "关键词": "可选", "条件": [{"字段": "中文字段名", "操作": "包含或等于", "值": "查询值"}], "分页": {"页码": 1, "每页": 18}}};
const state={tab:"overview",mode:"chart",chart:"national_defense",q:"",filters:{},conditions:[],page:1,pageSize:18};
function e(v){return String(v??"").replace(/[&<>"']/g,m=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[m]))}
function a(v){return Array.isArray(v)?v:(v===undefined||v===null||v===""?[]:[v])}
function text(v){return Array.isArray(v)?v.join(" "):typeof v==="object"&&v?JSON.stringify(v):String(v??"")}
function chips(v,red=false,max=8){const x=a(v);return x.slice(0,max).map(y=>`<span class="chip ${red?"chip-red":""}">${e(y)}</span>`).join("")+(x.length>max?`<span class="chip">另${x.length-max}项</span>`:"")}
function statusChip(v){const green=/运行|列装|官方|确认|通过/.test(v||"");const orange=/规划|测试|建设|扩建|核验|有限/.test(v||"");return `<span class="chip ${green?"chip-green":orange?"chip-orange":"chip-red"}">${e(v||"未标注")}</span>`}
function unique(data,field){const s=new Set();data.forEach(x=>a(x[field]).forEach(v=>v&&s.add(v)));return [...s].sort()}
function setTab(tab){state.tab=tab;state.q="";state.filters={};state.conditions=[];state.page=1;location.hash="#/"+tab;render()}
function nav(){
document.getElementById("tabs").innerHTML=MANIFEST.页面.map(x=>`<button class="tab ${state.tab===x.编号?"active":""}" onclick="setTab('${x.编号}')">${x.名称}</button>`).join("")
}
function hero(title,desc,actions=""){return `<div class="hero"><div><h2>${title}</h2><p>${desc}</p><div style="margin-top:14px">${actions}</div></div><div class="hero-mark"></div></div>`}
function render(){nav();document.getElementById("page").style.display="block";document.getElementById("detailPage").classList.remove("show");const fn=window["render_"+state.tab]||render_overview;fn()}
function render_overview(){
const o=DATA.overview;
const metrics=o.核心指标.map(x=>`<div class="metric"><div class="metric-label">${x.名称}</div><div class="metric-value">${x.数值}<span class="metric-unit">${x.单位}</span></div><div class="metric-note">${x.说明}</div></div>`).join("");
const max=Math.max(...o.组织体系分布.map(x=>x.数值));
const bars=o.组织体系分布.slice(0,10).map(x=>`<div class="bar-row"><span>${e(x.名称)}</span><div class="bar"><i style="width:${x.数值/max*100}%"></i></div><b>${x.数值}</b></div>`).join("");
const entries=o.业务入口.map(x=>`<div class="entry-card" onclick="setTab('${x.页面}')"><b>${x.名称}</b><span class="entry-num">${x.数量}</span><p>${x.说明}</p></div>`).join("");
document.getElementById("page").innerHTML=hero("知识库业务总览","以区域、组织与单位、装备、公共关键节点、演习和建设项目为主线组织知识数据。")+
`<div class="metrics">${metrics}</div><div class="grid2"><div class="panel"><div class="panel-title"><span>组织体系分布</span></div>${bars}</div><div class="panel"><div class="panel-title"><span>业务入口</span></div><div class="entry-grid">${entries}</div></div></div>`;
}
function toolbar(domain,data,filterFields){
const selects=filterFields.map(f=>`<select data-filter="${f}"><option value="">${f}:全部</option>${unique(data,f).slice(0,120).map(v=>`<option value="${e(v)}" ${state.filters[f]===String(v)?"selected":""}>${e(v)}</option>`).join("")}</select>`).join("");
return `<div class="toolbar"><input id="keyword" value="${e(state.q)}" placeholder="输入关键词查询当前页面">${selects}<button class="btn btn-soft" onclick="openQueryBuilder('${domain}')">组合查询</button><button class="btn" onclick="clearQuery()">清空</button><span class="toolbar-count" id="count"></span></div>`;
}
function bindToolbar(domain,renderFn){
const q=document.getElementById("keyword");if(q)q.oninput=()=>{state.q=q.value;state.page=1;renderFn()};
document.querySelectorAll("[data-filter]").forEach(s=>s.onchange=()=>{state.filters[s.dataset.filter]=s.value;state.page=1;renderFn()});
}
function clearQuery(){state.q="";state.filters={};state.conditions=[];state.page=1;render()}
function matches(item,fields){
const q=state.q.trim().toLowerCase();
if(q&&!fields.some(f=>text(item[f]).toLowerCase().includes(q)))return false;
for(const [f,v] of Object.entries(state.filters)){if(v&&!a(item[f]).some(x=>String(x)===String(v)))return false}
for(const c of state.conditions){
const value=item[c.field],all=a(value);
if(c.op==="包含"&&!all.some(x=>String(x).includes(c.value)))return false;
if(c.op==="等于"&&!all.some(x=>String(x)===c.value))return false;
if(c.op==="属于"&&!all.some(x=>c.value.split(/[、,,]/).map(y=>y.trim()).includes(String(x))))return false;
}
return true
}
function paginate(items){const start=(state.page-1)*state.pageSize;return items.slice(start,start+state.pageSize)}
function pagination(total,rerender){const pages=Math.max(1,Math.ceil(total/state.pageSize));return `<div class="pagination"><button class="btn" ${state.page<=1?"disabled":""} onclick="state.page--;${rerender}">上一页</button><span class="page-no">第 ${state.page} / ${pages} 页</span><button class="btn" ${state.page>=pages?"disabled":""} onclick="state.page++;${rerender}">下一页</button></div>`}
function render_regions(){
const cards=DATA.regions.map(r=>`<article class="region-card" onclick="openDetail('regions','${r.编号}')"><h3>${r.编号} · ${r.名称}</h3><p>${e(r.档案摘要)}</p><div>${chips(r.海岸形态,true,3)}</div><div class="region-stats"><div><b>${r.县市数量}</b><br><small>县市子库</small></div><div><b>${r.分段数量}</b><br><small>海岸分段</small></div></div></article>`).join("");
document.getElementById("page").innerHTML=hero("沿岸区域","采用九个综合研究区,下钻到沿海县市和海岸研究分段。")+`<div class="region-grid">${cards}</div>`;
}
function render_org_units(){
const chartActive=state.mode==="chart",cardsActive=state.mode==="cards";
document.getElementById("page").innerHTML=hero("组织与单位","组织架构与单位档案使用同一套280个唯一节点。点击架构节点或机构卡片可进入完整档案。")+
`<div class="segmented"><button class="seg-btn ${chartActive?"active":""}" onclick="state.mode='chart';render_org_units()">组织架构图</button><button class="seg-btn ${cardsActive?"active":""}" onclick="state.mode='cards';render_org_units()">机构与单位卡片</button></div><div id="orgBody"></div>`;
if(chartActive)renderOrgChart();else renderOrgCards(true);
}
function renderOrgChart(){
const views=DATA.organization_charts;
const current=views.find(x=>x.id===state.chart)||views[0];
const buttons=views.map(x=>`<button class="chart-view ${x.id===current.id?"active":""}" onclick="state.chart='${x.id}';renderOrgChart()">${x.名称}</button>`).join("");
const tree=`<div class="org-chart"><ul><li>${chartNode(current.tree)}</li></ul></div>`;
const roots=DATA.org_units.filter(x=>!x.行政上级编号).length;
document.getElementById("orgBody").innerHTML=`<div class="chart-toolbar">${buttons}</div><div class="panel" style="margin-bottom:14px"><div class="panel-title"><span>${current.名称}</span></div><p style="color:#777;font-size:13px">${current.说明}</p></div><div class="summary-strip"><div class="summary-card"><b>${DATA.org_units.length}</b><span>唯一组织节点</span></div><div class="summary-card"><b>${DATA.org_units.filter(x=>x.记录类别==="单位").length}</b><span>含深度单位档案</span></div><div class="summary-card"><b>${DATA.org_units.filter(x=>x.记录类别==="机构").length}</b><span>制度与组织节点</span></div><div class="summary-card"><b>${roots}</b><span>顶层或独立根节点</span></div></div><div class="org-chart-wrap">${tree}</div>`;
}
function chartNode(n){
if(!n)return "";
const children=(n.children||[]).length?`<ul>${n.children.map(c=>`<li>${chartNode(c)}</li>`).join("")}</ul>`:"";
return `<div class="org-node" onclick="openDetail('org_units','${n.id}')"><b>${e(n.名称)}</b><small>${e(n.类型)}</small><div class="node-kind">${e(n.记录类别)} · ${e(n.状态)}</div></div>${children}`;
}
function renderOrgCards(initial=false){
const data=DATA.org_units;
if(initial){
document.getElementById("orgBody").innerHTML=toolbar("组织与单位",data,["记录类别","体系","架构层级","当前状态"])+`<div id="orgCards"></div>`;
bindToolbar("组织与单位",()=>renderOrgCards(false));
}
const filtered=data.filter(x=>matches(x,["名称","档案摘要","体系","架构层级","公开任务","区域关联","装备类别"]));
const cards=paginate(filtered).map(x=>`<article class="data-card" onclick="openDetail('org_units','${x.id}')"><div class="card-top"><div><h3>${e(x.名称)}</h3><div class="card-sub">${e(x.体系)} · ${e(x.架构层级)}</div></div><span class="card-type">${e(x.记录类别)}</span></div><p>${e(x.档案摘要)}</p><div class="card-roles">${a(x.公开任务).slice(0,2).map(y=>`<div class="role-line">${e(y)}</div>`).join("")}</div><div class="card-footer"><span>${e(x.行政上级)}</span><span>${e(x.当前状态)}</span></div></article>`).join("");
document.getElementById("orgCards").innerHTML=`<div class="card-grid">${cards||'<div class="empty">没有匹配结果</div>'}</div>${pagination(filtered.length,"renderOrgCards(false)")}`;
const count=document.getElementById("count");if(count)count.textContent=`共 ${filtered.length} 个节点`;
}
function categorySummary(data,field,onClick){
const counts={};data.forEach(x=>{const k=x[field]||"未分类";counts[k]=(counts[k]||0)+1});
return `<div class="category-grid">${Object.entries(counts).sort((a,b)=>b[1]-a[1]).slice(0,10).map(([k,v])=>`<div class="category-card" onclick="${onClick}('${e(k)}')"><b>${v}</b><span>${e(k)}</span></div>`).join("")}</div>`
}
function render_equipment(initial=true){
const data=DATA.equipment;
document.getElementById("page").innerHTML=hero("武器装备","以装备卡片展示类别、使用体系、公开作用和生命周期状态,不把规划、采购、测试和列装混为一谈。")+
categorySummary(data,"类别中文","filterEquipmentCategory")+toolbar("武器装备",data,["类别中文","状态中文","可信度中文"])+`<div id="equipmentCards"></div>`;
bindToolbar("武器装备",()=>renderEquipmentCards());
renderEquipmentCards();
}
function filterEquipmentCategory(v){state.filters["类别中文"]=v;state.page=1;render_equipment()}
function renderEquipmentCards(){
const data=DATA.equipment.filter(x=>matches(x,["名称","别名","类别中文","使用体系中文","公开作用","状态中文"]));
const html=paginate(data).map(x=>`<article class="data-card" onclick="openDetail('equipment','${x.id}')"><div class="card-top"><div><h3>${e(x.名称)}</h3><div class="card-sub">${e(x.类别中文)} · ${a(x.使用体系中文).join("、")}</div></div>${statusChip(x.状态中文)}</div><p>${e(x.档案摘要)}</p><div class="card-roles">${a(x.公开作用).slice(0,3).map(y=>`<div class="role-line">${e(y)}</div>`).join("")}</div><div class="card-footer"><span>可信度:${e(x.可信度中文)}</span><span>${e(x.verified_at||"未标注")}</span></div></article>`).join("");
document.getElementById("equipmentCards").innerHTML=`<div class="card-grid">${html||'<div class="empty">没有匹配结果</div>'}</div>${pagination(data.length,"renderEquipmentCards()")}`;
const c=document.getElementById("count");if(c)c.textContent=`共 ${data.length} 项装备`;
}
function render_key_nodes(){
const data=DATA.key_nodes;
document.getElementById("page").innerHTML=hero("关键节点","从公共功能和韧性角度展示港口、机场、交通、能源、通信、供水、医疗和产业节点。")+
categorySummary(data,"类别中文","filterNodeCategory")+toolbar("关键节点",data,["类别中文","层级中文","状态中文","可信度中文"])+`<div id="nodeCards"></div>`;
bindToolbar("关键节点",()=>renderNodeCards());renderNodeCards();
}
function filterNodeCategory(v){state.filters["类别中文"]=v;state.page=1;render_key_nodes()}
function renderNodeCards(){
const data=DATA.key_nodes.filter(x=>matches(x,["名称","类别中文","区域中文","主管运营","公共功能","状态中文"]));
const html=paginate(data).map(x=>`<article class="data-card" onclick="openDetail('key_nodes','${x.id}')"><div class="card-top"><div><h3>${e(x.名称)}</h3><div class="card-sub">${e(x.类别中文)} · ${e(x.层级中文)}</div></div>${statusChip(x.状态中文)}</div><p>${e(x.档案摘要)}</p><div>${chips(x.区域中文,true,3)}</div><div class="card-roles">${a(x.公共功能).slice(0,2).map(y=>`<div class="role-line">${e(y)}</div>`).join("")}</div><div class="card-footer"><span>${e(x.主管运营)}</span><span>可信度:${e(x.可信度中文)}</span></div></article>`).join("");
document.getElementById("nodeCards").innerHTML=`<div class="card-grid">${html||'<div class="empty">没有匹配结果</div>'}</div>${pagination(data.length,"renderNodeCards()")}`;
const c=document.getElementById("count");if(c)c.textContent=`共 ${data.length} 个节点`;
}
function render_events_projects(){
const events=DATA.events.map(x=>`<article class="time-card" onclick="openDetail('events','${x.id}')"><div class="time-date">${e(x.日期显示)}</div><h3 style="margin:0 0 7px">${e(x.名称)}</h3><div>${chips([x.类型中文,...a(x.区域中文)],true,4)}</div><p>${e(x.档案摘要)}</p></article>`).join("");
const projects=DATA.projects.map(x=>`<article class="data-card" onclick="openDetail('projects','${x.id}')"><div class="card-top"><div><h3>${e(x.名称)}</h3><div class="card-sub">${e(x.类别中文)}</div></div>${statusChip(x.状态中文)}</div><p>${e(x.档案摘要)}</p><div class="card-footer"><span>建设项目</span><span>${e(x.状态中文)}</span></div></article>`).join("");
document.getElementById("page").innerHTML=hero("演习与项目","演习采用时间线,建设项目采用卡片,分别表达日期型事件和生命周期型项目。")+
`<div class="grid2"><section class="panel"><div class="panel-title"><span>演习与重要事件</span></div><div class="timeline">${events}</div></section><section class="panel"><div class="panel-title"><span>现代化与建设项目</span></div><div class="card-grid" style="grid-template-columns:1fr">${projects}</div></section></div>`;
}
function findItem(domain,id){
if(domain==="regions")return DATA.regions.find(x=>x.编号===id);
if(domain==="org_units")return DATA.org_units.find(x=>x.id===id);
if(domain==="equipment")return DATA.equipment.find(x=>x.id===id);
if(domain==="key_nodes")return DATA.key_nodes.find(x=>x.id===id);
if(domain==="events")return DATA.events.find(x=>x.id===id);
if(domain==="projects")return DATA.projects.find(x=>x.id===id);
}
function openDetail(domain,id){
const x=findItem(domain,id);if(!x)return;
document.getElementById("page").style.display="none";
const d=document.getElementById("detailPage");d.classList.add("show");
if(domain==="org_units")d.innerHTML=detailOrg(x);
else if(domain==="regions")d.innerHTML=detailRegion(x);
else if(domain==="equipment")d.innerHTML=detailEquipment(x);
else if(domain==="key_nodes")d.innerHTML=detailNode(x);
else d.innerHTML=detailEventProject(x,domain);
location.hash=`#/detail/${domain}/${encodeURIComponent(id)}`;
window.scrollTo({top:0});
}
function back(){document.getElementById("detailPage").classList.remove("show");document.getElementById("page").style.display="block";location.hash="#/"+state.tab}
function info(label,value){return `<div class="info-item"><label>${e(label)}</label><div>${value===undefined||value===null||value===""?'<span style="color:#999">未公开或未标注</span>':value}</div></div>`}
function listBlock(values){const v=a(values);return v.length?`<div class="text-list">${v.map(x=>`<div class="text-item">${e(x)}</div>`).join("")}</div>`:`<div class="empty" style="padding:20px">暂无独立条目</div>`}
function section(title,body){return `<section class="detail-section"><h3>${title}</h3>${body}</section>`}
function detailHeader(title,summary,tags){return `<span class="back-link" onclick="back()" style="color:var(--red);cursor:pointer;display:inline-block;margin-bottom:12px">← 返回上一页</span><div class="detail-hero"><h2>${e(title)}</h2><p>${e(summary)}</p><div class="detail-tags">${chips(tags,false,10)}</div></div>`}
function detailOrg(x){
const core=section("组织与单位概况",`<div class="info-grid">${info("记录类别",e(x.记录类别))}${info("体系",e(x.体系))}${info("架构层级",e(x.架构层级))}${info("组织或单位类型",e(x.单位类型))}${info("当前状态",statusChip(x.当前状态))}${info("可信度",e(x.可信度))}${info("行政上级",e(x.行政上级))}${info("军令或任务上级",e(x.军令或任务上级))}</div>`);
const tasks=section("公开职掌与任务",listBlock(x.公开任务));
const assoc=section("区域、装备与演训关联",`<div class="info-grid">${info("区域关联",chips(x.区域关联,true,12))}${info("装备类别",chips(x.装备类别,true,12))}${info("演训与事件",chips(x.演训与事件,true,12))}${info("适用范围",e(x.适用范围))}</div>`);
const childrenSec=section("直接下级机构或单位",x.下级机构.length?`<div class="mini-card-grid">${x.下级机构.map((n,i)=>`<div class="mini-card" ${x.下级机构编号[i]?`onclick="openDetail('org_units','${x.下级机构编号[i]}')" style="cursor:pointer"`:""}><b>${e(n)}</b><span style="font-size:11px;color:#888">点击查看档案</span></div>`).join("")}</div>`:`<div class="empty" style="padding:20px">未列出直接下级</div>`);
const relSec=section("组织关系",x.关系.length?`<div class="mini-card-grid">${x.关系.slice(0,16).map(r=>`<div class="mini-card"><b>${e(r.关系)}</b><div style="font-size:12px;line-height:1.65">${e(r.主体)} → ${e(r.客体)}</div><span style="font-size:11px;color:#888">${e(r.可信度)}</span></div>`).join("")}</div>`:`<div class="empty" style="padding:20px">暂无独立关系条目</div>`);
const history=section("历史沿革与说明",`<div class="text-item">${e(x.历史沿革)}</div>${x.说明?`<div class="text-item">${e(x.说明)}</div>`:""}`);
const gaps=section("资料缺口",`<div class="warning-box">${a(x.资料缺口).map(y=>`• ${e(y)}`).join("<br>")}</div>`);
const sources=section("来源与核实",`<div class="info-grid">${info("核实日期",e(x.核实日期))}${info("来源数量",String(a(x.来源).length))}</div><div style="margin-top:10px">${a(x.来源信息||x.来源).map(s=>`<div class="source-line">${e(s)}</div>`).join("")||'<div class="source-line">尚未列出独立来源编号</div>'}</div>`);
const safe=section("安全边界",`<div class="safe-box">${e(x.安全边界)}</div>`);
return detailHeader(x.名称,x.档案摘要,[x.记录类别,x.体系,x.当前状态])+`<div class="detail-grid"><div>${core}${tasks}${assoc}${childrenSec}${relSec}</div><aside>${history}${sources}${gaps}${safe}</aside></div>`;
}
function detailRegion(x){
const countyCards=x.县市子库.map(c=>`<div class="mini-card"><b>${e(c.name||c.county_name||c.名称||"县市子库")}</b><div style="font-size:12px;color:#777">${e(c.scope||c.沿岸范围||"沿岸县市档案")}</div></div>`).join("");
const segCards=x.海岸分段.map(s=>`<div class="mini-card"><b>${e(s.name||s.名称)}</b><div>${chips([s.coastal_type||s.海岸类型,s.所属区域],true,3)}</div><p style="font-size:11px;color:#777">${e(s.scope||s.空间范围||"")}</p></div>`).join("");
return detailHeader(x.名称,x.档案摘要,[x.编号,...a(x.海岸形态)])+
`<div class="detail-grid"><div>${section("区域基本信息",`<div class="info-grid">${info("空间范围",e(x.scope))}${info("县市数量",String(x.县市数量))}${info("海岸分段数量",String(x.分段数量))}${info("核实日期",e(x.last_verified||"未标注"))}${info("沿海县市",chips(x.沿海县市,true,15))}${info("支撑区域",chips(x.支撑区域,true,15))}</div>`)}${section("海岸形态",listBlock(x.海岸形态))}${section("海岸研究分段",`<div class="mini-card-grid">${segCards}</div>`)}</div><aside>${section("县市子库",`<div class="mini-card-grid" style="grid-template-columns:1fr">${countyCards}</div>`)}${section("资料来源",`<div>${a(x.来源信息||x.source_ids).map(s=>`<div class="source-line">${e(s)}</div>`).join("")||'<div class="source-line">待进一步补充</div>'}</div>`)}${section("研究边界",`<div class="safe-box">区域分段用于公开空间、治理和韧性研究,不等于军事防区、实际部署边界或行动分区。</div>`)}</aside></div>`;
}
function detailEquipment(x){
return detailHeader(x.名称,x.档案摘要,[x.类别中文,x.状态中文,...a(x.使用体系中文)])+
`<div class="detail-grid"><div>${section("装备概况",`<div class="info-grid">${info("装备类别",e(x.类别中文))}${info("使用体系",chips(x.使用体系中文,true,10))}${info("公开状态",statusChip(x.状态中文))}${info("可信度",e(x.可信度中文))}${info("别名",chips(x.aliases,true,10))}${info("核实日期",e(x.verified_at||"未标注"))}</div>`)}${section("公开作用",listBlock(x.公开作用))}${section("生命周期解释",`<div class="text-list"><div class="text-item">规划、预算、签约、交付、接装、训练、测评和形成能力属于不同阶段。</div><div class="text-item">本条目的当前状态为:${e(x.状态中文)}。</div><div class="text-item">装备价值需与感知、指挥、平台、保障和训练共同评估。</div></div>`)}</div><aside>${section("来源",a(x.来源信息||x.sources).map(s=>`<div class="source-line">${e(s)}</div>`).join("")||'<div class="source-line">待补</div>')}${section("备注",`<div class="text-item">${e(x.notes||"无额外公开说明。")}</div>`)}${section("资料边界",`<div class="safe-box">不记录或推断库存、精确部署、妥善率、射界、航线、战法和单位实际配赋。</div>`)}</aside></div>`;
}
function detailNode(x){
return detailHeader(x.名称,x.档案摘要,[x.类别中文,x.层级中文,x.状态中文])+
`<div class="detail-grid"><div>${section("节点概况",`<div class="info-grid">${info("节点类别",e(x.类别中文))}${info("节点层级",e(x.层级中文))}${info("所属区域",chips(x.区域中文,true,10))}${info("运行状态",statusChip(x.状态中文))}${info("主管或运营主体",e(x.主管运营))}${info("可信度",e(x.可信度中文))}</div>`)}${section("公共功能",listBlock(x.公共功能))}${section("上游依赖",listBlock(x.依赖类别))}${section("替代与备援",listBlock(x.替代备援))}</div><aside>${section("来源",a(x.来源信息||x.sources).map(s=>`<div class="source-line">${e(s)}</div>`).join("")||'<div class="source-line">待补</div>')}${section("资料说明",`<div class="text-item">${e(x.note||"无额外公开说明。")}</div>`)}${section("研究边界",`<div class="safe-box">节点用于公共功能、治理和韧性研究,不展示精确坐标、内部拓扑、实时容量、库存、脆弱点或攻击价值。</div>`)}</aside></div>`;
}
function detailEventProject(x,domain){
const isEvent=domain==="events";
return detailHeader(x.名称,x.档案摘要,[isEvent?x.类型中文:x.类别中文,x.状态中文])+
`<div class="detail-grid"><div>${section(isEvent?"事件信息":"项目信息",`<div class="info-grid">${info(isEvent?"事件类型":"项目类别",e(isEvent?x.类型中文:x.类别中文))}${info("状态",statusChip(x.状态中文))}${info("日期",e(x.日期显示||"不适用"))}${info("区域",chips(x.区域中文,true,12))}</div>`)}${section(isEvent?"主题与验证重点":"项目评估链条",isEvent?listBlock(x.topics):`<div class="text-list"><div class="text-item">规划与需求确认</div><div class="text-item">预算与采购</div><div class="text-item">生产、交付与接装</div><div class="text-item">训练、测评、保障与形成能力</div></div>`)}</div><aside>${section("来源",`<div class="source-line">${e(a(x.来源信息).join(";")||x.source||"待补")}</div>`)}${section("分析限制",`<div class="safe-box">${isEvent?"单次演习不能直接证明永久编制、完整战备或真实作战效果。":"项目存在不等于已经交付、接装或形成能力。"}</div>`)}</aside></div>`;
}
function openQueryBuilder(domainName){
const conf=QUERY_SCHEMA.查询域[domainName];if(!conf)return;
const opts=conf.字段.map(x=>`<option value="${e(x.字段)}">${e(x.显示名)}</option>`).join("");
const rows=(state.conditions.length?state.conditions:[{field:conf.字段[0].字段,op:"包含",value:""}]).map((c,i)=>queryRow(opts,c,i)).join("");
openModal(`<h2 style="margin-top:0">组合条件查询</h2><p style="color:#777;font-size:13px">使用中文字段构造条件,查询只作用于当前业务页面。</p><div id="queryRows">${rows}</div><button class="btn" onclick="addQueryRow('${domainName}')">增加条件</button> <button class="btn btn-red" onclick="applyQuery('${domainName}')">应用查询</button>`);
}
function queryRow(opts,c,i){return `<div class="query-row" data-query-row><select class="q-field">${opts.replace(`value="${c.field}"`,`value="${c.field}" selected`)}</select><select class="q-op"><option ${c.op==="包含"?"selected":""}>包含</option><option ${c.op==="等于"?"selected":""}>等于</option><option ${c.op==="属于"?"selected":""}>属于</option></select><input class="q-value" value="${e(c.value)}" placeholder="输入查询值"><button class="btn" onclick="this.parentElement.remove()">×</button></div>`}
function addQueryRow(domainName){const conf=QUERY_SCHEMA.查询域[domainName];const opts=conf.字段.map(x=>`<option value="${e(x.字段)}">${e(x.显示名)}</option>`).join("");document.getElementById("queryRows").insertAdjacentHTML("beforeend",queryRow(opts,{field:conf.字段[0].字段,op:"包含",value:""},99))}
function applyQuery(){state.conditions=[...document.querySelectorAll("[data-query-row]")].map(r=>({field:r.querySelector(".q-field").value,op:r.querySelector(".q-op").value,value:r.querySelector(".q-value").value})).filter(x=>x.value);state.page=1;closeModal();render()}
function openModal(html){document.getElementById("modal").innerHTML=html;document.getElementById("modalOverlay").classList.add("show")}
function closeModal(){document.getElementById("modalOverlay").classList.remove("show")}
function closeDrawer(){document.getElementById("drawerOverlay").classList.remove("show")}
document.getElementById("modalOverlay").onclick=x=>{if(x.target.id==="modalOverlay")closeModal()};
document.getElementById("drawerOverlay").onclick=x=>{if(x.target.id==="drawerOverlay")closeDrawer()};
const hash=location.hash.match(/^#\/([^/]+)/);if(hash&&MANIFEST.页面.some(x=>x.编号===hash[1]))state.tab=hash[1];render();
</script>
</body>
</html>
@@ -0,0 +1,322 @@
<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>台湾飞行部队知识数据展示平台 v3</title><style>
:root{
--red:#b4232d;--red2:#d94a54;--red3:#fff1f2;--red4:#7e1620;
--bg:#f4f6f8;--white:#fff;--text:#1e2329;--muted:#667085;
--line:#e5e7eb;--soft:#f8fafc;--green:#14804a;--amber:#b4690e;
--shadow:0 12px 36px rgba(31,41,55,.09);
}
*{box-sizing:border-box}
html,body{margin:0;min-height:100%;font-family:"Microsoft YaHei","PingFang SC","Noto Sans CJK SC",Arial,sans-serif;color:var(--text);background:var(--bg)}
button,input,select{font:inherit}
button{cursor:pointer}
a{color:var(--red);text-decoration:none}
.hidden{display:none!important}
.app{min-height:100vh;display:flex;flex-direction:column}
.header{height:76px;background:linear-gradient(120deg,var(--red4),var(--red));color:#fff;display:flex;align-items:center;justify-content:space-between;padding:0 28px;box-shadow:0 5px 18px rgba(126,22,32,.22);position:sticky;top:0;z-index:50}
.brand{display:flex;align-items:center;gap:14px}
.brand-mark{width:45px;height:45px;border:2px solid rgba(255,255,255,.72);border-radius:13px;display:grid;place-items:center;font-weight:800;font-size:21px;background:rgba(255,255,255,.1)}
.brand h1{font-size:21px;margin:0 0 4px;letter-spacing:.5px}
.brand p{font-size:12px;margin:0;color:rgba(255,255,255,.78)}
.header-actions{display:flex;align-items:center;gap:10px}
.search{position:relative}
.search input{width:330px;border:1px solid rgba(255,255,255,.25);background:rgba(255,255,255,.13);color:#fff;border-radius:10px;padding:11px 42px 11px 14px;outline:none}
.search input::placeholder{color:rgba(255,255,255,.72)}
.search .icon{position:absolute;right:13px;top:10px;font-size:18px}
.search-pop{position:absolute;right:0;top:48px;width:460px;max-height:460px;overflow:auto;background:#fff;color:var(--text);border:1px solid var(--line);border-radius:12px;box-shadow:var(--shadow);padding:7px;z-index:120}
.search-item{display:flex;gap:10px;padding:11px;border-radius:9px;border-bottom:1px dashed #eee}
.search-item:hover{background:var(--red3)}
.search-kind{min-width:58px;color:var(--red);font-size:12px;font-weight:700}
.search-item strong{display:block;font-size:14px;margin-bottom:4px}
.search-item small{color:var(--muted);line-height:1.45}
.btn{border:0;border-radius:9px;padding:10px 15px;font-weight:700;transition:.18s}
.btn.primary{background:var(--red);color:#fff}
.btn.primary:hover{background:var(--red4)}
.btn.white{background:#fff;color:var(--red)}
.btn.ghost{background:rgba(255,255,255,.12);color:#fff;border:1px solid rgba(255,255,255,.25)}
.btn.light{background:var(--red3);color:var(--red)}
.btn.line{background:#fff;color:var(--red);border:1px solid #efb7bc}
.btn.sm{padding:7px 11px;font-size:12px}
.tabs{height:56px;background:#fff;display:flex;align-items:end;gap:2px;padding:0 24px;border-bottom:1px solid var(--line);position:sticky;top:76px;z-index:40;overflow-x:auto}
.tab{border:0;background:transparent;color:#4b5563;font-weight:700;padding:17px 18px 14px;white-space:nowrap;border-bottom:3px solid transparent}
.tab:hover{color:var(--red)}
.tab.active{color:var(--red);border-bottom-color:var(--red)}
.main{width:min(1540px,100%);margin:auto;padding:24px 28px 54px;flex:1}
.page{display:none}
.page.active{display:block}
.page-head{display:flex;justify-content:space-between;gap:18px;align-items:flex-start;margin-bottom:20px}
.page-head h2{margin:0 0 7px;font-size:25px}
.page-head p{margin:0;color:var(--muted);line-height:1.65}
.page-actions{display:flex;gap:9px;flex-wrap:wrap}
.stats{display:grid;grid-template-columns:repeat(6,1fr);gap:14px}
.stat{background:#fff;border:1px solid var(--line);border-top:4px solid var(--red);border-radius:13px;padding:17px;box-shadow:0 5px 17px rgba(31,41,55,.04)}
.stat .label{color:var(--muted);font-size:13px}
.stat .num{font-size:29px;font-weight:850;margin:9px 0 3px}
.stat .hint{font-size:12px;color:#98a2b3}
.grid{display:grid;gap:15px}
.grid.cols2{grid-template-columns:repeat(2,minmax(0,1fr))}
.grid.cols3{grid-template-columns:repeat(3,minmax(0,1fr))}
.grid.cols4{grid-template-columns:repeat(4,minmax(0,1fr))}
.panel{background:#fff;border:1px solid var(--line);border-radius:14px;box-shadow:0 6px 22px rgba(31,41,55,.045);padding:18px}
.panel-head{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:15px}
.panel h3,.panel h4{margin:0}
.panel-head p{margin:4px 0 0;color:var(--muted);font-size:12px}
.section-title{display:flex;justify-content:space-between;align-items:end;margin:25px 0 13px}
.section-title h3{font-size:18px;margin:0}
.section-title span{color:var(--muted);font-size:12px}
.entry{background:#fff;border:1px solid var(--line);border-radius:14px;padding:19px;min-height:150px;position:relative;overflow:hidden;box-shadow:0 5px 18px rgba(31,41,55,.045)}
.entry:before{content:"";position:absolute;right:-25px;top:-35px;width:110px;height:110px;border-radius:50%;background:var(--red3)}
.entry .letter{position:relative;width:39px;height:39px;border-radius:10px;background:var(--red);color:#fff;display:grid;place-items:center;font-weight:800;margin-bottom:16px}
.entry strong{font-size:16px}
.entry p{color:var(--muted);line-height:1.65;font-size:13px}
.entry:hover{transform:translateY(-2px);box-shadow:var(--shadow);border-color:#efb7bc}
.notice{background:linear-gradient(120deg,#fff8f8,#fff);border:1px solid #f2c6ca;border-left:4px solid var(--red);border-radius:10px;padding:14px;line-height:1.7;color:#5e3034}
.badge,.chip{display:inline-flex;align-items:center;border-radius:999px;padding:5px 9px;font-size:12px;font-weight:700;margin:2px 4px 2px 0}
.badge.red,.chip.red{background:var(--red3);color:var(--red)}
.badge.green{background:#e9f8ef;color:var(--green)}
.badge.amber{background:#fff5e6;color:var(--amber)}
.badge.gray{background:#f1f3f5;color:#667085}
.bar-row{display:grid;grid-template-columns:90px 1fr 35px;align-items:center;gap:10px;margin:14px 0;font-size:13px}
.bar-track{height:9px;border-radius:8px;background:#f0f1f3;overflow:hidden}
.bar-fill{height:100%;background:linear-gradient(90deg,var(--red),var(--red2));border-radius:8px}
.unit-card{background:#fff;border:1px solid var(--line);border-radius:14px;padding:17px;display:flex;flex-direction:column;min-height:245px;box-shadow:0 5px 18px rgba(31,41,55,.04)}
.unit-card:hover{border-color:#e8a2a8;box-shadow:var(--shadow);transform:translateY(-2px)}
.unit-card .top{display:flex;justify-content:space-between;gap:12px}
.unit-card h4{font-size:16px;margin:0 0 5px}
.unit-card .sub{color:var(--muted);font-size:12px}
.unit-card p{color:#555f6d;line-height:1.65;font-size:13px;flex:1}
.unit-card .foot{display:flex;justify-content:space-between;align-items:center;margin-top:13px}
.filters{display:grid;grid-template-columns:1.4fr repeat(4,1fr) auto;gap:10px;background:#fff;border:1px solid var(--line);border-radius:13px;padding:13px;margin-bottom:15px}
.input,.select{width:100%;border:1px solid #d8dde4;border-radius:9px;padding:10px 11px;background:#fff;outline:none}
.input:focus,.select:focus{border-color:var(--red);box-shadow:0 0 0 3px rgba(180,35,45,.08)}
.view-switch{display:flex;gap:5px;background:#f5f6f8;padding:4px;border-radius:9px}
.view-switch button{border:0;background:transparent;padding:7px 10px;border-radius:7px;color:#667085}
.view-switch button.active{background:#fff;color:var(--red);box-shadow:0 2px 8px rgba(0,0,0,.07)}
.table-wrap{overflow:auto;border:1px solid var(--line);border-radius:12px;background:#fff}
table{width:100%;border-collapse:collapse;min-width:900px}
th{position:sticky;top:0;background:#fff5f5;color:#6e1e24;text-align:left;font-size:13px;padding:12px;border-bottom:1px solid #f1c8cc;z-index:2}
td{font-size:13px;padding:12px;border-bottom:1px solid #edf0f2;vertical-align:top;line-height:1.55}
tr:hover td{background:#fffafa}
.pagination{display:flex;justify-content:center;align-items:center;gap:8px;margin-top:16px}
.org-layout{display:grid;grid-template-columns:230px minmax(0,1fr) 305px;gap:15px}
.side-nav,.side-info{background:#fff;border:1px solid var(--line);border-radius:13px;padding:14px;height:max-content;position:sticky;top:151px}
.side-nav button{width:100%;border:0;background:#fff;text-align:left;padding:11px;border-radius:8px;color:#475467;font-weight:700}
.side-nav button.active,.side-nav button:hover{background:var(--red3);color:var(--red)}
.org-canvas{background:#fff;border:1px solid var(--line);border-radius:14px;overflow:auto;min-height:610px;padding:22px}
.org-toolbar{display:flex;justify-content:space-between;gap:10px;align-items:center;margin-bottom:18px}
.zoom{display:flex;gap:5px}
.tree{--scale:1;transform:scale(var(--scale));transform-origin:top center;min-width:980px;padding:10px 35px 50px}
.tree ul{padding-top:24px;position:relative;display:flex;justify-content:center}
.tree li{list-style:none;text-align:center;position:relative;padding:24px 7px 0}
.tree li:before,.tree li:after{content:"";position:absolute;top:0;width:50%;height:24px;border-top:1px solid #d8a5a9}
.tree li:before{right:50%;border-right:1px solid #d8a5a9}
.tree li:after{left:50%;border-left:1px solid #d8a5a9}
.tree li:only-child:before,.tree li:only-child:after{display:none}
.tree li:first-child:before,.tree li:last-child:after{border:0}
.tree li:last-child:before{border-radius:0 7px 0 0}
.tree li:first-child:after{border-radius:7px 0 0 0}
.tree ul ul:before{content:"";position:absolute;top:0;left:50%;height:24px;border-left:1px solid #d8a5a9}
.org-node{width:155px;min-height:62px;padding:10px 8px;border:1px solid #e4aab0;background:#fff;border-radius:10px;display:inline-flex;flex-direction:column;justify-content:center;box-shadow:0 4px 12px rgba(31,41,55,.055)}
.org-node.root{background:var(--red);color:#fff;border-color:var(--red)}
.org-node.branch{background:#fff2f3;color:var(--red4);font-weight:800}
.org-node:hover{box-shadow:0 9px 23px rgba(180,35,45,.15);transform:translateY(-2px)}
.org-node small{font-size:10px;margin-top:5px;color:#8b8f99}
.org-node.root small{color:rgba(255,255,255,.75)}
.kv{display:grid;grid-template-columns:86px 1fr;gap:9px;padding:10px 0;border-bottom:1px dashed #e7e8eb;font-size:13px}
.kv .k{color:var(--muted)}
.kv .v{font-weight:650;line-height:1.55}
.equipment-layout{display:grid;grid-template-columns:220px minmax(0,1fr);gap:15px}
.category-list button{width:100%;display:flex;justify-content:space-between;border:0;background:#fff;padding:11px;border-radius:8px;margin:3px 0;color:#475467}
.category-list button.active,.category-list button:hover{background:var(--red3);color:var(--red);font-weight:700}
.equip-card{background:#fff;border:1px solid var(--line);border-radius:13px;padding:16px;min-height:190px;display:flex;flex-direction:column}
.equip-card h4{margin:0 0 5px}
.equip-card p{font-size:13px;color:var(--muted);line-height:1.6;flex:1}
.flow{display:flex;align-items:stretch;gap:9px;overflow:auto;padding:5px 0 15px}
.flow-step{min-width:150px;background:#fff;border:1px solid #efb9be;border-radius:12px;padding:14px;position:relative}
.flow-step:not(:last-child):after{content:"›";position:absolute;right:-10px;top:40%;font-size:25px;color:var(--red)}
.flow-step .no{width:26px;height:26px;border-radius:50%;background:var(--red);color:#fff;display:grid;place-items:center;font-size:12px;font-weight:800}
.flow-step h4{margin:10px 0 5px}
.flow-step p{font-size:12px;color:var(--muted);line-height:1.55}
.lane{display:grid;grid-template-columns:140px 1fr;gap:12px;align-items:start;margin-bottom:12px}
.lane-title{background:var(--red);color:#fff;border-radius:10px;padding:14px;font-weight:800}
.lane-items{display:flex;gap:9px;flex-wrap:wrap}
.lane-item{background:#fff;border:1px solid var(--line);border-radius:10px;padding:12px;min-width:150px;flex:1}
.industry-stack{display:grid;gap:12px}
.industry-layer{background:#fff;border:1px solid var(--line);border-left:5px solid var(--red);border-radius:12px;padding:15px;display:grid;grid-template-columns:190px 1fr;gap:14px}
.risk-card{background:#fff;border:1px solid var(--line);border-radius:12px;padding:15px}
.risk-card h4{margin:0 0 8px;color:var(--red4)}
.timeline{position:relative;padding-left:27px}
.timeline:before{content:"";position:absolute;left:8px;top:7px;bottom:5px;width:2px;background:#e6b3b8}
.event{position:relative;background:#fff;border:1px solid var(--line);border-radius:11px;padding:14px;margin-bottom:12px}
.event:before{content:"";position:absolute;left:-25px;top:17px;width:12px;height:12px;border-radius:50%;background:var(--red);border:3px solid #ffd8dc}
.event .date{font-size:12px;color:var(--red);font-weight:800}
.event h4{margin:5px 0}
.event p{margin:0;color:var(--muted);font-size:13px;line-height:1.6}
.source-layout{display:grid;grid-template-columns:1.15fr .85fr;gap:15px}
.source-card{border:1px solid var(--line);background:#fff;border-radius:11px;padding:14px;margin-bottom:10px}
.source-card:hover{border-color:#e5a4aa;background:#fffafa}
.source-card h4{margin:0 0 7px}
.source-card p{margin:0;color:var(--muted);font-size:13px;line-height:1.55}
.empty{padding:40px;text-align:center;color:var(--muted)}
.modal,.drawer,.detail,.query-panel{position:fixed;inset:0}.detail{z-index:200}.drawer{z-index:320}.modal{z-index:420}.query-panel{z-index:500}
.backdrop{position:absolute;inset:0;background:rgba(20,24,31,.52);backdrop-filter:blur(2px)}
.modal-card{position:relative;width:min(820px,calc(100% - 34px));max-height:86vh;overflow:auto;margin:7vh auto;background:#fff;border-radius:16px;box-shadow:0 24px 80px rgba(0,0,0,.24);padding:26px}
.close{position:absolute;right:15px;top:14px;border:0;width:34px;height:34px;border-radius:9px;background:#f2f4f7;font-size:21px;color:#667085}
.drawer-card{position:absolute;right:0;top:0;height:100%;width:min(650px,92vw);background:#fff;box-shadow:-12px 0 45px rgba(0,0,0,.2);overflow:auto;padding:68px 25px 30px}
.drawer-head{position:absolute;right:0;top:0;width:min(650px,92vw);height:58px;background:#fff;border-bottom:1px solid var(--line);display:flex;align-items:center;justify-content:space-between;padding:0 20px;z-index:3}
.detail{background:#f4f6f8;overflow:auto}
.detail-top{height:72px;background:var(--red4);color:#fff;display:flex;align-items:center;justify-content:space-between;padding:0 25px;position:sticky;top:0;z-index:3}
.detail-body{width:min(1320px,100%);margin:auto;padding:24px}
.detail-hero{background:linear-gradient(120deg,var(--red),#d84955);color:#fff;border-radius:16px;padding:25px;display:grid;grid-template-columns:1fr 330px;gap:20px;box-shadow:var(--shadow)}
.detail-hero h2{font-size:27px;margin:0 0 9px}
.detail-hero p{line-height:1.7;color:rgba(255,255,255,.86)}
.detail-summary{background:rgba(255,255,255,.13);border:1px solid rgba(255,255,255,.2);border-radius:12px;padding:13px}
.detail-nav{display:flex;gap:4px;background:#fff;border:1px solid var(--line);border-radius:12px;padding:6px;margin:15px 0;position:sticky;top:84px;z-index:2;overflow:auto}
.detail-nav button{border:0;background:transparent;padding:10px 14px;border-radius:8px;font-weight:700;color:#667085;white-space:nowrap}
.detail-nav button.active{background:var(--red3);color:var(--red)}
.detail-section{scroll-margin-top:150px;margin-bottom:15px}
.detail-section h3{border-left:4px solid var(--red);padding-left:10px}
.rich{line-height:1.82;color:#475467}
.query-card{position:relative;width:min(1180px,calc(100% - 30px));height:88vh;margin:6vh auto;background:#fff;border-radius:16px;box-shadow:0 24px 80px rgba(0,0,0,.24);display:grid;grid-template-columns:310px 1fr;overflow:hidden}
.query-form{background:#fff8f8;border-right:1px solid #f2c9cd;padding:25px}
.query-result{padding:25px;overflow:auto}
.query-row{border:1px solid var(--line);border-radius:10px;padding:12px;margin-bottom:9px}
.query-row:hover{border-color:#e4a3aa;background:#fffafa}
.json{white-space:pre-wrap;word-break:break-word;background:#1f242b;color:#f5f7fa;padding:16px;border-radius:10px;font:12px/1.65 Consolas,monospace}
.footer{background:#fff;border-top:1px solid var(--line);color:#777f8b;padding:16px 28px;display:flex;justify-content:space-between;font-size:12px}
.mt{margin-top:15px}.mb{margin-bottom:15px}
@media(max-width:1180px){.stats{grid-template-columns:repeat(3,1fr)}.org-layout{grid-template-columns:200px 1fr}.side-info{display:none}.grid.cols4{grid-template-columns:repeat(2,1fr)}}
@media(max-width:820px){.header{height:auto;padding:13px 16px;align-items:flex-start}.brand p{display:none}.header-actions{display:none}.tabs{top:69px;padding-left:8px}.main{padding:17px 12px 45px}.stats{grid-template-columns:repeat(2,1fr)}.grid.cols2,.grid.cols3,.grid.cols4{grid-template-columns:1fr}.filters{grid-template-columns:1fr 1fr}.org-layout,.equipment-layout,.source-layout{grid-template-columns:1fr}.side-nav{position:static}.org-canvas{min-height:500px}.detail-hero{grid-template-columns:1fr}.query-card{grid-template-columns:1fr;height:94vh;margin:3vh auto}.query-form{border-right:0;border-bottom:1px solid #f2c9cd}.footer{display:block;line-height:1.8}}
.knowledge-layout{display:grid;grid-template-columns:230px minmax(0,1fr);gap:16px}
.knowledge-side{background:#fff;border:1px solid var(--line);border-radius:14px;padding:14px;height:max-content;position:sticky;top:151px}
.knowledge-side button{width:100%;border:0;background:#fff;text-align:left;padding:10px 11px;border-radius:8px;color:#475467;display:flex;justify-content:space-between;gap:8px}
.knowledge-side button:hover,.knowledge-side button.active{background:var(--red3);color:var(--red);font-weight:750}
.knowledge-card{background:#fff;border:1px solid var(--line);border-radius:14px;padding:18px;display:flex;flex-direction:column;min-height:220px;box-shadow:0 5px 18px rgba(31,41,55,.04)}
.knowledge-card:hover{border-color:#e5a3a9;box-shadow:var(--shadow);transform:translateY(-2px)}
.knowledge-card h4{margin:0 0 8px;font-size:16px}.knowledge-card p{color:var(--muted);line-height:1.7;font-size:13px;flex:1}
.knowledge-meta{display:flex;gap:6px;flex-wrap:wrap;margin:8px 0}.knowledge-path{font:11px Consolas,monospace;color:#8b929d;word-break:break-all;margin-top:8px}
.reader-layout{display:grid;grid-template-columns:245px minmax(0,1fr);gap:17px;align-items:start}
.reader-toc{background:#fff;border:1px solid var(--line);border-radius:13px;padding:13px;position:sticky;top:95px;max-height:calc(100vh - 120px);overflow:auto}
.reader-toc h4{margin:4px 8px 10px}.reader-toc button{width:100%;border:0;background:#fff;text-align:left;padding:9px 10px;border-radius:7px;color:#596273;font-size:13px;line-height:1.45}
.reader-toc button:hover,.reader-toc button.active{background:var(--red3);color:var(--red);font-weight:750}
.reader-main{min-width:0}.md-section{background:#fff;border:1px solid var(--line);border-radius:14px;padding:22px 25px;margin-bottom:15px;scroll-margin-top:95px;box-shadow:0 4px 16px rgba(31,41,55,.035)}
.md-section>h2{margin:0 -25px 19px;padding:0 25px 14px;border-bottom:1px solid #eee;font-size:20px;color:#6f1820}
.md-body{font-size:14px;line-height:1.92;color:#394150}.md-body h1,.md-body h2{color:#741923}.md-body h3{margin-top:25px;color:#8f2029;font-size:17px}.md-body h4{color:#333b46}
.md-body p{margin:10px 0}.md-body ul,.md-body ol{padding-left:25px}.md-body li{margin:5px 0}.md-body blockquote{margin:14px 0;padding:12px 15px;border-left:4px solid var(--red);background:#fff6f7;color:#5c3437;border-radius:0 8px 8px 0}
.md-body table{min-width:0;width:100%;display:table;margin:15px 0;border:1px solid var(--line);border-radius:9px;overflow:hidden}.md-body th{position:static;background:#fff1f2;color:#701920}.md-body td,.md-body th{border:1px solid #eceff2;padding:9px 11px}
.md-body pre{background:#20252c;color:#f5f6f7;border-radius:10px;padding:15px;overflow:auto;line-height:1.65}.md-body code{background:#f1f2f4;border-radius:5px;padding:2px 5px;font-family:Consolas,monospace;color:#8d2630}.md-body pre code{background:transparent;color:inherit;padding:0}
.md-body hr{border:0;border-top:1px solid #eceef1;margin:22px 0}.md-body strong{color:#252a31}.inline-ref,.inline-doc{border:0;border-radius:5px;background:#fff0f1;color:var(--red);padding:2px 6px;font:inherit;cursor:pointer}.inline-doc{font-family:Consolas,monospace;word-break:break-all}
.record-hero{background:linear-gradient(120deg,#fff5f6,#fff);border:1px solid #efc5c9;border-left:5px solid var(--red);border-radius:13px;padding:18px;margin:12px 0 17px}.record-hero h2{margin:0 0 8px}.record-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:13px}.record-group{background:#fff;border:1px solid var(--line);border-radius:12px;padding:15px}.record-group h3{margin:0 0 12px;color:#751b23}.record-field{display:grid;grid-template-columns:125px 1fr;gap:10px;padding:10px 0;border-bottom:1px dashed #e7e9ed;font-size:13px;line-height:1.65}.record-field:last-child{border-bottom:0}.record-field .label{color:#737b88}.record-field .value{font-weight:600;word-break:break-word}
.related-docs{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px}.related-doc{border:1px solid #ecd1d4;background:#fffafa;border-radius:10px;padding:12px;cursor:pointer}.related-doc:hover{border-color:var(--red)}.related-doc strong{display:block;margin-bottom:6px;color:#741b23}.related-doc p{margin:0;color:#69717e;font-size:12px;line-height:1.55}
.field-list{display:grid;gap:9px}.field-item{border:1px solid var(--line);border-radius:10px;padding:11px;background:#fff}.field-item strong{display:block;color:#7a1e26;margin-bottom:5px}.human-table{width:100%;min-width:0}.human-table th{position:static;width:170px}.technical-details{margin-top:15px;border:1px solid #e1e4e8;border-radius:10px;background:#fafbfc;padding:10px 13px}.technical-details summary{cursor:pointer;color:#666f7c;font-weight:700}.technical-details table{margin-top:10px}
.knowledge-preview{background:#fff;border:1px solid var(--line);border-radius:13px;padding:16px;margin-top:14px}.knowledge-preview h3{margin-top:0}.knowledge-preview .md-body{max-height:470px;overflow:auto;padding-right:6px}
.source-ref-btn{display:inline-flex;align-items:center;gap:5px;border:1px solid #e6aeb3;border-radius:7px;background:#fff4f5;color:var(--red);padding:3px 8px;margin:1px 3px;font:inherit;font-size:12px;font-weight:700;vertical-align:middle}.source-ref-btn:hover{background:var(--red);color:#fff}.source-ref-btn:before{content:"源";display:grid;place-items:center;width:18px;height:18px;border-radius:5px;background:rgba(180,35,45,.12);font-size:10px}.source-ref-btn:hover:before{background:rgba(255,255,255,.2)}
.missing-source{border-style:dashed;color:#9a6700;background:#fff8e6}.missing-source:before{content:"待";background:#fff0c2}
.org-ref-btn{display:inline-flex;align-items:center;border:1px solid #d7dce2;border-radius:7px;background:#f7f8fa;color:#344054;padding:3px 8px;margin:1px 3px;font:inherit;font-size:12px;font-weight:700}.org-ref-btn:hover{border-color:var(--red);color:var(--red);background:#fff5f6}
.unit-ref-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:9px;margin-top:5px}.unit-ref-card{border:1px solid #ead0d3;border-radius:10px;background:#fffafa;padding:10px 11px;text-align:left;color:#303640;min-width:0}.unit-ref-card:hover{border-color:var(--red);box-shadow:0 5px 15px rgba(180,35,45,.1)}.unit-ref-card strong{display:block;color:#771b24;margin-bottom:4px;white-space:normal}.unit-ref-card small{display:block;color:#747c89;line-height:1.45;white-space:normal}.unit-ref-card.static{cursor:default}.unit-ref-card.static:hover{box-shadow:none;border-color:#ead0d3}
.object-value-list{display:grid;gap:7px}.object-value-item{border:1px solid #e2e5e9;border-radius:8px;background:#fafbfc;padding:8px 10px}.object-value-item strong{display:block;color:#771b24;margin-bottom:3px}.object-value-item span{display:block;color:#69717e;font-size:12px;line-height:1.5}
@media(max-width:760px){.unit-ref-grid{grid-template-columns:1fr}}
@media(max-width:900px){.knowledge-layout,.reader-layout{grid-template-columns:1fr}.knowledge-side,.reader-toc{position:static;max-height:none}.record-grid,.related-docs{grid-template-columns:1fr}.md-section{padding:17px}.md-section>h2{margin-left:-17px;margin-right:-17px;padding-left:17px;padding-right:17px}}
.offline-source-panel{border:1px solid #efc1c5;background:#fffafa;border-radius:13px;padding:16px;margin:14px 0}
.offline-source-panel h3{margin:0 0 10px;color:var(--red4)}
.offline-status{display:inline-flex;align-items:center;gap:6px;border-radius:999px;padding:5px 10px;background:#f2f4f7;color:#475467;font-size:12px;font-weight:700}
.offline-status.available{background:#e9f8ef;color:#14804a}.offline-status.context{background:#fff3e8;color:#a55a08}
.citation-context{border:1px solid var(--line);border-left:4px solid var(--red);background:#fff;border-radius:10px;padding:13px;margin:10px 0;cursor:pointer}
.citation-context:hover{border-color:#dfa0a6;background:#fff8f8}.citation-context h4{margin:0 0 5px}.citation-context p{margin:0;color:#667085;line-height:1.7;font-size:13px}
.offline-link-disabled{color:#667085;border-bottom:1px dotted #98a2b3;cursor:default}.offline-reference-note{font-size:12px;color:#667085;margin-top:6px}
.structured-mention{display:grid;grid-template-columns:92px 1fr;gap:10px;border:1px solid var(--line);border-radius:10px;padding:12px;margin:9px 0;background:#fff}
.structured-mention .dataset{color:var(--red);font-weight:800;font-size:12px}.structured-mention strong{display:block;margin-bottom:4px}.structured-mention p{margin:0;color:#667085;font-size:13px;line-height:1.55}
</style></head><body>
<div class="app">
<header class="header"><div class="brand"><div class="brand-mark">航</div><div><h1>台湾飞行部队知识数据展示平台</h1><p>结构化数据检索 + Markdown全文 + 可点击来源与单位关系</p></div></div><div class="header-actions"><div class="search"><input id="globalSearch" placeholder="查询单位、装备、专题正文或来源"><span class="icon">⌕</span><div id="searchPop" class="search-pop hidden"></div></div><button class="btn white" data-action="open-query">交互查询</button><button class="btn ghost" data-action="reset">重置</button></div></header>
<nav class="tabs" id="tabs"><button class="tab active" data-tab="overview">总览</button><button class="tab" data-tab="organization">组织机构</button><button class="tab" data-tab="units">单位与兵力</button><button class="tab" data-tab="equipment">装备体系</button><button class="tab" data-tab="training">训练与保障</button><button class="tab" data-tab="industry">工业链与外部依赖</button><button class="tab" data-tab="safety">飞安与风险治理</button><button class="tab" data-tab="timeline">时间线与来源</button><button class="tab" data-tab="knowledge">知识专题库</button></nav>
<main class="main"><section id="page-overview" class="page active"></section><section id="page-organization" class="page"></section><section id="page-units" class="page"></section><section id="page-equipment" class="page"></section><section id="page-training" class="page"></section><section id="page-industry" class="page"></section><section id="page-safety" class="page"></section><section id="page-timeline" class="page"></section><section id="page-knowledge" class="page"></section></main>
<footer class="footer"><span>单文件离线原型 v3.1 · Markdown全文与来源链接修复版</span><span>公开资料边界:不展示实时战备、人员员额、可飞数量、弹药库存和内部设施</span></footer></div>
<div id="modal" class="modal hidden"><div class="backdrop" data-action="close-modal"></div><div class="modal-card"><button class="close" data-action="close-modal">×</button><div id="modalBody"></div></div></div>
<div id="drawer" class="drawer hidden"><div class="backdrop" data-action="close-drawer"></div><div class="drawer-head"><strong id="drawerTitle">详情</strong><button class="close" style="position:static" data-action="close-drawer">×</button></div><div class="drawer-card" id="drawerBody"></div></div>
<div id="detail" class="detail hidden"></div>
<div id="queryPanel" class="query-panel hidden"><div class="backdrop" data-action="close-query"></div><div class="query-card"><button class="close" data-action="close-query">×</button><div class="query-form" id="queryForm"></div><div class="query-result" id="queryResult"></div></div></div>
<script>const DATA={"meta":{"title":"台湾飞行部队知识数据展示平台","version":"原型版 3.2(离线来源内容版)","updated":"2026-06-23","notice":"基于公开资料构建;单位级人员、飞机可用数量及实时战备状态未公开时均不作推断。","offline":true},"units":[{"profile_id":"UFP-001","organization_id":"ORG-AF-SONGSHAN","name":"空军松山基地指挥部","path":"entities/units/air_force/songshan_base_command.md","type":"unit_profile","priority":"P1","topic_group":"unit_force_profiles","service":"air_force","organization_type":"base_command","parent_id":"ORG-AF-HQ","status":"CURRENT_CONFIRMED","confidence":"A","public_location":"台北松山地区(公开基地层级)","summary":"松山基地指挥部是空军特种运输、行政航空和首都地区基地运行的公开组织节点,内部同时包含飞行、修护补给和基地勤务功能。","mission_roles":["特种/行政运输","专机与公务航空保障","基地运行与军民航协调"],"platforms":["B-737-800","Fokker 50","Beech 1900等公开特种运输平台"],"force_structure":"基地指挥部级;公开内部要素包括特种运输队、修护补给队和基地勤务队。飞行单位规模和机组数量未公开。","personnel_domains":["特种运输飞行员与机组","专机任务与服务人员","机务与航电人员","航管/基地勤务/消防人员"],"training_profile":"执行机型换装、专机程序、仪表飞行、复杂空域和应急训练;具体课目与资格数量未公开。","support_profile":"修护补给队承担机务与补给,基地勤务队承担场站运行;涉及军民共用机场协调。","quantity_reference":"官方沿革明确2000年B-737-800成军;其他平台在官网公开列示。当前各型机确切数量和可用率不公开。","history":"由松山站、供应分队、机场勤务中队和基地大队等单位演变,1966年扩编为松山基地指挥部。","source_ids":["AF-SONGSHAN","AF-SPECIAL-AIRCRAFT"],"relations":["ORG-AF-SONGSHAN-SPECIAL-TRANSPORT","ORG-AF-SONGSHAN-MAINT","ORG-AF-SONGSHAN-BASE-SERVICE"],"force_level":"基地指挥部级","readiness_statement":"公开资料可确认单位、平台或任务存在,但不公开实时战备等级、可飞数量和当日人员状态。","notes":"","registry_status":"CURRENT_CONFIRMED","registry_confidence":"A","last_verified":"2026-06-23","unit_personnel_strength":"NOT_PUBLIC","unit_aircraft_strength":"NOT_PUBLIC_OR_NOT_STABLE","commander_field":"不写入静态档案;需查询单位官网最新公开信息","do_not_infer":["实时可飞数量或妥善率","人员实际员额、值班名单或资格分布","弹药库存和挂载数量","飞行频率、值班节奏和临时部署","基地内部设施、机堡位置和警卫布置"]},{"profile_id":"UFP-002","organization_id":"ORG-AF-SONGSHAN-SPECIAL-TRANSPORT","name":"空军松山基地指挥部特种运输队","path":"entities/units/air_force/songshan_special_transport_unit.md","type":"unit_profile","priority":"P2","topic_group":"unit_force_profiles","service":"air_force","organization_type":"special_transport_unit","parent_id":"ORG-AF-SONGSHAN","status":"CURRENT_PROBABLE","confidence":"B","public_location":"台北松山地区(公开基地层级)","summary":"特种运输队是松山基地指挥部内承担专机与行政运输的飞行要素,其档案重点是任务机组、平台构成和特殊服务程序,而不是推断高层行程。","mission_roles":["行政运输","专机运输","紧急或公务航空支援"],"platforms":["B-737-800","Fokker 50","Beech 1900等"],"force_structure":"队级飞行单位;上级为松山基地指挥部。现行完整内部机组编制和飞机分配未公开。","personnel_domains":["固定翼飞行员","任务机组与客舱服务","任务规划","机务与航电专业"],"training_profile":"机型换装、仪表飞行、机场程序、紧急程序和任务服务标准化。","support_profile":"依托所属联队的修护补给、基地勤务、航管气象和武器保障;单位级确切班组与员额未公开。","quantity_reference":"单位级飞机编配数、可飞数和飞行员员额未公开;仅确认机型/任务归属,不从联队总量反推。","history":"其前身与松山基地历次专机/运输编组有关,现行正式名称依据公开组织资料为较高可信。","source_ids":["AF-SONGSHAN","AF-SPECIAL-AIRCRAFT"],"relations":["ORG-AF-SONGSHAN"],"force_level":"special_transport_unit","readiness_statement":"公开资料可确认单位、平台或任务存在,但不公开实时战备等级、可飞数量和当日人员状态。","notes":"","registry_status":"CURRENT_PROBABLE","registry_confidence":"B","last_verified":"2026-06-23","unit_personnel_strength":"NOT_PUBLIC","unit_aircraft_strength":"NOT_PUBLIC_OR_NOT_STABLE","commander_field":"不写入静态档案;需查询单位官网最新公开信息","do_not_infer":["实时可飞数量或妥善率","人员实际Line truncated
(()=>{
'use strict';
const $=s=>document.querySelector(s), $$=s=>[...document.querySelectorAll(s)];
const esc=v=>String(v??'').replace(/[&<>"']/g,m=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#039;'}[m]));
const arr=v=>Array.isArray(v)?v:(v?String(v).split(/;|;|、/).map(x=>x.trim()).filter(Boolean):[]);
const uniq=a=>[...new Set(a.filter(Boolean))];
const clip=(v,n=110)=>String(v||'').length>n?String(v).slice(0,n)+'…':String(v||'');
const labels={service:{air_force:'空军',army:'陆军',navy:'海军',civil:'民政航空',other:'其他'},status:{CURRENT_CONFIRMED:'现行确认',CURRENT_PROBABLE:'现行较高可信',CURRENT_CONFIRMED_OR_TRANSITIONING:'现行/转型中',FIELD_CONFIRMED:'现役确认',FUNCTION_CONFIRMED:'功能确认',FUNCTION_INFERRED:'功能推定',PROCUREMENT_TRANSITIONING:'采购转型中',PROCUREMENT_CONFIRMED:'采购确认',PROCUREMENT_PACKAGE_CONFIRMED:'采购包确认',CURRENT_ROLE_CONFIRMED:'岗位确认',CURRENT_USE_CONFIRMED:'使用确认',TRAINING_FUNCTION_CONFIRMED:'训练功能确认',TRAINING_PIPELINE_INFERRED:'训练链推定',ORGANIZATION_UNRESOLVED:'组织待核',CURRENT_CONFIRMED_FUNCTION:'现行功能确认',HISTORICAL_BASELINE:'历史基线',NOT_PUBLIC:'未公开',NOT_PUBLIC_OR_NOT_STABLE:'未公开或非稳定口径',OPEN_SOURCE_LIMITED:'公开资料有限',PARTIALLY_COMPLETE:'部分完整',COMPLETED:'已完成',ANALYTICAL_ASSESSMENT:'分析评估',HIGH_EXTERNAL_DEPENDENCY:'外部依赖较高',OEM_AUTHORIZED_FMS_CONTROLLED:'原厂授权/军售管制',FMS_CONTROLLED:'军售管制',DOMESTIC_INTEGRATOR:'国内系统整合',DOMESTIC_DESIGN_AND_INTEGRATION:'国内设计与整合',JOINT_DEVELOPMENT_EXTERNAL_COMPONENTS:'合作研制/含外部部件',JOINT_OR_FOREIGN_CORE_DEPENDENCY:'合作或外部核心依赖',MIXED_UNRESOLVED:'混合/待核'},category:{FA:'飞行事故',SI:'严重征候',NI:'近失事件',GS:'地面安全',AN:'航管与导航',MQ:'维修质量',US:'无人航空安全',HR:'危险源报告',SAR:'搜救事件'}};
const FIELD_LABELS={profile_id:'档案编号',organization_id:'组织编号',name:'名称',name_zh:'名称',path:'知识文件路径',type:'类型',priority:'优先级',topic_group:'主题分组',service:'军种/系统',organization_type:'组织类型',parent_id:'上级组织编号',status:'状态',confidence:'可信度',public_location:'公开驻地',summary:'摘要',mission_roles:'主要任务',platforms:'平台与系统',force_structure:'兵力结构',personnel_domains:'人员专业',training_profile:'训练与资格',support_profile:'维修保障',quantity_reference:'数量口径',history:'沿革',source_ids:'来源编号',relations:'关联组织',force_level:'单位层级',readiness_statement:'战备信息边界',notes:'备注',registry_status:'登记状态',registry_confidence:'登记可信度',last_verified:'最后核验',unit_personnel_strength:'人员数量',unit_aircraft_strength:'飞机数量',commander_field:'主官字段',do_not_infer:'禁止推断',organization:'单位/机构',category:'类别',evidence:'公开证据',training_stage:'训练阶段',instructor_requirement:'教员要求',configuration_status:'构型状态',delivery_status:'交付/使用状态',platform_or_role:'平台或任务',platform_family:'平台族',level:'维修层级',task_family:'任务类别',responsible_entity:'责任单位',key_outputs:'主要输出',quality_gate:'质量关口',technical_dependency:'技术依赖',node_type:'节点类型',capabilities:'能力范围',maturity:'成熟度',foreign_interface:'国外接口',platform_or_system:'平台或系统',dimension:'依赖维度',domestic_capability:'国内能力',foreign_dependency:'外部依赖',mitigation:'缓解/自主化措施',unresolved:'待核问题',risk:'风险描述',examples:'表现或示例',controls:'控制措施',hfacs_level:'HFACS层级',stage:'治理阶段',owner:'责任主体',inputs:'输入',outputs:'输出',date:'日期',platform:'平台',severity:'严重度',outcome:'结果',grounding:'停飞/限制',return_to_flight:'复飞状态',cause_status:'原因状态',title:'标题',usage:'主要用途',url:'网址',verified:'核验日期'};
const state={tab:'overview',unitView:'cards',unitPage:1,unitSize:12,unitFilters:{q:'',service:'全部',level:'全部',location:'全部',status:'全部'},equipCat:'全部',orgService:'air_force',orgZoom:1,sourceQ:'',knowledgeCategory:'全部',knowledgeQ:'',knowledgePage:1,knowledgeSize:15};
const sourceMap=Object.fromEntries(DATA.sources.map(x=>[x.source_id,x]));const sourceLocalMap=SOURCE_LOCAL||{};
const orgMap=Object.fromEntries(DATA.organizations.map(x=>[x.organization_id,x]));
const unitMap=Object.fromEntries(DATA.units.map(x=>[x.organization_id,x]));
const knowledgeMap=Object.fromEntries(KNOWLEDGE.map(x=>[x.path,x]));
const status=v=>labels.status[v]||v||'待核'; const service=v=>labels.service[v]||v||'其他';
const badge=(v,kind='status')=>{const t=kind==='confidence'?`可信度 ${v||'待核'}`:status(v);let c=/确认|完成|现役/.test(t)?'green':(/待核|未公开|推定|有限/.test(t)?'amber':'red');return `<span class="badge ${c}">${esc(t)}</span>`};
const chips=(xs,limit=8)=>arr(xs).slice(0,limit).map(x=>`<span class="chip red">${esc(x)}</span>`).join('');
const kv=(k,v)=>`<div class="kv"><span class="k">${esc(k)}</span><span class="v">${v}</span></div>`;
const sourcePrefixes=new Set(Object.keys(sourceMap).map(id=>String(id).split('-')[0]));
const sourceButton=id=>sourceMap[id]?`<button type="button" class="source-ref-btn" data-action="open-source" data-id="${esc(id)}" title="查看来源详情">${esc(id)}</button>`:`<button type="button" class="source-ref-btn missing-source" data-action="missing-source" data-id="${esc(id)}" title="来源索引中暂未匹配">${esc(id)} · 待补来源</button>`;
const unitRefInfo=item=>{if(item===null||item===undefined)return null;let id='',name='',meta='';if(typeof item==='object'){id=item.organization_id||item.org_id||item.id||'';name=item.name||item.name_zh||item.organization_name||'';meta=item.relationship||item.relation||item.status||''}else{const raw=String(item).trim();id=(unitMap[raw]||orgMap[raw])?raw:'';name=unitMap[raw]?.name||orgMap[raw]?.name_zh||raw}if(id){name=name||unitMap[id]?.name||orgMap[id]?.name_zh||id;meta=meta||unitMap[id]?.force_level||orgMap[id]?.organization_type||'关联单位'}return{name:name||'未命名单位',id,meta}};
const renderUnitRefs=value=>{const items=arr(value).map(unitRefInfo).filter(Boolean);if(!items.length)return '待核';return `<div class="unit-ref-grid">${items.map(x=>{const action=x.id?(unitMap[x.id]?'open-unit':'open-org'):'';return action?`<button type="button" class="unit-ref-card" data-action="${action}" data-id="${esc(x.id)}"><strong>${esc(x.name)}</strong><small>${esc(x.meta||x.id)}</small></button>`:`<div class="unit-ref-card static"><strong>${esc(x.name)}</strong><small>${esc(x.meta||'公开关联单位')}</small></div>`}).join('')}</div>`};
const objectLabel=o=>o?.name||o?.name_zh||o?.title||o?.organization||o?.platform_or_system||o?.platform_or_role||o?.organization_id||o?.id||'结构化对象';
const objectSummary=o=>Object.entries(o||{}).filter(([k,v])=>!['name','name_zh','title','organization','platform_or_system','platform_or_role'].includes(k)&&v!==null&&v!==''&&typeof v!=='object').slice(0,3).map(([k,v])=>`${FIELD_LABELS[k]||k}:${String(v)}`).join(';');
const humanValue=(k,v)=>{if(v===null||v===undefined||v==='')return '未提供/待核';if(k==='source_ids')return arr(v).map(id=>sourceButton(String(id))).join(' ');if(['related_units','units_public','relations'].includes(k))return renderUnitRefs(v);if(Array.isArray(v)){if(!v.length)return '无';if(v.some(x=>x&&typeof x==='object'))return `<div class="object-value-list">${v.map(x=>typeof x==='object'?`<div class="object-value-item"><strong>${esc(objectLabel(x))}</strong><span>${esc(objectSummary(x)||'结构化关联对象')}</span></div>`:`<span class="chip red">${esc(x)}</span>`).join('')}</div>`;return chips(v,30)}if(typeof v==='object')return `<div class="object-value-list">${Object.entries(v).map(([a,b])=>`<div class="object-value-item"><strong>${esc(FIELD_LABELS[a]||a)}</strong><span>${esc(typeof b==='object'?JSON.stringify(b):String(b))}</span></div>`).join('')}</div>`;if(k==='service')return esc(service(v));if(/status/.test(k))return badge(v);if(k==='confidence')return badge(v,'confidence');return esc(String(v));};
const looksLikeSourceId=id=>/^[A-Z][A-Z0-9]*(?:-[A-Z0-9]+)+$/.test(id)&&sourcePrefixes.has(id.split('-')[0]);
function enrichKnowledgeHtml(raw){const box=document.createElement('div');box.innerHTML=raw||'';
box.querySelectorAll('[data-source-id]').forEach(n=>{const id=n.getAttribute('data-source-id');n.outerHTML=sourceButton(id)});
box.querySelectorAll('[data-knowledge-path]').forEach(n=>{const p=n.getAttribute('data-knowledge-path'),label=n.textContent.trim()||knowledgeMap[p]?.title||p;n.outerHTML=knowledgeMap[p]?`<button type="button" class="org-ref-btn" data-action="open-knowledge" data-path="${esc(p)}">${esc(label)}</button>`:`<span class="offline-link-disabled">${esc(label)}</span>`});
box.querySelectorAll('a').forEach(a=>{const label=a.textContent.trim()||'外部资料';a.outerHTML=`<span class="offline-link-disabled" title="离线部署未收录该外部页面内容">${esc(label)}</span>`});
box.querySelectorAll('code').forEach(code=>{const id=code.textContent.trim();if(sourceMap[id]||looksLikeSourceId(id)){code.outerHTML=sourceButton(id)}else if(unitMap[id]){code.outerHTML=`<button type="button" class="org-ref-btn" data-action="open-unit" data-id="${esc(id)}">${esc(unitMap[id].name||id)}</button>`}else if(orgMap[id]){code.outerHTML=`<button type="button" class="org-ref-btn" data-action="open-org" data-id="${esc(id)}">${esc(orgMap[id].name_zh||id)}</button>`}});
const walker=document.createTreeWalker(box,NodeFilter.SHOW_TEXT);const nodes=[];while(walker.nextNode())nodes.push(walker.currentNode);nodes.forEach(node=>{if(node.parentElement?.closest('button,script,style,code'))return;const text=node.nodeValue||'';const re=/\[([A-Z][A-Z0-9]*(?:-[A-Z0-9]+)+)\]/g;if(!re.test(text))return;re.lastIndex=0;const frag=document.createDocumentFragment();let last=0,m;while((m=re.exec(text))){frag.append(document.createTextNode(text.slice(last,m.index)));const id=m[1];if(sourceMap[id]||looksLikeSourceId(id)){const wrap=document.createElement('span');wrap.innerHTML=sourceButton(id);frag.append(wrap.firstElementChild)}else{frag.append(document.createTextNode(m[0]))}last=re.lastIndex}frag.append(document.createTextNode(text.slice(last)));node.replaceWith(frag)});return box.innerHTML}
function setTab(tab){state.tab=tab;$$('.tab').forEach(x=>x.classList.toggle('active',x.dataset.tab===tab));$$('.page').forEach(x=>x.classList.toggle('active',x.id===`page-${tab}`));window.scrollTo(0,0)}
function openModal(html){$('#modalBody').innerHTML=html;$('#modal').classList.remove('hidden');document.body.style.overflow='hidden'} function closeModal(){$('#modal').classList.add('hidden');document.body.style.overflow=''}
function openDrawer(title,html){$('#drawerTitle').textContent=title;$('#drawerBody').innerHTML=html;$('#drawer').classList.remove('hidden');document.body.style.overflow='hidden'} function closeDrawer(){$('#drawer').classList.add('hidden');document.body.style.overflow=''}
function closeDetail(){$('#detail').classList.add('hidden');$('#detail').innerHTML='';document.body.style.overflow=''} function closeQuery(){$('#queryPanel').classList.add('hidden');document.body.style.overflow=''}
function relatedDocs(paths){const ds=uniq(paths).map(p=>knowledgeMap[p]).filter(Boolean);return ds.length?`<div class="related-docs">${ds.map(d=>`<div class="related-doc" data-action="open-knowledge" data-path="${esc(d.path)}"><strong>${esc(d.title)}</strong><p>${esc(clip(d.summary,100))}</p></div>`).join('')}</div>`:'<p class="rich">暂无匹配的知识正文。</p>'}
function technicalFields(x){return `<details class="technical-details"><summary>查看全部结构化字段(中文字段名)</summary><table class="human-table"><tbody>${Object.entries(x).map(([k,v])=>`<tr><th>${esc(FIELD_LABELS[k]||k)}</th><td>${humanValue(k,v)}</td></tr>`).join('')}</tbody></table></details>`}
function infoGroup(title,fields,x){return `<section class="record-group"><h3>${esc(title)}</h3>${fields.filter(k=>k in x).map(k=>`<div class="record-field"><span class="label">${esc(FIELD_LABELS[k]||k)}</span><span class="value">${humanValue(k,x[k])}</span></div>`).join('')}</section>`}
const RELATED={simulator:['capabilities/training/simulators_training_devices_and_digital_training.md','capabilities/training/flight_training_pipeline_and_qualification_generation.md'],maintenance:['capabilities/maintenance/maintenance_levels_airworthiness_quality_and_configuration.md','capabilities/logistics/aviation_materials_suppliers_and_lifecycle_supply_chain.md'],industry:['capabilities/industry/domestic_aviation_industry_and_defense_ecosystem.md','capabilities/industry/external_technology_dependencies_and_autonomy_boundaries.md'],dependency:['capabilities/industry/external_technology_dependencies_and_autonomy_boundaries.md'],risk:['safety/frameworks/human_factors_crm_hfacs_fatigue_and_safety_culture.md','safety/frameworks/environment_atc_base_ground_and_uas_safety.md'],process:['safety/governance/investigation_evidence_grounding_inspection_and_return_to_flight.md','safety/governance/aviation_safety_governance_organizations_and_institutions.md'],training:['capabilities/training/flight_training_pipeline_and_qualification_generation.md'],safety:['safety/events/public_major_aviation_safety_events_timeline.md','safety/governance/investigation_evidence_grounding_inspection_and_return_to_flight.md']};
function recordTitle(type,x){return x.name||x.name_zh||x.title||x.organization||x.platform_or_system||x.platform_or_role||x.platform_family||x.domain||x.stage||x.summary||'专题详情'}
function openRecord(type,x){let groups=[];if(type==='simulator')groups=[['基本信息',['platform_or_role','category','organization','delivery_status','confidence']],['训练应用',['training_stage','evidence','instructor_requirement']],['构型与保障',['configuration_status','source_ids']]];else if(type==='maintenance')groups=[['维修对象',['platform_family','level','task_family','service','status']],['责任与输出',['responsible_entity','key_outputs','quality_gate']],['技术支撑',['technical_dependency','confidence','source_ids']]];else if(type==='industry')groups=[['产业节点',['organization','node_type','maturity','status','confidence']],['能力范围',['capabilities','platforms']],['外部接口',['foreign_interface','source_ids']]];else if(type==='dependency')groups=[['平台与维度',['platform_or_system','dimension','level']],['国内能力',['domestic_capability','mitigation']],['外部依赖与待核',['foreign_dependency','evidence','unresolved','source_ids']]];else if(type==='risk')groups=[['风险识别',['domain','risk','hfacs_level']],['表现与控制',['examples','controls']]];else if(type==='process')groups=[['治理阶段',['stage','owner','status']],['输入与输出',['inputs','outputs']],['证据依据',['evidence']]];else if(type==='training')groups=[['训练阶段',['stage_name','career_track','stage_order','service','status']],['责任与输入',['responsible_entity','input','academic']],['模拟与实飞',['simulator','flight','output']],['证据',['confidence','source_ids']]];else if(type==='safety')groups=[['事件概览',['date','service','platform','category','severity','summary','outcome']],['处置与恢复',['grounding','return_to_flight']],['调查状态',['cause_status','confidence','source_ids']]];else groups=[['详细字段',Object.keys(x)]];
const paths=RELATED[type]||[];openModal(`<div class="record-hero"><h2>${esc(recordTitle(type,x))}</h2><p>以下内容已按业务语义重新分组,原始JSON不再作为默认展示。</p></div><div class="record-grid">${groups.map(g=>infoGroup(g[0],g[1],x)).join('')}</div><section class="detail-section"><h3>关联知识正文</h3>${relatedDocs(paths)}</section>${technicalFields(x)}`)}
function renderKnowledgeDetail(doc,unit){if(!doc)return;const heroExtra=unit?`<div class="detail-summary">${kv('单位层级',esc(unit.force_level))}${kv('公开驻地',esc(unit.public_location))}${kv('状态',badge(unit.registry_status||unit.status))}${kv('可信度',badge(unit.registry_confidence||unit.confidence,'confidence'))}</div>`:`<div class="detail-summary">${kv('专题分类',esc(doc.category))}${kv('档案类型',esc(doc.type))}${kv('当前阶段',esc(doc.stage))}${kv('正文规模',`${doc.word_count} 字符`)}</div>`;$('#detail').innerHTML=`<div class="detail-top"><div><strong>${unit?'单位与兵力档案':'知识专题正文'}</strong><span style="opacity:.72;margin-left:12px">Markdown全文呈现</span></div><button class="btn white" data-action="close-detail">返回</button></div><div class="detail-body"><section class="detail-hero"><div><h2>${esc(doc.title)}</h2><p>${esc(doc.summary||doc.when||'知识库专题正文')}</p><div><span class="badge red">${esc(doc.category)}</span><span class="badge red">${esc(doc.type)}</span>${doc.confidence?badge(doc.confidence,'confidence'):''}</div></div>${heroExtra}</section><div class="reader-layout mt"><aside class="reader-toc"><h4>本页目录</h4>${doc.sections.map((s,i)=>`<button class="${i===0?'active':''}" data-action="reader-scroll" data-target="${s.id}">${esc(s.title)}</button>`).join('')}</aside><article class="reader-main">${doc.sections.map(s=>`<section id="${s.id}" class="md-section"><h2>${esc(s.title)}</h2><div class="md-body">${enrichKnowledgeHtml(s.html)}</div></section>`).join('')}</article></div></div>`;$('#detail').classList.remove('hidden');document.body.style.overflow='hidden'}
function openKnowledge(path){renderKnowledgeDetail(knowledgeMap[path])} function openUnit(id){const u=unitMap[id];if(!u)return;const d=knowledgeMap[u.path];if(d)renderKnowledgeDetail(d,u);else openRecord('unit',u)}
function unitCard(u){return `<article class="unit-card" data-action="open-unit" data-id="${esc(u.organization_id)}"><div class="top"><div><h4>${esc(u.name)}</h4><div class="sub">${esc(service(u.service))} · ${esc(u.force_level||u.organization_type)}</div></div>${badge(u.registry_status||u.status)}</div><div class="mt">${chips(u.platforms,4)}</div><p>${esc(clip(u.summary,145))}</p><div class="foot"><span>${esc(u.public_location||'驻地待核')}</span><button class="btn light sm">阅读完整档案</button></div></article>`}
function renderOverview(){const stats=[['单位档案',DATA.units.length,'41篇单位Markdown'],['组织实体',DATA.organizations.length,'组织与支援关系'],['装备平台',DATA.equipment.length,'平台与状态'],['知识专题',KNOWLEDGE.length,'非重复Markdown全文'],['飞安事件',DATA.safetyEvents.length,'公开事件基线'],['官方来源',DATA.sources.length,'一手资料索引']];const entry=[['组织机构','organization','组织架构图','按军种展开联队、旅、大队和队级飞行单位'],['单位兵力','units','完整单位档案','直接阅读对应Markdown全部章节'],['装备体系','equipment','装备专题','结构化装备信息关联知识正文'],['训练保障','training','能力生成','模拟器和维修弹框改为业务详情页'],['工业依赖','industry','产业专题','国内能力、供应商和外部依赖'],['飞安治理','safety','风险闭环','事故、人因、调查与复飞正文'],['知识专题','knowledge','Markdown全文','按分类浏览全部96篇非重复知识文件']];$('#page-overview').innerHTML=`<div class="page-head"><div><h2>全库态势总览</h2><p>结构化JSON用于检索、筛选和关联;Markdown正文作为详情页面的主要内容源,最大限度保留知识深度。</p></div><div class="page-actions"><button class="btn line" data-action="open-query">交互查询</button><button class="btn primary" data-action="goto-tab" data-tab="knowledge">进入知识专题库</button></div></div><div class="stats">${stats.map(s=>`<div class="stat"><div class="label">${s[0]}</div><div class="num">${s[1]}</div><div class="hint">${s[2]}</div></div>`).join('')}</div><div class="section-title"><h3>专题入口</h3><span>详情默认采用中文业务结构与Markdown全文</span></div><div class="grid cols3">${entry.map(e=>`<article class="entry" data-action="goto-tab" data-tab="${e[1]}"><div class="letter">${e[0][0]}</div><strong>${e[0]}</strong><p>${e[3]}</p><span class="badge red">${e[2]}</span></article>`).join('')}</div><div class="grid cols2 mt"><div class="panel"><h3>本版核心变化</h3>${['取消默认原始JSON弹框','单位详情加载完整Markdown','增加96篇知识专题浏览','搜索覆盖Markdown全文','专题详情采用字段分组、流程、关系和来源卡片'].map(x=>`<div class="source-card">${x}</div>`).join('')}</div><div class="panel"><h3>数据边界与口径</h3><div class="notice">${esc(DATA.meta.notice)}</div>${kv('Markdown正文','保留章节、表格、列表、引用、代码块和关联分析')}${kv('结构化数据','用于交互查询和跨实体关联,不替代知识正文')}${kv('数量口径','区分历史接收、项目总量、单位分配与实时可用')}</div></div><div class="section-title"><h3>重点单位</h3><span>进入二级页面阅读单位Markdown全文</span></div><div class="grid cols3">${DATA.units.filter(x=>x.priority==='P1').slice(0,9).map(unitCard).join('')}</div>`}
function orgImportant(o){return unitMap[o.organization_id]||['service_headquarters','functional_command','tactical_fighter_wing','tactical_composite_wing','composite_wing','aviation_brigade','aviation_group','training_command','base_command','rescue_squadron'].includes(o.organization_type)}
function orgTreeData(svc){const active=DATA.organizations.filter(o=>o.service===svc&&!String(o.status).includes('HISTORICAL')&&!String(o.status).includes('DEPRECATED')&&orgImportant(o));const ids=new Set(active.map(x=>x.organization_id)),roots=active.filter(x=>!x.parent_public||!ids.has(x.parent_public)),children={};active.forEach(x=>(children[x.parent_public]??=[]).push(x));return{roots,children}}
function nodeHtml(o,children,depth=0){const kids=(children[o.organization_id]||[]).sort((a,b)=>a.name_zh.localeCompare(b.name_zh,'zh-CN')),cls=depth===0?'root':depth===1?'branch':'';return `<li><button class="org-node ${cls}" data-action="open-org" data-id="${esc(o.organization_id)}"><span>${esc(o.name_zh)}</span><small>${esc(status(o.status))}</small></button>${kids.length?`<ul>${kids.map(k=>nodeHtml(k,children,depth+1)).join('')}</ul>`:''}</li>`}
function renderOrganization(){const groups=[['air_force','空军体系'],['army','陆军航空'],['navy','海军航空'],['civil','民政航空支援']],{roots,children}=orgTreeData(state.orgService);$('#page-organization').innerHTML=`<div class="page-head"><div><h2>组织机构图</h2><p>点击节点查看中文组织详情;已有单位Markdown的节点可继续进入全文档案。</p></div></div><div class="org-layout"><aside class="side-nav"><h3 style="margin:5px 8px 12px">组织分类</h3>${groups.map(g=>`<button class="${state.orgService===g[0]?'active':''}" data-action="org-service" data-service="${g[0]}">${g[1]}</button>`).join('')}<div class="notice mt">完整119个组织实体仍可通过交互查询检索。</div></aside><div class="org-canvas"><div class="org-toolbar"><div><strong>${groups.find(x=>x[0]===state.orgService)?.[1]||'组织体系'}</strong><div style="font-size:12px;color:#667085;margin-top:4px">点击组织节点进行下钻</div></div><div class="zoom"><button class="btn light sm" data-action="zoom-out">缩小</button><button class="btn light sm" data-action="zoom-reset">复原</button><button class="btn light sm" data-action="zoom-in">放大</button></div></div><div class="tree" style="--scale:${state.orgZoom}"><ul>${roots.map(r=>nodeHtml(r,children)).join('')||'<div class="empty">暂无主干节点</div>'}</ul></div></div><aside class="side-info"><h3>组织图说明</h3>${kv('红色节点','军种或主干根节点')}${kv('浅红节点','联队、旅、大队等主要分支')}${kv('白色节点','队级飞行单位或专业组织')}<div class="notice mt">组织存在不等于完整兵力公开。</div></aside></div>`}
function openOrg(id){const o=orgMap[id];if(!o)return;const u=unitMap[id],parent=orgMap[o.parent_public],childs=DATA.organizations.filter(x=>x.parent_public===id&&!String(x.status).includes('HISTORICAL')).slice(0,18);const orgDocPaths=['entities/organizations/air_force_headquarters_and_four_commands.md','entities/organizations/air_force_wings_1_to_7.md','entities/organizations/army_aviation_special_forces_system.md','entities/organizations/naval_antisubmarine_aviation_system.md'].filter(p=>knowledgeMap[p]);openModal(`<div class="record-hero"><h2>${esc(o.name_zh)}</h2><p>${esc(o.notes||'公开组织实体与关系档案')}</p></div><div class="record-grid">${infoGroup('组织身份',['organization_type','service','status','confidence','last_verified'],o)}<section class="record-group"><h3>隶属与关联</h3>${kv('公开上级',esc(parent?.name_zh||'待核/不适用'))}${kv('下级单位数',String(childs.length))}${childs.map(x=>`<div class="field-item" data-action="open-org" data-id="${esc(x.organization_id)}"><strong>${esc(x.name_zh)}</strong><span>${esc(status(x.status))}</span></div>`).join('')}</section></div>${u?`<button class="btn primary mt" data-action="open-unit" data-id="${esc(id)}">阅读完整单位Markdown档案</button>`:''}<section class="detail-section"><h3>关联组织专题</h3>${relatedDocs(orgDocPaths)}</section>${technicalFields(o)}`)}
function filteredUnits(){return DATA.units.filter(u=>{const f=state.unitFilters;return(!f.q||JSON.stringify(u).toLowerCase().includes(f.q.toLowerCase()))&&(f.service==='全部'||service(u.service)===f.service)&&(f.level==='全部'||String(u.force_level).includes(f.level))&&(f.location==='全部'||String(u.public_location).includes(f.location))&&(f.status==='全部'||status(u.registry_status||u.status)===f.status)})}
function renderUnits(){const all=filteredUnits(),pages=Math.max(1,Math.ceil(all.length/state.unitSize));state.unitPage=Math.min(state.unitPage,pages);const show=all.slice((state.unitPage-1)*state.unitSize,state.unitPage*state.unitSize),services=['全部',...uniq(DATA.units.map(x=>service(x.service)))],levels=['全部',...uniq(DATA.units.map(x=>x.force_level).filter(Boolean)).slice(0,12)],locs=['全部',...uniq(DATA.units.map(x=>String(x.public_location||'').split('(')[0]).filter(Boolean)).slice(0,15)],sts=['全部',...uniq(DATA.units.map(x=>status(x.registry_status||x.status)))];$('#page-units').innerHTML=`<div class="page-head"><div><h2>单位与兵力档案</h2><p>41个单位均连接对应Markdown全文,卡片摘要不再代替详细知识。</p></div><div class="view-switch"><button class="${state.unitView==='cards'?'active':''}" data-action="unit-view" data-view="cards">卡片</button><button class="${state.unitView==='table'?'active':''}" data-action="unit-view" data-view="table">矩阵</button></div></div><div class="filters"><input class="input" id="uQ" value="${esc(state.unitFilters.q)}" placeholder="查询单位、任务、装备或驻地"><select class="select" id="uService">${services.map(x=>`<option ${state.unitFilters.service===x?'selected':''}>${esc(x)}</option>`).join('')}</select><select class="select" id="uLevel">${levels.map(x=>`<option ${state.unitFilters.level===x?'selected':''}>${esc(x)}</option>`).join('')}</select><select class="select" id="uLoc">${locs.map(x=>`<option ${state.unitFilters.location===x?'selected':''}>${esc(x)}</option>`).join('')}</select><select class="select" id="uStatus">${sts.map(x=>`<option ${state.unitFilters.status===x?'selected':''}>${esc(x)}</option>`).join('')}</select><button class="btn light" data-action="clear-units">清空</button></div>${state.unitView==='cards'?`<div class="grid cols3">${show.map(unitCard).join('')||'<div class="empty">没有匹配单位</div>'}</div>`:`<div class="table-wrap"><table><thead><tr><th>单位名称</th><th>军种</th><th>层级</th><th>驻地</th><th>平台</th><th>任务</th><th>状态</th><th>完整档案</th></tr></thead><tbody>${show.map(u=>`<tr><td><strong>${esc(u.name)}</strong></td><td>${esc(service(u.service))}</td><td>${esc(u.force_level)}</td><td>${esc(u.public_location)}</td><td>${chips(u.platforms,4)}</td><td>${esc(arr(u.mission_roles).slice(0,3).join('、'))}</td><td>${badge(u.registry_status||u.status)}</td><td><button class="btn primary sm" data-action="open-unit" data-id="${esc(u.organization_id)}">阅读全文</button></td></tr>`).join('')}</tbody></table></div>`}<div class="pagination"><button class="btn line sm" data-action="unit-page" data-page="${state.unitPage-1}" ${state.unitPage<=1?'disabled':''}>上一页</button><span>第 ${state.unitPage} / ${pages} 页,共 ${all.length} 个单位</span><button class="btn line sm" data-action="unit-page" data-page="${state.unitPage+1}" ${state.unitPage>=pages?'disabled':''}>下一页</button></div>`;['uQ','uService','uLevel','uLoc','uStatus'].forEach(id=>$('#'+id).addEventListener(id==='uQ'?'input':'change',e=>{const k={uQ:'q',uService:'service',uLevel:'level',uLoc:'location',uStatus:'status'}[id];state.unitFilters[k]=e.target.value;state.unitPage=1;renderUnits()}))}
function equipName(x){return x.display_name||x.name_zh||x.platform_name||x.platform_id} function equipCat(x){const n=equipName(x),c=x.category||'';if(/F-16|Mirage|F-CK|战斗/.test(n+c))return'战斗机';if(/T-34|AJT|AT-3|TH-67|教练/.test(n+c))return'教练机';if(/E-2|预警/.test(n+c))return'预警机';if(/C-130|运输|B-737|Fokker|Beech/.test(n+c))return'运输与专机';if(/P-3|反潜巡逻/.test(n+c))return'反潜巡逻机';if(/AH-|UH-|OH-|CH-|S-70|EC225|500MD|直升/.test(n+c))return'直升机';if(/无人|MQ-|腾云|剑翔|锐鸢|Altius|Switchblade/.test(n+c))return'无人航空器';return'其他平台'}
function equipDocs(x){const cat=equipCat(x),m={'无人航空器':['entities/equipment/unmanned_aviation_forces.md','entities/equipment/unmanned_equipment_maturity_and_program_status.md'],'直升机':['entities/equipment/aircraft_platform_lineage.md','entities/equipment/army_navy_aviation_weapons_and_mission_systems.md'],'战斗机':['entities/equipment/aircraft_platform_lineage.md','entities/equipment/air_force_airborne_weapons_munitions_and_pods.md'],'教练机':['entities/equipment/aircraft_platform_lineage.md','capabilities/training/flight_training_pipeline_and_qualification_generation.md'],'预警机':['entities/equipment/special_rescue_vip_and_electronic_warfare_aircraft.md','capabilities/missions/mission_system_and_joint_relations.md'],'运输与专机':['entities/equipment/special_rescue_vip_and_electronic_warfare_aircraft.md'],'反潜巡逻机':['entities/equipment/special_rescue_vip_and_electronic_warfare_aircraft.md','entities/equipment/army_navy_aviation_weapons_and_mission_systems.md']};return m[cat]||['entities/equipment/aircraft_platform_lineage.md']}
function renderEquipment(){const cats=['全部',...uniq(DATA.equipment.map(equipCat))],list=DATA.equipment.filter(x=>state.equipCat==='全部'||equipCat(x)===state.equipCat);$('#page-equipment').innerHTML=`<div class="page-head"><div><h2>装备体系</h2><p>装备详情采用业务字段、武器关系、来源卡和关联Markdown专题,不再展示裸JSON。</p></div></div><div class="equipment-layout"><aside class="side-nav category-list"><h3 style="margin:5px 8px 12px">装备分类</h3>${cats.map(c=>`<button class="${state.equipCat===c?'active':''}" data-action="equip-cat" data-cat="${esc(c)}"><span>${esc(c)}</span><span>${c==='全部'?DATA.equipment.length:DATA.equipment.filter(x=>equipCat(x)===c).length}</span></button>`).join('')}</aside><div><div class="grid cols3">${list.map(x=>`<article class="equip-card" data-action="open-equip" data-id="${esc(x.platform_id)}"><div class="top"><h4>${esc(equipName(x))}</h4>${badge(x.status)}</div><div>${chips([equipCat(x),service(x.service)],4)}</div><p>公开配属:${esc(arr(x.units_public).join('、')||'待核')}。点击查看任务关系、武器系统、来源和专题正文。</p><div class="foot"><span>${esc(x.last_verified||'')}</span><button class="btn light sm">丰富详情</button></div></article>`).join('')}</div></div></div>`}
function openEquip(id){const x=DATA.equipment.find(e=>e.platform_id===id);if(!x)return;const rel=DATA.platformWeapon.filter(r=>String(r.platform).includes(equipName(x))||String(r.platform).includes(x.name_zh||'')),unitValue=(x.related_units&&x.related_units.length?x.related_units:x.units_public),srcs=arr(x.source_ids).map(id=>sourceMap[id]).filter(Boolean);openModal(`<div class="record-hero"><h2>${esc(equipName(x))}</h2><p>平台、任务、配属、武器关系、来源和关联知识正文综合展示。</p><div>${badge(x.status)} ${badge(x.confidence,'confidence')} <span class="badge red">${esc(equipCat(x))}</span></div></div><div class="record-grid"><section class="record-group"><h3>平台身份</h3>${kv('所属体系',esc(service(x.service)))}<div class="record-field"><span class="label">公开单位</span><span class="value">${renderUnitRefs(unitValue)}</span></div>${kv('最后核验',esc(x.last_verified||'待核'))}</section><section class="record-group"><h3>数量与状态边界</h3><p class="rich">军售批准、采购、交付、接装和稳定形成能力属于不同状态;不从全军采购总量推算单位编配或实时可用数量。</p></section></div><section class="detail-section"><h3>武器与任务系统关系</h3>${rel.length?`<div class="table-wrap"><table><thead><tr><th>武器或系统</th><th>用途</th><th>状态</th><th>可信度</th></tr></thead><tbody>${rel.map(r=>`<tr><td>${esc(r.weapon_or_system)}</td><td>${esc(r.role)}</td><td>${badge(r.status)}</td><td>${badge(r.confidence,'confidence')}</td></tr>`).join('')}</tbody></table></div>`:'<p class="rich">当前没有匹配到结构化武器关系,不代表平台没有任务系统。</p>'}</section><section class="detail-section"><h3>公开来源</h3>${srcs.length?srcs.map(s=>`<div class="source-card" data-action="open-source" data-id="${esc(s.source_id)}"><strong>${esc(s.title)}</strong><p>${esc(s.usage)}</p></div>`).join(''):'<p>来源编号已记录或待补充。</p>'}</section><section class="detail-section"><h3>关联知识正文</h3>${relatedDocs(equipDocs(x))}</section>${technicalFields(x)}`)}
function renderTraining(){const stages=[...DATA.training].sort((a,b)=>Number(a.stage_order)-Number(b.stage_order));$('#page-training').innerHTML=`<div class="page-head"><div><h2>训练与保障</h2><p>结构化卡片负责概览,点击后进入中文业务详情并可继续阅读训练、模拟器、维修和航材Markdown全文。</p></div></div><div class="panel"><div class="panel-head"><div><h3>飞行训练资格生成链</h3><p>点击任一阶段查看输入、学科、模拟器、实飞、输出和来源</p></div></div><div class="flow">${stages.slice(0,9).map((x,i)=>`<div class="flow-step" data-action="record-detail" data-type="training" data-index="${DATA.training.indexOf(x)}"><div class="no">${i+1}</div><h4>${esc(x.stage_name)}</h4><p>${esc(x.responsible_entity)}</p></div>`).join('')}</div></div><div class="section-title"><h3>模拟训练体系</h3><span>业务字段分组 + 关联Markdown</span></div><div class="grid cols3">${DATA.simulators.map((x,i)=>`<article class="unit-card" data-action="record-detail" data-type="simulator" data-index="${i}"><div class="top"><h4>${esc(x.platform_or_role)}</h4>${badge(x.delivery_status)}</div><div>${chips([x.category],3)}</div><p>${esc(x.evidence)}</p><div class="foot"><span>${esc(x.organization)}</span><button class="btn light sm">查看丰富详情</button></div></article>`).join('')}</div><div class="section-title"><h3>维修层级与保障能力</h3><span>点击查看责任主体、质量关口、技术依赖和全文专题</span></div><div class="grid cols2">${DATA.maintenance.map((x,i)=>`<div class="panel" data-action="record-detail" data-type="maintenance" data-index="${i}"><div class="panel-head"><div><h3>${esc(x.platform_family)}</h3><p>${esc(x.level)} · ${esc(x.task_family)}</p></div>${badge(x.status)}</div><p class="rich">${esc(x.key_outputs)}</p><div>${chips([x.responsible_entity,x.quality_gate],4)}</div></div>`).join('')}</div>`}
function renderIndustry(){$('#page-industry').innerHTML=`<div class="page-head"><div><h2>工业链与外部依赖</h2><p>产业节点和依赖矩阵均使用中文详情分组,并链接国产工业、供应链和外部依赖Markdown全文。</p></div></div><div class="industry-stack">${[['政策与军种需求','需求、预算、采购和产业政策'],['总体设计与系统整合','汉翔、中科院及项目主承包'],['专业系统与供应商','发动机、航电、雷达、结构、材料和特殊制程'],['维修与全寿期支援','军方厂库、国内MRO、原厂/FMS和技术资料'],['外部技术与认证','设计权、软件、专用工具、出口管制和适航认证']].map((x,i)=>`<div class="industry-layer"><div><span class="badge red">产业层级 ${i+1}</span><h3 class="mt">${x[0]}</h3></div><p class="rich">${x[1]}</p></div>`).join('')}</div><div class="section-title"><h3>国内产业节点</h3><span>点击查看能力范围、平台、成熟度与关联正文</span></div><div class="grid cols3">${DATA.industry.map((x,i)=>`<article class="unit-card" data-action="record-detail" data-type="industry" data-index="${i}"><div class="top"><h4>${esc(x.organization)}</h4>${badge(x.status)}</div><div>${chips([x.node_type,x.maturity],4)}</div><p>${esc(clip(x.capabilities,150))}</p><div class="foot"><span>${esc(x.foreign_interface)}</span><button class="btn light sm">丰富详情</button></div></article>`).join('')}</div><div class="section-title"><h3>平台级外部依赖矩阵</h3><span>默认不显示原始JSON</span></div><div class="table-wrap"><table><thead><tr><th>平台或系统</th><th>依赖维度</th><th>国内能力</th><th>外部依赖</th><th>等级</th><th>详情</th></tr></thead><tbody>${DATA.externalDependencies.map((x,i)=>`<tr><td><strong>${esc(x.platform_or_system)}</strong></td><td>${esc(x.dimension)}</td><td>${esc(clip(x.domestic_capability,75))}</td><td>${esc(clip(x.foreign_dependency,75))}</td><td><span class="badge red">${esc(x.level)}</span></td><td><button class="btn light sm" data-action="record-detail" data-type="dependency" data-index="${i}">展开</button></td></tr>`).join('')}</tbody></table></div>`}
function renderSafety(){$('#page-safety').innerHTML=`<div class="page-head"><div><h2>飞安、事故与风险治理</h2><p>风险和治理环节采用语义详情页面,并连接事故调查、人因、适航、环境和搜救Markdown专题。</p></div></div><div class="grid cols4">${DATA.safetyRisks.slice(0,11).map((x,i)=>`<article class="risk-card" data-action="record-detail" data-type="risk" data-index="${i}"><h4>${esc(x.domain)}</h4><p class="rich">${esc(x.risk)}</p><div>${chips(arr(x.controls),4)}</div></article>`).join('')}</div><div class="section-title"><h3>飞安治理闭环</h3><span>点击阶段查看输入、输出、责任主体与制度正文</span></div><div class="panel"><div class="flow">${DATA.safetyProcess.map((x,i)=>`<div class="flow-step" data-action="record-detail" data-type="process" data-index="${i}"><div class="no">${i+1}</div><h4>${esc(x.stage)}</h4><p>${esc(x.owner)}</p></div>`).join('')}</div></div><div class="section-title"><h3>公开重大飞安事件</h3><span>右侧抽屉提供业务字段和完整专题入口</span></div><div class="timeline">${DATA.safetyEvents.map((x,i)=>`<article class="event" data-action="open-event" data-index="${i}"><span class="date">${esc(x.date)}</span> <span class="badge red">${esc(labels.category[x.category]||x.category)}</span><h4>${esc(x.platform)} · ${esc(x.summary)}</h4><p>${esc(x.outcome)};${esc(x.cause_status)}</p></article>`).join('')}</div>`}
function openEvent(i){const x=DATA.safetyEvents[Number(i)];if(!x)return;const ss=arr(x.source_ids).map(id=>sourceMap[id]).filter(Boolean);openDrawer(`${x.date} · ${x.platform}`,`<div class="record-hero"><h2>${esc(x.summary)}</h2><p>事件事实、处置、复飞和原因状态分层展示。</p></div>${infoGroup('事件概览',['date','service','platform','category','severity','outcome'],x)}${infoGroup('停飞、检查与恢复',['grounding','return_to_flight'],x)}${infoGroup('调查与原因状态',['cause_status','confidence','source_ids'],x)}<section class="detail-section"><h3>公开来源</h3>${ss.map(s=>`<div class="source-card" data-action="open-source" data-id="${esc(s.source_id)}"><strong>${esc(s.title)}</strong><p>${esc(s.usage)}</p></div>`).join('')||'<p>来源编号已记录。</p>'}</section><section class="detail-section"><h3>关联知识正文</h3>${relatedDocs(RELATED.safety)}</section><div class="notice">初始通报、监督调查和最终原因属于不同证据层级,不应混写。</div>`)}
function renderTimeline(){const sources=DATA.sources.filter(x=>!state.sourceQ||JSON.stringify(x).toLowerCase().includes(state.sourceQ.toLowerCase())).slice(0,70);$('#page-timeline').innerHTML=`<div class="page-head"><div><h2>事件时间线与来源</h2><p>时间线与官方来源卡保持可检索,同时来源索引Markdown也可在知识专题库阅读全文。</p></div></div><div class="source-layout"><div class="panel"><div class="panel-head"><div><h3>重大事件时间线</h3><p>按公开日期倒序</p></div></div><div class="timeline">${DATA.timeline.map(x=>`<article class="event"><span class="date">${esc(x.date)}</span> <span class="badge red">${esc(x.type)}</span><h4>${esc(x.title)}</h4><p>${esc(x.detail||'公开资料未提供更多摘要')}</p></article>`).join('')}</div></div><div><div class="panel mb"><div class="panel-head"><div><h3>官方来源查询</h3><p>共 ${DATA.sources.length} 项来源</p></div></div><input id="sourceQ" class="input" value="${esc(state.sourceQ)}" placeholder="查询来源名称、用途或编号"><button class="btn line mt" data-action="open-knowledge" data-path="sources/官方来源索引_v7.md">阅读来源索引Markdown</button></div>${sources.map(s=>`<article class="source-card" data-action="open-source" data-id="${esc(s.source_id)}"><h4>${esc(s.title)}</h4><p>${esc(s.usage)}</p><div class="mt">${badge(s.confidence,'confidence')} <span class="badge gray">${esc(s.verified)}</span></div></article>`).join('')}</div></div>`;$('#sourceQ').addEventListener('input',e=>{state.sourceQ=e.target.value;renderTimeline()})}
function openSource(id){const s=sourceMap[id];if(!s)return;const local=sourceLocalMap[id]||{},contexts=local.citation_contexts||[],mentions=local.structured_mentions||[];
const original=local.has_original_content&&local.original_content?`<section class="offline-source-panel"><h3>本地收录的原始内容</h3><div class="md-body">${enrichKnowledgeHtml(local.original_content)}</div></section>`:'';
const contextHtml=contexts.length?`<section class="offline-source-panel"><h3>知识库引用上下文</h3><p class="rich">以下内容来自本地Markdown中引用该来源的章节,用于离线核对来源支撑范围;它不是外部网页的完整原文。</p>${contexts.map(c=>`<article class="citation-context" data-action="open-knowledge" data-path="${esc(c.path)}"><h4>${esc(c.title)}</h4><p><strong>${esc(c.section||'相关章节')}</strong>:${esc(c.snippet)}</p></article>`).join('')}</section>`:'';
const mentionHtml=mentions.length?`<section class="offline-source-panel"><h3>结构化数据中的使用位置</h3>${mentions.map(m=>`<article class="structured-mention"><span class="dataset">${esc(m.dataset)}</span><div><strong>${esc(m.title)}</strong><p>${esc(m.summary||'该记录引用此来源。')}</p></div></article>`).join('')}</section>`:'';
const noOriginal=!local.has_original_content?`<div class="notice mt">本地知识库未保存该官方页面或PDF的完整原文。当前为离线部署版本,因此不显示网址,也不提供外网跳转。</div>`:'';
openModal(`<div class="record-hero"><h2>${esc(s.title)}</h2><p>${esc(s.usage)}</p></div>${infoGroup('来源信息',['source_id','verified','confidence'],s)}<section class="offline-source-panel"><h3>离线收录状态</h3><span class="offline-status ${local.has_original_content?'available':(contexts.length||mentions.length?'context':'')}">${esc(local.local_status||'仅收录来源元数据')}</span><p class="offline-reference-note">来源用途摘要:${esc(s.usage||'未提供')}</p></section>${original}${contextHtml}${mentionHtml}${noOriginal}<div class="notice">来源只能支撑其明确记载的字段,不能由单一来源推断完整编制、实时状态或单位级兵力。</div>`)}
function knowledgeFiltered(){return KNOWLEDGE.filter(d=>(state.knowledgeCategory==='全部'||d.category===state.knowledgeCategory)&&(!state.knowledgeQ||(d.title+' '+d.summary+' '+d.text+' '+d.path).toLowerCase().includes(state.knowledgeQ.toLowerCase())))}
function renderKnowledge(){const cats=['全部',...uniq(KNOWLEDGE.map(x=>x.category))],list=knowledgeFiltered(),pages=Math.max(1,Math.ceil(list.length/state.knowledgeSize));state.knowledgePage=Math.min(state.knowledgePage,pages);const show=list.slice((state.knowledgePage-1)*state.knowledgeSize,state.knowledgePage*state.knowledgeSize),counts=CounterBy(KNOWLEDGE,'category');$('#page-knowledge').innerHTML=`<div class="page-head"><div><h2>知识专题库</h2><p>共 ${KNOWLEDGE.length} 篇非重复Markdown知识文件。可按主题分类、标题和正文全文检索。</p></div><div class="page-actions"><input id="knowledgeQ" class="input" style="width:330px" value="${esc(state.knowledgeQ)}" placeholder="查询标题、摘要、正文或路径"></div></div><div class="knowledge-layout"><aside class="knowledge-side"><h3 style="margin:5px 8px 12px">专题分类</h3>${cats.map(c=>`<button class="${state.knowledgeCategory===c?'active':''}" data-action="knowledge-cat" data-cat="${esc(c)}"><span>${esc(c)}</span><span>${c==='全部'?KNOWLEDGE.length:(counts[c]||0)}</span></button>`).join('')}</aside><div><div class="grid cols3">${show.map(d=>`<article class="knowledge-card" data-action="open-knowledge" data-path="${esc(d.path)}"><h4>${esc(d.title)}</h4><div class="knowledge-meta"><span class="badge red">${esc(d.category)}</span><span class="badge gray">${esc(d.type)}</span></div><p>${esc(clip(d.summary||d.when,155))}</p><div class="knowledge-path">${esc(d.path)}</div><div class="foot mt"><span>${d.sections.length} 个章节</span><button class="btn light sm">阅读全文</button></div></article>`).join('')||'<div class="empty">没有匹配的Markdown专题</div>'}</div><div class="pagination"><button class="btn line sm" data-action="knowledge-page" data-page="${state.knowledgePage-1}" ${state.knowledgePage<=1?'disabled':''}>上一页</button><span>第 ${state.knowledgePage} / ${pages} 页,共 ${list.length} 篇</span><button class="btn line sm" data-action="knowledge-page" data-page="${state.knowledgePage+1}" ${state.knowledgePage>=pages?'disabled':''}>下一页</button></div></div></div>`;$('#knowledgeQ').addEventListener('input',e=>{state.knowledgeQ=e.target.value;state.knowledgePage=1;renderKnowledge()})}
function CounterBy(items,key){return items.reduce((a,x)=>(a[x[key]]=(a[x[key]]||0)+1,a),{})}
const datasets={units:['单位档案',DATA.units,'unit'],organizations:['组织机构',DATA.organizations,'organization'],equipment:['装备平台',DATA.equipment,'equipment'],training:['训练阶段',DATA.training,'training'],simulators:['模拟器',DATA.simulators,'simulator'],maintenance:['维修能力',DATA.maintenance,'maintenance'],industry:['产业节点',DATA.industry,'industry'],dependencies:['外部依赖',DATA.externalDependencies,'dependency'],safety:['飞安事件',DATA.safetyEvents,'safety'],sources:['官方来源',DATA.sources,'source'],knowledge:['知识专题',KNOWLEDGE,'knowledge']};
function openDatasetRecord(key,x){const type=datasets[key][2];if(type==='unit')openUnit(x.organization_id);else if(type==='organization')openOrg(x.organization_id);else if(type==='equipment')openEquip(x.platform_id);else if(type==='source')openSource(x.source_id);else if(type==='knowledge')openKnowledge(x.path);else openRecord(type,x)}
function openQuery(){$('#queryPanel').classList.remove('hidden');document.body.style.overflow='hidden';$('#queryForm').innerHTML=`<h2>交互式数据查询</h2><p class="rich">结构化字段用于筛选;结果点击后打开中文业务详情或Markdown全文。</p><label>数据集</label><select id="qDataset" class="select mb">${Object.entries(datasets).map(([k,v])=>`<option value="${k}">${v[0]}</option>`).join('')}</select><label>查询字段</label><select id="qField" class="select mb"></select><label>条件</label><select id="qOp" class="select mb"><option value="contains">包含</option><option value="equals">等于</option></select><label>查询值</label><input id="qValue" class="input mb" placeholder="输入关键词"><button class="btn primary" id="qRun">执行查询</button><button class="btn light" id="qClear">清空</button><div class="notice mt">查询结果默认不显示原始JSON。</div>`;const fields=()=>{const d=datasets[$('#qDataset').value][1],ks=uniq(d.slice(0,50).flatMap(x=>Object.keys(x))).sort();$('#qField').innerHTML='<option value="__all">全部字段</option>'+ks.map(k=>`<option value="${esc(k)}">${esc(FIELD_LABELS[k]||k)}</option>`).join('')};const run=()=>{const key=$('#qDataset').value,d=datasets[key][1],f=$('#qField').value,op=$('#qOp').value,q=$('#qValue').value.trim().toLowerCase(),r=d.filter(x=>{const v=f==='__all'?JSON.stringify(x):JSON.stringify(x[f]??'');return!q||(op==='equals'?v.replace(/^"|"$/g,'').toLowerCase()===q:v.toLowerCase().includes(q))}).slice(0,100);$('#queryResult').innerHTML=`<div class="page-head"><div><h2>查询结果</h2><p>匹配 ${r.length} 条,点击查看业务详情</p></div></div>${r.map((x,i)=>`<div class="query-row" data-action="query-record" data-dataset="${key}" data-index="${d.indexOf(x)}"><strong>${esc(recordTitle(datasets[key][2],x))}</strong><p style="margin:5px 0;color:#667085">${esc(clip(x.summary||x.usage||x.evidence||x.text||JSON.stringify(x),170))}</p></div>`).join('')||'<div class="empty">没有匹配结果</div>'}`};$('#qDataset').addEventListener('change',()=>{fields();run()});$('#qField').addEventListener('change',run);$('#qOp').addEventListener('change',run);$('#qValue').addEventListener('input',run);$('#qRun').onclick=run;$('#qClear').onclick=()=>{$('#qValue').value='';run()};fields();run()}
function renderAll(){renderOverview();renderOrganization();renderUnits();renderEquipment();renderTraining();renderIndustry();renderSafety();renderTimeline();renderKnowledge()}
document.addEventListener('click',e=>{const el=e.target.closest('[data-action]');if(!el)return;const a=el.dataset.action;if(a==='goto-tab')setTab(el.dataset.tab);if(a==='open-unit')openUnit(el.dataset.id);if(a==='open-org')openOrg(el.dataset.id);if(a==='open-equip')openEquip(el.dataset.id);if(a==='open-event')openEvent(el.dataset.index);if(a==='open-source')openSource(el.dataset.id);if(a==='missing-source')openModal(`<div class="record-hero"><h2>来源待补充</h2><p>当前来源索引中未匹配到该编号。</p></div>${kv('来源编号',esc(el.dataset.id))}<div class="notice mt">该编号已在Markdown正文中出现,但尚未与来源索引建立映射。后续可在离线来源表中补充标题、用途、核验日期、可信度及本地原文或摘录;本页面不展示外部网址。</div>`);if(a==='open-knowledge')openKnowledge(el.dataset.path);if(a==='record-detail'){const map={training:DATA.training,simulator:DATA.simulators,maintenance:DATA.maintenance,industry:DATA.industry,dependency:DATA.externalDependencies,risk:DATA.safetyRisks,process:DATA.safetyProcess};openRecord(el.dataset.type,map[el.dataset.type][Number(el.dataset.index)])}if(a==='query-record'){const key=el.dataset.dataset;openDatasetRecord(key,datasets[key][1][Number(el.dataset.index)])}if(a==='close-modal')closeModal();if(a==='close-drawer')closeDrawer();if(a==='close-detail')closeDetail();if(a==='open-query')openQuery();if(a==='close-query')closeQuery();if(a==='org-service'){state.orgService=el.dataset.service;state.orgZoom=1;renderOrganization()}if(a==='zoom-in'){state.orgZoom=Math.min(1.45,state.orgZoom+.1);renderOrganization()}if(a==='zoom-out'){state.orgZoom=Math.max(.6,state.orgZoom-.1);renderOrganization()}if(a==='zoom-reset'){state.orgZoom=1;renderOrganization()}if(a==='unit-view'){state.unitView=el.dataset.view;renderUnits()}if(a==='unit-page'){state.unitPage=Number(el.dataset.page);renderUnits()}if(a==='clear-units'){state.unitFilters={q:'',service:'全部',level:'全部',location:'全部',status:'全部'};state.unitPage=1;renderUnits()}if(a==='equip-cat'){state.equipCat=el.dataset.cat;renderEquipment()}if(a==='knowledge-cat'){state.knowledgeCategory=el.dataset.cat;state.knowledgePage=1;renderKnowledge()}if(a==='knowledge-page'){state.knowledgePage=Number(el.dataset.page);renderKnowledge()}if(a==='reader-scroll'){const t=document.getElementById(el.dataset.target);if(t)t.scrollIntoView({behavior:'smooth',block:'start'});$$('.reader-toc button').forEach(b=>b.classList.toggle('active',b===el))}if(a==='reset'){state.unitFilters={q:'',service:'全部',level:'全部',location:'全部',status:'全部'};state.unitPage=1;state.equipCat='全部';state.orgService='air_force';state.sourceQ='';state.knowledgeCategory='全部';state.knowledgeQ='';renderAll();setTab('overview')}});
$$('.tab').forEach(t=>t.addEventListener('click',()=>setTab(t.dataset.tab)));
const gs=$('#globalSearch');function globalResults(q){q=q.trim().toLowerCase();if(!q)return[];const r=[];DATA.units.forEach(x=>{if(JSON.stringify(x).toLowerCase().includes(q))r.push({kind:'单位',title:x.name,desc:x.summary,action:'open-unit',id:x.organization_id})});DATA.equipment.forEach(x=>{if(JSON.stringify(x).toLowerCase().includes(q))r.push({kind:'装备',title:equipName(x),desc:`${equipCat(x)} · ${status(x.status)}`,action:'open-equip',id:x.platform_id})});KNOWLEDGE.forEach(x=>{if((x.title+' '+x.summary+' '+x.text).toLowerCase().includes(q))r.push({kind:'知识正文',title:x.title,desc:x.summary||x.path,action:'open-knowledge',id:x.path})});DATA.safetyEvents.forEach((x,i)=>{if(JSON.stringify(x).toLowerCase().includes(q))r.push({kind:'飞安事件',title:`${x.date} ${x.platform}`,desc:x.summary,action:'open-event',id:i})});DATA.sources.forEach(x=>{if(JSON.stringify(x).toLowerCase().includes(q))r.push({kind:'来源',title:x.title,desc:x.usage,action:'open-source',id:x.source_id})});return r.slice(0,25)}
gs.addEventListener('input',()=>{const r=globalResults(gs.value),p=$('#searchPop');if(!gs.value.trim()){p.classList.add('hidden');return}p.innerHTML=r.length?r.map(x=>`<div class="search-item" data-search-action="${x.action}" data-search-id="${esc(x.id)}"><span class="search-kind">${x.kind}</span><div><strong>${esc(x.title)}</strong><small>${esc(clip(x.desc,110))}</small></div></div>`).join(''):'<div class="empty">没有匹配结果</div>';p.classList.remove('hidden')});$('#searchPop').addEventListener('click',e=>{const x=e.target.closest('[data-search-action]');if(!x)return;$('#searchPop').classList.add('hidden');gs.value='';if(x.dataset.searchAction==='open-unit')openUnit(x.dataset.searchId);if(x.dataset.searchAction==='open-equip')openEquip(x.dataset.searchId);if(x.dataset.searchAction==='open-event')openEvent(x.dataset.searchId);if(x.dataset.searchAction==='open-source')openSource(x.dataset.searchId);if(x.dataset.searchAction==='open-knowledge')openKnowledge(x.dataset.searchId)});document.addEventListener('click',e=>{if(!e.target.closest('.search'))$('#searchPop').classList.add('hidden')});renderAll();
})();
</script></body></html>