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