Merge branch 'dhk-1' into 'master'

fix(roundtable): xdfx-A 深链会商聊天记录按行动id隔离存储

See merge request knowledge-base-bj/deerflow-server!145
This commit is contained in:
董宏凯 committed 2026-06-23 11:48:10 +00:00
commit 5fddb08e04
60 files changed
+5972 -549

No files matched your search

+2 -1
View File
@@ -79,11 +79,12 @@ The frontend (`frontend-web/`) is a React 19 + Vite SPA:
- **Routing**: React Router v7; top-level routes in `src/pages/PageRoutes.tsx`, workspace sub-routes in `src/pages/WorkspaceRoutes.tsx`
- **Pages**: `ChatPage` (thread chat), `AgentChatPage` (custom agent chat), `AgentsPage`/`NewAgentPage` (agent management), `ScheduledTasksPage`/`ScheduledTaskRunDetailPage` (scheduled tasks), `LightAppManagementPage`/`LightAppListPage`/`LightAppIframeView` (轻应用中心 — register/list/embed mini-apps)
- **定时任务「JSON → HTML 模板填充」(模板数据转换器)**: a third scheduled-task execution mode. In the create/edit dialog (`ScheduledTasksPage.tsx`), selecting the built-in agent **「模板数据转换器」** (`execution_agent_name === "template-json-builder"`, `TEMPLATE_BUILDER_AGENT_ID`) reveals a 「HTML模板」 select (`useHtmlTemplates` → `GET /api/scheduled-tasks/html-templates`) + a **.json 上传** button (reads the file's text into the prompt box) and **hides** the 必须生成 HTML/Markdown switches (this mode always outputs HTML). The chosen template persists as `execution_context.template_config.template_id` (`TemplateFillConfig` in `core/scheduled-tasks/types.ts`). At run time the backend converts the source JSON (the task prompt) into that template's `DATA` structure, validates (retry-on-fail), fills the template, and stores an HTML page previewable via the existing run-detail HTML preview. The source JSON is **any** shape — the agent's `SOUL.md` maps it to the template's schema skeleton. See the backend CLAUDE.md「Template-fill mode」for the engine (`deerflow/runtime/scheduler/template_fill.py`), agent seed, and dispatch.
- **轻应用中心 (Light App Center)**: a sibling of 任务管理 in the sidebar with two pages — 应用管理 (admin registers `window_open` or `iframe` mini-apps with a route-param builder: login params resolved from `localStorage.userInfo`, theme params literal) and 应用列表 (card grid of `window_open` apps, `window.open` launch). `iframe` apps are mounted under a chosen parent menu (XK/PG/TY/综合管理) and **dynamically injected into the sidebar** by `core/page-layout/use-sidebar-menu.ts` (`useSidebarMenuItems`), opening in the `LightAppIframeView` host. Client + React Query hooks in `strategy-components/api/light-apps.ts`; URL builder/constants in `strategy-components/lib/light-app.ts`. Backend: `/api/light-apps` (global/shared, admin-gated writes).
- **菜单管理 (Menu Management, admin-only)**: an **overlay** over the static sidebar menu — `MenuManagementPage` (`pages/MenuManagementPage.tsx`, route `/page/strategy/admin/menu-management`, opened from the admin-only 「菜单管理」 item in the **bottom-left settings dropdown** of `components/page-sidebar.tsx`). Admins rename / 停用·启用 / reorder (↑↓ or @dnd-kit drag) / re-parent (horizontal drag **or** the edit dialog's 「移动到」parent selector) any node across 一级/二级/三级, **including injected iframe light-app nodes** (`light-app-<appId>`). The static tree (`core/page-layout/sidebar-menu.ts`) stays the source of icons/routes; only per-node deltas (`label`/`disabled`/`parentId`/`sortOrder`) persist to the backend. Merge logic in `core/page-layout/menu-overrides.ts` (`applyMenuOverrides`, applied in `use-sidebar-menu.ts` after light-app injection, before role filter; disabling a node hides its whole subtree) — strict **3-level cap** (`wouldExceedDepth`). Client `strategy-components/api/menu-overrides.ts`. Backend: `/api/menu-overrides` (global/shared; open read, admin atomic replace-all).
- **敏感词管理 (Sensitive Word Management)**: configures the **展示层脱敏映射** (原始词 → 替换词). **Gated on the username `lqq`** (not the admin role) — `getAccountDisplayName().toLowerCase() === "lqq"` (username = email `@` prefix, else `user_id`, same as the profile page); the dropdown entry, the route guard, and the page-level guard all use this check. `src/lib/desensitize.ts` ships a frozen seed map `SEED_DESENSITIZE_MAP` (the deployment seed) plus a live mutable `DESENSITIZE_MAP` (initialized from the seed so `desensitize()` works before any fetch). `DesensitizeWordsLoader` (mounted in `App.tsx` `AppProviders`) fetches `/api/sensitive-words` on startup and, if non-empty, calls `replaceDesensitizeMap(...)` to make the backend config the source of truth (empty/failed → keep seed). Page `SensitiveWordsPage.tsx` (route `/page/strategy/admin/sensitive-words`, opened from the 「敏感词管理」 item in the **bottom-left settings dropdown** of `components/page-sidebar.tsx`, present in **both** full + collapsed sidebar modes) lists/adds/edits/启停/deletes words and saves the **whole set atomically** (replace-all); a 「导入种子词」 button merges any missing `SEED_DESENSITIZE_MAP` entries into the editor. Client + hooks in `strategy-components/api/sensitive-words.ts` (`wordsToMap` folds enabled rows into the runtime map). Backend: `/api/sensitive-words` (global/shared; open read, write allowed for admin **or** the `lqq` account), mirroring 菜单管理's store shape. **侧边栏 + 导航栏 menu labels are desensitized** at the data source: `useSidebarMenuItems`/`useActiveThirdLevel` (`core/page-layout/use-sidebar-menu.ts`) map every node's `label` through a desensitizer built **directly from the `/api/sensitive-words` query** (`makeDesensitizer(wordsToMap(words))`, falling back to the global seed `desensitize` while empty) — this sidesteps the `replaceDesensitizeMap` effect-timing race. Only `label` is rewritten; `path`/`id`/`icon` are untouched so routing/highlight are unaffected. All nav surfaces (`page-sidebar.tsx`, `page-sidebar-dark.tsx`, `page-sidebar-v1.tsx`, `strategy-components/Header.tsx`, `PageRoutes.tsx`) consume this hook and are covered; `MenuManagementPage` uses the raw tree so admins still edit real labels.
- **技能地址管理 (Skill Address Management, admin-only)**: 扫描运行中的技能(`skills/{public,custom}` 下所有 `.md`/`.py`)里的 **http(s) URL 与 IP/IP:端口**,按唯一地址聚合全部出现位置(跨 .md/.py),支持**批量替换**(如把 UAT 地址一次性改成生产)。主体抽成可复用组件 `SkillAddressesPanel`(`pages/SkillAddressesPage.tsx` 导出,默认导出 `SkillAddressesPage` 仍保留旧路由 `/page/strategy/admin/skill-addresses` 作直链回退),**作为「技能地址管理」标签页嵌入管理员「技能管理」页 `pages/AdminSkillsPage.tsx`**(该页加 Tabs:「技能列表」+「技能地址管理」;入口在「设置和更多」下拉的「技能管理」`workspace-nav-menu.tsx` → `/page/workspace/admin/skills`,不再单列于 `components/page-sidebar.tsx` 左下角设置下拉):打开自动扫描并带**进度条**(扫描逐技能 / 应用逐文件 X/N,应对 150+ 技能),URL/IP 类型过滤 + 搜索 + 「片段批量套用」(如所有 `uat.4.cn` → `prod.4.cn`)、每地址「替换为」输入框(可展开看 `file:line` 片段)、内联**预览**、**应用**、**多关键词检索**(空格分隔全部命中),以及**修改记录**面板——每次应用生成一条记录(显示时间/改了哪些地址 from→to/文件数/处数),可**针对单次记录回退**(二次确认)。client + NDJSON 流式解析在 `strategy-components/api/skill-addresses.ts`。后端 `/api/skill-addresses`(**仅管理员**):所有文件 IO 走 `asyncio.to_thread` 不阻塞事件循环;`GET /scan`(+`/scan/stream`)、`POST /preview`、`POST /apply`(+`/apply/stream`)、`GET /backups`、`POST /rollback/{id}`;**子串安全替换**(基于 token span,不会误伤子串)、`expected_total` 乐观锁(409)、写前自动磁盘备份到 `{skills_root}/.address-edits/`(同时写一份 `{id}.manifest.json` 记录本次替换明细,供修改记录展示)、应用后刷新技能系统提示缓存。纯引擎 `deerflow/skills/address_scan.py`,router `app/gateway/routers/skill_addresses.py`,tests `tests/test_skill_addresses.py`。范围仅运行中技能,不动 `deploy/minimal/skills/`。
- **按钮管理 (Task Button Management, admin-only)**: configures the **可配置跳转按钮** rendered in the left task panel of the task deep-link workspaces (rwfx / 3qfx / xdfx, see `TaskFxWorkspace`). Each button = `{business, label, linkType, target, appendTaskId, idParam, idKind, enabled, sortOrder}`; `linkType` is `url` (外链 `window.open`, optional append of `?{idParam}={id}` — **`idParam` is the editable query-param name**, e.g. `id`/`task_id`), `business` (内部切换到另一业务, reuses `resolveTaskDeeplinkTarget`, always task id), or `purpose` (弹出当前任务的「任务目的」表格 via the shared `TaskPurposeDialog`, no target — configurable for any page incl. xdfx). **`idKind` (`task`/`action`) only matters for xdfx**: its deep-link `?taskId=` is an 行动id and the 任务id is resolved at login (`fetchTaskIdFromAction`) — `TaskContext` now also stashes the raw `actionId`, so a url button can append either (default `task`). Page `TaskButtonsPage.tsx` (route `/page/strategy/admin/task-buttons`, **admin-gated**, entry「按钮管理」in the bottom-left settings dropdown of `components/page-sidebar.tsx`, both full + collapsed modes) has per-business tabs, edits name/type/target/启停/排序/增删, saves the **whole set atomically** (replace-all), plus 「导入默认」 to merge code-seed defaults. **Seed/fallback** (like 敏感词管理): only when the whole table is empty (fresh deploy, never saved) do the workspace + page fall back to `defaultTaskButtons(cfg)` — rwfx's url buttons (编辑/下一步/3q详情) built from the **live** `/api/public/config/task-deeplink` plus cross-business switch buttons for **all three** businesses (each switches to the other two — rwfx→三情/行动, 3qfx→任务/行动, xdfx→任务/三情). Once saved, the store is the sole source of truth. The old hardcoded jump buttons were migrated out: `TaskFxWorkspace` no longer hardcodes 切换到三情分析, `TaskActionsPanel` keeps only the non-jump 清除记录, and `TaskPurposePanel` dropped its 3q详情 button (now configurable) **and defaults the 目的树 table to collapsed** (chevron toggle). Helpers `core/auth/task-buttons.ts` (`TaskButton`, `defaultTaskButtons`, `resolveTaskButtons`, `buttonsForBusiness`, `runTaskButton`), client+hooks `strategy-components/api/task-buttons.ts`. Backend: `/api/task-buttons` (global/shared; open read, admin replace-all), store `deerflow.persistence.task_buttons` (table `task_buttons`, auto-created by `create_all`), wired `app.state.task_button_store`. Tests: `tests/test_task_buttons.py`.
- **按钮管理 (Task Button Management, admin-only)**: configures the **可配置跳转按钮** rendered in the left task panel of the task deep-link workspaces (rwfx / 3qfx / xdfx, see `TaskFxWorkspace`). Each button = `{business, label, linkType, target, appendTaskId, idParam, idKind, enabled, sortOrder}`; `linkType` is `url` (外链 `window.open`, optional append of `?{idParam}={id}` — **`idParam` is the editable query-param name**, e.g. `id`/`task_id`), `business` (内部切换到另一业务, reuses `resolveTaskDeeplinkTarget`, always task id), or `purpose` (弹出当前任务的「任务目的」表格 via the shared `TaskPurposeDialog`, no target — configurable for any page incl. xdfx). **`idKind` (`task`/`action`) only matters for xdfx**: its deep-link `?taskId=` is an 行动id and the 任务id is resolved at login (`fetchTaskIdFromAction`) — `TaskContext` now also stashes the raw `actionId`, so a url button can append either (default `task`). Page `TaskButtonsPage.tsx` (route `/page/strategy/admin/task-buttons`, **admin-gated**, entry「按钮管理」in the bottom-left settings dropdown of `components/page-sidebar.tsx`, both full + collapsed modes) has per-business tabs, edits name/type/target/启停/排序/增删, saves the **whole set atomically** (replace-all), plus 「导入默认」 to merge code-seed defaults. **Seed/fallback** (like 敏感词管理): only when the whole table is empty (fresh deploy, never saved) do the workspace + page fall back to `defaultTaskButtons(cfg)` — rwfx's url buttons (编辑/上一步/下一步/3q详情) built from the **live** `/api/public/config/task-deeplink` plus cross-business switch buttons for **all three** businesses (each switches to the other two — rwfx→三情/行动, 3qfx→任务/行动, xdfx→任务/三情). Once saved, the store is the sole source of truth. **Bottom 快捷跳转 bar groups by prev/next**: `TaskJumpBar` renders only「上一步/下一步」-type buttons in the bottom bar — a `prev` group (id 含 `prev` 或文案「上一步」, 描边次要样式 + ←图标) rendered **左侧**, then a `next` group (id 含 `next` 或文案「下一步」, 实心主色) on the **右侧** (container `justify-end`); everything else goes to the right-edge floating menu. The default 上一步 button (`rwfx-prev`, `url`, target = `DASHBOARD_AGENT_ROUTE` `/page/strategy/dashboard-agent`, `appendTaskId` + `idParam=taskId` → `?taskId=任务id`, `openMode=self`) 默认指向**应用内**「大屏绘制」页,在 **both** 单智能体 (`TaskFxWorkspace`) 与 多智能体会商 (`RoundtablePlanningPage` 深链 rwfx-A) 都显示(共用 `TaskJumpBar`)。**`runTaskButton` 的 `url` 类型支持应用内路由**:target 以单个 `/` 开头时按内部路由处理——`self` 走 react-router 同窗导航(不刷新),`blank` 拼成当前源的 hash 路由新标签打开;外链行为不变。 The old hardcoded jump buttons were migrated out: `TaskFxWorkspace` no longer hardcodes 切换到三情分析, `TaskActionsPanel` keeps only the non-jump 清除记录, and `TaskPurposePanel` dropped its 3q详情 button (now configurable) **and defaults the 目的树 table to collapsed** (chevron toggle). Helpers `core/auth/task-buttons.ts` (`TaskButton`, `defaultTaskButtons`, `resolveTaskButtons`, `buttonsForBusiness`, `runTaskButton`), client+hooks `strategy-components/api/task-buttons.ts`. Backend: `/api/task-buttons` (global/shared; open read, admin replace-all), store `deerflow.persistence.task_buttons` (table `task_buttons`, auto-created by `create_all`), wired `app.state.task_button_store`. Tests: `tests/test_task_buttons.py`.
- **State**: TanStack Query for server state, context providers (`SubtasksProvider`, `ArtifactsProvider`, `PromptInputProvider`) scoped to `WorkspaceRoutes`
- **LLM streaming**: `@langchain/langgraph-sdk` streams messages from the backend via SSE
- **UI**: Radix UI primitives + TailwindCSS v4 + shadcn-style components in `src/components/ui/`
+1 -1
View File
@@ -8,7 +8,7 @@ VITE_USE_REAL_DATA=true
VITE_MOCK_COP_TASK_DETAIL=true
# 研判情报报告(大屏绘制智能体深链 selectSqReport)数据源:true 走本地样例假数据(真实接口未就绪时调试用);联调通过后置 false。
VITE_MOCK_SQ_REPORT=false
VITE_MOCK_SQ_REPORT=true
# 深链任务详情(cop-task-detail:任务名称/描述)数据源:true 走本地假数据(真实接口未就绪时调试用);联调通过后置 false。
VITE_MOCK_COP_TASK_DETAIL=true
@@ -1,6 +1,6 @@
import type { Message } from "@langchain/langgraph-sdk";
import type { BaseStream } from "@langchain/langgraph-sdk/react";
import { CheckIcon, SendIcon, XIcon } from "lucide-react";
import { CheckIcon, PenLineIcon, SendIcon, XIcon } from "lucide-react";
import { useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
import { useStickToBottomContext } from "use-stick-to-bottom";
@@ -20,10 +20,10 @@ import {
groupMessages,
hasContent,
hasPresentFiles,
hasReasoning,
hasToolCalls,
isHiddenFromUIMessage,
} from "@/core/messages/utils";
hasReasoning,
hasToolCalls,
isHiddenFromUIMessage,
} from "@/core/messages/utils";
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
import { isThreadRagModeLocked, useLocalSettings } from "@/core/settings";
import { useSkills } from "@/core/skills/hooks";
@@ -103,6 +103,7 @@ export function MessageList({
onSetupAgentReject,
onSetupWritingApprove,
onSetupWritingReject,
onSetupWritingStart,
}: {
className?: string;
contentClassName?: string;
@@ -120,6 +121,8 @@ export function MessageList({
onSetupAgentReject?: (payload: SetupAgentApprovalPayload) => void | Promise<void>;
onSetupWritingApprove?: (payload: SetupWritingApprovalPayload) => void | Promise<void>;
onSetupWritingReject?: (payload: SetupWritingApprovalPayload) => void | Promise<void>;
/** 审批卡「直接开始写作」回调:应用配置(含已确认大纲)并立即启动写作流程(仅写作页提供,模板编辑等场景缺省隐藏该按钮)。 */
onSetupWritingStart?: (payload: SetupWritingApprovalPayload) => void | Promise<void>;
}) {
const baseRehypePlugins = useRehypeSplitWordsIntoSpans(thread.isLoading);
const rehypePlugins = useMemo(
@@ -152,10 +155,10 @@ export function MessageList({
});
}, []);
const allMessages = useMemo(
() => thread.messages.filter((m) => !isHiddenFromUIMessage(m)),
[thread.messages],
);
const allMessages = useMemo(
() => thread.messages.filter((m) => !isHiddenFromUIMessage(m)),
[thread.messages],
);
const messages = useMemo(
() =>
hiddenMessageIds.size === 0
@@ -430,6 +433,7 @@ export function MessageList({
message={message}
onApprove={onSetupWritingApprove}
onReject={onSetupWritingReject}
onStart={onSetupWritingStart}
/>
<MessageTokenUsageList
enabled={tokenUsageEnabled}
@@ -629,6 +633,8 @@ export type SetupAgentApprovalPayload = {
export type SetupWritingApprovalPayload = {
user_intent?: string;
article_type?: string;
/** 当 article_type 是已有类型库里没有的新类型时,模型识别出的一句话特征说明(命中已有类型时为空)。 */
article_type_detail?: string;
word_count_target?: number | null;
target_audience?: string;
writing_mode?: string;
@@ -820,14 +826,16 @@ function SetupWritingApprovalCard({
message,
onApprove,
onReject,
onStart,
}: {
disabled: boolean;
message: unknown;
onApprove?: (payload: SetupWritingApprovalPayload) => void | Promise<void>;
onReject?: (payload: SetupWritingApprovalPayload) => void | Promise<void>;
onStart?: (payload: SetupWritingApprovalPayload) => void | Promise<void>;
}) {
const payload = getSetupWritingApprovalPayload(message);
const [submitting, setSubmitting] = useState<"approve" | "reject" | null>(
const [submitting, setSubmitting] = useState<"approve" | "reject" | "start" | null>(
null,
);
@@ -838,7 +846,7 @@ function SetupWritingApprovalCard({
const approvalPayload = payload;
const busy = disabled || submitting !== null;
async function run(action: "approve" | "reject") {
async function run(action: "approve" | "reject" | "start") {
if (busy) {
return;
}
@@ -846,6 +854,8 @@ function SetupWritingApprovalCard({
try {
if (action === "approve") {
await onApprove?.(approvalPayload);
} else if (action === "start") {
await onStart?.(approvalPayload);
} else {
await onReject?.(approvalPayload);
}
@@ -854,9 +864,14 @@ function SetupWritingApprovalCard({
}
}
const hasNewType = !!approvalPayload.article_type_detail?.trim();
const rows: Array<{ label: string; value: string }> = [];
if (approvalPayload.article_type) {
rows.push({ label: "文章类型", value: approvalPayload.article_type });
rows.push({
label: "文章类型",
// 新类型(列表外)标注「· 新」,让用户知道这是模型新识别的类型。
value: hasNewType ? `${approvalPayload.article_type} · 新` : approvalPayload.article_type,
});
}
if (typeof approvalPayload.word_count_target === "number") {
rows.push({ label: "目标字数", value: `${approvalPayload.word_count_target} 字` });
@@ -903,6 +918,13 @@ function SetupWritingApprovalCard({
</div>
) : null}
{hasNewType ? (
<div className="space-y-1">
<div className="text-muted-foreground text-xs">新类型说明</div>
<p className="text-sm leading-6">{approvalPayload.article_type_detail}</p>
</div>
) : null}
{approvalPayload.outline ? (
<div className="space-y-1">
<div className="text-muted-foreground text-xs">已有大纲</div>
@@ -926,6 +948,7 @@ function SetupWritingApprovalCard({
</Button>
<Button
type="button"
variant={onStart ? "outline" : "default"}
size="sm"
disabled={busy || !onApprove}
onClick={() => void run("approve")}
@@ -933,6 +956,17 @@ function SetupWritingApprovalCard({
<CheckIcon className="h-4 w-4" />
应用到表单
</Button>
{onStart ? (
<Button
type="button"
size="sm"
disabled={busy}
onClick={() => void run("start")}
>
<PenLineIcon className="h-4 w-4" />
直接开始写作
</Button>
) : null}
</div>
</div>
);
+42 -8
View File
@@ -7,7 +7,8 @@
* 任务深链配置读出 rwfx 的 4 个历史按钮,3qfx/xdfx 为空)——这样真实跳转地址通过实时
* config 进种子,避免写死;一旦保存过,后端即唯一数据源。
*
* 按钮两类(``linkType``):``url`` 外链(``window.open``,可选追加 ``?id=任务id``)/
* 按钮两类(``linkType``):``url``(外链 ``window.open``;**或应用内路由**——target 以 ``/`` 开头
* 时按内部路由处理:``self`` 走 react-router 同窗导航、``blank`` 拼 hash 路由新标签;可选追加 ``?{idParam}=id``)/
* ``business`` 内部切换到另一业务(复用 [[task-deeplink]] 的 ``resolveTaskDeeplinkTarget``)。
*/
@@ -20,6 +21,9 @@ import {
resolveTaskDeeplinkTarget,
} from "@/core/auth/task-deeplink";
/** 「大屏绘制」独立页应用内路由(深链「上一步」默认指向它,跳转追加 ?taskId=)。 */
export const DASHBOARD_AGENT_ROUTE = "/page/strategy/dashboard-agent";
/** 任务工作区业务码(与路由 taskBase 一一对应)。 */
export type TaskBusiness = "rwfx" | "3qfx" | "xdfx";
@@ -94,7 +98,8 @@ export const TASK_BUSINESS_TARGETS: { code: TaskBusiness; label: string }[] = [
/**
* 代码种子默认按钮(仅在后端整张表为空时使用):
* - rwfx:历史 4 个按钮(编辑/下一步/3q详情,地址取自实时任务深链配置)+ 切换到三情/行动分析;
* - rwfx:历史按钮(编辑/下一步/3q详情,地址取自实时任务深链配置)+「上一步」+ 切换到三情/行动分析;
* 「上一步」紧邻「下一步」左侧(底部栏 prev 组在 next 组前),默认指向应用内大屏绘制页(?taskId=任务id);
* - 3qfx / xdfx:互相切换到另外两个业务。
* 即「三个业务都带切换按钮」。已保存过则用页面「导入默认」把缺失项补进来。
*/
@@ -147,6 +152,24 @@ export function defaultTaskButtons(cfg: TaskDeeplinkConfig): TaskButton[] {
enabled: true,
sortOrder: 0,
},
{
// 上一步:放在「下一步」左边(底部快捷跳转栏,prev 组渲染在 next 组之前)。
// 默认指向**应用内**「大屏绘制」页 /page/strategy/dashboard-agent(内部路由,以 / 开头),
// 携带 ?taskId=任务id(大屏页按 taskId 取数)。openMode=self → 同窗 react-router 导航;
// 大屏页路由在 PageLayout 里被**强制显示全局 chrome**(见 PageRoutes 的 forceChromeForDashboardAgent),
// 故即便从去 chrome 的工作区进来也带回顶部导航栏 + 侧边栏(embed=1 外部嵌入态仍隐藏)。
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 +181,7 @@ export function defaultTaskButtons(cfg: TaskDeeplinkConfig): TaskButton[] {
idKind: "task",
openMode: "blank",
enabled: true,
sortOrder: 1,
sortOrder: 2,
},
{
id: "rwfx-detail3q",
@@ -171,11 +194,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 +260,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 {
+22 -10
View File
@@ -212,21 +212,33 @@ export function extractToolResultPayload(
}
const THINK_TAG_RE = /<think>\s*([\s\S]*?)\s*<\/think>/g;
const THINK_OPEN = "<think>";
function splitInlineReasoning(content: string) {
const reasoningParts: string[] = [];
const cleaned = content
.replace(THINK_TAG_RE, (_, reasoning: string) => {
const normalized = reasoning.trim();
if (normalized) {
reasoningParts.push(normalized);
}
return "";
})
.trim();
// 1) 先抽出所有**已闭合**的 <think>...</think>。
let working = content.replace(THINK_TAG_RE, (_, reasoning: string) => {
const normalized = reasoning.trim();
if (normalized) {
reasoningParts.push(normalized);
}
return "";
});
// 2) 流式中常见**未闭合**的 <think>(还没等到 </think> 就先到达):把开标签之后的内容
// 当作「实时思考」抽到 reasoning、从答案正文里移除。否则模型慢/先思考时,用户既会看到
// 字面 "<think>",又会因答案区长时间空白而以为卡死。这样思考内容会实时流进思考区。
const openIdx = working.indexOf(THINK_OPEN);
if (openIdx !== -1 && !working.slice(openIdx).includes("</think>")) {
const partial = working.slice(openIdx + THINK_OPEN.length).trim();
if (partial) {
reasoningParts.push(partial);
}
working = working.slice(0, openIdx);
}
return {
content: cleaned,
content: working.trim(),
reasoning: reasoningParts.length > 0 ? reasoningParts.join("\n\n") : null,
};
}
@@ -4,6 +4,7 @@ import { OptType, writeYLog } from "@/core/log/y-log";
import type {
HtmlPageFavorite,
HtmlTemplateOption,
PublishedScheduledTask,
ScheduledTask,
ScheduledTaskDeliveryProfile,
@@ -33,6 +34,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
@@ -65,6 +65,7 @@ export function AIWritingForm({
audience, setAudience,
wordCountInput, setWordCountInput,
strictMode, setStrictMode,
addReferences, setAddReferences,
materialSource, setMaterialSource,
// 笔记来源(search_space_id + document_id 列表)已上提到 WritingFormContext,
// 以便左栏「笔记本」点击可直接设为素材来源(需求 0616 #5)。
@@ -164,6 +165,7 @@ export function AIWritingForm({
wordCountTarget: wordCount,
keywordCount: DEFAULT_KEYWORD_COUNT,
writingMode: hideAdvancedFields ? 'loose' : strictMode ? 'strict' : 'loose',
addReferences,
materialSource: materialSource,
modelName: selectedModel?.name || modelName || undefined,
// 只有选了"我的空间"才传笔记参数
@@ -381,6 +383,23 @@ export function AIWritingForm({
</Select>
</div>
<div className="mt-4 flex items-start gap-2 rounded-xl border border-[#e8dedd] bg-[#f9f5f5] px-3 py-3 dark:border-[#1B477E] dark:bg-[#022248]/40">
<input
type="checkbox"
id="add-references"
checked={addReferences}
onChange={(e) => setAddReferences(e.target.checked)}
disabled={disabled}
className="mt-0.5 size-3.5 shrink-0 cursor-pointer accent-primary"
/>
<label htmlFor="add-references" className="cursor-pointer select-none">
<span className="text-sm font-medium text-foreground/90">添加参考文献</span>
<p className="mt-0.5 text-xs leading-relaxed text-muted-foreground/80">
开启后,正文引用素材处自动加角标 [n],文末按出现顺序生成「参考文献」列表(像论文一样)。
</p>
</label>
</div>
<Button
type="submit"
disabled={disabled || !intent.trim()}
@@ -41,6 +41,7 @@ import {
* 把这个 flag 翻成 true 即可,UI / hook 全套代码都已就位。
*/
const ENABLE_PAUSE_RESUME_UI = false;
import { countArticleWords } from '../../utils/wordCount';
import { AIWritingForm } from './AIWritingForm';
import { AIWritingTimeline } from './AIWritingTimeline';
import { InterventionCard } from './InterventionCard';
@@ -482,6 +483,8 @@ export function AIWritingPanel({
outline={state.outline}
reviewResult={state.reviewResult}
draftTitle={state.currentDraftTitle}
draftWordCount={countArticleWords(state.currentDraftMarkdown)}
wordCountTarget={state.request?.wordCountTarget}
revisionCount={state.revisionCount}
skillSearchSteps={state.skillSearchSteps}
skillSearchInProgress={state.status === 'researching' && state.skillSearchSteps.length > 0}
@@ -24,6 +24,10 @@ interface InterventionCardProps {
outline: AIOutline | null;
reviewResult: ReviewResult | null;
draftTitle: string;
/** 当前草稿实际字数(与目标对比展示,让用户在确认时即看到字数是否达标) */
draftWordCount?: number;
/** 用户设定的目标字数 */
wordCountTarget?: number;
revisionCount: number;
skillSearchSteps?: SkillSearchStep[];
/** 是否正在检索中(researching/awaiting 切换之间,前端用于流式展示步骤进行中) */
@@ -34,7 +38,7 @@ interface InterventionCardProps {
export function InterventionCard({
pauseEvent, materials, outline, reviewResult,
draftTitle, revisionCount, skillSearchSteps, skillSearchInProgress, onSubmit, disabled,
draftTitle, draftWordCount, wordCountTarget, revisionCount, skillSearchSteps, skillSearchInProgress, onSubmit, disabled,
}: InterventionCardProps) {
const pausePoint = pauseEvent.pausePoint;
@@ -64,6 +68,8 @@ export function InterventionCard({
return (
<DraftConfirmCard
draftTitle={draftTitle}
draftWordCount={draftWordCount}
wordCountTarget={wordCountTarget}
revisionCount={revisionCount}
reviewResult={reviewResult}
onSubmit={onSubmit}
@@ -357,13 +363,15 @@ function OutlineConfirmCard({ outline: initialOutline, onSubmit, disabled }: Out
interface DraftConfirmCardProps {
draftTitle: string;
draftWordCount?: number;
wordCountTarget?: number;
revisionCount: number;
reviewResult: ReviewResult | null;
onSubmit: (payload: UserInterventionPayload) => void;
disabled?: boolean;
}
function DraftConfirmCard({ draftTitle, revisionCount, reviewResult, onSubmit, disabled }: DraftConfirmCardProps) {
function DraftConfirmCard({ draftTitle, draftWordCount, wordCountTarget, revisionCount, reviewResult, onSubmit, disabled }: DraftConfirmCardProps) {
const [notes, setNotes] = useState('');
const [showNotes, setShowNotes] = useState(false);
const [submitting, setSubmitting] = useState(false);
@@ -399,6 +407,11 @@ function DraftConfirmCard({ draftTitle, revisionCount, reviewResult, onSubmit, d
const hasReview = reviewResult != null && reviewResult.revisionNotes.length > 0;
// 实际字数 vs 目标字数对比:让用户在确认时直接看到是否达标,偏差超 100 字标橙提醒
const showWordCmp = wordCountTarget != null && wordCountTarget > 0 && draftWordCount != null && draftWordCount > 0;
const wordDiff = showWordCmp ? draftWordCount! - wordCountTarget! : 0;
const wordWithin = Math.abs(wordDiff) <= 100;
return (
<div className="rounded-md border bg-background space-y-3 p-3">
<div className="flex items-start gap-2">
@@ -410,6 +423,12 @@ function DraftConfirmCard({ draftTitle, revisionCount, reviewResult, onSubmit, d
{revisionCount > 0 ? `(第 ${revisionCount} 次修改稿)` : '(初稿)'}
· 右侧可预览完整内容
</p>
{showWordCmp && (
<p className={cn('text-xs mt-0.5', wordWithin ? 'text-muted-foreground' : 'text-orange-600 dark:text-orange-400')}>
实际 {draftWordCount} 字 / 目标 {wordCountTarget} 字
{wordDiff !== 0 && `(${wordDiff > 0 ? '超出' : '不足'} ${Math.abs(wordDiff)} 字${wordWithin ? ',在误差范围内' : ''})`}
</p>
)}
</div>
</div>
@@ -1,7 +1,21 @@
import { useState } from 'react';
import { ListTree, Plus } from 'lucide-react';
import { ListTree, Plus, GripVertical } from 'lucide-react';
import {
DndContext,
DragOverlay,
PointerSensor,
pointerWithin,
rectIntersection,
useSensor,
useSensors,
type CollisionDetection,
type DragEndEvent,
type DragStartEvent,
} from '@dnd-kit/core';
import { Button } from '@/components/ui/button';
import type { AIOutline, OutlineSection as OutlineSectionType } from '../../types/ai-writing';
import { cn } from '@/lib/utils';
import type { AIOutline, KeyPoint, OutlineSection as OutlineSectionType } from '../../types/ai-writing';
import { normalizeKeyPoint } from '../../types/ai-writing';
import { OutlineSection } from './OutlineSection';
import { CollapseTransition } from './CollapseTransition';
@@ -11,15 +25,177 @@ interface OutlineCardProps {
readOnly?: boolean;
}
// 节点路径(章节/小节/子要点)
export type DragPath =
| { type: 'section'; s: number }
| { type: 'keypoint'; s: number; k: number }
| { type: 'subpoint'; s: number; k: number; p: number };
// 节点 id:s{S} / s{S}-k{K} / s{S}-k{K}-p{P}
export function parseItemId(id: string): DragPath | null {
const m = id.match(/^s(\d+)(?:-k(\d+))?(?:-p(\d+))?$/);
if (!m) return null;
const s = parseInt(m[1], 10);
const k = m[2] !== undefined ? parseInt(m[2], 10) : undefined;
const p = m[3] !== undefined ? parseInt(m[3], 10) : undefined;
if (p !== undefined && k !== undefined) return { type: 'subpoint', s, k, p };
if (k !== undefined) return { type: 'keypoint', s, k };
return { type: 'section', s };
}
// 区域 id:before:<节点id> / after:<节点id> / into:<节点id>
type Zone = 'before' | 'after' | 'into';
function parseZoneId(zoneId: string): { zone: Zone; targetPath: DragPath; targetItemId: string } | null {
const m = zoneId.match(/^(before|after|into):(.+)$/);
if (!m) return null;
const targetItemId = m[2];
const targetPath = parseItemId(targetItemId);
if (!targetPath) return null;
return { zone: m[1] as Zone, targetPath, targetItemId };
}
function cloneOutline(outline: AIOutline): AIOutline {
return {
...outline,
sections: outline.sections.map((s) => ({
...s,
keyPoints: (s.keyPoints ?? []).map((kp) => {
const n = normalizeKeyPoint(kp);
return { ...n, subPoints: [...n.subPoints] } as KeyPoint;
}),
materialIds: [...(s.materialIds ?? [])],
})),
};
}
type SourceValue = OutlineSectionType | KeyPoint | string;
// 拖到自身子树内非法(章节扔进自己的小节/子要点 / 小节扔进自己的子要点)
function isDescendantTarget(from: DragPath, to: DragPath): boolean {
if (from.type === 'section') return to.s === from.s;
if (from.type === 'keypoint') return to.type !== 'section' && to.s === from.s && to.k === from.k;
return false;
}
function readAt(outline: AIOutline, p: DragPath): SourceValue {
if (p.type === 'section') return outline.sections[p.s];
const kps = outline.sections[p.s].keyPoints as KeyPoint[];
if (p.type === 'keypoint') return kps[p.k];
return kps[p.k].subPoints[p.p];
}
function removeAt(outline: AIOutline, p: DragPath): void {
if (p.type === 'section') { outline.sections.splice(p.s, 1); return; }
const kps = outline.sections[p.s].keyPoints as KeyPoint[];
if (p.type === 'keypoint') { kps.splice(p.k, 1); return; }
kps[p.k].subPoints.splice(p.p, 1);
}
function insertAt(outline: AIOutline, p: DragPath, value: SourceValue): void {
if (p.type === 'section') { outline.sections.splice(p.s, 0, value as OutlineSectionType); return; }
const kps = outline.sections[p.s].keyPoints as KeyPoint[];
if (p.type === 'keypoint') { kps.splice(p.k, 0, value as KeyPoint); return; }
kps[p.k].subPoints.splice(p.p, 0, value as string);
}
// 删源后,同一父列表里 target index 会前移一位
function adjustTargetAfterRemove(to: DragPath, from: DragPath): DragPath {
if (from.type === 'section') {
if (to.s > from.s) return { ...to, s: to.s - 1 } as DragPath;
return to;
}
if (from.type === 'keypoint') {
if (to.type !== 'section' && to.s === from.s && to.k > from.k) {
return { ...to, k: to.k - 1 } as DragPath;
}
return to;
}
if (to.type === 'subpoint' && to.s === from.s && to.k === from.k && to.p > from.p) {
return { ...to, p: to.p - 1 };
}
return to;
}
type Level = DragPath['type'];
function convertToLevel(value: SourceValue, fromLevel: Level, toLevel: Level): SourceValue {
if (fromLevel === toLevel) return value;
if (toLevel === 'section') {
if (fromLevel === 'keypoint') {
const kp = value as KeyPoint;
return {
sectionTitle: kp.pointTitle,
keyPoints: kp.subPoints.map((sp) => ({ pointTitle: sp, subPoints: [] })),
materialIds: [],
};
}
return { sectionTitle: value as string, keyPoints: [], materialIds: [] };
}
if (toLevel === 'keypoint') {
if (fromLevel === 'section') {
const sec = value as OutlineSectionType;
const sub = (sec.keyPoints ?? [])
.map((kp) => normalizeKeyPoint(kp).pointTitle)
.filter((s) => !!s);
return { pointTitle: sec.sectionTitle, subPoints: sub };
}
return { pointTitle: value as string, subPoints: [] };
}
if (fromLevel === 'section') return (value as OutlineSectionType).sectionTitle;
return (value as KeyPoint).pointTitle;
}
// into 区域:作为 target 节点的「末尾子项」插入
function childPathOf(outline: AIOutline, target: DragPath): DragPath | null {
if (target.type === 'section') {
const kps = outline.sections[target.s]?.keyPoints ?? [];
return { type: 'keypoint', s: target.s, k: kps.length };
}
if (target.type === 'keypoint') {
const kp = normalizeKeyPoint((outline.sections[target.s]?.keyPoints ?? [])[target.k]);
return { type: 'subpoint', s: target.s, k: target.k, p: kp.subPoints.length };
}
return null; // 子要点没有子节点
}
// before/after:作为 target 节点的兄弟插入
function siblingPathOf(target: DragPath, side: 'before' | 'after'): DragPath {
const delta = side === 'after' ? 1 : 0;
if (target.type === 'section') return { type: 'section', s: target.s + delta };
if (target.type === 'keypoint') return { type: 'keypoint', s: target.s, k: target.k + delta };
return { type: 'subpoint', s: target.s, k: target.k, p: target.p + delta };
}
// 自定义碰撞:pointerWithin 命中所有包含光标的 droppable,取 rect 最小的(最内层)
// —— 这是嵌套区域 (section > keypoint > subpoint,每层又分 before/into/after) 能
// 正确选中「最深的那个」的关键。
function getRectArea(rect: { width: number; height: number } | null | undefined): number {
if (!rect) return Infinity;
return rect.width * rect.height;
}
const innermostCollision: CollisionDetection = (args) => {
const pointer = pointerWithin(args);
if (pointer.length === 0) return rectIntersection(args);
let best = pointer[0];
let bestArea = getRectArea(args.droppableRects.get(best.id));
for (const c of pointer.slice(1)) {
const a = getRectArea(args.droppableRects.get(c.id));
if (a < bestArea) { best = c; bestArea = a; }
}
return [best];
};
export function OutlineCard({ outline, onChange, readOnly = false }: OutlineCardProps) {
const [collapsed, setCollapsed] = useState(false);
const [editingTitle, setEditingTitle] = useState(false);
const [titleDraft, setTitleDraft] = useState(outline.title);
const [dragIndex, setDragIndex] = useState<number | null>(null);
const [overIndex, setOverIndex] = useState<number | null>(null);
const [activeId, setActiveId] = useState<string | null>(null);
const editable = !readOnly && !!onChange;
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 4 } }));
function commitTitle() {
setEditingTitle(false);
const trimmed = titleDraft.trim();
@@ -40,14 +216,6 @@ export function OutlineCard({ outline, onChange, readOnly = false }: OutlineCard
onChange?.({ ...outline, sections: outline.sections.filter((_, idx) => idx !== i) });
}
function reorderSections(from: number, to: number) {
if (from === to) return;
const sections = [...outline.sections];
const [moved] = sections.splice(from, 1);
sections.splice(to, 0, moved);
onChange?.({ ...outline, sections });
}
function addSection() {
onChange?.({
...outline,
@@ -55,9 +223,66 @@ export function OutlineCard({ outline, onChange, readOnly = false }: OutlineCard
});
}
function handleDragStart(event: DragStartEvent) {
setActiveId(String(event.active.id));
}
function handleDragCancel() {
setActiveId(null);
}
function handleDragEnd(event: DragEndEvent) {
setActiveId(null);
const { active, over } = event;
if (!over || !editable) return;
const fromId = String(active.id);
const overId = String(over.id);
const from = parseItemId(fromId);
const zoneInfo = parseZoneId(overId);
if (!from || !zoneInfo) return;
const { zone, targetPath, targetItemId } = zoneInfo;
// 落在源自身的任何区域 → 无操作
if (targetItemId === fromId) return;
// 计算插入路径:into = 末尾子项;before/after = 同级兄弟
const insertPath = zone === 'into'
? childPathOf(outline, targetPath)
: siblingPathOf(targetPath, zone);
if (!insertPath) return;
// 拖入源自身子树 → 非法
if (isDescendantTarget(from, insertPath)) return;
const next = cloneOutline(outline);
const sourceValue = readAt(next, from);
removeAt(next, from);
const adjusted = adjustTargetAfterRemove(insertPath, from);
const converted = convertToLevel(sourceValue, from.type, adjusted.type);
insertAt(next, adjusted, converted);
onChange?.(next);
}
function renderDragOverlay() {
if (!activeId) return null;
const path = parseItemId(activeId);
if (!path) return null;
const section = outline.sections[path.s];
if (!section) return null;
if (path.type === 'section') {
return <OverlayCard level="section" label={section.sectionTitle} />;
}
const kp = normalizeKeyPoint((section.keyPoints ?? [])[path.k]);
if (path.type === 'keypoint') {
return <OverlayCard level="keypoint" label={kp.pointTitle} />;
}
return <OverlayCard level="subpoint" label={kp.subPoints?.[path.p] ?? ''} />;
}
return (
<div className="rounded-md border bg-muted/20 overflow-hidden">
{/* 头部 */}
<div className="flex items-center gap-2 px-3 py-2 border-b">
<ListTree className="size-3.5 text-purple-500 shrink-0" />
<span className="text-xs font-medium flex-1">文章大纲</span>
@@ -97,34 +322,30 @@ export function OutlineCard({ outline, onChange, readOnly = false }: OutlineCard
)}
</div>
{/* 章节列表 */}
<div className="space-y-1.5">
{outline.sections.map((section, i) => (
<OutlineSection
key={i}
section={section}
index={i}
onChange={(updated) => updateSection(i, updated)}
onRemove={editable ? () => removeSection(i) : undefined}
readOnly={!editable}
isDragging={dragIndex === i}
isOver={overIndex === i && dragIndex !== null && dragIndex !== i}
onDragStart={() => setDragIndex(i)}
onDragEnter={() => setOverIndex(i)}
onDrop={() => {
if (dragIndex !== null && dragIndex !== i) reorderSections(dragIndex, i);
setDragIndex(null);
setOverIndex(null);
}}
onDragEnd={() => {
setDragIndex(null);
setOverIndex(null);
}}
/>
))}
</div>
{/* 章节列表 —— 三区域拖放:顶/底边重排,中间嵌入为子项 */}
<DndContext
sensors={sensors}
collisionDetection={innermostCollision}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragCancel={handleDragCancel}
>
<div className="space-y-1.5">
{outline.sections.map((section, i) => (
<OutlineSection
key={i}
section={section}
index={i}
sectionIndex={i}
onChange={(updated) => updateSection(i, updated)}
onRemove={editable ? () => removeSection(i) : undefined}
readOnly={!editable}
/>
))}
</div>
<DragOverlay dropAnimation={null}>{renderDragOverlay()}</DragOverlay>
</DndContext>
{/* 添加章节 */}
{editable && (
<Button
type="button"
@@ -142,3 +363,23 @@ export function OutlineCard({ outline, onChange, readOnly = false }: OutlineCard
</div>
);
}
// 拖动时跟随光标的浮动预览。DragOverlay 把这个节点 portal 到 body,
// 不受任何祖先 overflow:hidden 影响,所以拖出卡片也始终可见。
function OverlayCard({ level, label }: { level: Level; label: string }) {
const styleByLevel: Record<Level, string> = {
section: 'rounded border-2 border-blue-400 bg-background text-xs font-medium px-2.5 py-2 shadow-lg',
keypoint: 'rounded border-2 border-blue-400 bg-muted/40 text-xs px-2 py-1.5 shadow-lg',
subpoint: 'rounded border-2 border-blue-400 bg-background text-xs px-2 py-1 shadow-lg',
};
return (
<div className={cn('flex items-center gap-1.5 cursor-grabbing min-w-[180px] max-w-[420px]', styleByLevel[level])}>
<GripVertical className="size-3 text-muted-foreground/50 shrink-0" />
{level === 'keypoint' && <span className="text-purple-500/70 shrink-0">###</span>}
{level === 'subpoint' && <span className="text-pink-500/70 shrink-0 text-[10px] font-mono">####</span>}
<span className="truncate">
{label || <span className="italic text-muted-foreground">(未命名)</span>}
</span>
</div>
);
}
@@ -1,5 +1,6 @@
import { useState, useMemo } from 'react';
import { ChevronRight, Plus, X, GripVertical } from 'lucide-react';
import { useDraggable, useDroppable } from '@dnd-kit/core';
import { cn } from '@/lib/utils';
import type { KeyPoint, OutlineSection as OutlineSectionType } from '../../types/ai-writing';
import { normalizeKeyPoint } from '../../types/ai-writing';
@@ -8,36 +9,34 @@ import { CollapseTransition } from './CollapseTransition';
interface OutlineSectionProps {
section: OutlineSectionType;
index: number;
/** 章节在 outline.sections 中的下标,用于生成稳定 dnd id */
sectionIndex: number;
onChange: (updated: OutlineSectionType) => void;
onRemove?: () => void;
readOnly?: boolean;
// Drag-and-drop (章节级别)
isDragging?: boolean;
isOver?: boolean;
onDragStart?: () => void;
onDragEnter?: () => void;
onDrop?: () => void;
onDragEnd?: () => void;
}
/**
* 章节卡片(Level 1)。内部渲染 keyPoints[] 为「小节子卡片」(Level 2),小节里再渲染
* subPoints[] 为 bullet 列表(Level 3)。
*
* 兼容旧 2 级大纲:section.keyPoints 可能是 (string | KeyPoint)[];进入组件前通过
* normalizeKeyPoint 统一成 KeyPoint[],对外输出也一律是 KeyPoint[](自然升级,用户
* 编辑过的旧 session 大纲会被持久化为新格式)。
*/
// 各级 grip 标题提示文案
const DRAG_HINT = '按住拖动:项的顶/底边 = 同级重排;中间区域 = 嵌入为子项';
const DRAG_HINT_LEAF = '按住拖动重排(顶半区 = 上插入,底半区 = 下插入)';
// ─── 章节 (Level 1) ───────────────────────────────────────────────────────────
export function OutlineSection({
section, index, onChange, onRemove, readOnly,
isDragging, isOver, onDragStart, onDragEnter, onDrop, onDragEnd,
section, index, sectionIndex, onChange, onRemove, readOnly,
}: OutlineSectionProps) {
const [expanded, setExpanded] = useState(false);
const [expanded, setExpanded] = useState(true);
const [editingTitle, setEditingTitle] = useState(false);
const [titleDraft, setTitleDraft] = useState(section.sectionTitle);
// 把 keyPoints 统一成 KeyPoint[];旧 string 元素会被 lift。useMemo 避免每次 render
// 都生成新引用导致子组件无谓重渲染。
const id = `s${sectionIndex}`;
const { attributes, listeners, setNodeRef: setDragRef, isDragging } = useDraggable({
id, disabled: readOnly,
});
// 三个 drop 区:before / into / after
const beforeDrop = useDroppable({ id: `before:${id}`, disabled: readOnly });
const intoDrop = useDroppable({ id: `into:${id}`, disabled: readOnly });
const afterDrop = useDroppable({ id: `after:${id}`, disabled: readOnly });
const keyPoints: KeyPoint[] = useMemo(
() => (section.keyPoints ?? []).map(normalizeKeyPoint),
[section.keyPoints],
@@ -52,128 +51,153 @@ export function OutlineSection({
setTitleDraft(section.sectionTitle);
}
}
function updateKeyPoint(i: number, updated: KeyPoint) {
const next = [...keyPoints];
next[i] = updated;
onChange({ ...section, keyPoints: next });
}
function removeKeyPoint(i: number) {
onChange({ ...section, keyPoints: keyPoints.filter((_, idx) => idx !== i) });
}
function addKeyPoint() {
onChange({ ...section, keyPoints: [...keyPoints, { pointTitle: '', subPoints: [] }] });
}
// 子要点总数,显示在章节头给个粗略概览
const totalSubPoints = keyPoints.reduce((sum, kp) => sum + (kp.subPoints?.length || 0), 0);
return (
<div
draggable={!readOnly}
onDragStart={(e) => { e.stopPropagation(); onDragStart?.(); }}
onDragOver={(e) => { e.preventDefault(); e.stopPropagation(); onDragEnter?.(); }}
onDrop={(e) => { e.preventDefault(); e.stopPropagation(); onDrop?.(); }}
onDragEnd={(e) => { e.stopPropagation(); onDragEnd?.(); }}
ref={setDragRef}
{...attributes}
className={cn(
'rounded border bg-background text-xs transition-all duration-150 select-none',
isDragging ? 'opacity-40 border-border/60' : isOver ? 'border-blue-400 ring-1 ring-blue-400/40' : 'border-border/60',
'relative rounded border text-xs transition-colors duration-150',
isDragging ? 'opacity-40 border-blue-300' : 'border-border/60',
intoDrop.isOver ? 'ring-2 ring-blue-400 ring-inset bg-blue-50/40 dark:bg-blue-950/30' : 'bg-background',
)}
>
{/* 章节头 */}
<div className="flex items-center gap-1.5 px-2.5 py-2">
{!readOnly && (
<GripVertical className="size-3 text-muted-foreground/40 shrink-0 cursor-grab active:cursor-grabbing" />
)}
<button
type="button"
onClick={() => setExpanded((v) => !v)}
className="shrink-0"
>
<ChevronRight
className={cn(
'size-3.5 text-muted-foreground transition-transform duration-200',
expanded && 'rotate-90',
)}
/>
</button>
{/* before:顶部 12px 命中区,命中时顶边显示蓝色线 */}
<div ref={beforeDrop.setNodeRef} className="absolute top-0 inset-x-0 h-3 z-20 pointer-events-none" />
{beforeDrop.isOver && <DropLine position="top" />}
<span className="text-muted-foreground/60 shrink-0 w-4 text-center">{index + 1}.</span>
{editingTitle && !readOnly ? (
<input
autoFocus
value={titleDraft}
onChange={(e) => setTitleDraft(e.target.value)}
onBlur={commitTitle}
onKeyDown={(e) => {
if (e.key === 'Enter') commitTitle();
if (e.key === 'Escape') { setTitleDraft(section.sectionTitle); setEditingTitle(false); }
}}
className="flex-1 bg-transparent border-b border-primary outline-none font-medium"
/>
) : (
<span
className={cn('flex-1 font-medium', !readOnly && 'cursor-text hover:text-primary')}
onClick={() => !readOnly && setEditingTitle(true)}
>
{section.sectionTitle || <span className="text-muted-foreground italic">(未命名)</span>}
</span>
)}
<span className="text-muted-foreground/50 shrink-0">
{keyPoints.length} 小节{totalSubPoints > 0 ? ` · ${totalSubPoints} 子要点` : ''}
</span>
{!readOnly && onRemove && (
<button type="button" onClick={onRemove} className="shrink-0 text-muted-foreground/40 hover:text-destructive">
<X className="size-3" />
</button>
)}
</div>
{/* 小节列表 (Level 2) */}
<CollapseTransition open={expanded}>
<div className="px-6 pb-2.5 pt-1 space-y-1.5 border-t">
{keyPoints.map((kp, i) => (
<KeyPointRow
key={i}
keyPoint={kp}
onChange={(updated) => updateKeyPoint(i, updated)}
onRemove={readOnly ? undefined : () => removeKeyPoint(i)}
readOnly={readOnly}
/>
))}
{/* into:包住头部 + 折叠体;本身就是 drop 容器 */}
<div ref={intoDrop.setNodeRef}>
{/* 章节头 */}
<div className="flex items-center gap-1.5 px-2.5 py-2">
{!readOnly && (
<button
type="button"
onClick={addKeyPoint}
className="flex items-center gap-1 text-muted-foreground/60 hover:text-primary pt-0.5"
{...listeners}
className="shrink-0 cursor-grab active:cursor-grabbing touch-none p-0.5 -m-0.5 text-muted-foreground/40 hover:text-muted-foreground"
aria-label="拖动排序章节"
title={DRAG_HINT}
>
<Plus className="size-3" />
添加小节
<GripVertical className="size-3" />
</button>
)}
<button
type="button"
onClick={() => setExpanded((v) => !v)}
className="shrink-0"
>
<ChevronRight
className={cn(
'size-3.5 text-muted-foreground transition-transform duration-200',
expanded && 'rotate-90',
)}
/>
</button>
<span className="text-muted-foreground/60 shrink-0 w-4 text-center">{index + 1}.</span>
{editingTitle && !readOnly ? (
<input
autoFocus
value={titleDraft}
onChange={(e) => setTitleDraft(e.target.value)}
onBlur={commitTitle}
onKeyDown={(e) => {
if (e.key === 'Enter') commitTitle();
if (e.key === 'Escape') { setTitleDraft(section.sectionTitle); setEditingTitle(false); }
}}
className="flex-1 bg-transparent border-b border-primary outline-none font-medium"
/>
) : (
<span
className={cn('flex-1 font-medium', !readOnly && 'cursor-text hover:text-primary')}
onClick={() => !readOnly && setEditingTitle(true)}
>
{section.sectionTitle || <span className="text-muted-foreground italic">(未命名)</span>}
</span>
)}
<span className="text-muted-foreground/50 shrink-0">
{keyPoints.length} 小节{totalSubPoints > 0 ? ` · ${totalSubPoints} 子要点` : ''}
</span>
{!readOnly && onRemove && (
<button type="button" onClick={onRemove} className="shrink-0 text-muted-foreground/40 hover:text-destructive">
<X className="size-3" />
</button>
)}
</div>
</CollapseTransition>
{/* 小节列表 (Level 2) */}
<CollapseTransition open={expanded}>
<div className="px-6 pb-2.5 pt-1 space-y-1.5 border-t">
{keyPoints.map((kp, i) => (
<KeyPointRow
key={i}
keyPoint={kp}
sectionIndex={sectionIndex}
keyPointIndex={i}
onChange={(updated) => updateKeyPoint(i, updated)}
onRemove={readOnly ? undefined : () => removeKeyPoint(i)}
readOnly={readOnly}
/>
))}
{!readOnly && (
<button
type="button"
onClick={addKeyPoint}
className="flex items-center gap-1 text-muted-foreground/60 hover:text-primary pt-0.5"
>
<Plus className="size-3" />
添加小节
</button>
)}
</div>
</CollapseTransition>
</div>
{/* after:底部 12px 命中区 */}
<div ref={afterDrop.setNodeRef} className="absolute bottom-0 inset-x-0 h-3 z-20 pointer-events-none" />
{afterDrop.isOver && <DropLine position="bottom" />}
</div>
);
}
// ─── 小节行(Level 2,内嵌 Level 3 子要点) ───────────────────────────────────────
// ─── 小节 (Level 2) ───────────────────────────────────────────────────────────
interface KeyPointRowProps {
keyPoint: KeyPoint;
sectionIndex: number;
keyPointIndex: number;
onChange: (updated: KeyPoint) => void;
onRemove?: () => void;
readOnly?: boolean;
}
function KeyPointRow({ keyPoint, onChange, onRemove, readOnly }: KeyPointRowProps) {
const [expanded, setExpanded] = useState(false);
function KeyPointRow({ keyPoint, sectionIndex, keyPointIndex, onChange, onRemove, readOnly }: KeyPointRowProps) {
const [expanded, setExpanded] = useState(true);
const [editingTitle, setEditingTitle] = useState(false);
const [titleDraft, setTitleDraft] = useState(keyPoint.pointTitle);
const id = `s${sectionIndex}-k${keyPointIndex}`;
const { attributes, listeners, setNodeRef: setDragRef, isDragging } = useDraggable({
id, disabled: readOnly,
});
const beforeDrop = useDroppable({ id: `before:${id}`, disabled: readOnly });
const intoDrop = useDroppable({ id: `into:${id}`, disabled: readOnly });
const afterDrop = useDroppable({ id: `after:${id}`, disabled: readOnly });
function commitTitle() {
setEditingTitle(false);
const trimmed = titleDraft.trim();
@@ -181,120 +205,222 @@ function KeyPointRow({ keyPoint, onChange, onRemove, readOnly }: KeyPointRowProp
onChange({ ...keyPoint, pointTitle: trimmed });
}
}
function updateSubPoint(i: number, value: string) {
const next = [...keyPoint.subPoints];
next[i] = value;
onChange({ ...keyPoint, subPoints: next });
}
function removeSubPoint(i: number) {
onChange({ ...keyPoint, subPoints: keyPoint.subPoints.filter((_, idx) => idx !== i) });
}
function addSubPoint() {
onChange({ ...keyPoint, subPoints: [...keyPoint.subPoints, ''] });
setExpanded(true); // 添加时自动展开,免得用户看不到刚加的输入框
setExpanded(true);
}
return (
<div className="rounded border border-border/40 bg-muted/10">
{/* 小节头 */}
<div className="flex items-center gap-1.5 px-2 py-1.5">
<button
type="button"
onClick={() => setExpanded((v) => !v)}
className="shrink-0"
aria-label="展开/收起子要点"
>
<ChevronRight
className={cn(
'size-3 text-muted-foreground/60 transition-transform duration-200',
expanded && 'rotate-90',
)}
/>
</button>
<div
ref={setDragRef}
{...attributes}
className={cn(
'relative rounded border border-border/40 transition-colors duration-150',
isDragging && 'opacity-40 border-blue-300',
intoDrop.isOver ? 'ring-2 ring-blue-400 ring-inset bg-blue-50/40 dark:bg-blue-950/30' : 'bg-muted/10',
)}
>
<div ref={beforeDrop.setNodeRef} className="absolute top-0 inset-x-0 h-2 z-20 pointer-events-none" />
{beforeDrop.isOver && <DropLine position="top" />}
<span className="text-purple-500/70 shrink-0">###</span>
{editingTitle && !readOnly ? (
<input
autoFocus
value={titleDraft}
onChange={(e) => setTitleDraft(e.target.value)}
onBlur={commitTitle}
onKeyDown={(e) => {
if (e.key === 'Enter') commitTitle();
if (e.key === 'Escape') { setTitleDraft(keyPoint.pointTitle); setEditingTitle(false); }
}}
className="flex-1 bg-transparent border-b border-primary outline-none"
placeholder="小节标题"
/>
) : (
<span
className={cn('flex-1', !readOnly && 'cursor-text hover:text-primary')}
onClick={() => !readOnly && setEditingTitle(true)}
>
{keyPoint.pointTitle || <span className="text-muted-foreground italic">(未命名小节)</span>}
</span>
)}
<span className="text-muted-foreground/40 shrink-0 text-[10px]">
{keyPoint.subPoints.length > 0 ? `${keyPoint.subPoints.length} 子要点` : '无子要点'}
</span>
{!readOnly && onRemove && (
<button
type="button"
onClick={onRemove}
className="shrink-0 text-muted-foreground/40 hover:text-destructive"
aria-label="删除小节"
>
<X className="size-3" />
</button>
)}
</div>
{/* 子要点 (Level 3) — 每个子要点会变成正文里的 #### 标题 + 一段独立论述 */}
<CollapseTransition open={expanded}>
<div className="px-6 pb-1.5 space-y-0.5 border-t border-border/30 bg-background/40">
{keyPoint.subPoints.map((sp, i) => (
<div key={i} className="flex items-center gap-1.5 pt-1">
{/* 用 #### 标记替代 bullet,提示这是「四级标题」而非简单列表项 */}
<span className="text-pink-500/70 shrink-0 text-[10px] font-mono">####</span>
{readOnly ? (
<span className="flex-1 text-muted-foreground">{sp}</span>
) : (
<input
value={sp}
onChange={(e) => updateSubPoint(i, e.target.value)}
placeholder="子要点标题(会展开成一段正文)"
className="flex-1 bg-transparent border-b border-transparent hover:border-border focus:border-primary outline-none text-muted-foreground"
/>
)}
{!readOnly && (
<button
type="button"
onClick={() => removeSubPoint(i)}
className="shrink-0 text-muted-foreground/30 hover:text-destructive"
aria-label="删除子要点"
>
<X className="size-3" />
</button>
)}
</div>
))}
<div ref={intoDrop.setNodeRef}>
{/* 小节头 */}
<div className="flex items-center gap-1.5 px-2 py-1.5">
{!readOnly && (
<button
type="button"
onClick={addSubPoint}
className="flex items-center gap-1 text-muted-foreground/50 hover:text-primary pt-0.5 text-[11px]"
{...listeners}
className="shrink-0 cursor-grab active:cursor-grabbing touch-none p-0.5 -m-0.5 text-muted-foreground/30 hover:text-muted-foreground"
aria-label="拖动排序小节"
title={DRAG_HINT}
>
<Plus className="size-3" />
添加子要点
<GripVertical className="size-3" />
</button>
)}
<button
type="button"
onClick={() => setExpanded((v) => !v)}
className="shrink-0"
aria-label="展开/收起子要点"
>
<ChevronRight
className={cn(
'size-3 text-muted-foreground/60 transition-transform duration-200',
expanded && 'rotate-90',
)}
/>
</button>
<span className="text-purple-500/70 shrink-0">###</span>
{editingTitle && !readOnly ? (
<input
autoFocus
value={titleDraft}
onChange={(e) => setTitleDraft(e.target.value)}
onBlur={commitTitle}
onKeyDown={(e) => {
if (e.key === 'Enter') commitTitle();
if (e.key === 'Escape') { setTitleDraft(keyPoint.pointTitle); setEditingTitle(false); }
}}
className="flex-1 bg-transparent border-b border-primary outline-none"
placeholder="小节标题"
/>
) : (
<span
className={cn('flex-1', !readOnly && 'cursor-text hover:text-primary')}
onClick={() => !readOnly && setEditingTitle(true)}
>
{keyPoint.pointTitle || <span className="text-muted-foreground italic">(未命名小节)</span>}
</span>
)}
<span className="text-muted-foreground/40 shrink-0 text-[10px]">
{keyPoint.subPoints.length > 0 ? `${keyPoint.subPoints.length} 子要点` : '无子要点'}
</span>
{!readOnly && onRemove && (
<button
type="button"
onClick={onRemove}
className="shrink-0 text-muted-foreground/40 hover:text-destructive"
aria-label="删除小节"
>
<X className="size-3" />
</button>
)}
</div>
</CollapseTransition>
{/* 子要点 (Level 3) */}
<CollapseTransition open={expanded}>
<div className="px-6 pb-1.5 space-y-0.5 border-t border-border/30 bg-background/40">
{keyPoint.subPoints.map((sp, i) => (
<SubPointRow
key={i}
value={sp}
sectionIndex={sectionIndex}
keyPointIndex={keyPointIndex}
subPointIndex={i}
onChange={(value) => updateSubPoint(i, value)}
onRemove={readOnly ? undefined : () => removeSubPoint(i)}
readOnly={readOnly}
/>
))}
{!readOnly && (
<button
type="button"
onClick={addSubPoint}
className="flex items-center gap-1 text-muted-foreground/50 hover:text-primary pt-0.5 text-[11px]"
>
<Plus className="size-3" />
添加子要点
</button>
)}
</div>
</CollapseTransition>
</div>
<div ref={afterDrop.setNodeRef} className="absolute bottom-0 inset-x-0 h-2 z-20 pointer-events-none" />
{afterDrop.isOver && <DropLine position="bottom" />}
</div>
);
}
// ─── 子要点 (Level 3,叶节点,无 into) ────────────────────────────────────────
interface SubPointRowProps {
value: string;
sectionIndex: number;
keyPointIndex: number;
subPointIndex: number;
onChange: (value: string) => void;
onRemove?: () => void;
readOnly?: boolean;
}
function SubPointRow({
value, sectionIndex, keyPointIndex, subPointIndex, onChange, onRemove, readOnly,
}: SubPointRowProps) {
const id = `s${sectionIndex}-k${keyPointIndex}-p${subPointIndex}`;
const { attributes, listeners, setNodeRef: setDragRef, isDragging } = useDraggable({
id, disabled: readOnly,
});
// 叶节点:没有 into;顶半区 = before、底半区 = after,把整行高度铺满
const beforeDrop = useDroppable({ id: `before:${id}`, disabled: readOnly });
const afterDrop = useDroppable({ id: `after:${id}`, disabled: readOnly });
return (
<div
ref={setDragRef}
{...attributes}
className={cn(
'relative flex items-center gap-1.5 pt-1 rounded transition-colors duration-150',
isDragging && 'opacity-40',
)}
>
<div
ref={beforeDrop.setNodeRef}
className="absolute left-0 right-0 top-0 bottom-1/2 z-20 pointer-events-none"
/>
{beforeDrop.isOver && <DropLine position="top" thin />}
{!readOnly && (
<button
type="button"
{...listeners}
className="shrink-0 cursor-grab active:cursor-grabbing touch-none p-0.5 -m-0.5 text-muted-foreground/30 hover:text-muted-foreground"
aria-label="拖动排序子要点"
title={DRAG_HINT_LEAF}
>
<GripVertical className="size-2.5" />
</button>
)}
<span className="text-pink-500/70 shrink-0 text-[10px] font-mono">####</span>
{readOnly ? (
<span className="flex-1 text-muted-foreground">{value}</span>
) : (
<input
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder="子要点标题(会展开成一段正文)"
className="flex-1 bg-transparent border-b border-transparent hover:border-border focus:border-primary outline-none text-muted-foreground"
/>
)}
{!readOnly && onRemove && (
<button
type="button"
onClick={onRemove}
className="shrink-0 text-muted-foreground/30 hover:text-destructive"
aria-label="删除子要点"
>
<X className="size-3" />
</button>
)}
<div
ref={afterDrop.setNodeRef}
className="absolute left-0 right-0 top-1/2 bottom-0 z-20 pointer-events-none"
/>
{afterDrop.isOver && <DropLine position="bottom" thin />}
</div>
);
}
// ─── 蓝色插入指示线 ───────────────────────────────────────────────────────────
function DropLine({ position, thin }: { position: 'top' | 'bottom'; thin?: boolean }) {
return (
<div
className={cn(
'absolute inset-x-1 bg-blue-500 rounded-full z-30 pointer-events-none',
thin ? 'h-[2px]' : 'h-[3px]',
position === 'top' ? '-top-px' : '-bottom-px',
)}
/>
);
}
@@ -21,6 +21,7 @@ import {
type SetupWritingApprovalPayload,
} from '@/components/workspace/messages';
import { ThreadContext } from '@/components/workspace/messages/context';
import { extractContentFromMessage, extractReasoningContentFromMessage, hasToolCalls } from '@/core/messages/utils';
import { getAPIClient } from '@/core/api';
import { useModels } from '@/core/models/hooks';
import { useThreadStream } from '@/core/threads/hooks';
@@ -29,6 +30,8 @@ import { cn } from '@/lib/utils';
import { useArticleTypes } from '../../hooks/useArticleTypes';
import { useWritingFormOptional } from '../../contexts/WritingFormContext';
import { useAIWritingOptional } from '../../contexts/AIWritingContext';
import type { AIWritingRequest } from '../../types/ai-writing';
import { WritingWelcome } from './WritingWelcome';
/**
@@ -61,6 +64,9 @@ export function WritingSetupChat({
// 缺省回填写作表单;onApprove 传入时脱离 WritingFormProvider(如模板创建页)。
const formCtx = useWritingFormOptional();
const applyProposal = onApproveProp ?? formCtx?.applyProposal;
// 「直接开始写作」:仅在真正的写作上下文(AIWritingProvider 内、未被复用为模板编辑)提供。
const aiWriting = useAIWritingOptional();
const canStartWriting = !onApproveProp && !!aiWriting;
// 首个模型可用时默认选中
useEffect(() => {
@@ -116,9 +122,68 @@ export function WritingSetupChat({
[applyProposal],
);
// 「直接开始写作」:意图确认卡里已带上**用户已确认的大纲**(setup agent 在确认大纲后才提交),
// 这里把审批建议(含该大纲)直接组装成写作请求并启动 ai_writing 流程,同时回填表单保持一致。
const handleStart = useCallback(
async (payload: SetupWritingApprovalPayload) => {
if (!aiWriting) return;
applyProposal?.(payload);
const wordCount =
typeof payload.word_count_target === 'number' && payload.word_count_target > 0
? payload.word_count_target
: Math.max(100, parseInt(formCtx?.wordCountInput ?? '', 10) || 800);
const materialSource = formCtx?.materialSource ?? 'general';
const request: AIWritingRequest = {
userIntent: payload.user_intent?.trim() || formCtx?.intent?.trim() || '',
// 已与用户确认的大纲:作为用户大纲传入,后端据此规划(留空则后端自动规划)
userOutline: payload.outline?.trim() || formCtx?.outlineText?.trim() || undefined,
articleType: payload.article_type?.trim() || formCtx?.articleType || 'analysis',
// 新(列表外)文章类型的一句话特征,仅本次写作临时使用(命中已有类型时为空)。
articleTypeDetail: payload.article_type_detail?.trim() || undefined,
targetAudience: payload.target_audience?.trim() || formCtx?.audience?.trim() || '普通读者',
wordCountTarget: wordCount,
keywordCount: 1,
writingMode: payload.writing_mode === 'strict' ? 'strict' : 'loose',
// 跟随表单的「添加参考文献」开关(默认开)。
addReferences: formCtx?.addReferences ?? true,
materialSource,
modelName: chatModel || formCtx?.modelName || undefined,
noteSearchSpaceId:
materialSource === 'notebook' ? (formCtx?.selectedNotebookId ?? undefined) : undefined,
noteSourceIds:
materialSource === 'notebook' && formCtx?.selectedNotebookId != null
? formCtx.selectedDocumentIds
: undefined,
};
if (!request.userIntent) {
toast.error('写作意图为空,请先补充意图');
return;
}
await aiWriting.startWriting(request);
},
[aiWriting, applyProposal, formCtx, chatModel],
);
// 对话还没有任何消息时显示欢迎/空状态屏(与原型图一致)
const showWelcome = thread.messages.length === 0 && !thread.isLoading;
// 首 token 到达前的「正在整理…」占位:模型在跑(isLoading),但当前助手回合还没有任何
// 可见输出(无答案文本、无 <think> 思考、无工具调用)—— 这段纯网络/排队延迟下答案区空白,
// 加个轻量提示避免用户以为卡死(思考一旦开始流式,此提示自动消失,改由思考区接力)。
const showThinkingPlaceholder = useMemo(() => {
if (!thread.isLoading) return false;
const last = thread.messages[thread.messages.length - 1];
if (!last || last.type !== 'ai') return true; // 还停在用户消息,AI 尚未开口
const hasOutput =
!!extractContentFromMessage(last).trim() ||
!!extractReasoningContentFromMessage(last) ||
hasToolCalls(last);
return !hasOutput;
}, [thread.isLoading, thread.messages]);
const handleReject = useCallback(async () => {
await handleChatSubmit('这份配置先不应用,请继续帮我调整。');
}, [handleChatSubmit]);
@@ -147,10 +212,21 @@ export function WritingSetupChat({
onClarificationSubmit={handleChatSubmit}
onSetupWritingApprove={handleApprove}
onSetupWritingReject={handleReject}
onSetupWritingStart={canStartWriting ? handleStart : undefined}
/>
)}
</div>
{/* 首 token 前的「正在整理…」占位:避免模型慢/排队时答案区空白像卡死 */}
{showThinkingPlaceholder && (
<div className="shrink-0 border-t bg-background px-4 py-2 dark:bg-transparent">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span className="inline-block size-1.5 animate-pulse rounded-full bg-blue-500" />
正在整理…
</div>
</div>
)}
<div className="bg-background dark:bg-transparent shrink-0 border-t px-4 py-4">
{/* 与原型图一致:白色圆角卡片包裹「模型选择行 + 输入框」 */}
<div className="space-y-3 rounded-2xl border border-[#ece5e4] bg-card p-3 shadow-sm dark:border-[#1B477E] dark:bg-[#022248]/40">
@@ -23,7 +23,7 @@ import {
import { useCanvasGraphContext } from '../contexts/CanvasGraphContext';
import { saveAIWritingTranscript } from '../api/ai-writing-sessions';
import { countWords } from '../utils/wordCount';
import { countArticleWords } from '../utils/wordCount';
import {
INITIAL_STATE,
customToProgressEvent,
@@ -280,10 +280,14 @@ export function AIWritingProvider({ children }: { children: ReactNode }) {
// 用户自带大纲原文(留空则后端走自动规划)
user_outline_raw: request.userOutline?.trim() || '',
article_type: request.articleType,
// 新(列表外)文章类型的一句话特征,仅本次写作临时使用;命中已有类型时为空。
article_type_detail: request.articleTypeDetail?.trim() || '',
target_audience: request.targetAudience,
word_count_target: request.wordCountTarget,
keyword_count: Math.max(1, Math.min(10, request.keywordCount)),
writing_mode: request.writingMode === 'strict' ? 'strict' : 'loose',
// 是否加参考文献(正文 [n] + 文末「参考文献」列表);缺省 false 不改其它入口行为。
add_references: request.addReferences ?? false,
material_source: request.materialSource,
notebook_search_space_id: request.noteSearchSpaceId ?? null,
notebook_source_ids: request.noteSourceIds ?? null,
@@ -328,7 +332,7 @@ export function AIWritingProvider({ children }: { children: ReactNode }) {
else if (pausePoint === 'outline_confirm') record.outline = state.outline ?? undefined;
else if (pausePoint === 'draft_confirm') {
record.draftTitle = state.currentDraftTitle;
record.draftWordCount = countWords(state.currentDraftMarkdown);
record.draftWordCount = countArticleWords(state.currentDraftMarkdown);
record.reviewResult = state.reviewResult ?? undefined;
} else if (pausePoint === 'review_confirm') {
record.reviewResult = state.reviewResult ?? undefined;
@@ -342,7 +346,7 @@ export function AIWritingProvider({ children }: { children: ReactNode }) {
pendingRevisionContext = {
submittedIssues: allIssues.filter((_, i) => selectedSet.has(i)),
skippedIssues: allIssues.filter((_, i) => !selectedSet.has(i)),
oldWordCount: countWords(state.currentDraftMarkdown),
oldWordCount: countArticleWords(state.currentDraftMarkdown),
};
}
@@ -34,6 +34,9 @@ export interface WritingFormValue {
setAudience: (v: string) => void;
strictMode: boolean;
setStrictMode: (v: boolean) => void;
/** 是否在成稿里加参考文献(正文 [n] 角标 + 文末「参考文献」列表,像论文)。默认开。 */
addReferences: boolean;
setAddReferences: (v: boolean) => void;
materialSource: 'general' | 'notebook';
setMaterialSource: (v: 'general' | 'notebook') => void;
/** 我的空间(笔记本)来源:选中的 search_space_id;上提到 Context 以便左栏「笔记本」点击直接设为素材来源。 */
@@ -81,6 +84,7 @@ export function WritingFormProvider({
const [wordCountInput, setWordCountInput] = useState('800');
const [audience, setAudience] = useState('普通读者');
const [strictMode, setStrictMode] = useState(false);
const [addReferences, setAddReferences] = useState(true);
const [materialSource, setMaterialSource] = useState<'general' | 'notebook'>(initialMaterialSource ?? 'general');
const [selectedNotebookId, setSelectedNotebookId] = useState<number | null>(initialNotebookId ?? null);
const [selectedDocumentIds, setSelectedDocumentIds] = useState<number[]>(initialDocumentIds ?? []);
@@ -117,13 +121,14 @@ export function WritingFormProvider({
wordCountInput, setWordCountInput,
audience, setAudience,
strictMode, setStrictMode,
addReferences, setAddReferences,
materialSource, setMaterialSource,
selectedNotebookId, setSelectedNotebookId,
selectedDocumentIds, setSelectedDocumentIds,
modelName, setModelName,
applyProposal,
}),
[intent, formExpanded, showOutline, outlineText, articleType, wordCountInput, audience, strictMode, materialSource, selectedNotebookId, selectedDocumentIds, modelName, applyProposal],
[intent, formExpanded, showOutline, outlineText, articleType, wordCountInput, audience, strictMode, addReferences, materialSource, selectedNotebookId, selectedDocumentIds, modelName, applyProposal],
);
return <WritingFormContext.Provider value={value}>{children}</WritingFormContext.Provider>;
@@ -21,7 +21,7 @@
* 这样 reducer 逻辑(INITIAL_STATE + switch 各分支)与老 hook 共享同一份代码,
* 只换"事件源"。阶段 7 删冗余时把老 hook 整个删掉就行。
*/
import { countWords } from '../utils/wordCount';
import { countArticleWords } from '../utils/wordCount';
import type {
AIWritingRequest,
AIWritingState,
@@ -339,7 +339,7 @@ function handleEvent(prev: AIWritingState, event: ProgressEvent): AIWritingState
case 'draft_ready': {
const newMarkdown = event.draftMarkdown ?? '';
const newWordCount = countWords(newMarkdown);
const newWordCount = countArticleWords(newMarkdown);
const summary = prev.pendingRevisionContext
? {
revisedIssues: prev.pendingRevisionContext.submittedIssues,
@@ -701,6 +701,10 @@ export type AIWritingValues = {
user_intent?: string;
user_outline_raw?: string;
article_type?: string;
/** 新(列表外)文章类型的一句话特征,仅本次写作临时使用。 */
article_type_detail?: string;
/** 是否加参考文献(正文 [n] 角标 + 文末「参考文献」列表)。 */
add_references?: boolean;
status?: string;
material_package?: unknown;
current_outline?: unknown;
@@ -25,25 +25,33 @@ export function useWritingEditor({
const onChangeRef = useRef(onChange);
useEffect(() => { onChangeRef.current = onChange; }, [onChange]);
// 编辑器自身最近一次 emit 出去的 markdown。父级把它存进 state 后会作为新的 `markdown` prop
// 回灌进来,下面的同步 effect 据此识别「这是我自己刚产生的变更」并跳过 setContent。
const lastEmittedRef = useRef<string | null>(null);
const editor = useEditor({
extensions,
editable,
content: markdown,
onUpdate({ editor }) {
const fn = onChangeRef.current;
if (!fn) return;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const md = (editor as any).storage?.markdown?.getMarkdown?.() ?? editor.getText();
fn(md);
lastEmittedRef.current = md;
onChangeRef.current?.(md);
},
}, []);
// Sync external markdown changes (e.g. after streaming completes)
// Sync external markdown changes (e.g. after streaming completes / AI 改写)
useEffect(() => {
if (!editor) return;
if (markdown === prevMarkdownRef.current) return;
prevMarkdownRef.current = markdown;
// 跳过「编辑器自身刚 emit、又被父级回灌」的变更:此时编辑器内容已经是最新的,再 setContent
// 会重新解析整篇文档、重置光标。markdown 序列化对空格/换行是有损的(如行尾空格被裁剪),
// 自激回灌一旦触发 setContent,就会把刚输入的空格吞掉并让光标跳到下一个字(本次修的 bug)。
if (markdown === lastEmittedRef.current) return;
const { from, to } = editor.state.selection;
editor.commands.setContent(markdown, { emitUpdate: false });
const docSize = editor.state.doc.content.size;
@@ -202,7 +202,13 @@ function AIWritingLayout({ hideAdvancedFields, initialNotebookId, initialDocumen
<WritingFormPanel hideAdvancedFields={hideAdvancedFields} />
</ResizablePanel>
<ResizableHandle withHandle />
{/* 隐形拖拽热区只向右(草稿区)延伸、可见抓手也微微右移,二者都不再盖住左侧时间线的
滚动条——否则鼠标想抓滚动条会误触左右 resize、导致滑不动。右侧是草稿区留白(px-10),
盖住无副作用。 */}
<ResizableHandle
withHandle
className="after:left-0 after:w-3 after:translate-x-0 [&>div]:translate-x-1.5"
/>
{/* 右侧:草稿预览面板 */}
<ResizablePanel
@@ -254,6 +254,8 @@ export interface AIWritingRequest {
/** 用户粘贴的「已有大纲」原文(可选)。非空时后端按此大纲生成,不再自动规划 */
userOutline?: string;
articleType: string;
/** 当 articleType 是已有类型库里没有的新类型时,配置助手识别的一句话特征说明(仅本次写作临时使用,不入库)。 */
articleTypeDetail?: string;
targetAudience: string;
wordCountTarget: number;
modelName?: string;
@@ -261,6 +263,8 @@ export interface AIWritingRequest {
keywordCount: number;
/** 写作模式:strict=严格依据素材 / loose=可结合通用知识(默认 loose) */
writingMode: "strict" | "loose";
/** 是否在成稿里加参考文献(正文 [n] 角标 + 文末「参考文献」列表,像论文)。默认 true。 */
addReferences?: boolean;
/** 检索类型:general=通用检索(素材收集专家按配置技能检索,默认知识库检索技能)/ notebook=我的空间 */
materialSource: "general" | "notebook";
/** 笔记来源(可选):空间 search_space_id */
@@ -10,3 +10,15 @@ export function countWords(text: string): number {
const alnumRuns = (text.match(/[A-Za-z0-9]+/g) ?? []).length;
return cjk + cjkPunct + alnumRuns;
}
/**
* 正文字数:与后端 word_count 同口径——**不计文末「参考文献」列表**。
* 后端在字数收敛后才追加 `## 参考文献` 段,其 word_count 只统计正文;前端展示「实际 vs 目标」
* 也必须排除该段,否则会因文献列表虚高几十~几百字,让用户误以为严重超标。
*/
export function countArticleWords(markdown: string): number {
if (!markdown) return 0;
const idx = markdown.lastIndexOf('## 参考文献');
const body = idx >= 0 ? markdown.slice(0, idx) : markdown;
return countWords(body);
}
+8 -1
View File
@@ -44,8 +44,15 @@ function PageLayout() {
() => isEmbedActive() || readEmbedParam(location.search) === "on",
[location.pathname, location.search],
);
// 「大屏绘制」独立页(/page/strategy/dashboard-agent)本就是「带导航栏 + 侧边栏」的独立页,
// 但它常被深链「上一步」从去 chrome 的工作区/新标签打开——那里 chromeByPreference 可能为 false
// (界面模式非全量、或会话级 sidebar 未置位),导致整页没有顶栏/侧栏。故对该路由**强制全局 chrome**
// (仍尊重 embed=1 嵌入态:外部 iframe 套用时仍隐藏)。
const forceChromeForDashboardAgent = location.pathname.includes("/strategy/dashboard-agent");
const pageSidebarAllowed =
chromeByPreference && !isChatsIframeWorkspacePath(location.pathname) && !embedActive;
(forceChromeForDashboardAgent || chromeByPreference) &&
!isChatsIframeWorkspacePath(location.pathname) &&
!embedActive;
// h-full(100%)而非 h-screen(100vh):配合 globals.css 的 html/body/#root height:100%
// 高度链,根布局跟随容器高度。全屏下 100% 解析到视口,与 h-screen 等价;无界等嵌套
+169 -40
View File
@@ -1,6 +1,6 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { type ChangeEvent, useEffect, useMemo, useRef, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import {
AlertCircle,
@@ -21,6 +21,7 @@ import {
RefreshCw,
TagsIcon,
Trash2,
Upload,
X,
} from "lucide-react";
import { useSearchParams } from "react-router-dom";
@@ -54,6 +55,7 @@ import {
type ScheduledTaskExecutionContext,
useHtmlPageFavoriteActions,
useHtmlPageFavorites,
useHtmlTemplates,
useScheduledTaskActions,
useScheduledTaskDeliveryProfile,
useScheduledTaskRuns,
@@ -107,6 +109,9 @@ function displayUsername(value?: string | null): string {
return at === -1 ? raw : raw.slice(0, at);
}
/** 内置「模板数据转换器」agent 的稳定 id(后端 seed)。选中它时表单露出 HTML 模板下拉。 */
const TEMPLATE_BUILDER_AGENT_ID = "template-json-builder";
const emptyForm = {
name: "",
description: "",
@@ -114,6 +119,8 @@ const emptyForm = {
timezone: "Asia/Shanghai",
prompt: "",
execution_agent_name: "",
// 模板填充模式:选中「模板数据转换器」后要把源 JSON 填进哪个 HTML 模板。
template_id: "",
// Two independent output switches (may both be on): require a Markdown file
// and/or render an HTML page. The form no longer splits into "普通任务 / HTML
// 页面" tabs.
@@ -172,6 +179,25 @@ export default function ScheduledTasksPage() {
const [editing, setEditing] = useState<ScheduledTask | null>(null);
const [creating, setCreating] = useState(false);
const [form, setForm] = useState(emptyForm);
// 模板填充模式的 .json 上传:读入文件文本填进 prompt 框(不改后端契约)。
const jsonUploadRef = useRef<HTMLInputElement | null>(null);
const handleJsonUpload = async (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
event.target.value = ""; // 允许重复上传同名文件
if (!file) return;
try {
const text = await file.text();
// 校验是 JSON,给即时反馈;非法也照样填入让用户修正。
try {
JSON.parse(text);
} catch {
toast.error("文件不是合法 JSON,已填入文本框请检查", darkToastOpts.error);
}
setForm((value) => ({ ...value, prompt: text }));
} catch {
toast.error("读取文件失败", darkToastOpts.error);
}
};
const dialogOpen = creating || !!editing;
const { data: htmlFavorites = [] } = useHtmlPageFavorites(dialogOpen);
const htmlFavoriteActions = useHtmlPageFavoriteActions();
@@ -285,6 +311,7 @@ export default function ScheduledTasksPage() {
// stored that under ``html_config.page_prompt``, so prefer it on edit.
prompt: html.page_prompt || task.prompt,
execution_agent_name: task.execution_agent_name ?? "",
template_id: task.execution_context?.template_config?.template_id ?? "",
require_markdown: !!task.execution_context?.require_markdown,
// Backward compatible: legacy HTML tasks only carry ``task_kind``.
require_html:
@@ -311,7 +338,15 @@ export default function ScheduledTasksPage() {
setScheduleConfig(DEFAULT_SCHEDULE_CONFIG);
};
const isHtmlTask = form.require_html;
// 模板填充模式:选中内置「模板数据转换器」agent。此时表单露出 HTML 模板下拉,
// 并隐藏「必须生成 HTML/Markdown」开关(本模式必产出 HTML)。
const isTemplateAgent = form.execution_agent_name === TEMPLATE_BUILDER_AGENT_ID;
const { data: htmlTemplates = [] } = useHtmlTemplates(isTemplateAgent);
const selectedTemplate = htmlTemplates.find((t) => t.id === form.template_id);
// 选中的模板是「只展示」型(convertible===false)时,不需要源 JSON,隐藏上传/输入。
const templateDisplayOnly =
isTemplateAgent && selectedTemplate?.convertible === false;
const isHtmlTask = !isTemplateAgent && form.require_html;
// The model actually shown/used: the user's choice, else the first configured
// model. Never a hardcoded GLM-5 — it follows config order and the selection.
const effectiveHtmlModel = form.html_model_name || availableModels[0]?.name || "";
@@ -322,8 +357,22 @@ export default function ScheduledTasksPage() {
// as the page requirement; ``task_kind`` is kept in sync only for backward
// compatibility with older readers.
const buildExecutionContext = (): ScheduledTaskExecutionContext => {
// 模板填充模式:只带 template_config,后端据此走「转换 JSON → 填充模板」分支。
if (isTemplateAgent && form.template_id) {
return {
template_config: { template_id: form.template_id },
require_html: false,
task_kind: "agent",
html_config: undefined,
};
}
if (!isHtmlTask) {
return { require_html: false, task_kind: "agent", html_config: undefined };
return {
require_html: false,
task_kind: "agent",
html_config: undefined,
template_config: undefined,
};
}
return {
require_html: true,
@@ -1110,46 +1159,126 @@ export default function ScheduledTasksPage() {
))}
</SelectContent>
</Select>
<Textarea
className="min-h-40"
value={form.prompt}
onChange={(event) =>
setForm((value) => ({ ...value, prompt: event.target.value }))
}
placeholder={
isHtmlTask
? "任务 / 页面生成要求,例如:检索新能源车最新舆情,生成适合领导快速浏览的态势页"
: "任务 Prompt"
}
/>
<div className="flex items-center justify-between rounded-md border p-3">
<div className="min-w-0 pr-3">
<p className="text-sm font-medium">必须生成 Markdown 文件</p>
<p className="text-muted-foreground text-xs">
开启后会提示大模型生成 Markdown(.md) 文件;系统会校验本次是否生成了 .md 文件且文件正文超过 100 字,未达到则记为失败。
</p>
{isTemplateAgent && (
<div className="flex flex-col gap-2 rounded-md border border-primary/30 bg-primary/5 p-3">
<div className="flex flex-col gap-1">
<p className="text-sm font-medium">HTML 模板</p>
<p className="text-muted-foreground text-xs">
{templateDisplayOnly ? (
<>选择要展示的 HTML 模板。该模板暂为「直接展示已有数据」,执行时将原样输出其现有页面。</>
) : (
<>
选择要把源数据填充进去的 HTML 模板。执行时智能体会把下方源 JSON 转换成该模板所需的结构,校验后填充生成可预览的页面。
<span className="text-foreground/70">未提供源 JSON 时,将直接展示该模板的默认数据。</span>
</>
)}
</p>
</div>
<Select
value={form.template_id || undefined}
onValueChange={(v) =>
setForm((value) => ({ ...value, template_id: v }))
}
>
<SelectTrigger className="w-full min-w-0 max-w-full overflow-hidden [&>span]:min-w-0 [&>span]:flex-1 [&>span]:truncate">
<SelectValue placeholder="选择 HTML 模板" />
</SelectTrigger>
<SelectContent
position="popper"
className="max-h-[40vh] w-[max(var(--radix-select-trigger-width),28rem)] max-w-[min(90vw,40rem)]"
>
{htmlTemplates.map((tpl) => (
<SelectItem key={tpl.id} value={tpl.id}>
<span className="block whitespace-normal break-words">
{tpl.name}
{tpl.description ? ` — ${tpl.description}` : ""}
</span>
</SelectItem>
))}
{htmlTemplates.length === 0 && (
<div className="text-muted-foreground px-2 py-1.5 text-xs">
暂无可用模板
</div>
)}
</SelectContent>
</Select>
{templateDisplayOnly ? (
<p className="text-muted-foreground text-xs">
该模板暂为「直接展示已有数据」,无需填写源 JSON;执行时将原样输出模板的现有页面。
</p>
) : (
<div className="flex items-center gap-2">
<input
ref={jsonUploadRef}
type="file"
accept=".json,application/json"
className="hidden"
onChange={handleJsonUpload}
/>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => jsonUploadRef.current?.click()}
>
<Upload className="mr-1 size-4" />
上传 .json 文件
</Button>
<span className="text-muted-foreground text-xs">
也可直接在下方粘贴源 JSON(上传会填入下方文本框)
</span>
</div>
)}
</div>
<Switch
checked={form.require_markdown}
onCheckedChange={(v) =>
setForm((value) => ({ ...value, require_markdown: v }))
)}
{!templateDisplayOnly && (
<Textarea
className="min-h-40"
value={form.prompt}
onChange={(event) =>
setForm((value) => ({ ...value, prompt: event.target.value }))
}
placeholder={
isTemplateAgent
? "源 JSON 数据(粘贴或上传 .json),将被智能体转换并填充进所选 HTML 模板;留空则展示模板默认数据"
: isHtmlTask
? "任务 / 页面生成要求,例如:检索新能源车最新舆情,生成适合领导快速浏览的态势页"
: "任务 Prompt"
}
/>
</div>
<div className="flex items-center justify-between rounded-md border p-3">
<div className="min-w-0 pr-3">
<p className="text-sm font-medium">必须生成 HTML 页面</p>
<p className="text-muted-foreground text-xs">
开启后会先用智能体检索整理真实数据,再渲染为完整 HTML 页面(可在执行记录中预览)。可与“必须生成 Markdown”同时开启。
</p>
</div>
<Switch
checked={form.require_html}
onCheckedChange={(v) =>
setForm((value) => ({ ...value, require_html: v }))
}
/>
</div>
)}
{!isTemplateAgent && (
<>
<div className="flex items-center justify-between rounded-md border p-3">
<div className="min-w-0 pr-3">
<p className="text-sm font-medium">必须生成 Markdown 文件</p>
<p className="text-muted-foreground text-xs">
开启后会提示大模型生成 Markdown(.md) 文件;系统会校验本次是否生成了 .md 文件且文件正文超过 100 字,未达到则记为失败。
</p>
</div>
<Switch
checked={form.require_markdown}
onCheckedChange={(v) =>
setForm((value) => ({ ...value, require_markdown: v }))
}
/>
</div>
<div className="flex items-center justify-between rounded-md border p-3">
<div className="min-w-0 pr-3">
<p className="text-sm font-medium">必须生成 HTML 页面</p>
<p className="text-muted-foreground text-xs">
开启后会先用智能体检索整理真实数据,再渲染为完整 HTML 页面(可在执行记录中预览)。可与“必须生成 Markdown”同时开启。
</p>
</div>
<Switch
checked={form.require_html}
onCheckedChange={(v) =>
setForm((value) => ({ ...value, require_html: v }))
}
/>
</div>
</>
)}
{isHtmlTask && (
<>
@@ -0,0 +1,319 @@
/**
* 「大屏绘制智能体」决心建议页 —— 任务信息 / 作战主张 / 打击目标 / 实施要点 / 决心总结
* (按设计图 dp-6.png 一比一实现)。
*
* **dashboard-agent 独占**:作为 DashboardApp `pages` 插槽里「决心建议」Tab 的内容,
* 不影响多智能体会商(Step3)大屏多页的样式。当前为「静态页面」,文案为占位内容,
* 动态数据后续接入。版式包含:
* 1. 任务信息 + 任务行动方向 / 作战主张(3 个编号方案)
* 2. 打击目标 / 目标建议(表)+ 实施要点
* 3. 决心建议总结(总结段 + 统计块)
*/
import { ClipboardList, Crosshair, Target, ListChecks, Flag, Clock, ShieldCheck, AlertTriangle, type LucideIcon } from "lucide-react";
import { Card, CardTitle, Table, Td, Tag, NoData, useDesensitize, type Tone } from "@/roundtable-planning/dashboard-agent/ui";
import type { DecisionData } from "@/roundtable-planning/dashboard-agent/sq-report";
// ── 占位数据 ──────────────────────────────────────────────────────────────────
const TASK_META: Array<{ label: string; value: string }> = [
{ label: "任务标题", value: "夺控 XX 关键地域,压制敌防御体系" },
{ label: "任务类型", value: "进攻作战" },
{ label: "任务时限", value: "5 小时内" },
{ label: "任务方向", value: "XX 方向" },
];
const TASK_INTRO =
"根据上级指示,我方对敌防御体系实施进攻,夺控 XX 关键地域,破坏敌指挥控制能力与防御部署,压制敌纵深支援能力,为后续行动创造有利条件。";
interface Proposal {
no: number;
title: string;
bullets: string[];
rec: string;
recTone: Tone;
}
const PROPOSALS: Proposal[] = [
{
no: 1,
title: "主攻突破,速战速决",
bullets: ["集中优势兵力突击敌防御薄弱点", "快速突破并夺控要点", "压制敌纵深火力支援能力"],
rec: "高",
recTone: "red",
},
{
no: 2,
title: "稳固推进,逐步压制",
bullets: ["先期压制敌火力与指挥体系", "分段推进,扩大战果", "稳固圈占,巩固防御"],
rec: "中",
recTone: "amber",
},
{
no: 3,
title: "迂回包抄,分割合围",
bullets: ["迂回敌防御侧翼实施包抄", "切割敌防御部署", "围点打援,歼敌纵深兵力"],
rec: "中",
recTone: "amber",
},
];
interface TargetRow {
no: number;
name: string;
type: string;
importance: string;
impTone: Tone;
priority: string;
priTone: Tone;
effect: string;
method: string;
timing: string;
}
const TARGETS: TargetRow[] = [
{ no: 1, name: "敌前沿防御阵地", type: "防御工事", importance: "高", impTone: "red", priority: "高", priTone: "red", effect: "摧毁瘫痪,降低防御能力", method: "精确打击", timing: "D+0~0+3h" },
{ no: 2, name: "敌指挥控制节点", type: "指挥所", importance: "高", impTone: "red", priority: "高", priTone: "red", effect: "压制致瘫", method: "火力打击", timing: "D+0~0+5h" },
{ no: 3, name: "敌炮兵阵地", type: "火力群", importance: "高", impTone: "red", priority: "中", priTone: "amber", effect: "压制摧毁", method: "远程火力", timing: "D+0~0+4h" },
{ no: 4, name: "敌防空火力点", type: "防空阵地", importance: "中", impTone: "amber", priority: "高", priTone: "red", effect: "前期压制夺取制空", method: "电子+火力", timing: "D+0~0+2h" },
{ no: 5, name: "敌纵深兵力", type: "增援部队", importance: "中", impTone: "amber", priority: "中", priTone: "amber", effect: "迟滞阻断", method: "火力封锁", timing: "D+0~0+18h" },
];
const KEY_POINTS = [
"集中优势兵力与火力,确保主攻方向突破能力。",
"先期制电、压制网络,为突击与夺控创造信息优势。",
"强化诸军兵种协同,提升整体作战效能。",
"严格协同空地火力与地面行动,形成体系打击合力。",
"做好预备兵力与增援应对预案,保持持续作战能力。",
"注重战场舆情与目标善后,及时修订完善方案。",
];
const SUMMARY =
"建议采取「主攻突破,速战速决」的作战行动方向,集中优势兵力与火力,优先打击敌防御阵地、敌指挥控制节点与敌纵深兵力,迅速压制敌防御体系,夺控 XX 关键地域,并同步破坏敌纵深支援能力,形成战役全局有利态势。";
interface SummaryStat {
icon: LucideIcon;
label: string;
value: string;
}
const SUMMARY_STATS: SummaryStat[] = [
{ icon: Crosshair, label: "推荐行动方向", value: "主攻突破,速战速决" },
{ icon: Clock, label: "建议处置时机", value: "D+0 ~ D+6h" },
{ icon: Target, label: "关键目标数量", value: "5 个" },
{ icon: ShieldCheck, label: "重点保障", value: "火力压制 / 空中协同 / 机动补给" },
{ icon: AlertTriangle, label: "风险提示", value: "敌防空反制 / 增援压力 / 地形限制" },
];
// ── 主组件(有真实数据走真实渲染,否则回退静态占位) ─────────────────────────────
export function Decision({ data }: { data?: DecisionData | null }) {
return data ? <DecisionReal data={data} /> : <NoData />;
}
function DecisionPlaceholder() {
return (
<div className="space-y-4 text-slate-700 dark:text-[var(--dash-text)]">
{/* 1. 任务信息 + 作战主张 */}
<div className="grid grid-cols-1 gap-3 lg:grid-cols-12">
<Card className="flex flex-col lg:col-span-4">
<CardTitle icon={ClipboardList} title="任务信息" />
<div className="flex-1 px-4 py-3.5">
<dl className="space-y-2">
{TASK_META.map((m) => (
<div key={m.label} className="flex gap-2 text-xs">
<dt className="w-16 shrink-0 text-slate-400 dark:text-[var(--dash-text-dim)]">{m.label}</dt>
<dd className="font-medium text-slate-700 dark:text-[var(--dash-text-strong)]">{m.value}</dd>
</div>
))}
</dl>
<div className="mt-3 border-t border-slate-100 pt-3 dark:border-[var(--dash-border)]">
<div className="mb-1 text-[11px] text-slate-400 dark:text-[var(--dash-text-dim)]">任务简介</div>
<p className="text-xs leading-relaxed text-slate-600 dark:text-[var(--dash-text)]">{TASK_INTRO}</p>
</div>
</div>
</Card>
<Card className="flex flex-col lg:col-span-8">
<CardTitle icon={Flag} title="任务行动方向 / 作战主张" />
<div className="grid flex-1 grid-cols-1 gap-3 px-4 py-3.5 sm:grid-cols-3">
{PROPOSALS.map((p) => (
<div
key={p.no}
className="flex flex-col rounded-md border border-slate-200 p-3 dark:border-[var(--dash-border)]"
>
<div className="mb-2 flex items-center gap-2">
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-red-600 text-[11px] font-bold text-white">
{p.no}
</span>
<h4 className="text-xs font-bold text-slate-900 dark:text-[var(--dash-text-strong)]">{p.title}</h4>
</div>
<ul className="flex-1 space-y-1.5">
{p.bullets.map((b) => (
<li key={b} className="flex gap-1.5 text-[11px] leading-snug text-slate-600 dark:text-[var(--dash-text)]">
<span className="mt-1.5 h-1 w-1 shrink-0 rounded-full bg-red-500" />
{b}
</li>
))}
</ul>
<div className="mt-2 flex items-center gap-1.5 border-t border-slate-100 pt-2 text-[11px] text-slate-400 dark:border-[var(--dash-border)]">
推荐程度
<Tag tone={p.recTone}>{p.rec}</Tag>
</div>
</div>
))}
</div>
</Card>
</div>
{/* 2. 打击目标 + 实施要点 */}
<div className="grid grid-cols-1 gap-3 lg:grid-cols-12">
<Card className="flex flex-col lg:col-span-8">
<CardTitle icon={Target} title="打击目标 / 目标建议" />
<div className="flex-1 px-4 py-3.5">
<Table head={["序号", "目标名称", "类型", "重要性", "优先级", "达成效果", "建议方式", "建议时机"]}>
{TARGETS.map((t) => (
<tr key={t.no}>
<Td className="font-mono text-slate-400">{t.no}</Td>
<Td className="whitespace-nowrap font-semibold text-slate-800 dark:text-[var(--dash-text-strong)]">{t.name}</Td>
<Td className="text-slate-500 dark:text-[var(--dash-text-sec)]">{t.type}</Td>
<Td>
<Tag tone={t.impTone}>{t.importance}</Tag>
</Td>
<Td>
<Tag tone={t.priTone}>{t.priority}</Tag>
</Td>
<Td className="text-slate-500 dark:text-[var(--dash-text-sec)]">{t.effect}</Td>
<Td className="whitespace-nowrap text-slate-500 dark:text-[var(--dash-text-sec)]">{t.method}</Td>
<Td className="whitespace-nowrap font-mono text-slate-400">{t.timing}</Td>
</tr>
))}
</Table>
</div>
</Card>
<Card className="flex flex-col lg:col-span-4">
<CardTitle icon={ListChecks} title="实施要点" />
<ol className="flex-1 space-y-2.5 px-4 py-3.5">
{KEY_POINTS.map((p, i) => (
<li key={p} className="flex gap-2 text-xs leading-relaxed">
<span className="flex h-4 w-4 shrink-0 items-center justify-center rounded-full bg-red-600 text-[10px] font-bold text-white">
{i + 1}
</span>
<span className="text-slate-600 dark:text-[var(--dash-text)]">{p}</span>
</li>
))}
</ol>
</Card>
</div>
{/* 3. 决心建议总结 */}
<Card>
<CardTitle icon={Flag} title="决心建议总结" />
<div className="px-4 py-3.5">
<p className="rounded border-l-2 border-red-500 bg-red-50 px-3 py-2.5 text-[13px] leading-relaxed text-slate-700 dark:bg-red-500/10 dark:text-[var(--dash-text)]">
{SUMMARY}
</p>
<div className="mt-4 grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
{SUMMARY_STATS.map((s) => (
<div key={s.label} className="rounded-md bg-slate-50 px-3 py-3 dark:bg-white/5">
<div className="mb-1.5 flex items-center gap-1.5 text-[11px] text-slate-500 dark:text-[var(--dash-text-sec)]">
<s.icon className="h-3.5 w-3.5 text-red-600" />
{s.label}
</div>
<div className="text-xs font-bold text-slate-900 dark:text-[var(--dash-text-strong)]">{s.value}</div>
</div>
))}
</div>
</div>
</Card>
</div>
);
}
// ── 真实数据渲染 ──────────────────────────────────────────────────────────────
function DecisionReal({ data }: { data: DecisionData }) {
const ds = useDesensitize();
return (
<div className="space-y-4 text-slate-700 dark:text-[var(--dash-text)]">
{/* 任务信息 + 作战目的 */}
<div className="grid grid-cols-1 gap-3 lg:grid-cols-12">
<Card className="flex flex-col lg:col-span-5">
<CardTitle icon={ClipboardList} title="任务信息" />
<div className="flex-1 px-4 py-3.5">
{data.taskTitle && (
<div className="mb-2">
<div className="mb-1 text-[11px] text-slate-400 dark:text-[var(--dash-text-dim)]">{ds("任务标题")}</div>
<p className="text-sm font-bold text-slate-800 dark:text-[var(--dash-text-strong)]">{data.taskTitle}</p>
</div>
)}
{data.taskIntro && (
<div className="border-t border-slate-100 pt-2.5 dark:border-[var(--dash-border)]">
<div className="mb-1 text-[11px] text-slate-400 dark:text-[var(--dash-text-dim)]">{ds("任务简介")}</div>
<p className="text-xs leading-relaxed text-slate-600 dark:text-[var(--dash-text)]">{data.taskIntro}</p>
</div>
)}
</div>
</Card>
{data.objectives.length > 0 && (
<Card className="flex flex-col lg:col-span-7">
<CardTitle icon={Crosshair} title="作战目的" />
<ol className="flex-1 space-y-2.5 px-4 py-3.5">
{data.objectives.map((o, i) => (
<li key={i} className="flex gap-2 text-xs leading-relaxed">
<span className="flex h-4 w-4 shrink-0 items-center justify-center rounded-full bg-red-600 text-[10px] font-bold text-white">{i + 1}</span>
<span className="text-slate-600 dark:text-[var(--dash-text)]">{o}</span>
</li>
))}
</ol>
</Card>
)}
</div>
{/* 打击目标 + 实施要点 */}
<div className="grid grid-cols-1 gap-3 lg:grid-cols-12">
{data.targets.length > 0 && (
<Card className="flex flex-col lg:col-span-8">
<CardTitle icon={Target} title="打击目标 / 目标建议" />
<div className="flex-1 px-4 py-3.5">
<Table head={["目标名称", "目标描述", "选择理由", "潜在影响", "推荐依据"]}>
{data.targets.map((t, i) => (
<tr key={i}>
<Td className="whitespace-nowrap font-semibold text-slate-800 dark:text-[var(--dash-text-strong)]">{t.name || "—"}</Td>
<Td className="text-slate-500 dark:text-[var(--dash-text-sec)]">{t.desc || "—"}</Td>
<Td className="text-slate-500 dark:text-[var(--dash-text-sec)]">{t.reason || "—"}</Td>
<Td className="text-slate-500 dark:text-[var(--dash-text-sec)]">{t.impact || "—"}</Td>
<Td className="text-slate-500 dark:text-[var(--dash-text-sec)]">{t.basis || "—"}</Td>
</tr>
))}
</Table>
</div>
</Card>
)}
{data.keyPoints.length > 0 && (
<Card className="flex flex-col lg:col-span-4">
<CardTitle icon={ListChecks} title="实施要点" />
<ol className="flex-1 space-y-2.5 px-4 py-3.5">
{data.keyPoints.map((p, i) => (
<li key={i} className="flex gap-2 text-xs leading-relaxed">
<span className="flex h-4 w-4 shrink-0 items-center justify-center rounded-full bg-red-600 text-[10px] font-bold text-white">{i + 1}</span>
<span className="text-slate-600 dark:text-[var(--dash-text)]">{p}</span>
</li>
))}
</ol>
</Card>
)}
</div>
{/* 决心建议总结 */}
{data.summary && (
<Card>
<CardTitle icon={Flag} title="决心建议总结" />
<div className="px-4 py-3.5">
<p className="rounded border-l-2 border-red-500 bg-red-50 px-3 py-2.5 text-[13px] leading-relaxed text-slate-700 dark:bg-red-500/10 dark:text-[var(--dash-text)]">
{data.summary}
</p>
</div>
</Card>
)}
</div>
);
}
@@ -0,0 +1,274 @@
/**
* 「大屏绘制智能体」处置建议页 —— 处置总结 / 目标 / 处置建议表 / 关键事态
* (按设计图 dp-7.png 一比一实现)。
*
* **dashboard-agent 独占**:作为 DashboardApp `pages` 插槽里「处置建议」Tab 的内容,
* 不影响多智能体会商(Step3)大屏多页的样式。当前为「静态页面」,文案为占位内容,
* 动态数据后续接入。版式包含:
* 1. 处置建议总结 + 目标(5 个处置目标卡)
* 2. 处置建议(大表)+ 关键事态(编号事件列表)
*/
import {
ClipboardCheck,
ClipboardList,
Flag,
ShieldAlert,
Megaphone,
Users,
Gavel,
Handshake,
AlertTriangle,
Star,
type LucideIcon,
} from "lucide-react";
import { Card, CardTitle, FooterNote, Table, Td, Tag, NoData, useDesensitize, type Tone } from "@/roundtable-planning/dashboard-agent/ui";
import type { DisposalData } from "@/roundtable-planning/dashboard-agent/sq-report";
// ── 占位数据 ──────────────────────────────────────────────────────────────────
const SUMMARY_ITEMS: Array<{ label: string; text: string }> = [
{ label: "总体判断", text: "当前事件可控但需密切关注,舆情存在扩散与升级风险,需快速反应、综合施策。" },
{ label: "主体策略", text: "以澄清事实、权威发声为主,依法依规处置,统筹线上线下,压缩谣言空间。" },
{ label: "处置重点", text: "统一对外口径,加强舆论引导,强化协同联动,做好重点群体与重点平台工作。" },
{ label: "风险防控", text: "警惕谣言扩散、情绪对立与境外炒作,防范局部聚集与事态升级,守牢安全底线。" },
];
interface GoalCard {
icon: LucideIcon;
title: string;
text: string;
}
const GOALS: GoalCard[] = [
{ icon: ShieldAlert, title: "阻止威胁扩张", text: "及时防御与压制扩张,减少不实信息蔓延与影响扩散。" },
{ icon: Megaphone, title: "澄清事实,统一口径", text: "权威发布、统一口径,及时回应社会关切。" },
{ icon: Users, title: "维护社会稳定", text: "引导社会情绪,防范对立与局部聚集,维护大局稳定。" },
{ icon: Gavel, title: "依法严正应对", text: "对违法违规行为依法处置,严正立场、坚守底线。" },
{ icon: Handshake, title: "提升协同效能", text: "强化多部门协同联动,形成处置合力与快速反应能力。" },
];
interface DisposalRow {
no: number;
content: string;
timing: string;
subject: string;
caliber: string;
method: string;
effect: string;
basis: string;
}
const DISPOSALS: DisposalRow[] = [
{ no: 1, content: "澄清事实,发布权威信息", timing: "立即", subject: "宣传部门 / 权威发布机构", caliber: "事实 / 立场", method: "权威发布、多渠道同步", effect: "统一口径,稳定舆论", basis: "公开透明" },
{ no: 2, content: "管控舆情,做强引导", timing: "持续", subject: "网信部门 / 平台", caliber: "引导 / 疏导", method: "加强议题设置与正向引导", effect: "压制负面,提升正能量", basis: "依法管理" },
{ no: 3, content: "防范网络,加强溯源处置", timing: "快速", subject: "公安部门 / 平台", caliber: "溯源 / 处置", method: "监测溯源、依法处置", effect: "切断扩散链条", basis: "合法合规" },
{ no: 4, content: "协同处置,打击违法行为", timing: "视情", subject: "多部门联动", caliber: "联动 / 执法", method: "联合执法、分级处置", effect: "形成有效震慑", basis: "严正应对" },
{ no: 5, content: "防范升级,维护稳定", timing: "持续", subject: "综合部门", caliber: "稳控 / 防控", method: "重点防控、预案处置", effect: "守牢稳定底线", basis: "安全第一" },
];
interface KeyEvent {
no: number;
title: string;
time: string;
impact: string;
impactTone: Tone;
}
const EVENTS: KeyEvent[] = [
{ no: 1, title: "不实信息在社交平台扩散", time: "05-16 09:30", impact: "高", impactTone: "red" },
{ no: 2, title: "部分舆情持续发酵引发讨论", time: "05-16 08:40", impact: "中", impactTone: "amber" },
{ no: 3, title: "相关话题持续热度上升", time: "05-16 10:05", impact: "中", impactTone: "amber" },
{ no: 4, title: "线下出现小范围聚集活动", time: "05-16 10:20", impact: "高", impactTone: "red" },
{ no: 5, title: "涉事相关单位回应迟缓", time: "05-16 09:50", impact: "中", impactTone: "amber" },
];
// ── 主组件(有真实数据走真实渲染,否则回退静态占位) ─────────────────────────────
export function Disposal({ data }: { data?: DisposalData | null }) {
return data ? <DisposalReal data={data} /> : <NoData />;
}
function DisposalPlaceholder() {
return (
<div className="space-y-4 text-slate-700 dark:text-[var(--dash-text)]">
{/* 1. 处置建议总结 + 目标 */}
<div className="grid grid-cols-1 gap-3 lg:grid-cols-12">
{/* 处置建议总结 */}
<Card className="flex flex-col lg:col-span-4">
<CardTitle icon={ClipboardCheck} title="处置建议总结" />
<ul className="flex-1 space-y-3 px-4 py-3.5">
{SUMMARY_ITEMS.map((s) => (
<li key={s.label} className="flex gap-2.5 text-xs leading-relaxed">
<span className="mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-red-600" />
<p className="text-slate-600 dark:text-[var(--dash-text)]">
<span className="font-bold text-red-600">{s.label}:</span>
{s.text}
</p>
</li>
))}
</ul>
<div className="mx-4 mb-3.5 flex flex-wrap items-center gap-x-4 gap-y-2 rounded bg-slate-50 px-3 py-2.5 dark:bg-white/5">
<div className="flex items-center gap-1.5 text-[11px] text-slate-500 dark:text-[var(--dash-text-sec)]">
综合处置建议
<Tag tone="red">稳健(取义)</Tag>
</div>
<div className="flex items-center gap-1.5 text-[11px] text-slate-500 dark:text-[var(--dash-text-sec)]">
建议执行优先级
<span className="flex items-center gap-0.5 text-red-600">
{Array.from({ length: 5 }).map((_, i) => (
<Star key={i} className="h-3.5 w-3.5 fill-current" />
))}
</span>
</div>
</div>
</Card>
{/* 目标 */}
<Card className="flex flex-col lg:col-span-8">
<CardTitle icon={Flag} title="目标" />
<div className="grid flex-1 grid-cols-1 gap-3 px-4 py-3.5 sm:grid-cols-2 lg:grid-cols-5">
{GOALS.map((g) => (
<div key={g.title} className="flex flex-col rounded-md border border-slate-200 p-3 dark:border-[var(--dash-border)]">
<span className="mb-2 flex h-8 w-8 items-center justify-center rounded-md bg-red-50 text-red-600 dark:bg-red-500/15">
<g.icon className="h-4 w-4" />
</span>
<h4 className="mb-1.5 text-xs font-bold text-slate-900 dark:text-[var(--dash-text-strong)]">{g.title}</h4>
<p className="text-[11px] leading-snug text-slate-500 dark:text-[var(--dash-text-sec)]">{g.text}</p>
</div>
))}
</div>
</Card>
</div>
{/* 2. 处置建议表 + 关键事态 */}
<div className="grid grid-cols-1 gap-3 lg:grid-cols-12">
{/* 处置建议 */}
<Card className="flex flex-col lg:col-span-8">
<CardTitle icon={ClipboardList} title="处置建议" />
<div className="flex-1 px-4 py-3.5">
<Table head={["序号", "建议内容", "时序", "处置主体", "口径要素", "处置方式", "预期效果", "立场依据"]}>
{DISPOSALS.map((d) => (
<tr key={d.no}>
<Td className="font-mono text-slate-400">{d.no}</Td>
<Td className="whitespace-nowrap font-semibold text-slate-800 dark:text-[var(--dash-text-strong)]">{d.content}</Td>
<Td className="whitespace-nowrap text-slate-500 dark:text-[var(--dash-text-sec)]">{d.timing}</Td>
<Td className="text-slate-500 dark:text-[var(--dash-text-sec)]">{d.subject}</Td>
<Td className="whitespace-nowrap text-slate-500 dark:text-[var(--dash-text-sec)]">{d.caliber}</Td>
<Td className="text-slate-500 dark:text-[var(--dash-text-sec)]">{d.method}</Td>
<Td className="text-slate-500 dark:text-[var(--dash-text-sec)]">{d.effect}</Td>
<Td className="whitespace-nowrap text-slate-500 dark:text-[var(--dash-text-sec)]">{d.basis}</Td>
</tr>
))}
</Table>
</div>
<FooterNote label="说明">
上述处置应在法治框架内规范实施,统筹线上线下,确保快速反应、综合施策。
</FooterNote>
</Card>
{/* 关键事态 */}
<Card className="flex flex-col lg:col-span-4">
<CardTitle icon={AlertTriangle} title="关键事态" />
<ul className="flex-1 space-y-3 px-4 py-3.5">
{EVENTS.map((e) => (
<li key={e.no} className="flex gap-2.5">
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-red-600 text-[11px] font-bold text-white">
{e.no}
</span>
<div className="min-w-0 flex-1">
<div className="text-xs font-semibold text-slate-800 dark:text-[var(--dash-text-strong)]">{e.title}</div>
<div className="mt-1 flex items-center gap-2 text-[11px] text-slate-400 dark:text-[var(--dash-text-dim)]">
<span className="font-mono">{e.time}</span>
<span>影响</span>
<Tag tone={e.impactTone}>{e.impact}</Tag>
</div>
</div>
</li>
))}
</ul>
<FooterNote label="提示">关键事态需密切跟踪,及时研判处置。</FooterNote>
</Card>
</div>
</div>
);
}
// ── 真实数据渲染 ──────────────────────────────────────────────────────────────
function DisposalReal({ data }: { data: DisposalData }) {
return (
<div className="space-y-4 text-slate-700 dark:text-[var(--dash-text)]">
{/* 目标 + 处置总结 */}
{(data.goal || data.summary) && (
<div className="grid grid-cols-1 gap-3 lg:grid-cols-2">
{data.goal && (
<Card className="flex flex-col">
<CardTitle icon={Flag} title="目标" />
<div className="flex-1 px-4 py-3.5">
<p className="text-xs leading-relaxed text-slate-600 dark:text-[var(--dash-text)]">{data.goal}</p>
</div>
</Card>
)}
{data.summary && (
<Card className="flex flex-col">
<CardTitle icon={ClipboardCheck} title="处置建议总结" />
<div className="flex-1 px-4 py-3.5">
<p className="rounded border-l-2 border-red-500 bg-red-50 px-3 py-2.5 text-xs leading-relaxed text-slate-700 dark:bg-red-500/10 dark:text-[var(--dash-text)]">
{data.summary}
</p>
</div>
</Card>
)}
</div>
)}
{/* 处置建议:每条建议一张卡片(建议内容作标题 + 其余字段做带标签的两列网格),
替代原 7 列大表——内容多时更易读、不再拥挤。 */}
{data.items.length > 0 && (
<Card>
<CardTitle icon={ClipboardList} title="处置建议" />
<div className="space-y-3 px-4 py-3.5">
{data.items.map((d, i) => (
<div
key={i}
className="rounded-md border border-slate-200 bg-slate-50/60 p-4 dark:border-[var(--dash-border)] dark:bg-[var(--dash-elev)]"
>
{/* 标题行:序号 + 建议内容 + 口径基调 */}
<div className="mb-3 flex items-start gap-2.5">
<span className="mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-red-600 text-[11px] font-bold text-white">
{i + 1}
</span>
<div className="min-w-0 flex-1 text-xs font-semibold leading-relaxed text-slate-800 dark:text-[var(--dash-text-strong)]">
{d.content || "—"}
</div>
{d.tone && (
<span className="shrink-0">
<Tag tone="amber">{d.tone}</Tag>
</span>
)}
</div>
{/* 其余字段:带标签两列网格 */}
{(d.method || d.effect || d.subject || d.basis) && (
<dl className="grid grid-cols-1 gap-x-6 gap-y-2.5 border-t border-dashed border-slate-200 pt-3 dark:border-[var(--dash-border)] sm:grid-cols-2">
{d.method && <Field label="处置方式与步骤" value={d.method} />}
{d.effect && <Field label="预期效果" value={d.effect} />}
{d.subject && <Field label="主责单位" value={d.subject} />}
{d.basis && <Field label="立场与原则" value={d.basis} />}
</dl>
)}
</div>
))}
</div>
</Card>
)}
</div>
);
}
/** 处置建议卡片里的「标签 + 值」字段(标签红色小字,值正文)。 */
function Field({ label, value }: { label: string; value: string }) {
const ds = useDesensitize();
return (
<div className="text-xs">
<dt className="mb-0.5 font-semibold text-[#222222] dark:text-[var(--dash-text-strong)]">{ds(label)}</dt>
<dd className="leading-relaxed text-slate-600 dark:text-[var(--dash-text)]">{value}</dd>
</div>
);
}
@@ -0,0 +1,334 @@
/**
* 「大屏绘制智能体」敌情页 —— 敌情详情版式(按设计图 dp-2.png 一比一实现)。
*
* **dashboard-agent 独占**:作为 DashboardApp `pages` 插槽里「敌情」Tab 的内容,
* 不影响多智能体会商(Step3)大屏多页的样式。当前为「静态页面」,文案为占位内容,
* 动态数据后续接入。版式包含:
* 1. 敌方目标表 + 关键事件表(上排两栏)
* 2. 对手叙事表(整行)
* 3. 重点关注对象表 + 传播渠道分析 + 风险热度研判(下排三栏)
*/
import { Target, CalendarClock, Users, Crosshair, Share2, Activity, ArrowUp } from "lucide-react";
import { Card, CardTitle, HeadBadge, Tag, Table, Td, FooterNote, NoData, type Tone } from "@/roundtable-planning/dashboard-agent/ui";
import type { EnemyData } from "@/roundtable-planning/dashboard-agent/sq-report";
// ── 占位数据 ──────────────────────────────────────────────────────────────────
interface EnemyTarget {
no: number;
name: string;
count: string;
gap: string;
gapTone: Tone;
note: string;
}
const TARGETS: EnemyTarget[] = [
{ no: 1, name: "战略级", count: "2 个", gap: "+2", gapTone: "red", note: "查实战略意义重大的目标节点" },
{ no: 2, name: "战役级", count: "3 个", gap: "+1", gapTone: "red", note: "重要枢纽与指挥控制节点" },
{ no: 3, name: "战术级", count: "5 个", gap: "-2", gapTone: "green", note: "前沿火力点 / 机动单元" },
{ no: 4, name: "支援级", count: "4 个", gap: "持平", gapTone: "slate", note: "后勤与通信保障目标" },
{ no: 5, name: "关键人员", count: "78 人", gap: "+3", gapTone: "red", note: "专家与高级指挥人员,重点关注" },
];
interface KeyEvent {
time: string;
title: string;
brief: string;
impact: string;
impactTone: Tone;
}
const EVENTS: KeyEvent[] = [
{ time: "05-16 09:30", title: "对我方侦察活动加强", brief: "敌多个 APT 组织对我重点系统进行扫描探测", impact: "高", impactTone: "red" },
{ time: "05-16 08:45", title: "北部方向兵力调动", brief: "北部出现兵力机动迹象,向前沿地域集结", impact: "高", impactTone: "red" },
{ time: "05-15 21:10", title: "增设通信节点", brief: "增设若干临时通信节点,提升指挥保障", impact: "中", impactTone: "amber" },
{ time: "05-15 13:05", title: "网络渗透扫描", brief: "针对我政务与能源系统的渗透扫描频度上升", impact: "高", impactTone: "red" },
];
interface Narrative {
name: string;
view: string;
intent: string;
channel: string;
audience: string;
heat: string;
trend: string;
trendTone: Tone;
}
const NARRATIVES: Narrative[] = [
{ name: "主导叙事", view: "渲染我方为地区紧张根源", intent: "争夺道义高地、动员国际舆论", channel: "境外主流媒体 / 社交平台", audience: "国际受众 / 中立国家", heat: "高", trend: "上升", trendTone: "red" },
{ name: "配套舆论", view: "夸大我方军事活动威胁", intent: "制造焦虑、离间区域关系", channel: "本地社交媒体矩阵", audience: "周边民众", heat: "中", trend: "上升", trendTone: "red" },
{ name: "情绪动员", view: "煽动对立与不满情绪", intent: "削弱我方内部凝聚力", channel: "短视频 / 论坛", audience: "青年群体", heat: "中", trend: "持平", trendTone: "slate" },
];
interface FocusObject {
no: number;
name: string;
category: string;
relevance: number;
relTone: Tone;
status: string;
statusTone: Tone;
}
const FOCUS: FocusObject[] = [
{ no: 1, name: "红方网络安全组织", category: "组织", relevance: 88, relTone: "red", status: "高度活跃", statusTone: "red" },
{ no: 2, name: "东方某无人机部队", category: "部队", relevance: 82, relTone: "red", status: "活跃", statusTone: "amber" },
{ no: 3, name: "北部战区通信枢纽", category: "设施", relevance: 76, relTone: "amber", status: "监控中", statusTone: "amber" },
{ no: 4, name: "某舆论账号矩阵", category: "媒体", relevance: 70, relTone: "amber", status: "活跃", statusTone: "amber" },
{ no: 5, name: "海外某情报机构", category: "机构", relevance: 65, relTone: "slate", status: "持续观察", statusTone: "slate" },
];
interface Channel {
name: string;
pct: number;
desc: string;
}
const CHANNELS: Channel[] = [
{ name: "即时通讯类", pct: 38, desc: "封闭式扩散,溯源难度大" },
{ name: "社交媒体类", pct: 27, desc: "扩散速度快、覆盖面广" },
{ name: "论坛 / 贴吧", pct: 16, desc: "话题沉淀、舆论发酵" },
{ name: "视频平台", pct: 12, desc: "情绪煽动性强" },
{ name: "其他渠道", pct: 7, desc: "小范围定向传播" },
];
const RISK_POINTS = [
"网络攻击与渗透风险偏高,关键系统面临持续探测。",
"信息渗透与认知作战风险上升,舆论环境复杂。",
"前沿方向摩擦风险存在,需防范局部突发事件。",
];
const RISK_CONCLUSION = "当前风险整体呈上升态势,须重点防范网络与认知域,强化预警与协同处置。";
// ── 主组件(有真实数据走真实渲染,否则回退静态占位) ─────────────────────────────
export function EnemyDetail({ data }: { data?: EnemyData | null }) {
return data ? <EnemyReal data={data} /> : <NoData />;
}
function EnemyPlaceholder() {
return (
<div className="space-y-4 text-slate-700 dark:text-[var(--dash-text)]">
{/* 1. 上排:敌方目标 + 关键事件 */}
<div className="grid grid-cols-1 gap-3 lg:grid-cols-12">
{/* 敌方目标 */}
<Card className="flex flex-col lg:col-span-5">
<CardTitle
icon={Target}
title="敌方目标"
badges={
<>
<HeadBadge label="组织目标" value="12 个" />
<HeadBadge label="人员目标" value="78 人" />
</>
}
/>
<div className="flex-1 px-4 py-3.5">
<Table head={["编号", "名称", "数量", "缺口", "状态说明"]}>
{TARGETS.map((t) => (
<tr key={t.no}>
<Td className="font-mono text-slate-400">{t.no}</Td>
<Td className="font-semibold text-slate-800 dark:text-[var(--dash-text-strong)]">{t.name}</Td>
<Td className="font-mono">{t.count}</Td>
<Td>
<Tag tone={t.gapTone}>{t.gap}</Tag>
</Td>
<Td className="text-slate-500 dark:text-[var(--dash-text-sec)]">{t.note}</Td>
</tr>
))}
</Table>
</div>
</Card>
{/* 关键事件 */}
<Card className="flex flex-col lg:col-span-7">
<CardTitle icon={CalendarClock} title="关键事件" />
<div className="flex-1 px-4 py-3.5">
<Table head={["时间", "标题", "内容摘要", "影响评估"]}>
{EVENTS.map((e) => (
<tr key={e.time}>
<Td className="whitespace-nowrap font-mono text-slate-400">{e.time}</Td>
<Td className="font-semibold text-slate-800 dark:text-[var(--dash-text-strong)]">{e.title}</Td>
<Td className="text-slate-500 dark:text-[var(--dash-text-sec)]">{e.brief}</Td>
<Td>
<Tag tone={e.impactTone}>{e.impact}</Tag>
</Td>
</tr>
))}
</Table>
</div>
<FooterNote label="事件说明">
近期事件以网络空间动作与军事活动配合为主,整体风险趋升,需持续跟踪重点节点。
</FooterNote>
</Card>
</div>
{/* 2. 对手叙事(整行) */}
<Card>
<CardTitle icon={Users} title="对手叙事" />
<div className="px-4 py-3.5">
<Table head={["名称", "主要观点", "叙事意图", "使用渠道", "受众群体", "热度", "影响烈度"]}>
{NARRATIVES.map((n) => (
<tr key={n.name}>
<Td className="whitespace-nowrap font-semibold text-slate-800 dark:text-[var(--dash-text-strong)]">{n.name}</Td>
<Td className="text-slate-500 dark:text-[var(--dash-text-sec)]">{n.view}</Td>
<Td className="text-slate-500 dark:text-[var(--dash-text-sec)]">{n.intent}</Td>
<Td className="text-slate-500 dark:text-[var(--dash-text-sec)]">{n.channel}</Td>
<Td className="text-slate-500 dark:text-[var(--dash-text-sec)]">{n.audience}</Td>
<Td>
<Tag tone={n.heat === "高" ? "red" : n.heat === "中" ? "amber" : "slate"}>{n.heat}</Tag>
</Td>
<Td>
<Tag tone={n.trendTone}>{n.trend}</Tag>
</Td>
</tr>
))}
</Table>
</div>
<FooterNote label="叙事说明">
对手叙事以「去式化行动」「外部归因」为核心,须以精准事实与权威信息及时反制,压缩其传播空间。
</FooterNote>
</Card>
{/* 3. 下排:重点关注对象 + 传播渠道分析 + 风险热度研判 */}
<div className="grid grid-cols-1 gap-3 lg:grid-cols-3">
{/* 重点关注对象 */}
<Card className="flex flex-col">
<CardTitle icon={Crosshair} title="重点关注对象" />
<div className="flex-1 px-4 py-3.5">
<Table head={["编号", "名称", "类别", "相关度", "状态"]}>
{FOCUS.map((f) => (
<tr key={f.no}>
<Td className="font-mono text-slate-400">{f.no}</Td>
<Td className="font-semibold text-slate-800 dark:text-[var(--dash-text-strong)]">{f.name}</Td>
<Td className="text-slate-500 dark:text-[var(--dash-text-sec)]">{f.category}</Td>
<Td>
<span className={`font-mono font-bold ${f.relTone === "red" ? "text-red-600" : f.relTone === "amber" ? "text-amber-600" : "text-slate-500"}`}>
{f.relevance}%
</span>
</Td>
<Td>
<Tag tone={f.statusTone}>{f.status}</Tag>
</Td>
</tr>
))}
</Table>
</div>
</Card>
{/* 传播渠道分析 */}
<Card className="flex flex-col">
<CardTitle icon={Share2} title="传播渠道分析(文字化)" />
<ul className="flex-1 space-y-3 px-4 py-3.5">
{CHANNELS.map((c) => (
<li key={c.name}>
<div className="mb-1 flex items-baseline justify-between gap-2 text-xs">
<span className="font-semibold text-slate-700 dark:text-[var(--dash-text-strong)]">{c.name}</span>
<span className="font-mono font-bold text-red-600">{c.pct}%</span>
</div>
<div className="h-1.5 overflow-hidden rounded-full bg-slate-100 dark:bg-white/10">
<div className="h-full rounded-full bg-red-500" style={{ width: `${c.pct}%` }} />
</div>
<div className="mt-1 text-[11px] leading-snug text-slate-400 dark:text-[var(--dash-text-dim)]">{c.desc}</div>
</li>
))}
</ul>
</Card>
{/* 风险热度研判 */}
<Card className="flex flex-col">
<CardTitle icon={Activity} title="风险热度研判(文字化)" />
<div className="flex-1 px-4 py-3.5">
<div className="mb-3 flex items-center justify-between rounded-md bg-red-50 px-3 py-2.5 dark:bg-red-500/10">
<span className="text-xs font-semibold text-slate-700 dark:text-[var(--dash-text-strong)]">总体风险等级</span>
<span className="inline-flex items-center gap-1 text-sm font-bold text-red-600">
<ArrowUp className="h-4 w-4" />
上升 1 级
</span>
</div>
<div className="mb-2 text-[11px] font-semibold text-red-600">主要风险点</div>
<ul className="space-y-2">
{RISK_POINTS.map((p) => (
<li key={p} className="flex gap-1.5 text-xs leading-snug text-slate-600 dark:text-[var(--dash-text)]">
<span className="mt-1.5 h-1 w-1 shrink-0 rounded-full bg-red-500" />
{p}
</li>
))}
</ul>
</div>
<FooterNote label="研判结论">{RISK_CONCLUSION}</FooterNote>
</Card>
</div>
</div>
);
}
// ── 真实数据渲染 ──────────────────────────────────────────────────────────────
function EnemyReal({ data }: { data: EnemyData }) {
return (
<div className="space-y-4 text-slate-700 dark:text-[var(--dash-text)]">
{data.targets.length > 0 && (
<Card>
<CardTitle icon={Target} title="敌方目标" badges={<HeadBadge label="目标数" value={`${data.targets.length}`} />} />
<div className="flex-1 px-4 py-3.5">
<Table head={["名称", "类别", "说明"]}>
{data.targets.map((t, i) => (
<tr key={i}>
<Td className="whitespace-nowrap font-semibold text-slate-800 dark:text-[var(--dash-text-strong)]">{t.name}</Td>
<Td className="whitespace-nowrap text-slate-500 dark:text-[var(--dash-text-sec)]">{t.category || "—"}</Td>
<Td className="text-slate-500 dark:text-[var(--dash-text-sec)]">{t.note || "—"}</Td>
</tr>
))}
</Table>
</div>
</Card>
)}
{data.narratives.length > 0 && (
<Card>
<CardTitle icon={Users} title="对手叙事" />
<div className="flex-1 px-4 py-3.5">
<Table head={["主题", "叙事方", "叙事意图", "类型", "概述"]}>
{data.narratives.map((n, i) => (
<tr key={i}>
<Td className="whitespace-nowrap font-semibold text-slate-800 dark:text-[var(--dash-text-strong)]">{n.topic || "—"}</Td>
<Td className="whitespace-nowrap text-slate-500 dark:text-[var(--dash-text-sec)]">{n.narrator || "—"}</Td>
<Td className="text-slate-500 dark:text-[var(--dash-text-sec)]">{n.intention || "—"}</Td>
<Td className="whitespace-nowrap text-slate-500 dark:text-[var(--dash-text-sec)]">{n.type || "—"}</Td>
<Td className="text-slate-500 dark:text-[var(--dash-text-sec)]">{n.summary || "—"}</Td>
</tr>
))}
</Table>
</div>
</Card>
)}
{data.events.length > 0 && (
<Card>
<CardTitle icon={CalendarClock} title="关键事件" />
{/* 时间线样式(参考示例图):左侧一条竖线,每个事件一个红点节点;时间在节点旁,
标题/摘要放进右侧浅底小卡。仅用已有字段(时间/标题/摘要),不加图片等无数据元素。 */}
<div className="space-y-5 px-4 py-3.5">
{data.events.map((g, i) => (
<div key={i}>
<div className="mb-3 text-xs font-bold text-slate-800 dark:text-[var(--dash-text-strong)]">{g.topic || "事件"}</div>
<ol className="relative ml-2 border-l-2 border-dashed border-slate-300 text-xs dark:border-[var(--dash-border)]">
{g.items.map((e, j) => (
<li key={j} className="mb-5 ml-5 last:mb-0">
<span className="absolute -left-[7px] mt-1 h-3 w-3 rounded-full border-2 border-red-400 bg-red-50 dark:bg-[var(--dash-card)]" />
{e.time && (
<div className="mb-1 font-mono text-[11px] text-slate-400 dark:text-[var(--dash-text-dim)]">{e.time}</div>
)}
<div className="rounded-md border border-slate-200 bg-slate-50 px-3 py-2.5 dark:border-[var(--dash-border)] dark:bg-[var(--dash-elev)]">
<div className="font-semibold text-slate-800 dark:text-[var(--dash-text-strong)]">{e.title}</div>
{e.brief && <div className="mt-1 leading-relaxed text-slate-500 dark:text-[var(--dash-text-sec)]">{e.brief}</div>}
</div>
</li>
))}
</ol>
</div>
))}
</div>
</Card>
)}
</div>
);
}
@@ -0,0 +1,384 @@
/**
* 「大屏绘制智能体」战场环境页 —— 信息环境 / 人文环境 / 综合研判(按设计图 dp-4.png 一比一实现)。
*
* **dashboard-agent 独占**:作为 DashboardApp `pages` 插槽里「战场环境」Tab 的内容,
* 不影响多智能体会商(Step3)大屏多页的样式。当前为「静态页面」,文案为占位内容,
* 动态数据后续接入。版式包含:
* 1. 顶部 5 个环境指数卡
* 2. 信息环境(5 栏维度卡:媒体生态 / 平台分布 / 基础设施 / 监管治理 / 受众习惯)
* 3. 人文环境(4 栏维度卡:社会 / 经济 / 舆论 / 文化)
* 4. 环境综合研判 + 信息来源与参考
*/
import {
Activity,
AlertTriangle,
Network,
AlertOctagon,
BarChart3,
Tv,
LayoutGrid,
Server,
ShieldCheck,
Users,
Building2,
Coins,
MessageSquare,
BookOpen,
Lightbulb,
FileText,
TrendingUp,
TrendingDown,
Minus,
type LucideIcon,
} from "lucide-react";
import { Card, CardTitle, FooterNote, Table, Td, Tag, NoData, useDesensitize, type Tone } from "@/roundtable-planning/dashboard-agent/ui";
import type { EnvData, EnvSection } from "@/roundtable-planning/dashboard-agent/sq-report";
// ── 占位数据 ──────────────────────────────────────────────────────────────────
type Trend = "up" | "down" | "flat";
interface Kpi {
label: string;
value: string;
icon: LucideIcon;
delta: string;
trend: Trend;
}
const KPIS: Kpi[] = [
{ label: "信息活跃度指数", value: "78.6", icon: Activity, delta: "+3.4", trend: "up" },
{ label: "舆论风险指数", value: "64.2", icon: AlertTriangle, delta: "+5.1", trend: "up" },
{ label: "网络连接通畅率", value: "58.7%", icon: Network, delta: "+2.3", trend: "up" },
{ label: "虚假信息占比", value: "23.6%", icon: AlertOctagon, delta: "-1.8", trend: "down" },
{ label: "关键平台覆盖率", value: "92.4%", icon: BarChart3, delta: "+0.6", trend: "up" },
];
interface EnvRow {
label: string;
value: string;
tone: Tone;
}
interface EnvField {
icon: LucideIcon;
title: string;
rows: EnvRow[];
assessLabel: string;
assessment: string;
}
const INFO_ENV: EnvField[] = [
{
icon: Tv,
title: "媒体生态分布",
rows: [
{ label: "主流媒体", value: "高", tone: "red" },
{ label: "自媒体", value: "高", tone: "red" },
{ label: "社交平台", value: "高", tone: "red" },
{ label: "境外媒体", value: "中", tone: "amber" },
{ label: "传统媒体", value: "中", tone: "amber" },
],
assessLabel: "总体评估",
assessment: "媒体生态多元,信息传播活跃。",
},
{
icon: LayoutGrid,
title: "平台分布",
rows: [
{ label: "即时通讯", value: "高", tone: "red" },
{ label: "社交媒体", value: "高", tone: "red" },
{ label: "视频平台", value: "高", tone: "red" },
{ label: "论坛社区", value: "中", tone: "amber" },
{ label: "新闻门户", value: "中", tone: "amber" },
],
assessLabel: "总体评估",
assessment: "平台覆盖广泛,传播路径多样。",
},
{
icon: Server,
title: "基础设施状况",
rows: [
{ label: "网络覆盖", value: "良好", tone: "green" },
{ label: "带宽容量", value: "充足", tone: "green" },
{ label: "移动网络", value: "良好", tone: "green" },
{ label: "数据中心", value: "稳定", tone: "green" },
{ label: "电力保障", value: "稳定", tone: "green" },
],
assessLabel: "总体评估",
assessment: "基础设施完备,保障能力强。",
},
{
icon: ShieldCheck,
title: "信息监管与治理",
rows: [
{ label: "内容审核", value: "较强", tone: "green" },
{ label: "谣言治理", value: "较强", tone: "green" },
{ label: "平台自律", value: "一般", tone: "slate" },
{ label: "跨境监管", value: "较弱", tone: "amber" },
],
assessLabel: "总体评估",
assessment: "治理体系较完善,执行力度持续提升。",
},
{
icon: Users,
title: "受众信息接触习惯",
rows: [
{ label: "移动端为主", value: "高", tone: "red" },
{ label: "短视频主导", value: "高", tone: "red" },
{ label: "信息甄别能力", value: "中", tone: "amber" },
{ label: "权威信息依赖", value: "中", tone: "amber" },
],
assessLabel: "总体评估",
assessment: "触达渠道广泛,注意力相对分散。",
},
];
const HUMAN_ENV: EnvField[] = [
{
icon: Building2,
title: "社会环境",
rows: [
{ label: "社会稳定度", value: "良好", tone: "green" },
{ label: "社会矛盾", value: "可控", tone: "amber" },
{ label: "民族宗教", value: "稳定", tone: "green" },
{ label: "城乡发展", value: "一般", tone: "slate" },
],
assessLabel: "评估结论",
assessment: "社会大局稳定,整体可控。",
},
{
icon: Coins,
title: "经济基础",
rows: [
{ label: "经济发展水平", value: "良好", tone: "green" },
{ label: "产业结构", value: "一般", tone: "slate" },
{ label: "就业形势", value: "一般", tone: "slate" },
{ label: "居民收入", value: "一般", tone: "slate" },
],
assessLabel: "评估结论",
assessment: "经济运行平稳,韧性较强。",
},
{
icon: MessageSquare,
title: "社会舆论",
rows: [
{ label: "主流舆论引导", value: "有效", tone: "green" },
{ label: "网络情绪", value: "一般", tone: "amber" },
{ label: "热点话题", value: "活跃", tone: "red" },
{ label: "意见多样性", value: "较强", tone: "amber" },
],
assessLabel: "评估结论",
assessment: "舆论总体平稳,热点多发。",
},
{
icon: BookOpen,
title: "文化环境",
rows: [
{ label: "文化认同", value: "较强", tone: "green" },
{ label: "价值观主流", value: "稳定", tone: "green" },
{ label: "文化多元", value: "一般", tone: "slate" },
{ label: "文化渗透风险", value: "中", tone: "amber" },
],
assessLabel: "评估结论",
assessment: "文化主流稳定,需防外来渗透。",
},
];
const REVIEW = [
"信息环境高度活跃,传播速度快、覆盖面广,主流舆论引导总体有效,但虚假信息与噪声并存。",
"网络基础设施完备、平台多元,为信息传播与舆论引导提供良好支撑,需防范境外渗透。",
"社会大局稳定,经济运行平稳,人文环境总体向好,为我方舆论工作提供有利条件。",
"综合研判:当前战场信息环境总体有利,须强化内容供给与精准引导,压缩虚假信息空间。",
];
interface SourceRow {
name: string;
type: string;
trust: string;
trustTone: Tone;
updated: string;
}
const SOURCES: SourceRow[] = [
{ name: "全网舆情监测平台", type: "舆情", trust: "高", trustTone: "green", updated: "2025-05-16 10:20" },
{ name: "媒体监测系统", type: "媒体", trust: "高", trustTone: "green", updated: "2025-05-16 10:00" },
{ name: "社交平台数据接口", type: "社交", trust: "中", trustTone: "amber", updated: "2025-05-16 09:40" },
{ name: "行业研究报告", type: "第三方", trust: "中", trustTone: "amber", updated: "2025-05-15 18:00" },
{ name: "公开统计数据", type: "官方", trust: "高", trustTone: "green", updated: "2025-05-14 16:00" },
];
const TREND_ICON: Record<Trend, LucideIcon> = { up: TrendingUp, down: TrendingDown, flat: Minus };
const TREND_COLOR: Record<Trend, string> = { up: "text-red-600", down: "text-emerald-600", flat: "text-slate-400" };
const VAL_COLOR: Record<Tone, string> = {
red: "text-red-600",
amber: "text-amber-600",
green: "text-emerald-600",
blue: "text-sky-600",
slate: "text-slate-400 dark:text-[var(--dash-text-dim)]",
};
// ── 主组件(有真实数据走真实渲染,否则回退静态占位) ─────────────────────────────
export function EnvironmentDetail({ data }: { data?: EnvData | null }) {
return data ? <EnvReal data={data} /> : <NoData />;
}
function EnvPlaceholder() {
return (
<div className="space-y-4 text-slate-700 dark:text-[var(--dash-text)]">
{/* 1. 顶部 5 个环境指数卡 */}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
{KPIS.map((k) => {
const TrendIco = TREND_ICON[k.trend];
return (
<Card key={k.label} className="p-4">
<div className="mb-3 flex items-center gap-2">
<span className="flex h-8 w-8 items-center justify-center rounded-md bg-red-50 text-red-600 dark:bg-red-500/15">
<k.icon className="h-4 w-4" />
</span>
<span className="text-xs font-medium text-slate-500 dark:text-[var(--dash-text-sec)]">{k.label}</span>
</div>
<div className="font-mono text-3xl font-black leading-none text-slate-900 dark:text-[var(--dash-text-strong)]">
{k.value}
</div>
<div className="mt-2 flex items-center gap-1 text-[11px] text-slate-400 dark:text-[var(--dash-text-dim)]">
<span>较昨日</span>
<span className={`inline-flex items-center gap-0.5 font-semibold ${TREND_COLOR[k.trend]}`}>
<TrendIco className="h-3 w-3" />
{k.delta}
</span>
</div>
</Card>
);
})}
</div>
{/* 2. 信息环境(5 栏) */}
<SectionLabel>信息环境</SectionLabel>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-5">
{INFO_ENV.map((f) => (
<EnvCard key={f.title} field={f} />
))}
</div>
{/* 3. 人文环境(4 栏) */}
<SectionLabel>人文环境</SectionLabel>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
{HUMAN_ENV.map((f) => (
<EnvCard key={f.title} field={f} />
))}
</div>
{/* 4. 环境综合研判 + 信息来源与参考 */}
<div className="grid grid-cols-1 gap-3 lg:grid-cols-12">
<Card className="flex flex-col lg:col-span-7">
<CardTitle icon={Lightbulb} title="环境综合研判" />
<ol className="flex-1 space-y-3 px-4 py-3.5">
{REVIEW.map((r, i) => (
<li key={r} className="flex gap-2.5 text-[13px] leading-relaxed">
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-red-600 text-[11px] font-bold text-white">
{i + 1}
</span>
<span className="text-slate-600 dark:text-[var(--dash-text)]">{r}</span>
</li>
))}
</ol>
</Card>
<Card className="flex flex-col lg:col-span-5">
<CardTitle icon={FileText} title="信息来源与参考" />
<div className="flex-1 px-4 py-3.5">
<Table head={["来源名称", "类型", "可信度", "更新时间"]}>
{SOURCES.map((s) => (
<tr key={s.name}>
<Td className="font-semibold text-slate-800 dark:text-[var(--dash-text-strong)]">{s.name}</Td>
<Td className="text-slate-500 dark:text-[var(--dash-text-sec)]">{s.type}</Td>
<Td>
<Tag tone={s.trustTone}>{s.trust}</Tag>
</Td>
<Td className="whitespace-nowrap font-mono text-slate-400">{s.updated}</Td>
</tr>
))}
</Table>
</div>
<FooterNote label="说明">可信度分为高 / 中 / 低,时效性以最新更新时间为准,综合研判时按权重折算。</FooterNote>
</Card>
</div>
</div>
);
}
/** 环境维度卡(红色标题 + 键值行 + 评估脚注)。 */
function EnvCard({ field }: { field: EnvField }) {
return (
<Card className="flex flex-col">
<CardTitle icon={field.icon} title={field.title} />
<div className="flex-1 px-4 py-3.5">
<dl className="space-y-2">
{field.rows.map((r) => (
<div key={r.label} className="flex items-center justify-between gap-2 text-xs">
<dt className="text-slate-400 dark:text-[var(--dash-text-dim)]">{r.label}</dt>
<dd className={`font-semibold ${VAL_COLOR[r.tone]}`}>{r.value}</dd>
</div>
))}
</dl>
</div>
<FooterNote label={field.assessLabel}>{field.assessment}</FooterNote>
</Card>
);
}
/** 行级区段标题(红色竖条 + 文字)。 */
function SectionLabel({ children }: { children: React.ReactNode }) {
const ds = useDesensitize();
return (
<div className="flex items-center gap-2 pt-1 text-sm font-bold text-slate-900 dark:text-[var(--dash-text-strong)]">
<span className="h-3.5 w-1 rounded-full bg-red-600" />
{typeof children === "string" ? ds(children) : children}
</div>
);
}
// ── 真实数据渲染 ──────────────────────────────────────────────────────────────
function EnvReal({ data }: { data: EnvData }) {
return (
<div className="space-y-4 text-slate-700 dark:text-[var(--dash-text)]">
{data.info.length > 0 && (
<>
<SectionLabel>信息环境</SectionLabel>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{data.info.map((s, i) => (
<EnvTextCard key={i} section={s} />
))}
</div>
</>
)}
{data.human.length > 0 && (
<>
<SectionLabel>人文环境</SectionLabel>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{data.human.map((s, i) => (
<EnvTextCard key={i} section={s} />
))}
</div>
</>
)}
</div>
);
}
/** 环境文本维度卡(标题 + 段落要点)。 */
function EnvTextCard({ section }: { section: EnvSection }) {
return (
<Card className="flex flex-col">
<CardTitle icon={FileText} title={section.title} />
<ul className="flex-1 space-y-2 px-4 py-3.5">
{section.paragraphs.map((p, i) => (
<li key={i} className="flex gap-2 text-xs leading-relaxed">
<span className="mt-1.5 h-1 w-1 shrink-0 rounded-full bg-red-500" />
<span className="text-slate-600 dark:text-[var(--dash-text)]">{p}</span>
</li>
))}
</ul>
</Card>
);
}
@@ -0,0 +1,365 @@
/**
* 「大屏绘制智能体」情况判断页 —— 敌我优劣 / 条件限制 / 风险应对 / 综合研判
* (按设计图 dp-5.png 一比一实现)。
*
* **dashboard-agent 独占**:作为 DashboardApp `pages` 插槽里「情况判断」Tab 的内容,
* 不影响多智能体会商(Step3)大屏多页的样式。当前为「静态页面」,文案为占位内容,
* 动态数据后续接入。版式包含:
* 1. 敌我优劣势分析(敌方优势/劣势 + 我方优势/劣势 四张要点卡)
* 2. 条件与限制(资源/法理/政治/时间 四张约束键值卡)
* 3. 风险与应对(风险表)+ 判断结论(综合研判)
*/
import { Swords, ShieldX, ShieldCheck, ShieldAlert, Package, Scale, Landmark, Clock, AlertTriangle, Gavel, type LucideIcon } from "lucide-react";
import { Card, CardTitle, FooterNote, Table, Td, Tag, Val, NoData, MarkdownText, useDesensitize, type Tone } from "@/roundtable-planning/dashboard-agent/ui";
import type { JudgeData } from "@/roundtable-planning/dashboard-agent/sq-report";
// ── 占位数据 ──────────────────────────────────────────────────────────────────
interface BulletCardData {
icon: LucideIcon;
title: string;
items: string[];
}
const SWOT: BulletCardData[] = [
{
icon: Swords,
title: "敌方优势",
items: [
"火力密度大,远程打击能力强",
"信息系统较完善,情报获取渠道多",
"机动能力强,可快速集结兵力",
"体系作战能力较强,协同较成熟",
],
},
{
icon: ShieldX,
title: "敌方劣势",
items: [
"地形适应性差,山地作战能力不足",
"部队协同较弱,指挥链路较长",
"兵力部署分散,防御纵深不足",
"后勤补给线长,维护保障压力大",
],
},
{
icon: ShieldCheck,
title: "我方优势",
items: [
"占据有利地形,防御体系完备",
"群众基础好,保障支援能力强",
"部队士气高昂,战斗意志坚定",
"熟悉地形地貌,机动灵活",
],
},
{
icon: ShieldAlert,
title: "我方劣势",
items: [
"远程火力相对不足,打击体系待完善",
"部分装备老化,信息化程度待提升",
"防空反导能力有限,制空压力较大",
"特定方向兵力薄弱,需重点加强",
],
},
];
interface KvRow {
label: string;
value: string;
tone: Tone;
}
interface ConstraintData {
icon: LucideIcon;
title: string;
rows: KvRow[];
footer: string;
}
const CONSTRAINTS: ConstraintData[] = [
{
icon: Package,
title: "资源约束",
rows: [
{ label: "弹药储备", value: "充足", tone: "green" },
{ label: "燃油保障", value: "适中", tone: "amber" },
{ label: "医疗资源", value: "紧张", tone: "red" },
{ label: "兵员补充", value: "适中", tone: "amber" },
{ label: "通信物资", value: "适中", tone: "amber" },
],
footer: "资源总体可支撑当前作战,医疗与持续保障需重点关注。",
},
{
icon: Scale,
title: "法理约束",
rows: [
{ label: "作战法理依据", value: "符合", tone: "green" },
{ label: "国际人道法", value: "合规", tone: "green" },
{ label: "平民保护责任", value: "严格", tone: "red" },
{ label: "舆论道义争取", value: "有利", tone: "green" },
],
footer: "行动总体合法合规,应在法理框架内规范实施。",
},
{
icon: Landmark,
title: "政治约束",
rows: [
{ label: "政治敏感度", value: "高", tone: "red" },
{ label: "对外关系影响", value: "中", tone: "amber" },
{ label: "国内舆论支持", value: "较高", tone: "green" },
{ label: "国际舆论环境", value: "一般", tone: "slate" },
],
footer: "政治考量需贯穿全程,注重舆论与外交配合。",
},
{
icon: Clock,
title: "时间约束",
rows: [
{ label: "现有作战窗口", value: "36 小时内", tone: "red" },
{ label: "兵力集结时限", value: "48 小时内", tone: "amber" },
{ label: "后勤展开时间", value: "72 小时内", tone: "amber" },
{ label: "上级决心时限", value: "60 小时内", tone: "amber" },
],
footer: "时间窗口偏紧,须加快决策与力量展开。",
},
];
interface RiskRow {
item: string;
level: string;
levelTone: Tone;
impact: string;
plan: string;
}
const RISKS: RiskRow[] = [
{ item: "敌火力突袭风险", level: "高", levelTone: "red", impact: "前沿阵地与人员装备损失", plan: "强化伪装防护与机动疏散" },
{ item: "敌增援兵力介入", level: "中", levelTone: "amber", impact: "局部兵力对比变化", plan: "预置阻援力量,封控通道" },
{ item: "通信干扰破坏", level: "中", levelTone: "amber", impact: "指挥效率下降", plan: "启用备份链路与抗扰手段" },
{ item: "网络攻击渗透", level: "高", levelTone: "red", impact: "信息系统受损", plan: "加固防护,分区隔离" },
{ item: "舆论造谣攻击", level: "低", levelTone: "green", impact: "局部认知影响", plan: "及时澄清,权威发声" },
];
interface StatBlock {
value: string;
caption: string;
}
const CONCLUSION_STATS: StatBlock[] = [
{ value: "我方占优", caption: "敌我对比" },
{ value: "中高", caption: "整体风险" },
{ value: "积极进取", caption: "决策倾向" },
];
const CONCLUSION_POINTS = [
"当前总体态势有利,敌方兵力行动受牵制。",
"我方占据地利与人和,具备掌握主动的条件。",
];
const CONCLUSION = "总体态势有利,敌方行动受牵制;建议积极把握主动,稳中求进、择机而动。";
// ── 主组件(有真实数据走真实渲染,否则回退静态占位) ─────────────────────────────
export function Judgement({ data }: { data?: JudgeData | null }) {
return data ? <JudgeReal data={data} /> : <NoData />;
}
function JudgePlaceholder() {
return (
<div className="space-y-4 text-slate-700 dark:text-[var(--dash-text)]">
{/* 1. 敌我优劣势分析 */}
<NumberedHeading n={1}>敌我优劣势分析</NumberedHeading>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
{SWOT.map((c) => (
<Card key={c.title} className="flex flex-col">
<CardTitle icon={c.icon} title={c.title} />
<ul className="flex-1 space-y-2 px-4 py-3.5">
{c.items.map((it) => (
<li key={it} className="flex gap-2 text-xs leading-relaxed">
<span className="mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-red-600" />
<span className="text-slate-600 dark:text-[var(--dash-text)]">{it}</span>
</li>
))}
</ul>
</Card>
))}
</div>
{/* 2. 条件与限制 */}
<NumberedHeading n={2}>条件与限制</NumberedHeading>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
{CONSTRAINTS.map((c) => (
<Card key={c.title} className="flex flex-col">
<CardTitle icon={c.icon} title={c.title} />
<div className="flex-1 px-4 py-3.5">
<dl className="space-y-2">
{c.rows.map((r) => (
<div key={r.label} className="flex items-center justify-between gap-2 text-xs">
<dt className="text-slate-400 dark:text-[var(--dash-text-dim)]">{r.label}</dt>
<dd>
<Val tone={r.tone}>{r.value}</Val>
</dd>
</div>
))}
</dl>
</div>
<FooterNote label="评估">{c.footer}</FooterNote>
</Card>
))}
</div>
{/* 3. 风险与应对 + 判断结论 */}
<NumberedHeading n={3}>风险与应对</NumberedHeading>
<div className="grid grid-cols-1 gap-3 lg:grid-cols-12">
{/* 风险与应对表 */}
<Card className="flex flex-col lg:col-span-7">
<CardTitle icon={AlertTriangle} title="风险与应对" />
<div className="flex-1 px-4 py-3.5">
<Table head={["风险事项", "等级", "可能影响", "应对计划"]}>
{RISKS.map((r) => (
<tr key={r.item}>
<Td className="whitespace-nowrap font-semibold text-slate-800 dark:text-[var(--dash-text-strong)]">{r.item}</Td>
<Td>
<Tag tone={r.levelTone}>{r.level}</Tag>
</Td>
<Td className="text-slate-500 dark:text-[var(--dash-text-sec)]">{r.impact}</Td>
<Td className="text-slate-500 dark:text-[var(--dash-text-sec)]">{r.plan}</Td>
</tr>
))}
</Table>
</div>
</Card>
{/* 判断结论(综合研判) */}
<Card className="flex flex-col lg:col-span-5">
<CardTitle icon={Gavel} title="判断结论(综合研判)" />
<div className="flex-1 px-4 py-3.5">
<div className="mb-3 grid grid-cols-3 gap-2">
{CONCLUSION_STATS.map((s) => (
<div key={s.caption} className="rounded-md bg-red-50 px-2 py-3 text-center dark:bg-red-500/10">
<div className="text-sm font-black text-red-600">{s.value}</div>
<div className="mt-1 text-[10px] text-slate-400 dark:text-[var(--dash-text-dim)]">{s.caption}</div>
</div>
))}
</div>
<ul className="space-y-2">
{CONCLUSION_POINTS.map((p) => (
<li key={p} className="flex gap-2 text-xs leading-relaxed">
<span className="mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-red-600" />
<span className="text-slate-600 dark:text-[var(--dash-text)]">{p}</span>
</li>
))}
</ul>
</div>
<FooterNote label="结论">{CONCLUSION}</FooterNote>
</Card>
</div>
</div>
);
}
/** 红色编号区段标题(① 敌我优劣势分析 …)。 */
function NumberedHeading({ n, children }: { n: number; children: React.ReactNode }) {
const ds = useDesensitize();
return (
<div className="flex items-center gap-2 pt-1">
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-red-600 text-[11px] font-bold text-white">
{n}
</span>
<h2 className="text-sm font-bold text-slate-900 dark:text-[var(--dash-text-strong)]">
{typeof children === "string" ? ds(children) : children}
</h2>
</div>
);
}
// ── 真实数据渲染 ──────────────────────────────────────────────────────────────
function SwotCard({ title, icon, items, dot }: { title: string; icon: LucideIcon; items: Array<{ title: string; content: string }>; dot: Tone }) {
const dotColor = dot === "green" ? "bg-emerald-500" : "bg-red-600";
return (
<Card className="flex flex-col">
<CardTitle icon={icon} title={title} />
<ul className="flex-1 space-y-2.5 px-4 py-3.5">
{items.map((it, i) => (
<li key={i} className="flex gap-2 text-xs leading-relaxed">
<span className={`mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full ${dotColor}`} />
<span className="text-slate-600 dark:text-[var(--dash-text)]">
{it.title && <span className="font-bold text-slate-800 dark:text-[var(--dash-text-strong)]">{it.title}:</span>}
{it.content}
</span>
</li>
))}
</ul>
</Card>
);
}
function JudgeReal({ data }: { data: JudgeData }) {
const hasSwot = data.strengths.length > 0 || data.weaknesses.length > 0;
const hasRisk = data.risks.length > 0 || data.responses.length > 0;
return (
<div className="space-y-4 text-slate-700 dark:text-[var(--dash-text)]">
{hasSwot && (
<>
<NumberedHeading n={1}>优劣势分析</NumberedHeading>
<div className="grid grid-cols-1 gap-3 lg:grid-cols-2">
{data.strengths.length > 0 && <SwotCard title="优势" icon={ShieldCheck} items={data.strengths} dot="green" />}
{data.weaknesses.length > 0 && <SwotCard title="劣势" icon={ShieldAlert} items={data.weaknesses} dot="red" />}
</div>
</>
)}
{data.constraints.length > 0 && (
<>
<NumberedHeading n={2}>条件与限制</NumberedHeading>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
{data.constraints.map((c, i) => (
<Card key={i} className="flex flex-col">
<CardTitle icon={Scale} title={c.title} />
<ul className="flex-1 space-y-2 px-4 py-3.5">
{c.items.map((it, j) => (
<li key={j} className="flex gap-2 text-xs leading-relaxed">
<span className="mt-1.5 h-1 w-1 shrink-0 rounded-full bg-red-500" />
<span className="text-slate-600 dark:text-[var(--dash-text)]">{it}</span>
</li>
))}
</ul>
</Card>
))}
</div>
</>
)}
{hasRisk && (
<>
<NumberedHeading n={3}>风险与应对</NumberedHeading>
<div className="grid grid-cols-1 gap-3 lg:grid-cols-2">
{data.risks.length > 0 && (
<Card className="flex flex-col">
<CardTitle icon={AlertTriangle} title="风险分析" />
<ul className="flex-1 space-y-2.5 px-4 py-3.5">
{data.risks.map((r, i) => (
<li key={i} className="flex gap-2 text-xs leading-relaxed">
<span className="mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-red-600" />
<span className="text-slate-600 dark:text-[var(--dash-text)]">{r}</span>
</li>
))}
</ul>
</Card>
)}
{data.responses.length > 0 && (
<Card className="flex flex-col">
<CardTitle icon={Gavel} title="应对方案" />
<ol className="flex-1 space-y-2.5 px-4 py-3.5">
{data.responses.map((r, i) => (
<li key={i} className="flex gap-2 text-xs leading-relaxed">
<span className="mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center rounded-full bg-red-600 text-[10px] font-bold text-white">{i + 1}</span>
<div className="min-w-0 flex-1 text-slate-600 dark:text-[var(--dash-text)]">
<MarkdownText>{r}</MarkdownText>
</div>
</li>
))}
</ol>
</Card>
)}
</div>
</>
)}
</div>
);
}
@@ -0,0 +1,332 @@
/**
* 「大屏绘制智能体」我情页 —— 我方口径 / 叙事 / 任务版式(按设计图 dp-3.png 一比一实现)。
*
* **dashboard-agent 独占**:作为 DashboardApp `pages` 插槽里「我情」Tab 的内容,
* 不影响多智能体会商(Step3)大屏多页的样式。当前为「静态页面」,文案为占位内容,
* 动态数据后续接入。版式包含:
* 1. 口径 + 叙事 + 上级要求(上排三栏)
* 2. 重点发布日历 + 口径关键词 + 传播任务分工速览(下排三栏)
*/
import { MessageSquareQuote, BookText, Star, CalendarDays, Tags, Megaphone } from "lucide-react";
import {
Card,
CardTitle,
CardFooterLink,
Tag,
Table,
Td,
NoData,
useDesensitize,
type Tone,
} from "@/roundtable-planning/dashboard-agent/ui";
import type { OursData } from "@/roundtable-planning/dashboard-agent/sq-report";
// ── 占位数据 ──────────────────────────────────────────────────────────────────
const TONE_META: Array<{ label: string; value: string }> = [
{ label: "创建时间", value: "2025-05-16 10:00" },
{ label: "适用范围", value: "对外宣传、媒体回应、公开发布" },
{ label: "发布层级", value: "公开" },
];
const TONE_POINTS = [
"坚定维护国家主权、安全与领土完整,立场坚定不移。",
"我方始终致力于维护地区和平稳定与共同发展。",
"任何外部势力干涉地区事务的行径都不得人心。",
"呼吁有关各方保持克制,通过对话协商解决分歧。",
];
interface NarrativeRow {
no: number;
title: string;
point: string;
time: string;
}
const NARRATIVES: NarrativeRow[] = [
{ no: 1, title: "维护正当权益", point: "正当维权行为合法合理", time: "05-16 10:00" },
{ no: 2, title: "致力地区和平", point: "始终是和平稳定的建设者", time: "05-16 09:00" },
{ no: 3, title: "倡导对话合作", point: "反对集团对抗与零和博弈", time: "05-15 18:30" },
{ no: 4, title: "揭批外部干涉", point: "外部势力是紧张根源", time: "05-15 14:00" },
];
const ORDERS = [
"坚定政治方向,主动发声,紧贴宣传节奏。",
"强化媒体引导,及时回应社会关切。",
"严守宣传纪律,确保信息口径统一。",
"加强协同联动,形成对外宣传合力。",
];
const ORDER_META: Array<{ label: string; value: string }> = [
{ label: "下发时间", value: "2025-05-16 08:00" },
{ label: "下发单位", value: "联合作战指挥部宣传工作组" },
];
interface CalendarItem {
time: string;
event: string;
status: string;
tone: Tone;
}
const TODAY_PLAN: CalendarItem[] = [
{ time: "09:00", event: "例行新闻发布会", status: "已发布", tone: "green" },
{ time: "14:00", event: "重点新闻通稿", status: "待发布", tone: "amber" },
{ time: "18:00", event: "对外答问口径", status: "待发布", tone: "amber" },
];
const UPCOMING: CalendarItem[] = [
{ time: "05-17 09:00", event: "专题新闻发布会", status: "待发布", tone: "amber" },
{ time: "05-18 10:00", event: "白皮书发布", status: "筹备中", tone: "slate" },
];
interface KeywordGroup {
name: string;
tone: Tone;
words: string[];
}
const KEYWORDS: KeywordGroup[] = [
{ name: "主权安全", tone: "red", words: ["主权", "领土完整", "国家安全", "核心利益"] },
{ name: "和平发展", tone: "green", words: ["和平", "稳定", "合作", "共同发展"] },
{ name: "立场态度", tone: "blue", words: ["坚决", "克制", "正当", "合法合理"] },
];
interface TaskRow {
dept: string;
task: string;
deadline: string;
}
const TASKS: TaskRow[] = [
{ dept: "新闻发布组", task: "组织发布会与新闻通稿", deadline: "当日" },
{ dept: "媒体协调组", task: "协调媒体报道口径", deadline: "持续" },
{ dept: "网络宣传组", task: "网络平台内容推送与监看", deadline: "实时" },
{ dept: "对外传播组", task: "多语种对外发声", deadline: "当日" },
];
// ── 主组件(有真实数据走真实渲染,否则回退静态占位) ─────────────────────────────
export function OurSituation({ data }: { data?: OursData | null }) {
return data ? <OursReal data={data} /> : <NoData />;
}
function OursPlaceholder() {
return (
<div className="space-y-4 text-slate-700 dark:text-[var(--dash-text)]">
{/* 1. 上排:口径 + 叙事 + 上级要求 */}
<div className="grid grid-cols-1 gap-3 lg:grid-cols-3">
{/* 口径 */}
<Card className="flex flex-col">
<CardTitle icon={MessageSquareQuote} title="口径" />
<div className="flex-1 px-4 py-3.5">
<dl className="mb-3 space-y-1.5">
{TONE_META.map((m) => (
<div key={m.label} className="flex gap-2 text-[11px]">
<dt className="shrink-0 text-slate-400 dark:text-[var(--dash-text-dim)]">{m.label}</dt>
<dd className="text-slate-600 dark:text-[var(--dash-text)]">{m.value}</dd>
</div>
))}
</dl>
<ul className="space-y-2 border-t border-slate-100 pt-3 dark:border-[var(--dash-border)]">
{TONE_POINTS.map((p) => (
<li key={p} className="flex gap-2 text-xs leading-relaxed">
<span className="mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-red-600" />
<span className="text-slate-600 dark:text-[var(--dash-text)]">{p}</span>
</li>
))}
</ul>
</div>
<CardFooterLink>查看完整口径</CardFooterLink>
</Card>
{/* 叙事 */}
<Card className="flex flex-col">
<CardTitle icon={BookText} title="叙事" />
<div className="flex-1 px-4 py-3.5">
<Table head={["编号", "标题", "核心要点", "更新时间"]}>
{NARRATIVES.map((n) => (
<tr key={n.no}>
<Td className="font-mono text-slate-400">{n.no}</Td>
<Td className="whitespace-nowrap font-semibold text-slate-800 dark:text-[var(--dash-text-strong)]">{n.title}</Td>
<Td className="text-slate-500 dark:text-[var(--dash-text-sec)]">{n.point}</Td>
<Td className="whitespace-nowrap font-mono text-slate-400">{n.time}</Td>
</tr>
))}
</Table>
</div>
<CardFooterLink>查看更多叙事</CardFooterLink>
</Card>
{/* 上级要求 */}
<Card className="flex flex-col">
<CardTitle icon={Star} title="上级要求" />
<ol className="flex-1 space-y-2.5 px-4 py-3.5">
{ORDERS.map((o, i) => (
<li key={o} className="flex gap-2 text-xs leading-relaxed">
<span className="flex h-4 w-4 shrink-0 items-center justify-center rounded-full bg-red-600 text-[10px] font-bold text-white">
{i + 1}
</span>
<span className="text-slate-600 dark:text-[var(--dash-text)]">{o}</span>
</li>
))}
</ol>
<dl className="mx-4 mb-3.5 space-y-1.5 rounded bg-slate-50 px-3 py-2.5 dark:bg-white/5">
{ORDER_META.map((m) => (
<div key={m.label} className="flex gap-2 text-[11px]">
<dt className="shrink-0 text-slate-400 dark:text-[var(--dash-text-dim)]">{m.label}</dt>
<dd className="text-slate-600 dark:text-[var(--dash-text)]">{m.value}</dd>
</div>
))}
</dl>
</Card>
</div>
{/* 2. 下排:重点发布日历 + 口径关键词 + 传播任务分工速览 */}
<div className="grid grid-cols-1 gap-3 lg:grid-cols-3">
{/* 重点发布日历 */}
<Card className="flex flex-col">
<CardTitle icon={CalendarDays} title="重点发布日历(05-16)" />
<div className="flex-1 space-y-3 px-4 py-3.5">
<CalendarSection title="今日重点发布安排" items={TODAY_PLAN} />
<CalendarSection title="近期重要安排" items={UPCOMING} />
</div>
<CardFooterLink>查看完整日历</CardFooterLink>
</Card>
{/* 口径关键词 */}
<Card className="flex flex-col">
<CardTitle icon={Tags} title="口径关键词" />
<div className="flex-1 space-y-3 px-4 py-3.5">
{KEYWORDS.map((g) => (
<div key={g.name}>
<div className="mb-1.5 text-[11px] font-semibold text-slate-400 dark:text-[var(--dash-text-dim)]">
{g.name}
</div>
<div className="flex flex-wrap gap-1.5">
{g.words.map((w) => (
<Tag key={w} tone={g.tone}>
{w}
</Tag>
))}
</div>
</div>
))}
</div>
<CardFooterLink>查看更多关键词</CardFooterLink>
</Card>
{/* 传播任务分工速览 */}
<Card className="flex flex-col">
<CardTitle icon={Megaphone} title="传播任务分工速览" />
<div className="flex-1 px-4 py-3.5">
<Table head={["部门", "主要任务", "完成时限"]}>
{TASKS.map((t) => (
<tr key={t.dept}>
<Td className="whitespace-nowrap font-semibold text-slate-800 dark:text-[var(--dash-text-strong)]">{t.dept}</Td>
<Td className="text-slate-500 dark:text-[var(--dash-text-sec)]">{t.task}</Td>
<Td className="whitespace-nowrap text-slate-500 dark:text-[var(--dash-text-sec)]">{t.deadline}</Td>
</tr>
))}
</Table>
</div>
<CardFooterLink>查看完整分工</CardFooterLink>
</Card>
</div>
</div>
);
}
/** 发布日历小节(标题 + 时间/事件/状态行)。 */
function CalendarSection({ title, items }: { title: string; items: CalendarItem[] }) {
return (
<div>
<div className="mb-1.5 text-[11px] font-semibold text-red-600">{title}</div>
<ul className="space-y-1.5">
{items.map((it) => (
<li key={`${it.time}-${it.event}`} className="flex items-center gap-2 text-xs">
<span className="w-20 shrink-0 font-mono text-slate-400 dark:text-[var(--dash-text-dim)]">{it.time}</span>
<span className="flex-1 truncate text-slate-600 dark:text-[var(--dash-text)]">{it.event}</span>
<Tag tone={it.tone}>{it.status}</Tag>
</li>
))}
</ul>
</div>
);
}
// ── 真实数据渲染 ──────────────────────────────────────────────────────────────
function OursReal({ data }: { data: OursData }) {
const ds = useDesensitize();
const keywords = Array.from(new Set(data.narratives.flatMap((n) => n.tags))).slice(0, 24);
return (
<div className="space-y-4 text-slate-700 dark:text-[var(--dash-text)]">
<div className="grid grid-cols-1 gap-3 lg:grid-cols-12">
{/* 口径 */}
{data.speeches.length > 0 && (
<Card className="flex flex-col lg:col-span-8">
<CardTitle icon={MessageSquareQuote} title="口径" />
<ul className="flex-1 space-y-3 px-4 py-3.5">
{data.speeches.map((s, i) => (
<li key={i} className="border-b border-slate-50 pb-3 last:border-0 last:pb-0 dark:border-[var(--dash-border)]">
<div className="mb-1 flex items-start justify-between gap-2">
<span className="text-xs font-bold text-slate-800 dark:text-[var(--dash-text-strong)]">{s.title || "口径"}</span>
{s.time && <span className="shrink-0 whitespace-nowrap font-mono text-[10px] text-slate-400 dark:text-[var(--dash-text-dim)]">{s.time}</span>}
</div>
{s.summary && <p className="line-clamp-3 text-xs leading-relaxed text-slate-600 dark:text-[var(--dash-text)]">{s.summary}</p>}
{s.direction && (
<p className="mt-1 line-clamp-2 text-[11px] leading-snug text-slate-400 dark:text-[var(--dash-text-dim)]">
<span className="font-semibold text-red-600">{ds("导向")}:</span>
{s.direction}
</p>
)}
</li>
))}
</ul>
</Card>
)}
{/* 上级要求 */}
{data.orders.length > 0 && (
<Card className="flex flex-col lg:col-span-4">
<CardTitle icon={Star} title="上级要求" />
<ol className="flex-1 space-y-2.5 px-4 py-3.5">
{data.orders.map((o, i) => (
<li key={i} className="flex gap-2 text-xs leading-relaxed">
<span className="flex h-4 w-4 shrink-0 items-center justify-center rounded-full bg-red-600 text-[10px] font-bold text-white">{i + 1}</span>
<span className="text-slate-600 dark:text-[var(--dash-text)]">{o}</span>
</li>
))}
</ol>
</Card>
)}
</div>
{/* 叙事 */}
{data.narratives.length > 0 && (
<Card>
<CardTitle icon={BookText} title="叙事" />
<ul className="space-y-3 px-4 py-3.5">
{data.narratives.map((n, i) => (
<li key={i} className="border-b border-slate-50 pb-3 last:border-0 last:pb-0 dark:border-[var(--dash-border)]">
<div className="mb-1 flex flex-wrap items-center gap-1.5">
<span className="text-xs font-bold text-slate-800 dark:text-[var(--dash-text-strong)]">{n.title || "叙事"}</span>
{n.tags.map((t) => (
<Tag key={t} tone="blue">{t}</Tag>
))}
</div>
{n.content && <p className="text-xs leading-relaxed text-slate-600 dark:text-[var(--dash-text)]">{n.content}</p>}
</li>
))}
</ul>
</Card>
)}
{/* 口径关键词(取自叙事标签) */}
{keywords.length > 0 && (
<Card>
<CardTitle icon={Tags} title="口径关键词" />
<div className="flex flex-wrap gap-1.5 px-4 py-3.5">
{keywords.map((w) => (
<Tag key={w} tone="red">{w}</Tag>
))}
</div>
</Card>
)}
</div>
);
}
@@ -0,0 +1,232 @@
/**
* 「大屏绘制智能体」总览页 —— **由六类战情数据聚合**而成(敌情/我情/战场环境/
* 情况判断/决心建议/处置建议),版式参考设计图 dp-1.png。
*
* **dashboard-agent 独占组件**:作为 DashboardApp `pages` 插槽里「总览」Tab 的内容,
* **不影响多智能体会商(Step3)大屏多页的样式**。
*
* 数据来自 `parseSqReport` 的整份 `SqReport`:
* - 顶部指标 = 各类目数量;关键战情摘要 / 三栏概览 / 判断·决策·处置 = 各类目要点提炼。
* - 接口返回为空(无任何类目)→ 显示「暂无数据」。
*/
import {
Target,
Zap,
Megaphone,
BookText,
ShieldAlert,
FileText,
Crosshair,
Shield,
Mountain,
Lightbulb,
Flag,
type LucideIcon,
} from "lucide-react";
import { Card, CardTitle, FooterNote, NoData, useDesensitize } from "@/roundtable-planning/dashboard-agent/ui";
import type { SqReport } from "@/roundtable-planning/dashboard-agent/sq-report";
// ── 聚合 ──────────────────────────────────────────────────────────────────────
function eventCount(r: SqReport): number {
return (r.enemy?.events ?? []).reduce((s, g) => s + (g.count || g.items.length || 0), 0);
}
interface OverviewColumn {
title: string;
icon: LucideIcon;
rows: Array<{ label: string; value: string }>;
focusTitle: string;
focus: string[];
}
// ── 主组件 ────────────────────────────────────────────────────────────────────
export function SituationOverview({ data }: { data?: SqReport | null }) {
const ds = useDesensitize();
const r = data;
const hasAny =
!!r && (!!r.enemy || !!r.ours || !!r.environment || !!r.judgement || !!r.decision || !!r.disposal);
if (!hasAny) return <NoData />;
// 顶部指标(各类目数量)
const metrics: Array<{ label: string; value: number; icon: LucideIcon }> = [
{ label: "敌方目标", value: r!.enemy?.targets.length ?? 0, icon: Target },
{ label: "关键事件", value: eventCount(r!), icon: Zap },
{ label: "对手叙事", value: r!.enemy?.narratives.length ?? 0, icon: Megaphone },
{ label: "我方口径", value: r!.ours?.speeches.length ?? 0, icon: BookText },
{ label: "风险项", value: r!.judgement?.risks.length ?? 0, icon: ShieldAlert },
{ label: "处置建议", value: r!.disposal?.items.length ?? 0, icon: FileText },
];
// 关键战情摘要(取各类目最有代表性的一句)
const summary: Array<{ label: string; text: string }> = [
r!.decision?.summary && { label: "决心研判", text: r!.decision.summary },
r!.disposal?.summary && { label: "处置研判", text: r!.disposal.summary },
r!.enemy?.narratives[0]?.summary && { label: "对手叙事", text: r!.enemy!.narratives[0].summary },
r!.judgement?.risks[0] && { label: "主要风险", text: r!.judgement!.risks[0] },
r!.ours?.speeches[0]?.summary && { label: "我方口径", text: r!.ours!.speeches[0].summary },
].filter(Boolean) as Array<{ label: string; text: string }>;
// 三栏概览
const columns: OverviewColumn[] = [];
if (r!.enemy) {
columns.push({
title: "敌情概览",
icon: Crosshair,
rows: [
{ label: "敌方目标", value: `${r!.enemy.targets.length} 个` },
{ label: "关键事件", value: `${eventCount(r!)} 起` },
{ label: "对手叙事", value: `${r!.enemy.narratives.length} 条` },
],
focusTitle: "重点事件",
focus: r!.enemy.events.map((e) => e.topic).filter(Boolean).slice(0, 3),
});
}
if (r!.ours) {
columns.push({
title: "我情概览",
icon: Shield,
rows: [
{ label: "口径", value: `${r!.ours.speeches.length} 条` },
{ label: "叙事", value: `${r!.ours.narratives.length} 条` },
{ label: "上级要求", value: `${r!.ours.orders.length} 项` },
],
focusTitle: "口径要点",
focus: r!.ours.speeches.map((s) => s.title).filter(Boolean).slice(0, 3),
});
}
if (r!.environment) {
columns.push({
title: "战场环境概览",
icon: Mountain,
rows: [
{ label: "信息环境", value: `${r!.environment.info.length} 维度` },
{ label: "人文环境", value: `${r!.environment.human.length} 维度` },
],
focusTitle: "环境维度",
focus: [...r!.environment.info, ...r!.environment.human].map((s) => s.title).slice(0, 4),
});
}
const judgePoints = (r!.judgement?.risks ?? []).slice(0, 3);
const decisionPoints = (r!.decision?.objectives ?? []).slice(0, 3);
const disposalPoints = (r!.disposal?.items ?? []).map((i) => i.content).filter(Boolean).slice(0, 3);
return (
<div className="space-y-4 text-slate-700 dark:text-[var(--dash-text)]">
{/* 1. 顶部指标 */}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">
{metrics.map((m) => (
<Card key={m.label} className="p-4">
<div className="mb-3 flex items-center gap-2">
<span className="flex h-8 w-8 items-center justify-center rounded-md bg-red-50 text-red-600 dark:bg-red-500/15">
<m.icon className="h-4 w-4" />
</span>
<span className="text-xs font-medium text-slate-500 dark:text-[var(--dash-text-sec)]">{ds(m.label)}</span>
</div>
<div className="font-mono text-3xl font-black leading-none text-slate-900 dark:text-[var(--dash-text-strong)]">{m.value}</div>
</Card>
))}
</div>
{/* 2. 关键战情摘要 */}
{summary.length > 0 && (
<Card>
<CardTitle icon={Megaphone} title="关键战情摘要" />
<ul className="space-y-3 px-4 py-3.5">
{summary.map((s) => (
<li key={s.label} className="flex gap-2.5 text-[13px] leading-relaxed">
<span className="mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-red-600" />
<p className="text-slate-600 dark:text-[var(--dash-text)]">
<span className="font-bold text-red-600">{ds(s.label)}:</span>
{s.text}
</p>
</li>
))}
</ul>
</Card>
)}
{/* 3. 三栏概览 */}
{columns.length > 0 && (
<div className="grid grid-cols-1 gap-3 lg:grid-cols-3">
{columns.map((col) => (
<Card key={col.title} className="flex flex-col">
<CardTitle icon={col.icon} title={col.title} />
<div className="grid flex-1 grid-cols-1 gap-x-4 gap-y-3 px-4 py-3.5 sm:grid-cols-2">
<dl className="space-y-2">
{col.rows.map((row) => (
<div key={row.label} className="flex items-baseline justify-between gap-2 text-xs">
<dt className="shrink-0 text-slate-400 dark:text-[var(--dash-text-dim)]">{ds(row.label)}</dt>
<dd className="text-right font-medium text-slate-800 dark:text-[var(--dash-text-strong)]">{row.value}</dd>
</div>
))}
</dl>
{col.focus.length > 0 && (
<div className="border-t border-dashed border-slate-100 pt-3 dark:border-[var(--dash-border)] sm:border-l sm:border-t-0 sm:pl-4 sm:pt-0">
<div className="mb-2 text-[11px] font-semibold text-red-600">{ds(col.focusTitle)}</div>
<ul className="space-y-1.5">
{col.focus.map((f, i) => (
<li key={i} className="flex gap-1.5 text-xs leading-snug text-slate-600 dark:text-[var(--dash-text)]">
<span className="mt-1.5 h-1 w-1 shrink-0 rounded-full bg-red-500" />
{f}
</li>
))}
</ul>
</div>
)}
</div>
</Card>
))}
</div>
)}
{/* 4. 情况判断 / 决策建议 / 处置建议 */}
{(judgePoints.length > 0 || decisionPoints.length > 0 || disposalPoints.length > 0) && (
<div className="grid grid-cols-1 gap-3 lg:grid-cols-3">
{judgePoints.length > 0 && (
<SummaryCard icon={Lightbulb} title="情况判断" points={judgePoints} footerLabel="风险研判" footer={r!.judgement?.responses[0]} />
)}
{decisionPoints.length > 0 && (
<SummaryCard icon={Flag} title="决策建议" points={decisionPoints} footerLabel="决心重点" footer={r!.decision?.taskTitle} />
)}
{disposalPoints.length > 0 && (
<SummaryCard icon={FileText} title="处置建议" points={disposalPoints} footerLabel="处置目标" footer={r!.disposal?.goal} />
)}
</div>
)}
</div>
);
}
/** 底部要点卡(编号要点 + 可选脚注)。 */
function SummaryCard({
icon,
title,
points,
footerLabel,
footer,
}: {
icon: LucideIcon;
title: string;
points: string[];
footerLabel: string;
footer?: string;
}) {
return (
<Card className="flex flex-col">
<CardTitle icon={icon} title={title} />
<ol className="flex-1 space-y-2.5 px-4 py-3.5">
{points.map((p, i) => (
<li key={i} className="flex gap-2 text-xs leading-relaxed">
<span className="flex h-4 w-4 shrink-0 items-center justify-center rounded-full bg-red-600 text-[10px] font-bold text-white">{i + 1}</span>
<span className="text-slate-600 dark:text-[var(--dash-text)]">{p}</span>
</li>
))}
</ol>
{footer && <FooterNote label={footerLabel}>{footer}</FooterNote>}
</Card>
);
}
@@ -0,0 +1,326 @@
/**
* 「大屏绘制智能体」战情报告(selectSqReport)→ 各页面展示模型 的适配器。
*
* 接口经 `fetchDashboardInputJson` → `extractSqReport` 后得到:
* `{ taskId, source, categories: { [类目]: 反序列化内容 } }`
* 类目键(categoryType 去掉「 JSON」后缀):敌情目标 / 敌情关键事件 / 敌情对手叙事 /
* 我情口径 / 我情叙事 / 我情上级要求 / 信息环境 / 人文环境 / 优劣势分析 /
* 条件与限制 / 风险及应对 / 决心建议 / 处置建议 / 行动方案。
*
* 本模块把这些**异构、自由文本**的内容防御式地适配成每个页面要用的结构化模型;
* 任一类目缺失/为空 → 对应页面 data 为 null,页面回退到静态占位(先做对接,编辑后续再说)。
*/
// ── 防御式取值 ────────────────────────────────────────────────────────────────
function asArray(v: unknown): unknown[] {
return Array.isArray(v) ? v : [];
}
function asObj(v: unknown): Record<string, unknown> {
return v && typeof v === "object" && !Array.isArray(v) ? (v as Record<string, unknown>) : {};
}
function asStr(v: unknown): string {
if (typeof v === "string") return v;
if (v == null) return "";
if (typeof v === "number" || typeof v === "boolean") return String(v);
return "";
}
function strArr(v: unknown): string[] {
return asArray(v).map(asStr).filter((s) => s.trim().length > 0);
}
/**
* 过滤掉自由文本里的「引用角标」标记,做得**通用**:
* - 独立角标:`[[1]]`、`[[12]]`、`[[1,3]]` 等(方括号里任意非括号内容);
* - 带中文标签:`资料[[1]]`、`来源[[2]]`、`参考[[3]]` …;
* - 括号包裹:`(资料[[1]])`、`(来源[[12]])`、`([[3]])` …(中英文括号)。
* 删除后再清理残留的空括号与多余空白。用于所有展示文本(解析末尾整树清洗)。
*/
function stripCitations(s: string): string {
if (!s) return s;
return s
// 1) 括号包裹且内部含 [[..]] 的整体删除(不跨越其它括号)
.replace(/[((][^(())]*?\[\[[^[\]]+\]\][^(())]*?[))]/g, "")
// 2) 可选中文标签 + [[..]],或独立 [[..]]
.replace(/(?:资料|来源|参考|出处|引用|文献|注|脚注)?\s*\[\[[^[\]]+\]\]/g, "")
// 3) 残留空括号 + 多余空白
.replace(/[((]\s*[))]/g, "")
.replace(/[ \t]{2,}/g, " ")
.trim();
}
/** 深度清洗:递归把对象/数组里所有字符串过一遍 {@link stripCitations},返回新值。 */
function deepStripCitations<T>(value: T): T {
if (typeof value === "string") return stripCitations(value) as unknown as T;
if (Array.isArray(value)) return value.map(deepStripCitations) as unknown as T;
if (value && typeof value === "object") {
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(value as Record<string, unknown>)) out[k] = deepStripCitations(v);
return out as T;
}
return value;
}
/** 取 categories 容器(输入即 fetchDashboardInputJson 的返回)。 */
export function getSqCategories(input: unknown): Record<string, unknown> | null {
const o = asObj(input);
const cats = asObj(o.categories);
return Object.keys(cats).length > 0 ? cats : null;
}
// ── 各页面展示模型 ────────────────────────────────────────────────────────────
export interface EnemyData {
targets: Array<{ name: string; category: string; note: string }>;
events: Array<{ topic: string; count: number; items: Array<{ time: string; title: string; brief: string }> }>;
narratives: Array<{ topic: string; narrator: string; intention: string; type: string; summary: string }>;
}
export interface OursData {
speeches: Array<{ title: string; summary: string; direction: string; time: string }>;
narratives: Array<{ title: string; intent: string; tags: string[]; content: string }>;
orders: string[];
}
export interface EnvSection {
title: string;
paragraphs: string[];
}
export interface EnvData {
info: EnvSection[];
human: EnvSection[];
}
export interface JudgeData {
strengths: Array<{ title: string; content: string }>;
weaknesses: Array<{ title: string; content: string }>;
constraints: Array<{ title: string; items: string[] }>;
risks: string[];
responses: string[];
}
export interface DecisionData {
taskTitle: string;
taskIntro: string;
objectives: string[];
targets: Array<{ name: string; desc: string; reason: string; impact: string; basis: string }>;
keyPoints: string[];
summary: string;
}
export interface DisposalData {
goal: string;
summary: string;
items: Array<{ content: string; method: string; tone: string; effect: string; subject: string; basis: string }>;
}
export interface SqReport {
enemy: EnemyData | null;
ours: OursData | null;
environment: EnvData | null;
judgement: JudgeData | null;
decision: DecisionData | null;
disposal: DisposalData | null;
}
// ── 适配函数(每个返回 null 表示该类目无可用数据) ─────────────────────────────
function adaptEnemy(cats: Record<string, unknown>): EnemyData | null {
// 敌方目标:组织 + 人物实体
const tgt = asObj(cats["敌情目标"]);
const targets = [...asArray(tgt.organization), ...asArray(tgt.human)]
.map((e) => {
const o = asObj(e);
return { name: asStr(o.name), category: asStr(o.text) || asStr(o.etype), note: asStr(o.reason) };
})
.filter((t) => t.name);
// 关键事件:按 topic 分组
const events = asArray(cats["敌情关键事件"])
.map((g) => {
const o = asObj(g);
const items = asArray(o.events)
.map((ev) => {
const e = asObj(ev);
return { time: asStr(e.date) || asStr(e.publish_time), title: asStr(e.title), brief: asStr(e.content) };
})
.filter((i) => i.title);
return { topic: asStr(o.topic), count: typeof o.count === "number" ? o.count : items.length, items };
})
.filter((g) => g.topic || g.items.length > 0);
// 对手叙事
const narratives = asArray(cats["敌情对手叙事"])
.map((n) => {
const o = asObj(n);
return {
topic: asStr(o.topic) || asStr(o.event_name),
narrator: asStr(o.narrator) || asStr(o.entity),
intention: asStr(o.intention) || asStr(o.intent_of_the_opponent),
type: asStr(o.topicType) || asStr(o.narrative_topic_types),
summary: asStr(o.summary) || asStr(o.event_summary),
};
})
.filter((n) => n.topic || n.summary);
if (targets.length === 0 && events.length === 0 && narratives.length === 0) return null;
return { targets, events, narratives };
}
function adaptOurs(cats: Record<string, unknown>): OursData | null {
const speech = asObj(cats["我情口径"]);
const speeches = [
...asArray(speech.selfSpeech).map((s) => {
const o = asObj(s);
return {
title: asStr(o.trendSpeechTitle) || asStr(o.trendSpeechTitleMini),
summary: asStr(o.trendSpeechLike) || asStr(o.trendSpeechContent),
direction: asStr(o.narrativeDirection),
time: asStr(o.trendSpeechTime),
};
}),
...asArray(speech.selfConsistents).map((s) => {
const o = asObj(s);
return {
title: asStr(o.trendConsistentTitleMini) || asStr(o.trendConsistentTitle),
summary: asStr(o.trendConsistentLike) || asStr(o.trendConsistentContent),
direction: asStr(o.narrativeDirection),
time: asStr(o.trendConsistentTime),
};
}),
].filter((s) => s.title || s.summary);
const narratives = asArray(cats["我情叙事"])
.map((n) => {
const o = asObj(n);
return {
title: asStr(o["标题"]),
intent: asStr(o["意图"]),
tags: strArr(o["标签"]),
content: asStr(o["内容"]),
};
})
.filter((n) => n.title || n.content);
// 上级要求:字符串(含编号),按行拆分、去掉行首编号
const orderRaw = cats["我情上级要求"];
const orderText = typeof orderRaw === "string" ? orderRaw : asStr(orderRaw);
const orders = orderText
.split(/\r?\n/)
.map((l) => l.trim().replace(/^[0-9]+[.、)]\s*/, ""))
.filter((l) => l.length > 0);
if (speeches.length === 0 && narratives.length === 0 && orders.length === 0) return null;
return { speeches, narratives, orders };
}
function adaptEnv(cats: Record<string, unknown>): EnvData | null {
const toSections = (raw: unknown): EnvSection[] =>
Object.entries(asObj(raw))
.filter(([k]) => k !== "type")
// 段落里过滤掉「引用角标」([[1]] / 资料[[1]] / (资料[[1]]) 等),过滤后为空的段落丢弃
.map(([title, v]) => ({
title: stripCitations(title),
paragraphs: strArr(v).map(stripCitations).filter((p) => p.length > 0),
}))
.filter((s) => s.paragraphs.length > 0);
const info = toSections(cats["信息环境"]);
const human = toSections(cats["人文环境"]);
if (info.length === 0 && human.length === 0) return null;
return { info, human };
}
function adaptJudge(cats: Record<string, unknown>): JudgeData | null {
const sw = asObj(cats["优劣势分析"]);
const mapTC = (v: unknown) =>
asArray(v)
.map((x) => {
const o = asObj(x);
return { title: asStr(o["title"]), content: asStr(o["content"]) };
})
.filter((x) => x.title || x.content);
const strengths = mapTC(sw["优势"]);
const weaknesses = mapTC(sw["劣势"]);
const con = asObj(cats["条件与限制"]);
const constraints = Object.entries(con)
.map(([title, v]) => ({ title: title.replace(/分析与?/, ""), items: strArr(v) }))
.filter((c) => c.items.length > 0);
const risk = asObj(cats["风险及应对"]);
const risks = strArr(risk["风险分析"]);
const responses = strArr(risk["应对方案"]);
if (strengths.length === 0 && weaknesses.length === 0 && constraints.length === 0 && risks.length === 0 && responses.length === 0)
return null;
return { strengths, weaknesses, constraints, risks, responses };
}
function adaptDecision(cats: Record<string, unknown>): DecisionData | null {
const root = asObj(cats["决心建议"]);
const core = asObj(root["决心建议"]);
const targets = asArray(core["打击目标"])
.map((t) => {
const o = asObj(t);
return {
name: asStr(o["目标名称"]),
desc: asStr(o["目标描述"]),
reason: asStr(o["选择理由"]),
impact: asStr(o["潜在影响"]),
basis: asStr(o["推荐依据"]),
};
})
.filter((t) => t.name || t.desc);
const data: DecisionData = {
taskTitle: asStr(core["任务标题"]),
taskIntro: asStr(core["任务简介"]),
objectives: strArr(core["作战目的"]),
targets,
keyPoints: strArr(root["实施要点"]),
summary: asStr(root["决心建议总结"]),
};
if (!data.taskTitle && !data.taskIntro && data.objectives.length === 0 && targets.length === 0 && data.keyPoints.length === 0 && !data.summary)
return null;
return data;
}
function adaptDisposal(cats: Record<string, unknown>): DisposalData | null {
const root = asObj(cats["处置建议"]);
const items = asArray(root["处置建议"])
.map((it) => {
const o = asObj(it);
const subj = asObj(o["处置主体"]);
return {
content: asStr(o["建议内容"]),
method: asStr(o["处置方式与步骤"]),
tone: asStr(o["口径基调"]),
effect: asStr(o["预期效果"]),
subject: asStr(subj["主责单位"]),
basis: asStr(o["立场与原则"]),
};
})
.filter((i) => i.content || i.method);
const data: DisposalData = {
goal: asStr(root["目标"]),
summary: asStr(root["处置建议总结"]),
items,
};
if (!data.goal && !data.summary && items.length === 0) return null;
return data;
}
/** 把 fetchDashboardInputJson 的返回解析成各页面展示模型(无 categories → 全 null)。 */
export function parseSqReport(input: unknown): SqReport {
const cats = getSqCategories(input);
if (!cats) {
return { enemy: null, ours: null, environment: null, judgement: null, decision: null, disposal: null };
}
// 整树清洗:所有类目的所有字符串字段统一过滤引用角标(通用,不止战场环境)。
return deepStripCitations({
enemy: adaptEnemy(cats),
ours: adaptOurs(cats),
environment: adaptEnv(cats),
judgement: adaptJudge(cats),
decision: adaptDecision(cats),
disposal: adaptDisposal(cats),
});
}
@@ -0,0 +1,242 @@
/**
* 「大屏绘制智能体」战情页公共 UI 片段(卡片 / 标题 / 更多按钮 / 色块徽标 / 表格)。
*
* **dashboard-agent 独占**:仅供战情系列静态页(SituationOverview / EnemyDetail / …)
* 复用,不影响会商共享的 DashboardApp / dashboard 组件群。配色对齐设计图(白底 + 红色强调)。
*/
import { createContext, useContext, type ReactNode } from "react";
import { ChevronRight, Inbox, type LucideIcon } from "lucide-react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { desensitize } from "@/lib/desensitize";
import { markdownComponents } from "@/roundtable-planning/components/markdown-components";
/**
* 战情页**展示层脱敏函数**的 Context。
*
* 由 `DashboardAgentPage` 注入「后台敏感词(/api/sensitive-words)」版本(与「敏感词管理」
* 配置页同源),让本文件所有公共 UI 片段渲染的**静态文案**(卡片标题 / 表头 / 徽标 / 脚注 等,
* 内含 目标→MB、叙事→XS 这类映射词)也统一脱敏;缺省回退全局种子 {@link desensitize}。
* 数据文本已在数据层脱敏,这里再过一遍是幂等的(替换词为拉丁码,不会二次命中中文原词)。
*/
const DesensitizeContext = createContext<(s: string) => string>(desensitize);
export const DesensitizeProvider = DesensitizeContext.Provider;
export function useDesensitize() {
return useContext(DesensitizeContext);
}
/** children 若是纯字符串则脱敏,否则原样返回(元素/数字等不动)。 */
function dsNode(fn: (s: string) => string, node: ReactNode): ReactNode {
return typeof node === "string" ? fn(node) : node;
}
/** 空数据占位(接口返回为空 / 该类目缺失时显示)。 */
export function NoData({ label = "暂无数据" }: { label?: string }) {
const ds = useDesensitize();
return (
<div className="flex min-h-[320px] flex-1 flex-col items-center justify-center gap-2 py-20 text-slate-400 dark:text-[var(--dash-text-dim)]">
<Inbox className="h-10 w-10 opacity-40" />
<span className="text-sm">{ds(label)}</span>
</div>
);
}
/** 卡片外框(白底 + slate 细边 + 轻阴影)。 */
export function Card({ className = "", children }: { className?: string; children: React.ReactNode }) {
return (
<div
className={`rounded-md border border-slate-200 bg-white shadow-sm dark:border-[var(--dash-border)] dark:bg-[var(--dash-card)] ${className}`}
>
{children}
</div>
);
}
/** 卡片标题(红色图标 + 标题 + 可选徽标 + 可选右侧操作 + 底部分隔线)。 */
export function CardTitle({
icon: Icon,
title,
badges,
action,
}: {
icon: LucideIcon;
title: string;
badges?: React.ReactNode;
action?: React.ReactNode;
}) {
const ds = useDesensitize();
return (
<div className="flex items-center justify-between gap-2 border-b border-slate-100 px-4 py-2.5 dark:border-[var(--dash-border)]">
<div className="flex min-w-0 items-center gap-2">
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded bg-red-50 text-red-600 dark:bg-red-500/15">
<Icon className="h-3.5 w-3.5" />
</span>
<h3 className="shrink-0 text-sm font-bold tracking-tight text-slate-900 dark:text-[var(--dash-text-strong)]">
{ds(title)}
</h3>
{badges && <div className="flex min-w-0 flex-wrap items-center gap-1.5">{badges}</div>}
</div>
{action}
</div>
);
}
/** 标题徽标药丸(如「组织目标 12 个」,数字红色高亮)。 */
export function HeadBadge({ label, value }: { label: string; value: string }) {
const ds = useDesensitize();
return (
<span className="inline-flex items-center gap-1 rounded bg-slate-50 px-1.5 py-0.5 text-[11px] text-slate-500 dark:bg-white/5 dark:text-[var(--dash-text-sec)]">
{ds(label)}
<b className="font-bold text-red-600">{ds(value)}</b>
</span>
);
}
/** 「更多 >」操作按钮(标题右侧,可自定义文案)。 */
export function MoreLink({ label = "更多", onClick }: { label?: string; onClick?: () => void }) {
const ds = useDesensitize();
return (
<button
type="button"
onClick={onClick}
className="inline-flex shrink-0 items-center gap-0.5 text-xs font-medium text-red-600 hover:underline"
>
{ds(label)}
<ChevronRight className="h-3.5 w-3.5" />
</button>
);
}
/** 卡片底部「查看完整… >」链接条(顶部分隔线 + 红色链接)。 */
export function CardFooterLink({ children, onClick }: { children: React.ReactNode; onClick?: () => void }) {
const ds = useDesensitize();
return (
<div className="mt-auto border-t border-slate-100 px-4 py-2.5 dark:border-[var(--dash-border)]">
<button
type="button"
onClick={onClick}
className="inline-flex items-center gap-0.5 text-xs font-medium text-red-600 hover:underline"
>
{dsNode(ds, children)}
<ChevronRight className="h-3.5 w-3.5" />
</button>
</div>
);
}
export type Tone = "red" | "amber" | "green" | "blue" | "slate";
const TONE_CLASS: Record<Tone, string> = {
red: "bg-red-50 text-red-600 dark:bg-red-500/15",
amber: "bg-amber-50 text-amber-600 dark:bg-amber-500/15",
green: "bg-emerald-50 text-emerald-600 dark:bg-emerald-500/15",
blue: "bg-sky-50 text-sky-600 dark:bg-sky-500/15",
slate: "bg-slate-100 text-slate-500 dark:bg-white/10 dark:text-[var(--dash-text-sec)]",
};
/** 语义色块徽标(高/中/低、上升/持平/下降、偏高 等)。 */
export function Tag({ tone, children }: { tone: Tone; children: React.ReactNode }) {
const ds = useDesensitize();
return (
<span className={`inline-flex items-center rounded px-1.5 py-0.5 text-[11px] font-semibold ${TONE_CLASS[tone]}`}>
{dsNode(ds, children)}
</span>
);
}
const VAL_TEXT: Record<Tone, string> = {
red: "text-red-600",
amber: "text-amber-600",
green: "text-emerald-600",
blue: "text-sky-600",
slate: "text-slate-400 dark:text-[var(--dash-text-dim)]",
};
/** 语义色取值文字(键值行右侧的 高/中/充足/紧张 等,以颜色表强弱)。 */
export function Val({ tone, children }: { tone: Tone; children: React.ReactNode }) {
const ds = useDesensitize();
return <span className={`font-semibold ${VAL_TEXT[tone]}`}>{dsNode(ds, children)}</span>;
}
/** 紧凑表格(表头灰字 + 行细分隔线,对齐设计图密集表格)。 */
export function Table({ head, children }: { head: string[]; children: React.ReactNode }) {
const ds = useDesensitize();
// 外层 overflow-x-auto:字号抬到 16px 后,多列密集表(如 8 列)超宽时横向滚动而非撑破卡片,保证布局不错乱。
// 不加负 margin:表格左右须与卡片标题/正文同在 px-4 基线上,否则整张表相对标题左移看着错位。
return (
<div className="overflow-x-auto">
<table className="w-full border-collapse text-xs">
<thead>
<tr className="text-left text-[11px] text-slate-400 dark:text-[var(--dash-text-dim)]">
{head.map((h) => (
<th
key={h}
className="border-b border-slate-100 px-1.5 pb-2 font-semibold first:pl-0 last:pr-0 dark:border-[var(--dash-border)]"
>
{ds(h)}
</th>
))}
</tr>
</thead>
<tbody>{children}</tbody>
</table>
</div>
);
}
/** 表格数据单元(统一内边距 + 细分隔线 + 顶对齐)。 */
export function Td({ className = "", children }: { className?: string; children?: React.ReactNode }) {
const ds = useDesensitize();
return (
<td
className={`border-b border-slate-50 px-1.5 py-2 align-top leading-relaxed first:pl-0 last:pr-0 dark:border-[var(--dash-border)] ${className}`}
>
{dsNode(ds, children)}
</td>
);
}
/** 卡片底部「说明 / 动态」提示条(左侧色条 + 浅底)。 */
export function FooterNote({
label,
tone = "red",
children,
}: {
label: string;
tone?: Tone;
children: React.ReactNode;
}) {
const border = tone === "green" ? "border-emerald-500" : tone === "blue" ? "border-sky-500" : "border-red-500";
const labelColor =
tone === "green"
? "text-emerald-700 dark:text-emerald-400"
: tone === "blue"
? "text-sky-700 dark:text-sky-400"
: "text-red-600";
const bg =
tone === "green" ? "bg-emerald-50 dark:bg-emerald-500/10" : tone === "blue" ? "bg-sky-50 dark:bg-sky-500/10" : "bg-red-50 dark:bg-red-500/10";
const ds = useDesensitize();
return (
<div className={`mx-4 mb-3.5 rounded border-l-2 ${border} ${bg} px-3 py-2 text-[11px] leading-relaxed`}>
<span className={`font-semibold ${labelColor}`}>{ds(label)}:</span>
<span className="text-slate-600 dark:text-[var(--dash-text)]">{dsNode(ds, children)}</span>
</div>
);
}
/**
* 富文本(Markdown)渲染——战情数据里部分字段(如「应对方案」)带 Markdown 标记,
* 直接当纯文本渲染会露出 `**`/`-`/`#` 等源码。这里用与圆桌报告同一套
* {@link markdownComponents}(react-markdown + remark-gfm)渲染,并先脱敏。
* 纯文本传入也能正常渲染(按段落输出),可安全套用。
*/
export function MarkdownText({ children }: { children: string }) {
const ds = useDesensitize();
return (
<div className="dash-md">
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
{ds(children ?? "")}
</ReactMarkdown>
</div>
);
}
@@ -8,7 +8,7 @@
* - 不传 data 时回落参考 demo(DEMO_DATA)。
*/
import './dashboard.css';
import { useEffect, useState } from 'react';
import { useEffect, useState, type ReactNode } from 'react';
import { TabType } from './types';
import {
DashboardDataProvider,
@@ -31,23 +31,48 @@ import {
const TAB_ICONS = [LayoutGrid, Users, Megaphone, ShieldAlert, FileText, Activity];
export function DashboardApp({ data = DEMO_DATA }: { data?: DashboardDataValue }) {
const [activeTab, setActiveTab] = useState<TabType>('overview');
/** 自定义页(dashboard-agent 战情多页用):一个 Tab = id + 标签 + 图标 + 正文。 */
export interface DashboardPage {
id: string;
label: string;
icon?: ReactNode;
content: ReactNode;
}
// 数据切换(切历史记录/重绘 → data 引用变化)时复位到总览:避免停留在上一份数据
// 钻入的某个席位详情页,而新数据里无同名席位导致「加载席位数据失败」。
export function DashboardApp({
data = DEMO_DATA,
pages,
}: {
data?: DashboardDataValue;
/**
* 可选自定义页集:传入时整套 Tab(标签/图标)与正文都由它驱动,替换默认的
* 「总览 + 各席位」(dashboard-agent 用它注入战情多页)。会商 Step3 不传 → 行为零变化。
*/
pages?: DashboardPage[];
}) {
const usingPages = !!pages && pages.length > 0;
const firstTab: TabType = usingPages ? pages![0].id : 'overview';
const [activeTab, setActiveTab] = useState<TabType>(firstTab);
// 数据/页集切换(切历史记录/重绘 → data 引用变化)时复位到首个 Tab:避免停留在上一份
// 数据钻入的某个席位详情页,而新数据里无同名席位导致「加载席位数据失败」。
useEffect(() => {
setActiveTab('overview');
}, [data]);
setActiveTab(firstTab);
}, [data, firstTab]);
// 导航 Tab:总览 + 各席位(由数据动态生成,原参考为写死的固定 6 项)。
const tabs: TabType[] = ['overview', ...data.SEAT_CARDS.map((s) => s.id)];
// 导航 Tab:自定义页集 → 各页 id;否则总览 + 各席位(由数据动态生成)。
const tabs: TabType[] = usingPages
? pages!.map((p) => p.id)
: ['overview', ...data.SEAT_CARDS.map((s) => s.id)];
const getTabLabel = (tab: TabType) =>
tab === 'overview' ? '总览' : data.SEAT_CARDS.find((c) => c.id === tab)?.title ?? tab;
const getTabLabel = (tab: TabType) => {
if (usingPages) return pages!.find((p) => p.id === tab)?.label ?? tab;
return tab === 'overview' ? '总览' : data.SEAT_CARDS.find((c) => c.id === tab)?.title ?? tab;
};
const getTabIcon = (tab: TabType) => {
const cls = 'w-4 h-4';
if (usingPages) return pages!.find((p) => p.id === tab)?.icon ?? <Compass className={cls} />;
if (tab === 'overview') return <Compass className={cls} />;
const idx = data.SEAT_CARDS.findIndex((c) => c.id === tab);
const Ico = TAB_ICONS[(idx >= 0 ? idx : 0) % TAB_ICONS.length];
@@ -76,12 +101,15 @@ export function DashboardApp({ data = DEMO_DATA }: { data?: DashboardDataValue }
)}
</div>
<div className="flex flex-wrap items-center gap-2.5 self-start lg:self-center text-xs text-slate-500 dark:text-[var(--dash-text-sec)]">
<div className="flex items-center gap-1.5 font-mono px-3 py-1.5 bg-white dark:bg-[var(--dash-card)] border border-slate-200 dark:border-[var(--dash-border)] shadow-sm rounded-sm">
<Clock className="w-3.5 h-3.5 text-rose-600 dark:text-[var(--dash-primary)] animate-pulse" />
<span>生成于 {data.meta.generatedAt}</span>
{/* 战情多页(dashboard-agent)不展示「生成于」时间标签;会商 Step3 大屏保留。 */}
{!usingPages && (
<div className="flex flex-wrap items-center gap-2.5 self-start lg:self-center text-xs text-slate-500 dark:text-[var(--dash-text-sec)]">
<div className="flex items-center gap-1.5 font-mono px-3 py-1.5 bg-white dark:bg-[var(--dash-card)] border border-slate-200 dark:border-[var(--dash-border)] shadow-sm rounded-sm">
<Clock className="w-3.5 h-3.5 text-rose-600 dark:text-[var(--dash-primary)] animate-pulse" />
<span>生成于 {data.meta.generatedAt}</span>
</div>
</div>
</div>
)}
</header>
{/* 导航 Tab */}
@@ -109,7 +137,18 @@ export function DashboardApp({ data = DEMO_DATA }: { data?: DashboardDataValue }
{/* Body */}
<AnimatePresence mode="wait">
{activeTab === 'overview' ? (
{usingPages ? (
<motion.div
key={activeTab}
initial={{ opacity: 0, y: 5 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -5 }}
transition={{ duration: 0.25 }}
className="space-y-6"
>
{pages!.find((p) => p.id === activeTab)?.content}
</motion.div>
) : activeTab === 'overview' ? (
<motion.div
key="overview-dashboard"
initial={{ opacity: 0, y: 5 }}
@@ -15,24 +15,26 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useLocation, useSearchParams } from "react-router-dom";
import { toast } from "sonner";
import {
ArrowLeft,
Boxes,
Code2,
ExternalLink,
LayoutPanelTop,
Loader2,
Pencil,
RefreshCw,
Sparkles,
} from "lucide-react";
import { Loader2, RefreshCw, Sparkles } from "lucide-react";
import { fetchDashboardInputJson } from "@/core/auth/dashboard-input";
import { readCopTaskDetail } from "@/core/auth/task-deeplink";
import { desensitizeDeep } from "@/lib/desensitize";
import { desensitize, makeDesensitizer } from "@/lib/desensitize";
import { useSensitiveWords, wordsToMap } from "@/strategy-components/api/sensitive-words";
import { useModels } from "@/core/models/hooks";
import { useTheme } from "@/contexts/ThemeContext";
import { DashboardApp } from "@/roundtable-planning/dashboard/DashboardApp";
import { Compass, Crosshair, Shield, Mountain, Lightbulb, Flag, FileText } from "lucide-react";
import { DashboardApp, type DashboardPage } from "@/roundtable-planning/dashboard/DashboardApp";
import { SituationOverview } from "@/roundtable-planning/dashboard-agent/SituationOverview";
import { EnemyDetail } from "@/roundtable-planning/dashboard-agent/EnemyDetail";
import { OurSituation } from "@/roundtable-planning/dashboard-agent/OurSituation";
import { EnvironmentDetail } from "@/roundtable-planning/dashboard-agent/EnvironmentDetail";
import { Judgement } from "@/roundtable-planning/dashboard-agent/Judgement";
import { Decision } from "@/roundtable-planning/dashboard-agent/Decision";
import { Disposal } from "@/roundtable-planning/dashboard-agent/Disposal";
import { parseSqReport, type SqReport } from "@/roundtable-planning/dashboard-agent/sq-report";
import { DesensitizeProvider } from "@/roundtable-planning/dashboard-agent/ui";
import { adaptReportData } from "@/roundtable-planning/dashboard/data-context";
import { DashboardChatPanel } from "@/roundtable-planning/components/DashboardChatPanel";
import { Step2SandboxLayout } from "@/roundtable-planning/components/Step2SandboxLayout";
@@ -56,9 +58,33 @@ function formatNow(): string {
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
/**
* 用**指定脱敏函数**深度遍历对象/数组(仅替换字符串值),返回新值(不改入参)。
* 与 `desensitizeDeep` 的差别:脱敏函数由调用方传入——这里直接用 `/api/sensitive-words`
* 查询结果构造(见组件内 `desensitizeText`),避免依赖全局 `DESENSITIZE_MAP` 的异步回灌时序。
*/
function deepDesensitize<T>(value: T, fn: (s: string) => string): T {
if (typeof value === "string") return fn(value) as unknown as T;
if (Array.isArray(value)) return value.map((v) => deepDesensitize(v, fn)) as unknown as T;
if (value && typeof value === "object") {
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(value as Record<string, unknown>)) out[k] = deepDesensitize(v, fn);
return out as T;
}
return value;
}
type DashView = "chat" | "page";
type RenderMode = "app" | "html";
/**
* 静态页面阶段开关(先按设计图搭总览版式,动态数据后续再接):
* - 默认落在「大屏页面」视图,直接展示 SituationOverview 静态版式;
* - 不自动调用智能体抽取(避免静态阶段空跑)。
* 接入动态数据后置为 false 即恢复原有「请求数据→智能体抽取→查看大屏」流程。
*/
const STATIC_PREVIEW = true;
/** 从 URL / 路由 state / 登录暂存任务详情解析 taskId。 */
function useTaskId(): string {
const [searchParams] = useSearchParams();
@@ -83,6 +109,8 @@ export default function DashboardAgentPage() {
// 输入 JSON(登录拉取/占位)——auto-start 时由 buildInitialPrompt 闭包读取。
const inputRef = useRef<unknown>(null);
// 战情报告解析结果(selectSqReport → 各页面展示模型);解析失败/无数据 → null(页面回退占位)。
const [sqReport, setSqReport] = useState<SqReport | null>(null);
const dashboard = useStep3Dashboard({
intentReady: null,
@@ -113,7 +141,8 @@ export default function DashboardAgentPage() {
} = dashboard;
const [loading, setLoading] = useState(true);
const [view, setView] = useState<DashView>("chat");
// 静态阶段默认进「大屏页面」,直接展示静态版式(见 STATIC_PREVIEW)。
const [view, setView] = useState<DashView>(STATIC_PREVIEW ? "page" : "chat");
const [renderMode, setRenderMode] = useState<RenderMode>("app");
// 真实接口请求失败/无数据时的提示(真实模式不拿假数据兜底,改为提示+重试)。
const [inputError, setInputError] = useState<string | null>(null);
@@ -152,6 +181,18 @@ export default function DashboardAgentPage() {
fetchDashboardInputJson(taskId).catch(() => null),
]);
inputRef.current = input;
// 解析战情报告 → 各页面展示模型(容错:异常/无数据均不抛错,页面回退占位)。
try {
const parsed = parseSqReport(input);
// 调试:在浏览器控制台打印「接口原始 JSON」与「解析后的战情数据」,便于核对解析是否正确。
// eslint-disable-next-line no-console
console.log("[大屏绘制] taskId=%s\n原始输入 JSON:", taskId, input, "\n解析后 sqReport:", parsed);
setSqReport(parsed);
} catch (err) {
// eslint-disable-next-line no-console
console.warn("[大屏绘制] parseSqReport 解析失败:", err, "\n原始输入:", input);
setSqReport(null);
}
const savedDialogues = (session?.transcript as Step2Dialogue[] | undefined) ?? [];
const validReport = session?.reportJson && parseReportJson(session.reportJson);
if (validReport) {
@@ -161,14 +202,20 @@ export default function DashboardAgentPage() {
} else if (savedDialogues.length > 0) {
// 有历史聊天但无有效结果 → 进生成过程展示历史,不自动重跑(点「重新绘制」可重生成)。
hydrateTranscriptRef.current(savedDialogues, session?.reportJson ?? null);
setView("chat");
// 静态阶段仍停在「大屏页面」看版式(历史对话可点「编辑」查看)。
setView(STATIC_PREVIEW ? "page" : "chat");
} else {
// 无任何记录 → 接口返回了真实数据才启动模型抽取;否则提示(真实模式不拿假数据兜底)。
setView("chat");
if (input) {
startRef.current();
// 静态阶段(STATIC_PREVIEW):不自动抽取,停在「大屏页面」展示静态版式。
if (STATIC_PREVIEW) {
setView("page");
} else {
setInputError("未获取到任务数据:接口请求失败或未返回内容,请稍后重试。");
setView("chat");
if (input) {
startRef.current();
} else {
setInputError("未获取到任务数据:接口请求失败或未返回内容,请稍后重试。");
}
}
}
} finally {
@@ -206,8 +253,17 @@ export default function DashboardAgentPage() {
return () => setPreviewEnabled(false);
}, [view, setPreviewEnabled]);
// 展示层脱敏函数:**直接用 `/api/sensitive-words` 查询结果**构造(不走全局 DESENSITIZE_MAP),
// 避免与 `replaceDesensitizeMap` 的 effect 时序竞态——与侧边栏/导航栏菜单脱敏同一手法。
// 后台为空(或未加载)时回退到全局种子 `desensitize`。供大屏所有 react 页面 + 导航 Tab 文本共用。
const { data: sensitiveWords } = useSensitiveWords();
const desensitizeText = useMemo(() => {
const map = wordsToMap(sensitiveWords ?? []);
return Object.keys(map).length > 0 ? makeDesensitizer(map) : desensitize;
}, [sensitiveWords]);
// ── 大屏渲染数据(深度脱敏后渲染,覆盖大屏所有展示文本) ──
const report = useMemo(() => (data ? desensitizeDeep(data) : null), [data]);
const report = useMemo(() => (data ? deepDesensitize(data, desensitizeText) : null), [data, desensitizeText]);
const dashboardData = useMemo(
() => (report ? adaptReportData(report, formatNow()) : undefined),
[report],
@@ -218,6 +274,35 @@ export default function DashboardAgentPage() {
);
const hasResult = !!dashboardData || !!dashboardHtml;
// 展示层脱敏:所有页面文本经深度替换后再渲染(与会商大屏一致),脱敏函数来自后台敏感词查询。
const safeReport = useMemo<SqReport | null>(() => {
if (!sqReport) return null;
try {
return deepDesensitize(sqReport, desensitizeText) as SqReport;
} catch {
return sqReport; // 脱敏异常不影响展示
}
}, [sqReport, desensitizeText]);
// 是否有可展示的真实数据:战情报告(safeReport)或已生成的大屏(report-json / HTML)。
// 三者皆无 → 不渲染大屏的导航栏与标题,仅显示「暂无数据」。
const hasData = !!safeReport || !!dashboardData || !!dashboardHtml;
// 战情多页:总览 + 六个详情页;详情页传入对应的(脱敏后)真实数据(无则页面回退占位)。
// 导航 Tab 文本(label)也经 desensitizeText 脱敏后再传给 DashboardApp 渲染导航栏。
const dashPages: DashboardPage[] = useMemo(
() => [
{ id: "overview", label: desensitizeText("总览"), icon: <Compass className="h-4 w-4" />, content: <SituationOverview data={safeReport} /> },
{ id: "enemy", label: desensitizeText("敌情"), icon: <Crosshair className="h-4 w-4" />, content: <EnemyDetail data={safeReport?.enemy} /> },
{ id: "ours", label: desensitizeText("我情"), icon: <Shield className="h-4 w-4" />, content: <OurSituation data={safeReport?.ours} /> },
{ id: "field", label: desensitizeText("战场环境"), icon: <Mountain className="h-4 w-4" />, content: <EnvironmentDetail data={safeReport?.environment} /> },
{ id: "judge", label: desensitizeText("情况判断"), icon: <Lightbulb className="h-4 w-4" />, content: <Judgement data={safeReport?.judgement} /> },
{ id: "decision", label: desensitizeText("决心建议"), icon: <Flag className="h-4 w-4" />, content: <Decision data={safeReport?.decision} /> },
{ id: "disposal", label: desensitizeText("处置建议"), icon: <FileText className="h-4 w-4" />, content: <Disposal data={safeReport?.disposal} /> },
],
[safeReport, desensitizeText],
);
// 网页渲染:blob URL 作 iframe src(:target 导航需独立文档 URL,见 Step3DashboardMode 注释)。
const [htmlBlobUrl, setHtmlBlobUrl] = useState<string | null>(null);
useEffect(() => {
@@ -311,94 +396,39 @@ export default function DashboardAgentPage() {
}
return (
<div className="flex h-full min-h-0 flex-col text-slate-800 dark:text-slate-200">
{/* 顶栏 */}
<div className="dashboard-agent-topbar shrink-0 flex flex-wrap items-center justify-between gap-3 border-b border-slate-100 bg-gradient-to-r from-slate-50 to-white px-6 py-3">
<div className="flex items-center gap-2.5">
<Sparkles className="h-4.5 w-4.5 text-primary" />
<h3 className="text-sm font-black tracking-tight text-slate-900">大屏绘制智能体</h3>
<span className="inline-flex items-center gap-1.5 rounded-full border border-red-200/50 bg-[var(--brand-200)]/60 px-2 py-0.5 text-[10px] font-bold text-primary">
{(phase === "creating" || phase === "streaming") && <Loader2 className="h-3 w-3 animate-spin" />}
{view === "chat" ? "生成过程" : "大屏页面"}
</span>
</div>
<div className="flex items-center gap-2">
{view === "page" ? (
<>
<div className="inline-flex items-center gap-0.5 rounded-xl border border-slate-200 bg-slate-50 p-0.5">
<button
type="button"
onClick={() => setRenderMode("app")}
title="用应用内 React 组件渲染(Tab 切换、动画、席位钻取)"
className={`flex items-center gap-1 rounded-lg px-2.5 py-1 text-[10.5px] font-bold transition ${
renderMode === "app"
? "border border-slate-200 bg-white text-primary shadow-sm"
: "text-slate-500 hover:text-slate-700"
}`}
>
<Boxes className="h-3.5 w-3.5" />
应用渲染
</button>
<button
type="button"
onClick={() => setRenderMode("html")}
disabled={!dashboardHtml}
title={dashboardHtml ? "用自包含 HTML 大屏渲染(与新窗口/下载一致)" : "尚无已生成的 HTML,先完成一次绘制"}
className={`flex items-center gap-1 rounded-lg px-2.5 py-1 text-[10.5px] font-bold transition disabled:cursor-not-allowed disabled:opacity-40 ${
renderMode === "html"
? "border border-slate-200 bg-white text-primary shadow-sm"
: "text-slate-500 hover:text-slate-700"
}`}
>
<Code2 className="h-3.5 w-3.5" />
网页渲染
</button>
</div>
<button
type="button"
onClick={() => setView("chat")}
title="回到生成过程,加载聊天记录继续问答修改"
className="flex items-center gap-1 rounded-xl border border-slate-200 bg-white px-3 py-1.5 text-[10.5px] font-bold text-slate-700 transition hover:bg-slate-50"
>
<Pencil className="h-3.5 w-3.5 text-slate-500" />
编辑
</button>
<button
type="button"
onClick={handleOpenNewWindow}
title={renderMode === "html" ? "新标签页打开自包含 HTML 大屏" : "新标签页打开应用内 React 大屏(全屏)"}
className="flex items-center gap-1 rounded-xl border border-slate-200 bg-white px-3 py-1.5 text-[10.5px] font-bold text-slate-700 transition hover:bg-slate-50"
>
<ExternalLink className="h-3.5 w-3.5 text-slate-500" />
新窗口打开
</button>
</>
) : (
hasResult && (
<button
type="button"
onClick={() => setView("page")}
title="查看已生成的大屏页面"
className="flex items-center gap-1 rounded-xl border border-slate-200 bg-white px-3 py-1.5 text-[10.5px] font-bold text-slate-700 transition hover:bg-slate-50"
>
<LayoutPanelTop className="h-3.5 w-3.5 text-slate-500" />
查看大屏
</button>
)
)}
</div>
</div>
// dashboard-agent-fontscale:本页字号下限 16px(见 globals.css,仅作用于本页)。
<div className="dashboard-agent-fontscale flex h-full min-h-0 flex-col text-slate-800 dark:text-slate-200">
{/* 主体 */}
{loading ? (
// 加载框:请求战情报告期间显示,拿到数据后再渲染大屏(避免先闪占位再跳真实数据)。
<div className="flex flex-1 flex-col items-center justify-center gap-3 text-sm text-slate-500">
<Loader2 className="h-6 w-6 animate-spin text-primary" />
<div className="text-center">
<div className="font-semibold text-slate-700">正在请求任务数据…</div>
<div className="mt-1 text-xs text-slate-400">拉取接口返回后将自动开始智能体抽取绘制</div>
<div className="mt-1 text-xs text-slate-400">拉取接口返回后展示大屏</div>
</div>
</div>
) : view === "page" ? (
!hasData ? (
// 无数据:隐藏大屏导航栏与顶部标题,仅显示「暂无数据」。
<div className="flex flex-1 items-center justify-center text-sm text-slate-400">
暂无数据
</div>
) : renderMode === "html" && htmlBlobUrl ? (
<iframe
title="任务研讨大屏"
src={htmlBlobUrl}
className="min-h-0 w-full flex-1 border-0 bg-white"
sandbox="allow-scripts allow-popups allow-downloads allow-same-origin"
/>
) : (
<div className="min-h-0 flex-1 overflow-auto">
{/* 注入后台敏感词脱敏函数:让战情各页(含静态卡片标题/表头/脚注等)统一脱敏。 */}
<DesensitizeProvider value={desensitizeText}>
<DashboardApp data={dashboardData} pages={dashPages} />
</DesensitizeProvider>
</div>
)
) : inputError && dialogues.length === 0 ? (
<div className="flex flex-1 items-center justify-center p-8 text-center text-sm text-slate-500">
<div className="max-w-sm">
@@ -414,19 +444,6 @@ export default function DashboardAgentPage() {
</button>
</div>
</div>
) : view === "page" ? (
renderMode === "html" && htmlBlobUrl ? (
<iframe
title="任务研讨大屏"
src={htmlBlobUrl}
className="min-h-0 w-full flex-1 border-0 bg-white"
sandbox="allow-scripts allow-popups allow-downloads allow-same-origin"
/>
) : (
<div className="min-h-0 flex-1 overflow-auto">
<DashboardApp data={dashboardData} />
</div>
)
) : (
<div className="min-h-0 flex-1 p-3">
<section className="h-full min-h-0 overflow-hidden rounded-2xl border border-slate-200/60 bg-white">
@@ -70,6 +70,7 @@ import { getAvatar, Man1Avatar, RobotAvatar } from "@/roundtable-planning/compon
import { ImportRoundtableDraftDialog } from "@/components/workspace/roundtable-draft-share-dialogs";
import { TaskPurposeDialog } from "@/strategy-components/components/task-components/TaskPurposeDialog";
import { TaskJumpBar } from "@/strategy-components/components/task-components/TaskJumpBar";
import { RwfxSqReportGate } from "@/strategy-components/components/task-components/RwfxSqReportGate";
import type { TaskBusiness } from "@/core/auth/task-buttons";
import {
derivePurposeLevels,
@@ -169,6 +170,21 @@ interface RoundtablePrefill {
autoStart?: boolean;
}
/**
* 会商「聊天记录」存储键:草稿(useDraftPersistence / useRoundtableDrafts)与后台作业
* (startJob / getJobByDraft / cancelJob)统一按此键隔离与回显。
*
* - **xdfx-A(行动分析会商)**:同一任务下可派生多个「行动」,各行动的会商需互相独立,
* 故按「行动id」(`actionId`,即深链 URL `?taskId=` 原始传入值) 隔离,而非解析出的真实任务id。
* - 其它业务:无 `actionId`,回退真实 `taskId`。
*
* ⚠️ 仅用于聊天记录/作业存储。目的树、入库技能、任务详情、底部快捷跳转栏仍用真实
* `prefill.taskId`(见各调用点),不受此键影响。
*/
function recordTaskIdOf(prefill: RoundtablePrefill | null): string | null {
return prefill?.actionId ?? prefill?.taskId ?? null;
}
/**
* 无界(wujie)抽屉场景:父页面通过 `window.$wujie.props` 注入「任务名称/描述/业务链条」。
* iframe 降级时 props 可能晚于首帧,故再读 hash 路由 query 作兜底(宿主 `appendEmbedQueryToUrl` 写入)。
@@ -715,7 +731,8 @@ export default function RoundtablePlanningPage() {
currentStep,
setCurrentStep,
triggerToast,
taskId: prefillRef.current?.taskId ?? null,
// 聊天记录存储键:xdfx-A 按行动id 隔离,其它业务按真实 taskId(见 recordTaskIdOf)。
taskId: recordTaskIdOf(prefillRef.current),
intentSending: step1.isIntentSending,
step2FinalizedCount,
step3SavedTick,
@@ -1045,7 +1062,8 @@ export default function RoundtablePlanningPage() {
// C1.5(无界回显):带 taskId 时,挂载即按任务取最新草稿回显。命中 → 抑制 autoStart
// (避免重复发首条消息);未命中 → 放行 autoStart 走全新任务。只跑一次。
useEffect(() => {
const tid = prefillRef.current?.taskId;
// 回显聊天记录按存储键(xdfx-A=行动id),与 useDraftPersistence 的 taskId 保持一致。
const tid = recordTaskIdOf(prefillRef.current);
if (!tid) return;
let cancelled = false;
void (async () => {
@@ -1475,7 +1493,7 @@ export default function RoundtablePlanningPage() {
step2.stopOrchestration();
// 1. 取消后端作业:避免前后端双跑/抢同一线程(409),线程随后由前端接管续跑。
// executor.cancel 会 task.cancel() 中断在跑的 run,DB 同步置 cancelled。
await cancelJob(jobId, prefillRef.current?.taskId ?? null).catch(() => null);
await cancelJob(jobId, recordTaskIdOf(prefillRef.current)).catch(() => null);
// 2. 加载作业最近写回草稿的快照:恢复 threadIds(作业真实线程)+ 协调名 + 完整对话。
// hydrateFromDraft 已同步写好 threadIds/coordinatorName 的 ref,供下一步接管即时读取。
await drafts.handleLoadDraft(draftId);
@@ -1579,7 +1597,8 @@ export default function RoundtablePlanningPage() {
if (agents.length === 0) return null;
// 后台挂起发起前**立刻强制存一次**当前聊天(保住发起时刻的本地研讨记录,防丢)。
const draftId = await drafts.saveDraftNow(2);
const tid = prefillRef.current?.taskId ?? null;
// 后台作业按聊天记录存储键归属(xdfx-A=行动id):报告写回对应 task-draft 存储、去重一致。
const tid = recordTaskIdOf(prefillRef.current);
// 防重复:该草稿已有在跑作业就不再新建(避免重复作业 / 前后端双跑)。
const existing = draftId ? await getJobByDraft(draftId, tid).catch(() => null) : null;
const alreadyRunning =
@@ -1650,7 +1669,8 @@ export default function RoundtablePlanningPage() {
const chainSnap = chainMeta;
const planSnap = mode === "dag" ? orchestrationPlan : null;
const draftId = drafts.currentDraftId;
const tid = prefillRef.current?.taskId ?? null;
// 后台作业按聊天记录存储键归属(xdfx-A=行动id),与草稿存储一致。
const tid = recordTaskIdOf(prefillRef.current);
// 立刻:标记「挂起中」+ 跳回第一步 + 提示(不等接口)。
if (draftId) markPendingSuspend(draftId);
@@ -1745,7 +1765,7 @@ export default function RoundtablePlanningPage() {
if (agents.length === 0) return;
startJobKeepalive({
draftId: drafts.currentDraftId ?? undefined,
taskId: prefillRef.current?.taskId ?? undefined,
taskId: recordTaskIdOf(prefillRef.current) ?? undefined,
intent: buildInitialIntent(),
agents,
model: selectedModel || undefined,
@@ -2060,7 +2080,7 @@ export default function RoundtablePlanningPage() {
onSelectChain={handleSelectChainFromSidebar}
onOpenDraft={drafts.handleLoadDraft}
activeDraftId={drafts.currentDraftId}
entryRailTaskId={prefillRef.current?.taskId ?? null}
entryRailTaskId={recordTaskIdOf(prefillRef.current)}
entryRailBusinessCode={prefillRef.current?.businessCode ?? null}
/>
@@ -2234,6 +2254,16 @@ export default function RoundtablePlanningPage() {
roundtable
/>
)}
{/* 任务分析会商(rwfx-A)深链:与单智能体共用 3q 预检闸门,未完成则引导前往三情**会商**(3qfx-A)。 */}
{prefillRef.current?.taskId &&
prefillRef.current?.businessCode &&
BUSINESS_CODE_TO_TASK_BUSINESS[prefillRef.current.businessCode] === "rwfx" && (
<RwfxSqReportGate
taskId={prefillRef.current.taskId}
targetGoPath="3qfx-A"
targetLabel="3qfx 会商"
/>
)}
</div>
);
}
@@ -0,0 +1,106 @@
/**
* rwfx「3q 完成预检」闸门(**单智能体 + 多智能体会商共用**):进入任务分析(rwfx / rwfx-A 会商)时
* 调研判情报报告接口(``selectSqReport``)。若报告为空(数组空 / 全 null,即 3q 未完成)→ 弹框提示
* 「3q 未完成,是否前往三情分析」,「前往」按 ``targetGoPath`` 解析深链目标后跳转(同一任务 taskId 重绑):
* - 单智能体:``targetGoPath="3qfx"``(``TaskFxWorkspace``,跳单智能体三情工作区);
* - 多智能体会商:``targetGoPath="3qfx-A"``(``RoundtablePlanningPage``,跳三情会商)。
* 报告正常 / 接口不可判定(缺地址、请求失败)时不打扰用户。
*
* **两处共用本组件**——改一处两处都生效(用户要求复用,避免双份逻辑漂移)。
* 见 [[dashboard-input]] / [[task-deeplink]]。
*/
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { Loader2 } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { readCopTaskDetail, resolveTaskDeeplinkTarget } from "@/core/auth/task-deeplink";
import { fetchSqReportStatus } from "@/core/auth/dashboard-input";
export function RwfxSqReportGate({
taskId,
targetGoPath = "3qfx",
targetLabel = "3qfx",
}: {
taskId: string;
/** 「前往」目标 goPath:单智能体 = ``"3qfx"``(默认);多智能体会商 = ``"3qfx-A"``。 */
targetGoPath?: string;
/** 文案/按钮里展示的目标名(默认 ``3qfx``;会商可传 ``"3qfx 会商"``)。 */
targetLabel?: string;
}) {
const navigate = useNavigate();
const [open, setOpen] = useState(false);
const [jumping, setJumping] = useState(false);
useEffect(() => {
if (!taskId) return;
let alive = true;
void fetchSqReportStatus(taskId).then((status) => {
// 仅「明确为空(3q 未完成)」才弹框;complete / unknown 静默放行。
if (alive && status === "incomplete") setOpen(true);
});
return () => {
alive = false;
};
}, [taskId]);
const handleGo = async () => {
const detail = readCopTaskDetail();
if (!detail || !taskId) {
toast.error("缺少任务信息,无法跳转");
return;
}
setJumping(true);
try {
// 复用深链解析:会清掉当前任务上下文并按目标业务(3qfx / 3qfx-A)重新绑定(同一 taskId)。
const target = await resolveTaskDeeplinkTarget(targetGoPath, detail, taskId);
if (target) {
setOpen(false);
navigate(target.path, { state: target.state });
} else {
toast.error(`未配置三情分析(${targetLabel})对应的业务智能体`);
}
} catch {
toast.error("跳转失败,请稍后再试");
} finally {
setJumping(false);
}
};
return (
<Dialog open={open} onOpenChange={(o) => !jumping && setOpen(o)}>
<DialogContent>
<DialogHeader>
<DialogTitle>3q 未完成</DialogTitle>
<DialogDescription>
未查询到该任务的研判情报报告(3q 未完成),是否前往该任务的三情分析({targetLabel})?
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)} disabled={jumping}>
暂不
</Button>
<Button
// 弹框经 Radix Portal 渲染到 body,在 `.strategy-data` 作用域之外,故用全局按主题
// 定义的品牌色 token(:root/.dark/.lightblue 都有),三套主题都能正确取色。
className="bg-[var(--workspace-brand)] text-[var(--workspace-brand-foreground)] hover:opacity-90"
onClick={handleGo}
disabled={jumping}
>
{jumping ? <Loader2 className="h-4 w-4 animate-spin" /> : `前往 ${targetLabel}`}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -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>
);
}
+33
View File
@@ -34,6 +34,12 @@
@source inline("border-l-{2,4}");
@source inline("border-l-{slate,gray,primary}-{300,400,500}");
/* 战情大屏(dashboard-agent):时间线圆圈/虚线、FooterNote 左侧强调条等用到的
* 语义色边框——本项目边框色需显式 safelist,否则只生成宽度、颜色回退成灰色。
* 明、暗两套各一行。 */
@source inline("border-{red,emerald,sky,amber}-{200,300,400,500,600,700}");
@source inline("dark:border-{red,emerald,sky,amber}-{200,300,400,500,600,700}");
/* Link */
@source inline("text-{blue,primary,accent}-{500,600,700}");
@source inline("hover:text-{blue,primary,accent}-{600,700,800}");
@@ -656,6 +662,33 @@
border-color: #1a2f5a !important;
}
/* 「大屏绘制智能体」战情大屏:**全页最小字号 16px**(仅命中本页 wrapper
* .dashboard-agent-fontscale —— 加在 DashboardAgentPage 根容器上,会包住其下的
* DashboardApp 顶部标题/导航 Tab/页脚 + 七个战情子页)。把 10/11/12/13px 统一抬到 16px、
* 14px(text-sm) 抬到 18px,保留「正文 16 / 卡片标题 18(再叠 bold) / 指标大数 ≥20」的层级,
* 看上去自然不错乱;text-xl/2xl/3xl 本就 ≥16,保持原样不动。
* 作用域严格限定本页:会商共享的 DashboardApp(无此 wrapper)与其它页面零影响。 */
.dashboard-agent-fontscale
:is(.text-\[10px\], .text-\[11px\], .text-xs, .text-\[13px\]) {
font-size: 16px !important;
line-height: 1.75 !important;
}
.dashboard-agent-fontscale .text-sm {
font-size: 18px !important;
line-height: 1.65 !important;
}
/* 语义色边框兜底:本项目 border-red/emerald/sky 等颜色类未被 Tailwind 生成时只剩宽度、
* 颜色回退成灰色(时间线圆圈/虚线、FooterNote 左侧强调条)。这里在本页作用域内**直接定义**
* 这些类的 border-color(用 Tailwind v4 颜色变量,带 hex 兜底),不依赖 @source 安全列表的
* 重新生成,改完即时生效。仅命中 .dashboard-agent-fontscale 子树,不影响其它页面。 */
.dashboard-agent-fontscale .border-red-200 { border-color: var(--color-red-200, #fecaca) !important; }
.dashboard-agent-fontscale .border-red-300 { border-color: var(--color-red-300, #fca5a5) !important; }
.dashboard-agent-fontscale .border-red-400 { border-color: var(--color-red-400, #f87171) !important; }
.dashboard-agent-fontscale .border-red-500 { border-color: var(--color-red-500, #ef4444) !important; }
.dashboard-agent-fontscale .border-emerald-500 { border-color: var(--color-emerald-500, #10b981) !important; }
.dashboard-agent-fontscale .border-sky-500 { border-color: var(--color-sky-500, #0ea5e9) !important; }
.workspace-panel {
border: 1px solid #f0d7d0;
background: #ffffff;
@@ -300,6 +300,8 @@ Two on-demand/automatic refresh paths layer on top:
**Unified output switches (`require_html` + `require_markdown`).** The task form no longer splits "???? / HTML ??" into separate kinds. `execution_context` now carries two **independent** booleans that may both be on: `require_html` (???? HTML) and `require_markdown` (???? Markdown). `_task_requires_html()` returns true for `require_html` **or** legacy `task_kind == "html_page"` (so old tasks need no data migration), and `_run_task_attempt()` dispatches to `_run_html_page_attempt` when it is true, else the regular agent attempt. When **both** switches are on, `_gather_html_data()` returns `(text, files)` ? the gather agent run is additionally told to save a `.md` file ? and `_run_html_page_attempt` validates it (`MarkdownRequirementError` on failure) and surfaces it alongside the generated page, so the run delivers both artifacts. The single main `prompt` doubles as the HTML page requirement (`html_config.page_prompt`); there is no separate page-prompt field. Tests: `tests/test_scheduled_task_require_html.py` + the markdown-coexistence cases in `tests/test_html_page_tasks.py`.
**Template-fill mode (JSON → 固定 HTML 模板填充).** A third execution mode, distinct from the free-form `require_html` render pipeline: convert a user-supplied JSON into a **fixed** HTML template's data structure and fill it. Triggered when `execution_context.template_config.template_id` is set (chosen in the task form after selecting the built-in **「模板数据转换器」** agent `template-json-builder`). `_task_template_id()` reads it and `_run_task_attempt()` dispatches to `_run_template_fill_attempt` **before** the `require_html` check. Pipeline: take source JSON (the task `prompt`, pasted or uploaded .json) → load the selected agent's `SOUL.md`+model and run a **direct** `_invoke_conversion_model` call (system=SOUL, human=conversion prompt carrying the template's compact **schema skeleton** + source) → `extract_json_object` → `template_fill.validate_data` (top-level keys + container types) → **on failure feed the structure errors + `field_coverage_gaps` back and retry up to `_MAX_TEMPLATE_FILL_RETRIES`=2** → `template_fill.fill_template` replaces the template's `const DATA={…}` line (and refreshes `MANIFEST.系统.更新时间`) → archive `index.html` + persist a `scheduled_html_pages` row → return `format:"html"` (reusing the existing HTML preview at `/runs/{id}/html`). Any terminal failure raises `TemplateFillError` → run marked failed (no retry, message shown). **Pure engine** `deerflow/runtime/scheduler/template_fill.py` (harness, no `app.*`): the template registry (`TEMPLATES`: id=`no1` name=`no.1` 台湾滨海防卫大屏, and id=`no2` name=`no.2` 台湾飞行部队平台 with **`convertible=False`** — a 「只展示」 template that renders its existing HTML **as-is**, no model/validation/fill, until conversion is wired in later; `_run_template_fill_attempt` short-circuits to `read_template_text` for it, and `list_templates`/`is_convertible` expose the flag so the form hides the JSON input), `list_templates`/`reference_data`/`schema_skeleton_json`/`validate_data`/`field_coverage_gaps`/`fill_template` — **the template's own embedded `DATA` is the schema source of truth** (parsed at load, 1-sample-per-array skeleton fed to the LLM, top-level structure as the validation basis); template HTML files live under `runtime/scheduler/templates/<id>/`, add a `TemplateSpec` to register a no.2. The agent is seeded like the AI-writing ones: `app/gateway/routers/_template_builder_seed.py` + `_template_builder_seed_assets/template-json-builder/{config.yaml,SOUL.md}`, called from the lifespan before `_sync_legacy_agents` (which upserts it into the `agents` table as built-in/published so it shows in `/api/agents` → the task form's 执行智能体 dropdown). Template list API: `GET /api/scheduled-tasks/html-templates` → `{templates:[{id,name,description}]}` (open). Frontend: when the agent dropdown = `template-json-builder` the form hides the HTML/Markdown switches and shows a 「HTML模板」 select (`useHtmlTemplates`) + a .json upload (reads file text into the prompt box). Tests: `tests/test_template_fill.py`, `tests/test_template_fill_attempt.py`, `tests/test_template_builder_seed.py`.
### Per-user Prompts & UI Preferences
**Custom prompts can be scoped to one agent.** `user_custom_prompts` gains a nullable `agent_id` column (migration `20260603_02`): `NULL` = global (the chip shows in every chat), otherwise the prompt only surfaces when that agent is selected. The visibility filter is applied in the frontend `CustomPromptBar` (it receives the current agent id from `ChatPage`'s selector or `AgentChatPage`'s `assistantId`); the backend (`/api/user-prompts` CRUD) just stores/returns/clears `agent_id` (update accepts an explicit `null` to clear). Tests: `tests/test_user_prompts_agent_id.py`.
@@ -960,6 +960,20 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
"AI-writing functional agent seeding failed at startup (non-fatal)"
)
# 内置「模板数据转换器」agent(JSON → HTML 模板填充)。同样必须在
# _sync_legacy_agents **之前**补建目录,后者把它 upsert 进 agents 表,
# 第一次启动 /api/agents 与定时任务下拉框里就有它。
try:
from app.gateway.routers._template_builder_seed import (
ensure_template_builder_agent,
)
ensure_template_builder_agent()
except Exception:
logger.exception(
"Template-builder agent seeding failed at startup (non-fatal)"
)
# ?????????????????3Q/6BF/7BF/8BF??agent/chain ?????
try:
business_mapping_store = getattr(app.state, "business_mapping_store", None)
@@ -0,0 +1,63 @@
"""内置「模板数据转换器」agent 的「缺失自动补建」工具。
``template-json-builder`` 是定时任务「JSON → HTML 模板填充」功能的执行大脑:当用户在
定时任务表单里选中它、再选一个 HTML 模板时,到点执行会用它的 ``SOUL.md`` 把源 JSON
智能映射成模板需要的 ``DATA`` 结构(详见
:mod:`deerflow.runtime.scheduler.template_fill` 与调度器 ``service.py``)。
与 ``_ai_writing_seed.py`` / ``_roundtable_seed.py`` 完全同构:验证版 ``SOUL.md`` +
``config.yaml`` 随 git 发布于 ``_template_builder_seed_assets/<id>/``,seeder 只在
``.deer-flow/agents/<id>/`` 目录**缺失**时字节级复制(绝不覆盖线上手改)。lifespan 在
``_sync_legacy_agents`` 之前调用本 seeder,后者扫描 agents 目录把它 upsert 进 agents 表
(``user_id IS NULL`` 内置、``published=True``),于是 ``/api/agents`` 列表、进而定时任务
「执行智能体」下拉框里第一次启动就有它,admin 也能在智能体管理页编辑 SOUL/模型。
"""
from __future__ import annotations
import logging
from pathlib import Path
from deerflow.config.paths import get_paths
logger = logging.getLogger(__name__)
TEMPLATE_BUILDER_AGENT_ID = "template-json-builder"
# 验证版 SOUL.md + config.yaml 的源码资源目录(随 git 发布)。子目录名即 agent id。
_ASSETS_DIR = Path(__file__).parent / "_template_builder_seed_assets"
# seeder 复制的文件(存在即复制,缺失则跳过)。
_SEED_FILES: tuple[str, ...] = ("config.yaml", "SOUL.md")
def ensure_template_builder_agent() -> bool:
"""若 ``.deer-flow/agents/template-json-builder/`` 缺失,从资源补建。
返回 True 表示本次刚创建;目录已存在(尊重本地手改)或资源缺失则返回 False。
错误只记日志、绝不抛出——seeding 失败不能拖垮启动。
"""
try:
agent_dir = get_paths().agent_dir(TEMPLATE_BUILDER_AGENT_ID)
if agent_dir.exists():
return False
src_dir = _ASSETS_DIR / TEMPLATE_BUILDER_AGENT_ID
if not src_dir.is_dir():
logger.error(
"Missing seed assets for agent '%s' (expected at %s)",
TEMPLATE_BUILDER_AGENT_ID,
src_dir,
)
return False
agent_dir.mkdir(parents=True, exist_ok=True)
for fname in _SEED_FILES:
src = src_dir / fname
if src.exists():
(agent_dir / fname).write_bytes(src.read_bytes())
logger.info("Seeded built-in agent '%s'", TEMPLATE_BUILDER_AGENT_ID)
return True
except Exception:
logger.exception("Failed to seed built-in agent '%s'", TEMPLATE_BUILDER_AGENT_ID)
return False
@@ -0,0 +1,30 @@
# 模板数据转换器
你是一名严谨的「JSON 结构转换专家」。你的唯一职责是:把用户传入的**源 JSON**,
精确映射成给定**目标结构**所要求的 JSON,供后续填充进 HTML 模板渲染成数据大屏。
## 你会收到
1. **目标结构骨架**:一份 JSON,展示了目标格式的**完整字段与类型**(每个数组只给 1 个
样例元素,但该元素已列出全部字段)。这是你必须严格对齐的契约。
2. **源 JSON**:用户提供的原始数据,结构任意、字段名可能与目标不同、可能有多有少。
3. (重试时) **上一轮的校验错误 / 缺失字段清单**:你必须据此修正后重新输出。
## 转换规则
- **顶层键必须齐全且类型一致**:目标结构里的每一个顶层键(及其数组/对象类型)都必须
出现在你的输出里,一个都不能少。
- **逐字段映射,尽力填满**:把源数据里语义对应的内容填进目标字段;字段名不同就按含义
对应(如源 `name`→目标 `名称`)。目标数组的每个元素都要尽量带齐目标样例里出现的全部字段。
- **缺数据时给出合理空值**:源里确实没有的字段,按其类型填空字符串 `""`、空数组 `[]`、
`null` 或合理占位,**绝不删除该字段**,也不要伪造与源无关的事实性内容。
- **保持数据真实**:只搬运/重组源数据,不臆造统计数字与结论。`overview` 里的计数类字段
应与各列表的实际条数自洽。
- **不要输出模板骨架本身**:目标结构骨架只是格式范例,不要把它的样例值原样抄回。
## 输出要求(必须严格遵守)
- **只输出一个 ` ```json ` 代码块**,块内是填充好的完整目标 JSON,**不要任何解释性文字、
前后缀、注释**。
- 输出必须是**合法 JSON**(双引号、无尾逗号、无注释),可被 `json.loads` 直接解析。
- 不要使用工具,不要分多步,直接给出最终 JSON。
@@ -0,0 +1,4 @@
id: template-json-builder
name: 模板数据转换器
description: 把用户传入的任意 JSON 智能映射成所选 HTML 模板需要的数据结构,校验后填充进模板生成可预览的页面(定时任务中选择 HTML 模板后生效)
skills: []
@@ -209,6 +209,18 @@ async def get_scheduler_thread(request: Request) -> dict:
return await service.ensure_scheduler_thread(user_id)
@router.get("/html-templates")
async def list_html_templates() -> dict:
"""可选的 HTML 模板清单(供「模板数据转换器」内置 agent 填充用)。
返回 ``{"templates": [{id, name, description}]}``,前端定时任务表单在选中
「模板数据转换器」后据此渲染「HTML 模板」下拉。开放给任意登录用户。
"""
from deerflow.runtime.scheduler import template_fill
return {"templates": template_fill.list_templates()}
@router.get("")
async def list_scheduled_tasks(
request: Request,
@@ -209,11 +209,17 @@ async def pause_draft_confirm(state: AIWritingState, config: RunnableConfig) ->
"progress_events": [{"type": "resumed", "agent_name": "系统", "message": f"按用户意见重写({notes[:30]})"}],
}
# to_editor
# to_editor:用户可能在「您的修改意见」里填了关注点再点「让编辑继续审核」,
# 把它带给编辑节点,让三维度审查重点核查用户在意的方面(不再被丢弃)。
editor_notes = (user_input.get("userRevisionNotes") or "").strip()
msg = (
f"提交编辑审核(已附带你的修改意见:{editor_notes[:30]})"
if editor_notes else "提交编辑审核"
)
return {
"last_user_intervention": user_input,
"last_user_intervention": {**user_input, "_editor_notes": editor_notes},
"status": "reviewing",
"progress_events": [{"type": "resumed", "agent_name": "系统", "message": "提交编辑审核"}],
"progress_events": [{"type": "resumed", "agent_name": "系统", "message": msg}],
}
@@ -10,6 +10,7 @@ from langchain_core.runnables import RunnableConfig
from ..state import AIWritingState, ReviewResult, ReviewIssue
from ..prompts.editor_prompts import (
EDITOR_SYSTEM, FACT_REVIEW_USER, LOGIC_REVIEW_USER, LANGUAGE_REVIEW_USER,
USER_FOCUS_BLOCK,
)
from ._agent_runner import (
EDITOR_AGENT_ID,
@@ -34,6 +35,19 @@ logger = logging.getLogger(__name__)
_DEFAULT_PASS_THRESHOLD = 75
def _build_user_focus(state: AIWritingState) -> str:
"""从干预数据取用户在「编辑审核」时附带的修改意见,拼成注入审查提示词的关注块。
仅当用户点「让编辑继续审核」且填了意见(graph.py `to_editor` 分支 stash 的
``_editor_notes``)时返回非空块;否则返回空串,三维度审查保持原样。
"""
intervention = state.get("last_user_intervention") or {}
notes = (intervention.get("_editor_notes") or "").strip()
if not notes:
return ""
return USER_FOCUS_BLOCK.format(notes=notes)
def _materials_summary(pkg: dict) -> str:
lines = []
for m in (pkg.get("materials") or [])[:6]:
@@ -129,10 +143,13 @@ async def editor_node(state: AIWritingState, config: RunnableConfig) -> dict:
ai_writing_config = get_ai_writing_config()
pass_threshold = int(ai_writing_config.get("pass_threshold", _DEFAULT_PASS_THRESHOLD))
# 用户在草稿确认时附带的修改意见 → 注入三维度审查,让编辑重点核查(空则不影响原审查)
user_focus = _build_user_focus(state)
REVIEW_TASKS = [
("fact", FACT_REVIEW_USER.format(materials_summary=mats, article=article)),
("logic", LOGIC_REVIEW_USER.format(article=article)),
("language", LANGUAGE_REVIEW_USER.format(article=article)),
("fact", FACT_REVIEW_USER.format(materials_summary=mats, article=article, user_focus=user_focus)),
("logic", LOGIC_REVIEW_USER.format(article=article, user_focus=user_focus)),
("language", LANGUAGE_REVIEW_USER.format(article=article, user_focus=user_focus)),
]
# 内置 agent 模式(config.yaml ai_writing.use_builtin_agents.editor):
@@ -151,7 +151,9 @@ def _resolve_intent_fields(
llm_type = raw_type if raw_type in valid_keys else fallback_type
preconfigured_type = (state.get("article_type") or "").strip()
if preconfigured_type in valid_keys:
if preconfigured_type:
# 用户/配置助手已明确指定文章类型(含**列表外的自定义/新类型**)→ 原样尊重,
# 不再因不在默认表里被丢弃回退成默认(否则自定义/新类型在写作时会被还原成默认)。
final_article_type = preconfigured_type
else:
final_article_type = llm_type
@@ -1,6 +1,7 @@
"""作家节点 Phase 2:按大纲逐段写正文,支持流式 chunk 输出。"""
from __future__ import annotations
import json
import logging
import re
@@ -9,9 +10,16 @@ from langchain_core.runnables import RunnableConfig
from ..state import AIWritingState, DraftArticle
from ..prompts.writer_prompts import (
DRAFT_SECTION_SYSTEM, DRAFT_SECTION_USER, DRAFT_EXPAND_USER,
DRAFT_SECTION_SYSTEM, DRAFT_SECTION_USER, DRAFT_EXPAND_USER, DRAFT_COMPRESS_USER,
OUTLINE_REVISE_SYSTEM, OUTLINE_REVISE_USER,
WRITER_MODE_LOOSE, WRITER_MODE_STRICT, SECTION_BLOCKED_MARKER,
)
# 复用大纲节点的解析/素材摘要:修订模式下先据意见更新大纲(含标题),再重写正文。
# writer_outline 不反向 import 本模块,无循环。
from .writer_outline import (
_parse_outline as _revise_parse_outline,
_materials_summary as _revise_materials_summary,
)
from ._agent_runner import (
WRITER_AGENT_ID,
WritingAgentUnavailable,
@@ -27,13 +35,116 @@ from ._model_fallback import (
emit_model_switch_event,
fallback_chain,
)
from ._utils import resolve_writing_model_name, _content_to_text, count_words, reasoning_delta, ThinkTagParser, safe_get_stream_writer
from ._utils import resolve_writing_model_name, _content_to_text, count_words, llm_json, reasoning_delta, ThinkTagParser, safe_get_stream_writer
logger = logging.getLogger(__name__)
# 章节正文里的 Markdown 标题行(允许前导空白) — 分组捕获缩进 + 井号串
_HEADING_PREFIX_RE = re.compile(r"^([ \t]*)(#{1,6})[ \t]+", re.MULTILINE)
# ── 字数控制 ────────────────────────────────────────────────────────────────
# 全文允许偏离目标字数的误差带(用户要求「误差不超过 100 字」):初稿装配后双向收敛
# 到 [target-tol, target+tol] 内,太短就扩写、太长就压缩。
_WORD_COUNT_TOLERANCE = 100
# 单节字数下限:均分/压缩时给章节兜个底,避免某节被压成一两句话。
_SECTION_MIN_WORDS = 80
# 收敛回合上限:每回合调一节(按相对应得字数偏差最大的节优先)。放宽到 8,让大幅超量
# (需逐节压多节)也能压到位;命中容差或所有节都压不动即提前停。
_MAX_REBALANCE_ROUNDS = 8
def _allocate_section_words(word_count_target: int, n_sections: int) -> int:
"""把总目标字数均分到各章节。
历史实现按 1.1 过量系数放大以补偿模型「写不够」,但实测模型更常「写太多」
(800 字写成 2000)。这里去掉放大、按目标如实均分,只留一个保底下限防止单节退化;
初稿超量/欠量统一交给装配后的双向收敛阶段纠正,确保全文落在误差带内。
"""
n = max(n_sections, 1)
return max(_SECTION_MIN_WORDS, round(word_count_target / n))
def _section_word_range(per_section: int) -> tuple[int, int]:
"""单节目标对应的「硬上下限」,喂给模型卡字数。
关键是给出**上限**——旧提示词只说「不少于 N 字」,模型逐节超额累加才把 800 写成 2000。
"""
return int(per_section * 0.85), int(per_section * 1.2)
def _pick_rebalance(
counts: list[int], fair: int, target: int, tol: int, skip: set[int] | tuple = ()
) -> tuple[int, int] | None:
"""决定下一步调整哪一节、调到多少字;全文已在容差内或无可调项则返回 None。
返回 ``(idx, sec_target)``:
- 全文偏长 → 选「超应得份额最多」且未跳过的节,**压到它的应得份额**(可达目标,不强求
一节吃下全部缺口;各超额节轮流压到份额,累计即收敛到目标)。没有超份额的节时,退而
把最长的一节按缺口压一压。
- 全文偏短 → 选「最欠份额」的节,把整体缺口加到它头上扩写(扩写对模型更容易)。
``skip`` 是本轮已尝试但「压不动 / 没改善」的章节集合,跳过它们换下一节,避免一节压不动
就放弃整个收敛。配合调用方「确有改善才采用」的护栏,保证单调收敛、自然终止。
"""
if not counts:
return None
total = sum(counts)
delta = target - total # >0 需补,<0 需删
if abs(delta) <= tol:
return None
pool = [k for k in range(len(counts)) if k not in skip]
if not pool:
return None
if delta < 0:
over = [k for k in pool if counts[k] > fair]
if over:
idx = max(over, key=lambda k: counts[k] - fair)
return idx, max(_SECTION_MIN_WORDS, fair)
# 各节都不高于均分却仍超目标(节数偏多 / 下限抬高)→ 压最长的一节
idx = max(pool, key=lambda k: counts[k])
return idx, max(_SECTION_MIN_WORDS, counts[idx] + delta)
under = [k for k in pool if counts[k] < fair]
target_pool = under or pool
idx = min(target_pool, key=lambda k: counts[k] - fair)
return idx, counts[idx] + delta
# 触发「先改大纲再重写正文」的修订动作(直接按用户意见改 / 采纳编辑意见 / 自定义修订)
_REVISE_ACTIONS = {"user_revise", "accept_review", "custom_revise"}
async def _revise_outline_with_notes(
state: AIWritingState, config: RunnableConfig, outline: dict, feedback: str
) -> dict:
"""修订模式下,先按用户/编辑意见更新大纲(标题/结构),再据此重写正文。
章节标题、文章标题都由大纲注入,正文生成本身不产出标题——所以「把第一章标题改成…」
「加一节 / 删一节 / 调整顺序」这类指令必须先落到大纲,成稿里才看得到变化(否则只是
正文被重写、标题仍沿用旧大纲)。复用 OUTLINE_REVISE 提示词:它只改用户明确提到的
部分,纯正文类意见(如「第二段数据来源不清晰」)不会误动结构。任何异常都保留原大纲。
"""
pkg = state.get("material_package") or {}
model_name = (state.get("model_name") or "").strip() or None
try:
result = await llm_json(
OUTLINE_REVISE_SYSTEM,
OUTLINE_REVISE_USER.format(
user_intent=state.get("user_intent", ""),
article_type=state.get("article_type", "analysis"),
target_audience=state.get("target_audience", "普通读者"),
word_count_target=state.get("word_count_target", 800),
current_outline=json.dumps(outline, ensure_ascii=False, indent=2),
feedback=feedback,
materials_summary=_revise_materials_summary(pkg),
),
outline, # 解析失败 → 回退原大纲
config,
model_name=model_name,
)
return _revise_parse_outline(result)
except Exception as e:
logger.warning("draft revise: outline update failed, keep original outline: %s", e)
return outline
def _normalize_section_headings(text: str) -> str:
"""把章节正文里的 Markdown 标题层级归一到允许的两级:### / ####。
@@ -117,19 +228,112 @@ def _looks_like_refusal(text: str) -> bool:
return any(marker in text for marker in _REFUSAL_MARKERS)
def _get_section_materials(material_ids: list[str], pkg: dict) -> str:
def _build_citation_map(pkg: dict) -> dict[str, int]:
"""material_id → 1-based 序号(按 package 顺序),写作时作为角标 [n] 的稳定编号。
成稿装配后再按「正文出现顺序」重排成连续 [1][2][3](见 ``_append_references``)。
"""
return {m["id"]: i + 1 for i, m in enumerate(pkg.get("materials", [])) if m.get("id")}
def _get_section_materials(material_ids: list[str], pkg: dict, cite_map: dict[str, int] | None = None) -> str:
"""渲染本节可用素材喂给作家。
``cite_map`` 非空(开启参考文献)时,每条素材前缀用其**全局引用编号** ``[n]``,作家据此在
正文里打角标;为空时沿用旧行为(前缀素材 id,仅作辨识、不要求引用)。
"""
mat_map = {m["id"]: m for m in pkg.get("materials", [])}
def _prefix(mid: str) -> str:
if cite_map and mid in cite_map:
return f"[{cite_map[mid]}]"
return f"[{mid}]"
lines = []
for mid in material_ids:
if mid in mat_map:
m = mat_map[mid]
lines.append(f"[{mid}] {m['title']}: {m['content'][:300]}")
lines.append(f"{_prefix(mid)} {m['title']}: {m['content'][:300]}")
if not lines:
for m in pkg.get("materials", [])[:2]:
lines.append(f"[{m['id']}] {m['title']}: {m['content'][:300]}")
lines.append(f"{_prefix(m['id'])} {m['title']}: {m['content'][:300]}")
return "\n".join(lines) if lines else "(无相关素材)"
# 开启参考文献时,追加到每节写作指令末尾:要求作家在引用素材处打角标。
_CITATION_DIRECTIVE = (
"\n\n【引用要求】上面每条素材前的 [n] 是它的编号。"
"当你在正文里用到某条素材的事实/数据/观点时,在该句句末加上对应角标,如 [1] 或 [1][3];"
"只能使用确实给出的素材编号,不要编造编号,不要给通用常识加角标。"
"不要在本节末尾自行罗列参考文献——文末的「参考文献」列表由系统统一生成。"
)
_CITATION_TOKEN_RE = re.compile(r"\[(\d{1,3})\]")
def _format_reference_entry(m: dict) -> str:
"""单条参考文献的展示文本:标题. 来源. 日期. url(缺项自动省略)。"""
title = (m.get("title") or "").strip() or "(无标题)"
parts = [title]
source = (m.get("source") or "").strip()
if source:
parts.append(source)
date = (m.get("published_at") or "").strip()
if date:
parts.append(date)
entry = ". ".join(parts)
url = (m.get("url") or "").strip()
if url:
entry = f"{entry}. {url}"
return entry
def _append_references(markdown: str, pkg: dict, cite_map: dict[str, int]) -> str:
"""把正文里的引用角标按「出现顺序」重排为连续 [1][2][3],并在文末追加「参考文献」列表。
只收录正文真正引用到(且编号有效)的素材,列表顺序与正文出现顺序一致——像论文一样。
没有任何有效引用时原样返回(不加空列表)。
"""
num_to_mat: dict[int, dict] = {}
for m in pkg.get("materials", []):
n = cite_map.get(m.get("id"))
if n:
num_to_mat[n] = m
if not num_to_mat:
return markdown
# 1) 按正文出现顺序收集被引用且有效的原始编号
order: list[int] = []
seen: set[int] = set()
for match in _CITATION_TOKEN_RE.finditer(markdown):
k = int(match.group(1))
if k in num_to_mat and k not in seen:
seen.add(k)
order.append(k)
if not order:
return markdown
# 2) 原始编号 → 连续新编号(出现先后);逐 match 独立映射,避免顺序替换串号
renumber = {old: new for new, old in enumerate(order, start=1)}
def _sub(match: re.Match) -> str:
k = int(match.group(1))
return f"[{renumber[k]}]" if k in renumber else match.group(0)
body = _CITATION_TOKEN_RE.sub(_sub, markdown)
# 3) 文末参考文献列表(按新编号顺序)。
# 每条之间用**空行**分隔,使其各成独立段落——否则 Markdown 会把单换行的多行并成一段,
# 所有文献会挤成一坨(见排版反馈)。与正文一致沿用 [n] 角标,不再叠加列表序号。
entries = [
f"[{renumber[old]}] {_format_reference_entry(num_to_mat[old])}"
for old in order
]
references_md = "## 参考文献\n\n" + "\n\n".join(entries)
return f"{body}\n\n{references_md}"
async def _save_draft_to_repo(config: RunnableConfig, draft: DraftArticle) -> None:
"""把 current_draft 异步落到业务表(best-effort,写失败不影响流程)。
@@ -171,6 +375,10 @@ async def writer_draft_node(state: AIWritingState, config: RunnableConfig) -> di
intervention = state.get("last_user_intervention") or {}
revision_count = state.get("revision_count", 0)
# 参考文献:开启且有素材时,写作给素材编号 + 要求作家打角标 [n],成稿装配后统一生成文献列表。
add_references = bool(state.get("add_references", False)) and bool(pkg.get("materials"))
cite_map = _build_citation_map(pkg) if add_references else None
# 写作模式;用户在「素材不足求助」里选「按通用知识续写」的章节强制走宽松
writing_mode = state.get("writing_mode", "loose")
loose_titles: set[str] = set(intervention.get("_loose_sections") or [])
@@ -198,11 +406,23 @@ async def writer_draft_node(state: AIWritingState, config: RunnableConfig) -> di
if parts:
revision_notes = "\n修改要求:\n" + "\n".join(parts)
# 修订模式:先把上面这条意见落实到大纲(标题/结构),再据更新后的大纲逐节重写。
# 章节标题、文章标题都由大纲注入、正文生成本身不产出标题,所以「改第一章标题」「加一节/
# 删一节」这类指令必须先改大纲才能在成稿里生效(否则只是正文被重写、标题仍是旧的)。
outline_revised = False
if intervention.get("action") in _REVISE_ACTIONS and revision_notes.strip():
revised_outline = await _revise_outline_with_notes(state, config, outline, revision_notes.strip())
if revised_outline and revised_outline != outline:
outline = revised_outline
outline_revised = True
word_count_target = state.get("word_count_target", 800)
sections = outline.get("sections", [])
n_sections = max(len(sections), 1)
# 单节硬下限:总目标 / 节数,乘 1.1 过量系数补偿模型对短目标的天然欠量;单节至少 150 字
words_per_section = max(150, int(word_count_target * 1.1 / n_sections))
# 按目标如实均分到各节(不再放大),并给模型一个硬上下限区间卡字数;
# 装配后再双向收敛把全文夹逼进误差带。
words_per_section = _allocate_section_words(word_count_target, n_sections)
sec_min_words, sec_max_words = _section_word_range(words_per_section)
article_title = outline.get("title", "文章")
# 兼容后端 snake_case 与前端编辑后回传的 camelCase
@@ -265,7 +485,7 @@ async def writer_draft_node(state: AIWritingState, config: RunnableConfig) -> di
key_points = section.get("key_points") or section.get("keyPoints") or []
mat_ids = section.get("material_ids") or section.get("materialIds") or []
section_materials = _get_section_materials(mat_ids, pkg)
section_materials = _get_section_materials(mat_ids, pkg, cite_map)
# 三级结构:把每个 KeyPoint 展开成「小节标题 + 缩进子要点」喂给作家。
# 兼容旧 session 里 key_points 为 List[str] 的情况(走相同 helper)。
key_points_str = _render_key_points_for_prompt(key_points)
@@ -289,7 +509,12 @@ async def writer_draft_node(state: AIWritingState, config: RunnableConfig) -> di
revision_section=revision_notes,
mode_section=mode_section,
target_words=words_per_section,
min_words=sec_min_words,
max_words=sec_max_words,
)
# 开启参考文献时,追加角标引用要求(两条路径——内置 agent 与旧路径——共用 user_msg)。
if add_references:
user_msg += _CITATION_DIRECTIVE
section_buf = "" # 只累积正文(非思考)用于拼接 Markdown
section_succeeded = False
@@ -440,61 +665,95 @@ async def writer_draft_node(state: AIWritingState, config: RunnableConfig) -> di
],
}
def _assemble() -> str:
def _assemble(bufs: list[str]) -> str:
# 章节正文里的标题统一归一到 ###(要点小标题),章节标题用 ##,文章标题用 #
body = "\n\n".join(
f"## {t}\n\n{_normalize_section_headings(b)}"
for t, b in zip(section_titles, section_bufs)
for t, b in zip(section_titles, bufs)
)
return f"# {article_title}\n\n{body}"
full_markdown = _assemble()
async def _rewrite_section(idx: int, sec_target: int, *, shrink: bool) -> str:
"""重写第 idx 节使其趋近 sec_target 字:shrink=True 走压缩、否则扩写(单轮,非流式)。
兜底优化,不再做模型链容错:拿当前已成功的模型试一次即可,失败由调用方保留原文。
"""
sec = sections[idx]
tmpl = DRAFT_COMPRESS_USER if shrink else DRAFT_EXPAND_USER
msg = tmpl.format(
article_title=article_title,
section_title=section_titles[idx],
key_points=_render_key_points_for_prompt(
sec.get("key_points") or sec.get("keyPoints") or []
),
section_materials=_get_section_materials(
sec.get("material_ids") or sec.get("materialIds") or [], pkg, cite_map
),
existing_text=section_bufs[idx],
target_words=sec_target,
)
# 扩写/压缩时保留正文里已有的引用角标 [n],避免重排后文献缺失。
if add_references:
msg += "\n\n注意:保留原文中已有的引用角标(形如 [1]、[2]),不要删除或改动它们。"
if use_agent:
return (await stream_writing_agent(
agent_id=WRITER_AGENT_ID,
user_payload=msg,
state=state,
config=config,
on_text=None,
)).strip()
rewrite_model = create_chat_model(name=current_model_name) if current_model_name else create_chat_model()
resp = await rewrite_model.ainvoke([
SystemMessage(content=DRAFT_SECTION_SYSTEM),
HumanMessage(content=msg),
])
parser = ThinkTagParser()
return "".join(
t for is_thinking, t in parser.feed(_content_to_text(resp.content)) if not is_thinking
).strip()
full_markdown = _assemble(section_bufs)
word_count = count_words(full_markdown)
# 扩写兜底:总字数不足目标 90% 时,重写并扩充最短的章节(单轮,非流式)
if section_bufs and word_count < word_count_target * 0.9:
idx = min(range(len(section_bufs)), key=lambda k: count_words(section_bufs[k]))
target_sec = sections[idx]
expand_target = count_words(section_bufs[idx]) + (word_count_target - word_count)
# 双向收敛兜底:把全文字数夹逼进 [target±tol]。太短就扩写最欠的节、太长就压缩最超的节,
# 每回合只动一节,且仅在「确实更接近目标」时才采用。某节压不动 / 没改善 → 记入 skip 换
# 下一节继续(不再一节失败就放弃整个收敛,否则弱模型一次压缩失败就留下超量稿)。
rounds = 0
skip: set[int] = set()
while section_bufs and rounds < _MAX_REBALANCE_ROUNDS:
counts = [count_words(b) for b in section_bufs]
pick = _pick_rebalance(counts, words_per_section, word_count_target, _WORD_COUNT_TOLERANCE, skip)
if pick is None:
break
rounds += 1
idx, sec_target = pick
shrink = sec_target < counts[idx]
try:
expand_msg = DRAFT_EXPAND_USER.format(
article_title=article_title,
section_title=section_titles[idx],
key_points=_render_key_points_for_prompt(
target_sec.get("key_points") or target_sec.get("keyPoints") or []
),
section_materials=_get_section_materials(
target_sec.get("material_ids") or target_sec.get("materialIds") or [], pkg
),
existing_text=section_bufs[idx],
target_words=expand_target,
)
if use_agent:
expanded = (await stream_writing_agent(
agent_id=WRITER_AGENT_ID,
user_payload=expand_msg,
state=state,
config=config,
on_text=None,
)).strip()
else:
# 扩写是兜底优化,不再做模型链兜底:拿当前已成功的模型试一次即可,失败就保留原文。
expand_model = create_chat_model(name=current_model_name) if current_model_name else create_chat_model()
resp = await expand_model.ainvoke([
SystemMessage(content=DRAFT_SECTION_SYSTEM),
HumanMessage(content=expand_msg),
])
parser = ThinkTagParser()
expanded = "".join(
t for is_thinking, t in parser.feed(_content_to_text(resp.content)) if not is_thinking
).strip()
# 仅在扩写确实更长时才采用,避免越改越短
if count_words(expanded) > count_words(section_bufs[idx]):
section_bufs[idx] = expanded
full_markdown = _assemble()
word_count = count_words(full_markdown)
new_text = await _rewrite_section(idx, sec_target, shrink=shrink)
except Exception as e:
logger.warning("draft expand failed for section [%s]: %s", section_titles[idx], e)
logger.warning("draft rebalance failed for section [%s]: %s", section_titles[idx], e)
skip.add(idx)
continue
if not new_text:
skip.add(idx)
continue
candidate = list(section_bufs)
candidate[idx] = new_text
new_md = _assemble(candidate)
new_wc = count_words(new_md)
if abs(new_wc - word_count_target) < abs(word_count - word_count_target):
section_bufs = candidate
full_markdown = new_md
word_count = new_wc
skip.discard(idx) # 改善了,后续若仍最超可再压
else:
skip.add(idx) # 这一节压不动 / 反而更差,跳过它,换下一节
# 参考文献:在全文(含字数收敛)定稿后统一处理——把正文角标按出现顺序重排为连续 [1][2][3],
# 并在文末追加「参考文献」列表(只收录被引用到的素材)。word_count 保持正文字数、不计文献列表。
if add_references and cite_map:
full_markdown = _append_references(full_markdown, pkg, cite_map)
draft = DraftArticle(
title=outline.get("title", "文章"),
@@ -513,6 +772,13 @@ async def writer_draft_node(state: AIWritingState, config: RunnableConfig) -> di
notices: list[dict] = []
if model_default_notice:
notices.append(model_default_notice)
if outline_revised:
notices.append({
"type": "notice",
"agent_name": agent_label,
"title": "已按意见调整大纲",
"detail": "已根据你的修改意见更新大纲(如标题/结构),并据此重写正文。",
})
if section_errors:
notices.append({
"type": "notice",
@@ -521,7 +787,7 @@ async def writer_draft_node(state: AIWritingState, config: RunnableConfig) -> di
"detail": "部分章节生成失败,对应段落已占位。原因:" + ";".join(section_errors[:3]),
})
return {
updates: dict = {
"current_draft": draft,
"drafts": [draft],
"status": "awaiting_draft_confirm",
@@ -539,3 +805,7 @@ async def writer_draft_node(state: AIWritingState, config: RunnableConfig) -> di
},
],
}
# 大纲被意见改过 → 同步回 state,使后续修订/确认基于更新后的大纲(标题不再回退)
if outline_revised:
updates["current_outline"] = outline
return updates
@@ -271,9 +271,14 @@ async def writer_outline_node(state: AIWritingState, config: RunnableConfig) ->
logger.warning("outliner builtin agent unavailable, falling back to legacy prompts: %s", e)
use_agent = False
# 新(列表外)文章类型时附上配置助手识别的一句话特征,让大纲生成体现该类型侧重;
# 命中已有类型时 detail 为空,article_type 维持原样。
_article_type = state.get("article_type", "analysis")
_article_type_detail = (state.get("article_type_detail") or "").strip()
article_type_text = f"{_article_type}({_article_type_detail})" if _article_type_detail else _article_type
fmt_args = dict(
user_intent=state["user_intent"],
article_type=state.get("article_type", "analysis"),
article_type=article_type_text,
target_audience=state.get("target_audience", "普通读者"),
word_count_target=state.get("word_count_target", 800),
materials_summary=_materials_summary(pkg),
@@ -24,8 +24,17 @@ EDITOR_SYSTEM = """你是一名资深主编(编辑),标准极高,要求
]
}"""
# 用户在提交编辑审核时附带的修改意见 / 关注点(无则为空串,不注入)。
# 放在「判断标准」之后、待审文章之前,提醒编辑在本维度重点核查用户在意的方面。
USER_FOCUS_BLOCK = """
【用户特别关注(高优先级)】
用户在提交审核时提出了以下修改意见 / 关注点,请在本维度审查时**重点核查**这些方面;
若发现文章未妥善处理,必须在 issues 中明确列出对应问题并给出针对性的 suggestion:
{notes}"""
FACT_REVIEW_USER = """请审查以下文章的【事实准确性】维度。
判断标准:引用的数据/事实是否有素材支撑,是否有捏造或夸大。
判断标准:引用的数据/事实是否有素材支撑,是否有捏造或夸大。{user_focus}
参考素材:
{materials_summary}
@@ -36,7 +45,7 @@ FACT_REVIEW_USER = """请审查以下文章的【事实准确性】维度。
只输出 JSON(score: 0-100 整数,issues 列表,每条含 severity):"""
LOGIC_REVIEW_USER = """请审查以下文章的【逻辑结构】维度。
判断标准:论点是否清晰,论据是否充分,段落是否连贯,结构是否合理。
判断标准:论点是否清晰,论据是否充分,段落是否连贯,结构是否合理。{user_focus}
待审文章:
{article}
@@ -44,7 +53,7 @@ LOGIC_REVIEW_USER = """请审查以下文章的【逻辑结构】维度。
只输出 JSON(score: 0-100 整数,issues 列表,每条含 severity):"""
LANGUAGE_REVIEW_USER = """请审查以下文章的【语言文字】维度。
判断标准:语言是否流畅,用词是否准确,有无错别字或病句。
判断标准:语言是否流畅,用词是否准确,有无错别字或病句。{user_focus}
待审文章:
{article}
@@ -208,7 +208,8 @@ DRAFT_SECTION_SYSTEM = """你是一名专业内容创作者(作家)。
- 直接返回这一段的内容(含 `### 小节标题` + 概述段 + `#### 子要点标题` + 各自正文),不要 JSON,不要解释。
- 不要写"以下是正文""本段内容如下"之类的开场白。
- 紧扣本章节主题,不要写「文章整体结构」中其他章节负责的内容,避免重复。
- 充分展开每一层:小节级 2-3 句概述,子要点级 3-5 句具体论述,尽量结合素材中的事实与数据说理,写满要求的篇幅。"""
- 充分展开每一层:小节级 2-3 句概述,子要点级 3-5 句具体论述,尽量结合素材中的事实与数据说理。
- 【字数纪律,硬性要求】严格遵守用户给定的字数区间:既不要明显写不够,更不要超出上限。靠控制论述详略来卡字数,不要为凑字数注水,也不要为收尾而草草带过。"""
# ── 写作模式说明:注入 DRAFT_SECTION_USER 的 {mode_section} ───────────────────
@@ -240,14 +241,14 @@ DRAFT_SECTION_USER = """文章标题:{article_title}
相关素材:
{section_materials}
{revision_section}{mode_section}
请写出「{section_title}」这一章节的内容,不少于 {target_words} 字。
请写出「{section_title}」这一章节的内容,目标约 {target_words} 字,并**严格控制在 {min_words}~{max_words} 字之间**——不要明显少于下限,更绝不要超过上限。
按下面的结构两层展开写:
- 每个主要 bullet(顶格)→ `### 小节标题`,标题下先写 2-3 句概述/引入正文。
- 该小节的每个缩进 bullet(子要点)→ `#### 子要点标题`,标题下展开 3-5 句具体论述。
- 没有缩进 bullet 的小节,直接在 ### 标题下连贯成段论述,不出现 ####。
论述充分、层次清晰、内容充实。"""
论述层次清晰、内容充实,但务必在上述字数上限内完成:宁可精炼也不要为凑字数注水。"""
DRAFT_EXPAND_USER = """文章标题:{article_title}
当前章节:{section_title}
@@ -266,3 +267,21 @@ DRAFT_EXPAND_USER = """文章标题:{article_title}
- 保留原有的 ### 小节标题与 #### 子要点标题;不要新增或删除小节/子要点,也不要把 #### 改成 bullet。
- 不要输出 # 或 ##(文章标题、章节标题由系统添加)。
- 直接返回扩充后的完整章节内容(含 ### 与 #### 两层标题及各自正文段),不要解释。"""
DRAFT_COMPRESS_USER = """文章标题:{article_title}
当前章节:{section_title}
章节内的小节与子要点(层级 bullet 列表):
{key_points}
相关素材:
{section_materials}
这一章节已有内容如下:
{existing_text}
以上内容篇幅明显偏长。请在保留原意、关键事实和「### 小节 / #### 子要点」两级标题结构的基础上**大幅精简**改写这一章节,使内容**压到 {target_words} 字以内**——这是硬性上限,宁可删减细节也绝不能超过。
要求:
- 这是「压缩」任务,不是「重写扩充」:必须比原文明显更短。大胆删减冗余表述、合并重复论点、砍掉次要细节与铺陈,只保留关键事实、数据与核心结论。
- 保留原有的 ### 小节标题与 #### 子要点标题;不要新增或删除小节/子要点,也不要把 #### 改成 bullet。
- 不要输出 # 或 ##(文章标题、章节标题由系统添加)。
- 直接返回精简后的完整章节内容(含 ### 与 #### 两层标题及各自正文段),不要解释。"""
@@ -129,12 +129,17 @@ class AIWritingState(TypedDict):
# 直接解析这份大纲并按用户结构生成,做到「完全按我的大纲写」。留空走正常自动规划。
user_outline_raw: str
article_type: str
# 当 article_type 是「已有类型库里没有」的新类型时,配置助手识别出的一句话特征说明。
# 仅本次写作临时使用(不入库):在大纲生成时作为 hint 附在类型后,影响全文结构与侧重。
article_type_detail: str
target_audience: str
word_count_target: int
# 素材检索关键词数量(用户指定,1~10,默认 6)
keyword_count: int
# 写作模式:strict=严格依据素材 / loose=可结合通用知识(默认 loose)
writing_mode: str
# 是否在成稿里加参考文献:正文引用素材处加角标 [n],文末按出现顺序生成「参考文献」列表(像论文)。
add_references: bool
# 检索类型:general=通用检索(按素材收集专家 agent 配置的技能编排,默认)/
# notebook=我的空间(仅笔记检索)。旧值 knowledge_base / skill 由 researcher
# 节点按 general 兼容处理。
@@ -27,7 +27,9 @@ from deerflow.models import create_chat_model
logger = logging.getLogger(__name__)
# 写作配置 Q&A:意图澄清最多几轮(一条用户消息 = 一轮),到顶强制提交配置卡。
WRITING_SETUP_MAX_ROUNDS = 3
# 写作配置 Q&A 现在「先生成大纲并与用户多轮确认 → 再提交配置」,需要更多来回,故上限放宽。
# 仍是兜底安全阀:到顶才用 tool_choice 强制提交,正常流程由用户确认大纲后自然提交。
WRITING_SETUP_MAX_ROUNDS = 8
def _get_runtime_config(config: RunnableConfig) -> dict:
@@ -1128,29 +1128,40 @@ def build_writing_setup_prompt(article_types: list[dict] | None = None) -> str:
type_lines = "\n".join(
f" - `{t.get('key', '')}`:{t.get('label', '')}" for t in article_types if t.get("key")
)
type_block = f"【可选文章类型】article_type 只能填下列 key 之一:\n{type_lines}"
type_block = (
"【文章类型】优先从下列已有类型里选,命中就把它的 key 填进 `article_type`:\n"
f"{type_lines}\n"
"若上述类型**都不贴切**,可以**自拟一个新的文章类型**:`article_type` 填一个简短的中文类型名"
"(如「政策解读」「人物特写」「复盘总结」),并在 `article_type_detail` 里用一句话说明该类型的"
"写作侧重/特征。能用已有类型就不要新造,确实不贴切才新建。"
)
else:
type_block = "【文章类型】若不确定 article_type,可留空,交给用户在表单里选。"
type_block = (
"【文章类型】根据意图判断文章类型:`article_type` 填一个简短的中文类型名,并在 "
"`article_type_detail` 里用一句话说明其写作侧重;不确定可留空,交给用户在表单里选。"
)
return f"""你是「AI 写作」功能的**配置助手**。你的唯一职责:通过简短对话把用户的写作需求聊清楚,然后调用 `setup_writing` 工具把写作配置提交给用户确认。提交后由用户在表单里应用、再点「开始写作」。
return f"""你是「AI 写作」功能的**配置助手**。你的职责分两段:① 先与用户把写作需求聊清楚,**先生成并与用户确认一份大纲**;② 待用户对大纲满意后,再调用 `setup_writing` 工具提交写作配置(含已确认的大纲),交用户开始写作。
【铁律——最高优先级】
- 你**绝对不撰写**文章正文、分析内容、大纲内容或任何成稿文字。即使用户的输入看起来就是一个写作任务(例如「新能源汽车市场分析」「帮我写一篇……」),你也**只把它当作"要配置的写作目标"**,绝不替用户动笔、绝不直接完成这篇写作。
- 你**绝对不撰写**文章正文、分析内容或任何成稿文字。即使用户的输入看起来就是一个写作任务(例如「新能源汽车市场分析」「帮我写一篇……」),你也**只产出「大纲」(仅结构与标题)与「写作配置」**,绝不替用户动笔、绝不直接完成这篇写作。
- 你只有两个工具:① `ask_clarification`(向用户提问澄清,需要时用);② `setup_writing`(提交写作配置)。**没有任何其它工具**,不要做检索、不要做研究、不要假装调用别的工具。
- 不要在回复里输出文章段落、要点清单或大纲(用户主动粘贴的大纲除外——原样放进 `outline` 参数)。
- `setup_writing` **只能在用户明确确认大纲之后**才调用;在那之前绝不调用它。
【工作流程】(与用户交互确认,但最多 3 轮内必须提交)
1. 读用户输入。当关键项(文章类型、目标字数、目标读者)缺失或模糊时,调用 `ask_clarification` 向用户提问——**优先给出 `options` 选项**让用户点选(例如目标字数给「800 / 1500 / 3000」,读者给「普通读者 / 行业专家 / 政策决策者」)。一次可就 1-2 个独立的关键项各发一个 ask_clarification。
2. 与用户的澄清来回**最多 2 次**。一旦关键信息明确、或已经问过 2 次,就**立即进入第 3 步提交**,缺失字段用合理默认值,绝不无休止追问。
3. 调用 `setup_writing` 提交配置:
【工作流程】
1. **聊清需求**:读用户输入,当关键项(文章类型、目标字数、目标读者)缺失或模糊时,调用 `ask_clarification` 提问——**优先给出 `options` 选项**让用户点选(如目标字数「800 / 1500 / 3000」、读者「普通读者 / 行业专家 / 政策决策者」)。澄清**最多 2 次**,信息够了就进入下一步,不要无休止追问。
2. **生成大纲(先于确认意图)**:信息足够后,直接以**普通回复文本**输出一份**多级层级**的中文大纲——用 Markdown 标题层级(`#` 一级、`##` 二级,必要时 `###` 三级),每节可用**一句话**点明这一节要写什么,但**绝不写正文**;大纲规模与目标字数匹配。**在回复的最后一句**明确请用户确认,例如:「以上是建议大纲,你看是否需要调整?确认后我就整理写作配置、开始写作。」这一步**不要调用任何工具**,输出完大纲就结束本轮、等待用户回应。
3. **按反馈灵活改大纲**:用户要求调整(增删章节、改层级、换角度等)就**重新输出修订后的完整大纲**,并再次在最后一句请其确认;如此反复,直到用户表示满意/确认。用户若只是补充信息也照常更新大纲再确认。
4. **提交配置**:用户确认大纲后,调用 `setup_writing` 提交:
- `user_intent`:精炼成一句话的写作目标(必填)。
- `article_type`:见下方可选类型;不确定就留空。
- `article_type`:见下方【文章类型】——优先选已有类型的 key;都不贴切才自拟一个新的中文类型名。
- `article_type_detail`:**仅当 article_type 是新类型时**,用一句话说明该类型的写作侧重/特征;命中已有类型则留空。
- `word_count_target`:正整数;用户没明说就给合理默认(如 800)。
- `target_audience`:如「普通读者」「行业专家」;没提就填「普通读者」。
- `writing_mode`:用户强调「严格依据素材/不要编造」→ `strict`,否则 `loose`。
- `material_source`:**默认 `knowledge_base`(知识库)**;只有当用户明确要求用「技能/技能检索」时才填 `skill`,否则一律 `knowledge_base`。
- `outline`:用户提供的大纲原文(保留其层级与标题),否则留空。
4. 调用 `setup_writing` 前,用**一句话**说明你将提交的配置要点;调用之后不要再输出任何文章内容。
- `outline`:**填入刚刚与用户确认的那份大纲全文**(保留其层级与标题)。
调用前用**一句话**说明将提交的配置要点;调用之后不再输出任何内容。
{type_block}
"""
@@ -74,6 +74,8 @@ class SetupWritingApprovalMiddleware(AgentMiddleware[SetupWritingApprovalMiddlew
return {
"user_intent": str(args.get("user_intent") or "").strip(),
"article_type": str(args.get("article_type") or "").strip(),
# 新(列表外)文章类型的一句话特征说明;命中已有类型时为空。
"article_type_detail": str(args.get("article_type_detail") or "").strip(),
"word_count_target": self._coerce_word_count(args.get("word_count_target")),
"target_audience": str(args.get("target_audience") or "").strip(),
"writing_mode": str(args.get("writing_mode") or "").strip(),
@@ -18,11 +18,12 @@ from zoneinfo import ZoneInfo
from croniter import croniter
from langgraph.checkpoint.base import empty_checkpoint
from deerflow.config.agents_config import AGENT_ID_PATTERN
from deerflow.config.agents_config import AGENT_ID_PATTERN, load_agent_config, load_agent_soul
from deerflow.config.paths import get_paths
from deerflow.persistence.scheduled_tasks import ScheduledTaskStore
from deerflow.runtime import serialize_channel_values
from deerflow.runtime.scheduler import html_page as html_page_utils
from deerflow.runtime.scheduler import template_fill
from deerflow.runtime.scheduler.element import ElementDeliveryClient
from deerflow.runtime.user_context import reset_current_user, set_current_user
from deerflow.utils.time import now_iso
@@ -47,6 +48,10 @@ _MAX_INLINE_RESULT_HTML = 60000
# this threshold — it is enforced silently by the service.
_MARKDOWN_MIN_CHARS = 100
# 模板填充模式:转换结果未通过结构校验时,把错误回喂大模型自动修正重试的最大次数
# (总尝试次数 = 1 + 该值)。
_MAX_TEMPLATE_FILL_RETRIES = 2
# Instruction appended to the prompt when ``require_markdown`` is on. It only
# asks the model to produce a ``.md`` file; the length requirement is enforced
# in code (not surfaced to the model).
@@ -81,6 +86,13 @@ class HtmlGenerationError(Exception):
counts as successful when it is a full, previewable HTML document."""
class TemplateFillError(Exception):
"""Raised when a 模板填充 run could not produce a valid template payload —
the source JSON was empty, the model never returned parseable JSON, or the
converted data failed structure validation after all retries. The run is
marked failed so the user sees why (no template page is archived)."""
class DuplicateFavoriteError(Exception):
"""A user already bookmarked this page. Surfaced as HTTP 409 by the router
so the same page cannot be favorited twice."""
@@ -246,6 +258,23 @@ def _task_requires_html(task: dict[str, Any]) -> bool:
return ctx.get("task_kind") == "html_page"
def _task_template_id(task: dict[str, Any]) -> str | None:
"""The HTML template a 模板填充 task targets, or ``None`` for a normal task.
Stored at ``execution_context.template_config.template_id``. When set, the
run converts the source JSON into that template's ``DATA`` structure and
fills it — a distinct mode from the free-form HTML render pipeline.
"""
ctx = task.get("execution_context")
if not isinstance(ctx, dict):
return None
cfg = ctx.get("template_config")
if not isinstance(cfg, dict):
return None
template_id = cfg.get("template_id")
return str(template_id) if template_id else None
def _has_valid_markdown_file(result_files: list[dict[str, Any]] | None) -> bool:
"""True when the run produced a ``.md`` file whose text exceeds the minimum.
@@ -980,6 +1009,12 @@ class ScheduledTaskService:
needed, and old ``task_kind == "html_page"`` tasks still resolve to the
HTML pipeline via :func:`_task_requires_html`.
"""
# 模板填充模式优先:选中了 HTML 模板就走「转换 JSON → 填充固定模板」分支,
# 与「检索数据 → 自由渲染 HTML」(require_html) 是两套不同流程。
if _task_template_id(task) is not None:
return await self._run_template_fill_attempt(
task, runtime_thread_id=runtime_thread_id, scheduled_for=scheduled_for, run_id=run_id
)
if _task_requires_html(task):
return await self._run_html_page_attempt(
task, runtime_thread_id=runtime_thread_id, scheduled_for=scheduled_for, run_id=run_id
@@ -1339,6 +1374,196 @@ class ScheduledTaskService:
"created_at": datetime.now(UTC).isoformat(),
}
@staticmethod
def _build_conversion_prompt(skeleton_json: str, source: str) -> str:
return (
"请把下面的【源数据】转换为【目标结构】要求的 JSON。\n\n"
"【目标结构】(展示了完整字段与类型,其中数组只示意 1 个样例元素,实际请按源数据填充全部元素):\n"
"```json\n" + skeleton_json + "\n```\n\n"
"【源数据】:\n" + source + "\n\n"
"只输出一个 ```json 代码块,内含填充好的完整目标 JSON,不要任何解释文字。"
)
@staticmethod
def _build_retry_prompt(base_prompt: str, errors: list[str], gaps: list[str]) -> str:
lines = [base_prompt, "\n\n上一轮转换结果未通过校验,请严格修正后重新输出**完整**的目标 JSON:"]
if errors:
lines.append("- 结构错误:" + ";".join(errors[:12]))
if gaps:
lines.append("- 字段缺失(请补齐):" + "、".join(gaps[:30]))
return "\n".join(lines)
async def _invoke_conversion_model(self, model_name: str | None, soul: str | None, user_prompt: str) -> str:
"""Invoke the conversion model once (SOUL as system, prompt as human).
Thinking is disabled for stable JSON output. Returns the model's text.
"""
from langchain_core.messages import HumanMessage, SystemMessage
from deerflow.models import create_chat_model
model = create_chat_model(name=model_name or None, thinking_enabled=False)
messages: list[Any] = []
if soul:
messages.append(SystemMessage(content=soul))
messages.append(HumanMessage(content=user_prompt))
response = await model.ainvoke(messages)
content = getattr(response, "content", response)
if isinstance(content, str):
return content
if isinstance(content, list):
parts = [part.get("text", "") if isinstance(part, dict) else str(part) for part in content]
return "\n".join(p for p in parts if p)
return str(content)
async def _run_template_fill_attempt(
self,
task: dict[str, Any],
*,
runtime_thread_id: str,
scheduled_for: datetime,
run_id: str,
) -> dict[str, Any]:
"""模板填充模式:把源 JSON 转换成所选模板的 DATA 结构,校验后填充产出 HTML。
Pipeline: 取源 JSON(任务 prompt) → 用内置「模板数据转换器」agent 的 SOUL +
模型把它映射成目标结构 → 结构校验(不过则回喂错误重试) → 填充模板 → 归档
``index.html`` + 持久化 ``scheduled_html_pages`` → 返回 ``format: "html"``
结果(复用现有 HTML 预览)。任一步彻底失败抛 :class:`TemplateFillError`,
run 记为 failed。
"""
template_id = _task_template_id(task)
spec = template_fill.get_template(template_id) if template_id else None
if spec is None:
raise TemplateFillError(f"未知的 HTML 模板:{template_id!r}")
source = str(task.get("prompt") or "").strip()
used_default = False
data_obj: dict[str, Any] | None = None
# 「只展示」模板(convertible=False):暂不转换,原样输出模板已有的 HTML。
if not spec.convertible:
html = template_fill.read_template_text(template_id)
used_default = True
elif not source:
# 未提供源数据 → 直接展示模板自带的默认数据(不调模型、不校验)。
data_obj = template_fill.reference_data(template_id)
used_default = True
else:
ctx = task.get("execution_context") or {}
tcfg = ctx.get("template_config") if isinstance(ctx, dict) else {}
tcfg = tcfg if isinstance(tcfg, dict) else {}
# 解析选中的内置 agent 的 SOUL + 模型(template_config.model_name 优先,其次 agent.model)。
agent_ref = task.get("execution_agent_name")
agent_id = await self._resolve_agent_id(agent_ref, task["user_id"]) if agent_ref else None
soul = load_agent_soul(agent_id) if agent_id else None
model_name = str(tcfg.get("model_name") or "").strip() or None
if not model_name and agent_id:
try:
agent_cfg = load_agent_config(agent_id)
model_name = (agent_cfg.model if agent_cfg else None) or None
except Exception:
model_name = None
skeleton = template_fill.schema_skeleton_json(template_id)
base_prompt = self._build_conversion_prompt(skeleton, source)
# 转换 → 校验 → 失败回喂重试。
last_errors: list[str] = []
prompt = base_prompt
for attempt in range(_MAX_TEMPLATE_FILL_RETRIES + 1):
raw = await self._invoke_conversion_model(model_name, soul, prompt)
candidate = html_page_utils.extract_json_object(raw)
if candidate is None:
last_errors = ["模型未返回可解析的 JSON 对象"]
else:
errors = template_fill.validate_data(template_id, candidate)
if not errors:
data_obj = candidate
break
last_errors = errors
gaps = template_fill.field_coverage_gaps(template_id, candidate)
prompt = self._build_retry_prompt(base_prompt, errors, gaps)
logger.info(
"模板填充[run=%s]第 %d 次校验未通过:%s",
run_id,
attempt + 1,
";".join(last_errors[:5]),
)
if data_obj is None:
raise TemplateFillError("转换结果未通过结构校验:" + ";".join(last_errors[:8]))
# 填充模板(顺便把 MANIFEST 更新时间刷成本次计划日期)。「只展示」模板上面已
# 把原始 HTML 放进 html,这里跳过填充。
task_tz = ZoneInfo(task.get("timezone") or "UTC")
local_time = scheduled_for.astimezone(task_tz)
if data_obj is not None:
try:
html = template_fill.fill_template(template_id, data_obj, update_date=local_time.date())
except template_fill.TemplateError as exc:
raise TemplateFillError(f"模板填充失败:{exc}") from exc
# 归档 index.html。
archive_path = self._archive_html_index(run_id, html)
title = str(task.get("name") or spec.name).strip()
files: list[dict[str, Any]] = []
if archive_path is not None:
files.append(
{
"name": "index.html",
"path": str(archive_path),
"virtual_path": "/mnt/user-data/outputs/index.html",
"mime_type": "text/html",
"size": archive_path.stat().st_size if archive_path.exists() else None,
"kind": "html",
}
)
data_snapshot = {"template_id": template_id, "source_chars": len(source), "used_default": used_default}
prompt_snapshot = {"template_id": template_id, "used_default": used_default}
# 持久化页面资产(best-effort,失败不影响 run 成功)。
store = self._html_store
if store is not None:
try:
await store.create_page(
{
"id": str(uuid.uuid4()),
"task_id": task["task_id"],
"run_id": run_id,
"user_id": task["user_id"],
"title": title,
"html_content": html,
"html_file_path": str(archive_path) if archive_path else None,
"data_snapshot": data_snapshot,
"prompt_snapshot": prompt_snapshot,
"reference_favorite_id": None,
"style_summary": None,
"layout_summary": None,
}
)
except Exception:
logger.warning("Failed to persist scheduled_html_pages row for template-fill run %s", run_id, exc_info=True)
inline_html = html if len(html) <= _MAX_INLINE_RESULT_HTML else None
return {
"title": f"{title} - {local_time.strftime('%Y-%m-%d %H:%M')}",
"content": (
f"已展示模板「{spec.name}」的现有数据"
if not spec.convertible
else f"未提供源数据,已用模板「{spec.name}」的默认数据生成页面"
if used_default
else f"已根据源数据填充模板「{spec.name}」生成页面"
),
"format": "html",
"html": inline_html,
"data_snapshot": data_snapshot,
"prompt_snapshot": prompt_snapshot,
"files": files,
"created_at": datetime.now(UTC).isoformat(),
}
async def _run_agent_attempt(
self,
task: dict[str, Any],
@@ -0,0 +1,289 @@
"""HTML 模板「JSON 填充」纯引擎 + 模板注册表。
定时任务的「模板数据转换器」内置 agent 把用户传入的任意 JSON 智能映射成某个
HTML 模板需要的 ``DATA`` 结构后,本模块负责把数据**填充**进模板并产出一份完整、
可预览的 HTML 文档。
设计要点
--------
* **模板自身即 schema 来源**:每个模板的 HTML 里都内嵌一行 ``const DATA={...};``
(台湾滨海防卫展示系统 no.1 还内嵌 ``const MANIFEST={...};``)。我们直接解析这行
拿到「参考数据」,既当**目标格式的范例**(喂给大模型),又当**结构校验**的基准
(顶层键 + 类型)。注册表因此不需要手写一份庞大的 schema 字面量,改模板即改契约。
* **行级安全替换**:模板把每个常量压在**单独一行**(``const DATA=...;`` 以分号结尾、
行内无换行),所以用 ``^const VAR=.*;$`` (MULTILINE) 精确命中该行整体替换,绝不
误伤模板里别处出现的同名子串。替换值用函数回调写入,避开 ``re.sub`` 把 JSON 里的
反斜杠当反向引用的坑。
* **harness 层、零 app.* 依赖**:可被调度器(harness)与 Gateway 路由(app)共同 import。
公开入口:``list_templates`` / ``get_template`` / ``reference_data`` /
``schema_skeleton_json`` / ``validate_data`` / ``field_coverage_gaps`` / ``fill_template``。
"""
from __future__ import annotations
import json
import re
from dataclasses import dataclass
from datetime import date
from functools import lru_cache
from pathlib import Path
from typing import Any
# 模板资源根目录(随 git 发布,与本模块同级 ``templates/`` 子目录)。
_TEMPLATES_ROOT = Path(__file__).parent / "templates"
@dataclass(frozen=True)
class TemplateSpec:
"""一个 HTML 模板的注册信息。
``file`` 相对 :data:`_TEMPLATES_ROOT`;``data_var``/``manifest_var`` 是模板里
待替换的 JS 常量名(``manifest_var`` 为空表示该模板没有 MANIFEST 行)。
``convertible=False`` 表示该模板暂只「直接展示已有数据」——执行时原样输出模板
HTML,不做 JSON 转换/校验/填充(无 ``const DATA=`` 注入行的模板用它,后续接入
转换后改回 True)。
"""
id: str
name: str
description: str
file: str
data_var: str = "DATA"
manifest_var: str = ""
convertible: bool = True
# 已注册模板。新增 no.2/no.3 时在此追加一条即可——前端下拉、调度器填充全自动生效。
TEMPLATES: tuple[TemplateSpec, ...] = (
TemplateSpec(
id="no1",
name="no.1",
description="",
file="no1/taiwan_littoral_ui_redwhite_v2.html",
data_var="DATA",
manifest_var="MANIFEST",
),
TemplateSpec(
id="no2",
name="no.2",
description="",
file="no2/taiwan_air_force_v3_2.html",
# 暂只展示已有数据,不做 JSON 转换/填充(后续接入转换后再开)。
convertible=False,
),
)
_TEMPLATES_BY_ID: dict[str, TemplateSpec] = {t.id: t for t in TEMPLATES}
class TemplateError(Exception):
"""模板缺失 / 解析失败 / 填充失败。"""
def list_templates() -> list[dict[str, Any]]:
"""前端下拉用的轻量清单(只含 id/name/description/convertible,不含庞大数据)。"""
return [
{"id": t.id, "name": t.name, "description": t.description, "convertible": t.convertible}
for t in TEMPLATES
]
def get_template(template_id: str) -> TemplateSpec | None:
return _TEMPLATES_BY_ID.get(template_id)
def is_convertible(template_id: str) -> bool:
spec = _TEMPLATES_BY_ID.get(template_id)
return bool(spec and spec.convertible)
def _require(template_id: str) -> TemplateSpec:
spec = _TEMPLATES_BY_ID.get(template_id)
if spec is None:
raise TemplateError(f"未知模板 id:{template_id!r}")
return spec
def _template_path(spec: TemplateSpec) -> Path:
return _TEMPLATES_ROOT / spec.file
def read_template_text(template_id: str) -> str:
spec = _require(template_id)
path = _template_path(spec)
if not path.is_file():
raise TemplateError(f"模板文件缺失:{path}")
return path.read_text(encoding="utf-8")
def _const_line_re(var: str) -> re.Pattern[str]:
# 单行 ``const VAR={...};`` —— 默认点号不跨行,故 ``.*`` 恰好吃到该物理行的结尾分号。
return re.compile(r"^const " + re.escape(var) + r"=.*;[ \t]*$", re.MULTILINE)
def _extract_const_object(text: str, var: str) -> Any:
"""解析模板里 ``const VAR={...};`` 那一行的 JSON 值。"""
match = _const_line_re(var).search(text)
if match is None:
raise TemplateError(f"模板中未找到 const {var}=… 行")
line = match.group(0).strip()
body = line[len("const ") + len(var) + 1 :] # 去掉 ``const VAR=``
body = body.rstrip().rstrip(";").rstrip()
try:
return json.loads(body)
except json.JSONDecodeError as exc: # pragma: no cover - 模板损坏才会触发
raise TemplateError(f"解析 const {var} 失败:{exc}") from exc
@lru_cache(maxsize=16)
def reference_data(template_id: str) -> dict[str, Any]:
"""模板内嵌的参考 ``DATA`` 对象(目标格式范例 + 校验基准),按模板缓存。"""
spec = _require(template_id)
text = read_template_text(template_id)
obj = _extract_const_object(text, spec.data_var)
if not isinstance(obj, dict):
raise TemplateError(f"模板 {template_id} 的 {spec.data_var} 不是对象")
return obj
def _skeleton(value: Any, *, list_sample: int = 1, depth: int = 0, max_depth: int = 6) -> Any:
"""把一份具体数据压成「结构骨架」:保留全部字段与类型,每个数组只留前 N 个样例。
数组元素同构,留 1 个样例即可暴露该元素的完整字段集,既完整又紧凑——正好做大模型
的「目标格式说明」。
"""
if depth >= max_depth:
return "…"
if isinstance(value, dict):
return {k: _skeleton(v, list_sample=list_sample, depth=depth + 1, max_depth=max_depth) for k, v in value.items()}
if isinstance(value, list):
if not value:
return []
return [_skeleton(v, list_sample=list_sample, depth=depth + 1, max_depth=max_depth) for v in value[:list_sample]]
return value
@lru_cache(maxsize=16)
def schema_skeleton_json(template_id: str) -> str:
"""目标格式的结构骨架(JSON 字符串),注入到大模型的转换 prompt 里。"""
skeleton = _skeleton(reference_data(template_id))
return json.dumps(skeleton, ensure_ascii=False, indent=1)
def top_level_keys(template_id: str) -> list[str]:
return list(reference_data(template_id).keys())
def validate_data(template_id: str, obj: Any) -> list[str]:
"""硬校验:转换结果必须是对象,且顶层键齐全、容器类型与模板一致。
返回错误列表(空 = 通过)。只校验顶层结构——字段级缺失通过
:func:`field_coverage_gaps` 作为软反馈回喂大模型,而不在这里硬卡死。
"""
errors: list[str] = []
if not isinstance(obj, dict):
return ["转换结果不是 JSON 对象(应为 {…})"]
ref = reference_data(template_id)
for key, ref_val in ref.items():
if key not in obj:
errors.append(f"缺少顶层字段「{key}」")
continue
got = obj[key]
if isinstance(ref_val, list) and not isinstance(got, list):
errors.append(f"字段「{key}」应为数组")
elif isinstance(ref_val, dict) and not isinstance(got, dict):
errors.append(f"字段「{key}」应为对象")
return errors
def _element_fields(value: Any) -> set[str]:
"""取一个数组里所有 dict 元素字段名的并集。"""
fields: set[str] = set()
if isinstance(value, list):
for item in value:
if isinstance(item, dict):
fields.update(item.keys())
return fields
def field_coverage_gaps(template_id: str, obj: Any, *, max_items: int = 40) -> list[str]:
"""软反馈:对照参考数据,列出转换结果里缺失的字段(数组元素字段 / 对象子键)。
用于把「字段没填全」回喂大模型让它补齐,不作为硬性失败。
"""
gaps: list[str] = []
if not isinstance(obj, dict):
return gaps
ref = reference_data(template_id)
for key, ref_val in ref.items():
if key not in obj:
continue
got = obj[key]
if isinstance(ref_val, dict) and isinstance(got, dict):
for sub in ref_val:
if sub not in got:
gaps.append(f"{key}.{sub} 缺失")
elif isinstance(ref_val, list) and isinstance(got, list):
want = _element_fields(ref_val)
have = _element_fields(got)
for f in sorted(want - have):
gaps.append(f"{key}[].{f} 缺失")
if len(gaps) >= max_items:
return gaps[:max_items] + ["…(更多字段省略)"]
return gaps
def _dump_const(var: str, obj: Any) -> str:
body = json.dumps(obj, ensure_ascii=False)
return f"const {var}={body};"
def fill_template(
template_id: str,
data_obj: Any,
*,
manifest_overrides: dict[str, Any] | None = None,
update_date: date | None = None,
) -> str:
"""把 ``data_obj`` 填进模板的 ``const DATA=…`` 行,产出完整 HTML 文档。
可选地更新 MANIFEST 的「系统.更新时间」(``update_date``,默认不动) 以及任意
``manifest_overrides``(深合并到解析后的 MANIFEST,如改系统名称)。
"""
spec = _require(template_id)
text = read_template_text(template_id)
data_re = _const_line_re(spec.data_var)
if data_re.search(text) is None:
raise TemplateError(f"模板 {template_id} 中未找到 const {spec.data_var}= 行")
new_data_line = _dump_const(spec.data_var, data_obj)
text = data_re.sub(lambda _m: new_data_line, text, count=1)
if spec.manifest_var and (manifest_overrides or update_date is not None):
manifest_re = _const_line_re(spec.manifest_var)
match = manifest_re.search(text)
if match is not None:
try:
manifest = _extract_const_object(text, spec.manifest_var)
except TemplateError:
manifest = None
if isinstance(manifest, dict):
if update_date is not None:
system = manifest.get("系统")
if isinstance(system, dict):
system["更新时间"] = update_date.isoformat()
if manifest_overrides:
_deep_merge(manifest, manifest_overrides)
new_manifest_line = _dump_const(spec.manifest_var, manifest)
text = manifest_re.sub(lambda _m: new_manifest_line, text, count=1)
return text
def _deep_merge(base: dict[str, Any], overrides: dict[str, Any]) -> None:
for k, v in overrides.items():
if isinstance(v, dict) and isinstance(base.get(k), dict):
_deep_merge(base[k], v)
else:
base[k] = v
@@ -0,0 +1,265 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>台湾滨海防卫知识数据展示系统</title>
<style>
:root{
--red:#c8102e;--red2:#8f1024;--light-red:#fff1f3;--soft-red:#fde7eb;
--bg:#f5f5f6;--card:#fff;--text:#262626;--muted:#737373;--line:#e5e5e5;
--green:#168a4a;--orange:#c56a00;--shadow:0 8px 24px rgba(74,12,24,.08)
}
*{box-sizing:border-box}html{scroll-behavior:smooth}body{margin:0;background:var(--bg);color:var(--text);font-family:"PingFang SC","Microsoft YaHei",Arial,sans-serif}
button,input,select{font:inherit}.header{position:sticky;top:0;z-index:30;background:#fff;border-bottom:1px solid var(--line);box-shadow:0 3px 12px rgba(0,0,0,.04)}
.header-top{height:70px;padding:0 28px;display:flex;align-items:center;gap:24px}.logo{width:42px;height:42px;border-radius:12px;background:linear-gradient(145deg,var(--red),var(--red2));display:grid;place-items:center;color:#fff;font-weight:800}
.title{flex:1}.title h1{font-size:19px;margin:0 0 5px}.title p{margin:0;font-size:12px;color:var(--muted)}.header-state{text-align:right;font-size:12px;color:var(--muted)}
.tabs{display:flex;padding:0 24px;gap:4px;overflow:auto}.tab{border:0;background:transparent;padding:13px 18px;color:#666;cursor:pointer;border-bottom:3px solid transparent;white-space:nowrap}.tab.active{color:var(--red);font-weight:700;border-bottom-color:var(--red);background:linear-gradient(to top,var(--light-red),transparent)}
.main{padding:24px 28px 48px}.hero{background:linear-gradient(135deg,#fff,#fff5f6);border:1px solid #f2d4d9;border-radius:20px;padding:24px;display:flex;justify-content:space-between;align-items:center;gap:20px;margin-bottom:18px;box-shadow:var(--shadow)}
.hero h2{margin:0 0 8px;font-size:26px}.hero p{margin:0;color:var(--muted);line-height:1.7}.hero-mark{width:90px;height:90px;border-radius:50%;background:radial-gradient(circle,#fff 0 30%,#fbd5dc 31% 55%,var(--red) 56%);opacity:.9}
.btn{border:1px solid #d8d8d8;background:#fff;color:#333;border-radius:9px;padding:8px 13px;cursor:pointer}.btn:hover{border-color:var(--red);color:var(--red)}.btn-red{background:var(--red);border-color:var(--red);color:#fff}.btn-red:hover{background:var(--red2);color:#fff}.btn-soft{background:var(--light-red);color:var(--red);border-color:#f0bec8}
.metrics{display:grid;grid-template-columns:repeat(3,1fr);gap:14px;margin-bottom:18px}.metric{background:#fff;border:1px solid var(--line);border-radius:16px;padding:17px;box-shadow:var(--shadow);position:relative;overflow:hidden}.metric:after{content:"";position:absolute;right:-20px;top:-30px;width:90px;height:90px;border-radius:50%;background:var(--light-red)}.metric-label{font-size:13px;color:var(--muted)}.metric-value{font-size:30px;font-weight:800;color:var(--red);margin-top:8px}.metric-unit{font-size:12px;margin-left:4px}.metric-note{font-size:12px;color:#888;margin-top:6px}
.grid2{display:grid;grid-template-columns:1fr 1fr;gap:16px}.panel{background:#fff;border:1px solid var(--line);border-radius:17px;padding:18px;box-shadow:var(--shadow)}.panel-title{font-weight:800;margin-bottom:14px;display:flex;align-items:center;justify-content:space-between}.panel-title:before{content:"";width:5px;height:18px;background:var(--red);border-radius:5px;margin-right:9px}.panel-title span:first-child{display:flex;align-items:center}
.bar-row{display:grid;grid-template-columns:150px 1fr 38px;gap:10px;align-items:center;font-size:12px;margin:10px 0}.bar{height:9px;background:#f1f1f1;border-radius:20px;overflow:hidden}.bar i{display:block;height:100%;background:linear-gradient(90deg,var(--red),#ed6b7e);border-radius:20px}
.entry-grid,.card-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:14px}.entry-card,.data-card{background:#fff;border:1px solid var(--line);border-radius:16px;padding:17px;box-shadow:var(--shadow);cursor:pointer;transition:.18s}.entry-card:hover,.data-card:hover{transform:translateY(-2px);border-color:#e0a0ab}.entry-card b,.data-card h3{display:block;margin:0 0 8px}.entry-num{font-size:25px;color:var(--red);font-weight:800}.entry-card p,.data-card p{font-size:12px;color:var(--muted);line-height:1.65;margin:8px 0 0}
.toolbar{background:#fff;border:1px solid var(--line);border-radius:14px;padding:12px;display:flex;gap:9px;align-items:center;flex-wrap:wrap;margin-bottom:16px;box-shadow:var(--shadow)}.toolbar input,.toolbar select{height:37px;border:1px solid #ddd;border-radius:8px;background:#fff;padding:0 10px;color:#333}.toolbar input{flex:1;min-width:250px}.toolbar-count{margin-left:auto;color:var(--muted);font-size:12px}
.chip{display:inline-flex;align-items:center;border-radius:999px;padding:4px 9px;background:#f3f3f3;color:#555;font-size:11px;margin:2px}.chip-red{background:var(--light-red);color:var(--red)}.chip-green{background:#edf9f2;color:var(--green)}.chip-orange{background:#fff6e8;color:var(--orange)}
.region-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:15px}.region-card{background:#fff;border:1px solid var(--line);border-radius:18px;padding:18px;box-shadow:var(--shadow);cursor:pointer;position:relative;overflow:hidden}.region-card:before{content:"";position:absolute;left:0;top:0;bottom:0;width:5px;background:var(--red)}.region-card h3{margin:0 0 8px;font-size:18px}.region-card p{font-size:12px;color:var(--muted);line-height:1.7}.region-stats{display:flex;gap:18px;margin-top:13px;border-top:1px solid #f0f0f0;padding-top:12px}.region-stats b{color:var(--red);font-size:18px}
.segmented{display:flex;gap:5px;background:#f0f0f0;padding:4px;border-radius:11px;width:max-content;margin-bottom:15px}.seg-btn{border:0;background:transparent;padding:8px 14px;border-radius:8px;color:#666;cursor:pointer}.seg-btn.active{background:#fff;color:var(--red);font-weight:700;box-shadow:0 2px 7px rgba(0,0,0,.08)}
.chart-toolbar{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:16px}.chart-view{padding:9px 14px;border:1px solid #e1c3c9;background:#fff7f8;color:#8c2335;border-radius:9px;cursor:pointer}.chart-view.active{background:var(--red);color:#fff;border-color:var(--red)}
.org-chart-wrap{overflow:auto;background:#fff;border:1px solid var(--line);border-radius:18px;padding:28px;box-shadow:var(--shadow);min-height:420px}
.org-chart{display:flex;justify-content:center;min-width:max-content}.org-chart ul{padding-top:24px;position:relative;display:flex;justify-content:center;margin:0;padding-left:0}.org-chart li{list-style:none;text-align:center;position:relative;padding:24px 8px 0}.org-chart li:before,.org-chart li:after{content:"";position:absolute;top:0;right:50%;border-top:1px solid #c7a8ae;width:50%;height:24px}.org-chart li:after{right:auto;left:50%;border-left:1px solid #c7a8ae}.org-chart li:only-child:after,.org-chart li:only-child:before{display:none}.org-chart li:only-child{padding-top:0}.org-chart li:first-child:before,.org-chart li:last-child:after{border:0}.org-chart li:last-child:before{border-right:1px solid #c7a8ae;border-radius:0 6px 0 0}.org-chart li:first-child:after{border-radius:6px 0 0 0}.org-chart ul ul:before{content:"";position:absolute;top:0;left:50%;border-left:1px solid #c7a8ae;width:0;height:24px}
.org-node{width:168px;min-height:80px;background:#fff;border:1px solid #e3c1c7;border-top:4px solid var(--red);border-radius:12px;padding:10px;display:inline-block;box-shadow:0 5px 14px rgba(96,12,28,.08);cursor:pointer}.org-node:hover{background:#fff6f7;border-color:var(--red)}.org-node b{font-size:13px;display:block;line-height:1.45}.org-node small{font-size:10px;color:var(--muted);display:block;margin-top:5px}.org-node .node-kind{font-size:10px;color:var(--red);margin-top:5px}
.summary-strip{display:grid;grid-template-columns:repeat(4,1fr);gap:12px;margin:15px 0}.summary-card{background:#fff;border:1px solid var(--line);border-radius:13px;padding:14px}.summary-card b{display:block;color:var(--red);font-size:21px}.summary-card span{font-size:12px;color:var(--muted)}
.data-card{display:flex;flex-direction:column;min-height:205px}.card-top{display:flex;justify-content:space-between;gap:10px}.card-type{font-size:11px;color:var(--red);background:var(--light-red);padding:4px 8px;border-radius:999px;height:max-content}.card-sub{font-size:12px;color:#777;margin-bottom:10px}.card-roles{display:grid;gap:6px;margin-top:8px}.role-line{font-size:12px;background:#fafafa;border-left:3px solid #e7a1ad;padding:7px 9px}.card-footer{margin-top:auto;padding-top:12px;border-top:1px solid #eee;display:flex;justify-content:space-between;color:#888;font-size:11px}
.category-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:10px;margin-bottom:16px}.category-card{background:#fff;border:1px solid var(--line);border-radius:13px;padding:13px;cursor:pointer}.category-card:hover{border-color:var(--red)}.category-card b{color:var(--red);font-size:21px;display:block}.category-card span{font-size:11px;color:#666}
.timeline{position:relative;padding-left:26px}.timeline:before{content:"";position:absolute;left:7px;top:4px;bottom:4px;width:2px;background:#efc5cc}.time-card{position:relative;background:#fff;border:1px solid var(--line);border-radius:14px;padding:16px;margin-bottom:14px;box-shadow:var(--shadow);cursor:pointer}.time-card:before{content:"";position:absolute;left:-25px;top:19px;width:12px;height:12px;border:3px solid var(--red);background:#fff;border-radius:50%}.time-date{color:var(--red);font-size:12px;font-weight:700;margin-bottom:5px}
.pagination{display:flex;justify-content:center;align-items:center;gap:9px;margin-top:18px}.page-no{color:#777;font-size:12px}
.overlay{position:fixed;inset:0;background:rgba(36,8,14,.42);z-index:50;display:none}.overlay.show{display:block}.drawer{position:absolute;right:0;top:0;width:min(560px,94vw);height:100%;background:#fff;padding:24px;overflow:auto;box-shadow:-12px 0 35px rgba(0,0,0,.16)}.modal{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);width:min(840px,92vw);max-height:88vh;overflow:auto;background:#fff;border-radius:18px;padding:24px}.close{float:right}
.detail-page{display:none}.detail-page.show{display:block}.detail-hero{background:linear-gradient(135deg,var(--red),var(--red2));color:#fff;border-radius:20px;padding:25px;margin-bottom:16px;box-shadow:var(--shadow)}.detail-hero h2{margin:0 0 10px;font-size:26px}.detail-hero p{margin:0;line-height:1.7;color:#ffe8ed}.detail-tags{margin-top:13px}.detail-tags .chip{background:rgba(255,255,255,.16);color:#fff}
.detail-grid{display:grid;grid-template-columns:2fr 1fr;gap:16px}.detail-section{background:#fff;border:1px solid var(--line);border-radius:16px;padding:18px;margin-bottom:16px;box-shadow:var(--shadow)}.detail-section h3{margin:0 0 14px;font-size:16px;display:flex;align-items:center}.detail-section h3:before{content:"";width:4px;height:17px;background:var(--red);border-radius:5px;margin-right:8px}.info-grid{display:grid;grid-template-columns:repeat(2,1fr);gap:10px}.info-item{background:#fafafa;border-radius:10px;padding:11px}.info-item label{display:block;font-size:11px;color:#888;margin-bottom:5px}.info-item div{font-size:13px;line-height:1.55}.text-list{display:grid;gap:9px}.text-item{padding:10px 12px;border-left:4px solid #e5a6b1;background:#fff8f9;border-radius:7px;font-size:13px;line-height:1.6}
.mini-card-grid{display:grid;grid-template-columns:repeat(2,1fr);gap:10px}.mini-card{border:1px solid #eee;border-radius:11px;padding:12px;background:#fff}.mini-card b{display:block;margin-bottom:6px}.source-line{padding:9px 0;border-bottom:1px dashed #ddd;font-size:12px}.warning-box{background:#fff7e8;border:1px solid #f1d29e;border-radius:11px;padding:13px;color:#7a4d00;font-size:12px;line-height:1.65}.safe-box{background:#f7f7f7;border:1px solid #ddd;border-radius:11px;padding:13px;color:#555;font-size:12px;line-height:1.65}
.query-row{display:grid;grid-template-columns:1fr 120px 1fr 42px;gap:8px;margin-bottom:8px}.query-row select,.query-row input{height:37px;border:1px solid #ddd;border-radius:8px;padding:0 9px}.empty{text-align:center;padding:50px;color:#888}
@media(max-width:1150px){.entry-grid,.card-grid,.region-grid{grid-template-columns:repeat(2,1fr)}.category-grid{grid-template-columns:repeat(3,1fr)}.detail-grid{grid-template-columns:1fr}.metrics{grid-template-columns:repeat(2,1fr)}}
@media(max-width:720px){.header-top{padding:10px 14px;height:auto}.header-state{display:none}.main{padding:14px}.hero-mark{display:none}.hero h2{font-size:21px}.metrics,.grid2,.entry-grid,.card-grid,.region-grid,.summary-strip,.category-grid,.info-grid,.mini-card-grid{grid-template-columns:1fr}.toolbar input{min-width:100%}.query-row{grid-template-columns:1fr}.org-chart-wrap{padding:15px}.org-node{width:145px}}
</style>
</head>
<body>
<header class="header">
<div class="header-top">
<div class="logo">防</div>
<div class="title"><h1>台湾滨海防卫知识数据展示系统</h1><p>红白业务版 · 组织架构与单位档案已合并</p></div>
<div class="header-state">知识库版本 2.0<br>更新日期 2026-06-23</div>
</div>
<nav class="tabs" id="tabs"></nav>
</header>
<main class="main">
<section id="page"></section>
<section id="detailPage" class="detail-page"></section>
</main>
<div id="drawerOverlay" class="overlay"><aside class="drawer"><button class="btn close" onclick="closeDrawer()">关闭</button><div id="drawer"></div></aside></div>
<div id="modalOverlay" class="overlay"><div class="modal"><button class="btn close" onclick="closeModal()">关闭</button><div id="modal"></div></div></div>
<script>
const DATA={"overview": {"核心指标": [{"名称": "综合区域", "数值": 9, "单位": "区", "说明": "覆盖本岛沿岸与主要离岛"}, {"名称": "组织与单位", "数值": 280, "单位": "个", "说明": "已去除组织与单位重复"}, {"名称": "单位深度档案", "数值": 179, "单位": "份", "说明": "已合并到对应组织节点"}, {"名称": "装备条目", "数值": 99, "单位": "项", "说明": "区分规划、采购、测试和列装"}, {"名称": "关键节点", "数值": 141, "单位": "个", "说明": "公共功能与韧性视角"}, {"名称": "演习与项目", "数值": 12, "单位": "项", "说明": "事件时间线与建设项目"}], "组织体系分布": [{"名称": "组织治理体系", "数值": 101}, {"名称": "海巡", "数值": 43}, {"名称": "全民动员", "数值": 29}, {"名称": "国防与联合体系", "数值": 28}, {"名称": "海军", "数值": 21}, {"名称": "陆军", "数值": 18}, {"名称": "空军", "数值": 16}, {"名称": "公共支援", "数值": 16}, {"名称": "海军陆战队", "数值": 8}], "装备类别分布": [{"名称": "无人系统", "数值": 12}, {"名称": "防空", "数值": 11}, {"名称": "海军平台", "数值": 10}, {"名称": "海巡平台", "数值": 9}, {"名称": "航空", "数值": 8}, {"名称": "地面火力", "数值": 7}, {"名称": "装甲", "数值": 5}, {"名称": "雷达与感知", "数值": 5}, {"名称": "反装甲", "数值": 4}, {"名称": "反舰", "数值": 3}, {"名称": "潜舰", "数值": 3}, {"名称": "反水雷", "数值": 3}, {"名称": "舰载防空", "数值": 3}, {"名称": "电子战", "数值": 3}, {"名称": "指挥控制", "数值": 3}, {"名称": "工程保障", "数值": 2}, {"名称": "布雷", "数值": 1}, {"名称": "综合支援", "数值": 1}, {"名称": "反无人机", "数值": 1}, {"名称": "无人水面平台", "数值": 1}, {"名称": "通信", "数值": 1}, {"名称": "机动运输", "数值": 1}, {"名称": "防护", "数值": 1}, {"名称": "医疗保障", "数值": 1}], "节点类别分布": [{"名称": "港口与海运", "数值": 26}, {"名称": "供水", "数值": 22}, {"名称": "能源与燃料", "数值": 18}, {"名称": "机场与航空", "数值": 17}, {"名称": "产业、物流与维修", "数值": 15}, {"名称": "陆路交通", "数值": 13}, {"名称": "医疗与救援", "数值": 11}, {"名称": "通信与数位韧性", "数值": 8}, {"名称": "应变指挥与警报", "数值": 6}, {"名称": "离岛持续保障", "数值": 5}], "业务入口": [{"页面": "regions", "名称": "沿岸区域", "数量": 9, "说明": "九区、县市与海岸分段"}, {"页面": "org_units", "名称": "组织与单位", "数量": 280, "说明": "组织架构图与单位深度档案"}, {"页面": "equipment", "名称": "武器装备", "数量": 99, "说明": "类别、体系、状态和公开作用"}, {"页面": "key_nodes", "名称": "关键节点", "数量": 141, "说明": "公共功能、依赖与替代"}, {"页面": "events_projects", "名称": "演习与项目", "数量": 12, "说明": "事件时间线和建设项目"}], "数据状态": {"知识库版本": "2.0", "页面版本": "2.0", "更新时间": "2026-06-23", "校验状态": "通过"}}, "regions": [{"region_id": "R01", "name": "北部沿岸", "scope": "基隆市、新北市沿岸及台北都会支撑区", "coastal_counties": ["基隆市", "新北市"], "support_areas": ["台北市", "桃园北部"], "coastal_morphology": ["野柳—东北角岬湾岩岸", "基隆港湾与人工港岸", "淡水河口沙岸与河口湿地", "八里—林口台地前缘海岸"], "segment_ids": ["S-R01-01", "S-R01-02", "S-R01-03", "S-R01-04", "S-R01-05"], "source_ids": ["SRC-WRA-TAIPEI-COAST", "SRC-TIPC-PORTS-2026", "SRC-CGA-ORG-2024", "SRC-CAA-AIRPORTS-2024"], "last_verified": "2026-06-23", "safety": "aggregate_only", "编号": "R01", "名称": "北部沿岸", "沿海县市": ["基隆市", "新北市"], "支撑区域": ["台北市", "桃园北部"], "海岸形态": ["野柳—东北角岬湾岩岸", "基隆港湾与人工港岸", "淡水河口沙岸与河口湿地", "八里—林口台地前缘海岸"], "县市子库": [{"county_id": "C-KEELUNG", "name": "基隆市", "region_id": "R01", "coastal_belt": ["基隆港城、外木山及东北角接口"], "dominant_functions": ["港口城市", "国际商港", "都会支撑"], "priority_risks": ["港城交通耦合", "强浪台风", "危险品与港区事故"], "last_verified": "2026-06-23", "safety": "aggregate_only"}, {"county_id": "C-NEWTAIPEI", "name": "新北市", "region_id": "R01", "coastal_belt": ["八里、林口、淡水、三芝、石门、金山、万里、瑞芳、贡寮"], "dominant_functions": ["台北港", "淡水河口", "北海岸观光渔业"], "priority_risks": ["海岸类型差异大", "跨都会交通", "东北角山区通道"], "last_verified": "2026-06-23", "safety": "aggregate_only"}], "海岸分段": [{"segment_id": "S-R01-01", "region_id": "R01", "name": "基隆Line truncated
const MANIFEST={"系统": {"名称": "台湾滨海防卫知识数据展示系统", "版本": "2.0", "知识库版本": "2.0", "更新时间": "2026-06-23", "主题": {"模式": "红白", "主色": "#c8102e", "深红": "#8f1024", "浅红": "#fff1f3", "背景": "#f7f7f8", "卡片": "#ffffff", "文字": "#262626"}}, "页面": [{"编号": "overview", "名称": "总览", "数据源": "data/overview.json"}, {"编号": "regions", "名称": "沿岸区域", "数据源": "data/regions.json"}, {"编号": "org_units", "名称": "组织与单位", "数据源": "data/org_units.json", "架构数据": "data/organization_charts.json"}, {"编号": "equipment", "名称": "武器装备", "数据源": "data/equipment.json"}, {"编号": "key_nodes", "名称": "关键节点", "数据源": "data/key_nodes.json"}, {"编号": "events_projects", "名称": "演习与项目", "数据源": "data/events_projects.json"}], "交互规则": {"默认展示": "卡片与图形化面板", "列表限制": "不超过六个核心字段", "快速预览": "右侧抽屉", "复杂档案": "二级详情页", "长数组": "分组卡片或弹框", "查询": "各页面独立关键词、分类筛选和组合条件查询", "禁止展示": ["精确坐标", "实时部署", "兵力库存", "通信拓扑", "行动路线", "脆弱点排序"]}};
const QUERY_SCHEMA={"版本": "2.0", "说明": "前端以中文条件构造器生成内部查询对象,不设置独立全库检索页面。", "查询域": {"沿岸区域": {"数据源": "regions", "字段": [{"字段": "名称", "显示名": "区域名称", "操作": ["包含", "等于"]}, {"字段": "沿海县市", "显示名": "沿海县市", "操作": ["包含"]}, {"字段": "海岸形态", "显示名": "海岸形态", "操作": ["包含"]}]}, "组织与单位": {"数据源": "org_units", "字段": [{"字段": "名称", "显示名": "名称", "操作": ["包含", "等于"]}, {"字段": "记录类别", "显示名": "记录类别", "操作": ["等于"]}, {"字段": "体系", "显示名": "体系", "操作": ["等于", "属于"]}, {"字段": "架构层级", "显示名": "架构层级", "操作": ["等于"]}, {"字段": "区域关联", "显示名": "区域关联", "操作": ["包含"]}, {"字段": "当前状态", "显示名": "当前状态", "操作": ["等于"]}]}, "武器装备": {"数据源": "equipment", "字段": [{"字段": "名称", "显示名": "装备名称", "操作": ["包含"]}, {"字段": "类别中文", "显示名": "装备类别", "操作": ["等于"]}, {"字段": "使用体系中文", "显示名": "使用体系", "操作": ["包含"]}, {"字段": "状态中文", "显示名": "公开状态", "操作": ["等于"]}]}, "关键节点": {"数据源": "key_nodes", "字段": [{"字段": "名称", "显示名": "节点名称", "操作": ["包含"]}, {"字段": "类别中文", "显示名": "节点类别", "操作": ["等于"]}, {"字段": "区域中文", "显示名": "所属区域", "操作": ["包含"]}, {"字段": "状态中文", "显示名": "运行状态", "操作": ["等于"]}]}, "演习与项目": {"数据源": "events_projects", "字段": [{"字段": "名称", "显示名": "名称", "操作": ["包含"]}, {"字段": "类型中文", "显示名": "事件类型", "操作": ["等于"]}, {"字段": "类别中文", "显示名": "项目类别", "操作": ["等于"]}, {"字段": "状态中文", "显示名": "状态", "操作": ["等于"]}]}}, "内部查询对象": {"数据域": "页面数据源编号", "关键词": "可选", "条件": [{"字段": "中文字段名", "操作": "包含或等于", "值": "查询值"}], "分页": {"页码": 1, "每页": 18}}};
const state={tab:"overview",mode:"chart",chart:"national_defense",q:"",filters:{},conditions:[],page:1,pageSize:18};
function e(v){return String(v??"").replace(/[&<>"']/g,m=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[m]))}
function a(v){return Array.isArray(v)?v:(v===undefined||v===null||v===""?[]:[v])}
function text(v){return Array.isArray(v)?v.join(" "):typeof v==="object"&&v?JSON.stringify(v):String(v??"")}
function chips(v,red=false,max=8){const x=a(v);return x.slice(0,max).map(y=>`<span class="chip ${red?"chip-red":""}">${e(y)}</span>`).join("")+(x.length>max?`<span class="chip">另${x.length-max}项</span>`:"")}
function statusChip(v){const green=/运行|列装|官方|确认|通过/.test(v||"");const orange=/规划|测试|建设|扩建|核验|有限/.test(v||"");return `<span class="chip ${green?"chip-green":orange?"chip-orange":"chip-red"}">${e(v||"未标注")}</span>`}
function unique(data,field){const s=new Set();data.forEach(x=>a(x[field]).forEach(v=>v&&s.add(v)));return [...s].sort()}
function setTab(tab){state.tab=tab;state.q="";state.filters={};state.conditions=[];state.page=1;location.hash="#/"+tab;render()}
function nav(){
document.getElementById("tabs").innerHTML=MANIFEST.页面.map(x=>`<button class="tab ${state.tab===x.编号?"active":""}" onclick="setTab('${x.编号}')">${x.名称}</button>`).join("")
}
function hero(title,desc,actions=""){return `<div class="hero"><div><h2>${title}</h2><p>${desc}</p><div style="margin-top:14px">${actions}</div></div><div class="hero-mark"></div></div>`}
function render(){nav();document.getElementById("page").style.display="block";document.getElementById("detailPage").classList.remove("show");const fn=window["render_"+state.tab]||render_overview;fn()}
function render_overview(){
const o=DATA.overview;
const metrics=o.核心指标.map(x=>`<div class="metric"><div class="metric-label">${x.名称}</div><div class="metric-value">${x.数值}<span class="metric-unit">${x.单位}</span></div><div class="metric-note">${x.说明}</div></div>`).join("");
const max=Math.max(...o.组织体系分布.map(x=>x.数值));
const bars=o.组织体系分布.slice(0,10).map(x=>`<div class="bar-row"><span>${e(x.名称)}</span><div class="bar"><i style="width:${x.数值/max*100}%"></i></div><b>${x.数值}</b></div>`).join("");
const entries=o.业务入口.map(x=>`<div class="entry-card" onclick="setTab('${x.页面}')"><b>${x.名称}</b><span class="entry-num">${x.数量}</span><p>${x.说明}</p></div>`).join("");
document.getElementById("page").innerHTML=hero("知识库业务总览","以区域、组织与单位、装备、公共关键节点、演习和建设项目为主线组织知识数据。")+
`<div class="metrics">${metrics}</div><div class="grid2"><div class="panel"><div class="panel-title"><span>组织体系分布</span></div>${bars}</div><div class="panel"><div class="panel-title"><span>业务入口</span></div><div class="entry-grid">${entries}</div></div></div>`;
}
function toolbar(domain,data,filterFields){
const selects=filterFields.map(f=>`<select data-filter="${f}"><option value="">${f}:全部</option>${unique(data,f).slice(0,120).map(v=>`<option value="${e(v)}" ${state.filters[f]===String(v)?"selected":""}>${e(v)}</option>`).join("")}</select>`).join("");
return `<div class="toolbar"><input id="keyword" value="${e(state.q)}" placeholder="输入关键词查询当前页面">${selects}<button class="btn btn-soft" onclick="openQueryBuilder('${domain}')">组合查询</button><button class="btn" onclick="clearQuery()">清空</button><span class="toolbar-count" id="count"></span></div>`;
}
function bindToolbar(domain,renderFn){
const q=document.getElementById("keyword");if(q)q.oninput=()=>{state.q=q.value;state.page=1;renderFn()};
document.querySelectorAll("[data-filter]").forEach(s=>s.onchange=()=>{state.filters[s.dataset.filter]=s.value;state.page=1;renderFn()});
}
function clearQuery(){state.q="";state.filters={};state.conditions=[];state.page=1;render()}
function matches(item,fields){
const q=state.q.trim().toLowerCase();
if(q&&!fields.some(f=>text(item[f]).toLowerCase().includes(q)))return false;
for(const [f,v] of Object.entries(state.filters)){if(v&&!a(item[f]).some(x=>String(x)===String(v)))return false}
for(const c of state.conditions){
const value=item[c.field],all=a(value);
if(c.op==="包含"&&!all.some(x=>String(x).includes(c.value)))return false;
if(c.op==="等于"&&!all.some(x=>String(x)===c.value))return false;
if(c.op==="属于"&&!all.some(x=>c.value.split(/[、,,]/).map(y=>y.trim()).includes(String(x))))return false;
}
return true
}
function paginate(items){const start=(state.page-1)*state.pageSize;return items.slice(start,start+state.pageSize)}
function pagination(total,rerender){const pages=Math.max(1,Math.ceil(total/state.pageSize));return `<div class="pagination"><button class="btn" ${state.page<=1?"disabled":""} onclick="state.page--;${rerender}">上一页</button><span class="page-no">第 ${state.page} / ${pages} 页</span><button class="btn" ${state.page>=pages?"disabled":""} onclick="state.page++;${rerender}">下一页</button></div>`}
function render_regions(){
const cards=DATA.regions.map(r=>`<article class="region-card" onclick="openDetail('regions','${r.编号}')"><h3>${r.编号} · ${r.名称}</h3><p>${e(r.档案摘要)}</p><div>${chips(r.海岸形态,true,3)}</div><div class="region-stats"><div><b>${r.县市数量}</b><br><small>县市子库</small></div><div><b>${r.分段数量}</b><br><small>海岸分段</small></div></div></article>`).join("");
document.getElementById("page").innerHTML=hero("沿岸区域","采用九个综合研究区,下钻到沿海县市和海岸研究分段。")+`<div class="region-grid">${cards}</div>`;
}
function render_org_units(){
const chartActive=state.mode==="chart",cardsActive=state.mode==="cards";
document.getElementById("page").innerHTML=hero("组织与单位","组织架构与单位档案使用同一套280个唯一节点。点击架构节点或机构卡片可进入完整档案。")+
`<div class="segmented"><button class="seg-btn ${chartActive?"active":""}" onclick="state.mode='chart';render_org_units()">组织架构图</button><button class="seg-btn ${cardsActive?"active":""}" onclick="state.mode='cards';render_org_units()">机构与单位卡片</button></div><div id="orgBody"></div>`;
if(chartActive)renderOrgChart();else renderOrgCards(true);
}
function renderOrgChart(){
const views=DATA.organization_charts;
const current=views.find(x=>x.id===state.chart)||views[0];
const buttons=views.map(x=>`<button class="chart-view ${x.id===current.id?"active":""}" onclick="state.chart='${x.id}';renderOrgChart()">${x.名称}</button>`).join("");
const tree=`<div class="org-chart"><ul><li>${chartNode(current.tree)}</li></ul></div>`;
const roots=DATA.org_units.filter(x=>!x.行政上级编号).length;
document.getElementById("orgBody").innerHTML=`<div class="chart-toolbar">${buttons}</div><div class="panel" style="margin-bottom:14px"><div class="panel-title"><span>${current.名称}</span></div><p style="color:#777;font-size:13px">${current.说明}</p></div><div class="summary-strip"><div class="summary-card"><b>${DATA.org_units.length}</b><span>唯一组织节点</span></div><div class="summary-card"><b>${DATA.org_units.filter(x=>x.记录类别==="单位").length}</b><span>含深度单位档案</span></div><div class="summary-card"><b>${DATA.org_units.filter(x=>x.记录类别==="机构").length}</b><span>制度与组织节点</span></div><div class="summary-card"><b>${roots}</b><span>顶层或独立根节点</span></div></div><div class="org-chart-wrap">${tree}</div>`;
}
function chartNode(n){
if(!n)return "";
const children=(n.children||[]).length?`<ul>${n.children.map(c=>`<li>${chartNode(c)}</li>`).join("")}</ul>`:"";
return `<div class="org-node" onclick="openDetail('org_units','${n.id}')"><b>${e(n.名称)}</b><small>${e(n.类型)}</small><div class="node-kind">${e(n.记录类别)} · ${e(n.状态)}</div></div>${children}`;
}
function renderOrgCards(initial=false){
const data=DATA.org_units;
if(initial){
document.getElementById("orgBody").innerHTML=toolbar("组织与单位",data,["记录类别","体系","架构层级","当前状态"])+`<div id="orgCards"></div>`;
bindToolbar("组织与单位",()=>renderOrgCards(false));
}
const filtered=data.filter(x=>matches(x,["名称","档案摘要","体系","架构层级","公开任务","区域关联","装备类别"]));
const cards=paginate(filtered).map(x=>`<article class="data-card" onclick="openDetail('org_units','${x.id}')"><div class="card-top"><div><h3>${e(x.名称)}</h3><div class="card-sub">${e(x.体系)} · ${e(x.架构层级)}</div></div><span class="card-type">${e(x.记录类别)}</span></div><p>${e(x.档案摘要)}</p><div class="card-roles">${a(x.公开任务).slice(0,2).map(y=>`<div class="role-line">${e(y)}</div>`).join("")}</div><div class="card-footer"><span>${e(x.行政上级)}</span><span>${e(x.当前状态)}</span></div></article>`).join("");
document.getElementById("orgCards").innerHTML=`<div class="card-grid">${cards||'<div class="empty">没有匹配结果</div>'}</div>${pagination(filtered.length,"renderOrgCards(false)")}`;
const count=document.getElementById("count");if(count)count.textContent=`共 ${filtered.length} 个节点`;
}
function categorySummary(data,field,onClick){
const counts={};data.forEach(x=>{const k=x[field]||"未分类";counts[k]=(counts[k]||0)+1});
return `<div class="category-grid">${Object.entries(counts).sort((a,b)=>b[1]-a[1]).slice(0,10).map(([k,v])=>`<div class="category-card" onclick="${onClick}('${e(k)}')"><b>${v}</b><span>${e(k)}</span></div>`).join("")}</div>`
}
function render_equipment(initial=true){
const data=DATA.equipment;
document.getElementById("page").innerHTML=hero("武器装备","以装备卡片展示类别、使用体系、公开作用和生命周期状态,不把规划、采购、测试和列装混为一谈。")+
categorySummary(data,"类别中文","filterEquipmentCategory")+toolbar("武器装备",data,["类别中文","状态中文","可信度中文"])+`<div id="equipmentCards"></div>`;
bindToolbar("武器装备",()=>renderEquipmentCards());
renderEquipmentCards();
}
function filterEquipmentCategory(v){state.filters["类别中文"]=v;state.page=1;render_equipment()}
function renderEquipmentCards(){
const data=DATA.equipment.filter(x=>matches(x,["名称","别名","类别中文","使用体系中文","公开作用","状态中文"]));
const html=paginate(data).map(x=>`<article class="data-card" onclick="openDetail('equipment','${x.id}')"><div class="card-top"><div><h3>${e(x.名称)}</h3><div class="card-sub">${e(x.类别中文)} · ${a(x.使用体系中文).join("、")}</div></div>${statusChip(x.状态中文)}</div><p>${e(x.档案摘要)}</p><div class="card-roles">${a(x.公开作用).slice(0,3).map(y=>`<div class="role-line">${e(y)}</div>`).join("")}</div><div class="card-footer"><span>可信度:${e(x.可信度中文)}</span><span>${e(x.verified_at||"未标注")}</span></div></article>`).join("");
document.getElementById("equipmentCards").innerHTML=`<div class="card-grid">${html||'<div class="empty">没有匹配结果</div>'}</div>${pagination(data.length,"renderEquipmentCards()")}`;
const c=document.getElementById("count");if(c)c.textContent=`共 ${data.length} 项装备`;
}
function render_key_nodes(){
const data=DATA.key_nodes;
document.getElementById("page").innerHTML=hero("关键节点","从公共功能和韧性角度展示港口、机场、交通、能源、通信、供水、医疗和产业节点。")+
categorySummary(data,"类别中文","filterNodeCategory")+toolbar("关键节点",data,["类别中文","层级中文","状态中文","可信度中文"])+`<div id="nodeCards"></div>`;
bindToolbar("关键节点",()=>renderNodeCards());renderNodeCards();
}
function filterNodeCategory(v){state.filters["类别中文"]=v;state.page=1;render_key_nodes()}
function renderNodeCards(){
const data=DATA.key_nodes.filter(x=>matches(x,["名称","类别中文","区域中文","主管运营","公共功能","状态中文"]));
const html=paginate(data).map(x=>`<article class="data-card" onclick="openDetail('key_nodes','${x.id}')"><div class="card-top"><div><h3>${e(x.名称)}</h3><div class="card-sub">${e(x.类别中文)} · ${e(x.层级中文)}</div></div>${statusChip(x.状态中文)}</div><p>${e(x.档案摘要)}</p><div>${chips(x.区域中文,true,3)}</div><div class="card-roles">${a(x.公共功能).slice(0,2).map(y=>`<div class="role-line">${e(y)}</div>`).join("")}</div><div class="card-footer"><span>${e(x.主管运营)}</span><span>可信度:${e(x.可信度中文)}</span></div></article>`).join("");
document.getElementById("nodeCards").innerHTML=`<div class="card-grid">${html||'<div class="empty">没有匹配结果</div>'}</div>${pagination(data.length,"renderNodeCards()")}`;
const c=document.getElementById("count");if(c)c.textContent=`共 ${data.length} 个节点`;
}
function render_events_projects(){
const events=DATA.events.map(x=>`<article class="time-card" onclick="openDetail('events','${x.id}')"><div class="time-date">${e(x.日期显示)}</div><h3 style="margin:0 0 7px">${e(x.名称)}</h3><div>${chips([x.类型中文,...a(x.区域中文)],true,4)}</div><p>${e(x.档案摘要)}</p></article>`).join("");
const projects=DATA.projects.map(x=>`<article class="data-card" onclick="openDetail('projects','${x.id}')"><div class="card-top"><div><h3>${e(x.名称)}</h3><div class="card-sub">${e(x.类别中文)}</div></div>${statusChip(x.状态中文)}</div><p>${e(x.档案摘要)}</p><div class="card-footer"><span>建设项目</span><span>${e(x.状态中文)}</span></div></article>`).join("");
document.getElementById("page").innerHTML=hero("演习与项目","演习采用时间线,建设项目采用卡片,分别表达日期型事件和生命周期型项目。")+
`<div class="grid2"><section class="panel"><div class="panel-title"><span>演习与重要事件</span></div><div class="timeline">${events}</div></section><section class="panel"><div class="panel-title"><span>现代化与建设项目</span></div><div class="card-grid" style="grid-template-columns:1fr">${projects}</div></section></div>`;
}
function findItem(domain,id){
if(domain==="regions")return DATA.regions.find(x=>x.编号===id);
if(domain==="org_units")return DATA.org_units.find(x=>x.id===id);
if(domain==="equipment")return DATA.equipment.find(x=>x.id===id);
if(domain==="key_nodes")return DATA.key_nodes.find(x=>x.id===id);
if(domain==="events")return DATA.events.find(x=>x.id===id);
if(domain==="projects")return DATA.projects.find(x=>x.id===id);
}
function openDetail(domain,id){
const x=findItem(domain,id);if(!x)return;
document.getElementById("page").style.display="none";
const d=document.getElementById("detailPage");d.classList.add("show");
if(domain==="org_units")d.innerHTML=detailOrg(x);
else if(domain==="regions")d.innerHTML=detailRegion(x);
else if(domain==="equipment")d.innerHTML=detailEquipment(x);
else if(domain==="key_nodes")d.innerHTML=detailNode(x);
else d.innerHTML=detailEventProject(x,domain);
location.hash=`#/detail/${domain}/${encodeURIComponent(id)}`;
window.scrollTo({top:0});
}
function back(){document.getElementById("detailPage").classList.remove("show");document.getElementById("page").style.display="block";location.hash="#/"+state.tab}
function info(label,value){return `<div class="info-item"><label>${e(label)}</label><div>${value===undefined||value===null||value===""?'<span style="color:#999">未公开或未标注</span>':value}</div></div>`}
function listBlock(values){const v=a(values);return v.length?`<div class="text-list">${v.map(x=>`<div class="text-item">${e(x)}</div>`).join("")}</div>`:`<div class="empty" style="padding:20px">暂无独立条目</div>`}
function section(title,body){return `<section class="detail-section"><h3>${title}</h3>${body}</section>`}
function detailHeader(title,summary,tags){return `<span class="back-link" onclick="back()" style="color:var(--red);cursor:pointer;display:inline-block;margin-bottom:12px">← 返回上一页</span><div class="detail-hero"><h2>${e(title)}</h2><p>${e(summary)}</p><div class="detail-tags">${chips(tags,false,10)}</div></div>`}
function detailOrg(x){
const core=section("组织与单位概况",`<div class="info-grid">${info("记录类别",e(x.记录类别))}${info("体系",e(x.体系))}${info("架构层级",e(x.架构层级))}${info("组织或单位类型",e(x.单位类型))}${info("当前状态",statusChip(x.当前状态))}${info("可信度",e(x.可信度))}${info("行政上级",e(x.行政上级))}${info("军令或任务上级",e(x.军令或任务上级))}</div>`);
const tasks=section("公开职掌与任务",listBlock(x.公开任务));
const assoc=section("区域、装备与演训关联",`<div class="info-grid">${info("区域关联",chips(x.区域关联,true,12))}${info("装备类别",chips(x.装备类别,true,12))}${info("演训与事件",chips(x.演训与事件,true,12))}${info("适用范围",e(x.适用范围))}</div>`);
const childrenSec=section("直接下级机构或单位",x.下级机构.length?`<div class="mini-card-grid">${x.下级机构.map((n,i)=>`<div class="mini-card" ${x.下级机构编号[i]?`onclick="openDetail('org_units','${x.下级机构编号[i]}')" style="cursor:pointer"`:""}><b>${e(n)}</b><span style="font-size:11px;color:#888">点击查看档案</span></div>`).join("")}</div>`:`<div class="empty" style="padding:20px">未列出直接下级</div>`);
const relSec=section("组织关系",x.关系.length?`<div class="mini-card-grid">${x.关系.slice(0,16).map(r=>`<div class="mini-card"><b>${e(r.关系)}</b><div style="font-size:12px;line-height:1.65">${e(r.主体)} → ${e(r.客体)}</div><span style="font-size:11px;color:#888">${e(r.可信度)}</span></div>`).join("")}</div>`:`<div class="empty" style="padding:20px">暂无独立关系条目</div>`);
const history=section("历史沿革与说明",`<div class="text-item">${e(x.历史沿革)}</div>${x.说明?`<div class="text-item">${e(x.说明)}</div>`:""}`);
const gaps=section("资料缺口",`<div class="warning-box">${a(x.资料缺口).map(y=>`• ${e(y)}`).join("<br>")}</div>`);
const sources=section("来源与核实",`<div class="info-grid">${info("核实日期",e(x.核实日期))}${info("来源数量",String(a(x.来源).length))}</div><div style="margin-top:10px">${a(x.来源信息||x.来源).map(s=>`<div class="source-line">${e(s)}</div>`).join("")||'<div class="source-line">尚未列出独立来源编号</div>'}</div>`);
const safe=section("安全边界",`<div class="safe-box">${e(x.安全边界)}</div>`);
return detailHeader(x.名称,x.档案摘要,[x.记录类别,x.体系,x.当前状态])+`<div class="detail-grid"><div>${core}${tasks}${assoc}${childrenSec}${relSec}</div><aside>${history}${sources}${gaps}${safe}</aside></div>`;
}
function detailRegion(x){
const countyCards=x.县市子库.map(c=>`<div class="mini-card"><b>${e(c.name||c.county_name||c.名称||"县市子库")}</b><div style="font-size:12px;color:#777">${e(c.scope||c.沿岸范围||"沿岸县市档案")}</div></div>`).join("");
const segCards=x.海岸分段.map(s=>`<div class="mini-card"><b>${e(s.name||s.名称)}</b><div>${chips([s.coastal_type||s.海岸类型,s.所属区域],true,3)}</div><p style="font-size:11px;color:#777">${e(s.scope||s.空间范围||"")}</p></div>`).join("");
return detailHeader(x.名称,x.档案摘要,[x.编号,...a(x.海岸形态)])+
`<div class="detail-grid"><div>${section("区域基本信息",`<div class="info-grid">${info("空间范围",e(x.scope))}${info("县市数量",String(x.县市数量))}${info("海岸分段数量",String(x.分段数量))}${info("核实日期",e(x.last_verified||"未标注"))}${info("沿海县市",chips(x.沿海县市,true,15))}${info("支撑区域",chips(x.支撑区域,true,15))}</div>`)}${section("海岸形态",listBlock(x.海岸形态))}${section("海岸研究分段",`<div class="mini-card-grid">${segCards}</div>`)}</div><aside>${section("县市子库",`<div class="mini-card-grid" style="grid-template-columns:1fr">${countyCards}</div>`)}${section("资料来源",`<div>${a(x.来源信息||x.source_ids).map(s=>`<div class="source-line">${e(s)}</div>`).join("")||'<div class="source-line">待进一步补充</div>'}</div>`)}${section("研究边界",`<div class="safe-box">区域分段用于公开空间、治理和韧性研究,不等于军事防区、实际部署边界或行动分区。</div>`)}</aside></div>`;
}
function detailEquipment(x){
return detailHeader(x.名称,x.档案摘要,[x.类别中文,x.状态中文,...a(x.使用体系中文)])+
`<div class="detail-grid"><div>${section("装备概况",`<div class="info-grid">${info("装备类别",e(x.类别中文))}${info("使用体系",chips(x.使用体系中文,true,10))}${info("公开状态",statusChip(x.状态中文))}${info("可信度",e(x.可信度中文))}${info("别名",chips(x.aliases,true,10))}${info("核实日期",e(x.verified_at||"未标注"))}</div>`)}${section("公开作用",listBlock(x.公开作用))}${section("生命周期解释",`<div class="text-list"><div class="text-item">规划、预算、签约、交付、接装、训练、测评和形成能力属于不同阶段。</div><div class="text-item">本条目的当前状态为:${e(x.状态中文)}。</div><div class="text-item">装备价值需与感知、指挥、平台、保障和训练共同评估。</div></div>`)}</div><aside>${section("来源",a(x.来源信息||x.sources).map(s=>`<div class="source-line">${e(s)}</div>`).join("")||'<div class="source-line">待补</div>')}${section("备注",`<div class="text-item">${e(x.notes||"无额外公开说明。")}</div>`)}${section("资料边界",`<div class="safe-box">不记录或推断库存、精确部署、妥善率、射界、航线、战法和单位实际配赋。</div>`)}</aside></div>`;
}
function detailNode(x){
return detailHeader(x.名称,x.档案摘要,[x.类别中文,x.层级中文,x.状态中文])+
`<div class="detail-grid"><div>${section("节点概况",`<div class="info-grid">${info("节点类别",e(x.类别中文))}${info("节点层级",e(x.层级中文))}${info("所属区域",chips(x.区域中文,true,10))}${info("运行状态",statusChip(x.状态中文))}${info("主管或运营主体",e(x.主管运营))}${info("可信度",e(x.可信度中文))}</div>`)}${section("公共功能",listBlock(x.公共功能))}${section("上游依赖",listBlock(x.依赖类别))}${section("替代与备援",listBlock(x.替代备援))}</div><aside>${section("来源",a(x.来源信息||x.sources).map(s=>`<div class="source-line">${e(s)}</div>`).join("")||'<div class="source-line">待补</div>')}${section("资料说明",`<div class="text-item">${e(x.note||"无额外公开说明。")}</div>`)}${section("研究边界",`<div class="safe-box">节点用于公共功能、治理和韧性研究,不展示精确坐标、内部拓扑、实时容量、库存、脆弱点或攻击价值。</div>`)}</aside></div>`;
}
function detailEventProject(x,domain){
const isEvent=domain==="events";
return detailHeader(x.名称,x.档案摘要,[isEvent?x.类型中文:x.类别中文,x.状态中文])+
`<div class="detail-grid"><div>${section(isEvent?"事件信息":"项目信息",`<div class="info-grid">${info(isEvent?"事件类型":"项目类别",e(isEvent?x.类型中文:x.类别中文))}${info("状态",statusChip(x.状态中文))}${info("日期",e(x.日期显示||"不适用"))}${info("区域",chips(x.区域中文,true,12))}</div>`)}${section(isEvent?"主题与验证重点":"项目评估链条",isEvent?listBlock(x.topics):`<div class="text-list"><div class="text-item">规划与需求确认</div><div class="text-item">预算与采购</div><div class="text-item">生产、交付与接装</div><div class="text-item">训练、测评、保障与形成能力</div></div>`)}</div><aside>${section("来源",`<div class="source-line">${e(a(x.来源信息).join(";")||x.source||"待补")}</div>`)}${section("分析限制",`<div class="safe-box">${isEvent?"单次演习不能直接证明永久编制、完整战备或真实作战效果。":"项目存在不等于已经交付、接装或形成能力。"}</div>`)}</aside></div>`;
}
function openQueryBuilder(domainName){
const conf=QUERY_SCHEMA.查询域[domainName];if(!conf)return;
const opts=conf.字段.map(x=>`<option value="${e(x.字段)}">${e(x.显示名)}</option>`).join("");
const rows=(state.conditions.length?state.conditions:[{field:conf.字段[0].字段,op:"包含",value:""}]).map((c,i)=>queryRow(opts,c,i)).join("");
openModal(`<h2 style="margin-top:0">组合条件查询</h2><p style="color:#777;font-size:13px">使用中文字段构造条件,查询只作用于当前业务页面。</p><div id="queryRows">${rows}</div><button class="btn" onclick="addQueryRow('${domainName}')">增加条件</button> <button class="btn btn-red" onclick="applyQuery('${domainName}')">应用查询</button>`);
}
function queryRow(opts,c,i){return `<div class="query-row" data-query-row><select class="q-field">${opts.replace(`value="${c.field}"`,`value="${c.field}" selected`)}</select><select class="q-op"><option ${c.op==="包含"?"selected":""}>包含</option><option ${c.op==="等于"?"selected":""}>等于</option><option ${c.op==="属于"?"selected":""}>属于</option></select><input class="q-value" value="${e(c.value)}" placeholder="输入查询值"><button class="btn" onclick="this.parentElement.remove()">×</button></div>`}
function addQueryRow(domainName){const conf=QUERY_SCHEMA.查询域[domainName];const opts=conf.字段.map(x=>`<option value="${e(x.字段)}">${e(x.显示名)}</option>`).join("");document.getElementById("queryRows").insertAdjacentHTML("beforeend",queryRow(opts,{field:conf.字段[0].字段,op:"包含",value:""},99))}
function applyQuery(){state.conditions=[...document.querySelectorAll("[data-query-row]")].map(r=>({field:r.querySelector(".q-field").value,op:r.querySelector(".q-op").value,value:r.querySelector(".q-value").value})).filter(x=>x.value);state.page=1;closeModal();render()}
function openModal(html){document.getElementById("modal").innerHTML=html;document.getElementById("modalOverlay").classList.add("show")}
function closeModal(){document.getElementById("modalOverlay").classList.remove("show")}
function closeDrawer(){document.getElementById("drawerOverlay").classList.remove("show")}
document.getElementById("modalOverlay").onclick=x=>{if(x.target.id==="modalOverlay")closeModal()};
document.getElementById("drawerOverlay").onclick=x=>{if(x.target.id==="drawerOverlay")closeDrawer()};
const hash=location.hash.match(/^#\/([^/]+)/);if(hash&&MANIFEST.页面.some(x=>x.编号===hash[1]))state.tab=hash[1];render();
</script>
</body>
</html>
@@ -0,0 +1,322 @@
<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>台湾飞行部队知识数据展示平台 v3</title><style>
:root{
--red:#b4232d;--red2:#d94a54;--red3:#fff1f2;--red4:#7e1620;
--bg:#f4f6f8;--white:#fff;--text:#1e2329;--muted:#667085;
--line:#e5e7eb;--soft:#f8fafc;--green:#14804a;--amber:#b4690e;
--shadow:0 12px 36px rgba(31,41,55,.09);
}
*{box-sizing:border-box}
html,body{margin:0;min-height:100%;font-family:"Microsoft YaHei","PingFang SC","Noto Sans CJK SC",Arial,sans-serif;color:var(--text);background:var(--bg)}
button,input,select{font:inherit}
button{cursor:pointer}
a{color:var(--red);text-decoration:none}
.hidden{display:none!important}
.app{min-height:100vh;display:flex;flex-direction:column}
.header{height:76px;background:linear-gradient(120deg,var(--red4),var(--red));color:#fff;display:flex;align-items:center;justify-content:space-between;padding:0 28px;box-shadow:0 5px 18px rgba(126,22,32,.22);position:sticky;top:0;z-index:50}
.brand{display:flex;align-items:center;gap:14px}
.brand-mark{width:45px;height:45px;border:2px solid rgba(255,255,255,.72);border-radius:13px;display:grid;place-items:center;font-weight:800;font-size:21px;background:rgba(255,255,255,.1)}
.brand h1{font-size:21px;margin:0 0 4px;letter-spacing:.5px}
.brand p{font-size:12px;margin:0;color:rgba(255,255,255,.78)}
.header-actions{display:flex;align-items:center;gap:10px}
.search{position:relative}
.search input{width:330px;border:1px solid rgba(255,255,255,.25);background:rgba(255,255,255,.13);color:#fff;border-radius:10px;padding:11px 42px 11px 14px;outline:none}
.search input::placeholder{color:rgba(255,255,255,.72)}
.search .icon{position:absolute;right:13px;top:10px;font-size:18px}
.search-pop{position:absolute;right:0;top:48px;width:460px;max-height:460px;overflow:auto;background:#fff;color:var(--text);border:1px solid var(--line);border-radius:12px;box-shadow:var(--shadow);padding:7px;z-index:120}
.search-item{display:flex;gap:10px;padding:11px;border-radius:9px;border-bottom:1px dashed #eee}
.search-item:hover{background:var(--red3)}
.search-kind{min-width:58px;color:var(--red);font-size:12px;font-weight:700}
.search-item strong{display:block;font-size:14px;margin-bottom:4px}
.search-item small{color:var(--muted);line-height:1.45}
.btn{border:0;border-radius:9px;padding:10px 15px;font-weight:700;transition:.18s}
.btn.primary{background:var(--red);color:#fff}
.btn.primary:hover{background:var(--red4)}
.btn.white{background:#fff;color:var(--red)}
.btn.ghost{background:rgba(255,255,255,.12);color:#fff;border:1px solid rgba(255,255,255,.25)}
.btn.light{background:var(--red3);color:var(--red)}
.btn.line{background:#fff;color:var(--red);border:1px solid #efb7bc}
.btn.sm{padding:7px 11px;font-size:12px}
.tabs{height:56px;background:#fff;display:flex;align-items:end;gap:2px;padding:0 24px;border-bottom:1px solid var(--line);position:sticky;top:76px;z-index:40;overflow-x:auto}
.tab{border:0;background:transparent;color:#4b5563;font-weight:700;padding:17px 18px 14px;white-space:nowrap;border-bottom:3px solid transparent}
.tab:hover{color:var(--red)}
.tab.active{color:var(--red);border-bottom-color:var(--red)}
.main{width:min(1540px,100%);margin:auto;padding:24px 28px 54px;flex:1}
.page{display:none}
.page.active{display:block}
.page-head{display:flex;justify-content:space-between;gap:18px;align-items:flex-start;margin-bottom:20px}
.page-head h2{margin:0 0 7px;font-size:25px}
.page-head p{margin:0;color:var(--muted);line-height:1.65}
.page-actions{display:flex;gap:9px;flex-wrap:wrap}
.stats{display:grid;grid-template-columns:repeat(6,1fr);gap:14px}
.stat{background:#fff;border:1px solid var(--line);border-top:4px solid var(--red);border-radius:13px;padding:17px;box-shadow:0 5px 17px rgba(31,41,55,.04)}
.stat .label{color:var(--muted);font-size:13px}
.stat .num{font-size:29px;font-weight:850;margin:9px 0 3px}
.stat .hint{font-size:12px;color:#98a2b3}
.grid{display:grid;gap:15px}
.grid.cols2{grid-template-columns:repeat(2,minmax(0,1fr))}
.grid.cols3{grid-template-columns:repeat(3,minmax(0,1fr))}
.grid.cols4{grid-template-columns:repeat(4,minmax(0,1fr))}
.panel{background:#fff;border:1px solid var(--line);border-radius:14px;box-shadow:0 6px 22px rgba(31,41,55,.045);padding:18px}
.panel-head{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:15px}
.panel h3,.panel h4{margin:0}
.panel-head p{margin:4px 0 0;color:var(--muted);font-size:12px}
.section-title{display:flex;justify-content:space-between;align-items:end;margin:25px 0 13px}
.section-title h3{font-size:18px;margin:0}
.section-title span{color:var(--muted);font-size:12px}
.entry{background:#fff;border:1px solid var(--line);border-radius:14px;padding:19px;min-height:150px;position:relative;overflow:hidden;box-shadow:0 5px 18px rgba(31,41,55,.045)}
.entry:before{content:"";position:absolute;right:-25px;top:-35px;width:110px;height:110px;border-radius:50%;background:var(--red3)}
.entry .letter{position:relative;width:39px;height:39px;border-radius:10px;background:var(--red);color:#fff;display:grid;place-items:center;font-weight:800;margin-bottom:16px}
.entry strong{font-size:16px}
.entry p{color:var(--muted);line-height:1.65;font-size:13px}
.entry:hover{transform:translateY(-2px);box-shadow:var(--shadow);border-color:#efb7bc}
.notice{background:linear-gradient(120deg,#fff8f8,#fff);border:1px solid #f2c6ca;border-left:4px solid var(--red);border-radius:10px;padding:14px;line-height:1.7;color:#5e3034}
.badge,.chip{display:inline-flex;align-items:center;border-radius:999px;padding:5px 9px;font-size:12px;font-weight:700;margin:2px 4px 2px 0}
.badge.red,.chip.red{background:var(--red3);color:var(--red)}
.badge.green{background:#e9f8ef;color:var(--green)}
.badge.amber{background:#fff5e6;color:var(--amber)}
.badge.gray{background:#f1f3f5;color:#667085}
.bar-row{display:grid;grid-template-columns:90px 1fr 35px;align-items:center;gap:10px;margin:14px 0;font-size:13px}
.bar-track{height:9px;border-radius:8px;background:#f0f1f3;overflow:hidden}
.bar-fill{height:100%;background:linear-gradient(90deg,var(--red),var(--red2));border-radius:8px}
.unit-card{background:#fff;border:1px solid var(--line);border-radius:14px;padding:17px;display:flex;flex-direction:column;min-height:245px;box-shadow:0 5px 18px rgba(31,41,55,.04)}
.unit-card:hover{border-color:#e8a2a8;box-shadow:var(--shadow);transform:translateY(-2px)}
.unit-card .top{display:flex;justify-content:space-between;gap:12px}
.unit-card h4{font-size:16px;margin:0 0 5px}
.unit-card .sub{color:var(--muted);font-size:12px}
.unit-card p{color:#555f6d;line-height:1.65;font-size:13px;flex:1}
.unit-card .foot{display:flex;justify-content:space-between;align-items:center;margin-top:13px}
.filters{display:grid;grid-template-columns:1.4fr repeat(4,1fr) auto;gap:10px;background:#fff;border:1px solid var(--line);border-radius:13px;padding:13px;margin-bottom:15px}
.input,.select{width:100%;border:1px solid #d8dde4;border-radius:9px;padding:10px 11px;background:#fff;outline:none}
.input:focus,.select:focus{border-color:var(--red);box-shadow:0 0 0 3px rgba(180,35,45,.08)}
.view-switch{display:flex;gap:5px;background:#f5f6f8;padding:4px;border-radius:9px}
.view-switch button{border:0;background:transparent;padding:7px 10px;border-radius:7px;color:#667085}
.view-switch button.active{background:#fff;color:var(--red);box-shadow:0 2px 8px rgba(0,0,0,.07)}
.table-wrap{overflow:auto;border:1px solid var(--line);border-radius:12px;background:#fff}
table{width:100%;border-collapse:collapse;min-width:900px}
th{position:sticky;top:0;background:#fff5f5;color:#6e1e24;text-align:left;font-size:13px;padding:12px;border-bottom:1px solid #f1c8cc;z-index:2}
td{font-size:13px;padding:12px;border-bottom:1px solid #edf0f2;vertical-align:top;line-height:1.55}
tr:hover td{background:#fffafa}
.pagination{display:flex;justify-content:center;align-items:center;gap:8px;margin-top:16px}
.org-layout{display:grid;grid-template-columns:230px minmax(0,1fr) 305px;gap:15px}
.side-nav,.side-info{background:#fff;border:1px solid var(--line);border-radius:13px;padding:14px;height:max-content;position:sticky;top:151px}
.side-nav button{width:100%;border:0;background:#fff;text-align:left;padding:11px;border-radius:8px;color:#475467;font-weight:700}
.side-nav button.active,.side-nav button:hover{background:var(--red3);color:var(--red)}
.org-canvas{background:#fff;border:1px solid var(--line);border-radius:14px;overflow:auto;min-height:610px;padding:22px}
.org-toolbar{display:flex;justify-content:space-between;gap:10px;align-items:center;margin-bottom:18px}
.zoom{display:flex;gap:5px}
.tree{--scale:1;transform:scale(var(--scale));transform-origin:top center;min-width:980px;padding:10px 35px 50px}
.tree ul{padding-top:24px;position:relative;display:flex;justify-content:center}
.tree li{list-style:none;text-align:center;position:relative;padding:24px 7px 0}
.tree li:before,.tree li:after{content:"";position:absolute;top:0;width:50%;height:24px;border-top:1px solid #d8a5a9}
.tree li:before{right:50%;border-right:1px solid #d8a5a9}
.tree li:after{left:50%;border-left:1px solid #d8a5a9}
.tree li:only-child:before,.tree li:only-child:after{display:none}
.tree li:first-child:before,.tree li:last-child:after{border:0}
.tree li:last-child:before{border-radius:0 7px 0 0}
.tree li:first-child:after{border-radius:7px 0 0 0}
.tree ul ul:before{content:"";position:absolute;top:0;left:50%;height:24px;border-left:1px solid #d8a5a9}
.org-node{width:155px;min-height:62px;padding:10px 8px;border:1px solid #e4aab0;background:#fff;border-radius:10px;display:inline-flex;flex-direction:column;justify-content:center;box-shadow:0 4px 12px rgba(31,41,55,.055)}
.org-node.root{background:var(--red);color:#fff;border-color:var(--red)}
.org-node.branch{background:#fff2f3;color:var(--red4);font-weight:800}
.org-node:hover{box-shadow:0 9px 23px rgba(180,35,45,.15);transform:translateY(-2px)}
.org-node small{font-size:10px;margin-top:5px;color:#8b8f99}
.org-node.root small{color:rgba(255,255,255,.75)}
.kv{display:grid;grid-template-columns:86px 1fr;gap:9px;padding:10px 0;border-bottom:1px dashed #e7e8eb;font-size:13px}
.kv .k{color:var(--muted)}
.kv .v{font-weight:650;line-height:1.55}
.equipment-layout{display:grid;grid-template-columns:220px minmax(0,1fr);gap:15px}
.category-list button{width:100%;display:flex;justify-content:space-between;border:0;background:#fff;padding:11px;border-radius:8px;margin:3px 0;color:#475467}
.category-list button.active,.category-list button:hover{background:var(--red3);color:var(--red);font-weight:700}
.equip-card{background:#fff;border:1px solid var(--line);border-radius:13px;padding:16px;min-height:190px;display:flex;flex-direction:column}
.equip-card h4{margin:0 0 5px}
.equip-card p{font-size:13px;color:var(--muted);line-height:1.6;flex:1}
.flow{display:flex;align-items:stretch;gap:9px;overflow:auto;padding:5px 0 15px}
.flow-step{min-width:150px;background:#fff;border:1px solid #efb9be;border-radius:12px;padding:14px;position:relative}
.flow-step:not(:last-child):after{content:"›";position:absolute;right:-10px;top:40%;font-size:25px;color:var(--red)}
.flow-step .no{width:26px;height:26px;border-radius:50%;background:var(--red);color:#fff;display:grid;place-items:center;font-size:12px;font-weight:800}
.flow-step h4{margin:10px 0 5px}
.flow-step p{font-size:12px;color:var(--muted);line-height:1.55}
.lane{display:grid;grid-template-columns:140px 1fr;gap:12px;align-items:start;margin-bottom:12px}
.lane-title{background:var(--red);color:#fff;border-radius:10px;padding:14px;font-weight:800}
.lane-items{display:flex;gap:9px;flex-wrap:wrap}
.lane-item{background:#fff;border:1px solid var(--line);border-radius:10px;padding:12px;min-width:150px;flex:1}
.industry-stack{display:grid;gap:12px}
.industry-layer{background:#fff;border:1px solid var(--line);border-left:5px solid var(--red);border-radius:12px;padding:15px;display:grid;grid-template-columns:190px 1fr;gap:14px}
.risk-card{background:#fff;border:1px solid var(--line);border-radius:12px;padding:15px}
.risk-card h4{margin:0 0 8px;color:var(--red4)}
.timeline{position:relative;padding-left:27px}
.timeline:before{content:"";position:absolute;left:8px;top:7px;bottom:5px;width:2px;background:#e6b3b8}
.event{position:relative;background:#fff;border:1px solid var(--line);border-radius:11px;padding:14px;margin-bottom:12px}
.event:before{content:"";position:absolute;left:-25px;top:17px;width:12px;height:12px;border-radius:50%;background:var(--red);border:3px solid #ffd8dc}
.event .date{font-size:12px;color:var(--red);font-weight:800}
.event h4{margin:5px 0}
.event p{margin:0;color:var(--muted);font-size:13px;line-height:1.6}
.source-layout{display:grid;grid-template-columns:1.15fr .85fr;gap:15px}
.source-card{border:1px solid var(--line);background:#fff;border-radius:11px;padding:14px;margin-bottom:10px}
.source-card:hover{border-color:#e5a4aa;background:#fffafa}
.source-card h4{margin:0 0 7px}
.source-card p{margin:0;color:var(--muted);font-size:13px;line-height:1.55}
.empty{padding:40px;text-align:center;color:var(--muted)}
.modal,.drawer,.detail,.query-panel{position:fixed;inset:0}.detail{z-index:200}.drawer{z-index:320}.modal{z-index:420}.query-panel{z-index:500}
.backdrop{position:absolute;inset:0;background:rgba(20,24,31,.52);backdrop-filter:blur(2px)}
.modal-card{position:relative;width:min(820px,calc(100% - 34px));max-height:86vh;overflow:auto;margin:7vh auto;background:#fff;border-radius:16px;box-shadow:0 24px 80px rgba(0,0,0,.24);padding:26px}
.close{position:absolute;right:15px;top:14px;border:0;width:34px;height:34px;border-radius:9px;background:#f2f4f7;font-size:21px;color:#667085}
.drawer-card{position:absolute;right:0;top:0;height:100%;width:min(650px,92vw);background:#fff;box-shadow:-12px 0 45px rgba(0,0,0,.2);overflow:auto;padding:68px 25px 30px}
.drawer-head{position:absolute;right:0;top:0;width:min(650px,92vw);height:58px;background:#fff;border-bottom:1px solid var(--line);display:flex;align-items:center;justify-content:space-between;padding:0 20px;z-index:3}
.detail{background:#f4f6f8;overflow:auto}
.detail-top{height:72px;background:var(--red4);color:#fff;display:flex;align-items:center;justify-content:space-between;padding:0 25px;position:sticky;top:0;z-index:3}
.detail-body{width:min(1320px,100%);margin:auto;padding:24px}
.detail-hero{background:linear-gradient(120deg,var(--red),#d84955);color:#fff;border-radius:16px;padding:25px;display:grid;grid-template-columns:1fr 330px;gap:20px;box-shadow:var(--shadow)}
.detail-hero h2{font-size:27px;margin:0 0 9px}
.detail-hero p{line-height:1.7;color:rgba(255,255,255,.86)}
.detail-summary{background:rgba(255,255,255,.13);border:1px solid rgba(255,255,255,.2);border-radius:12px;padding:13px}
.detail-nav{display:flex;gap:4px;background:#fff;border:1px solid var(--line);border-radius:12px;padding:6px;margin:15px 0;position:sticky;top:84px;z-index:2;overflow:auto}
.detail-nav button{border:0;background:transparent;padding:10px 14px;border-radius:8px;font-weight:700;color:#667085;white-space:nowrap}
.detail-nav button.active{background:var(--red3);color:var(--red)}
.detail-section{scroll-margin-top:150px;margin-bottom:15px}
.detail-section h3{border-left:4px solid var(--red);padding-left:10px}
.rich{line-height:1.82;color:#475467}
.query-card{position:relative;width:min(1180px,calc(100% - 30px));height:88vh;margin:6vh auto;background:#fff;border-radius:16px;box-shadow:0 24px 80px rgba(0,0,0,.24);display:grid;grid-template-columns:310px 1fr;overflow:hidden}
.query-form{background:#fff8f8;border-right:1px solid #f2c9cd;padding:25px}
.query-result{padding:25px;overflow:auto}
.query-row{border:1px solid var(--line);border-radius:10px;padding:12px;margin-bottom:9px}
.query-row:hover{border-color:#e4a3aa;background:#fffafa}
.json{white-space:pre-wrap;word-break:break-word;background:#1f242b;color:#f5f7fa;padding:16px;border-radius:10px;font:12px/1.65 Consolas,monospace}
.footer{background:#fff;border-top:1px solid var(--line);color:#777f8b;padding:16px 28px;display:flex;justify-content:space-between;font-size:12px}
.mt{margin-top:15px}.mb{margin-bottom:15px}
@media(max-width:1180px){.stats{grid-template-columns:repeat(3,1fr)}.org-layout{grid-template-columns:200px 1fr}.side-info{display:none}.grid.cols4{grid-template-columns:repeat(2,1fr)}}
@media(max-width:820px){.header{height:auto;padding:13px 16px;align-items:flex-start}.brand p{display:none}.header-actions{display:none}.tabs{top:69px;padding-left:8px}.main{padding:17px 12px 45px}.stats{grid-template-columns:repeat(2,1fr)}.grid.cols2,.grid.cols3,.grid.cols4{grid-template-columns:1fr}.filters{grid-template-columns:1fr 1fr}.org-layout,.equipment-layout,.source-layout{grid-template-columns:1fr}.side-nav{position:static}.org-canvas{min-height:500px}.detail-hero{grid-template-columns:1fr}.query-card{grid-template-columns:1fr;height:94vh;margin:3vh auto}.query-form{border-right:0;border-bottom:1px solid #f2c9cd}.footer{display:block;line-height:1.8}}
.knowledge-layout{display:grid;grid-template-columns:230px minmax(0,1fr);gap:16px}
.knowledge-side{background:#fff;border:1px solid var(--line);border-radius:14px;padding:14px;height:max-content;position:sticky;top:151px}
.knowledge-side button{width:100%;border:0;background:#fff;text-align:left;padding:10px 11px;border-radius:8px;color:#475467;display:flex;justify-content:space-between;gap:8px}
.knowledge-side button:hover,.knowledge-side button.active{background:var(--red3);color:var(--red);font-weight:750}
.knowledge-card{background:#fff;border:1px solid var(--line);border-radius:14px;padding:18px;display:flex;flex-direction:column;min-height:220px;box-shadow:0 5px 18px rgba(31,41,55,.04)}
.knowledge-card:hover{border-color:#e5a3a9;box-shadow:var(--shadow);transform:translateY(-2px)}
.knowledge-card h4{margin:0 0 8px;font-size:16px}.knowledge-card p{color:var(--muted);line-height:1.7;font-size:13px;flex:1}
.knowledge-meta{display:flex;gap:6px;flex-wrap:wrap;margin:8px 0}.knowledge-path{font:11px Consolas,monospace;color:#8b929d;word-break:break-all;margin-top:8px}
.reader-layout{display:grid;grid-template-columns:245px minmax(0,1fr);gap:17px;align-items:start}
.reader-toc{background:#fff;border:1px solid var(--line);border-radius:13px;padding:13px;position:sticky;top:95px;max-height:calc(100vh - 120px);overflow:auto}
.reader-toc h4{margin:4px 8px 10px}.reader-toc button{width:100%;border:0;background:#fff;text-align:left;padding:9px 10px;border-radius:7px;color:#596273;font-size:13px;line-height:1.45}
.reader-toc button:hover,.reader-toc button.active{background:var(--red3);color:var(--red);font-weight:750}
.reader-main{min-width:0}.md-section{background:#fff;border:1px solid var(--line);border-radius:14px;padding:22px 25px;margin-bottom:15px;scroll-margin-top:95px;box-shadow:0 4px 16px rgba(31,41,55,.035)}
.md-section>h2{margin:0 -25px 19px;padding:0 25px 14px;border-bottom:1px solid #eee;font-size:20px;color:#6f1820}
.md-body{font-size:14px;line-height:1.92;color:#394150}.md-body h1,.md-body h2{color:#741923}.md-body h3{margin-top:25px;color:#8f2029;font-size:17px}.md-body h4{color:#333b46}
.md-body p{margin:10px 0}.md-body ul,.md-body ol{padding-left:25px}.md-body li{margin:5px 0}.md-body blockquote{margin:14px 0;padding:12px 15px;border-left:4px solid var(--red);background:#fff6f7;color:#5c3437;border-radius:0 8px 8px 0}
.md-body table{min-width:0;width:100%;display:table;margin:15px 0;border:1px solid var(--line);border-radius:9px;overflow:hidden}.md-body th{position:static;background:#fff1f2;color:#701920}.md-body td,.md-body th{border:1px solid #eceff2;padding:9px 11px}
.md-body pre{background:#20252c;color:#f5f6f7;border-radius:10px;padding:15px;overflow:auto;line-height:1.65}.md-body code{background:#f1f2f4;border-radius:5px;padding:2px 5px;font-family:Consolas,monospace;color:#8d2630}.md-body pre code{background:transparent;color:inherit;padding:0}
.md-body hr{border:0;border-top:1px solid #eceef1;margin:22px 0}.md-body strong{color:#252a31}.inline-ref,.inline-doc{border:0;border-radius:5px;background:#fff0f1;color:var(--red);padding:2px 6px;font:inherit;cursor:pointer}.inline-doc{font-family:Consolas,monospace;word-break:break-all}
.record-hero{background:linear-gradient(120deg,#fff5f6,#fff);border:1px solid #efc5c9;border-left:5px solid var(--red);border-radius:13px;padding:18px;margin:12px 0 17px}.record-hero h2{margin:0 0 8px}.record-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:13px}.record-group{background:#fff;border:1px solid var(--line);border-radius:12px;padding:15px}.record-group h3{margin:0 0 12px;color:#751b23}.record-field{display:grid;grid-template-columns:125px 1fr;gap:10px;padding:10px 0;border-bottom:1px dashed #e7e9ed;font-size:13px;line-height:1.65}.record-field:last-child{border-bottom:0}.record-field .label{color:#737b88}.record-field .value{font-weight:600;word-break:break-word}
.related-docs{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px}.related-doc{border:1px solid #ecd1d4;background:#fffafa;border-radius:10px;padding:12px;cursor:pointer}.related-doc:hover{border-color:var(--red)}.related-doc strong{display:block;margin-bottom:6px;color:#741b23}.related-doc p{margin:0;color:#69717e;font-size:12px;line-height:1.55}
.field-list{display:grid;gap:9px}.field-item{border:1px solid var(--line);border-radius:10px;padding:11px;background:#fff}.field-item strong{display:block;color:#7a1e26;margin-bottom:5px}.human-table{width:100%;min-width:0}.human-table th{position:static;width:170px}.technical-details{margin-top:15px;border:1px solid #e1e4e8;border-radius:10px;background:#fafbfc;padding:10px 13px}.technical-details summary{cursor:pointer;color:#666f7c;font-weight:700}.technical-details table{margin-top:10px}
.knowledge-preview{background:#fff;border:1px solid var(--line);border-radius:13px;padding:16px;margin-top:14px}.knowledge-preview h3{margin-top:0}.knowledge-preview .md-body{max-height:470px;overflow:auto;padding-right:6px}
.source-ref-btn{display:inline-flex;align-items:center;gap:5px;border:1px solid #e6aeb3;border-radius:7px;background:#fff4f5;color:var(--red);padding:3px 8px;margin:1px 3px;font:inherit;font-size:12px;font-weight:700;vertical-align:middle}.source-ref-btn:hover{background:var(--red);color:#fff}.source-ref-btn:before{content:"源";display:grid;place-items:center;width:18px;height:18px;border-radius:5px;background:rgba(180,35,45,.12);font-size:10px}.source-ref-btn:hover:before{background:rgba(255,255,255,.2)}
.missing-source{border-style:dashed;color:#9a6700;background:#fff8e6}.missing-source:before{content:"待";background:#fff0c2}
.org-ref-btn{display:inline-flex;align-items:center;border:1px solid #d7dce2;border-radius:7px;background:#f7f8fa;color:#344054;padding:3px 8px;margin:1px 3px;font:inherit;font-size:12px;font-weight:700}.org-ref-btn:hover{border-color:var(--red);color:var(--red);background:#fff5f6}
.unit-ref-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:9px;margin-top:5px}.unit-ref-card{border:1px solid #ead0d3;border-radius:10px;background:#fffafa;padding:10px 11px;text-align:left;color:#303640;min-width:0}.unit-ref-card:hover{border-color:var(--red);box-shadow:0 5px 15px rgba(180,35,45,.1)}.unit-ref-card strong{display:block;color:#771b24;margin-bottom:4px;white-space:normal}.unit-ref-card small{display:block;color:#747c89;line-height:1.45;white-space:normal}.unit-ref-card.static{cursor:default}.unit-ref-card.static:hover{box-shadow:none;border-color:#ead0d3}
.object-value-list{display:grid;gap:7px}.object-value-item{border:1px solid #e2e5e9;border-radius:8px;background:#fafbfc;padding:8px 10px}.object-value-item strong{display:block;color:#771b24;margin-bottom:3px}.object-value-item span{display:block;color:#69717e;font-size:12px;line-height:1.5}
@media(max-width:760px){.unit-ref-grid{grid-template-columns:1fr}}
@media(max-width:900px){.knowledge-layout,.reader-layout{grid-template-columns:1fr}.knowledge-side,.reader-toc{position:static;max-height:none}.record-grid,.related-docs{grid-template-columns:1fr}.md-section{padding:17px}.md-section>h2{margin-left:-17px;margin-right:-17px;padding-left:17px;padding-right:17px}}
.offline-source-panel{border:1px solid #efc1c5;background:#fffafa;border-radius:13px;padding:16px;margin:14px 0}
.offline-source-panel h3{margin:0 0 10px;color:var(--red4)}
.offline-status{display:inline-flex;align-items:center;gap:6px;border-radius:999px;padding:5px 10px;background:#f2f4f7;color:#475467;font-size:12px;font-weight:700}
.offline-status.available{background:#e9f8ef;color:#14804a}.offline-status.context{background:#fff3e8;color:#a55a08}
.citation-context{border:1px solid var(--line);border-left:4px solid var(--red);background:#fff;border-radius:10px;padding:13px;margin:10px 0;cursor:pointer}
.citation-context:hover{border-color:#dfa0a6;background:#fff8f8}.citation-context h4{margin:0 0 5px}.citation-context p{margin:0;color:#667085;line-height:1.7;font-size:13px}
.offline-link-disabled{color:#667085;border-bottom:1px dotted #98a2b3;cursor:default}.offline-reference-note{font-size:12px;color:#667085;margin-top:6px}
.structured-mention{display:grid;grid-template-columns:92px 1fr;gap:10px;border:1px solid var(--line);border-radius:10px;padding:12px;margin:9px 0;background:#fff}
.structured-mention .dataset{color:var(--red);font-weight:800;font-size:12px}.structured-mention strong{display:block;margin-bottom:4px}.structured-mention p{margin:0;color:#667085;font-size:13px;line-height:1.55}
</style></head><body>
<div class="app">
<header class="header"><div class="brand"><div class="brand-mark">航</div><div><h1>台湾飞行部队知识数据展示平台</h1><p>结构化数据检索 + Markdown全文 + 可点击来源与单位关系</p></div></div><div class="header-actions"><div class="search"><input id="globalSearch" placeholder="查询单位、装备、专题正文或来源"><span class="icon">⌕</span><div id="searchPop" class="search-pop hidden"></div></div><button class="btn white" data-action="open-query">交互查询</button><button class="btn ghost" data-action="reset">重置</button></div></header>
<nav class="tabs" id="tabs"><button class="tab active" data-tab="overview">总览</button><button class="tab" data-tab="organization">组织机构</button><button class="tab" data-tab="units">单位与兵力</button><button class="tab" data-tab="equipment">装备体系</button><button class="tab" data-tab="training">训练与保障</button><button class="tab" data-tab="industry">工业链与外部依赖</button><button class="tab" data-tab="safety">飞安与风险治理</button><button class="tab" data-tab="timeline">时间线与来源</button><button class="tab" data-tab="knowledge">知识专题库</button></nav>
<main class="main"><section id="page-overview" class="page active"></section><section id="page-organization" class="page"></section><section id="page-units" class="page"></section><section id="page-equipment" class="page"></section><section id="page-training" class="page"></section><section id="page-industry" class="page"></section><section id="page-safety" class="page"></section><section id="page-timeline" class="page"></section><section id="page-knowledge" class="page"></section></main>
<footer class="footer"><span>单文件离线原型 v3.1 · Markdown全文与来源链接修复版</span><span>公开资料边界:不展示实时战备、人员员额、可飞数量、弹药库存和内部设施</span></footer></div>
<div id="modal" class="modal hidden"><div class="backdrop" data-action="close-modal"></div><div class="modal-card"><button class="close" data-action="close-modal">×</button><div id="modalBody"></div></div></div>
<div id="drawer" class="drawer hidden"><div class="backdrop" data-action="close-drawer"></div><div class="drawer-head"><strong id="drawerTitle">详情</strong><button class="close" style="position:static" data-action="close-drawer">×</button></div><div class="drawer-card" id="drawerBody"></div></div>
<div id="detail" class="detail hidden"></div>
<div id="queryPanel" class="query-panel hidden"><div class="backdrop" data-action="close-query"></div><div class="query-card"><button class="close" data-action="close-query">×</button><div class="query-form" id="queryForm"></div><div class="query-result" id="queryResult"></div></div></div>
<script>const DATA={"meta":{"title":"台湾飞行部队知识数据展示平台","version":"原型版 3.2(离线来源内容版)","updated":"2026-06-23","notice":"基于公开资料构建;单位级人员、飞机可用数量及实时战备状态未公开时均不作推断。","offline":true},"units":[{"profile_id":"UFP-001","organization_id":"ORG-AF-SONGSHAN","name":"空军松山基地指挥部","path":"entities/units/air_force/songshan_base_command.md","type":"unit_profile","priority":"P1","topic_group":"unit_force_profiles","service":"air_force","organization_type":"base_command","parent_id":"ORG-AF-HQ","status":"CURRENT_CONFIRMED","confidence":"A","public_location":"台北松山地区(公开基地层级)","summary":"松山基地指挥部是空军特种运输、行政航空和首都地区基地运行的公开组织节点,内部同时包含飞行、修护补给和基地勤务功能。","mission_roles":["特种/行政运输","专机与公务航空保障","基地运行与军民航协调"],"platforms":["B-737-800","Fokker 50","Beech 1900等公开特种运输平台"],"force_structure":"基地指挥部级;公开内部要素包括特种运输队、修护补给队和基地勤务队。飞行单位规模和机组数量未公开。","personnel_domains":["特种运输飞行员与机组","专机任务与服务人员","机务与航电人员","航管/基地勤务/消防人员"],"training_profile":"执行机型换装、专机程序、仪表飞行、复杂空域和应急训练;具体课目与资格数量未公开。","support_profile":"修护补给队承担机务与补给,基地勤务队承担场站运行;涉及军民共用机场协调。","quantity_reference":"官方沿革明确2000年B-737-800成军;其他平台在官网公开列示。当前各型机确切数量和可用率不公开。","history":"由松山站、供应分队、机场勤务中队和基地大队等单位演变,1966年扩编为松山基地指挥部。","source_ids":["AF-SONGSHAN","AF-SPECIAL-AIRCRAFT"],"relations":["ORG-AF-SONGSHAN-SPECIAL-TRANSPORT","ORG-AF-SONGSHAN-MAINT","ORG-AF-SONGSHAN-BASE-SERVICE"],"force_level":"基地指挥部级","readiness_statement":"公开资料可确认单位、平台或任务存在,但不公开实时战备等级、可飞数量和当日人员状态。","notes":"","registry_status":"CURRENT_CONFIRMED","registry_confidence":"A","last_verified":"2026-06-23","unit_personnel_strength":"NOT_PUBLIC","unit_aircraft_strength":"NOT_PUBLIC_OR_NOT_STABLE","commander_field":"不写入静态档案;需查询单位官网最新公开信息","do_not_infer":["实时可飞数量或妥善率","人员实际员额、值班名单或资格分布","弹药库存和挂载数量","飞行频率、值班节奏和临时部署","基地内部设施、机堡位置和警卫布置"]},{"profile_id":"UFP-002","organization_id":"ORG-AF-SONGSHAN-SPECIAL-TRANSPORT","name":"空军松山基地指挥部特种运输队","path":"entities/units/air_force/songshan_special_transport_unit.md","type":"unit_profile","priority":"P2","topic_group":"unit_force_profiles","service":"air_force","organization_type":"special_transport_unit","parent_id":"ORG-AF-SONGSHAN","status":"CURRENT_PROBABLE","confidence":"B","public_location":"台北松山地区(公开基地层级)","summary":"特种运输队是松山基地指挥部内承担专机与行政运输的飞行要素,其档案重点是任务机组、平台构成和特殊服务程序,而不是推断高层行程。","mission_roles":["行政运输","专机运输","紧急或公务航空支援"],"platforms":["B-737-800","Fokker 50","Beech 1900等"],"force_structure":"队级飞行单位;上级为松山基地指挥部。现行完整内部机组编制和飞机分配未公开。","personnel_domains":["固定翼飞行员","任务机组与客舱服务","任务规划","机务与航电专业"],"training_profile":"机型换装、仪表飞行、机场程序、紧急程序和任务服务标准化。","support_profile":"依托所属联队的修护补给、基地勤务、航管气象和武器保障;单位级确切班组与员额未公开。","quantity_reference":"单位级飞机编配数、可飞数和飞行员员额未公开;仅确认机型/任务归属,不从联队总量反推。","history":"其前身与松山基地历次专机/运输编组有关,现行正式名称依据公开组织资料为较高可信。","source_ids":["AF-SONGSHAN","AF-SPECIAL-AIRCRAFT"],"relations":["ORG-AF-SONGSHAN"],"force_level":"special_transport_unit","readiness_statement":"公开资料可确认单位、平台或任务存在,但不公开实时战备等级、可飞数量和当日人员状态。","notes":"","registry_status":"CURRENT_PROBABLE","registry_confidence":"B","last_verified":"2026-06-23","unit_personnel_strength":"NOT_PUBLIC","unit_aircraft_strength":"NOT_PUBLIC_OR_NOT_STABLE","commander_field":"不写入静态档案;需查询单位官网最新公开信息","do_not_infer":["实时可飞数量或妥善率","人员实际Line truncated
(()=>{
'use strict';
const $=s=>document.querySelector(s), $$=s=>[...document.querySelectorAll(s)];
const esc=v=>String(v??'').replace(/[&<>"']/g,m=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#039;'}[m]));
const arr=v=>Array.isArray(v)?v:(v?String(v).split(/;|;|、/).map(x=>x.trim()).filter(Boolean):[]);
const uniq=a=>[...new Set(a.filter(Boolean))];
const clip=(v,n=110)=>String(v||'').length>n?String(v).slice(0,n)+'…':String(v||'');
const labels={service:{air_force:'空军',army:'陆军',navy:'海军',civil:'民政航空',other:'其他'},status:{CURRENT_CONFIRMED:'现行确认',CURRENT_PROBABLE:'现行较高可信',CURRENT_CONFIRMED_OR_TRANSITIONING:'现行/转型中',FIELD_CONFIRMED:'现役确认',FUNCTION_CONFIRMED:'功能确认',FUNCTION_INFERRED:'功能推定',PROCUREMENT_TRANSITIONING:'采购转型中',PROCUREMENT_CONFIRMED:'采购确认',PROCUREMENT_PACKAGE_CONFIRMED:'采购包确认',CURRENT_ROLE_CONFIRMED:'岗位确认',CURRENT_USE_CONFIRMED:'使用确认',TRAINING_FUNCTION_CONFIRMED:'训练功能确认',TRAINING_PIPELINE_INFERRED:'训练链推定',ORGANIZATION_UNRESOLVED:'组织待核',CURRENT_CONFIRMED_FUNCTION:'现行功能确认',HISTORICAL_BASELINE:'历史基线',NOT_PUBLIC:'未公开',NOT_PUBLIC_OR_NOT_STABLE:'未公开或非稳定口径',OPEN_SOURCE_LIMITED:'公开资料有限',PARTIALLY_COMPLETE:'部分完整',COMPLETED:'已完成',ANALYTICAL_ASSESSMENT:'分析评估',HIGH_EXTERNAL_DEPENDENCY:'外部依赖较高',OEM_AUTHORIZED_FMS_CONTROLLED:'原厂授权/军售管制',FMS_CONTROLLED:'军售管制',DOMESTIC_INTEGRATOR:'国内系统整合',DOMESTIC_DESIGN_AND_INTEGRATION:'国内设计与整合',JOINT_DEVELOPMENT_EXTERNAL_COMPONENTS:'合作研制/含外部部件',JOINT_OR_FOREIGN_CORE_DEPENDENCY:'合作或外部核心依赖',MIXED_UNRESOLVED:'混合/待核'},category:{FA:'飞行事故',SI:'严重征候',NI:'近失事件',GS:'地面安全',AN:'航管与导航',MQ:'维修质量',US:'无人航空安全',HR:'危险源报告',SAR:'搜救事件'}};
const FIELD_LABELS={profile_id:'档案编号',organization_id:'组织编号',name:'名称',name_zh:'名称',path:'知识文件路径',type:'类型',priority:'优先级',topic_group:'主题分组',service:'军种/系统',organization_type:'组织类型',parent_id:'上级组织编号',status:'状态',confidence:'可信度',public_location:'公开驻地',summary:'摘要',mission_roles:'主要任务',platforms:'平台与系统',force_structure:'兵力结构',personnel_domains:'人员专业',training_profile:'训练与资格',support_profile:'维修保障',quantity_reference:'数量口径',history:'沿革',source_ids:'来源编号',relations:'关联组织',force_level:'单位层级',readiness_statement:'战备信息边界',notes:'备注',registry_status:'登记状态',registry_confidence:'登记可信度',last_verified:'最后核验',unit_personnel_strength:'人员数量',unit_aircraft_strength:'飞机数量',commander_field:'主官字段',do_not_infer:'禁止推断',organization:'单位/机构',category:'类别',evidence:'公开证据',training_stage:'训练阶段',instructor_requirement:'教员要求',configuration_status:'构型状态',delivery_status:'交付/使用状态',platform_or_role:'平台或任务',platform_family:'平台族',level:'维修层级',task_family:'任务类别',responsible_entity:'责任单位',key_outputs:'主要输出',quality_gate:'质量关口',technical_dependency:'技术依赖',node_type:'节点类型',capabilities:'能力范围',maturity:'成熟度',foreign_interface:'国外接口',platform_or_system:'平台或系统',dimension:'依赖维度',domestic_capability:'国内能力',foreign_dependency:'外部依赖',mitigation:'缓解/自主化措施',unresolved:'待核问题',risk:'风险描述',examples:'表现或示例',controls:'控制措施',hfacs_level:'HFACS层级',stage:'治理阶段',owner:'责任主体',inputs:'输入',outputs:'输出',date:'日期',platform:'平台',severity:'严重度',outcome:'结果',grounding:'停飞/限制',return_to_flight:'复飞状态',cause_status:'原因状态',title:'标题',usage:'主要用途',url:'网址',verified:'核验日期'};
const state={tab:'overview',unitView:'cards',unitPage:1,unitSize:12,unitFilters:{q:'',service:'全部',level:'全部',location:'全部',status:'全部'},equipCat:'全部',orgService:'air_force',orgZoom:1,sourceQ:'',knowledgeCategory:'全部',knowledgeQ:'',knowledgePage:1,knowledgeSize:15};
const sourceMap=Object.fromEntries(DATA.sources.map(x=>[x.source_id,x]));const sourceLocalMap=SOURCE_LOCAL||{};
const orgMap=Object.fromEntries(DATA.organizations.map(x=>[x.organization_id,x]));
const unitMap=Object.fromEntries(DATA.units.map(x=>[x.organization_id,x]));
const knowledgeMap=Object.fromEntries(KNOWLEDGE.map(x=>[x.path,x]));
const status=v=>labels.status[v]||v||'待核'; const service=v=>labels.service[v]||v||'其他';
const badge=(v,kind='status')=>{const t=kind==='confidence'?`可信度 ${v||'待核'}`:status(v);let c=/确认|完成|现役/.test(t)?'green':(/待核|未公开|推定|有限/.test(t)?'amber':'red');return `<span class="badge ${c}">${esc(t)}</span>`};
const chips=(xs,limit=8)=>arr(xs).slice(0,limit).map(x=>`<span class="chip red">${esc(x)}</span>`).join('');
const kv=(k,v)=>`<div class="kv"><span class="k">${esc(k)}</span><span class="v">${v}</span></div>`;
const sourcePrefixes=new Set(Object.keys(sourceMap).map(id=>String(id).split('-')[0]));
const sourceButton=id=>sourceMap[id]?`<button type="button" class="source-ref-btn" data-action="open-source" data-id="${esc(id)}" title="查看来源详情">${esc(id)}</button>`:`<button type="button" class="source-ref-btn missing-source" data-action="missing-source" data-id="${esc(id)}" title="来源索引中暂未匹配">${esc(id)} · 待补来源</button>`;
const unitRefInfo=item=>{if(item===null||item===undefined)return null;let id='',name='',meta='';if(typeof item==='object'){id=item.organization_id||item.org_id||item.id||'';name=item.name||item.name_zh||item.organization_name||'';meta=item.relationship||item.relation||item.status||''}else{const raw=String(item).trim();id=(unitMap[raw]||orgMap[raw])?raw:'';name=unitMap[raw]?.name||orgMap[raw]?.name_zh||raw}if(id){name=name||unitMap[id]?.name||orgMap[id]?.name_zh||id;meta=meta||unitMap[id]?.force_level||orgMap[id]?.organization_type||'关联单位'}return{name:name||'未命名单位',id,meta}};
const renderUnitRefs=value=>{const items=arr(value).map(unitRefInfo).filter(Boolean);if(!items.length)return '待核';return `<div class="unit-ref-grid">${items.map(x=>{const action=x.id?(unitMap[x.id]?'open-unit':'open-org'):'';return action?`<button type="button" class="unit-ref-card" data-action="${action}" data-id="${esc(x.id)}"><strong>${esc(x.name)}</strong><small>${esc(x.meta||x.id)}</small></button>`:`<div class="unit-ref-card static"><strong>${esc(x.name)}</strong><small>${esc(x.meta||'公开关联单位')}</small></div>`}).join('')}</div>`};
const objectLabel=o=>o?.name||o?.name_zh||o?.title||o?.organization||o?.platform_or_system||o?.platform_or_role||o?.organization_id||o?.id||'结构化对象';
const objectSummary=o=>Object.entries(o||{}).filter(([k,v])=>!['name','name_zh','title','organization','platform_or_system','platform_or_role'].includes(k)&&v!==null&&v!==''&&typeof v!=='object').slice(0,3).map(([k,v])=>`${FIELD_LABELS[k]||k}:${String(v)}`).join(';');
const humanValue=(k,v)=>{if(v===null||v===undefined||v==='')return '未提供/待核';if(k==='source_ids')return arr(v).map(id=>sourceButton(String(id))).join(' ');if(['related_units','units_public','relations'].includes(k))return renderUnitRefs(v);if(Array.isArray(v)){if(!v.length)return '无';if(v.some(x=>x&&typeof x==='object'))return `<div class="object-value-list">${v.map(x=>typeof x==='object'?`<div class="object-value-item"><strong>${esc(objectLabel(x))}</strong><span>${esc(objectSummary(x)||'结构化关联对象')}</span></div>`:`<span class="chip red">${esc(x)}</span>`).join('')}</div>`;return chips(v,30)}if(typeof v==='object')return `<div class="object-value-list">${Object.entries(v).map(([a,b])=>`<div class="object-value-item"><strong>${esc(FIELD_LABELS[a]||a)}</strong><span>${esc(typeof b==='object'?JSON.stringify(b):String(b))}</span></div>`).join('')}</div>`;if(k==='service')return esc(service(v));if(/status/.test(k))return badge(v);if(k==='confidence')return badge(v,'confidence');return esc(String(v));};
const looksLikeSourceId=id=>/^[A-Z][A-Z0-9]*(?:-[A-Z0-9]+)+$/.test(id)&&sourcePrefixes.has(id.split('-')[0]);
function enrichKnowledgeHtml(raw){const box=document.createElement('div');box.innerHTML=raw||'';
box.querySelectorAll('[data-source-id]').forEach(n=>{const id=n.getAttribute('data-source-id');n.outerHTML=sourceButton(id)});
box.querySelectorAll('[data-knowledge-path]').forEach(n=>{const p=n.getAttribute('data-knowledge-path'),label=n.textContent.trim()||knowledgeMap[p]?.title||p;n.outerHTML=knowledgeMap[p]?`<button type="button" class="org-ref-btn" data-action="open-knowledge" data-path="${esc(p)}">${esc(label)}</button>`:`<span class="offline-link-disabled">${esc(label)}</span>`});
box.querySelectorAll('a').forEach(a=>{const label=a.textContent.trim()||'外部资料';a.outerHTML=`<span class="offline-link-disabled" title="离线部署未收录该外部页面内容">${esc(label)}</span>`});
box.querySelectorAll('code').forEach(code=>{const id=code.textContent.trim();if(sourceMap[id]||looksLikeSourceId(id)){code.outerHTML=sourceButton(id)}else if(unitMap[id]){code.outerHTML=`<button type="button" class="org-ref-btn" data-action="open-unit" data-id="${esc(id)}">${esc(unitMap[id].name||id)}</button>`}else if(orgMap[id]){code.outerHTML=`<button type="button" class="org-ref-btn" data-action="open-org" data-id="${esc(id)}">${esc(orgMap[id].name_zh||id)}</button>`}});
const walker=document.createTreeWalker(box,NodeFilter.SHOW_TEXT);const nodes=[];while(walker.nextNode())nodes.push(walker.currentNode);nodes.forEach(node=>{if(node.parentElement?.closest('button,script,style,code'))return;const text=node.nodeValue||'';const re=/\[([A-Z][A-Z0-9]*(?:-[A-Z0-9]+)+)\]/g;if(!re.test(text))return;re.lastIndex=0;const frag=document.createDocumentFragment();let last=0,m;while((m=re.exec(text))){frag.append(document.createTextNode(text.slice(last,m.index)));const id=m[1];if(sourceMap[id]||looksLikeSourceId(id)){const wrap=document.createElement('span');wrap.innerHTML=sourceButton(id);frag.append(wrap.firstElementChild)}else{frag.append(document.createTextNode(m[0]))}last=re.lastIndex}frag.append(document.createTextNode(text.slice(last)));node.replaceWith(frag)});return box.innerHTML}
function setTab(tab){state.tab=tab;$$('.tab').forEach(x=>x.classList.toggle('active',x.dataset.tab===tab));$$('.page').forEach(x=>x.classList.toggle('active',x.id===`page-${tab}`));window.scrollTo(0,0)}
function openModal(html){$('#modalBody').innerHTML=html;$('#modal').classList.remove('hidden');document.body.style.overflow='hidden'} function closeModal(){$('#modal').classList.add('hidden');document.body.style.overflow=''}
function openDrawer(title,html){$('#drawerTitle').textContent=title;$('#drawerBody').innerHTML=html;$('#drawer').classList.remove('hidden');document.body.style.overflow='hidden'} function closeDrawer(){$('#drawer').classList.add('hidden');document.body.style.overflow=''}
function closeDetail(){$('#detail').classList.add('hidden');$('#detail').innerHTML='';document.body.style.overflow=''} function closeQuery(){$('#queryPanel').classList.add('hidden');document.body.style.overflow=''}
function relatedDocs(paths){const ds=uniq(paths).map(p=>knowledgeMap[p]).filter(Boolean);return ds.length?`<div class="related-docs">${ds.map(d=>`<div class="related-doc" data-action="open-knowledge" data-path="${esc(d.path)}"><strong>${esc(d.title)}</strong><p>${esc(clip(d.summary,100))}</p></div>`).join('')}</div>`:'<p class="rich">暂无匹配的知识正文。</p>'}
function technicalFields(x){return `<details class="technical-details"><summary>查看全部结构化字段(中文字段名)</summary><table class="human-table"><tbody>${Object.entries(x).map(([k,v])=>`<tr><th>${esc(FIELD_LABELS[k]||k)}</th><td>${humanValue(k,v)}</td></tr>`).join('')}</tbody></table></details>`}
function infoGroup(title,fields,x){return `<section class="record-group"><h3>${esc(title)}</h3>${fields.filter(k=>k in x).map(k=>`<div class="record-field"><span class="label">${esc(FIELD_LABELS[k]||k)}</span><span class="value">${humanValue(k,x[k])}</span></div>`).join('')}</section>`}
const RELATED={simulator:['capabilities/training/simulators_training_devices_and_digital_training.md','capabilities/training/flight_training_pipeline_and_qualification_generation.md'],maintenance:['capabilities/maintenance/maintenance_levels_airworthiness_quality_and_configuration.md','capabilities/logistics/aviation_materials_suppliers_and_lifecycle_supply_chain.md'],industry:['capabilities/industry/domestic_aviation_industry_and_defense_ecosystem.md','capabilities/industry/external_technology_dependencies_and_autonomy_boundaries.md'],dependency:['capabilities/industry/external_technology_dependencies_and_autonomy_boundaries.md'],risk:['safety/frameworks/human_factors_crm_hfacs_fatigue_and_safety_culture.md','safety/frameworks/environment_atc_base_ground_and_uas_safety.md'],process:['safety/governance/investigation_evidence_grounding_inspection_and_return_to_flight.md','safety/governance/aviation_safety_governance_organizations_and_institutions.md'],training:['capabilities/training/flight_training_pipeline_and_qualification_generation.md'],safety:['safety/events/public_major_aviation_safety_events_timeline.md','safety/governance/investigation_evidence_grounding_inspection_and_return_to_flight.md']};
function recordTitle(type,x){return x.name||x.name_zh||x.title||x.organization||x.platform_or_system||x.platform_or_role||x.platform_family||x.domain||x.stage||x.summary||'专题详情'}
function openRecord(type,x){let groups=[];if(type==='simulator')groups=[['基本信息',['platform_or_role','category','organization','delivery_status','confidence']],['训练应用',['training_stage','evidence','instructor_requirement']],['构型与保障',['configuration_status','source_ids']]];else if(type==='maintenance')groups=[['维修对象',['platform_family','level','task_family','service','status']],['责任与输出',['responsible_entity','key_outputs','quality_gate']],['技术支撑',['technical_dependency','confidence','source_ids']]];else if(type==='industry')groups=[['产业节点',['organization','node_type','maturity','status','confidence']],['能力范围',['capabilities','platforms']],['外部接口',['foreign_interface','source_ids']]];else if(type==='dependency')groups=[['平台与维度',['platform_or_system','dimension','level']],['国内能力',['domestic_capability','mitigation']],['外部依赖与待核',['foreign_dependency','evidence','unresolved','source_ids']]];else if(type==='risk')groups=[['风险识别',['domain','risk','hfacs_level']],['表现与控制',['examples','controls']]];else if(type==='process')groups=[['治理阶段',['stage','owner','status']],['输入与输出',['inputs','outputs']],['证据依据',['evidence']]];else if(type==='training')groups=[['训练阶段',['stage_name','career_track','stage_order','service','status']],['责任与输入',['responsible_entity','input','academic']],['模拟与实飞',['simulator','flight','output']],['证据',['confidence','source_ids']]];else if(type==='safety')groups=[['事件概览',['date','service','platform','category','severity','summary','outcome']],['处置与恢复',['grounding','return_to_flight']],['调查状态',['cause_status','confidence','source_ids']]];else groups=[['详细字段',Object.keys(x)]];
const paths=RELATED[type]||[];openModal(`<div class="record-hero"><h2>${esc(recordTitle(type,x))}</h2><p>以下内容已按业务语义重新分组,原始JSON不再作为默认展示。</p></div><div class="record-grid">${groups.map(g=>infoGroup(g[0],g[1],x)).join('')}</div><section class="detail-section"><h3>关联知识正文</h3>${relatedDocs(paths)}</section>${technicalFields(x)}`)}
function renderKnowledgeDetail(doc,unit){if(!doc)return;const heroExtra=unit?`<div class="detail-summary">${kv('单位层级',esc(unit.force_level))}${kv('公开驻地',esc(unit.public_location))}${kv('状态',badge(unit.registry_status||unit.status))}${kv('可信度',badge(unit.registry_confidence||unit.confidence,'confidence'))}</div>`:`<div class="detail-summary">${kv('专题分类',esc(doc.category))}${kv('档案类型',esc(doc.type))}${kv('当前阶段',esc(doc.stage))}${kv('正文规模',`${doc.word_count} 字符`)}</div>`;$('#detail').innerHTML=`<div class="detail-top"><div><strong>${unit?'单位与兵力档案':'知识专题正文'}</strong><span style="opacity:.72;margin-left:12px">Markdown全文呈现</span></div><button class="btn white" data-action="close-detail">返回</button></div><div class="detail-body"><section class="detail-hero"><div><h2>${esc(doc.title)}</h2><p>${esc(doc.summary||doc.when||'知识库专题正文')}</p><div><span class="badge red">${esc(doc.category)}</span><span class="badge red">${esc(doc.type)}</span>${doc.confidence?badge(doc.confidence,'confidence'):''}</div></div>${heroExtra}</section><div class="reader-layout mt"><aside class="reader-toc"><h4>本页目录</h4>${doc.sections.map((s,i)=>`<button class="${i===0?'active':''}" data-action="reader-scroll" data-target="${s.id}">${esc(s.title)}</button>`).join('')}</aside><article class="reader-main">${doc.sections.map(s=>`<section id="${s.id}" class="md-section"><h2>${esc(s.title)}</h2><div class="md-body">${enrichKnowledgeHtml(s.html)}</div></section>`).join('')}</article></div></div>`;$('#detail').classList.remove('hidden');document.body.style.overflow='hidden'}
function openKnowledge(path){renderKnowledgeDetail(knowledgeMap[path])} function openUnit(id){const u=unitMap[id];if(!u)return;const d=knowledgeMap[u.path];if(d)renderKnowledgeDetail(d,u);else openRecord('unit',u)}
function unitCard(u){return `<article class="unit-card" data-action="open-unit" data-id="${esc(u.organization_id)}"><div class="top"><div><h4>${esc(u.name)}</h4><div class="sub">${esc(service(u.service))} · ${esc(u.force_level||u.organization_type)}</div></div>${badge(u.registry_status||u.status)}</div><div class="mt">${chips(u.platforms,4)}</div><p>${esc(clip(u.summary,145))}</p><div class="foot"><span>${esc(u.public_location||'驻地待核')}</span><button class="btn light sm">阅读完整档案</button></div></article>`}
function renderOverview(){const stats=[['单位档案',DATA.units.length,'41篇单位Markdown'],['组织实体',DATA.organizations.length,'组织与支援关系'],['装备平台',DATA.equipment.length,'平台与状态'],['知识专题',KNOWLEDGE.length,'非重复Markdown全文'],['飞安事件',DATA.safetyEvents.length,'公开事件基线'],['官方来源',DATA.sources.length,'一手资料索引']];const entry=[['组织机构','organization','组织架构图','按军种展开联队、旅、大队和队级飞行单位'],['单位兵力','units','完整单位档案','直接阅读对应Markdown全部章节'],['装备体系','equipment','装备专题','结构化装备信息关联知识正文'],['训练保障','training','能力生成','模拟器和维修弹框改为业务详情页'],['工业依赖','industry','产业专题','国内能力、供应商和外部依赖'],['飞安治理','safety','风险闭环','事故、人因、调查与复飞正文'],['知识专题','knowledge','Markdown全文','按分类浏览全部96篇非重复知识文件']];$('#page-overview').innerHTML=`<div class="page-head"><div><h2>全库态势总览</h2><p>结构化JSON用于检索、筛选和关联;Markdown正文作为详情页面的主要内容源,最大限度保留知识深度。</p></div><div class="page-actions"><button class="btn line" data-action="open-query">交互查询</button><button class="btn primary" data-action="goto-tab" data-tab="knowledge">进入知识专题库</button></div></div><div class="stats">${stats.map(s=>`<div class="stat"><div class="label">${s[0]}</div><div class="num">${s[1]}</div><div class="hint">${s[2]}</div></div>`).join('')}</div><div class="section-title"><h3>专题入口</h3><span>详情默认采用中文业务结构与Markdown全文</span></div><div class="grid cols3">${entry.map(e=>`<article class="entry" data-action="goto-tab" data-tab="${e[1]}"><div class="letter">${e[0][0]}</div><strong>${e[0]}</strong><p>${e[3]}</p><span class="badge red">${e[2]}</span></article>`).join('')}</div><div class="grid cols2 mt"><div class="panel"><h3>本版核心变化</h3>${['取消默认原始JSON弹框','单位详情加载完整Markdown','增加96篇知识专题浏览','搜索覆盖Markdown全文','专题详情采用字段分组、流程、关系和来源卡片'].map(x=>`<div class="source-card">${x}</div>`).join('')}</div><div class="panel"><h3>数据边界与口径</h3><div class="notice">${esc(DATA.meta.notice)}</div>${kv('Markdown正文','保留章节、表格、列表、引用、代码块和关联分析')}${kv('结构化数据','用于交互查询和跨实体关联,不替代知识正文')}${kv('数量口径','区分历史接收、项目总量、单位分配与实时可用')}</div></div><div class="section-title"><h3>重点单位</h3><span>进入二级页面阅读单位Markdown全文</span></div><div class="grid cols3">${DATA.units.filter(x=>x.priority==='P1').slice(0,9).map(unitCard).join('')}</div>`}
function orgImportant(o){return unitMap[o.organization_id]||['service_headquarters','functional_command','tactical_fighter_wing','tactical_composite_wing','composite_wing','aviation_brigade','aviation_group','training_command','base_command','rescue_squadron'].includes(o.organization_type)}
function orgTreeData(svc){const active=DATA.organizations.filter(o=>o.service===svc&&!String(o.status).includes('HISTORICAL')&&!String(o.status).includes('DEPRECATED')&&orgImportant(o));const ids=new Set(active.map(x=>x.organization_id)),roots=active.filter(x=>!x.parent_public||!ids.has(x.parent_public)),children={};active.forEach(x=>(children[x.parent_public]??=[]).push(x));return{roots,children}}
function nodeHtml(o,children,depth=0){const kids=(children[o.organization_id]||[]).sort((a,b)=>a.name_zh.localeCompare(b.name_zh,'zh-CN')),cls=depth===0?'root':depth===1?'branch':'';return `<li><button class="org-node ${cls}" data-action="open-org" data-id="${esc(o.organization_id)}"><span>${esc(o.name_zh)}</span><small>${esc(status(o.status))}</small></button>${kids.length?`<ul>${kids.map(k=>nodeHtml(k,children,depth+1)).join('')}</ul>`:''}</li>`}
function renderOrganization(){const groups=[['air_force','空军体系'],['army','陆军航空'],['navy','海军航空'],['civil','民政航空支援']],{roots,children}=orgTreeData(state.orgService);$('#page-organization').innerHTML=`<div class="page-head"><div><h2>组织机构图</h2><p>点击节点查看中文组织详情;已有单位Markdown的节点可继续进入全文档案。</p></div></div><div class="org-layout"><aside class="side-nav"><h3 style="margin:5px 8px 12px">组织分类</h3>${groups.map(g=>`<button class="${state.orgService===g[0]?'active':''}" data-action="org-service" data-service="${g[0]}">${g[1]}</button>`).join('')}<div class="notice mt">完整119个组织实体仍可通过交互查询检索。</div></aside><div class="org-canvas"><div class="org-toolbar"><div><strong>${groups.find(x=>x[0]===state.orgService)?.[1]||'组织体系'}</strong><div style="font-size:12px;color:#667085;margin-top:4px">点击组织节点进行下钻</div></div><div class="zoom"><button class="btn light sm" data-action="zoom-out">缩小</button><button class="btn light sm" data-action="zoom-reset">复原</button><button class="btn light sm" data-action="zoom-in">放大</button></div></div><div class="tree" style="--scale:${state.orgZoom}"><ul>${roots.map(r=>nodeHtml(r,children)).join('')||'<div class="empty">暂无主干节点</div>'}</ul></div></div><aside class="side-info"><h3>组织图说明</h3>${kv('红色节点','军种或主干根节点')}${kv('浅红节点','联队、旅、大队等主要分支')}${kv('白色节点','队级飞行单位或专业组织')}<div class="notice mt">组织存在不等于完整兵力公开。</div></aside></div>`}
function openOrg(id){const o=orgMap[id];if(!o)return;const u=unitMap[id],parent=orgMap[o.parent_public],childs=DATA.organizations.filter(x=>x.parent_public===id&&!String(x.status).includes('HISTORICAL')).slice(0,18);const orgDocPaths=['entities/organizations/air_force_headquarters_and_four_commands.md','entities/organizations/air_force_wings_1_to_7.md','entities/organizations/army_aviation_special_forces_system.md','entities/organizations/naval_antisubmarine_aviation_system.md'].filter(p=>knowledgeMap[p]);openModal(`<div class="record-hero"><h2>${esc(o.name_zh)}</h2><p>${esc(o.notes||'公开组织实体与关系档案')}</p></div><div class="record-grid">${infoGroup('组织身份',['organization_type','service','status','confidence','last_verified'],o)}<section class="record-group"><h3>隶属与关联</h3>${kv('公开上级',esc(parent?.name_zh||'待核/不适用'))}${kv('下级单位数',String(childs.length))}${childs.map(x=>`<div class="field-item" data-action="open-org" data-id="${esc(x.organization_id)}"><strong>${esc(x.name_zh)}</strong><span>${esc(status(x.status))}</span></div>`).join('')}</section></div>${u?`<button class="btn primary mt" data-action="open-unit" data-id="${esc(id)}">阅读完整单位Markdown档案</button>`:''}<section class="detail-section"><h3>关联组织专题</h3>${relatedDocs(orgDocPaths)}</section>${technicalFields(o)}`)}
function filteredUnits(){return DATA.units.filter(u=>{const f=state.unitFilters;return(!f.q||JSON.stringify(u).toLowerCase().includes(f.q.toLowerCase()))&&(f.service==='全部'||service(u.service)===f.service)&&(f.level==='全部'||String(u.force_level).includes(f.level))&&(f.location==='全部'||String(u.public_location).includes(f.location))&&(f.status==='全部'||status(u.registry_status||u.status)===f.status)})}
function renderUnits(){const all=filteredUnits(),pages=Math.max(1,Math.ceil(all.length/state.unitSize));state.unitPage=Math.min(state.unitPage,pages);const show=all.slice((state.unitPage-1)*state.unitSize,state.unitPage*state.unitSize),services=['全部',...uniq(DATA.units.map(x=>service(x.service)))],levels=['全部',...uniq(DATA.units.map(x=>x.force_level).filter(Boolean)).slice(0,12)],locs=['全部',...uniq(DATA.units.map(x=>String(x.public_location||'').split('(')[0]).filter(Boolean)).slice(0,15)],sts=['全部',...uniq(DATA.units.map(x=>status(x.registry_status||x.status)))];$('#page-units').innerHTML=`<div class="page-head"><div><h2>单位与兵力档案</h2><p>41个单位均连接对应Markdown全文,卡片摘要不再代替详细知识。</p></div><div class="view-switch"><button class="${state.unitView==='cards'?'active':''}" data-action="unit-view" data-view="cards">卡片</button><button class="${state.unitView==='table'?'active':''}" data-action="unit-view" data-view="table">矩阵</button></div></div><div class="filters"><input class="input" id="uQ" value="${esc(state.unitFilters.q)}" placeholder="查询单位、任务、装备或驻地"><select class="select" id="uService">${services.map(x=>`<option ${state.unitFilters.service===x?'selected':''}>${esc(x)}</option>`).join('')}</select><select class="select" id="uLevel">${levels.map(x=>`<option ${state.unitFilters.level===x?'selected':''}>${esc(x)}</option>`).join('')}</select><select class="select" id="uLoc">${locs.map(x=>`<option ${state.unitFilters.location===x?'selected':''}>${esc(x)}</option>`).join('')}</select><select class="select" id="uStatus">${sts.map(x=>`<option ${state.unitFilters.status===x?'selected':''}>${esc(x)}</option>`).join('')}</select><button class="btn light" data-action="clear-units">清空</button></div>${state.unitView==='cards'?`<div class="grid cols3">${show.map(unitCard).join('')||'<div class="empty">没有匹配单位</div>'}</div>`:`<div class="table-wrap"><table><thead><tr><th>单位名称</th><th>军种</th><th>层级</th><th>驻地</th><th>平台</th><th>任务</th><th>状态</th><th>完整档案</th></tr></thead><tbody>${show.map(u=>`<tr><td><strong>${esc(u.name)}</strong></td><td>${esc(service(u.service))}</td><td>${esc(u.force_level)}</td><td>${esc(u.public_location)}</td><td>${chips(u.platforms,4)}</td><td>${esc(arr(u.mission_roles).slice(0,3).join('、'))}</td><td>${badge(u.registry_status||u.status)}</td><td><button class="btn primary sm" data-action="open-unit" data-id="${esc(u.organization_id)}">阅读全文</button></td></tr>`).join('')}</tbody></table></div>`}<div class="pagination"><button class="btn line sm" data-action="unit-page" data-page="${state.unitPage-1}" ${state.unitPage<=1?'disabled':''}>上一页</button><span>第 ${state.unitPage} / ${pages} 页,共 ${all.length} 个单位</span><button class="btn line sm" data-action="unit-page" data-page="${state.unitPage+1}" ${state.unitPage>=pages?'disabled':''}>下一页</button></div>`;['uQ','uService','uLevel','uLoc','uStatus'].forEach(id=>$('#'+id).addEventListener(id==='uQ'?'input':'change',e=>{const k={uQ:'q',uService:'service',uLevel:'level',uLoc:'location',uStatus:'status'}[id];state.unitFilters[k]=e.target.value;state.unitPage=1;renderUnits()}))}
function equipName(x){return x.display_name||x.name_zh||x.platform_name||x.platform_id} function equipCat(x){const n=equipName(x),c=x.category||'';if(/F-16|Mirage|F-CK|战斗/.test(n+c))return'战斗机';if(/T-34|AJT|AT-3|TH-67|教练/.test(n+c))return'教练机';if(/E-2|预警/.test(n+c))return'预警机';if(/C-130|运输|B-737|Fokker|Beech/.test(n+c))return'运输与专机';if(/P-3|反潜巡逻/.test(n+c))return'反潜巡逻机';if(/AH-|UH-|OH-|CH-|S-70|EC225|500MD|直升/.test(n+c))return'直升机';if(/无人|MQ-|腾云|剑翔|锐鸢|Altius|Switchblade/.test(n+c))return'无人航空器';return'其他平台'}
function equipDocs(x){const cat=equipCat(x),m={'无人航空器':['entities/equipment/unmanned_aviation_forces.md','entities/equipment/unmanned_equipment_maturity_and_program_status.md'],'直升机':['entities/equipment/aircraft_platform_lineage.md','entities/equipment/army_navy_aviation_weapons_and_mission_systems.md'],'战斗机':['entities/equipment/aircraft_platform_lineage.md','entities/equipment/air_force_airborne_weapons_munitions_and_pods.md'],'教练机':['entities/equipment/aircraft_platform_lineage.md','capabilities/training/flight_training_pipeline_and_qualification_generation.md'],'预警机':['entities/equipment/special_rescue_vip_and_electronic_warfare_aircraft.md','capabilities/missions/mission_system_and_joint_relations.md'],'运输与专机':['entities/equipment/special_rescue_vip_and_electronic_warfare_aircraft.md'],'反潜巡逻机':['entities/equipment/special_rescue_vip_and_electronic_warfare_aircraft.md','entities/equipment/army_navy_aviation_weapons_and_mission_systems.md']};return m[cat]||['entities/equipment/aircraft_platform_lineage.md']}
function renderEquipment(){const cats=['全部',...uniq(DATA.equipment.map(equipCat))],list=DATA.equipment.filter(x=>state.equipCat==='全部'||equipCat(x)===state.equipCat);$('#page-equipment').innerHTML=`<div class="page-head"><div><h2>装备体系</h2><p>装备详情采用业务字段、武器关系、来源卡和关联Markdown专题,不再展示裸JSON。</p></div></div><div class="equipment-layout"><aside class="side-nav category-list"><h3 style="margin:5px 8px 12px">装备分类</h3>${cats.map(c=>`<button class="${state.equipCat===c?'active':''}" data-action="equip-cat" data-cat="${esc(c)}"><span>${esc(c)}</span><span>${c==='全部'?DATA.equipment.length:DATA.equipment.filter(x=>equipCat(x)===c).length}</span></button>`).join('')}</aside><div><div class="grid cols3">${list.map(x=>`<article class="equip-card" data-action="open-equip" data-id="${esc(x.platform_id)}"><div class="top"><h4>${esc(equipName(x))}</h4>${badge(x.status)}</div><div>${chips([equipCat(x),service(x.service)],4)}</div><p>公开配属:${esc(arr(x.units_public).join('、')||'待核')}。点击查看任务关系、武器系统、来源和专题正文。</p><div class="foot"><span>${esc(x.last_verified||'')}</span><button class="btn light sm">丰富详情</button></div></article>`).join('')}</div></div></div>`}
function openEquip(id){const x=DATA.equipment.find(e=>e.platform_id===id);if(!x)return;const rel=DATA.platformWeapon.filter(r=>String(r.platform).includes(equipName(x))||String(r.platform).includes(x.name_zh||'')),unitValue=(x.related_units&&x.related_units.length?x.related_units:x.units_public),srcs=arr(x.source_ids).map(id=>sourceMap[id]).filter(Boolean);openModal(`<div class="record-hero"><h2>${esc(equipName(x))}</h2><p>平台、任务、配属、武器关系、来源和关联知识正文综合展示。</p><div>${badge(x.status)} ${badge(x.confidence,'confidence')} <span class="badge red">${esc(equipCat(x))}</span></div></div><div class="record-grid"><section class="record-group"><h3>平台身份</h3>${kv('所属体系',esc(service(x.service)))}<div class="record-field"><span class="label">公开单位</span><span class="value">${renderUnitRefs(unitValue)}</span></div>${kv('最后核验',esc(x.last_verified||'待核'))}</section><section class="record-group"><h3>数量与状态边界</h3><p class="rich">军售批准、采购、交付、接装和稳定形成能力属于不同状态;不从全军采购总量推算单位编配或实时可用数量。</p></section></div><section class="detail-section"><h3>武器与任务系统关系</h3>${rel.length?`<div class="table-wrap"><table><thead><tr><th>武器或系统</th><th>用途</th><th>状态</th><th>可信度</th></tr></thead><tbody>${rel.map(r=>`<tr><td>${esc(r.weapon_or_system)}</td><td>${esc(r.role)}</td><td>${badge(r.status)}</td><td>${badge(r.confidence,'confidence')}</td></tr>`).join('')}</tbody></table></div>`:'<p class="rich">当前没有匹配到结构化武器关系,不代表平台没有任务系统。</p>'}</section><section class="detail-section"><h3>公开来源</h3>${srcs.length?srcs.map(s=>`<div class="source-card" data-action="open-source" data-id="${esc(s.source_id)}"><strong>${esc(s.title)}</strong><p>${esc(s.usage)}</p></div>`).join(''):'<p>来源编号已记录或待补充。</p>'}</section><section class="detail-section"><h3>关联知识正文</h3>${relatedDocs(equipDocs(x))}</section>${technicalFields(x)}`)}
function renderTraining(){const stages=[...DATA.training].sort((a,b)=>Number(a.stage_order)-Number(b.stage_order));$('#page-training').innerHTML=`<div class="page-head"><div><h2>训练与保障</h2><p>结构化卡片负责概览,点击后进入中文业务详情并可继续阅读训练、模拟器、维修和航材Markdown全文。</p></div></div><div class="panel"><div class="panel-head"><div><h3>飞行训练资格生成链</h3><p>点击任一阶段查看输入、学科、模拟器、实飞、输出和来源</p></div></div><div class="flow">${stages.slice(0,9).map((x,i)=>`<div class="flow-step" data-action="record-detail" data-type="training" data-index="${DATA.training.indexOf(x)}"><div class="no">${i+1}</div><h4>${esc(x.stage_name)}</h4><p>${esc(x.responsible_entity)}</p></div>`).join('')}</div></div><div class="section-title"><h3>模拟训练体系</h3><span>业务字段分组 + 关联Markdown</span></div><div class="grid cols3">${DATA.simulators.map((x,i)=>`<article class="unit-card" data-action="record-detail" data-type="simulator" data-index="${i}"><div class="top"><h4>${esc(x.platform_or_role)}</h4>${badge(x.delivery_status)}</div><div>${chips([x.category],3)}</div><p>${esc(x.evidence)}</p><div class="foot"><span>${esc(x.organization)}</span><button class="btn light sm">查看丰富详情</button></div></article>`).join('')}</div><div class="section-title"><h3>维修层级与保障能力</h3><span>点击查看责任主体、质量关口、技术依赖和全文专题</span></div><div class="grid cols2">${DATA.maintenance.map((x,i)=>`<div class="panel" data-action="record-detail" data-type="maintenance" data-index="${i}"><div class="panel-head"><div><h3>${esc(x.platform_family)}</h3><p>${esc(x.level)} · ${esc(x.task_family)}</p></div>${badge(x.status)}</div><p class="rich">${esc(x.key_outputs)}</p><div>${chips([x.responsible_entity,x.quality_gate],4)}</div></div>`).join('')}</div>`}
function renderIndustry(){$('#page-industry').innerHTML=`<div class="page-head"><div><h2>工业链与外部依赖</h2><p>产业节点和依赖矩阵均使用中文详情分组,并链接国产工业、供应链和外部依赖Markdown全文。</p></div></div><div class="industry-stack">${[['政策与军种需求','需求、预算、采购和产业政策'],['总体设计与系统整合','汉翔、中科院及项目主承包'],['专业系统与供应商','发动机、航电、雷达、结构、材料和特殊制程'],['维修与全寿期支援','军方厂库、国内MRO、原厂/FMS和技术资料'],['外部技术与认证','设计权、软件、专用工具、出口管制和适航认证']].map((x,i)=>`<div class="industry-layer"><div><span class="badge red">产业层级 ${i+1}</span><h3 class="mt">${x[0]}</h3></div><p class="rich">${x[1]}</p></div>`).join('')}</div><div class="section-title"><h3>国内产业节点</h3><span>点击查看能力范围、平台、成熟度与关联正文</span></div><div class="grid cols3">${DATA.industry.map((x,i)=>`<article class="unit-card" data-action="record-detail" data-type="industry" data-index="${i}"><div class="top"><h4>${esc(x.organization)}</h4>${badge(x.status)}</div><div>${chips([x.node_type,x.maturity],4)}</div><p>${esc(clip(x.capabilities,150))}</p><div class="foot"><span>${esc(x.foreign_interface)}</span><button class="btn light sm">丰富详情</button></div></article>`).join('')}</div><div class="section-title"><h3>平台级外部依赖矩阵</h3><span>默认不显示原始JSON</span></div><div class="table-wrap"><table><thead><tr><th>平台或系统</th><th>依赖维度</th><th>国内能力</th><th>外部依赖</th><th>等级</th><th>详情</th></tr></thead><tbody>${DATA.externalDependencies.map((x,i)=>`<tr><td><strong>${esc(x.platform_or_system)}</strong></td><td>${esc(x.dimension)}</td><td>${esc(clip(x.domestic_capability,75))}</td><td>${esc(clip(x.foreign_dependency,75))}</td><td><span class="badge red">${esc(x.level)}</span></td><td><button class="btn light sm" data-action="record-detail" data-type="dependency" data-index="${i}">展开</button></td></tr>`).join('')}</tbody></table></div>`}
function renderSafety(){$('#page-safety').innerHTML=`<div class="page-head"><div><h2>飞安、事故与风险治理</h2><p>风险和治理环节采用语义详情页面,并连接事故调查、人因、适航、环境和搜救Markdown专题。</p></div></div><div class="grid cols4">${DATA.safetyRisks.slice(0,11).map((x,i)=>`<article class="risk-card" data-action="record-detail" data-type="risk" data-index="${i}"><h4>${esc(x.domain)}</h4><p class="rich">${esc(x.risk)}</p><div>${chips(arr(x.controls),4)}</div></article>`).join('')}</div><div class="section-title"><h3>飞安治理闭环</h3><span>点击阶段查看输入、输出、责任主体与制度正文</span></div><div class="panel"><div class="flow">${DATA.safetyProcess.map((x,i)=>`<div class="flow-step" data-action="record-detail" data-type="process" data-index="${i}"><div class="no">${i+1}</div><h4>${esc(x.stage)}</h4><p>${esc(x.owner)}</p></div>`).join('')}</div></div><div class="section-title"><h3>公开重大飞安事件</h3><span>右侧抽屉提供业务字段和完整专题入口</span></div><div class="timeline">${DATA.safetyEvents.map((x,i)=>`<article class="event" data-action="open-event" data-index="${i}"><span class="date">${esc(x.date)}</span> <span class="badge red">${esc(labels.category[x.category]||x.category)}</span><h4>${esc(x.platform)} · ${esc(x.summary)}</h4><p>${esc(x.outcome)};${esc(x.cause_status)}</p></article>`).join('')}</div>`}
function openEvent(i){const x=DATA.safetyEvents[Number(i)];if(!x)return;const ss=arr(x.source_ids).map(id=>sourceMap[id]).filter(Boolean);openDrawer(`${x.date} · ${x.platform}`,`<div class="record-hero"><h2>${esc(x.summary)}</h2><p>事件事实、处置、复飞和原因状态分层展示。</p></div>${infoGroup('事件概览',['date','service','platform','category','severity','outcome'],x)}${infoGroup('停飞、检查与恢复',['grounding','return_to_flight'],x)}${infoGroup('调查与原因状态',['cause_status','confidence','source_ids'],x)}<section class="detail-section"><h3>公开来源</h3>${ss.map(s=>`<div class="source-card" data-action="open-source" data-id="${esc(s.source_id)}"><strong>${esc(s.title)}</strong><p>${esc(s.usage)}</p></div>`).join('')||'<p>来源编号已记录。</p>'}</section><section class="detail-section"><h3>关联知识正文</h3>${relatedDocs(RELATED.safety)}</section><div class="notice">初始通报、监督调查和最终原因属于不同证据层级,不应混写。</div>`)}
function renderTimeline(){const sources=DATA.sources.filter(x=>!state.sourceQ||JSON.stringify(x).toLowerCase().includes(state.sourceQ.toLowerCase())).slice(0,70);$('#page-timeline').innerHTML=`<div class="page-head"><div><h2>事件时间线与来源</h2><p>时间线与官方来源卡保持可检索,同时来源索引Markdown也可在知识专题库阅读全文。</p></div></div><div class="source-layout"><div class="panel"><div class="panel-head"><div><h3>重大事件时间线</h3><p>按公开日期倒序</p></div></div><div class="timeline">${DATA.timeline.map(x=>`<article class="event"><span class="date">${esc(x.date)}</span> <span class="badge red">${esc(x.type)}</span><h4>${esc(x.title)}</h4><p>${esc(x.detail||'公开资料未提供更多摘要')}</p></article>`).join('')}</div></div><div><div class="panel mb"><div class="panel-head"><div><h3>官方来源查询</h3><p>共 ${DATA.sources.length} 项来源</p></div></div><input id="sourceQ" class="input" value="${esc(state.sourceQ)}" placeholder="查询来源名称、用途或编号"><button class="btn line mt" data-action="open-knowledge" data-path="sources/官方来源索引_v7.md">阅读来源索引Markdown</button></div>${sources.map(s=>`<article class="source-card" data-action="open-source" data-id="${esc(s.source_id)}"><h4>${esc(s.title)}</h4><p>${esc(s.usage)}</p><div class="mt">${badge(s.confidence,'confidence')} <span class="badge gray">${esc(s.verified)}</span></div></article>`).join('')}</div></div>`;$('#sourceQ').addEventListener('input',e=>{state.sourceQ=e.target.value;renderTimeline()})}
function openSource(id){const s=sourceMap[id];if(!s)return;const local=sourceLocalMap[id]||{},contexts=local.citation_contexts||[],mentions=local.structured_mentions||[];
const original=local.has_original_content&&local.original_content?`<section class="offline-source-panel"><h3>本地收录的原始内容</h3><div class="md-body">${enrichKnowledgeHtml(local.original_content)}</div></section>`:'';
const contextHtml=contexts.length?`<section class="offline-source-panel"><h3>知识库引用上下文</h3><p class="rich">以下内容来自本地Markdown中引用该来源的章节,用于离线核对来源支撑范围;它不是外部网页的完整原文。</p>${contexts.map(c=>`<article class="citation-context" data-action="open-knowledge" data-path="${esc(c.path)}"><h4>${esc(c.title)}</h4><p><strong>${esc(c.section||'相关章节')}</strong>:${esc(c.snippet)}</p></article>`).join('')}</section>`:'';
const mentionHtml=mentions.length?`<section class="offline-source-panel"><h3>结构化数据中的使用位置</h3>${mentions.map(m=>`<article class="structured-mention"><span class="dataset">${esc(m.dataset)}</span><div><strong>${esc(m.title)}</strong><p>${esc(m.summary||'该记录引用此来源。')}</p></div></article>`).join('')}</section>`:'';
const noOriginal=!local.has_original_content?`<div class="notice mt">本地知识库未保存该官方页面或PDF的完整原文。当前为离线部署版本,因此不显示网址,也不提供外网跳转。</div>`:'';
openModal(`<div class="record-hero"><h2>${esc(s.title)}</h2><p>${esc(s.usage)}</p></div>${infoGroup('来源信息',['source_id','verified','confidence'],s)}<section class="offline-source-panel"><h3>离线收录状态</h3><span class="offline-status ${local.has_original_content?'available':(contexts.length||mentions.length?'context':'')}">${esc(local.local_status||'仅收录来源元数据')}</span><p class="offline-reference-note">来源用途摘要:${esc(s.usage||'未提供')}</p></section>${original}${contextHtml}${mentionHtml}${noOriginal}<div class="notice">来源只能支撑其明确记载的字段,不能由单一来源推断完整编制、实时状态或单位级兵力。</div>`)}
function knowledgeFiltered(){return KNOWLEDGE.filter(d=>(state.knowledgeCategory==='全部'||d.category===state.knowledgeCategory)&&(!state.knowledgeQ||(d.title+' '+d.summary+' '+d.text+' '+d.path).toLowerCase().includes(state.knowledgeQ.toLowerCase())))}
function renderKnowledge(){const cats=['全部',...uniq(KNOWLEDGE.map(x=>x.category))],list=knowledgeFiltered(),pages=Math.max(1,Math.ceil(list.length/state.knowledgeSize));state.knowledgePage=Math.min(state.knowledgePage,pages);const show=list.slice((state.knowledgePage-1)*state.knowledgeSize,state.knowledgePage*state.knowledgeSize),counts=CounterBy(KNOWLEDGE,'category');$('#page-knowledge').innerHTML=`<div class="page-head"><div><h2>知识专题库</h2><p>共 ${KNOWLEDGE.length} 篇非重复Markdown知识文件。可按主题分类、标题和正文全文检索。</p></div><div class="page-actions"><input id="knowledgeQ" class="input" style="width:330px" value="${esc(state.knowledgeQ)}" placeholder="查询标题、摘要、正文或路径"></div></div><div class="knowledge-layout"><aside class="knowledge-side"><h3 style="margin:5px 8px 12px">专题分类</h3>${cats.map(c=>`<button class="${state.knowledgeCategory===c?'active':''}" data-action="knowledge-cat" data-cat="${esc(c)}"><span>${esc(c)}</span><span>${c==='全部'?KNOWLEDGE.length:(counts[c]||0)}</span></button>`).join('')}</aside><div><div class="grid cols3">${show.map(d=>`<article class="knowledge-card" data-action="open-knowledge" data-path="${esc(d.path)}"><h4>${esc(d.title)}</h4><div class="knowledge-meta"><span class="badge red">${esc(d.category)}</span><span class="badge gray">${esc(d.type)}</span></div><p>${esc(clip(d.summary||d.when,155))}</p><div class="knowledge-path">${esc(d.path)}</div><div class="foot mt"><span>${d.sections.length} 个章节</span><button class="btn light sm">阅读全文</button></div></article>`).join('')||'<div class="empty">没有匹配的Markdown专题</div>'}</div><div class="pagination"><button class="btn line sm" data-action="knowledge-page" data-page="${state.knowledgePage-1}" ${state.knowledgePage<=1?'disabled':''}>上一页</button><span>第 ${state.knowledgePage} / ${pages} 页,共 ${list.length} 篇</span><button class="btn line sm" data-action="knowledge-page" data-page="${state.knowledgePage+1}" ${state.knowledgePage>=pages?'disabled':''}>下一页</button></div></div></div>`;$('#knowledgeQ').addEventListener('input',e=>{state.knowledgeQ=e.target.value;state.knowledgePage=1;renderKnowledge()})}
function CounterBy(items,key){return items.reduce((a,x)=>(a[x[key]]=(a[x[key]]||0)+1,a),{})}
const datasets={units:['单位档案',DATA.units,'unit'],organizations:['组织机构',DATA.organizations,'organization'],equipment:['装备平台',DATA.equipment,'equipment'],training:['训练阶段',DATA.training,'training'],simulators:['模拟器',DATA.simulators,'simulator'],maintenance:['维修能力',DATA.maintenance,'maintenance'],industry:['产业节点',DATA.industry,'industry'],dependencies:['外部依赖',DATA.externalDependencies,'dependency'],safety:['飞安事件',DATA.safetyEvents,'safety'],sources:['官方来源',DATA.sources,'source'],knowledge:['知识专题',KNOWLEDGE,'knowledge']};
function openDatasetRecord(key,x){const type=datasets[key][2];if(type==='unit')openUnit(x.organization_id);else if(type==='organization')openOrg(x.organization_id);else if(type==='equipment')openEquip(x.platform_id);else if(type==='source')openSource(x.source_id);else if(type==='knowledge')openKnowledge(x.path);else openRecord(type,x)}
function openQuery(){$('#queryPanel').classList.remove('hidden');document.body.style.overflow='hidden';$('#queryForm').innerHTML=`<h2>交互式数据查询</h2><p class="rich">结构化字段用于筛选;结果点击后打开中文业务详情或Markdown全文。</p><label>数据集</label><select id="qDataset" class="select mb">${Object.entries(datasets).map(([k,v])=>`<option value="${k}">${v[0]}</option>`).join('')}</select><label>查询字段</label><select id="qField" class="select mb"></select><label>条件</label><select id="qOp" class="select mb"><option value="contains">包含</option><option value="equals">等于</option></select><label>查询值</label><input id="qValue" class="input mb" placeholder="输入关键词"><button class="btn primary" id="qRun">执行查询</button><button class="btn light" id="qClear">清空</button><div class="notice mt">查询结果默认不显示原始JSON。</div>`;const fields=()=>{const d=datasets[$('#qDataset').value][1],ks=uniq(d.slice(0,50).flatMap(x=>Object.keys(x))).sort();$('#qField').innerHTML='<option value="__all">全部字段</option>'+ks.map(k=>`<option value="${esc(k)}">${esc(FIELD_LABELS[k]||k)}</option>`).join('')};const run=()=>{const key=$('#qDataset').value,d=datasets[key][1],f=$('#qField').value,op=$('#qOp').value,q=$('#qValue').value.trim().toLowerCase(),r=d.filter(x=>{const v=f==='__all'?JSON.stringify(x):JSON.stringify(x[f]??'');return!q||(op==='equals'?v.replace(/^"|"$/g,'').toLowerCase()===q:v.toLowerCase().includes(q))}).slice(0,100);$('#queryResult').innerHTML=`<div class="page-head"><div><h2>查询结果</h2><p>匹配 ${r.length} 条,点击查看业务详情</p></div></div>${r.map((x,i)=>`<div class="query-row" data-action="query-record" data-dataset="${key}" data-index="${d.indexOf(x)}"><strong>${esc(recordTitle(datasets[key][2],x))}</strong><p style="margin:5px 0;color:#667085">${esc(clip(x.summary||x.usage||x.evidence||x.text||JSON.stringify(x),170))}</p></div>`).join('')||'<div class="empty">没有匹配结果</div>'}`};$('#qDataset').addEventListener('change',()=>{fields();run()});$('#qField').addEventListener('change',run);$('#qOp').addEventListener('change',run);$('#qValue').addEventListener('input',run);$('#qRun').onclick=run;$('#qClear').onclick=()=>{$('#qValue').value='';run()};fields();run()}
function renderAll(){renderOverview();renderOrganization();renderUnits();renderEquipment();renderTraining();renderIndustry();renderSafety();renderTimeline();renderKnowledge()}
document.addEventListener('click',e=>{const el=e.target.closest('[data-action]');if(!el)return;const a=el.dataset.action;if(a==='goto-tab')setTab(el.dataset.tab);if(a==='open-unit')openUnit(el.dataset.id);if(a==='open-org')openOrg(el.dataset.id);if(a==='open-equip')openEquip(el.dataset.id);if(a==='open-event')openEvent(el.dataset.index);if(a==='open-source')openSource(el.dataset.id);if(a==='missing-source')openModal(`<div class="record-hero"><h2>来源待补充</h2><p>当前来源索引中未匹配到该编号。</p></div>${kv('来源编号',esc(el.dataset.id))}<div class="notice mt">该编号已在Markdown正文中出现,但尚未与来源索引建立映射。后续可在离线来源表中补充标题、用途、核验日期、可信度及本地原文或摘录;本页面不展示外部网址。</div>`);if(a==='open-knowledge')openKnowledge(el.dataset.path);if(a==='record-detail'){const map={training:DATA.training,simulator:DATA.simulators,maintenance:DATA.maintenance,industry:DATA.industry,dependency:DATA.externalDependencies,risk:DATA.safetyRisks,process:DATA.safetyProcess};openRecord(el.dataset.type,map[el.dataset.type][Number(el.dataset.index)])}if(a==='query-record'){const key=el.dataset.dataset;openDatasetRecord(key,datasets[key][1][Number(el.dataset.index)])}if(a==='close-modal')closeModal();if(a==='close-drawer')closeDrawer();if(a==='close-detail')closeDetail();if(a==='open-query')openQuery();if(a==='close-query')closeQuery();if(a==='org-service'){state.orgService=el.dataset.service;state.orgZoom=1;renderOrganization()}if(a==='zoom-in'){state.orgZoom=Math.min(1.45,state.orgZoom+.1);renderOrganization()}if(a==='zoom-out'){state.orgZoom=Math.max(.6,state.orgZoom-.1);renderOrganization()}if(a==='zoom-reset'){state.orgZoom=1;renderOrganization()}if(a==='unit-view'){state.unitView=el.dataset.view;renderUnits()}if(a==='unit-page'){state.unitPage=Number(el.dataset.page);renderUnits()}if(a==='clear-units'){state.unitFilters={q:'',service:'全部',level:'全部',location:'全部',status:'全部'};state.unitPage=1;renderUnits()}if(a==='equip-cat'){state.equipCat=el.dataset.cat;renderEquipment()}if(a==='knowledge-cat'){state.knowledgeCategory=el.dataset.cat;state.knowledgePage=1;renderKnowledge()}if(a==='knowledge-page'){state.knowledgePage=Number(el.dataset.page);renderKnowledge()}if(a==='reader-scroll'){const t=document.getElementById(el.dataset.target);if(t)t.scrollIntoView({behavior:'smooth',block:'start'});$$('.reader-toc button').forEach(b=>b.classList.toggle('active',b===el))}if(a==='reset'){state.unitFilters={q:'',service:'全部',level:'全部',location:'全部',status:'全部'};state.unitPage=1;state.equipCat='全部';state.orgService='air_force';state.sourceQ='';state.knowledgeCategory='全部';state.knowledgeQ='';renderAll();setTab('overview')}});
$$('.tab').forEach(t=>t.addEventListener('click',()=>setTab(t.dataset.tab)));
const gs=$('#globalSearch');function globalResults(q){q=q.trim().toLowerCase();if(!q)return[];const r=[];DATA.units.forEach(x=>{if(JSON.stringify(x).toLowerCase().includes(q))r.push({kind:'单位',title:x.name,desc:x.summary,action:'open-unit',id:x.organization_id})});DATA.equipment.forEach(x=>{if(JSON.stringify(x).toLowerCase().includes(q))r.push({kind:'装备',title:equipName(x),desc:`${equipCat(x)} · ${status(x.status)}`,action:'open-equip',id:x.platform_id})});KNOWLEDGE.forEach(x=>{if((x.title+' '+x.summary+' '+x.text).toLowerCase().includes(q))r.push({kind:'知识正文',title:x.title,desc:x.summary||x.path,action:'open-knowledge',id:x.path})});DATA.safetyEvents.forEach((x,i)=>{if(JSON.stringify(x).toLowerCase().includes(q))r.push({kind:'飞安事件',title:`${x.date} ${x.platform}`,desc:x.summary,action:'open-event',id:i})});DATA.sources.forEach(x=>{if(JSON.stringify(x).toLowerCase().includes(q))r.push({kind:'来源',title:x.title,desc:x.usage,action:'open-source',id:x.source_id})});return r.slice(0,25)}
gs.addEventListener('input',()=>{const r=globalResults(gs.value),p=$('#searchPop');if(!gs.value.trim()){p.classList.add('hidden');return}p.innerHTML=r.length?r.map(x=>`<div class="search-item" data-search-action="${x.action}" data-search-id="${esc(x.id)}"><span class="search-kind">${x.kind}</span><div><strong>${esc(x.title)}</strong><small>${esc(clip(x.desc,110))}</small></div></div>`).join(''):'<div class="empty">没有匹配结果</div>';p.classList.remove('hidden')});$('#searchPop').addEventListener('click',e=>{const x=e.target.closest('[data-search-action]');if(!x)return;$('#searchPop').classList.add('hidden');gs.value='';if(x.dataset.searchAction==='open-unit')openUnit(x.dataset.searchId);if(x.dataset.searchAction==='open-equip')openEquip(x.dataset.searchId);if(x.dataset.searchAction==='open-event')openEvent(x.dataset.searchId);if(x.dataset.searchAction==='open-source')openSource(x.dataset.searchId);if(x.dataset.searchAction==='open-knowledge')openKnowledge(x.dataset.searchId)});document.addEventListener('click',e=>{if(!e.target.closest('.search'))$('#searchPop').classList.add('hidden')});renderAll();
})();
</script></body></html>
@@ -28,24 +28,30 @@ def setup_writing(
writing_mode: str | None = None,
material_source: str | None = None,
outline: str | None = None,
article_type_detail: str | None = None,
) -> Command:
"""把与用户聊清的写作配置提交给前端表单(不直接开始写作)。
Args:
user_intent: 用户的写作意图(精炼成一句话的写作目标)。
article_type: 文章类型的 key(须是系统给定的合法类型之一;不确定可留空)。
article_type: 文章类型。优先填系统给定的合法类型 key;若已有类型都不贴切,可**自拟一个新的
文章类型**——此时填一个简短的中文类型名(如「政策解读」「人物特写」),并在
``article_type_detail`` 里说明该类型特征。不确定可留空。
word_count_target: 目标字数(正整数)。
target_audience: 目标读者(如「行业专家」「普通读者」)。
writing_mode: 写作模式,'strict'(严格依据素材)或 'loose'(可结合通用知识)。
material_source: 素材来源,'knowledge_base'(知识库)或 'skill'(技能检索)。
outline: 若用户已提供大纲,原样填入其大纲文本(保留其层级与标题),否则留空。
outline: **已与用户确认的大纲全文**(保留其层级与标题)。本工具在用户确认大纲后调用,
故通常应带上这份大纲;用户未提供/未确认大纲时才留空。
article_type_detail: 仅当 ``article_type`` 是**新类型(不在已有列表里)**时填写:用一句话说明
这个类型的写作侧重/特征(如「以政策原文为锚、逐条拆解影响并给出执行建议」)。命中已有类型时留空。
"""
logger.info("[setup_writing] proposed writing config submitted (intercepted by approval middleware)")
return Command(
update={
"messages": [
ToolMessage(
content="写作配置已生成,请在右侧确认后应用到表单。",
content="写作配置已生成,请在右侧确认后应用到表单,或直接开始写作。",
tool_call_id=runtime.tool_call_id,
)
]