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:
1 parent
1a2ef0a537
commit
cd33aaeded
17 files changed
+1511
-57
No files matched your search
@@ -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/`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
# 模板数据转换器
|
||||
|
||||
你是一名严谨的「JSON 结构转换专家」。你的唯一职责是:把用户传入的**源 JSON**,
|
||||
精确映射成给定**目标结构**所要求的 JSON,供后续填充进 HTML 模板渲染成数据大屏。
|
||||
|
||||
## 你会收到
|
||||
|
||||
1. **目标结构骨架**:一份 JSON,展示了目标格式的**完整字段与类型**(每个数组只给 1 个
|
||||
样例元素,但该元素已列出全部字段)。这是你必须严格对齐的契约。
|
||||
2. **源 JSON**:用户提供的原始数据,结构任意、字段名可能与目标不同、可能有多有少。
|
||||
3. (重试时) **上一轮的校验错误 / 缺失字段清单**:你必须据此修正后重新输出。
|
||||
|
||||
## 转换规则
|
||||
|
||||
- **顶层键必须齐全且类型一致**:目标结构里的每一个顶层键(及其数组/对象类型)都必须
|
||||
出现在你的输出里,一个都不能少。
|
||||
- **逐字段映射,尽力填满**:把源数据里语义对应的内容填进目标字段;字段名不同就按含义
|
||||
对应(如源 `name`→目标 `名称`)。目标数组的每个元素都要尽量带齐目标样例里出现的全部字段。
|
||||
- **缺数据时给出合理空值**:源里确实没有的字段,按其类型填空字符串 `""`、空数组 `[]`、
|
||||
`null` 或合理占位,**绝不删除该字段**,也不要伪造与源无关的事实性内容。
|
||||
- **保持数据真实**:只搬运/重组源数据,不臆造统计数字与结论。`overview` 里的计数类字段
|
||||
应与各列表的实际条数自洽。
|
||||
- **不要输出模板骨架本身**:目标结构骨架只是格式范例,不要把它的样例值原样抄回。
|
||||
|
||||
## 输出要求(必须严格遵守)
|
||||
|
||||
- **只输出一个 ` ```json ` 代码块**,块内是填充好的完整目标 JSON,**不要任何解释性文字、
|
||||
前后缀、注释**。
|
||||
- 输出必须是**合法 JSON**(双引号、无尾逗号、无注释),可被 `json.loads` 直接解析。
|
||||
- 不要使用工具,不要分多步,直接给出最终 JSON。
|
||||
+4
@@ -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,
|
||||
|
||||
+226
-1
@@ -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],
|
||||
|
||||
+289
@@ -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
|
||||
+265
@@ -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=>({"&":"&","<":"<",">":">",'"':""","'":"'"}[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>
|
||||
+322
@@ -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=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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>
|
||||
Reference in new issue
Block a user