Merge branch 'dhk-1' into 'master'

fix(登录): redirect 透传登录 URL 其余 query 到目标页

See merge request knowledge-base-bj/deerflow-server!150
This commit is contained in:
董宏凯 committed 2026-06-24 11:49:00 +00:00
commit 5b818836d7
54 files changed
+3472 -337

No files matched your search

+1 -1
View File
@@ -79,7 +79,7 @@ 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 can be **pure JSON, plain text, or text-with-embedded-JSON** — the backend pre-extracts any embedded JSON and the agent's `SOUL.md` maps it (or the plain text's semantics) 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.
- **定时任务「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 can be **pure JSON, plain text, or text-with-embedded-JSON** — the backend pre-extracts any embedded JSON and the agent's `SOUL.md` maps it (or the plain text's semantics) to the template's schema skeleton. **Chat-page debug entry (only this agent's `AgentChatPage`)**: when `agent_id === "template-json-builder"`, the chat header shows a 「页面模板」 select + a 「渲染预览」 button — after a normal Q&A run, the button takes the **latest AI reply**, POSTs it to `POST /api/scheduled-tasks/template-fill/render` (`renderTemplateFill`), and pops a dialog rendering the filled HTML in a `sandbox="allow-scripts"` iframe plus the structure-validation status/errors/gaps — for debugging the agent's conversion effect without creating a scheduled task. Gated strictly on the agent id, so other agents' chat pages are untouched. See the backend CLAUDE.md「Template-fill mode」for the engine (`deerflow/runtime/scheduler/template_fill.py`), agent seed, dispatch, and the render API.
- **轻应用中心 (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.
@@ -2,6 +2,7 @@
import { Check, LayoutIcon, PanelLeftIcon, Shirt } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import { Button } from "@/components/ui/button";
import {
@@ -140,6 +141,7 @@ export function LayoutModeSwitcher({
fallback?: "full" | "compact";
}) {
const [mode, setLayoutMode] = useLayoutMode();
const navigate = useNavigate();
const current = mode ?? fallback;
const TriggerIcon = current === "full" ? PanelLeftIcon : LayoutIcon;
@@ -166,7 +168,10 @@ export function LayoutModeSwitcher({
return (
<DropdownMenuItem
key={option.id}
onClick={() => setLayoutMode(option.id)}
onClick={() => {
setLayoutMode(option.id);
navigate("/page/workspace/chats/new");
}}
className="gap-2"
>
<Icon className="size-4 shrink-0" />
@@ -42,6 +42,26 @@ export async function listHtmlTemplates() {
return data.templates ?? [];
}
/** 「模板数据转换器」对话页「渲染预览」结果。 */
export type TemplateRenderResult = {
ok: boolean;
display_only?: boolean;
errors: string[];
gaps: string[];
html: string | null;
};
/** 调试用:把一段(智能体回复的)文本里的 JSON 填进所选 HTML 模板,返回完整 HTML 与校验信息。 */
export async function renderTemplateFill(templateId: string, text: string) {
return parseResponse<TemplateRenderResult>(
await apiFetch(`${baseUrl()}/template-fill/render`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ template_id: templateId, text }),
}),
);
}
export interface ListScheduledTasksParams {
search?: string;
tagIds?: string[];
@@ -22,6 +22,8 @@ export type HtmlPageConfig = {
negative_prompt?: string | null;
reference_favorite_id?: string | null;
model_name?: string | null;
/** 仅当参考选了内置模板(tpl:<id>)时生效:关闭则直接用模板默认数据,开启才检索抽取。 */
template_extract?: boolean;
};
/** 选中内置「模板数据转换器」agent 后,要把源 JSON 填充进的 HTML 模板。
@@ -82,6 +82,27 @@ export async function renameAIWritingSession(id: string, title: string): Promise
return resp.json();
}
/**
* 更新历史会话的成稿正文(历史回看时用户在右侧编辑器里直接修改并保存)。
* draftTitle 省略时后端不动原标题。
*/
export async function updateAIWritingDraft(
id: string,
draftMarkdown: string,
draftTitle?: string,
): Promise<AIWritingSessionSummary> {
const resp = await apiFetch(`${BASE()}/${id}/draft`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
draft_markdown: draftMarkdown,
...(draftTitle === undefined ? {} : { draft_title: draftTitle }),
}),
});
if (!resp.ok) throw new Error(`保存修改失败: ${resp.status}`);
return resp.json();
}
export async function deleteAIWritingSession(id: string): Promise<void> {
const resp = await apiFetch(`${BASE()}/${id}`, { method: 'DELETE' });
if (!resp.ok) throw new Error(`删除失败: ${resp.status}`);
@@ -41,10 +41,23 @@ export function AIWritingDraftPanel() {
const showCompletedDraftView = isDraftEditable || isHistoryMode;
const writingStatus = writing?.state.status;
const streamingSectionCount = writing?.state.streamingDraftSections.length ?? 0;
// 按用户意见修改中、且重写流还没开始(无任何流式章节):右侧先恢复初始空占位,
// 把旧稿清掉;一旦重写流式 chunk 到达(streamingDraftSections 非空)就走下面的
// 流式预览,实时展示重写过程;draft_ready 后 isRevising 置回 false 显示新成稿。
if (writing?.state.isRevising && streamingSectionCount === 0) {
return (
<div className="h-full bg-muted/10 border-l">
<DraftWaitingPlaceholder />
</div>
);
}
// 仅当章节仍在累积时走流式 Markdown;draft_ready 清空 sections 后即使 status 尚未切走也进画布
const isActivelyStreamingDraft =
(writingStatus === 'writing_draft' || writingStatus === 'revising') &&
(writing?.state.streamingDraftSections.length ?? 0) > 0;
streamingSectionCount > 0;
const streamingMarkdown = isActivelyStreamingDraft && writing
? assembleStreamingDraftMarkdown(
@@ -102,7 +115,8 @@ export function AIWritingDraftPanel() {
<TextArtifactRenderer
content={textContent}
isStreaming={!showCompletedDraftView}
readOnly={isHistoryMode}
readOnly={false}
onSaveDraft={isHistoryMode ? history?.saveHistoryDraft : undefined}
/>
</div>
</div>
@@ -3,6 +3,7 @@ import { PenLine, SlidersHorizontal } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { cn } from '@/lib/utils';
import {
Select,
SelectContent,
@@ -67,6 +68,7 @@ export function AIWritingForm({
strictMode, setStrictMode,
addReferences, setAddReferences,
materialSource, setMaterialSource,
skipResearch, setSkipResearch,
// 笔记来源(search_space_id + document_id 列表)已上提到 WritingFormContext,
// 以便左栏「笔记本」点击可直接设为素材来源(需求 0616 #5)。
selectedNotebookId, setSelectedNotebookId,
@@ -164,13 +166,16 @@ export function AIWritingForm({
: audience.trim() || DEFAULT_AUDIENCE,
wordCountTarget: wordCount,
keywordCount: DEFAULT_KEYWORD_COUNT,
writingMode: hideAdvancedFields ? 'loose' : strictMode ? 'strict' : 'loose',
// 跳过素材收集时强制 loose(无素材可严格依据,后端也会强制)
writingMode: skipResearch ? 'loose' : hideAdvancedFields ? 'loose' : strictMode ? 'strict' : 'loose',
addReferences,
materialSource: materialSource,
skipResearch,
// 跳过素材收集时检索类型无意义,归一为 general
materialSource: skipResearch ? 'general' : materialSource,
modelName: selectedModel?.name || modelName || undefined,
// 只有选了"我的空间"才传笔记参数
noteSearchSpaceId: materialSource === 'notebook' ? (selectedNotebookId ?? undefined) : undefined,
noteSourceIds: materialSource === 'notebook' && selectedNotebookId != null ? selectedDocumentIds : undefined,
// 只有选了"我的空间"且未跳过素材收集才传笔记参数
noteSearchSpaceId: !skipResearch && materialSource === 'notebook' ? (selectedNotebookId ?? undefined) : undefined,
noteSourceIds: !skipResearch && materialSource === 'notebook' && selectedNotebookId != null ? selectedDocumentIds : undefined,
});
}
@@ -278,6 +283,25 @@ export function AIWritingForm({
</div>
</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="skip-research"
checked={skipResearch}
onChange={(e) => setSkipResearch(e.target.checked)}
disabled={disabled}
className="mt-0.5 size-3.5 shrink-0 cursor-pointer accent-primary"
/>
<label htmlFor="skip-research" 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">
勾选后跳过素材检索阶段(不弹素材确认),AI 直接用通用知识规划大纲并写作;检索类型与「严格依据素材」将被忽略。
</p>
</label>
</div>
{!skipResearch && (
<div className="mt-4 space-y-2">
<label className={FIELD_LABEL_CLASS}>检索类型</label>
<Select
@@ -354,6 +378,7 @@ export function AIWritingForm({
</div>
)}
</div>
)}
<div className="mt-4 space-y-2">
<label className={FIELD_LABEL_CLASS}>写作模型</label>
@@ -416,15 +441,17 @@ export function AIWritingForm({
<input
type="checkbox"
id="strict-mode"
checked={strictMode}
checked={strictMode && !skipResearch}
onChange={(e) => setStrictMode(e.target.checked)}
disabled={disabled}
className="mt-0.5 size-3.5 shrink-0 cursor-pointer accent-primary"
disabled={disabled || skipResearch}
className="mt-0.5 size-3.5 shrink-0 cursor-pointer accent-primary disabled:cursor-not-allowed"
/>
<label htmlFor="strict-mode" className="cursor-pointer select-none">
<label htmlFor="strict-mode" className={cn('select-none', skipResearch ? 'cursor-not-allowed opacity-50' : 'cursor-pointer')}>
<span className="text-sm font-medium text-foreground/90">严格依据素材写作</span>
<p className="mt-0.5 text-xs leading-relaxed text-muted-foreground/80">
勾选后作家只依据检索素材写作,素材不足以支撑某章节时会暂停询问你;不勾选则可结合通用知识补充论述。
{skipResearch
? '已选「不收集素材」,本项不适用:将直接用通用知识写作。'
: '勾选后作家只依据检索素材写作,素材不足以支撑某章节时会暂停询问你;不勾选则可结合通用知识补充论述。'}
</p>
</label>
</div>
@@ -26,6 +26,7 @@ function normalizeCompletedIntervention(rec: Record<string, unknown>): Completed
if (rr) out.reviewResult = normalizeReviewResult(rr);
if (out.draftTitle == null && rec.draft_title != null) out.draftTitle = rec.draft_title;
if (out.draftWordCount == null && rec.draft_word_count != null) out.draftWordCount = rec.draft_word_count;
if (out.userInput == null && rec.user_input != null) out.userInput = rec.user_input;
return out as unknown as CompletedIntervention;
}
@@ -496,9 +496,13 @@ export function AIWritingPanel({
/>
)}
{/* 大纲流式思考框 */}
{/* 大纲流式思考框(用户自带大纲时文案显示「整理」) */}
{state.status === 'writing_outline' && (state.outlineStreamingText || state.outlineThinkingText) && (
<OutlineStreamingView text={state.outlineStreamingText} thinkingText={state.outlineThinkingText} />
<OutlineStreamingView
text={state.outlineStreamingText}
thinkingText={state.outlineThinkingText}
organizing={!!state.request?.userOutline}
/>
)}
{/* 编辑审核流式结果 */}
@@ -560,7 +564,11 @@ export function AIWritingPanel({
/* 意图解析流式过程中(LLM 还在吐 JSON)不要显示 skeleton */
!state.intentStreamingText && !state.intentThinkingText && (
<div className="px-4 pb-4">
<WritingSkeletonCard status={state.status} researchProgress={state.researchProgress} />
<WritingSkeletonCard
status={state.status}
researchProgress={state.researchProgress}
organizing={!!state.request?.userOutline}
/>
</div>
)}
@@ -605,7 +613,9 @@ export function AIWritingPanel({
)}
{state.status === 'researching' && state.researchProgress
? state.researchProgress
: STATUS_LABEL[state.status] ?? state.status}
: state.status === 'writing_outline' && state.request?.userOutline
? '作家整理你的大纲中…'
: STATUS_LABEL[state.status] ?? state.status}
</>
)}
</div>
@@ -1,6 +1,7 @@
import type { CompletedIntervention, ProgressEvent } from '../../types/ai-writing';
import { TimelineStep } from './TimelineStep';
import { CompletedInterventionCard } from './CompletedInterventionCard';
import { UserRequirementCard } from './UserRequirementCard';
interface AIWritingTimelineProps {
events: ProgressEvent[];
@@ -48,8 +49,13 @@ export function AIWritingTimeline({
awaitUserIdx += 1;
if (completedRecord) {
// 已完成的干预点 → 渲染折叠历史卡
slot = <CompletedInterventionCard record={completedRecord} />;
// 已完成的干预点 → 先以独立样式回显用户的输入要求,再渲染折叠历史卡
slot = (
<div className="space-y-2">
<UserRequirementCard record={completedRecord} />
<CompletedInterventionCard record={completedRecord} />
</div>
);
} else if (currentPause && isLast) {
// 当前活跃干预点 → 渲染操作卡
slot = currentInterventionSlot;
@@ -455,13 +455,6 @@ function DraftConfirmCard({ draftTitle, draftWordCount, wordCountTarget, revisio
)}
<div className="flex flex-col gap-1.5 pt-1">
<Button
type="button" size="sm" className="w-full h-8 text-xs gap-1.5"
onClick={() => submit('finalize')} disabled={disabled || submitting}
>
<CheckCircle2 className="size-3.5" />确认定稿
</Button>
{/* 按编辑审核意见修改(有审核结果时始终显示) */}
{hasReview && (
<Button
@@ -504,6 +497,13 @@ function DraftConfirmCard({ draftTitle, draftWordCount, wordCountTarget, revisio
<ArrowRight className="size-3.5" />让编辑继续审核
</Button>
</div>
<Button
type="button" size="sm" className="w-full h-8 text-xs gap-1.5"
onClick={() => submit('finalize')} disabled={disabled || submitting}
>
<CheckCircle2 className="size-3.5" />确认定稿
</Button>
</div>
</div>
);
@@ -4,9 +4,11 @@ import { FileText, ChevronDown, ChevronRight, Brain } from 'lucide-react';
interface OutlineStreamingViewProps {
text: string;
thinkingText?: string;
/** 用户自带大纲时为 true:文案显示「整理你的大纲」而非「规划大纲」。 */
organizing?: boolean;
}
export function OutlineStreamingView({ text, thinkingText }: OutlineStreamingViewProps) {
export function OutlineStreamingView({ text, thinkingText, organizing }: OutlineStreamingViewProps) {
const preRef = useRef<HTMLPreElement>(null);
const thinkRef = useRef<HTMLPreElement>(null);
const [thinkOpen, setThinkOpen] = useState(true);
@@ -66,7 +68,7 @@ export function OutlineStreamingView({ text, thinkingText }: OutlineStreamingVie
<div className="flex items-center gap-2 px-3 py-2 border-b border-purple-200/60 dark:border-purple-900/60 bg-purple-100/40 dark:bg-purple-900/30">
<FileText className="size-3.5 text-purple-500 shrink-0" />
<span className="text-xs font-medium text-purple-600 dark:text-purple-400">
作家正在规划大纲…
{organizing ? '作家正在整理你的大纲…' : '作家正在规划大纲…'}
</span>
<span className="ml-auto inline-flex gap-1">
{[0, 1, 2].map((i) => (
@@ -0,0 +1,50 @@
import { UserRound } from 'lucide-react';
import type { CompletedIntervention } from '../../types/ai-writing';
/**
* 时间线里「用户输入交互的要求」独立样式卡 —— 与 AI 步骤(灰)、完成卡(绿)、
* 等待卡(黄)在视觉上明确区分:靛蓝主色 + 用户图标 + 顶部要求类型标签。
*
* 触发场景(record.userInput 非空时):
* 素材确认 → 按用户要求补充检索内容
* 大纲确认 → 用户提出大纲修改意见
* 草稿确认 → 用户提出写作修改意见
* 编辑意见处理 → 用户在编辑审核时提出处理意见
*/
function requirementLabel(record: CompletedIntervention): string {
switch (record.pausePoint) {
case 'material_confirm':
return '补充检索内容';
case 'outline_confirm':
return '修改大纲意见';
case 'draft_confirm':
return '修改写作意见';
case 'review_confirm':
return '编辑审核意见';
default:
return '我的要求';
}
}
interface Props {
record: CompletedIntervention;
}
export function UserRequirementCard({ record }: Props) {
if (!record.userInput) return null;
return (
<div className="mt-2 rounded-md border border-indigo-200 bg-indigo-50/60 dark:border-indigo-900/40 dark:bg-indigo-950/20 px-3 py-2">
<div className="flex items-center gap-1.5">
<UserRound className="size-3.5 text-indigo-500 shrink-0" />
<span className="text-[11px] font-semibold text-indigo-600 dark:text-indigo-400">
我的要求 · {requirementLabel(record)}
</span>
</div>
<p className="mt-1 pl-5 text-xs leading-relaxed text-foreground/90 whitespace-pre-wrap break-words">
{record.userInput}
</p>
</div>
);
}
@@ -15,6 +15,8 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Switch } from '@/components/ui/switch';
import { Zap } from 'lucide-react';
import { ArtifactsProvider } from '@/components/workspace/artifacts';
import {
MessageList,
@@ -59,6 +61,8 @@ export function WritingSetupChat({
const threadId = useMemo(() => uuid(), []);
const [threadPrepared, setThreadPrepared] = useState(false);
const [chatModel, setChatModel] = useState('');
// 快捷模式:开启后跳过逐项选项澄清(文章类型/字数/读者用默认),给标题直接生成大纲。
const [quickMode, setQuickMode] = useState(false);
const { models, isLoading: modelsLoading } = useModels();
const { articleTypes } = useArticleTypes();
// 缺省回填写作表单;onApprove 传入时脱离 WritingFormProvider(如模板创建页)。
@@ -85,6 +89,7 @@ export function WritingSetupChat({
context: {
mode: 'flash',
is_writing_setup: true,
writing_setup_quick: quickMode,
valid_article_types: validArticleTypes,
model_name: chatModel || undefined,
},
@@ -133,7 +138,8 @@ export function WritingSetupChat({
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 skipResearch = formCtx?.skipResearch ?? false;
const materialSource = skipResearch ? 'general' : (formCtx?.materialSource ?? 'general');
const request: AIWritingRequest = {
userIntent: payload.user_intent?.trim() || formCtx?.intent?.trim() || '',
@@ -145,9 +151,11 @@ export function WritingSetupChat({
targetAudience: payload.target_audience?.trim() || formCtx?.audience?.trim() || '普通读者',
wordCountTarget: wordCount,
keywordCount: 1,
writingMode: payload.writing_mode === 'strict' ? 'strict' : 'loose',
// 跳过素材收集时强制 loose
writingMode: skipResearch ? 'loose' : payload.writing_mode === 'strict' ? 'strict' : 'loose',
// 跟随表单的「添加参考文献」开关(默认开)。
addReferences: formCtx?.addReferences ?? true,
skipResearch,
materialSource,
modelName: chatModel || formCtx?.modelName || undefined,
noteSearchSpaceId:
@@ -258,9 +266,25 @@ export function WritingSetupChat({
</Select>
)}
</div>
<span className="hidden shrink-0 text-[11px] text-muted-foreground/70 sm:block">
输入想法,按 Enter 直接解析填充
</span>
{/* 快捷模式开关:放在模型选择右侧。开启后跳过逐项选项确认,给标题直接出大纲。 */}
<label
className={cn(
'flex shrink-0 cursor-pointer select-none items-center gap-1.5 rounded-md border px-2 py-1 text-xs transition',
quickMode
? 'border-primary/40 bg-primary/5 text-primary dark:border-[#1B477E] dark:bg-[#022248]'
: 'border-input bg-background text-muted-foreground dark:border-[#1B477E] dark:bg-[#022248]',
)}
title="开启后跳过逐项选项确认(文章类型/字数/读者用默认),给出标题即直接生成大纲"
>
<Zap className={cn('size-3.5 shrink-0', quickMode && 'fill-current')} />
<span className="shrink-0">快捷模式</span>
<Switch
checked={quickMode}
onCheckedChange={setQuickMode}
disabled={thread.isLoading}
className="ml-0.5 scale-90"
/>
</label>
</div>
<PromptInput onSubmit={({ text }) => void handleChatSubmit(text)}>
<PromptInputTextarea
@@ -5,6 +5,8 @@ interface WritingSkeletonCardProps {
status: WritingStatus;
/** 素材收集阶段的实时进度文案(仅 researching 阶段使用) */
researchProgress?: string;
/** 用户自带大纲时为 true:大纲阶段文案显示「整理你的大纲」而非「规划大纲」。 */
organizing?: boolean;
}
// ── 基础骨架元素 ──────────────────────────────────────────────────────────────
@@ -80,12 +82,12 @@ function MaterialsSkeleton({ researchProgress }: { researchProgress?: string })
// ── 大纲规划骨架屏 ────────────────────────────────────────────────────────────
function OutlineSkeleton() {
function OutlineSkeleton({ organizing }: { organizing?: boolean } = {}) {
return (
<div className="space-y-3 p-4">
<div className="flex items-center gap-2 text-xs text-purple-500 font-medium">
<ThinkingDots color="bg-purple-400" />
<span>作家规划大纲中…</span>
<span>{organizing ? '作家整理你的大纲中…' : '作家规划大纲中…'}</span>
</div>
{/* 文章标题占位 */}
@@ -213,7 +215,7 @@ const SKELETON_MAP: Partial<Record<WritingStatus, React.FC>> = {
awaiting_review_confirm: RevisingSkeleton, // 采纳编辑意见后
};
export function WritingSkeletonCard({ status, researchProgress }: WritingSkeletonCardProps) {
export function WritingSkeletonCard({ status, researchProgress, organizing }: WritingSkeletonCardProps) {
// 素材收集阶段单独渲染,以便透传实时进度文案
if (status === 'researching' || status === 'awaiting_material_confirm') {
return (
@@ -222,6 +224,14 @@ export function WritingSkeletonCard({ status, researchProgress }: WritingSkeleto
</div>
);
}
// 大纲阶段单独渲染,以便透传「整理 / 规划」文案(用户自带大纲走「整理」)
if (status === 'writing_outline' || status === 'awaiting_outline_confirm') {
return (
<div className="rounded-xl border bg-card shadow-sm overflow-hidden">
<OutlineSkeleton organizing={organizing} />
</div>
);
}
const Skeleton = SKELETON_MAP[status];
if (!Skeleton) return null;
return (
@@ -24,6 +24,8 @@ interface TextArtifactRendererProps {
isStreaming?: boolean;
/** 完成态只读(如历史回看):保留目录 + 排版,禁用编辑与 AI 润色 */
readOnly?: boolean;
/** 历史回看可编辑场景:提供后工具栏出现「保存」按钮,把修改持久化回会话 */
onSaveDraft?: (markdown: string, title?: string) => Promise<void>;
}
interface PanelPos {
@@ -40,7 +42,7 @@ interface LastAction {
customInstruction?: string;
}
export function TextArtifactRenderer({ content, isStreaming, readOnly = false }: TextArtifactRendererProps) {
export function TextArtifactRenderer({ content, isStreaming, readOnly = false, onSaveDraft }: TextArtifactRendererProps) {
const { artifact, setArtifact } = useCanvasGraphContext();
const { writingSelection } = useCanvasUIContext();
@@ -280,6 +282,7 @@ export function TextArtifactRenderer({ content, isStreaming, readOnly = false }:
markdown={content.fullMarkdown}
title={content.title}
readOnly={readOnly}
onSave={onSaveDraft}
/>
<div className="flex-1 flex min-h-0 overflow-hidden">
@@ -14,6 +14,7 @@ import {
Pilcrow,
Download,
Loader2,
Save,
} from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
@@ -71,14 +72,32 @@ interface WritingToolbarProps {
title?: string;
/** 历史回看等场景:仅保留导出,隐藏格式工具 */
readOnly?: boolean;
/** 提供后显示「保存」按钮,把当前正文持久化(历史回看可编辑场景) */
onSave?: (markdown: string, title?: string) => Promise<void>;
}
export function WritingToolbar({ editor, markdown, title, readOnly = false }: WritingToolbarProps) {
export function WritingToolbar({ editor, markdown, title, readOnly = false, onSave }: WritingToolbarProps) {
const [exporting, setExporting] = useState(false);
const [saving, setSaving] = useState(false);
if (!editor) return null;
const canExport = !exporting && !!markdown?.trim();
const canSave = !!onSave && !saving && !!markdown?.trim();
const handleSave = async () => {
if (!onSave || saving || !markdown?.trim()) return;
setSaving(true);
try {
await onSave(markdown, title);
toast.success("已保存修改");
} catch (err) {
console.error("[writing-save] failed", err);
toast.error("保存失败,请稍后重试");
} finally {
setSaving(false);
}
};
const handleExport = async () => {
if (!canExport) return;
@@ -240,8 +259,40 @@ export function WritingToolbar({ editor, markdown, title, readOnly = false }: Wr
<Strikethrough className="h-3.5 w-3.5" />
</ToolbarButton>
{/* Right side: export + model selector */}
{/* Right side: save + export + model selector */}
<div className="ml-auto flex items-center gap-1">
{onSave && (
<>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onMouseDown={(e) => {
e.preventDefault();
void handleSave();
}}
disabled={!canSave}
className={cn(
"inline-flex h-7 items-center justify-center gap-1 rounded-md px-2 text-xs font-medium transition-colors",
"bg-primary text-primary-foreground hover:bg-primary/90",
"disabled:pointer-events-none disabled:opacity-40",
)}
>
{saving ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Save className="h-3.5 w-3.5" />
)}
保存
</button>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">
保存修改到历史记录
</TooltipContent>
</Tooltip>
<Separator orientation="vertical" className="mx-0.5 h-4" />
</>
)}
<Tooltip>
<TooltipTrigger asChild>
<button
@@ -289,6 +289,8 @@ export function AIWritingProvider({ children }: { children: ReactNode }) {
// 是否加参考文献(正文 [n] + 文末「参考文献」列表);缺省 false 不改其它入口行为。
add_references: request.addReferences ?? false,
material_source: request.materialSource,
// 不收集素材,直接用通用知识写作:researcher 节点据此短路跳过检索
skip_research: request.skipResearch ?? false,
notebook_search_space_id: request.noteSearchSpaceId ?? null,
notebook_source_ids: request.noteSourceIds ?? null,
revision_count: 0,
@@ -328,6 +330,17 @@ export function AIWritingProvider({ children }: { children: ReactNode }) {
pausePoint: pausePoint as CompletedIntervention['pausePoint'],
action: payload.action,
};
// 捕获用户在该干预点输入的自然语言要求,供时间线以「我的要求」独立样式回显:
// 素材确认 → 补充检索内容(userQuery) / 大纲确认 → 修改意见(outlineFeedback) /
// 草稿确认 + 编辑意见处理 → 修改·审核意见(userRevisionNotes)
const userInput = (
payload.userQuery ??
payload.outlineFeedback ??
payload.userRevisionNotes ??
''
).trim();
if (userInput) record.userInput = userInput;
if (pausePoint === 'material_confirm') record.materials = state.materials;
else if (pausePoint === 'outline_confirm') record.outline = state.outline ?? undefined;
else if (pausePoint === 'draft_confirm') {
@@ -1,6 +1,6 @@
import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from 'react';
import { getAIWritingSession } from '../api/ai-writing-sessions';
import { getAIWritingSession, updateAIWritingDraft } from '../api/ai-writing-sessions';
import type { AIWritingSessionSummary } from '../api/ai-writing-sessions';
import { useCanvasGraphContext } from './CanvasGraphContext';
import { useCanvasUIContext } from './CanvasUIContext';
@@ -10,6 +10,8 @@ type AIWritingHistoryContextValue = {
isHistoryMode: boolean;
openHistory: (session: AIWritingSessionSummary) => Promise<void>;
closeHistory: () => void;
/** 历史回看时把用户修改后的正文保存回该会话(持久化到后端)。 */
saveHistoryDraft: (markdown: string, title?: string) => Promise<void>;
};
const AIWritingHistoryContext = createContext<AIWritingHistoryContextValue | null>(null);
@@ -52,14 +54,25 @@ export function AIWritingHistoryProvider({ children }: { children: ReactNode })
setIsDraftEditable(true);
}, [setArtifact, setIsDraftEditable]);
const saveHistoryDraft = useCallback(
async (markdown: string, title?: string) => {
if (!historySession) return;
const updated = await updateAIWritingDraft(historySession.id, markdown, title);
// 回写本地会话,保证后续重开/重命名读到最新成稿
setHistorySession((prev) => (prev && prev.id === updated.id ? updated : prev));
},
[historySession],
);
const value = useMemo(
() => ({
historySession,
isHistoryMode: historySession !== null,
openHistory,
closeHistory,
saveHistoryDraft,
}),
[historySession, openHistory, closeHistory],
[historySession, openHistory, closeHistory, saveHistoryDraft],
);
return (
@@ -39,6 +39,9 @@ export interface WritingFormValue {
setAddReferences: (v: boolean) => void;
materialSource: 'general' | 'notebook';
setMaterialSource: (v: 'general' | 'notebook') => void;
/** 不收集素材,直接用通用知识写作:勾选后跳过素材检索阶段(含素材确认暂停点)。默认关。 */
skipResearch: boolean;
setSkipResearch: (v: boolean) => void;
/** 我的空间(笔记本)来源:选中的 search_space_id;上提到 Context 以便左栏「笔记本」点击直接设为素材来源。 */
selectedNotebookId: number | null;
setSelectedNotebookId: (v: number | null) => void;
@@ -86,6 +89,7 @@ export function WritingFormProvider({
const [strictMode, setStrictMode] = useState(false);
const [addReferences, setAddReferences] = useState(true);
const [materialSource, setMaterialSource] = useState<'general' | 'notebook'>(initialMaterialSource ?? 'general');
const [skipResearch, setSkipResearch] = useState(false);
const [selectedNotebookId, setSelectedNotebookId] = useState<number | null>(initialNotebookId ?? null);
const [selectedDocumentIds, setSelectedDocumentIds] = useState<number[]>(initialDocumentIds ?? []);
const [modelName, setModelName] = useState('');
@@ -123,12 +127,13 @@ export function WritingFormProvider({
strictMode, setStrictMode,
addReferences, setAddReferences,
materialSource, setMaterialSource,
skipResearch, setSkipResearch,
selectedNotebookId, setSelectedNotebookId,
selectedDocumentIds, setSelectedDocumentIds,
modelName, setModelName,
applyProposal,
}),
[intent, formExpanded, showOutline, outlineText, articleType, wordCountInput, audience, strictMode, addReferences, materialSource, selectedNotebookId, selectedDocumentIds, modelName, applyProposal],
[intent, formExpanded, showOutline, outlineText, articleType, wordCountInput, audience, strictMode, addReferences, materialSource, skipResearch, selectedNotebookId, selectedDocumentIds, modelName, applyProposal],
);
return <WritingFormContext.Provider value={value}>{children}</WritingFormContext.Provider>;
@@ -70,6 +70,7 @@ const INITIAL_STATE: AIWritingState = {
streamingReviewItems: [],
revisionSummary: null,
pendingRevisionContext: null,
isRevising: false,
};
export { INITIAL_STATE };
@@ -118,7 +119,8 @@ export function reducer(prev: AIWritingState, action: Action): AIWritingState {
...(ns === 'writing_outline' ? { outlineStreamingText: '', outlineThinkingText: '' } : {}),
...(ns === 'writing_draft' ? { streamingDraftSections: [], streamingDraftTitle: '' } : {}),
...(ns === 'reviewing' ? { streamingReviewItems: [] } : {}),
...(ns === 'revising' ? { streamingDraftSections: [], streamingDraftTitle: '' } : {}),
// 进入「修改」:右侧画布恢复初始空占位(isRevising),不再显示旧稿/流式重写
...(ns === 'revising' ? { streamingDraftSections: [], streamingDraftTitle: '', isRevising: true } : {}),
completedInterventions: [...prev.completedInterventions, action.record],
...(action.pendingRevisionContext ? { pendingRevisionContext: action.pendingRevisionContext } : {}),
};
@@ -357,6 +359,8 @@ function handleEvent(prev: AIWritingState, event: ProgressEvent): AIWritingState
streamingDraftTitle: '',
revisionSummary: summary,
pendingRevisionContext: null,
// 修改完成:解除右侧空占位,展示新成稿
isRevising: false,
};
}
@@ -705,6 +709,8 @@ export type AIWritingValues = {
article_type_detail?: string;
/** 是否加参考文献(正文 [n] 角标 + 文末「参考文献」列表)。 */
add_references?: boolean;
/** 不收集素材,直接用通用知识写作(researcher 节点据此短路跳过检索)。 */
skip_research?: boolean;
status?: string;
material_package?: unknown;
current_outline?: unknown;
@@ -143,7 +143,7 @@ export default function ResearchTemplateEditorPage() {
const payload = toPayload(form);
if (editing && id) await update(id, payload);
else await create(payload);
navigate(LIST_PATH);
navigate(LIST_PATH, { replace: true });
} catch (e) {
setFormError(e instanceof Error ? e.message : '保存失败');
setSaving(false);
@@ -154,7 +154,7 @@ export default function ResearchTemplateEditorPage() {
<div className="flex h-full flex-col bg-background">
<header className="flex shrink-0 items-center justify-between border-b border-border px-5 py-3">
<div className="flex items-center gap-2">
<Button variant="ghost" size="icon-sm" onClick={() => navigate(LIST_PATH)}>
<Button variant="ghost" size="icon-sm" onClick={() => navigate(-1)}>
<ArrowLeft className="size-4" />
</Button>
<div>
@@ -168,7 +168,7 @@ export default function ResearchTemplateEditorPage() {
</div>
<div className="flex items-center gap-2">
{formError && <span className="text-sm text-destructive">{formError}</span>}
<Button variant="outline" size="sm" onClick={() => navigate(LIST_PATH)} disabled={saving}>
<Button variant="outline" size="sm" onClick={() => navigate(-1)} disabled={saving}>
取消
</Button>
<Button size="sm" onClick={() => void handleSave()} disabled={saving}>
@@ -14,6 +14,7 @@ import {
import { useResearchTemplates } from '../hooks/useResearchTemplates';
import { useArticleTypes } from '../hooks/useArticleTypes';
const AI_WRITING_PATH = '/page/canvas/ai-writing';
const NEW_PATH = '/page/canvas/research-templates/new';
const editPath = (id: string) => `/page/canvas/research-templates/edit/${id}`;
@@ -48,7 +49,11 @@ export default function ResearchTemplatesConfigPage() {
<div className="mx-auto h-full w-full max-w-4xl overflow-y-auto p-6">
<div className="mb-5 flex items-center justify-between">
<div className="flex items-center gap-2">
<Button variant="ghost" size="icon-sm" onClick={() => navigate(-1)}>
<Button
variant="ghost"
size="icon-sm"
onClick={() => navigate(AI_WRITING_PATH, { replace: true })}
>
<ArrowLeft className="size-4" />
</Button>
<div>
@@ -267,6 +267,9 @@ export interface AIWritingRequest {
addReferences?: boolean;
/** 检索类型:general=通用检索(素材收集专家按配置技能检索,默认知识库检索技能)/ notebook=我的空间 */
materialSource: "general" | "notebook";
/** 不收集素材,直接用通用知识写作:勾选后跳过整个素材检索流程(含素材确认暂停点),
* 直接进入大纲规划;后端强制 loose 写作模式。默认 false。 */
skipResearch?: boolean;
/** 笔记来源(可选):空间 search_space_id */
noteSearchSpaceId?: number;
/** 笔记来源(可选):空间内指定文档的 document_id 列表,空数组表示检索整个空间 */
@@ -316,6 +319,10 @@ export interface CompletedIntervention {
| "review_confirm"
| "section_help";
action: string;
/** 用户在该干预点输入的自然语言要求(补充检索内容 / 大纲修改意见 / 写作修改意见 /
* 编辑审核处理意见)。仅当用户实际输入了文字时存在,用于在时间线里以「我的要求」
* 独立样式回显——区别于 AI 步骤与绿色完成卡。 */
userInput?: string;
// material_confirm
materials?: AIMaterial[];
// outline_confirm
@@ -382,4 +389,7 @@ export interface AIWritingState {
skippedIssues: ReviewIssue[];
oldWordCount: number;
} | null;
/** 是否正按用户意见修改中(revising 提交起 → draft_ready 止)。
* 为 true 时右侧画布恢复初始空占位,不展示旧稿 / 流式重写过程。 */
isRevising: boolean;
}
+150
View File
@@ -31,11 +31,32 @@ import { saveLastAgentId, useThreadSettings } from "@/core/settings";
import { stopThreadRun, useThreadStream } from "@/core/threads/hooks";
import { textOfMessage } from "@/core/threads/utils";
import { OptType, writeYLog } from "@/core/log/y-log";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
renderTemplateFill,
useHtmlTemplates,
type TemplateRenderResult,
} from "@/core/scheduled-tasks";
import { env } from "@/env";
import { readTaskContext, taskThreadMetadata } from "@/core/auth/task-purpose";
import { TaskPurposePanel } from "@/strategy-components/components/task-components/TaskPurposePanel";
import { desensitize } from "@/lib/desensitize";
/** 内置「模板数据转换器」agent 的稳定 id。仅此 agent 的对话页显示「页面模板 + 渲染预览」调试入口。 */
const TEMPLATE_BUILDER_AGENT_ID = "template-json-builder";
export default function AgentChatPage({
embeddedAgentId,
chatBasePath,
@@ -190,6 +211,41 @@ export default function AgentChatPage({
},
});
// ── 「模板数据转换器」对话页专属调试入口(仅此 agent)──────────────────────────
// 选一个页面模板,问答跑完后点「渲染预览」:取最近一条 AI 回复里的 JSON 填进模板,
// 弹出 HTML 预览 + 结构校验信息,用于调试该智能体的转换效果。
const isTemplateBuilder = agent_id === TEMPLATE_BUILDER_AGENT_ID;
const { data: debugTemplates = [] } = useHtmlTemplates(isTemplateBuilder);
const [debugTemplateId, setDebugTemplateId] = useState("");
const [debugPreviewOpen, setDebugPreviewOpen] = useState(false);
const [debugBusy, setDebugBusy] = useState(false);
const [debugPreview, setDebugPreview] = useState<TemplateRenderResult | null>(
null,
);
const runDebugPreview = useCallback(async () => {
if (!debugTemplateId) return;
const lastAi = [...thread.messages]
.reverse()
.find((m) => (m as { type?: string }).type === "ai");
const text = (lastAi ? textOfMessage(lastAi) : "") ?? "";
setDebugBusy(true);
setDebugPreview(null);
setDebugPreviewOpen(true);
try {
const result = await renderTemplateFill(debugTemplateId, text);
setDebugPreview(result);
} catch (error) {
setDebugPreview({
ok: false,
errors: [error instanceof Error ? error.message : "渲染失败"],
gaps: [],
html: null,
});
} finally {
setDebugBusy(false);
}
}, [debugTemplateId, thread.messages]);
const handleSubmit = useCallback(
(message: PromptInputMessage) => {
setHasStartedConversation(true);
@@ -327,6 +383,36 @@ export default function AgentChatPage({
</div>
<div className="flex items-center">
{/* 「模板数据转换器」专属:选页面模板 + 渲染预览(调试该智能体效果)。 */}
{isTemplateBuilder && (
<div className="mr-2 flex items-center gap-1.5">
<Select
value={debugTemplateId || undefined}
onValueChange={setDebugTemplateId}
>
<SelectTrigger className="h-8 w-[150px] text-xs">
<SelectValue placeholder="选择页面模板" />
</SelectTrigger>
<SelectContent position="popper">
{debugTemplates.map((tpl) => (
<SelectItem key={tpl.id} value={tpl.id}>
{tpl.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Tooltip content="用最近一条回复里的 JSON 填进所选模板并预览">
<Button
size="sm"
variant="secondary"
disabled={!debugTemplateId || debugBusy}
onClick={runDebugPreview}
>
{debugBusy ? "渲染中…" : "渲染预览"}
</Button>
</Tooltip>
</div>
)}
{/* 3qfx/xdfx 任务深链:用户主动「新建对话」进问答页后,点此发出任务开场信息开始问答。
首次进入且无历史(taskAutoStart)会自动发,不显示此按钮。 */}
{taskStartText && isLandingState && !taskAutoStart && (
@@ -489,6 +575,70 @@ export default function AgentChatPage({
showRecentChats={!taskCtxOn}
/>
</div>
{/* 「模板数据转换器」调试预览弹窗(仅此 agent)。 */}
{isTemplateBuilder && (
<Dialog open={debugPreviewOpen} onOpenChange={setDebugPreviewOpen}>
<DialogContent className="flex h-[88vh] w-[92vw] max-w-[92vw] flex-col gap-0 p-0">
<DialogHeader className="border-b px-4 py-2">
<DialogTitle className="text-sm">
模板填充预览 · 渲染结果
</DialogTitle>
</DialogHeader>
<div className="max-h-28 overflow-y-auto border-b px-4 py-2 text-xs">
{debugBusy && (
<span className="text-muted-foreground">正在渲染…</span>
)}
{!debugBusy && debugPreview && (
<div className="flex flex-col gap-1">
<span
className={
debugPreview.html
? debugPreview.ok
? "text-emerald-600"
: "text-amber-600"
: "text-destructive"
}
>
{debugPreview.display_only
? "该模板为「只展示」,已原样输出现有数据"
: debugPreview.html
? debugPreview.ok
? "✓ 结构校验通过"
: "⚠ 结构校验有问题(已照常填充,可对照下方修正后再让智能体重答)"
: "✗ 渲染失败"}
</span>
{debugPreview.errors.length > 0 && (
<span className="text-destructive">
错误:{debugPreview.errors.join(";")}
</span>
)}
{debugPreview.gaps.length > 0 && (
<span className="text-muted-foreground">
缺失字段:{debugPreview.gaps.slice(0, 20).join("、")}
{debugPreview.gaps.length > 20 ? " …" : ""}
</span>
)}
</div>
)}
</div>
<div className="min-h-0 flex-1 bg-white">
{debugPreview?.html ? (
<iframe
title="模板填充预览"
srcDoc={debugPreview.html}
sandbox="allow-scripts"
className="size-full border-0"
/>
) : (
<div className="text-muted-foreground flex size-full items-center justify-center text-sm">
{debugBusy ? "正在渲染…" : "无可预览内容"}
</div>
)}
</div>
</DialogContent>
</Dialog>
)}
</ThreadContext.Provider>
);
}
+20 -1
View File
@@ -87,11 +87,30 @@ export default function LoginPage() {
// 优先级最高:显式指定即按它跳,先于 taskId/goPath 深链与默认首页。出于安全只接受**站内
// 相对路径**:拒绝带协议的绝对 URL(http(s):、javascript: 等)、协议相对 URL(//host)与反斜杠,
// 避免开放式跳转;缺前导斜杠时自动补全。
//
// 参数透传:登录 URL 上「非登录自身消费」的其余 query(如 taskId / hideChrome)会被透传到
// 目标页 query——这样 `?redirect=/page/strategy/dashboard-agent&taskId=3334` 也能让目标页拿到
// taskId,无需把它编进 redirect 值里。目标已自带的同名参数优先(不覆盖);登录鉴权类参数
// (token/authToken/ticket 等)一律**不透传**,避免泄漏到应用页地址栏。
const redirectTo = useMemo(() => {
const raw = searchParams.get('redirect')?.trim() ?? '';
if (!raw) return '';
if (/^[a-z][a-z0-9+.-]*:/i.test(raw) || raw.startsWith('//') || raw.includes('\\')) return '';
return raw.startsWith('/') ? raw : `/${raw}`;
const normalized = raw.startsWith('/') ? raw : `/${raw}`;
const [pathOnly, ownQuery = ''] = normalized.split('?');
const target = new URLSearchParams(ownQuery);
// 登录流程自身消费的参数,不透传到目标页(尤以鉴权类为甚)。
const LOGIN_OWNED = new Set([
'redirect', 'ticket', 'userId', 'env', 'theme', 'yUserId',
'token', 'access_token', 'password', 'authToken', 'goPath', 'from',
]);
new URLSearchParams(searchParams.toString()).forEach((value, key) => {
if (LOGIN_OWNED.has(key)) return;
if (target.has(key)) return; // 目标自带优先
target.set(key, value);
});
const qs = target.toString();
return qs ? `${pathOnly}?${qs}` : pathOnly;
}, [searchParams]);
const externalParams = useMemo(
() => ({
+52 -1
View File
@@ -133,6 +133,8 @@ const emptyForm = {
html_negative_prompt: "",
html_reference_favorite_id: "",
html_model_name: "",
// 参考选了内置模板(tpl:<id>)时的「数据抽取」开关:关闭=用模板默认数据,开启=检索抽取。
html_template_extract: false,
};
/** 覆盖 Textarea 默认 field-sizing-content,避免输入过多时撑破弹窗 */
@@ -320,6 +322,7 @@ export default function ScheduledTasksPage() {
html_negative_prompt: html.negative_prompt ?? "",
html_reference_favorite_id: html.reference_favorite_id ?? "",
html_model_name: html.model_name ?? "",
html_template_extract: !!html.template_extract,
});
// Prefer the canonical cron_expr over the user-typed schedule_text: the
// latter may be natural language ("每天9点") that parseCron can't read,
@@ -341,7 +344,10 @@ export default function ScheduledTasksPage() {
// 模板填充模式:选中内置「模板数据转换器」agent。此时表单露出 HTML 模板下拉,
// 并隐藏「必须生成 HTML/Markdown」开关(本模式必产出 HTML)。
const isTemplateAgent = form.execution_agent_name === TEMPLATE_BUILDER_AGENT_ID;
const { data: htmlTemplates = [] } = useHtmlTemplates(isTemplateAgent);
// 模板清单:模板填充模式需要(选模板),require_html 模式也需要(作为「参考收藏页面」选项)。
const { data: htmlTemplates = [] } = useHtmlTemplates(
isTemplateAgent || form.require_html,
);
const selectedTemplate = htmlTemplates.find((t) => t.id === form.template_id);
// 选中的模板是「只展示」型(convertible===false)时,不需要源 JSON,隐藏上传/输入。
const templateDisplayOnly =
@@ -385,6 +391,10 @@ export default function ScheduledTasksPage() {
// Send the user's selection; fall back to the first configured model,
// else null (backend then uses the system default). No hardcoded GLM-5.
model_name: form.html_model_name.trim() || availableModels[0]?.name || null,
// 仅当参考是内置模板(tpl:<id>)时有意义:关闭=用模板默认数据,开启=检索抽取。
template_extract: form.html_reference_favorite_id.startsWith("tpl:")
? form.html_template_extract
: false,
},
};
};
@@ -1305,6 +1315,30 @@ export default function ScheduledTasksPage() {
>
不参考(默认)
</button>
{/* 内置 HTML 模板作为风格参考:值存为 ``tpl:<id>``,生成时参照其 CSS/版式。 */}
{htmlTemplates.map((tpl) => {
const refValue = `tpl:${tpl.id}`;
const selected = form.html_reference_favorite_id === refValue;
return (
<button
key={refValue}
type="button"
onClick={() =>
setForm((value) => ({
...value,
html_reference_favorite_id: refValue,
}))
}
className={cn(
"rounded px-2 py-1.5 text-left text-sm transition-colors hover:bg-accent/60",
selected && "bg-accent",
)}
title={`参照模板「${tpl.name}」的视觉风格与版式`}
>
模板:{tpl.name}
</button>
);
})}
{htmlFavorites.map((fav) => {
const selected = form.html_reference_favorite_id === fav.id;
return (
@@ -1343,6 +1377,23 @@ export default function ScheduledTasksPage() {
)}
</div>
</div>
{/* 参考选了内置模板(tpl:<id>)时:数据抽取开关。关闭=用模板默认数据。 */}
{form.html_reference_favorite_id.startsWith("tpl:") && (
<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">执行数据抽取</p>
<p className="text-muted-foreground text-xs">
关闭则直接用所选模板的默认数据生成(含全部子页面);开启才检索并按模板结构抽取数据填充。
</p>
</div>
<Switch
checked={form.html_template_extract}
onCheckedChange={(v) =>
setForm((value) => ({ ...value, html_template_extract: v }))
}
/>
</div>
)}
<div className="flex flex-col gap-1">
<p className="text-muted-foreground text-xs">生成模型</p>
<Select
+5 -1
View File
@@ -278,6 +278,10 @@ Seeding mirrors the roundtable pattern: verified `config.yaml` + `SOUL.md` ship
**Agentic skill execution (`ai_writing.researcher_skill_agent`, default false).** By default a configured non-native skill is *not* run agent-style — the node just turns its `description` into a query for the generic search provider. With this flag on, the directed-skill branch instead runs `nodes/_skill_agent.py::run_skill_search_agent`: it injects the skill's **SKILL.md** (read from `skill.skill_file`) alongside the researcher SOUL as the system prompt, binds a `search` tool to the researcher model, and runs a bounded (`_MAX_TURNS=4`) tool-call loop so the model drives the retrieval per the skill's instructions; the `SearchResult`s the tool returns are harvested directly as materials (no fuzzy text extraction). Requires a tool-calling model; if the model can't tool-call (returns no `tool_calls`) or the agent yields nothing, `_execute_skill` falls back to the legacy description→query search so materials are never empty. The built-in `knowledge-base-search` native concurrent path is unchanged. Tests: `tests/test_ai_writing_skill_agent.py`.
**Targeted user revision (局部修改 — 只改命中章节).** When the user submits a free-text 修改意见 on the draft-confirm card (`action == "user_revise"`), `writer_draft_node` no longer always rewrites the whole article section-by-section. Before the full-rewrite loop it tries `_targeted_user_revision` (`nodes/writer_draft.py`): `_split_draft_sections` parses the previous `current_draft.full_markdown` into `(article_title, [(section_title, body)], references_block)` (level-1 `#` title, level-2 `##` sections, trailing `## 参考文献` pulled out and kept verbatim), then `_detect_revision_scope` runs a small `llm_json` classifier (prompts `DRAFT_REVISE_SCOPE_{SYSTEM,USER}`) returning **per-section targets** `[{index, new_title, rewrite_body}]`: it maps the note to the section indices it targets ("第一段/开头/引言"→0, "结尾/结论"→last, by title/topic) **and** distinguishes a **章节标题改名** (`new_title` set, `rewrite_body=false` → rename only, body untouched) from a **正文修改** (`rewrite_body=true`). Title-only renames apply directly to the assembled `## {new_title}` heading **and are written back into `current_outline.sections[i].section_title`** (so the renamed title persists through confirm/finalize/history — this fixes "改章节标题不生效"). Body rewrites run `_stream_revise_section` (prompt `DRAFT_REVISE_SECTION_USER`, streamed as `draft_chunk`). **Only hit sections are touched; unhit sections are preserved byte-for-byte and are NOT re-emitted to the stream** — the frontend clears the right canvas to its initial placeholder on entering `revising` and shows only the live rewrite of the changed section(s), so the user sees「清空 → 流式重写 → 终稿」rather than the whole article rebuilding. The classifier returns `whole_article=true` (整体性意见 like 「整体语气更正式」/「全文都改」, **structure changes** 加章/删章/调整顺序, or a **文章总标题** change) → `_detect_revision_scope` yields `None` → `_targeted_user_revision` returns `None` → the node **falls back to the whole-article rewrite** (which still runs `_revise_outline_with_notes` for title/structure changes). Also falls back when there is no prior draft, an empty note, or scope-detection errors. Editor-review revisions (`accept_review`) keep the holistic rewrite. Reassembly preserves the article title + structure and re-appends the original references block; word count is computed on the body (excluding references). Frontend: `isRevising` in `AIWritingState` (`types/ai-writing.ts`) is set in `useAIWritingStream.ts`'s `intervention_submitted` (`ns === 'revising'`) and cleared on `draft_ready`; `AIWritingDraftPanel.tsx` shows the initial `DraftWaitingPlaceholder` while `isRevising && streamingDraftSections.length === 0`, then the streaming preview once chunks arrive. Tests: `tests/test_ai_writing_targeted_revision.py`.
**History draft editing (历史回看可编辑成稿).** The right-side draft in 历史回看 is no longer read-only — the user can edit it like the live 写作完成态 (Tiptap + 目录 + 格式工具栏 + AI 润色) and **persist** the change back to the session. `PUT /api/ai-writing/sessions/{id}/draft` `{draft_markdown, draft_title?}` (owner-checked → 404 otherwise; `draft_title` omitted ⇒ keep原标题) updates the row via the existing `AIWritingSessionRepository.update`. Frontend: `AIWritingDraftPanel` now passes `readOnly={false}` + an `onSaveDraft` handler (only in history mode) → `TextArtifactRenderer` → `WritingToolbar` 「保存」button (loading + toast); the handler is `AIWritingHistoryContext.saveHistoryDraft(markdown, title?)` → `updateAIWritingDraft` (`api/ai-writing-sessions.ts`), which write-backs the returned summary to `historySession`. Edits to the **live** completed draft stay ephemeral (export-only) as before — only history mode shows 保存. Tests: `tests/test_ai_writing_draft_update.py`.
**Background suspend (后台挂起).** A writing session can be handed off to the server to auto-drive the remaining flow to finalize, surviving page leave / refresh / account switch. `POST /api/ai-writing/sessions/{id}/background` (owner-checked, idempotent) starts `AIWritingJobExecutor` (`app/gateway/ai_writing_job_executor.py`), wired in `deps.py` as `app.state.ai_writing_job_executor`. The executor **drives the same `ai_writing` graph in-process** — it builds `make_ai_writing_graph()`, attaches the shared `app.state.checkpointer` (the very saver the runtime worker uses, so it continues the real thread state the frontend already wrote), sets the owning user via `set_current_user`, and loops `aget_state` → `ainvoke(Command(resume=…))` until `status==done`. **Auto-action per interrupt**: material/outline confirm → `confirm`; section_help → every blocked section `loose` (continue with general knowledge, never re-loop to research); draft_confirm → `to_editor` until reviewed, then `finalize` (on pass, or when `revision_count >= max_revisions`); review_confirm → `accept_review` (revise & re-review until pass or budget exhausted, then forced finalize). To avoid racing a still-running runtime worker (suspend mid-node), the driver **waits for an actionable state** (`_wait_for_actionable`): it only acts when the graph is at an interrupt / done, or when the checkpoint stops advancing (no worker → abandoned/restart). No new table — the graph checkpoint + `ai_writing_sessions` already hold all state; `status="background"` marks 挂起中 (surfaced in the history dropdown), nodes write `draft`/`review_result`/`status=done` as usual, so the user reopens the finished article from history. Per-user concurrency cap `AI_WRITING_BACKGROUND_SLOTS_PER_USER` (default 2, same SQLite-lock rationale as the roundtable executor). Startup `reconcile` (in `deps.py`) relaunches drivers for sessions left `background` after a restart (`AIWritingSessionRepository.list_by_status`). **Progress flowchart popup**: `GET /api/ai-writing/sessions/{id}/progress` (owner-checked) reads the graph checkpoint via `executor.get_progress` and derives a canonical pipeline `stage` key (`intent/research/material_confirm/outline/outline_confirm/draft/draft_confirm/review/done`, from the pending interrupt's `pause_point` or `snapshot.next` node) + `note` (素材不足求助 / 编辑打回) + review progress + `isRunning`. Frontend: 「后台挂起」 button + auto-suspend on route unmount / `beforeunload` (`AIWritingPanel.tsx`, `suspendAIWritingToBackground[Keepalive]` in `api/ai-writing-sessions.ts`); 挂起中 badge in `AIWritingHistoryDropdown.tsx`, and **clicking a 挂起中 history record opens a flowchart dialog** (`AIWritingFlowDialog.tsx` polls `getAIWritingProgress` every 2.5s, renders the fixed vertical pipeline `AIWritingFlowChart.tsx` lit by the live `stage`) so the user sees how far the background run has gotten without reopening/taking over the session. **Transcript consistency**: the rich history timeline (`AIWritingHistoryView` → `AIWritingTimeline`) replays `transcript = {progressEvents, completedInterventions}`, normally assembled+saved client-side. A background run has no client, so the executor rebuilds it at the end (`_save_transcript`/`_build_transcript_from_checkpoint`) **entirely from the final checkpoint** — both `progressEvents` and `completedInterventions` are derived from the same set of `resumed` events (every pause-resume emits exactly one), so they are always 1:1 aligned. For each `resumed`: classify its message → `(pausePoint, action)` (`_classify_resumed`, matched against `graph.py`'s resume messages), insert an `await_user` (with the pause prompt) before it, and build a **rich** intervention card enriched from final state (material_confirm→`materials`, outline_confirm→`outline`, draft_confirm→`draftTitle`/`draftWordCount`/`reviewResult`, review_confirm→`reviewResult`); `materials_ready` is inserted before the first material-confirm. (Earlier code merged the frontend's pre-suspend `completedInterventions` with auto ones and aligned by index — but the frontend's last save can predate its most recent decision, so the counts drifted and cards rendered off-by-one with empty data; deriving everything from the checkpoint root-fixes both.) Raw (snake_case) events **and** intervention data are normalized on load by the **exported** `normalizeProgressDict` / `normalize{Material,Outline,ReviewResult}` (idempotent on already-camelCase live transcripts — one normalize path for both sources), so reopening a background-completed session shows the same cards as a frontend-driven one. Tests: `tests/test_ai_writing_background.py`.
### Admin Leaderboard Snapshots (`/api/admin/users/leaderboard*`)
@@ -300,7 +304,7 @@ 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 the source (the task `prompt`, pasted or uploaded .json) — which may be **pure JSON, plain text, or text-with-embedded-JSON** (`template_fill.extract_embedded_json` pre-extracts any embedded `{…}`/`[…]` with a string-aware balanced scan and surfaces it as a labeled block while keeping the full original text; plain text → no block, the model reads it semantically) → 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** + the recognized-JSON block + 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`.
**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 the source (the task `prompt`, pasted or uploaded .json) — which may be **pure JSON, plain text, or text-with-embedded-JSON** (`template_fill.extract_embedded_json` pre-extracts any embedded `{…}`/`[…]` with a string-aware balanced scan and surfaces it as a labeled block while keeping the full original text; plain text → no block, the model reads it semantically) → 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** + the recognized-JSON block + 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,convertible}]}` (open). **Debug-render API** `POST /api/scheduled-tasks/template-fill/render` `{template_id, text?|data?}` → `{ok, display_only?, errors, gaps, html}` (open): no model call — `extract_embedded_json(text)` (or `data`) → `validate_data` → `fill_template`, returning the filled HTML **plus** the validation errors/field-gaps (fills even when invalid so you can see the effect). Powers the chat-page debug button below; non-convertible templates return their raw HTML. **Templates as `require_html` 参考收藏页面 (use the REAL template, all sub-pages)**: the 参考收藏页面 list in the task form also offers the templates (value `tpl:<id>`, e.g. `tpl:no1`). These templates are **JS-driven multi-page SPAs** (sub-pages/tabs are rendered by script from `DATA`), so a free-form model render would only ever produce the main page and **drop every sub-page**. Therefore, when `_run_html_page_attempt` sees a `tpl:<id>` reference (`template_fill.reference_template_id` parses it; distinguishes a template marker from a real favorite id), it short-circuits **before** the free-form pipeline to `_run_html_template_reference`, which renders the **actual template file** (all sub-pages intact): convertible templates gather real data (`_gather_html_data`) → convert to the template `DATA` (`_convert_to_template_data`, shared with template-fill mode) → **`merge_with_reference`** (per top-level key = per sub-page: any sub-page/field with no extracted content falls back to the template's **original** data, so sub-pages never render blank) → `fill_template`; non-convertible templates (no.2) output the raw template as-is; if the task prompt **and** description are both empty, or gather/conversion yields nothing, it uses the template's original data outright. **数据抽取开关** `html_cLine truncated
### Per-user Prompts & UI Preferences
@@ -33,6 +33,13 @@ class RenameSessionRequest(BaseModel):
title: str
class UpdateDraftRequest(BaseModel):
# 历史回看时用户在右侧编辑器里修改后的正文 Markdown
draft_markdown: str
# 可选:标题一并更新(用户改了文章标题时)
draft_title: Optional[str] = None
class AdminCleanupRequest(BaseModel):
"""手动清理接口的可选覆盖参数。
@@ -195,6 +202,28 @@ async def rename_session(session_id: str, body: RenameSessionRequest, request: R
return updated
@router.put("/sessions/{session_id}/draft", response_model=SessionResponse)
async def update_session_draft(session_id: str, body: UpdateDraftRequest, request: Request):
"""更新会话的成稿正文(历史回看时用户在右侧编辑器里直接修改并保存)。"""
from app.gateway.deps import get_ai_writing_session_store, get_current_user
repo = get_ai_writing_session_store(request)
if repo is None:
raise HTTPException(status_code=503, detail="Session store not available")
user_id = await get_current_user(request)
# draft_title 缺省(None)时不动原标题,传了才更新
extra = {} if body.draft_title is None else {"draft_title": body.draft_title}
updated = await repo.update(
session_id,
user_id=user_id,
draft_markdown=body.draft_markdown,
**extra,
)
if updated is None:
raise HTTPException(status_code=404, detail="Session not found")
return updated
@router.delete("/sessions/{session_id}")
async def delete_session(session_id: str, request: Request):
"""删除历史会话。"""
@@ -221,6 +221,50 @@ async def list_html_templates() -> dict:
return {"templates": template_fill.list_templates()}
class TemplateRenderRequest(BaseModel):
"""「模板数据转换器」对话页的调试渲染入参。"""
template_id: str
# 智能体回复原文(含转换后的 JSON);后端用 extract_embedded_json 抽取后填充。
text: str | None = None
# 或直接给已解析好的对象(优先于 text)。
data: Any | None = None
@router.post("/template-fill/render")
async def render_template_fill(body: TemplateRenderRequest) -> dict:
"""调试用:把一段(智能体回复的)内容里的 JSON 填进所选 HTML 模板并返回完整 HTML。
供「模板数据转换器」对话页的「渲染预览」按钮调试智能体效果——不调模型,只做
抽取 → 校验 → 填充,把校验错误/缺失字段一并返回方便定位。「只展示」模板原样返回。
开放给任意登录用户。
"""
from deerflow.runtime.scheduler import template_fill
spec = template_fill.get_template(body.template_id)
if spec is None:
raise HTTPException(status_code=404, detail=f"未知模板:{body.template_id}")
# 「只展示」模板(no.2 等):原样返回模板 HTML。
if not spec.convertible:
html = template_fill.read_template_text(body.template_id)
return {"ok": True, "display_only": True, "errors": [], "gaps": [], "html": html}
obj = body.data
if obj is None and body.text:
obj = template_fill.extract_embedded_json(body.text)
if obj is None:
return {"ok": False, "errors": ["未从内容中识别到可用的 JSON"], "gaps": [], "html": None}
errors = template_fill.validate_data(body.template_id, obj)
gaps = template_fill.field_coverage_gaps(body.template_id, obj)
try:
html = template_fill.fill_template(body.template_id, obj)
except template_fill.TemplateError as exc:
return {"ok": False, "errors": [str(exc)], "gaps": gaps, "html": None}
return {"ok": not errors, "errors": errors, "gaps": gaps, "html": html}
@router.get("")
async def list_scheduled_tasks(
request: Request,
@@ -239,6 +239,7 @@ _CONTEXT_CONFIGURABLE_KEYS: frozenset[str] = frozenset(
"is_bootstrap",
"is_writing_setup",
"valid_article_types",
"writing_setup_quick",
"excluded_tools",
"skill_stop_names",
"skill_stop_message",
+12 -12
View File
@@ -10,18 +10,18 @@ gateway:
port: 8001
models:
# - name: deepseek-chat
# display_name: DeepSeek Chat
# use: langchain_openai:ChatOpenAI
# model: deepseek-chat
# api_key: sk-35345154983047aba9e12faa86c5f62c
# base_url: https://api.deepseek.com
# request_timeout: 600.0
# max_retries: 2
# max_tokens: 4096
# temperature: 1.0
# supports_vision: false
# supports_thinking: true
- name: deepseek-chat
display_name: DeepSeek Chat
use: langchain_openai:ChatOpenAI
model: deepseek-chat
api_key: sk-686b7bc000be4b7cbed6dccb3897c411
base_url: https://api.deepseek.com
request_timeout: 600.0
max_retries: 2
max_tokens: 4096
temperature: 1.0
supports_vision: false
supports_thinking: true
- name: zai-org/GLM-5-FP8
display_name: zai-org/GLM-5-FP8
@@ -62,6 +62,17 @@ async def pause_material_confirm(state: AIWritingState) -> dict:
approved_ids = set(user_input.get("approvedMaterialIds") or [m["id"] for m in pkg["materials"]])
filtered = [m for m in pkg["materials"] if m["id"] in approved_ids]
updated_pkg = {**pkg, "materials": filtered}
# 用户自带大纲:researcher 已在「搜集素材」阶段解析大纲并归类素材 → 跳过大纲生成步,
# 仅把被用户取消勾选的素材从各章节 material_ids 里剔除,直接进入大纲确认。
prebuilt = state.get("current_outline")
if prebuilt and (state.get("user_outline_raw") or "").strip():
return {
"material_package": updated_pkg,
"current_outline": _prune_outline_material_ids(prebuilt, approved_ids),
"last_user_intervention": user_input,
"status": "awaiting_outline_confirm",
"progress_events": [{"type": "resumed", "agent_name": "系统", "message": "已采用你提供的大纲并归类素材,请确认大纲"}],
}
return {
"material_package": updated_pkg,
"last_user_intervention": user_input,
@@ -90,6 +101,21 @@ async def pause_material_confirm(state: AIWritingState) -> dict:
}
def _prune_outline_material_ids(outline: dict, approved_ids: set) -> dict:
"""把大纲各章节 material_ids 里、用户在素材确认卡取消勾选的素材编号剔除掉。
用户自带大纲时素材归类发生在「搜集素材」阶段(researcher),早于素材确认;
用户在确认卡里去掉某些素材后,用这个函数把对应编号从大纲剔除,保持一致。
"""
if not outline:
return outline
sections = []
for s in outline.get("sections", []):
kept = [mid for mid in (s.get("material_ids") or []) if mid in approved_ids]
sections.append({**s, "material_ids": kept})
return {**outline, "sections": sections}
def _normalize_outline(outline: dict) -> dict:
"""将前端回传的 camelCase 大纲统一为后端 snake_case 格式。"""
if not outline:
@@ -318,7 +344,13 @@ def after_researcher(state: AIWritingState) -> str:
def after_material_pause(state: AIWritingState) -> str:
return "researcher" if state["status"] == "researching" else "writer_outline"
status = state["status"]
if status == "researching":
return "researcher"
# 用户自带大纲:researcher 已在搜集阶段整理好大纲 → 跳过 writer_outline 生成步,直接确认大纲。
if status == "awaiting_outline_confirm":
return "pause_outline"
return "writer_outline"
def after_outline_pause(state: AIWritingState) -> str:
@@ -396,9 +428,9 @@ def make_ai_writing_graph(config=None):
g.set_entry_point("intent_parser")
g.add_edge("intent_parser", "researcher")
g.add_conditional_edges("researcher", after_researcher,
{"pause_material": "pause_material", "writer_draft": "writer_draft"})
{"pause_material": "pause_material", "writer_draft": "writer_draft", "writer_outline": "writer_outline"})
g.add_conditional_edges("pause_material", after_material_pause,
{"researcher": "researcher", "writer_outline": "writer_outline"})
{"researcher": "researcher", "writer_outline": "writer_outline", "pause_outline": "pause_outline"})
g.add_edge("writer_outline", "pause_outline")
g.add_conditional_edges("pause_outline", after_outline_pause,
{"writer_outline": "writer_outline", "writer_draft": "writer_draft"})
@@ -184,6 +184,7 @@ async def stream_writing_agent(
on_text: Callable[[str, bool], None] | None = None,
explicit_model_fallback: str | None = None,
strict_model: bool = True,
disable_thinking: bool = False,
) -> str:
"""在「SOUL 作 system prompt + 单条 Human 输入」契约下流式运行一个写作 agent。
@@ -194,6 +195,9 @@ async def stream_writing_agent(
chunk 包装成本阶段的 custom 事件(outline_chunk / draft_chunk /
review_chunk ...)推给前端;None = 不透传(如 researcher 的辅助任务)。
explicit_model_fallback / strict_model: 见 resolve_agent_model_name。
disable_thinking: True 时强制关闭模型思考(force_disable_thinking),用于
素材收集/大纲整理这类不需要思考的阶段(GLM5 等爱出 <think>)。关不掉的
模型仍由下方 ThinkTagParser 把思考分流到思考框,不会泄进正文。
Returns:
累积的**正文**文本(<think> 思考与 reasoning_content 已剥离),供
@@ -212,7 +216,7 @@ async def stream_writing_agent(
from deerflow.models import create_chat_model
model = create_chat_model(name=model_name)
model = create_chat_model(name=model_name, force_disable_thinking=disable_thinking)
full_text = ""
parser = ThinkTagParser()
@@ -246,12 +250,15 @@ async def run_writing_agent(
on_text: Callable[[str, bool], None] | None = None,
explicit_model_fallback: str | None = None,
strict_model: bool = True,
disable_thinking: bool = False,
) -> dict:
"""跑一个写作 agent 并按 marker 契约解析其结构化输出(失败回 fallback)。
模型调用异常时与 ``_utils.llm_json`` 同口径:返回
``{**fallback, "_llm_error": str(e)}``,不上抛(SOUL 缺失除外——
WritingAgentUnavailable 原样上抛,由节点切回旧 prompt 路径)。
disable_thinking: 透传给 stream_writing_agent,强制关闭思考(见其文档)。
"""
try:
full_text = await stream_writing_agent(
@@ -262,6 +269,7 @@ async def run_writing_agent(
on_text=on_text,
explicit_model_fallback=explicit_model_fallback,
strict_model=strict_model,
disable_thinking=disable_thinking,
)
except WritingAgentUnavailable:
raise
@@ -40,6 +40,26 @@ def get_ai_writing_config() -> dict:
return {}
# 用户自带大纲时,检索词生成的大纲提示片段做长度截断,防止 token 溢出。
OUTLINE_KEYWORD_SNIPPET_CHARS = 1000
def keyword_outline_section(state: dict) -> str:
"""用户自带大纲时,给检索词生成附上大纲原文,让关键词覆盖各章节主题。
意图解析(intent_parser)与素材收集(researcher)的检索词生成共用,无大纲返回
空串(提示词里该行变空行,行为与原来一致)。
"""
raw = (state.get("user_outline_raw") or "").strip()
if not raw:
return ""
snippet = raw[:OUTLINE_KEYWORD_SNIPPET_CHARS]
return (
"用户已提供文章大纲,请结合写作意图,让检索词覆盖大纲涉及的各章节主题:\n"
f"{snippet}"
)
def safe_get_stream_writer():
"""返回一个 stream writer callable;在没有 LangGraph runtime 上下文时退化成 no-op。
@@ -63,15 +83,21 @@ def safe_get_stream_writer():
return lambda _payload: None
def get_model(config: RunnableConfig, model_name: str | None = None):
def get_model(config: RunnableConfig, model_name: str | None = None, *, disable_thinking: bool = False):
"""创建对话模型。
model_name 显式传入时优先使用(如素材收集专家的检索词/排序专用快模型),
否则回退到 configurable.model_name(用户在写作表单里选的主模型)。
disable_thinking=True 时强制关闭模型的思考/推理(force_disable_thinking):
对 OpenAI 兼容网关注入 GLM ``thinking.type=disabled`` / Qwen·vLLM
``enable_thinking=False`` 三种禁用形态,用于素材收集、大纲整理这类不需要思考的
结构化任务(GLM5 等内网模型尤其爱出 <think>)。关不掉的模型由各调用方的
ThinkTagParser 兜底,照常把思考分流到思考框。
"""
from deerflow.models import create_chat_model
name = model_name or config.get("configurable", {}).get("model_name", "") or None
return create_chat_model(name=name)
return create_chat_model(name=name, force_disable_thinking=disable_thinking)
def resolve_writing_model_name(config: RunnableConfig, state: dict) -> tuple[str | None, bool]:
@@ -316,16 +342,18 @@ class ThinkTagParser:
return 0
async def llm_json(system: str, user: str, fallback: dict, config: RunnableConfig, model_name: str | None = None) -> dict:
async def llm_json(system: str, user: str, fallback: dict, config: RunnableConfig, model_name: str | None = None, *, disable_thinking: bool = False) -> dict:
"""调用 LLM,解析 JSON 输出,失败用 fallback。
model_name 可覆盖默认模型(如检索词/排序使用配置的专用快模型)。
disable_thinking=True 时强制关闭思考(见 get_model),用于检索词/素材排序这类
结构化抽取任务,省掉 GLM5 等模型的 <think> 思考开销。
"""
messages = [SystemMessage(content=system), HumanMessage(content=user)]
try:
# get_model 放进 try:模型名在 config.yaml 找不到时 create_chat_model 会抛
# ValueError,若留在 try 外会逐层上抛使整个图硬崩溃。
model = get_model(config, model_name=model_name)
model = get_model(config, model_name=model_name, disable_thinking=disable_thinking)
resp = await model.ainvoke(messages)
return parse_json(_content_to_text(resp.content), fallback)
except Exception as e:
@@ -18,6 +18,7 @@ from ._model_fallback import (
from ._utils import (
ThinkTagParser,
_content_to_text,
keyword_outline_section,
parse_json,
reasoning_delta,
safe_get_stream_writer,
@@ -75,17 +76,18 @@ _DEFAULT_ARTICLE_TYPES: list[dict] = [
]
_SYSTEM_TEMPLATE = """你是一名写作助手。
你的任务:分析用户的写作意图,提取结构化的写作配置。
你的任务:分析用户的写作意图,提取结构化的写作配置,并顺便提炼用于搜集素材的搜索关键词。
【输出要求】
- 只输出一个 JSON 对象,不要任何解释文字,不要 markdown 代码块。
- 必须使用英文双引号 "。
- article_type 只能是以下之一:{type_options}。
- keywords 为字符串数组:依据写作意图(及用户提供的大纲,若有)提炼的搜索关键词,要具体、可搜索,避免过于宽泛。
【完整示例】
输入意图:帮我写一篇面向行业专家的深度文章,分析今年新能源汽车出口为什么大涨,大概两千字
正确输出:
{{"article_type": "feature", "target_audience": "行业专家", "word_count_target": 2000, "refined_intent": "分析今年新能源汽车出口高速增长的原因"}}"""
{{"article_type": "feature", "target_audience": "行业专家", "word_count_target": 2000, "refined_intent": "分析今年新能源汽车出口高速增长的原因", "keywords": ["新能源汽车出口", "汽车出口数据", "新能源汽车海外市场", "电动车出口增长"]}}"""
_USER_TEMPLATE = """用户写作意图:{user_intent}
@@ -93,13 +95,14 @@ _USER_TEMPLATE = """用户写作意图:{user_intent}
- 文章类型(article_type):{preconfigured_article_type}
- 目标读者(target_audience):{preconfigured_audience}
- 目标字数(word_count_target):{preconfigured_word_count}
请输出以下 JSON,article_type / target_audience / word_count_target 必须与上述一致,只需根据用户意图精炼 refined_intent:
{outline_section}
请输出以下 JSON:article_type / target_audience / word_count_target 必须与上述一致;根据用户意图精炼 refined_intent;并提炼 {keyword_count} 个用于搜集素材的搜索关键词放入 keywords(数量不多不少、具体可搜索):
{{{{
"article_type": "{preconfigured_article_type}",
"target_audience": "{preconfigured_audience}",
"word_count_target": {preconfigured_word_count},
"refined_intent": "精炼后的写作意图(一句话)"
"refined_intent": "精炼后的写作意图(一句话)",
"keywords": ["检索词1", "检索词2"]
}}}}"""
@@ -204,11 +207,19 @@ async def intent_parser_node(state: AIWritingState, config: RunnableConfig) -> d
if not isinstance(preconfigured_wc, int) or preconfigured_wc <= 0:
preconfigured_wc = 800
# 检索词数量(用户表单指定,1~10,默认 6);意图解析顺便按此数量生成检索词,
# 供 researcher 首轮直接复用,省掉素材收集阶段单独的关键词 LLM 调用。
raw_kc = state.get("keyword_count")
keyword_count = raw_kc if isinstance(raw_kc, int) and 1 <= raw_kc <= 10 else 6
user = _USER_TEMPLATE.format(
user_intent=state["user_intent"],
preconfigured_article_type=fallback_type,
preconfigured_audience=fallback_result["target_audience"],
preconfigured_word_count=preconfigured_wc,
keyword_count=keyword_count,
# 用户自带大纲时附上大纲原文,让检索词覆盖各章节主题(与 researcher 共用同一逻辑)
outline_section=keyword_outline_section(state),
)
# 流式调用:与 writer_outline / writer_draft 同套机制 —— LLM 逐字吐出 JSON,
@@ -312,4 +323,13 @@ async def intent_parser_node(state: AIWritingState, config: RunnableConfig) -> d
if final_refined_intent != state["user_intent"]:
updates["user_intent"] = final_refined_intent
# 意图解析顺便生成的检索词:清洗后存入 state,researcher 首轮直接复用。
# 解析失败/为空时不写该字段(保持 None),researcher 回退到自己生成(优雅降级)。
raw_keywords = result.get("keywords")
if isinstance(raw_keywords, list):
precomputed = [s for kw in raw_keywords if (s := str(kw).strip())]
precomputed = list(dict.fromkeys(precomputed))
if precomputed:
updates["precomputed_keywords"] = precomputed
return updates
@@ -41,7 +41,7 @@ from ._agent_runner import (
use_builtin_agent,
)
from ._skill_agent import run_skill_search_agent, use_skill_search_agent
from ._utils import get_ai_writing_config, llm_json, safe_get_stream_writer
from ._utils import get_ai_writing_config, keyword_outline_section, llm_json, safe_get_stream_writer
logger = logging.getLogger(__name__)
@@ -52,9 +52,9 @@ _AGENT_KEYWORDS_USER = """任务:提取检索词
写作意图:{user_intent}
文章类型:{article_type}
目标读者:{target_audience}
{outline_section}
请提取 {keyword_count} 个搜索关键词,用于搜集相关素材。
关键词数量必须为 {keyword_count} 个,不多不少;必须依据上述写作意图提炼真实检索词。
关键词数量必须为 {keyword_count} 个,不多不少;必须依据上述写作意图(及大纲,若有)提炼真实检索词。
严格按 SOUL 约定输出 [KEYWORDS_READY] + JSON。
/no_think"""
@@ -225,6 +225,59 @@ def _per_keyword_limit(max_materials: int, total_keywords: int) -> int:
return (max_materials + total_keywords - 1) // total_keywords
# 「再追加 N 条素材」之类的数量指令:动词 + 数字(+ 量词 + 名词)。
_APPEND_COUNT_VERBS = "追加|新增|增加|补充|再来|多来|多给|多找|多搜|多检索|再给|再要|再找|再搜|再加"
_APPEND_COUNT_RE = re.compile(
rf"(?:再|又|继续|另外?|额外)?\s*(?:{_APPEND_COUNT_VERBS})\s*"
r"(?:我|你|您|咱|大家|一下|一些|些|到)?\s*"
r"([0-9一二三四五六七八九十两]+)\s*(?:条|个|篇|份)?\s*(?:素材|资料|内容|结果|数据|文献)?"
)
_CN_DIGITS = {"零": 0, "一": 1, "两": 2, "二": 2, "三": 3, "四": 4, "五": 5, "六": 6, "七": 7, "八": 8, "九": 9}
def _cn_to_int(s: str) -> int | None:
"""把数量字符串解析成整数:阿拉伯数字直接转,常见中文数字(0~99)兜底。"""
if s.isdigit():
try:
return int(s)
except ValueError:
return None
if "十" in s: # 十 / 十X / X十 / X十Y
left, _, right = s.partition("十")
tens = _CN_DIGITS.get(left, 1) if left else 1
ones = _CN_DIGITS.get(right, 0) if right else 0
return tens * 10 + ones
if len(s) == 1 and s in _CN_DIGITS:
return _CN_DIGITS[s]
return None
def _parse_append_request(text: str) -> tuple[int | None, str]:
"""从用户的追加输入里解析「再追加 N 条素材」这类显式数量诉求。
返回 ``(count, residual_topic)``:
- ``count``:用户要求新增的素材条数(1~100),未显式说明 → ``None``。
- ``residual_topic``:剔除数量指令后剩下的检索主题。为空表示用户只给了数量、
没给新主题,调用方应回退到写作意图(而不是把「再追加10条素材」当检索词)。
"""
raw = (text or "").strip()
if not raw:
return None, ""
m = _APPEND_COUNT_RE.search(raw)
if not m:
return None, raw
count = _cn_to_int(m.group(1))
if count is None or count <= 0:
return None, raw
count = min(count, _MAX_MAX_MATERIALS)
residual = (raw[: m.start()] + raw[m.end():]).strip()
# 去掉残留的纯标点 / 语气词后,判断是否还剩真正的主题
residual_core = re.sub(r"[,,。.、;;::!!??\s的了吧呢啊]+", "", residual)
if len(residual_core) < 2:
residual = ""
return count, residual
def _notice(title: str, detail: str, raw: str = "", agent_name: str = "素材收集专家") -> dict:
"""构造一个非致命告警事件,前端在时间线显示「标题 + 真实原因」,不打断写作流程。
@@ -361,6 +414,15 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict
is_user_query_search = action in ("re_search", "enable_skill_search") and bool(user_query)
# 兼容旧版"追加关键词"调用方(如果还有 client 传 extra_keywords 数组而非 user_query)
is_append_only = action == "re_search" and not user_query and bool(extra_keywords)
# 用户在追加输入里显式说「再追加 N 条素材」:尊重这个数量。若只给了数量、没给新主题,
# 就按原写作意图继续检索,而不是把「再追加10条素材」这串话本身当成检索词。
requested_append_count: int | None = None
effective_user_query: str = user_query
if is_user_query_search:
requested_append_count, residual_topic = _parse_append_request(user_query)
if requested_append_count is not None:
effective_user_query = residual_topic or intent
# 用户在写作表单选择的检索类型:general(通用检索,按 agent 配置技能编排)/
# notebook(我的空间)。旧值 knowledge_base / skill 一律按 general 兼容。
raw_source = (state.get("material_source") or "general").strip()
@@ -399,49 +461,80 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict
def _emit(message: str) -> None:
writer({"type": "research_progress", "message": message})
# 跳过素材收集:用户在初始表单勾选「不收集素材,直接用通用知识写作」。
# 仅在**首次**进入(无任何 pause 干预 = intervention 为空)时短路,避免后续
# 「补充检索 / 按输入追加」等再入研究节点时被误跳过。直接产出空素材包、强制
# loose 写作模式(无素材可严格依据),并跳过素材确认暂停点直奔大纲规划。
if state.get("skip_research") and not intervention:
empty_pkg = MaterialPackage(keywords=[], materials=[], summary="用户选择不收集素材,直接用通用知识写作")
return {
"material_package": empty_pkg,
"writing_mode": "loose",
"status": "writing_outline",
"progress_events": [
{"type": "step_start", "agent_name": researcher_label, "message": "已跳过素材收集,直接用通用知识写作"},
{"type": "materials_ready", "agent_name": researcher_label, "message": "未收集素材(通用知识写作)"},
],
}
# Step 1: 提取关键词
kw_result: dict = {} # 走"用户输入 / 追加"分支时跳过 LLM 调用,notice 拼接需要兜底值
if is_user_query_search:
# 用户输入分支:把整段自然语言作为唯一检索词,尊重原意,不再做关键词拆分
keywords: list[str] = [user_query]
_emit(f"按你的输入「{user_query[:30]}」匹配技能检索…")
# 用户输入分支:把整段自然语言作为唯一检索词,尊重原意,不再做关键词拆分。
# 若用户只说了「再追加 N 条素材」,effective_user_query 已回退为写作意图。
keywords: list[str] = [effective_user_query]
_emit(f"按你的输入「{effective_user_query[:30]}」匹配技能检索…")
elif is_append_only:
# 兼容旧版追加模式:直接用用户填的关键词,不再调 LLM
keywords = _clean_keywords(extra_keywords, intent)
_emit(f"追加检索 {len(keywords)} 个关键词…")
else:
_emit("正在生成检索词…")
if use_agent:
kw_result = await run_writing_agent(
agent_id=RESEARCHER_AGENT_ID,
user_payload=_AGENT_KEYWORDS_USER.format(
user_intent=intent,
article_type=article_type,
target_audience=target_audience,
keyword_count=keyword_count,
),
marker=KEYWORDS_READY_MARKER,
fallback={"keywords": [intent]},
state=state,
config=config,
explicit_model_fallback=keyword_model,
strict_model=False,
)
# 首轮检索(无任何 pause 干预)且意图解析阶段已顺便生成检索词 → 直接复用,
# 省掉素材收集阶段单独的「生成检索词」LLM 往返。re_search(用户要求重新生成
# 检索词、覆盖式重检)等再入路径 intervention 非空,仍走下方正常生成。
precomputed = state.get("precomputed_keywords") or []
if precomputed and not intervention:
keywords = _clean_keywords(precomputed, intent)
_emit(f"复用意图解析阶段生成的 {len(keywords)} 个检索词…")
else:
kw_result = await llm_json(
EXTRACT_KEYWORDS_SYSTEM,
EXTRACT_KEYWORDS_USER.format(
user_intent=intent,
article_type=article_type,
target_audience=target_audience,
keyword_count=keyword_count,
),
fallback={"keywords": [intent]},
config=config,
model_name=keyword_model,
)
# 清洗关键词:剔除占位符 / 空值,全空时回退写作意图
keywords = _clean_keywords(kw_result.get("keywords"), intent)
_emit("正在生成检索词…")
# 用户自带大纲时,把大纲原文带进检索词生成,让关键词结合意图覆盖各章节主题
outline_section = keyword_outline_section(state)
if use_agent:
kw_result = await run_writing_agent(
agent_id=RESEARCHER_AGENT_ID,
user_payload=_AGENT_KEYWORDS_USER.format(
user_intent=intent,
article_type=article_type,
target_audience=target_audience,
keyword_count=keyword_count,
outline_section=outline_section,
),
marker=KEYWORDS_READY_MARKER,
fallback={"keywords": [intent]},
state=state,
config=config,
explicit_model_fallback=keyword_model,
strict_model=False,
disable_thinking=True,
)
else:
kw_result = await llm_json(
EXTRACT_KEYWORDS_SYSTEM,
EXTRACT_KEYWORDS_USER.format(
user_intent=intent,
article_type=article_type,
target_audience=target_audience,
keyword_count=keyword_count,
outline_section=outline_section,
),
fallback={"keywords": [intent]},
config=config,
model_name=keyword_model,
disable_thinking=True,
)
# 清洗关键词:剔除占位符 / 空值,全空时回退写作意图
keywords = _clean_keywords(kw_result.get("keywords"), intent)
if extra_keywords:
keywords = list(dict.fromkeys(keywords + extra_keywords))
@@ -511,6 +604,9 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict
configured = _load_configured_skills()
provider = get_search_provider(ai_writing_config)
per_skill = max(1, max_materials)
# 用户显式要「再追加 N 条」时,单技能至少检索 N 条,保证能凑够数量(末尾再截断到 N)
if requested_append_count:
per_skill = max(per_skill, requested_append_count)
ordered = configured
if is_user_query_search and len(configured) > 1:
@@ -521,7 +617,7 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict
model_name=keyword_model,
emit_step=_emit_skill_step,
max_select=5,
user_query=user_query,
user_query=effective_user_query,
candidates=configured,
)
ranked = await picker.pick(user_intent=intent)
@@ -633,7 +729,7 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict
skill=sk,
intent=intent,
keywords=keywords,
user_query=user_query if is_user_query_search else "",
user_query=effective_user_query if is_user_query_search else "",
config=config,
state=state,
model_name_fallback=keyword_model,
@@ -648,7 +744,7 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict
# 定向技能检索(默认路径 / 智能体模式回退):user_query 模式优先用用户原话;
# 非知识库技能再附上 description 让 query 贴合该技能的领域。
if is_user_query_search:
skill_query = user_query if is_knowledge else f"{user_query} {sk.description or ''}".strip()
skill_query = effective_user_query if is_knowledge else f"{effective_user_query} {sk.description or ''}".strip()
else:
skill_query = f"{intent} {sk.description or ''}".strip() or intent
_emit_skill_step({
@@ -681,13 +777,16 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict
"matched_count": added,
})
# 早停上限:通常为 max_materials;用户显式要「再追加 N 条」时放宽到 N,确保凑够
collect_limit = max(max_materials, requested_append_count or 0)
for sk in ordered:
added, error_detail = await _execute_skill(sk)
tried_names.add(sk.name)
_emit_skill_outcome(sk, added, error_detail)
# 早停:累积素材已达上限
if len(new_results) >= max_materials:
if len(new_results) >= collect_limit:
_emit_skill_step({
"type": "stop_early",
"message": f"已获取 {len(new_results)} 条素材,达到上限,停止调用后续技能",
@@ -738,6 +837,9 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict
appended_count = 0
for r in new_results:
# 用户显式要「再追加 N 条」时,凑够 N 条新素材即止,严格按用户要求的数量给
if requested_append_count and appended_count >= requested_append_count:
break
if r.url and r.url in seen_urls:
continue
if r.url:
@@ -783,6 +885,7 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict
config=config,
explicit_model_fallback=rank_model,
strict_model=False,
disable_thinking=True,
)
else:
rank_result = await llm_json(
@@ -791,6 +894,7 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict
fallback=rank_fallback,
config=config,
model_name=rank_model,
disable_thinking=True,
)
rank_error = rank_result.get("_llm_error", "")
ranked_ids: list[str] = rank_result.get("ranked_ids", [m["id"] for m in raw_materials])
@@ -856,13 +960,36 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict
step_start_msg = f"通用检索(技能编排 {len(skill_steps)} 步):{', '.join(keywords[:3])}"
else:
step_start_msg = f"搜索关键词:{', '.join(keywords[:3])}"
progress_events: list[dict] = [
{"type": "step_start", "agent_name": researcher_label, "message": step_start_msg},
*notices,
{"type": "materials_ready", "agent_name": researcher_label, "message": summary},
]
extra_updates: dict = {}
# 用户自带大纲:在「搜集素材」阶段顺便解析大纲并把素材归类到各章节,
# 从而跳过 writer_outline 的大纲生成步(见 graph after_material_pause 路由)。
# 补充检索(is_supplement)直接回写作、沿用原大纲,不在此整理。
if not is_supplement and (state.get("user_outline_raw") or "").strip():
from .writer_outline import build_user_outline_with_materials
try:
organized = await build_user_outline_with_materials(state, config, pkg)
except Exception:
logger.warning("researcher: 整理用户大纲失败(非致命),回退到大纲生成步", exc_info=True)
organized = None
if organized:
extra_updates["current_outline"] = organized
progress_events.append({
"type": "outline_ready",
"agent_name": researcher_label,
"message": f"已按你提供的大纲整理素材,共 {len(organized.get('sections', []))} 个章节",
})
return {
"material_package": pkg,
# 补充检索:直接回写作(沿用原大纲);常规检索:进入素材确认暂停点
"status": "writing_draft" if is_supplement else "awaiting_material_confirm",
"progress_events": [
{"type": "step_start", "agent_name": researcher_label, "message": step_start_msg},
*notices,
{"type": "materials_ready", "agent_name": researcher_label, "message": summary},
],
**extra_updates,
"progress_events": progress_events,
}
@@ -11,6 +11,7 @@ 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_COMPRESS_USER,
DRAFT_REVISE_SCOPE_SYSTEM, DRAFT_REVISE_SCOPE_USER, DRAFT_REVISE_SECTION_USER,
OUTLINE_REVISE_SYSTEM, OUTLINE_REVISE_USER,
WRITER_MODE_LOOSE, WRITER_MODE_STRICT, SECTION_BLOCKED_MARKER,
)
@@ -363,6 +364,283 @@ async def _save_draft_to_repo(config: RunnableConfig, draft: DraftArticle) -> No
logger.exception("writer_draft: save_draft failed (non-fatal) thread_id=%s", thread_id)
# ── 用户局部修改(只改命中章节,其余原样保留)────────────────────────────────
#
# 用户在草稿确认卡填「修改意见」(action=user_revise)时,若意见只指向部分章节
# (如「帮我修改一下第一段」),不再整篇逐节重写——先用 LLM 识别意见指向的章节,
# 只重写命中章节、其余章节字节级原样保留。意见偏整体性 / 无法定位时返回 None,由
# writer_draft_node 回退到既有的整篇重写路径。
# 文章标题(单 #)与章节标题(双 ##)的行级匹配。`^#[ \t]+` 不会命中 `## `(第 2 字符是
# `#` 非空白);`^##[ \t]+` 也不会命中 `### / ####`,互不串扰。
_ARTICLE_TITLE_RE = re.compile(r"^#[ \t]+(.+?)[ \t]*$", re.MULTILINE)
_SECTION_HEADING_RE = re.compile(r"^##[ \t]+(.+?)[ \t]*$", re.MULTILINE)
_REFERENCES_TITLE = "参考文献"
def _split_draft_sections(markdown: str) -> tuple[str, list[tuple[str, str]], str | None]:
"""把成稿 Markdown 拆成 (文章标题, [(章节标题, 章节正文), ...], 文末参考文献块或 None)。
章节正文不含 `## 标题` 行(与写作循环里的 section_bufs 语义一致)。文末「## 参考文献」
单独抽出原样保留,不计入可编辑章节。
"""
matches = list(_SECTION_HEADING_RE.finditer(markdown))
head = markdown[: matches[0].start()] if matches else markdown
m_title = _ARTICLE_TITLE_RE.search(head)
article_title = (m_title.group(1).strip() if m_title else "").strip()
sections: list[tuple[str, str]] = []
references_block: str | None = None
for i, m in enumerate(matches):
title = m.group(1).strip()
body_start = m.end()
body_end = matches[i + 1].start() if i + 1 < len(matches) else len(markdown)
if title == _REFERENCES_TITLE:
references_block = markdown[m.start():body_end].strip("\n")
continue
body = markdown[body_start:body_end].strip("\n")
sections.append((title, body))
return article_title, sections, references_block
async def _detect_revision_scope(
state: AIWritingState, config: RunnableConfig,
parsed_sections: list[tuple[str, str]], note: str,
) -> list[dict] | None:
"""LLM 识别意见指向哪些章节、各自怎么改(改标题 / 改正文)。
返回 ``[{"index": int, "new_title": str | None, "rewrite_body": bool}, ...]``,
整体性 / 改变结构 / 无法定位 / 异常一律返回 None(回退整篇重写)。
"""
listing = "\n".join(f"{i}. {t}" for i, (t, _b) in enumerate(parsed_sections))
model_name = (state.get("model_name") or "").strip() or None
n = len(parsed_sections)
try:
result = await llm_json(
DRAFT_REVISE_SCOPE_SYSTEM,
DRAFT_REVISE_SCOPE_USER.format(n=n, sections=listing, note=note),
{"whole_article": True, "targets": []},
config,
model_name=model_name,
)
except Exception as e:
logger.warning("draft targeted-revise: scope detection failed: %s", e)
return None
if not isinstance(result, dict) or result.get("whole_article"):
return None
by_idx: dict[int, dict] = {}
raw_targets = result.get("targets")
if isinstance(raw_targets, list) and raw_targets:
for item in raw_targets:
if not isinstance(item, dict):
continue
try:
idx = int(item.get("index"))
except (ValueError, TypeError):
continue
if not (0 <= idx < n):
continue
nt = item.get("new_title")
new_title = nt.strip() if isinstance(nt, str) and nt.strip() else None
rewrite_body = item.get("rewrite_body")
# 缺省:模型没给 rewrite_body 时——有新标题就当「只改标题」,否则当「改正文」
if rewrite_body is None:
rewrite_body = new_title is None
by_idx[idx] = {"index": idx, "new_title": new_title, "rewrite_body": bool(rewrite_body)}
else:
# 兼容旧结构 target_indexes:纯正文重写
raw_idx = result.get("target_indexes") or result.get("targetIndexes") or []
if isinstance(raw_idx, list):
for x in raw_idx:
try:
idx = int(x)
except (ValueError, TypeError):
continue
if 0 <= idx < n:
by_idx[idx] = {"index": idx, "new_title": None, "rewrite_body": True}
# 丢弃既不改标题也不改正文的空操作项
targets = [t for t in by_idx.values() if t["new_title"] is not None or t["rewrite_body"]]
return targets or None
async def _stream_revise_section(
state: AIWritingState, config: RunnableConfig, *,
article_title: str, idx: int, section_title: str, existing_text: str, key_points_str: str,
section_materials: str, note: str, add_references: bool,
writer, use_agent: bool, legacy_model_name: str | None,
) -> str:
"""流式重写单个命中章节并返回新正文(失败/为空时回退原文)。"""
msg = DRAFT_REVISE_SECTION_USER.format(
article_title=article_title or "文章",
section_title=section_title,
key_points=key_points_str,
section_materials=section_materials,
existing_text=existing_text,
note=note,
)
if add_references:
msg += "\n\n注意:保留原文中已有的引用角标(形如 [1]、[2]),不要删除或改动它们。"
def _on_text(text: str, is_thinking: bool, _i: int = idx, _t: str = section_title) -> None:
writer({
"type": "draft_chunk", "section_index": _i, "section_title": _t,
"text": text, "is_thinking": is_thinking, "is_section_done": False,
})
try:
if use_agent:
buf = await stream_writing_agent(
agent_id=WRITER_AGENT_ID, user_payload=msg,
state=state, config=config, on_text=_on_text,
)
else:
from deerflow.models import create_chat_model
buf = ""
parser = ThinkTagParser()
m = create_chat_model(name=legacy_model_name) if legacy_model_name else create_chat_model()
async for chunk in m.astream([
SystemMessage(content=DRAFT_SECTION_SYSTEM),
HumanMessage(content=msg),
]):
reasoning = reasoning_delta(chunk)
if reasoning:
_on_text(reasoning, True)
delta = _content_to_text(chunk.content)
if not delta:
continue
for is_thinking, text in parser.feed(delta):
if not is_thinking:
buf += text
_on_text(text, is_thinking)
except Exception as e:
logger.warning("draft targeted-revise: section [%s] rewrite failed, keep original: %s", section_title, e)
buf = ""
finally:
writer({
"type": "draft_chunk", "section_index": idx, "section_title": section_title,
"text": "", "is_section_done": True,
})
cleaned = (buf or "").strip()
return cleaned or existing_text
async def _targeted_user_revision(
state: AIWritingState, config: RunnableConfig, *,
pkg: dict, cite_map: dict | None, add_references: bool, note: str,
writer, agent_label: str, use_agent: bool, legacy_model_name: str | None,
extra_notices: list[dict] | None = None,
) -> dict | None:
"""尝试只重写意见命中的章节;不适用(无旧稿/意见偏整体/解析失败)时返回 None 走整篇重写。"""
prev_draft = state.get("current_draft") or {}
prev_md = (prev_draft.get("full_markdown") or "").strip()
if not prev_md or not note.strip():
return None
article_title, parsed, references_block = _split_draft_sections(prev_md)
if not parsed:
return None
targets = await _detect_revision_scope(state, config, parsed, note)
if not targets:
return None
by_idx = {t["index"]: t for t in targets}
outline = state.get("current_outline") or {"title": article_title, "sections": []}
out_sections = outline.get("sections", [])
revision_count = state.get("revision_count", 0)
new_titles: list[str] = []
new_bodies: list[str] = []
changed_labels: list[str] = []
title_changed = False
for idx, (sec_title, body) in enumerate(parsed):
t = by_idx.get(idx)
if t is None:
# 未命中章节:标题、正文字节级原样保留(不重写、不下发流式)
new_titles.append(sec_title)
new_bodies.append(body)
continue
final_title = t["new_title"] or sec_title
if t["new_title"] and t["new_title"] != sec_title:
title_changed = True
if t["rewrite_body"]:
out_sec = out_sections[idx] if idx < len(out_sections) else {}
key_points_str = _render_key_points_for_prompt(
out_sec.get("key_points") or out_sec.get("keyPoints") or []
)
section_materials = _get_section_materials(
out_sec.get("material_ids") or out_sec.get("materialIds") or [], pkg, cite_map
)
new_body = await _stream_revise_section(
state, config, article_title=article_title, idx=idx, section_title=final_title,
existing_text=body, key_points_str=key_points_str, section_materials=section_materials,
note=note, add_references=add_references, writer=writer,
use_agent=use_agent, legacy_model_name=legacy_model_name,
)
else:
# 只改标题、不动正文:正文原样保留
new_body = body
new_titles.append(final_title)
new_bodies.append(new_body)
changed_labels.append(final_title)
body_md = "\n\n".join(
f"## {t}\n\n{_normalize_section_headings(b)}"
for t, b in zip(new_titles, new_bodies)
)
full_markdown = f"# {article_title}\n\n{body_md}"
word_count = count_words(full_markdown)
if references_block:
full_markdown = f"{full_markdown}\n\n{references_block}"
# 章节标题被改 → 同步回 outline(按 index 改 section_title),使后续确认/审核/历史结构一致
updated_outline = outline
if title_changed and out_sections:
updated_outline = {
**outline,
"sections": [
({**s, "section_title": new_titles[i]} if i < len(new_titles) else s)
for i, s in enumerate(out_sections)
],
}
draft = DraftArticle(
title=article_title or updated_outline.get("title", "文章"),
outline=updated_outline,
full_markdown=full_markdown,
word_count=word_count,
revision_count=revision_count,
)
await _save_draft_to_repo(config, draft)
changed_names = "、".join(changed_labels) if changed_labels else "相关章节"
notices: list[dict] = list(extra_notices or [])
notices.append({
"type": "notice", "agent_name": agent_label, "title": "局部修改",
"detail": f"已按你的意见只改动了 {len(by_idx)} 个章节({changed_names}),其余内容原样保留。",
})
updates: dict = {
"current_draft": draft,
"drafts": [draft],
"status": "awaiting_draft_confirm",
"progress_events": [
{"type": "step_start", "agent_name": agent_label, "message": f"开始局部修改(第{revision_count+1}稿)"},
*notices,
{
"type": "draft_ready", "agent_name": agent_label,
"message": f"修改完成,共 {word_count} 字",
"draft_title": article_title or updated_outline.get("title", "文章"),
"draft_markdown": full_markdown,
"revision_count": revision_count,
},
],
}
if title_changed:
updates["current_outline"] = updated_outline
return updates
async def writer_draft_node(state: AIWritingState, config: RunnableConfig) -> dict:
outline = state.get("current_outline")
if not outline:
@@ -390,9 +668,11 @@ async def writer_draft_node(state: AIWritingState, config: RunnableConfig) -> di
# 修改意见(来自用户或编辑)
revision_notes = ""
user_note_raw = "" # 用户原话(不含「用户修改意见:」前缀),供局部修改的范围识别与单节重写用
if intervention.get("action") == "user_revise":
notes = intervention.get("_user_notes") or intervention.get("user_revision_notes", "")
if notes:
user_note_raw = notes
revision_notes = f"\n用户修改意见:{notes}"
elif intervention.get("action") in ("accept_review", "custom_revise"):
review = state.get("review_result") or {}
@@ -472,6 +752,21 @@ async def writer_draft_node(state: AIWritingState, config: RunnableConfig) -> di
}
draft_chain = fallback_chain(initial_name)
from deerflow.models import create_chat_model
# 用户局部修改:意见只指向部分章节时,只重写命中章节、其余字节级原样保留(不整篇重写)。
# 偏整体性 / 无法定位 / 无旧稿时 _targeted_user_revision 返回 None,继续走下面的整篇重写路径。
if intervention.get("action") == "user_revise" and user_note_raw.strip():
legacy_model_name = draft_chain[0] if (not use_agent and draft_chain) else None
targeted = await _targeted_user_revision(
state, config,
pkg=pkg, cite_map=cite_map, add_references=add_references,
note=user_note_raw, writer=writer, agent_label=agent_label,
use_agent=use_agent, legacy_model_name=legacy_model_name,
extra_notices=[model_default_notice] if model_default_notice else None,
)
if targeted is not None:
return targeted
section_titles: list[str] = []
section_bufs: list[str] = [] # 各章节正文(不含 ## 标题),供扩写兜底逐节替换
section_errors: list[str] = [] # 分节写作的模型异常,供完成后非致命提示
@@ -3,14 +3,13 @@ from __future__ import annotations
import json
import logging
import re
from langchain_core.messages import HumanMessage, SystemMessage
from langchain_core.runnables import RunnableConfig
from ..outline_parser import parse_user_outline
from ..prompts.writer_prompts import (
OUTLINE_MATERIAL_ASSIGN_SYSTEM,
OUTLINE_MATERIAL_ASSIGN_USER,
OUTLINE_NORMALIZE_SYSTEM,
OUTLINE_NORMALIZE_USER,
OUTLINE_REVISE_SYSTEM,
@@ -35,7 +34,7 @@ from ._model_fallback import (
emit_model_switch_event,
fallback_chain,
)
from ._utils import ThinkTagParser, _content_to_text, llm_json, parse_json, reasoning_delta, resolve_writing_model_name, safe_get_stream_writer
from ._utils import ThinkTagParser, _content_to_text, parse_json, reasoning_delta, resolve_writing_model_name, safe_get_stream_writer
logger = logging.getLogger(__name__)
@@ -143,12 +142,81 @@ def _derive_title(state: AIWritingState) -> str:
return intent[:30].rstrip() + ("…" if len(intent) > 30 else "")
async def _normalize_user_outline_via_llm(state: AIWritingState, config: RunnableConfig, raw: str) -> Outline:
"""确定性解析失败时,调 LLM 把用户大纲原文归一成三级结构(严格保真、不改写)。
# ── 素材关联:确定性词重叠(替代 LLM,零延迟)────────────────────────────────────
_ALNUM_TOKEN_RE = re.compile(r"[a-z0-9]+")
_CJK_CHAR_RE = re.compile(r"[一-鿿]")
再失败则把每个非空行当作一个章节,保证永远产出一份可用大纲、不丢用户内容。
def _text_tokens(text: str) -> set[str]:
"""把文本切成可比较的 token 集合:CJK 取相邻 bigram、英文/数字取整词(均小写)。
bigram 让「新能源汽车出口」这类短标题与素材标题/正文做稳定的词重叠匹配,
无需任何模型调用。
"""
if not text:
return set()
text = text.lower()
tokens: set[str] = {w for w in _ALNUM_TOKEN_RE.findall(text) if len(w) >= 2}
cjk = _CJK_CHAR_RE.findall(text)
tokens.update(cjk[i] + cjk[i + 1] for i in range(len(cjk) - 1))
return tokens
def _section_query_text(section: dict) -> str:
"""章节用于匹配素材的文本:章节标题 + 各小节标题 + 子要点,越全匹配越准。"""
parts = [section.get("section_title") or ""]
for kp in section.get("key_points", []) or []:
if isinstance(kp, dict):
parts.append(kp.get("point_title") or "")
parts.extend(s for s in (kp.get("sub_points") or []) if isinstance(s, str))
elif isinstance(kp, str):
parts.append(kp)
return " ".join(p for p in parts if p)
def _assign_materials_heuristic(outline: Outline, pkg: dict) -> Outline:
"""确定性地把素材按词重叠归类到各章节(替代 LLM 调用,零延迟、零 <think> 等待)。
每条素材分配给与其标题/正文词重叠最高的章节(必须有重叠才分配);与所有章节都不
重叠的素材不强行塞入(writer_draft 自带「取前 2 条素材」兜底)。只改 material_ids,
绝不动标题与层级。
"""
sections = outline.get("sections", [])
materials = pkg.get("materials") or []
if not sections or not materials:
return outline
section_tokens = [_text_tokens(_section_query_text(s)) for s in sections]
assigned: list[list[str]] = [[] for _ in sections]
for m in materials:
mid = m.get("id")
if not mid:
continue
mat_tok = _text_tokens(f"{m.get('title', '')} {m.get('content', '')}")
if not mat_tok:
continue
best_idx, best_score = -1, 0
for i, sec_tok in enumerate(section_tokens):
score = len(mat_tok & sec_tok)
if score > best_score:
best_idx, best_score = i, score
if best_idx >= 0:
assigned[best_idx].append(mid)
for section, ids in zip(sections, assigned):
section["material_ids"] = ids
return outline
async def _stream_normalize_user_outline(
state: AIWritingState, config: RunnableConfig, raw: str, writer
) -> Outline:
"""散文化大纲:确定性解析失败时流式调 LLM 归一成三级结构,实时展示给前端。
内网模型 <think> 关不掉 → 用 ThinkTagParser 把思考与正文分流:思考推 is_thinking
思考框、正文累积后解析 JSON,用户全程看得到进度而非干等。再失败则把每个非空行当作
一个章节兜底,保证永远产出可用大纲、不丢用户内容。
"""
model_name = (state.get("model_name") or "").strip() or None
line_fallback = {
"title": "",
"sections": [
@@ -156,75 +224,86 @@ async def _normalize_user_outline_via_llm(state: AIWritingState, config: Runnabl
for ln in raw.splitlines() if ln.strip()
] or [{"section_title": "正文", "key_points": [], "material_ids": []}],
}
result = await llm_json(
OUTLINE_NORMALIZE_SYSTEM,
OUTLINE_NORMALIZE_USER.format(raw_outline=raw),
line_fallback,
config,
model_name=model_name,
)
return _parse_outline(result)
name, _ = resolve_writing_model_name(config, state)
full_text = ""
try:
from deerflow.models import create_chat_model
# 大纲整理是结构化保真任务,强制关思考(GLM5 等爱出 <think>);
# 关不掉的模型仍由下方 ThinkTagParser 把思考分流到思考框实时展示。
model = create_chat_model(name=name, force_disable_thinking=True)
parser = ThinkTagParser()
async for chunk in model.astream([
SystemMessage(content=OUTLINE_NORMALIZE_SYSTEM),
HumanMessage(content=OUTLINE_NORMALIZE_USER.format(raw_outline=raw)),
]):
reasoning = reasoning_delta(chunk)
if reasoning:
writer({"type": "outline_chunk", "text": reasoning, "is_thinking": True})
delta = _content_to_text(chunk.content)
if not delta:
continue
for is_thinking, text in parser.feed(delta):
if not is_thinking:
full_text += text
writer({"type": "outline_chunk", "text": text, "is_thinking": is_thinking})
except Exception as e:
logger.error("stream normalize user outline failed: %s", e, exc_info=True)
return _parse_outline(line_fallback)
return _parse_outline(parse_json(full_text, line_fallback))
async def _assign_materials_to_outline(
state: AIWritingState, config: RunnableConfig, outline: Outline, pkg: dict
) -> Outline:
"""为用户大纲的各章节回填 material_ids(结构锁定:只取编号、按章节序号嫁接)。
async def build_user_outline_with_materials(
state: AIWritingState, config: RunnableConfig, pkg: dict
) -> Outline | None:
"""搜集素材阶段调用:用户自带大纲时,解析其大纲并把已检索素材归类到各章节。
LLM 只负责「哪个章节配哪些素材」,标题与层级一律以解析结果为准,杜绝改写。失败则
保持 material_ids 为空(writer_draft 自带「取前 2 条素材」兜底)。
复用本模块的解析/归类逻辑,让 researcher 能在「搜集素材」阶段顺便整理素材,
从而**跳过 writer_outline 的大纲生成步**(见 graph 路由)。无用户大纲返回 None。
已有结构化大纲(如 re_search 二次检索)则复用结构、仅重新归类素材,不重复解析。
只做**确定性**解析与素材关联(零 LLM、瞬时):散文化、解析不出层级的大纲返回
None —— 不在 researcher 阶段阻塞式调 LLM 归一,交给 writer_outline 流式归一并实时
展示(见 graph after_material_pause 路由)。
"""
sections = outline.get("sections", [])
materials = pkg.get("materials") or []
if not sections or not materials:
return outline
valid_ids = {m["id"] for m in materials}
sections_list = "\n".join(
f"{i + 1}. {s.get('section_title', '')}" for i, s in enumerate(sections)
)
model_name = (state.get("model_name") or "").strip() or None
result = await llm_json(
OUTLINE_MATERIAL_ASSIGN_SYSTEM,
OUTLINE_MATERIAL_ASSIGN_USER.format(
user_intent=state.get("user_intent", ""),
sections_list=sections_list,
materials_summary=_materials_summary(pkg),
),
{"assignments": []},
config,
model_name=model_name,
)
assignments = result.get("assignments")
if not isinstance(assignments, list):
return outline
for i, section in enumerate(sections):
if i >= len(assignments):
break
ids = assignments[i]
if isinstance(ids, list):
section["material_ids"] = [mid for mid in ids if mid in valid_ids]
return outline
raw = (state.get("user_outline_raw") or "").strip()
if not raw:
return None
existing = state.get("current_outline")
if existing and existing.get("sections"):
outline: Outline = existing
else:
outline = parse_user_outline(raw)
if outline is None:
return None
if not outline.get("title"):
outline = {**outline, "title": _derive_title(state)}
return _assign_materials_heuristic(outline, pkg)
async def _build_outline_from_user(
state: AIWritingState, config: RunnableConfig, pkg: dict, raw: str
) -> dict:
"""用户自带大纲分支:解析用户大纲 → 关联素材 → 直接进确认,不调 LLM 重新规划。"""
"""用户自带大纲分支:解析用户大纲 → 关联素材 → 直接进确认,不调 LLM 重新规划。
可确定性解析的大纲(带编号/标题/缩进)零 LLM、瞬时完成;只有散文化、解析不出层级
的大纲才流式调一次 LLM 归一(实时展示思考/正文,应对内网模型关不掉的 <think>),
素材关联一律走确定性词重叠。
"""
writer = safe_get_stream_writer()
writer({"type": "outline_chunk", "text": "正在整理你提供的大纲…\n", "is_thinking": True})
outline = parse_user_outline(raw)
if outline is None:
# 确定性解析拿不准(散文化/格式混乱)→ LLM 兜底归一
outline = await _normalize_user_outline_via_llm(state, config, raw)
# 确定性解析拿不准(散文化/格式混乱)→ 流式 LLM 兜底归一,全程实时展示
outline = await _stream_normalize_user_outline(state, config, raw, writer)
else:
writer({"type": "outline_chunk", "text": "正在整理你提供的大纲…\n", "is_thinking": True})
if not outline.get("title"):
outline = {**outline, "title": _derive_title(state)}
# 仍走了检索 → 把已检索素材关联到用户章节(结构锁定)
outline = await _assign_materials_to_outline(state, config, outline, pkg)
# 仍走了检索 → 把已检索素材关联到用户章节(确定性词重叠,零 LLM)
outline = _assign_materials_heuristic(outline, pkg)
section_count = len(outline.get("sections", []))
return {
@@ -314,6 +393,7 @@ async def writer_outline_node(state: AIWritingState, config: RunnableConfig) ->
full_text = await stream_writing_agent(
agent_id=OUTLINER_AGENT_ID,
user_payload=agent_payload,
disable_thinking=True,
state=state,
config=config,
on_text=_on_text,
@@ -342,7 +422,10 @@ async def writer_outline_node(state: AIWritingState, config: RunnableConfig) ->
full_text = ""
parser = ThinkTagParser()
try:
model = create_chat_model(name=candidate) if candidate else create_chat_model()
model = (
create_chat_model(name=candidate, force_disable_thinking=True)
if candidate else create_chat_model(force_disable_thinking=True)
)
async for chunk in model.astream([
SystemMessage(content=system_prompt),
HumanMessage(content=user_prompt),
@@ -7,9 +7,13 @@ EXTRACT_KEYWORDS_SYSTEM = """你是一名专业情报研究员(素材收集专
你的工作背景:你在一家权威媒体机构任职,熟悉各类信息源的可信度评估。
你只能做以下事情:
1. 从写作意图中按要求的数量提取搜索关键词
1. 从写作意图(以及用户提供的文章大纲,若有)中按要求的数量提取搜索关键词
你不能写文章,不能评价内容好坏,只做关键词提取。
【结合大纲】
- 若用户提供了文章大纲,关键词要结合写作意图,尽量覆盖大纲涉及的各章节主题,
让检索到的素材能支撑每个章节,而不是只围绕一个笼统的意图。
【严禁思考】
- 这是一个简单的结构化抽取任务,禁止任何思考、分析、推理或规划过程。
- 禁止输出 <think></think> 标签及其中的任何内容,禁止输出"让我想想""首先"之类的过渡语。
@@ -28,10 +32,10 @@ EXTRACT_KEYWORDS_SYSTEM = """你是一名专业情报研究员(素材收集专
EXTRACT_KEYWORDS_USER = """写作意图:{user_intent}
文章类型:{article_type}
目标读者:{target_audience}
{outline_section}
请提取 {keyword_count} 个搜索关键词,用于搜集相关素材。
关键词数量必须为 {keyword_count} 个,不多不少。
关键词必须是依据上述写作意图提炼的真实检索词,不要照抄下方 JSON 模板里的占位符。
关键词必须是依据上述写作意图(及大纲,若有)提炼的真实检索词,不要照抄下方 JSON 模板里的占位符。
只输出 JSON(下方 ... 是占位示意,不是真实关键词):
{{"keywords": ["...", "..."]}}
@@ -149,33 +149,12 @@ OUTLINE_NORMALIZE_SYSTEM = """你是一名大纲格式整理助手。
OUTLINE_NORMALIZE_USER = """用户粘贴的大纲原文:
{raw_outline}
请逐条保留原文,仅判断层级,输出结构化大纲 JSON。"""
请逐条保留原文,仅判断层级,输出结构化大纲 JSON。
# ── 用户自带大纲:素材关联(结构锁定,只挑 material_ids)──────────────────────────
/no_think"""
OUTLINE_MATERIAL_ASSIGN_SYSTEM = """你是一名资料编辑。
给定一份【已确定的文章大纲】(章节顺序固定)和一批检索到的素材,你的任务是为每个章节
挑选最相关的素材编号。
【规则】
- 不要修改大纲的任何标题或结构,你只输出每个章节对应的素材编号列表。
- 严格按大纲给出的章节顺序输出,一个章节对应一个编号数组;该章节没有相关素材就给 []。
- material_id 必须来自给定素材清单里出现过的编号,不要编造。
- 一条素材可以分配给多个章节,也可以一个都不分配。
【输出要求】
- 只输出一个 JSON 对象,不要解释文字,不要 markdown 代码块,必须用英文双引号。
- 结构:{"assignments": [["m001","m002"], [], ["m003"]]},数组长度等于章节数、顺序一致。"""
OUTLINE_MATERIAL_ASSIGN_USER = """写作意图:{user_intent}
文章大纲(章节按顺序编号):
{sections_list}
可用素材:
{materials_summary}
请按章节顺序,为每个章节挑选相关的 material_id,输出 assignments 数组。"""
# 注:用户自带大纲的「素材关联」已改为确定性词重叠(writer_outline._assign_materials_heuristic),
# 零 LLM、零 <think> 等待,原 OUTLINE_MATERIAL_ASSIGN_* 提示词已废弃移除。
DRAFT_SECTION_SYSTEM = """你是一名专业内容创作者(作家)。
你的目标:根据大纲和素材,写出一段结构清晰、高质量的文章内容。
@@ -250,6 +229,61 @@ DRAFT_SECTION_USER = """文章标题:{article_title}
论述层次清晰、内容充实,但务必在上述字数上限内完成:宁可精炼也不要为凑字数注水。"""
# ── 用户局部修改:识别意见指向的章节范围 ───────────────────────────────────────
DRAFT_REVISE_SCOPE_SYSTEM = """你是一名编辑助理。用户对一篇已成稿的文章提出了一句修改意见,你要判断这条意见要改文章里的哪些「章节」,以及每个章节具体改什么。
文章由若干带编号的章节组成(编号从 0 开始)。
【判断规则】(最高优先级)
1. 定位意见指向的章节:
- 序号:「第一段 / 第一部分 / 第1章」→ 编号 0,「第二段」→ 编号 1,依此类推;
- 位置:「开头 / 引言 / 导语」→ 第一个章节,「结尾 / 结论 / 最后一段」→ 最后一个章节;
- 直接点名某个章节的标题或主题。
2. 对每个命中的章节,判断用户要改什么:
- 若要改这一章节的**标题**(如「把第一章的标题改为简介」「第二部分改名叫…」),把新标题文字放进 new_title;否则 new_title 写 null。
- 若要改这一章节的**正文内容**(润色 / 补充 / 精简 / 重写等),rewrite_body 写 true;若只改标题、不动正文,rewrite_body 写 false。
3. 下列情况属于整体性、或会改变文章结构,一律令 whole_article=true、targets 留空:
- 整体性:「全文都改」「通篇润色」「整体语气更正式」「每一部分都精简」;
- 结构性:新增章节、删除章节、调整章节顺序、合并 / 拆分章节;
- 改**文章总标题**(注意:是整篇文章的标题,不是某个章节的标题)。
4. 拿不准、无法明确定位到具体章节时,一律 whole_article=true(交给整篇重写更安全)。
【严禁思考与表态】
- 不要输出任何思考、解释或对用户说的话;收到输入后立即直接输出最终 JSON。
【输出要求】
- 只输出一个 JSON 对象,不要解释文字,不要 markdown 代码块,必须用英文双引号。
- new_title 没有就写 null;targets 为空数组表示走整篇重写。
- 结构:{"whole_article": false, "targets": [{"index": 0, "new_title": "简介", "rewrite_body": false}]}"""
DRAFT_REVISE_SCOPE_USER = """文章共有 {n} 个章节,编号与标题如下:
{sections}
用户的修改意见:{note}
请判断这条意见要改哪些章节、各自怎么改(标题 / 正文),按要求只输出 JSON。"""
# ── 用户局部修改:只重写命中的单个章节(保留其余内容)─────────────────────────
DRAFT_REVISE_SECTION_USER = """文章标题:{article_title}
当前章节:{section_title}
章节内的小节与子要点(层级 bullet 列表):
{key_points}
相关素材:
{section_materials}
这一章节的现有内容如下:
{existing_text}
用户对本章节的修改意见:{note}
请**只针对用户意见**修改这一章节:
- 落实用户意见的同时,未被要求改动的内容、事实与措辞尽量保留原样,不要借机重写整段。
- 保留原有的「### 小节标题 / #### 子要点标题」两级结构,不要新增或删除小节/子要点,也不要把 #### 改成 bullet。
- 不要输出 # 或 ##(文章标题、章节标题由系统添加)。
- 直接返回修改后的完整章节内容(含 ### 与 #### 两级标题及各自正文段),不要解释、不要前置说明。"""
DRAFT_EXPAND_USER = """文章标题:{article_title}
当前章节:{section_title}
章节内的小节与子要点(层级 bullet 列表):
@@ -144,6 +144,10 @@ class AIWritingState(TypedDict):
# notebook=我的空间(仅笔记检索)。旧值 knowledge_base / skill 由 researcher
# 节点按 general 兼容处理。
material_source: str
# 跳过素材收集:用户在初始表单勾选「不收集素材,直接用通用知识写作」时为 True。
# researcher 节点在**首次**进入(无任何 pause 干预)时短路,跳过整个检索流程直接
# 去大纲规划;强制 loose 写作模式(没有素材可严格依据)。
skip_research: bool
# 用户在写作表单选定的主模型。大纲/写作/审核严格使用它(不回退默认)。
# 由 intent_parser 在首次 invocation 从 configurable.model_name 捕获进 state,
# 经 checkpoint 持久化 —— interrupt/resume 是新 invocation、其 config 不带原
@@ -153,6 +157,11 @@ class AIWritingState(TypedDict):
notebook_search_space_id: Optional[int]
notebook_source_ids: Optional[List[int]]
# 意图解析阶段(intent_parser)顺便生成的检索词:与意图理解共用同一次 LLM 调用,
# researcher 首轮检索时直接复用,省掉素材收集阶段单独的「生成检索词」LLM 往返。
# 解析失败/为空时为 None,researcher 回退到自己生成检索词(优雅降级)。
precomputed_keywords: Optional[List[str]]
# Agent A 输出
material_package: Optional[MaterialPackage]
@@ -437,6 +437,8 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
# 走写作配置专用 prompt,只负责帮用户把写作表单聊清并提交,不真正开始写作。
is_writing_setup = cfg.get("is_writing_setup", False)
writing_setup_article_types = cfg.get("valid_article_types") if is_writing_setup else None
# 快捷模式:跳过逐项选项澄清,给标题直接出大纲(见 build_writing_setup_prompt)。
writing_setup_quick = bool(cfg.get("writing_setup_quick", False)) if is_writing_setup else False
memory_injection_enabled = bool(cfg.get("memory_injection_enabled", True))
rag_mode_enabled = bool(cfg.get("rag_mode_enabled", False))
writing_mode = bool(cfg.get("writing_mode", False))
@@ -512,17 +514,20 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
# 关键:绝不给全套工具、绝不复用 lead 超级体提示,否则模型会把「新能源汽车市场分析」之类
# 长得像任务的输入直接做掉,而不是转成写作配置提交。
# 末尾追加 WritingSetupRoundCapMiddleware:澄清最多 3 轮,到顶用 tool_choice 强制提交配置卡。
# 快捷模式:用默认选项、不逐项澄清提问(但仍生成并确认大纲)——直接**不挂** ask_clarification,
# 让模型物理上问不了澄清问题,比只靠提示词约束更可靠。
from deerflow.agents.lead_agent.prompt import build_writing_setup_prompt
from deerflow.agents.middlewares.writing_setup_round_cap_middleware import WritingSetupRoundCapMiddleware
setup_tools = [setup_writing] if writing_setup_quick else [setup_writing, ask_clarification_tool]
return create_agent(
model=create_chat_model(name=model_name, thinking_enabled=thinking_enabled, force_disable_thinking=thinking_force_disabled, app_config=resolved_app_config),
tools=[setup_writing, ask_clarification_tool],
tools=setup_tools,
middleware=[
*_build_middlewares(config, model_name=model_name, app_config=resolved_app_config),
WritingSetupRoundCapMiddleware(max_rounds=WRITING_SETUP_MAX_ROUNDS),
],
system_prompt=build_writing_setup_prompt(writing_setup_article_types),
system_prompt=build_writing_setup_prompt(writing_setup_article_types, quick_mode=writing_setup_quick),
state_schema=ThreadState,
)
@@ -1095,13 +1095,17 @@ def _build_bootstrap_section() -> str:
"""
def build_writing_setup_prompt(article_types: list[dict] | None = None) -> str:
def build_writing_setup_prompt(article_types: list[dict] | None = None, *, quick_mode: bool = False) -> str:
"""写作配置 Q&A 的**独立**系统提示(不复用 lead-agent 超级体提示)。
关键:写作配置助手绝不能像 lead agent 那样「自己把任务做掉」——用户输入常常长得像一个
可执行任务(如「新能源汽车市场分析」),若复用 lead 提示 + 全套工具,模型会直接去研究/写
分析。这里用一份职责单一、口吻强硬的独立提示,配合「只挂 setup_writing 一个工具」,把模型
死死框在「聊清需求 → 调 setup_writing」上。
``quick_mode``(快捷模式):跳过「逐项选项澄清」——文章类型/字数/读者等一律取合理默认,
**不调用 ``ask_clarification``**,用户一给出标题/主题就**直接生成大纲**;大纲仍交用户确认,
确认后再调 ``setup_writing`` 提交。供前端「快捷模式」开关使用。
"""
if article_types:
type_lines = "\n".join(
@@ -1120,18 +1124,35 @@ def build_writing_setup_prompt(article_types: list[dict] | None = None) -> str:
"`article_type_detail` 里用一句话说明其写作侧重;不确定可留空,交给用户在表单里选。"
)
if quick_mode:
workflow_block = """【工作流程——快捷模式】
1. **不澄清、用默认**:**禁止**就文章类型/字数/读者等选项反问用户(你**没有** `ask_clarification` 工具,也**绝不**用普通文本向用户提澄清问题)。这些字段一律自行取**合理默认**(字数默认 800,读者默认「普通读者」,文章类型按意图判断)。
2. **跳过提问、直接出大纲**:用户一给出标题或写作主题,**立刻**以**普通回复文本**输出一份**多级层级**的中文大纲——用 Markdown 标题层级(`#` 一级、`##` 二级,必要时 `###` 三级),每节可用**一句话**点明这一节要写什么,但**绝不写正文**;大纲规模与默认字数匹配。**在回复的最后一句**明确请用户确认,例如:「以上是建议大纲,你看是否需要调整?确认后我就整理写作配置、开始写作。」这一步**不要调用任何工具**,输出完大纲就结束本轮、等待用户回应。
3. **按反馈灵活改大纲**:用户要求调整(增删章节、改层级、换角度等)就**重新输出修订后的完整大纲**,并再次在最后一句请其确认;如此反复,直到用户表示满意/确认。
4. **提交配置**:用户确认大纲后,调用 `setup_writing` 提交:"""
else:
workflow_block = """【工作流程】
1. **聊清需求**:读用户输入,当关键项(文章类型、目标字数、目标读者)缺失或模糊时,调用 `ask_clarification` 提问——**优先给出 `options` 选项**让用户点选(如目标字数「800 / 1500 / 3000」、读者「普通读者 / 行业专家 / 政策决策者」)。澄清**最多 2 次**,信息够了就进入下一步,不要无休止追问。
2. **生成大纲(先于确认意图)**:信息足够后,直接以**普通回复文本**输出一份**多级层级**的中文大纲——用 Markdown 标题层级(`#` 一级、`##` 二级,必要时 `###` 三级),每节可用**一句话**点明这一节要写什么,但**绝不写正文**;大纲规模与目标字数匹配。**在回复的最后一句**明确请用户确认,例如:「以上是建议大纲,你看是否需要调整?确认后我就整理写作配置、开始写作。」这一步**不要调用任何工具**,输出完大纲就结束本轮、等待用户回应。
3. **按反馈灵活改大纲**:用户要求调整(增删章节、改层级、换角度等)就**重新输出修订后的完整大纲**,并再次在最后一句请其确认;如此反复,直到用户表示满意/确认。用户若只是补充信息也照常更新大纲再确认。
4. **提交配置**:用户确认大纲后,调用 `setup_writing` 提交:"""
# 快捷模式与普通模式的唯一区别:是否「逐项澄清提问」。两者都**先生成并确认大纲**再提交。
# 快捷模式不挂 ask_clarification 工具(见 make_lead_agent),故这里据 quick_mode 调整工具说明。
tools_law = (
"- 你只有**一个工具**:`setup_writing`(提交写作配置)——快捷模式**没有** `ask_clarification`,**不向用户提任何澄清问题**,缺失项一律取合理默认。**没有任何其它工具**,不要做检索/研究。"
if quick_mode
else "- 你只有两个工具:① `ask_clarification`(向用户提问澄清,需要时用);② `setup_writing`(提交写作配置)。**没有任何其它工具**,不要做检索、不要做研究、不要假装调用别的工具。"
)
return f"""你是「AI 写作」功能的**配置助手**。你的职责分两段:① 先与用户把写作需求聊清楚,**先生成并与用户确认一份大纲**;② 待用户对大纲满意后,再调用 `setup_writing` 工具提交写作配置(含已确认的大纲),交用户开始写作。
【铁律——最高优先级】
- 你**绝对不撰写**文章正文、分析内容或任何成稿文字。即使用户的输入看起来就是一个写作任务(例如「新能源汽车市场分析」「帮我写一篇……」),你也**只产出「大纲」(仅结构与标题)与「写作配置」**,绝不替用户动笔、绝不直接完成这篇写作。
- 你只有两个工具:① `ask_clarification`(向用户提问澄清,需要时用);② `setup_writing`(提交写作配置)。**没有任何其它工具**,不要做检索、不要做研究、不要假装调用别的工具。
{tools_law}
- `setup_writing` **只能在用户明确确认大纲之后**才调用;在那之前绝不调用它。
【工作流程】
1. **聊清需求**:读用户输入,当关键项(文章类型、目标字数、目标读者)缺失或模糊时,调用 `ask_clarification` 提问——**优先给出 `options` 选项**让用户点选(如目标字数「800 / 1500 / 3000」、读者「普通读者 / 行业专家 / 政策决策者」)。澄清**最多 2 次**,信息够了就进入下一步,不要无休止追问。
2. **生成大纲(先于确认意图)**:信息足够后,直接以**普通回复文本**输出一份**多级层级**的中文大纲——用 Markdown 标题层级(`#` 一级、`##` 二级,必要时 `###` 三级),每节可用**一句话**点明这一节要写什么,但**绝不写正文**;大纲规模与目标字数匹配。**在回复的最后一句**明确请用户确认,例如:「以上是建议大纲,你看是否需要调整?确认后我就整理写作配置、开始写作。」这一步**不要调用任何工具**,输出完大纲就结束本轮、等待用户回应。
3. **按反馈灵活改大纲**:用户要求调整(增删章节、改层级、换角度等)就**重新输出修订后的完整大纲**,并再次在最后一句请其确认;如此反复,直到用户表示满意/确认。用户若只是补充信息也照常更新大纲再确认。
4. **提交配置**:用户确认大纲后,调用 `setup_writing` 提交:
{workflow_block}
- `user_intent`:精炼成一句话的写作目标(必填)。
- `article_type`:见下方【文章类型】——优先选已有类型的 key;都不贴切才自拟一个新的中文类型名。
- `article_type_detail`:**仅当 article_type 是新类型时**,用一句话说明该类型的写作侧重/特征;命中已有类型则留空。
@@ -1198,6 +1198,18 @@ class ScheduledTaskService:
negative_prompt = str(cfg.get("negative_prompt") or "").strip()
reference_favorite_id = cfg.get("reference_favorite_id") or None
# 选了内置模板作参考(``tpl:<id>``) → 用**真实模板**出页,保留全部子页面;
# 绝不走下面的自由渲染(模型重画会丢掉 JS 驱动的多页/子页面)。
tpl_ref_id = (
template_fill.reference_template_id(str(reference_favorite_id))
if reference_favorite_id
else None
)
if tpl_ref_id is not None:
return await self._run_html_template_reference(
task, tpl_id=tpl_ref_id, runtime_thread_id=runtime_thread_id, scheduled_for=scheduled_for, run_id=run_id
)
# 1) Gather source data (real, via the agent's search tools). When the
# Markdown switch is also on, the gather pass additionally saves the .md
# file, returned here so we can validate + surface it next to the page.
@@ -1211,6 +1223,7 @@ class ScheduledTaskService:
)
# 2) Load reference style/layout from a favorite if one was selected.
# (模板参考 ``tpl:<id>`` 已在上面分支提前 return,这里只剩用户收藏的页面。)
reference_style = None
reference_layout = None
if reference_favorite_id:
@@ -1432,6 +1445,224 @@ class ScheduledTaskService:
return "\n".join(p for p in parts if p)
return str(content)
async def _convert_to_template_data(
self,
template_id: str,
source: str,
*,
model_name: str | None,
soul: str | None,
run_id: str,
) -> dict[str, Any]:
"""把源数据(JSON/文本/含JSON文本)转换成模板的 DATA 结构,校验+回喂重试。
成功返回校验通过的对象;全部尝试失败抛 :class:`TemplateFillError`。
"""
skeleton = template_fill.schema_skeleton_json(template_id)
# 源数据可能是纯 JSON / 纯文本 / 含 JSON 的文本:先尽力抽出内嵌 JSON 单独标注。
embedded_json = template_fill.extract_embedded_json(source)
base_prompt = self._build_conversion_prompt(skeleton, source, embedded_json)
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:
return candidate
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]),
)
raise TemplateFillError("转换结果未通过结构校验:" + ";".join(last_errors[:8]))
async def _finalize_template_html(
self,
task: dict[str, Any],
*,
spec: Any,
template_id: str,
html: str,
run_id: str,
scheduled_for: datetime,
content: str,
source_chars: int,
used_default: bool,
extra_files: list[dict[str, Any]] | None = None,
) -> dict[str, Any]:
"""归档 index.html + 持久化 scheduled_html_pages + 构造 format=html 结果。
``extra_files`` 会排在 index.html 之前一并带上(如必须生成 Markdown 时的 .md)。
"""
task_tz = ZoneInfo(task.get("timezone") or "UTC")
local_time = scheduled_for.astimezone(task_tz)
archive_path = self._archive_html_index(run_id, html)
title = str(task.get("name") or spec.name).strip()
files: list[dict[str, Any]] = list(extra_files or [])
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": source_chars, "used_default": used_default}
prompt_snapshot = {"template_id": template_id, "used_default": used_default}
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 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": content,
"format": "html",
"html": inline_html,
"data_snapshot": data_snapshot,
"prompt_snapshot": prompt_snapshot,
"files": files,
"created_at": datetime.now(UTC).isoformat(),
}
async def _run_html_template_reference(
self,
task: dict[str, Any],
*,
tpl_id: str,
runtime_thread_id: str,
scheduled_for: datetime,
run_id: str,
) -> dict[str, Any]:
"""require_html 模式选了内置模板(``tpl:<id>``):用**真实模板**出页,保留全部子页面。
模板是 JS 驱动的多页应用,子页面靠脚本从 DATA 渲染——绝不能让模型重画(会丢子
页面)。故:可转换模板=先用智能体检索真实数据→转换成模板 DATA→填进真模板;只展示
模板=原样输出。转换失败兜底为模板默认数据,至少保证页面与子页面可用。
"""
spec = template_fill.get_template(tpl_id)
if spec is None:
raise HtmlGenerationError(f"未知的 HTML 模板:{tpl_id}")
cfg = self._html_config(task)
model_name = str(cfg.get("model_name") or "").strip() or None
# 「执行数据抽取」开关**只**控制 HTML 用「抽取数据」还是「模板默认数据」,
# 不影响 Markdown 生成等其它正常操作。
extract = bool(cfg.get("template_extract"))
also_markdown = _task_requires_markdown(task)
prompt = str(task.get("prompt") or "").strip()
description = str(task.get("description") or "").strip()
# 真正抽取 HTML 数据:开关开 且 有描述/任务 prompt 且 模板可转换(只展示模板恒不抽取)。
do_extract = extract and bool(prompt or description) and spec.convertible
task_tz = ZoneInfo(task.get("timezone") or "UTC")
local_time = scheduled_for.astimezone(task_tz)
def _pick_md(gathered: list[dict[str, Any]]) -> list[dict[str, Any]]:
return [
item
for item in (gathered or [])
if isinstance(item, dict) and str(item.get("name") or "").lower().endswith((".md", ".markdown"))
]
# gather:HTML 要抽取 或 需要 Markdown 时才跑(同一次 agent 运行可兼顾两者);
# 都不需要则完全不跑 agent。Markdown 校验独立于抽取开关。
data_text = ""
md_files: list[dict[str, Any]] = []
if do_extract or also_markdown:
data_text, gathered = await self._gather_html_data(
task, runtime_thread_id=runtime_thread_id, scheduled_for=scheduled_for, run_id=run_id
)
if also_markdown:
if not _has_valid_markdown_file(gathered):
raise MarkdownRequirementError(
f"未满足输出要求:需生成 Markdown(.md) 文件,且文件正文不少于 {_MARKDOWN_MIN_CHARS} 字"
)
md_files = _pick_md(gathered)
# 只展示模板:HTML 原样输出(含全部子页面);Markdown 已按需独立生成。
if not spec.convertible:
html = template_fill.read_template_text(tpl_id)
return await self._finalize_template_html(
task,
spec=spec,
template_id=tpl_id,
html=html,
run_id=run_id,
scheduled_for=scheduled_for,
content=f"已套用模板「{spec.name}」(含全部子页面)",
source_chars=0,
used_default=True,
extra_files=md_files,
)
# HTML 数据来源:抽取(开关开 + 有数据)→ 转换填充;否则用模板默认数据(子页面齐全)。
source = (data_text or "").strip()
used_default = True
if do_extract and source:
try:
converted = await self._convert_to_template_data(
tpl_id, source, model_name=model_name, soul=None, run_id=run_id
)
# 子页面没抽到有效内容的,回退模板原有数据,避免子页面空白。
data_obj = template_fill.merge_with_reference(tpl_id, converted)
content = f"已检索数据并套用模板「{spec.name}」(空子页面回退原数据,含全部子页面)"
used_default = False
except TemplateFillError:
logger.warning("模板参考转换失败,回退默认数据 run=%s tpl=%s", run_id, tpl_id, exc_info=True)
data_obj = template_fill.reference_data(tpl_id)
content = f"数据转换未通过,已回退模板「{spec.name}」默认数据(含全部子页面)"
else:
data_obj = template_fill.reference_data(tpl_id)
content = (
f"未开启数据抽取,已用模板「{spec.name}」默认数据(含全部子页面)"
if not extract
else f"未提供描述/未获取到数据,已用模板「{spec.name}」默认数据(含全部子页面)"
)
try:
html = template_fill.fill_template(tpl_id, data_obj, update_date=local_time.date())
except template_fill.TemplateError as exc:
raise HtmlGenerationError(f"模板填充失败:{exc}") from exc
return await self._finalize_template_html(
task,
spec=spec,
template_id=tpl_id,
html=html,
run_id=run_id,
scheduled_for=scheduled_for,
content=content,
source_chars=len(source),
used_default=used_default,
extra_files=md_files,
)
async def _run_template_fill_attempt(
self,
task: dict[str, Any],
@@ -1454,23 +1685,33 @@ class ScheduledTaskService:
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。
# 「只展示」模板:原样输出已有 HTML(含全部子页面)。
if not spec.convertible:
html = template_fill.read_template_text(template_id)
used_default = True
elif not source:
# 未提供源数据 → 直接展示模板自带的默认数据(不调模型、不校验)。
return await self._finalize_template_html(
task,
spec=spec,
template_id=template_id,
html=html,
run_id=run_id,
scheduled_for=scheduled_for,
content=f"已展示模板「{spec.name}」的现有数据",
source_chars=len(source),
used_default=True,
)
if not source:
# 未提供源数据 → 用模板自带默认数据(不调模型、不校验)。
data_obj = template_fill.reference_data(template_id)
content = f"未提供源数据,已用模板「{spec.name}」的默认数据生成页面"
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 的 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
@@ -1482,106 +1723,31 @@ class ScheduledTaskService:
except Exception:
model_name = None
skeleton = template_fill.schema_skeleton_json(template_id)
# 源数据可能是纯 JSON / 纯文本 / 含 JSON 的文本:先尽力抽出内嵌 JSON
# 单独标注,原文一并保留(纯文本则无内嵌 JSON,让模型按语义理解)。
embedded_json = template_fill.extract_embedded_json(source)
base_prompt = self._build_conversion_prompt(skeleton, source, embedded_json)
converted = await self._convert_to_template_data(
template_id, source, model_name=model_name, soul=soul, run_id=run_id
)
# 子页面没抽到有效内容的,回退模板原有数据,避免子页面空白。
data_obj = template_fill.merge_with_reference(template_id, converted)
content = f"已根据源数据填充模板「{spec.name}」生成页面"
used_default = False
# 转换 → 校验 → 失败回喂重试。
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(),
}
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
return await self._finalize_template_html(
task,
spec=spec,
template_id=template_id,
html=html,
run_id=run_id,
scheduled_for=scheduled_for,
content=content,
source_chars=len(source),
used_default=used_default,
)
async def _run_agent_attempt(
self,
@@ -140,6 +140,68 @@ TEMPLATES: tuple[TemplateSpec, ...] = (
# 暂只展示已有数据,不做 JSON 转换/填充(后续接入转换后再开)。
convertible=False,
),
TemplateSpec(
id="no3",
name="no.3",
description="",
file="no3/no3.html",
# 无 const DATA= 注入点,暂只展示已有数据(后续接入转换后再开)。
convertible=False,
),
TemplateSpec(
id="no4",
name="no.4",
description="",
file="no4/no4.html",
# 数据存于 <script id="app-data" type="application/json"> 元素(非 const DATA= 行),
# 当前引擎只支持 const VAR= 行注入,故暂注册为只展示;接转换需扩展元素注入。
convertible=False,
),
TemplateSpec(
id="no5",
name="no.5",
description="",
file="no5/no5.html",
# 数据用 `let DATA = {…}`(let + 空格,非 const DATA= 行),当前引擎不匹配,
# 故暂注册为只展示;接转换需扩展行注入正则。
convertible=False,
),
TemplateSpec(
id="no6",
name="no.6",
description="",
file="no6/no6.html",
# 数据用 `const DATA = {…}`(= 前有空格,当前引擎正则要求 const DATA=),不匹配,
# 故暂注册为只展示;接转换需放宽行注入正则。
convertible=False,
),
TemplateSpec(
id="no7",
name="no.7",
description="",
file="no7/no7.html",
# 数据存于 <script id="PAGE_DATA" type="application/json"> 元素(同 no.4 类元素注入),
# 当前引擎只支持 const VAR= 行注入,故暂注册为只展示;接转换需扩展元素注入。
convertible=False,
),
TemplateSpec(
id="no8",
name="no.8",
description="",
file="no8/no8.html",
# 数据存于 <script id="DATA" type="application/json"> 元素(同 no.4/no.7 元素注入),
# 当前引擎只支持 const VAR= 行注入,故暂注册为只展示;接转换需扩展元素注入。
convertible=False,
),
TemplateSpec(
id="no9",
name="no.9",
description="",
file="no9/no9.html",
# 数据用 `const DATA = {…}`(= 前有空格,同 no.6),当前引擎正则要求 const DATA=,不匹配,
# 故暂注册为只展示;接转换需放宽行注入正则。
convertible=False,
),
)
_TEMPLATES_BY_ID: dict[str, TemplateSpec] = {t.id: t for t in TEMPLATES}
@@ -302,6 +364,50 @@ def field_coverage_gaps(template_id: str, obj: Any, *, max_items: int = 40) -> l
return gaps
def _has_content(value: Any) -> bool:
"""该值是否含「有效内容」(用于判断子页面 / 字段是否抽到东西)。
空 / None / 空串 / 全空的列表或字典 视为无内容;数字(含 0)、布尔、非空字符串
视为有内容。
"""
if value is None:
return False
if isinstance(value, str):
return bool(value.strip())
if isinstance(value, (list, tuple)):
return any(_has_content(v) for v in value)
if isinstance(value, dict):
return any(_has_content(v) for v in value.values())
return True # 数字 / 布尔 / 其它标量
def _merge_fill(got: Any, ref: Any) -> Any:
"""用 ``got`` 覆盖,但 ``got`` 里「没有有效内容」的部分回退 ``ref``。
字典逐键递归(空分支回退);列表/标量有内容就整体保留 ``got``(列表不逐元素合并,
避免与原模板元素错配)。
"""
if not _has_content(got):
return ref
if isinstance(ref, dict) and isinstance(got, dict):
out = dict(got) # 保留 got 里 ref 没有的额外键
for key, ref_val in ref.items():
out[key] = _merge_fill(got.get(key), ref_val)
return out
return got
def merge_with_reference(template_id: str, data: Any) -> dict[str, Any]:
"""用转换结果覆盖,但「没抽到有效内容」的子页面 / 字段回退模板原有数据。
顶层键即子页面:某子页面(及其内部空字段)无有效内容时,用模板自带的原始数据补上,
保证页面与子页面不空白。整体为空时等价于返回模板默认数据。
"""
ref = reference_data(template_id)
merged = _merge_fill(data, ref)
return merged if isinstance(merged, dict) else dict(ref)
def _dump_const(var: str, obj: Any) -> str:
body = json.dumps(obj, ensure_ascii=False)
return f"const {var}={body};"
@@ -354,3 +460,16 @@ def _deep_merge(base: dict[str, Any], overrides: dict[str, Any]) -> None:
_deep_merge(base[k], v)
else:
base[k] = v
# ── require_html 模式选「内置模板」作参考 ─────────────────────────────────────────
# 「参考收藏页面」下拉里模板选项的值存成 ``tpl:<id>``,与用户收藏(普通 id)区分。
# 选了模板 → 调度器用**真实模板**出页(保留全部子页面),不抽风格摘要(那会丢子页面)。
REFERENCE_PREFIX = "tpl:"
def reference_template_id(reference: str | None) -> str | None:
"""``tpl:no1`` → ``no1``;非模板参考(普通收藏 id / 空)返回 None。"""
if isinstance(reference, str) and reference.startswith(REFERENCE_PREFIX):
return reference[len(REFERENCE_PREFIX) :] or None
return None
@@ -0,0 +1,184 @@
<!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:#b4232d;--red-deep:#7f111b;--red-2:#d34a53;--pink:#fff4f4;--bg:#f7f3f3;--card:#fff;--line:#ecd6d8;--text:#312b2c;--muted:#7a6b6d;--shadow:0 10px 30px rgba(98,24,33,.08);--radius:14px}
*{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}.app{min-height:100vh}.topbar{height:72px;background:linear-gradient(110deg,var(--red-deep),var(--red));color:#fff;display:flex;align-items:center;padding:0 28px;position:sticky;top:0;z-index:50;box-shadow:0 8px 30px rgba(91,12,20,.22)}
.brand{display:flex;align-items:center;gap:14px;min-width:360px}.brand-mark{width:42px;height:42px;border-radius:13px;background:rgba(255,255,255,.16);display:grid;place-items:center;border:1px solid rgba(255,255,255,.28);font-weight:900}.brand h1{font-size:19px;margin:0;letter-spacing:.4px}.brand p{margin:4px 0 0;font-size:12px;color:#f8dfe2}
.global-search{margin-left:auto;width:min(520px,42vw);position:relative}.global-search input{width:100%;height:42px;border-radius:22px;border:1px solid rgba(255,255,255,.3);background:rgba(255,255,255,.12);color:#fff;padding:0 46px 0 18px;outline:none}.global-search input::placeholder{color:#f4d8db}.search-icon{position:absolute;right:15px;top:10px;font-size:18px}.top-actions{display:flex;gap:8px;margin-left:14px}.ghost-btn{border:1px solid rgba(255,255,255,.32);background:rgba(255,255,255,.10);color:#fff;border-radius:10px;padding:9px 12px}
.tabbar{background:#fff;border-bottom:1px solid var(--line);display:flex;gap:6px;padding:10px 24px;position:sticky;top:72px;z-index:45;overflow:auto}.tab-btn{border:0;background:transparent;color:#6e5c5e;padding:11px 17px;border-radius:11px;white-space:nowrap;font-weight:700}.tab-btn:hover{background:var(--pink);color:var(--red)}.tab-btn.active{background:var(--red);color:#fff;box-shadow:0 7px 16px rgba(180,35,45,.22)}
.content{padding:22px 26px 50px;max-width:1680px;margin:0 auto}.tab-page{display:none}.tab-page.active{display:block}.page-head{display:flex;align-items:flex-start;justify-content:space-between;margin-bottom:17px;gap:16px}.page-head h2{margin:0;font-size:25px;color:var(--red-deep)}.page-head p{margin:7px 0 0;color:var(--muted);max-width:840px;line-height:1.7}.page-actions{display:flex;gap:8px}.btn{border:1px solid var(--line);background:#fff;color:var(--red-deep);padding:9px 13px;border-radius:10px;font-weight:700}.btn.primary{background:var(--red);color:#fff;border-color:var(--red)}.btn.soft{background:var(--pink);border-color:#f2c9cd}.btn:hover{transform:translateY(-1px)}
.hero{background:linear-gradient(125deg,#fff,#fff7f7);border:1px solid var(--line);border-radius:20px;box-shadow:var(--shadow);padding:24px;display:grid;grid-template-columns:1.25fr .75fr;gap:24px;position:relative;overflow:hidden}.hero:after{content:"";position:absolute;width:270px;height:270px;border-radius:50%;right:-110px;top:-125px;background:radial-gradient(circle,rgba(180,35,45,.15),rgba(180,35,45,0))}.hero-title{font-size:29px;margin:0;color:var(--red-deep)}.hero-desc{color:#655557;line-height:1.85;margin:13px 0 16px}.hero-tags{display:flex;gap:8px;flex-wrap:wrap}.chip{display:inline-flex;align-items:center;gap:5px;border-radius:999px;padding:6px 10px;background:var(--pink);color:var(--red-deep);border:1px solid #f1ced1;font-size:12px;font-weight:700}.hero-panel{display:grid;grid-template-columns:repeat(2,1fr);gap:10px;align-content:center}.mini-stat{background:#fff;border:1px solid var(--line);border-radius:14px;padding:13px 15px}.mini-stat b{font-size:24px;color:var(--red)}.mini-stat span{display:block;color:var(--muted);font-size:12px;margin-top:4px}
.stat-grid{display:grid;grid-template-columns:repeat(5,minmax(140px,1fr));gap:13px;margin-top:16px}.stat-card{background:#fff;border:1px solid var(--line);border-radius:var(--radius);padding:17px;box-shadow:0 5px 18px rgba(85,30,35,.045);position:relative}.stat-card:before{content:"";position:absolute;left:0;top:17px;width:4px;height:34px;background:var(--red);border-radius:0 4px 4px 0}.stat-card strong{font-size:27px;color:var(--red-deep);display:block}.stat-card span{font-size:13px;color:var(--muted);display:block;margin-top:5px}.section-title{display:flex;align-items:center;justify-content:space-between;margin:27px 0 12px}.section-title h3{margin:0;color:var(--red-deep);font-size:18px}.section-title small{color:var(--muted)}
.grid-2{display:grid;grid-template-columns:repeat(2,1fr);gap:15px}.grid-3{display:grid;grid-template-columns:repeat(3,1fr);gap:15px}.panel{background:#fff;border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:18px}.panel-title{display:flex;justify-content:space-between;align-items:center;margin-bottom:13px}.panel-title h3,.panel-title h4{margin:0;color:var(--red-deep)}.domain-bars{display:flex;flex-direction:column;gap:11px}.bar-row{display:grid;grid-template-columns:120px 1fr 44px;gap:10px;align-items:center;font-size:13px}.bar-track{height:9px;background:#f2e5e6;border-radius:8px;overflow:hidden}.bar-fill{height:100%;background:linear-gradient(90deg,var(--red),#e36b73);border-radius:8px}.system-card{padding:18px;border-radius:14px;border:1px solid var(--line);background:linear-gradient(145deg,#fff,#fff8f8)}.system-card h4{margin:0 0 7px;color:var(--red-deep)}.system-card p{color:var(--muted);line-height:1.7;margin:0}.system-list{display:flex;flex-wrap:wrap;gap:7px;margin-top:13px}
.toolbar{background:#fff;border:1px solid var(--line);border-radius:14px;padding:12px;display:flex;flex-wrap:wrap;gap:9px;align-items:center;margin-bottom:15px;box-shadow:0 5px 17px rgba(80,30,34,.035)}.control{height:38px;border:1px solid var(--line);border-radius:9px;background:#fff;padding:0 11px;color:var(--text);min-width:145px;outline:none}.control:focus{border-color:var(--red)}.toolbar .spacer{flex:1}.segmented{display:flex;background:#f5ebec;border-radius:9px;padding:3px}.seg-btn{border:0;background:transparent;padding:7px 10px;border-radius:7px;color:#6c595b}.seg-btn.active{background:#fff;color:var(--red);box-shadow:0 2px 8px rgba(90,30,35,.09)}
.card-grid{display:grid;grid-template-columns:repeat(3,minmax(260px,1fr));gap:14px}.record-card{background:#fff;border:1px solid var(--line);border-radius:15px;padding:17px;box-shadow:0 7px 22px rgba(90,28,35,.045);transition:.18s;position:relative;overflow:hidden}.record-card:hover{transform:translateY(-3px);box-shadow:0 14px 28px rgba(90,28,35,.09);border-color:#ddb3b7}.record-card:after{content:"";position:absolute;left:0;right:0;bottom:0;height:3px;background:linear-gradient(90deg,var(--red),#e7959a);opacity:.8}.record-top{display:flex;gap:10px;justify-content:space-between}.record-title{font-size:17px;font-weight:800;color:var(--red-deep);margin:0}.status{font-size:11px;border-radius:99px;padding:5px 8px;background:#f6e7e8;color:var(--red-deep);white-space:nowrap}.record-meta{display:flex;gap:6px;flex-wrap:wrap;margin:10px 0}.meta-pill{background:#f7f3f3;color:#715f61;border-radius:6px;padding:4px 7px;font-size:11px}.record-desc{font-size:13px;line-height:1.65;color:#665759;min-height:43px}.record-foot{display:flex;align-items:center;justify-content:space-between;margin-top:14px}.confidence{color:var(--muted);font-size:11px}.link-btn{border:0;background:transparent;color:var(--red);font-weight:800;padding:4px 0}
.table-wrap{display:none;background:#fff;border:1px solid var(--line);border-radius:14px;overflow:auto}.table-wrap.active{display:block}.data-table{width:100%;border-collapse:collapse;min-width:960px}.data-table th{background:#fff4f4;color:var(--red-deep);padding:12px;text-align:left;font-size:12px;position:sticky;top:0}.data-table td{padding:11px 12px;border-top:1px solid #f1e4e5;font-size:12px;vertical-align:top}.data-table tr:hover td{background:#fffafa}.pager{display:flex;justify-content:center;align-items:center;gap:7px;margin:17px}.pager button{border:1px solid var(--line);background:#fff;border-radius:8px;padding:7px 10px;color:var(--red-deep)}.pager button.active{background:var(--red);color:#fff}
.org-layout{display:grid;grid-template-columns:minmax(0,1fr) 360px;gap:15px}.org-canvas{background:#fff;border:1px solid var(--line);border-radius:15px;padding:20px;min-height:670px;overflow:auto;box-shadow:var(--shadow)}.org-tree,.org-tree ul{list-style:none;margin:0;padding-left:28px;position:relative}.org-tree>li{padding-left:0}.org-tree ul:before{content:"";position:absolute;top:0;bottom:18px;left:12px;border-left:1px solid #d8b4b7}.org-tree li{position:relative;padding:6px 0 6px 21px}.org-tree li:before{content:"";position:absolute;left:12px;top:25px;width:10px;border-top:1px solid #d8b4b7}.org-tree>li:before{display:none}.org-node{display:inline-grid;grid-template-columns:25px 1fr auto;gap:8px;align-items:center;min-width:235px;max-width:370px;border:1px solid var(--line);background:#fff;border-radius:11px;padding:9px 10px;box-shadow:0 4px 12px rgba(100,30,36,.05);transition:.17s}.org-node:hover,.org-node.selected{border-color:var(--red);background:#fff6f6}.expand-btn{width:24px;height:24px;border:0;background:#f5e4e6;color:var(--red);border-radius:7px;font-weight:900}.org-node-name{font-size:13px;font-weight:800;color:var(--red-deep)}.org-node-type{font-size:10px;color:var(--muted);margin-top:3px}.org-side{background:#fff;border:1px solid var(--line);border-radius:15px;padding:18px;box-shadow:var(--shadow);height:max-content;position:sticky;top:140px}.org-empty{color:var(--muted);line-height:1.8;text-align:center;padding:55px 15px}.org-side h3{color:var(--red-deep);margin:0 0 10px}.kv-grid{display:grid;grid-template-columns:repeat(2,1fr);gap:9px}.kv-card{background:#fff8f8;border:1px solid #f1dddd;border-radius:10px;padding:10px}.kv-card small{color:var(--muted);display:block;margin-bottom:4px}.kv-card b{color:var(--red-deep);font-size:13px}.side-summary{font-size:13px;line-height:1.7;color:#65565a;margin:12px 0}.relation-list{display:flex;flex-wrap:wrap;gap:6px}.relation-chip{font-size:11px;background:#f8eeee;color:#7d2830;border:1px solid #eed2d5;border-radius:999px;padding:5px 8px}
.subtabs{display:flex;gap:8px;margin-bottom:13px}.subtab-btn{border:1px solid var(--line);background:#fff;border-radius:10px;padding:9px 14px;color:#6e5b5d;font-weight:700}.subtab-btn.active{background:var(--red);color:#fff;border-color:var(--red)}
.timeline{position:relative;padding-left:20px}.timeline:before{content:"";position:absolute;left:5px;top:4px;bottom:4px;width:2px;background:#edd4d7}.timeline-item{position:relative;padding:0 0 18px 15px}.timeline-item:before{content:"";position:absolute;left:-19px;top:4px;width:10px;height:10px;border-radius:50%;background:var(--red);box-shadow:0 0 0 4px #f8e8ea}.timeline-item h5{margin:0 0 5px;color:var(--red-deep)}.timeline-item p{margin:0;color:var(--muted);font-size:12px;line-height:1.6}
.drawer-mask,.modal-mask{position:fixed;inset:0;background:rgba(45,18,21,.42);z-index:80;display:none}.drawer-mask.open,.modal-mask.open{display:block}.drawer{position:absolute;right:0;top:0;height:100%;width:min(490px,94vw);background:#fff;box-shadow:-18px 0 45px rgba(50,15,20,.18);display:flex;flex-direction:column}.drawer-head{background:linear-gradient(115deg,var(--red-deep),var(--red));color:#fff;padding:20px}.drawer-head h3{margin:0;font-size:22px}.drawer-head p{margin:7px 0 0;color:#f6dfe1;font-size:13px}.close-btn{position:absolute;right:15px;top:14px;width:34px;height:34px;border-radius:10px;border:1px solid rgba(255,255,255,.3);background:rgba(255,255,255,.12);color:#fff;font-size:20px}.drawer-body{padding:18px;overflow:auto;flex:1}.drawer-actions{padding:14px 18px;border-top:1px solid var(--line);display:flex;gap:8px}.quick-section{border:1px solid var(--line);border-radius:12px;padding:13px;margin-bottom:11px}.quick-section h4{margin:0 0 8px;color:var(--red-deep)}.quick-section p{margin:0;color:#65575a;line-height:1.65;font-size:13px}
.modal-mask{padding:26px;overflow:auto}.modal-page{max-width:1250px;margin:0 auto;background:#fff;border-radius:18px;min-height:calc(100vh - 52px);box-shadow:0 30px 80px rgba(50,10,17,.3);overflow:hidden}.modal-hero{background:linear-gradient(120deg,var(--red-deep),var(--red));color:#fff;padding:28px 32px;position:relative}.modal-hero h2{margin:0;font-size:27px}.modal-hero p{margin:9px 0;color:#f8dfe1;line-height:1.7;max-width:850px}.modal-tabs{display:flex;gap:6px;padding:11px 20px;border-bottom:1px solid var(--line);overflow:auto;position:sticky;top:0;background:#fff;z-index:2}.modal-tab{border:0;background:#fff4f4;color:#6e5356;border-radius:9px;padding:9px 12px;white-space:nowrap}.modal-tab.active{background:var(--red);color:#fff}.modal-content{padding:22px 27px}.detail-section{display:none}.detail-section.active{display:block}.detail-section h3{color:var(--red-deep);margin:0 0 15px}.detail-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:13px}.detail-block{border:1px solid var(--line);border-radius:13px;padding:15px;background:linear-gradient(145deg,#fff,#fffbfb)}.detail-block h4{margin:0 0 8px;color:#7f1b24;font-size:14px}.detail-value{color:#5f5153;font-size:13px;line-height:1.75;word-break:break-word}.detail-value ul{margin:5px 0;padding-left:19px}.detail-value li{margin:5px 0}.unknown{color:#9a7d80;font-style:italic}.markdown-content{line-height:1.85;color:#574a4c}.markdown-content h1,.markdown-content h2,.markdown-content h3{color:var(--red-deep)}.markdown-content code{background:#f7eded;padding:2px 5px;border-radius:5px}.markdown-content pre{background:#2c2526;color:#fff;border-radius:10px;padding:13px;overflow:auto}
.search-hero{background:#fff;border:1px solid var(--line);border-radius:16px;padding:22px;box-shadow:var(--shadow)}.big-search{display:flex;gap:9px}.big-search input{flex:1;height:50px;border:1px solid var(--line);border-radius:12px;padding:0 16px;font-size:15px;outline:none}.big-search input:focus{border-color:var(--red);box-shadow:0 0 0 4px #f7e4e6}.search-groups{display:grid;grid-template-columns:190px 1fr;gap:16px;margin-top:16px}.facet{background:#fff;border:1px solid var(--line);border-radius:13px;padding:13px;height:max-content}.facet h4{margin:0 0 10px;color:var(--red-deep)}.facet label{display:flex;gap:8px;align-items:center;font-size:12px;margin:8px 0;color:#625456}.result-list{display:flex;flex-direction:column;gap:10px}.search-result{background:#fff;border:1px solid var(--line);border-radius:13px;padding:15px}.search-result h4{margin:0;color:var(--red-deep)}.search-result p{margin:7px 0;color:#67585a;line-height:1.6;font-size:13px}.mark{background:#ffe0a8;color:#6f3500;padding:0 2px;border-radius:2px}.empty-state{padding:65px 20px;text-align:center;color:var(--muted);background:#fff;border:1px dashed #dbbec1;border-radius:14px}
.notice{border-left:4px solid var(--red);background:#fff7f7;padding:12px 14px;border-radius:0 10px 10px 0;color:#675457;font-size:12px;line-height:1.7}.footer-note{text-align:center;color:#8a7779;font-size:11px;margin-top:28px}
@media(max-width:1100px){.stat-grid{grid-template-columns:repeat(3,1fr)}.card-grid,.grid-3{grid-template-columns:repeat(2,1fr)}.org-layout{grid-template-columns:1fr}.org-side{position:static}.hero{grid-template-columns:1fr}.search-groups{grid-template-columns:1fr}}
@media(max-width:720px){.topbar{padding:0 13px}.brand{min-width:0}.brand p{display:none}.global-search{display:none}.content{padding:16px 12px}.tabbar{padding:8px 10px;top:72px}.stat-grid,.card-grid,.grid-2,.grid-3,.detail-grid{grid-template-columns:1fr}.page-head{flex-direction:column}.org-node{min-width:210px}.modal-mask{padding:0}.modal-page{min-height:100vh;border-radius:0}.search-groups{grid-template-columns:1fr}}
/* v1.1 全文增强详情 */
.drawer{width:min(620px,96vw)}
.drawer-body{background:linear-gradient(180deg,#fff,#fffafa)}
.drawer-summary{font-size:14px;line-height:1.85;color:#594a4c;margin:0 0 14px}
.drawer-metrics{display:grid;grid-template-columns:repeat(4,1fr);gap:8px;margin:12px 0 15px}
.drawer-metric{border:1px solid var(--line);background:#fff;border-radius:11px;padding:10px;text-align:center}
.drawer-metric b{display:block;color:var(--red);font-size:20px}.drawer-metric span{font-size:11px;color:var(--muted)}
.drawer-section{background:#fff;border:1px solid var(--line);border-radius:13px;padding:14px;margin:11px 0}
.drawer-section h4{margin:0 0 9px;color:var(--red-deep);display:flex;justify-content:space-between;gap:8px}
.drawer-section .mini-content{font-size:13px;line-height:1.75;color:#625356}
.preview-list{margin:0;padding-left:19px}.preview-list li{margin:5px 0}
.source-path{font-family:Consolas,monospace;background:#f8eeee;border:1px solid #efd8da;border-radius:8px;padding:8px;word-break:break-all;font-size:11px;color:#79585c}
.modal-mask{padding:12px}.modal-page{max-width:1580px;min-height:calc(100vh - 24px);border-radius:16px}
.modal-hero{padding:24px 30px}.modal-hero h2{font-size:29px}.modal-hero-grid{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:20px;align-items:end}
.modal-hero-stats{display:grid;grid-template-columns:repeat(4,minmax(86px,1fr));gap:8px;min-width:390px}
.hero-stat{background:rgba(255,255,255,.12);border:1px solid rgba(255,255,255,.24);border-radius:11px;padding:10px;text-align:center}
.hero-stat b{display:block;font-size:20px}.hero-stat span{font-size:10px;color:#f7dfe1}
.modal-tabs{padding:10px 22px;top:0}.modal-content{padding:0;background:#faf7f7}
.detail-section{min-height:560px}
.detail-layout{display:grid;grid-template-columns:225px minmax(0,1fr) 285px;gap:16px;align-items:start;padding:20px}
.detail-nav,.detail-aside{position:sticky;top:67px;background:#fff;border:1px solid var(--line);border-radius:13px;padding:13px;max-height:calc(100vh - 120px);overflow:auto}
.detail-nav h4,.detail-aside h4{margin:0 0 10px;color:var(--red-deep)}
.toc-link{display:block;width:100%;text-align:left;border:0;background:transparent;color:#6c575a;border-left:3px solid transparent;padding:7px 8px;border-radius:5px;font-size:12px;line-height:1.4}
.toc-link:hover{background:#fff3f4;color:var(--red);border-left-color:var(--red)}
.toc-l3{padding-left:20px;color:#887477}.toc-l4{padding-left:30px;color:#9b8588}
.detail-main{min-width:0}
.detail-dashboard{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px}
.detail-module{background:#fff;border:1px solid var(--line);border-radius:14px;padding:17px;box-shadow:0 5px 18px rgba(80,25,32,.04);scroll-margin-top:82px}
.detail-module.full{grid-column:1/-1}.detail-module h3{margin:0 0 13px;color:var(--red-deep);font-size:18px;border-bottom:1px solid #f1e0e1;padding-bottom:9px}
.field-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px}
.field-card{border:1px solid #f0dfe0;background:linear-gradient(145deg,#fff,#fffafa);border-radius:11px;padding:12px;min-width:0}
.field-card.wide{grid-column:1/-1}.field-card h4{margin:0 0 7px;color:#81202a;font-size:13px}.field-value{font-size:13px;line-height:1.78;color:#594c4e;word-break:break-word}
.field-value p{margin:5px 0}.field-value ul,.field-value ol{padding-left:20px;margin:5px 0}.field-value li{margin:5px 0}
.value-chips{display:flex;flex-wrap:wrap;gap:6px}.value-chip{background:#fff1f2;border:1px solid #efd0d3;color:#7f242d;border-radius:8px;padding:5px 8px;font-size:12px}
.evidence-note{margin-top:8px;background:#fff5df;border-left:3px solid #d5942e;padding:8px 10px;border-radius:6px;color:#765a2e;font-size:11px}
.evidence-state{font-size:10px;border-radius:99px;padding:3px 7px;background:#eee;color:#6d6062;margin-left:6px}
.timeline{position:relative;margin:4px 0;padding-left:23px}.timeline:before{content:"";position:absolute;left:7px;top:5px;bottom:5px;border-left:2px solid #e5c0c3}.timeline-item{position:relative;padding:0 0 13px 8px;color:#5d4e50;font-size:13px;line-height:1.7}.timeline-item:before{content:"";position:absolute;left:-21px;top:6px;width:9px;height:9px;border-radius:50%;background:var(--red);box-shadow:0 0 0 4px #fae9ea}
.markdown-shell{background:#fff;border:1px solid var(--line);border-radius:14px;padding:24px;box-shadow:0 5px 18px rgba(80,25,32,.04);min-height:600px}
.markdown-content{font-size:14px;line-height:1.9;color:#4f4345}.markdown-content h1{font-size:26px;border-bottom:2px solid var(--red);padding-bottom:10px}.markdown-content h2{font-size:21px;margin-top:30px;border-left:5px solid var(--red);padding:6px 0 6px 12px;background:linear-gradient(90deg,#fff3f4,transparent)}.markdown-content h3{font-size:17px;margin-top:23px}.markdown-content h4{font-size:15px;color:#8f2731}.markdown-content blockquote{margin:14px 0;border-left:4px solid var(--red);background:#fff4f4;padding:12px 16px;color:#654b4f}.markdown-content table{width:100%;border-collapse:collapse;margin:15px 0;font-size:12px}.markdown-content th{background:#fff0f1;color:#7f111b}.markdown-content th,.markdown-content td{border:1px solid #ead5d7;padding:9px;vertical-align:top}.markdown-content hr{border:0;border-top:1px solid var(--line);margin:22px 0}.markdown-content a{color:var(--red)}
.wiki-link{border:0;background:#fff0f1;color:var(--red);font-weight:700;border-radius:6px;padding:2px 6px;cursor:pointer}.wiki-link:hover{background:var(--red);color:#fff}
.aside-stat-grid{display:grid;grid-template-columns:repeat(2,1fr);gap:7px}.aside-stat{background:#fff6f6;border:1px solid #efd9db;border-radius:9px;padding:8px}.aside-stat b{display:block;color:var(--red);font-size:17px}.aside-stat span{font-size:10px;color:var(--muted)}
.aside-group{margin-top:14px}.aside-group h5{margin:0 0 7px;color:#7d242d}.aside-list{display:flex;flex-direction:column;gap:6px}.aside-rel{border:1px solid #efd9db;background:#fffafa;border-radius:8px;padding:8px;text-align:left;color:#5e4d50;font-size:11px;cursor:pointer}.aside-rel:hover{border-color:var(--red);color:var(--red)}
.relation-groups{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:15px}.relation-group{background:#fff;border:1px solid var(--line);border-radius:13px;padding:15px}.relation-group h3{font-size:16px}.relation-cards{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:9px}.relation-card{border:1px solid #eed8da;background:#fffafa;border-radius:10px;padding:11px;cursor:pointer}.relation-card:hover{border-color:var(--red);transform:translateY(-1px)}.relation-card b{display:block;color:#7f111b}.relation-card span{font-size:11px;color:var(--muted)}
.evidence-page{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px;padding:20px}.evidence-panel{background:#fff;border:1px solid var(--line);border-radius:14px;padding:17px}.evidence-panel.wide{grid-column:1/-1}.evidence-panel h3{margin:0 0 12px;color:var(--red-deep)}
.original-badge{display:inline-flex;align-items:center;gap:5px;background:#ecf7ef;color:#2d6e42;border:1px solid #cce5d3;border-radius:99px;padding:5px 9px;font-size:11px;font-weight:700}
@media(max-width:1180px){.detail-layout{grid-template-columns:190px minmax(0,1fr)}.detail-aside{display:none}.modal-hero-grid{grid-template-columns:1fr}.modal-hero-stats{min-width:0}}
@media(max-width:820px){.detail-layout{display:block;padding:12px}.detail-nav{position:static;max-height:none;margin-bottom:12px}.detail-dashboard,.field-grid,.relation-groups,.relation-cards,.evidence-page{grid-template-columns:1fr}.detail-module,.field-card.wide,.evidence-panel.wide{grid-column:auto}.modal-hero-stats{grid-template-columns:repeat(2,1fr)}.drawer-metrics{grid-template-columns:repeat(2,1fr)}}
</style>
<style id="fallback-style">
#staticFallback{padding:22px 26px 50px;max-width:1680px;margin:0 auto}.fallback-alert{background:#fff7e8;border:1px solid #edd3a4;color:#735626;border-radius:12px;padding:12px 15px;margin:14px 0}.fallback-grid{display:grid;grid-template-columns:repeat(4,minmax(150px,1fr));gap:12px}.fallback-card{background:#fff;border:1px solid #ecd6d8;border-radius:14px;padding:17px;box-shadow:0 7px 20px rgba(90,28,35,.05)}.fallback-card b{display:block;color:#7f111b;font-size:25px}.fallback-card span{color:#7a6b6d;font-size:12px}.fallback-section{background:#fff;border:1px solid #ecd6d8;border-radius:15px;padding:18px;margin-top:15px}.fallback-section h3{color:#7f111b;margin:0 0 10px}.fallback-list{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px}.fallback-list div{background:#fff7f7;border:1px solid #f0dfe0;border-radius:9px;padding:10px;color:#594c4e}.runtime-error{background:#fff0f0;border:1px solid #d56a73;color:#7f111b;border-radius:10px;padding:12px;margin:12px 26px;font-weight:700}@media(max-width:800px){.fallback-grid,.fallback-list{grid-template-columns:1fr 1fr}}@media(max-width:520px){.fallback-grid,.fallback-list{grid-template-columns:1fr}}
</style>
</head>
<body>
<div class="app">
<header class="topbar">
<div class="brand"><div class="brand-mark">海</div><div><h1>台湾海上作战力量知识展示系统</h1><p>公开来源知识图谱与档案检索平台</p></div></div>
<div class="global-search"><input id="globalSearch" placeholder="搜索单位、舰艇、装备、基地、项目或任务…"><span class="search-icon">⌕</span></div>
<div class="top-actions"><button class="ghost-btn" id="dataInfoBtn">数据说明</button></div>
</header>
<nav class="tabbar" id="tabbar"><button class="tab-btn active" data-tab="总览">总览</button><button class="tab-btn " data-tab="组织机构">组织机构</button><button class="tab-btn " data-tab="单位档案">单位档案</button><button class="tab-btn " data-tab="舰艇装备">舰艇与装备</button><button class="tab-btn " data-tab="基地保障">基地与保障</button><button class="tab-btn " data-tab="建军项目">建军项目</button><button class="tab-btn " data-tab="战略任务">战略任务</button><button class="tab-btn " data-tab="综合检索">综合检索</button></nav>
<div id="runtimeError" class="runtime-error" hidden></div>
<section id="staticFallback">
<div class="hero">
<div><h2 class="hero-title">海上力量知识数据一体化展示</h2><p class="hero-desc">页面正在载入完整知识数据。即使浏览器限制脚本,下面仍会显示知识库概况和主要入口,不会出现空白页面。</p><div class="hero-tags"><span class="chip">公开来源</span><span class="chip">415个知识条目</span><span class="chip">Markdown全文接入</span><span class="chip">红白业务界面</span></div></div>
<div class="hero-panel"><div class="mini-stat"><b>415</b><span>知识条目</span></div><div class="mini-stat"><b>351</b><span>实体关系</span></div><div class="mini-stat"><b>159</b><span>组织与单位</span></div><div class="mini-stat"><b>135</b><span>舰艇档案</span></div></div>
</div>
<div class="fallback-alert">若页面始终停留在此静态概览,说明当前预览环境禁止执行本地脚本。下载本页面或完整包后,用 Chrome、Edge 等浏览器打开即可使用全部查询、组织图、弹框和二级详情功能。</div>
<div class="fallback-grid">
<div class="fallback-card"><b>159</b><span>组织与单位</span></div><div class="fallback-card"><b>38</b><span>舰级档案</span></div><div class="fallback-card"><b>97</b><span>单舰履历</span></div><div class="fallback-card"><b>37</b><span>非舰艇装备</span></div>
<div class="fallback-card"><b>12</b><span>战略环境</span></div><div class="fallback-card"><b>23</b><span>基地与保障</span></div><div class="fallback-card"><b>20</b><span>建军项目</span></div><div class="fallback-card"><b>25</b><span>战略任务</span></div>
</div>
<div class="fallback-section"><h3>主要知识入口</h3><div class="fallback-list"><div>组织机构:海军、海巡双根组织架构及单位关系</div><div>单位档案:沿革、任务、装备、训练、部署和保障</div><div>舰艇装备:舰级共性、单舰履历和非舰艇装备</div><div>基地保障:军港、外岛、船厂、维修和韧性体系</div><div>建军项目:潜艇、轻巡、岸置反舰、无人和海巡造舰</div><div>战略任务:平时、危机、战时和恢复阶段任务链</div></div></div>
</section>
<main class="content">
<section class="tab-page active" data-tab="总览" id="page-overview"></section>
<section class="tab-page" data-tab="组织机构" id="page-org"></section>
<section class="tab-page" data-tab="单位档案" id="page-units"></section>
<section class="tab-page" data-tab="舰艇装备" id="page-equipment"></section>
<section class="tab-page" data-tab="基地保障" id="page-bases"></section>
<section class="tab-page" data-tab="建军项目" id="page-projects"></section>
<section class="tab-page" data-tab="战略任务" id="page-tasks"></section>
<section class="tab-page" data-tab="综合检索" id="page-search"></section>
</main>
</div>
<div class="drawer-mask" id="drawerMask"><aside class="drawer"><button class="close-btn" id="drawerClose">×</button><div class="drawer-head" id="drawerHead"></div><div class="drawer-body" id="drawerBody"></div><div class="drawer-actions"><button class="btn" id="drawerCancel">关闭</button><button class="btn primary" id="fullDetailBtn">查看完整档案</button></div></aside></div>
<div class="modal-mask" id="modalMask"><article class="modal-page"><div class="modal-hero" id="modalHero"><button class="close-btn" id="modalClose">×</button></div><div class="modal-tabs" id="modalTabs"></div><div class="modal-content" id="modalContent"></div></article></div>
<script type="application/json" id="pageConfigData">{"系统信息":{"系统名称":"台湾海上作战力量知识展示系统","副标题":"公开来源知识图谱与档案检索平台","版本":"1.2","数据版本":"LLM WIKI v4.0 全文稳健版","语言":"中文","默认标签页":"总览"},"主题":{"主色":"#b4232d","深色":"#7f111b","浅色":"#fff4f4","背景":"#f7f3f3","卡片":"#ffffff","边框":"#ecd6d8","正文":"#312b2c","辅助文字":"#7a6b6d","圆角":"14px","阴影":"0 10px 30px rgba(98, 24, 33, .08)"},"全局交互":{"支持标签页切换":true,"支持全局检索":true,"支持关键词高亮":true,"支持卡片表格切换":true,"支持详情侧栏":true,"支持完整二级详情页":true,"支持组织节点展开收起":true,"支持关联跳转":true,"支持状态与类型筛选":true,"默认分页条数":12,"支持Markdown原文全文展示":true,"支持原文目录导航":true,"支持结构化档案全量展示":true,"支持时间线式事件展示":true},"标签页":[{"标识":"总览","名称":"总览","布局":"仪表盘","数据源":["统计数据","战略环境","建军项目","战略任务"],"区块":["顶部摘要","统计卡片","知识域覆盖","体系概览","重点项目","任务入口","数据质量说明"]},{"标识":"组织机构","名称":"组织机构","布局":"组织架构图+详情侧栏","数据源":["组织树","单位档案","关系数据"],"筛选":["所属体系","单位层级","单位类型","关系类型"],"详情方式":["右侧摘要","全屏二级详情"]},{"标识":"单位档案","名称":"单位档案","布局":"卡片/表格","数据源":["单位档案"],"筛选":["单位名称","所属体系","单位类型","单位层级","公开区域","可信度"],"详情方式":["详情侧栏","全屏二级详情"]},{"标识":"舰艇装备","名称":"舰艇与装备","布局":"三级子标签","子标签":["舰级档案","单舰履历","非舰艇装备"],"筛选":["名称","状态","平台类别","装备类别","使用方"],"详情方式":["详情弹框","全屏二级详情"]},{"标识":"基地保障","名称":"基地与保障","布局":"专题卡片+关系面板","数据源":["基地与保障专题","单位关系"],"筛选":["关键词","基地类型","关联单位"],"详情方式":["专题详情页"]},{"标识":"建军项目","名称":"建军项目","布局":"项目卡片+时间线","数据源":["建军项目专题"],"筛选":["项目名称","状态关键词","关联单位装备"],"详情方式":["项目详情页"]},{"标识":"战略任务","名称":"战略任务","布局":"任务卡片+任务链","数据源":["战略任务专题","关系数据"],"筛选":["任务名称","任务阶段","主责单位"],"详情方式":["任务详情页"]},{"标识":"综合检索","名称":"综合检索","布局":"跨库检索","数据源":["全部知识条目"],"筛选":["关键词","知识类别","状态","可信度"],"详情方式":["结果预览","完整详情"]}],"详情页模板":{"单位档案":["页头摘要","基本信息","沿革与组织","任务与能力","装备与保障","活动与事件","关联关系","资料与边界"],"舰级档案":["页头摘要","概览","动力与机动","感知与通信","武器与载荷","发展与差异","关联单舰","资料来源"],"单舰履历":["页头摘要","基本信息","生命周期","训练与行动","关联舰级","资料来源"],"装备档案":["页头摘要","基本信息","技术参数","任务与编配","人员与保障","能力评估","关联关系","资料来源"],"专题档案":["页头摘要","知识正文","关联实体","资料状态"],"统一增强详情":["完整档案","知识原文","关联关系","资料与证据","左侧章节导航","右侧信息摘要","关联实体跳转"]},"中文状态":{"现役":"正常","有限现役":"注意","测试中":"测试","在建":"建设","规划中":"规划","转型中":"转型","历史":"历史","待核":"待核"}}</script>
<script type="application/json" id="appData">{"元数据":{"名称":"台湾海上作战力量前端展示数据(单文件稳健版)","版本":"1.2","数据来源版本":"LLM WIKI v4.0 原子知识页","更新时间":"2026-06-24","原文接入":true},"统计数据":{"知识条目":415,"组织与单位":159,"舰级档案":38,"单舰履历":97,"非舰艇装备":37,"战略环境":12,"基地与保障":23,"建军项目":20,"战略任务":25,"关系数据":351,"方法规范":4},"组织树":[{"编号":"NAVY-HQ","名称":"海军司令部","单位类型":"军种司令部","单位层级":"军种级","所属体系":"海军","子节点":[{"编号":"NAVY-HQ-COMPTROLLER","名称":"主计处","单位类型":"幕僚单位","单位层级":"机关幕僚","所属体系":"海军","子节点":[]},{"编号":"NAVY-HQ-PERSONNEL","名称":"人事军务处","单位类型":"幕僚单位","单位层级":"机关幕僚","所属体系":"海军","子节点":[]},{"编号":"NAVY-HQ-INTEL","名称":"军事情报处","单位类型":"幕僚单位","单位层级":"机关幕僚","所属体系":"海军","子节点":[]},{"编号":"NAVY-RECRUIT-SOUTH","名称":"南区人才招募中心","单位类型":"人才招募单位","单位层级":"专业或基层单位","所属体系":"海军","子节点":[]},{"编号":"NAVY-HQ-LOG","名称":"后勤处","单位类型":"幕僚单位","单位层级":"机关幕僚","所属体系":"海军","子节点":[]},{"编号":"NAVY-HQ-READINESS","名称":"战备训练处","单位类型":"幕僚单位","单位层级":"机关幕僚","所属体系":"海军","子节点":[]},{"编号":"NAVY-HQ-COMBAT-SYS","名称":"战斗系统处","单位类型":"幕僚单位","单位层级":"机关幕僚","所属体系":"海军","子节点":[]},{"编号":"NAVY-HQ-POLWAR","名称":"政治作战室","单位类型":"幕僚单位","单位层级":"机关幕僚","所属体系":"海军","子节点":[]},{"编号":"NAVY-ETD-CMD","名称":"教育训练暨准则发展指挥部","单位类型":"指挥部","单位层级":"一级或二级指挥机关","所属体系":"海军","子节点":[{"编号":"TRG-NAVY-SPEC","名称":"海军专长训练中心","单位类型":"训练中心","单位层级":"专业训练机构","所属体系":"海军","子节点":[]},{"编号":"TRG-NAVY-RECRUIT","名称":"海军新兵训练中心","单位类型":"训练中心","单位层级":"专业训练机构","所属体系":"海军","子节点":[]},{"编号":"TRG-MARINE-SPEC","名称":"海军陆战队专长训练中心","单位类型":"训练中心","单位层级":"专业训练机构","所属体系":"海军","子节点":[]},{"编号":"TRG-MARINE-RECRUIT","名称":"海军陆战队新兵训练中心","单位类型":"训练中心","单位层级":"专业训练机构","所属体系":"海军","子节点":[]}]},{"编号":"NAVY-MAINT-CMD","名称":"海军保修指挥部","单位类型":"指挥部","单位层级":"一级或二级指挥机关","所属体系":"海军","子节点":[{"编号":"MAINT-KEELUNG","名称":"基隆后勤支援指挥部","单位类型":"后勤支援指挥部","单位层级":"区域后勤单位","所属体系":"海军","子节点":[]},{"编号":"MAINT-ZUOYING","名称":"左营后勤支援指挥部","单位类型":"后勤支援指挥部","单位层级":"区域后勤单位","所属体系":"海军","子节点":[]},{"编号":"MAINT-COMBAT-SYS-FACTORY","名称":"海军战斗系统工厂","单位类型":"工厂","单位层级":"专业或基层单位","所属体系":"海军","子节点":[]},{"编号":"MAINT-SUPPLY-DEPOT","名称":"海军补给总库","单位类型":"库","单位层级":"专业或基层单位","所属体系":"海军","子节点":[]},{"编号":"MAINT-SUAO","名称":"苏澳后勤支援指挥部","单位类型":"后勤支援指挥部","单位层级":"区域后勤单位","所属体系":"海军","子节点":[]},{"编号":"MAINT-MAGONG","名称":"马公后勤支援指挥部","单位类型":"后勤支援指挥部","单位层级":"区域后勤单位","所属体系":"海军","子节点":[]}]},{"编号":"NAVY-ACADEMY","名称":"海军军官学校","单位类型":"学校","单位层级":"专业或基层单位","所属体系":"海军","子节点":[]},{"编号":"NAVY-SERVICE-GRP","名称":"海军勤务大队","单位类型":"大队","单位层级":"专业或基层单位","所属体系":"海军","子节点":[]},{"编号":"NAVY-MEDICAL-OFFICE","名称":"海军司令部医务所","单位类型":"直属支援单位","单位层级":"专业或基层单位","所属体系":"海军","子节点":[]},{"编号":"NAVY-OCEAN-BUREAU","名称":"海军大气海洋局","单位类型":"局","单位层级":"专业或基层单位","所属体系":"海军","子节点":[]},{"编号":"NAVY-FLEET-CMD","名称":"海军舰队指挥部","单位类型":"指挥部","单位层级":"一级或二级指挥机关","所属体系":"海军","子节点":[{"编号":"NAVY-131","名称":"一三一舰队","单位类型":"舰队","单位层级":"作战舰队","所属体系":"海军","子节点":[]},{"编号":"NAVY-19Line truncated
<script>
window.__PAGE_CONFIG__=JSON.parse(document.getElementById('pageConfigData').textContent);
window.__APP_DATA__=JSON.parse(document.getElementById('appData').textContent);
const C=window.__PAGE_CONFIG__, D=window.__APP_DATA__;
const state={tab:'总览',selected:null,unitView:'card',unitPage:1,equipSub:'舰级档案',equipPage:1,searchQuery:'',searchTypes:new Set()};
const esc=s=>String(s??'').replace(/[&<>"']/g,m=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[m]));
const flat=v=>{if(v==null)return'';if(Array.isArray(v))return v.map(flat).filter(Boolean).join(';');if(typeof v==='object'){if('值'in v)return flat(v.值);return Object.entries(v).map(([k,x])=>`${k}:${flat(x)}`).filter(x=>!x.endsWith(':')).join(';')}return String(v)};
const list=v=>{if(v==null||v==='')return[];if(Array.isArray(v))return v.flatMap(list).filter(Boolean);if(typeof v==='object'&&'值'in v)return list(v.值);return [String(v)]};
const getAll=()=>['单位档案','舰级档案','单舰履历','非舰艇装备','战略环境','基地与保障','建军项目','战略任务','方法规范'].flatMap(k=>D[k]||[]);
const recById=id=>getAll().find(x=>x.编号===id);
const pageName={总览:'总览',组织机构:'组织机构',单位档案:'单位档案',舰艇装备:'舰艇与装备',基地保障:'基地与保障',建军项目:'建军项目',战略任务:'战略任务',综合检索:'综合检索'};
function switchTab(tab){state.tab=tab;document.querySelectorAll('.tab-btn').forEach(b=>b.classList.toggle('active',b.dataset.tab===tab));document.querySelectorAll('.tab-page').forEach(p=>p.classList.toggle('active',p.dataset.tab===tab));if(tab==='综合检索')setTimeout(()=>document.getElementById('searchInput')?.focus(),80);location.hash=encodeURIComponent(tab)}
function renderTabbar(){document.getElementById('tabbar').innerHTML=C.标签页.map(x=>`<button class="tab-btn ${x.标识===state.tab?'active':''}" data-tab="${x.标识}">${x.名称}</button>`).join('');document.querySelectorAll('.tab-btn').forEach(b=>b.onclick=()=>switchTab(b.dataset.tab))}
function statusClass(s){return /现役|已交装/.test(s)?'ok':/在建|测试|转型/.test(s)?'warn':'muted'}
function recordCard(r){const m=r.知识原文?.统计||{};return `<article class="record-card" data-id="${esc(r.编号)}"><div class="record-top"><h4 class="record-title">${esc(r.名称)}</h4><span class="status">${esc(r.状态||r.类别)}</span></div><div class="record-meta">${(r.标签||[]).slice(0,4).map(x=>`<span class="meta-pill">${esc(x)}</span>`).join('')}</div><div class="record-desc">${esc(r.摘要||'暂无摘要')}</div><div class="record-meta"><span class="meta-pill">${m.正文字符数||0}字</span><span class="meta-pill">${m.章节数||Object.keys(r.详情||{}).length}章节</span><span class="meta-pill">${(r.关联关系||[]).length}项关联</span></div><div class="record-foot"><span class="confidence">可信度:${esc(r.可信度||'待核')}</span><button class="link-btn" data-open="${esc(r.编号)}">查看完整知识 →</button></div></article>`}
function renderCards(el,records,page=1,pageSize=12){const start=(page-1)*pageSize,items=records.slice(start,start+pageSize);el.innerHTML=items.length?`<div class="card-grid">${items.map(recordCard).join('')}</div>${pager(records.length,page,pageSize)}`:`<div class="empty-state">未找到符合条件的数据</div>`;bindOpen(el)}
function pager(total,page,size){const pages=Math.ceil(total/size);if(pages<=1)return'';let btns='';for(let i=Math.max(1,page-2);i<=Math.min(pages,page+2);i++)btns+=`<button class="${i===page?'active':''}" data-page="${i}">${i}</button>`;return `<div class="pager"><button data-page="${Math.max(1,page-1)}">上一页</button>${btns}<button data-page="${Math.min(pages,page+1)}">下一页</button><span>共 ${total} 条</span></div>`}
function bindOpen(root=document){root.querySelectorAll('[data-open]').forEach(b=>b.onclick=e=>{e.stopPropagation();openDrawer(b.dataset.open)});root.querySelectorAll('.record-card').forEach(c=>c.onclick=()=>openDrawer(c.dataset.id))}
function prettyValue(v){if(v==null||v===''||(Array.isArray(v)&&!v.length))return'<span class="unknown">公开资料未确认</span>';if(typeof v==='object'&&!Array.isArray(v)&&'值'in v){const vals=list(v.值);return `${vals.length?`<ul>${vals.map(x=>`<li>${esc(x)}</li>`).join('')}</ul>`:'<span class="unknown">公开资料未确认</span>'}${v.说明?`<div class="notice">${esc(v.说明)}</div>`:''}`};if(Array.isArray(v))return v.length?`<ul>${v.map(x=>`<li>${esc(flat(x))}</li>`).join('')}</ul>`:'<span class="unknown">公开资料未确认</span>';if(typeof v==='object')return `<div class="detail-grid">${Object.entries(v).map(([k,x])=>`<div class="detail-block"><h4>${esc(k)}</h4><div class="detail-value">${prettyValue(x)}</div></div>`).join('')}</div>`;return esc(v)}
function isEmptyValue(v){return v==null||v===''||(Array.isArray(v)&&v.length===0)||(typeof v==='object'&&!Array.isArray(v)&&Object.keys(v).length===0)}
function meaningfulEntries(obj,limit=8){const out=[];const walk=(v,prefix='')=>{if(out.length>=limit||v==null)return;if(typeof v==='object'&&!Array.isArray(v)&&'值'in v){if(!isEmptyValue(v.值))out.push([prefix,v]);return}if(Array.isArray(v)){if(v.length)out.push([prefix,v]);return}if(typeof v==='object'){for(const [k,x] of Object.entries(v)){walk(x,prefix?`${prefix} / ${k}`:k);if(out.length>=limit)break}}else if(String(v).trim())out.push([prefix,v])};walk(obj);return out}
function renderPreviewValue(v){if(v&&typeof v==='object'&&!Array.isArray(v)&&'值'in v)v=v.值;if(Array.isArray(v))return `<ul class="preview-list">${v.slice(0,6).map(x=>`<li>${esc(flat(x))}</li>`).join('')}</ul>`;return `<div>${esc(shortText(flat(v),420))}</div>`}
function openDrawer(id){const r=recById(id);if(!r)return;state.selected=r;const m=r.知识原文?.统计||{};document.getElementById('drawerHead').innerHTML=`<h3>${esc(r.名称)}</h3><p>${esc(r.类别)} · ${esc(r.状态||'状态待核')} · 可信度 ${esc(r.可信度||'待核')}</p>`;const entries=meaningfulEntries(r.详情||{},7);document.getElementById('drawerBody').innerHTML=`<p class="drawer-summary">${esc(r.知识原文?.一句话浓缩||r.摘要||'')}</p><div class="drawer-metrics"><div class="drawer-metric"><b>${m.正文字符数||0}</b><span>原文字数</span></div><div class="drawer-metric"><b>${m.章节数||Object.keys(r.详情||{}).length}</b><span>知识章节</span></div><div class="drawer-metric"><b>${(r.关联关系||[]).length}</b><span>关联实体</span></div><div class="drawer-metric"><b>${esc(r.可信度||'待核')}</b><span>可信度</span></div></div><section class="drawer-section"><h4>核心档案摘要 <span class="original-badge">已接入MD原文</span></h4><div class="mini-content">${entries.map(([k,v])=>`<div class="field-card" style="margin-bottom:8px"><h4>${esc(k)}</h4><div class="field-value">${renderPreviewValue(v)}</div></div>`).join('')||'<span class="unknown">暂无结构化字段</span>'}</div></section><section class="drawer-section"><h4>原文章节</h4><div class="value-chips">${(r.知识原文?.目录||[]).filter(x=>x.层级===2).slice(0,12).map(x=>`<span class="value-chip">${esc(x.标题)}</span>`).join('')||'<span class="unknown">暂无目录</span>'}</div></section><section class="drawer-section"><h4>关联关系</h4><div class="relation-list">${(r.关联关系||[]).slice(0,12).map(x=>`<button class="aside-rel" data-open="${esc(x.编号)}">${esc(x.关系)} · ${esc(x.名称)}</button>`).join('')||'<span class="unknown">暂无结构化关系</span>'}</div></section><section class="drawer-section"><h4>来源文件</h4><div class="source-path">${esc(r.来源路径||r.知识原文?.来源文件||'未记录')}</div></section>`;document.getElementById('drawerMask').classList.add('open');bindOpen(document.getElementById('drawerBody'))}
function shortText(s,n){s=String(s||'');return s.length>n?s.slice(0,n)+'…':s}
function closeDrawer(){document.getElementById('drawerMask').classList.remove('open')}
function valueLooksTimeline(key,v){return /历史|时间|演习|部署|事件|事故|维修|改装|更新|里程碑|训练活动/.test(key)&&Array.isArray(v)&&v.length>0}
function renderRichValue(v,key=''){if(v==null||v===''||(Array.isArray(v)&&!v.length))return '<span class="unknown">公开资料未确认</span>';if(typeof v==='object'&&!Array.isArray(v)&&'值'in v){const stateLabel=v.状态?`<span class="evidence-state">${esc(v.状态)}</span>`:'';return `${renderRichValue(v.值,key)}${stateLabel}${v.说明?`<div class="evidence-note">${esc(v.说明)}</div>`:''}`};if(valueLooksTimeline(key,v))return `<div class="timeline">${v.map(x=>`<div class="timeline-item">${esc(flat(x))}</div>`).join('')}</div>`;if(Array.isArray(v)){const vals=v.map(flat).filter(Boolean);const chipMode=vals.length<=12&&vals.every(x=>x.length<=32);return chipMode?`<div class="value-chips">${vals.map(x=>`<span class="value-chip">${esc(x)}</span>`).join('')}</div>`:`<ul>${vals.map(x=>`<li>${esc(x)}</li>`).join('')}</ul>`}if(typeof v==='object')return `<div class="field-grid">${Object.entries(v).map(([k,x])=>fieldCard(k,x)).join('')}</div>`;const s=String(v);return s.includes('\n')?s.split(/\n+/).map(x=>`<p>${esc(x)}</p>`).join(''):esc(s)}
function fieldCard(k,v){const long=Array.isArray(v)&&v.length>4||typeof v==='string'&&v.length>170||typeof v==='object'&&!Array.isArray(v)&&!('值'in v);return `<div class="field-card ${long?'wide':''}"><h4>${esc(k)}</h4><div class="field-value">${renderRichValue(v,k)}</div></div>`}
function structuredModules(r){return Object.entries(r.详情||{}).map(([title,obj],i)=>`<section class="detail-module ${title==='知识正文'?'full':''}" id="struct-${i}"><h3>${esc(title)}</h3>${obj&&typeof obj==='object'&&!Array.isArray(obj)?`<div class="field-grid">${Object.entries(obj).map(([k,v])=>fieldCard(k,v)).join('')}</div>`:`<div class="field-value">${renderRichValue(obj,title)}</div>`}</section>`).join('')||'<div class="empty-state">暂无结构化档案</div>'}
function structuredToc(r){return Object.keys(r.详情||{}).map((t,i)=>`<button class="toc-link" data-scroll="struct-${i}">${esc(t)}</button>`).join('')}
function originalToc(r){return (r.知识原文?.目录||[]).filter(x=>x.层级>=2&&x.层级<=4).map(x=>`<button class="toc-link toc-l${x.层级}" data-scroll="${esc(x.锚点)}">${esc(x.标题)}</button>`).join('')||'<span class="unknown">暂无目录</span>'}
function asideHtml(r){const m=r.知识原文?.统计||{};return `<div class="aside-stat-grid"><div class="aside-stat"><b>${m.正文字符数||0}</b><span>正文字符</span></div><div class="aside-stat"><b>${m.章节数||0}</b><span>原文章节</span></div><div class="aside-stat"><b>${(r.关联关系||[]).length}</b><span>关联关系</span></div><div class="aside-stat"><b>${esc(r.可信度||'待核')}</b><span>可信度</span></div></div><div class="aside-group"><h5>档案状态</h5><div class="value-chips"><span class="value-chip">${esc(r.类别)}</span><span class="value-chip">${esc(r.状态||'待核')}</span><span class="value-chip">${esc(r.更新时间||'')}</span></div></div><div class="aside-group"><h5>快速关联</h5><div class="aside-list">${(r.关联关系||[]).slice(0,12).map(x=>`<button class="aside-rel" data-open="${esc(x.编号)}"><b>${esc(x.关系)}</b><br>${esc(x.名称)}</button>`).join('')||'<span class="unknown">暂无关联</span>'}</div></div><div class="aside-group"><h5>来源文件</h5><div class="source-path">${esc(r.来源路径||'未记录')}</div></div>`}
function relationHtml(r){const grouped={};(r.关联关系||[]).forEach(x=>(grouped[x.关系]??=[]).push(x));return `<div style="padding:20px"><div class="relation-groups">${Object.entries(grouped).map(([rel,items])=>`<section class="relation-group"><h3>${esc(rel)} <span class="evidence-state">${items.length}</span></h3><div class="relation-cards">${items.map(x=>`<article class="relation-card" data-open="${esc(x.编号)}"><b>${esc(x.名称)}</b><span>${esc(x.方向)} · ${esc(x.编号)}</span></article>`).join('')}</div></section>`).join('')||'<div class="empty-state">暂无结构化关系</div>'}</div></div>`}
function evidenceHtml(r){const m=r.知识原文?.统计||{};const gaps=[];const walk=(v,k='')=>{if(v==null)return;if(Array.isArray(v)){if(/缺口|不确定|局限/.test(k))gaps.push(...v.map(flat));return}if(typeof v==='object'){for(const [a,b] of Object.entries(v))walk(b,a)}};walk(r.详情||{});return `<div class="evidence-page"><section class="evidence-panel"><h3>档案元数据</h3><div class="field-grid">${fieldCard('知识编号',r.编号)}${fieldCard('知识类别',r.类别)}${fieldCard('当前状态',r.状态||'待核')}${fieldCard('可信度',r.可信度||'待核')}${fieldCard('更新时间',r.更新时间||'未记录')}${fieldCard('来源路径',r.来源路径||'未记录')}</div></section><section class="evidence-panel"><h3>原文覆盖统计</h3><div class="field-grid">${Object.entries(m).map(([k,v])=>fieldCard(k,v)).join('')}</div></section><section class="evidence-panel wide"><h3>信息缺口与使用边界</h3>${gaps.length?`<ul>${[...new Set(gaps)].map(x=>`<li>${esc(x)}</li>`).join('')}</ul>`:'<div class="notice">未在结构化字段中提取到单独的信息缺口;仍需遵循不展示实时部署、精确阵位、库存和值班规律的边界。</div>'}</section><section class="evidence-panel wide"><h3>Markdown原文文件</h3><div class="source-path">${esc(r.知识原文?.来源文件||r.来源路径||'未记录')}</div><p>完整原文已嵌入“知识原文”标签,不再只展示摘要字段。</p></section></div>`}
function activateModalTab(key){document.querySelectorAll('.modal-tab').forEach(x=>x.classList.toggle('active',x.dataset.i===key));document.querySelectorAll('.detail-section').forEach(x=>x.classList.toggle('active',x.dataset.i===key))}
function bindScrollLinks(root=document){root.querySelectorAll('[data-scroll]').forEach(b=>b.onclick=()=>{const el=document.getElementById(b.dataset.scroll);el?.scrollIntoView({behavior:'smooth',block:'start'})})}
function openModal(r=state.selected){if(!r)return;closeDrawer();state.selected=r;const m=r.知识原文?.统计||{};document.getElementById('modalHero').innerHTML=`<button class="close-btn" id="modalClose2">×</button><div class="modal-hero-grid"><div><h2>${esc(r.名称)}</h2><p>${esc(r.知识原文?.一句话浓缩||r.摘要||'')}</p><div class="hero-tags">${(r.标签||[]).map(x=>`<span class="chip">${esc(x)}</span>`).join('')}<span class="chip">可信度 ${esc(r.可信度||'待核')}</span><span class="original-badge">Markdown原文已接入</span></div></div><div class="modal-hero-stats"><div class="hero-stat"><b>${m.正文字符数||0}</b><span>正文字符</span></div><div class="hero-stat"><b>${m.章节数||Object.keys(r.详情||{}).length}</b><span>知识章节</span></div><div class="hero-stat"><b>${(r.关联关系||[]).length}</b><span>关联实体</span></div><div class="hero-stat"><b>${esc(r.状态||'待核')}</b><span>当前状态</span></div></div></div>`;document.getElementById('modalTabs').innerHTML=`<button class="modal-tab active" data-i="structured">完整档案</button><button class="modal-tab" data-i="original">知识原文</button><button class="modal-tab" data-i="relations">关联关系</button><button class="modal-tab" data-i="evidence">资料与证据</button>`;document.getElementById('modalContent').innerHTML=`<section class="detail-section active" data-i="structured"><div class="detail-layout"><nav class="detail-nav"><h4>档案目录</h4>${structuredToc(r)}</nav><main class="detail-main"><div class="detail-dashboard">${structuredModules(r)}</div></main><aside class="detail-aside"><h4>档案摘要</h4>${asideHtml(r)}</aside></div></section><section class="detail-section" data-i="original"><div class="detail-layout"><nav class="detail-nav"><h4>原文目录</h4>${originalToc(r)}</nav><main class="detail-main"><article class="markdown-shell"><div class="markdown-content">${r.知识原文?.HTML||'<div class="empty-state">未接入Markdown原文</div>'}</div></article></main><aside class="detail-aside"><h4>原文信息</h4>${asideHtml(r)}</aside></div></section><section class="detail-section" data-i="relations">${relationHtml(r)}</section><section class="detail-section" data-i="evidence">${evidenceHtml(r)}</section>`;document.getElementById('modalMask').classList.add('open');document.getElementById('modalClose2').onclick=closeModal;document.querySelectorAll('.modal-tab').forEach(b=>b.onclick=()=>activateModalTab(b.dataset.i));bindOpen(document.getElementById('modalContent'));bindScrollLinks(document.getElementById('modalContent'))}
function renderSection(obj){if(obj&&typeof obj==='object'&&!Array.isArray(obj)&&Object.keys(obj).length===1&&'内容'in obj)return `<div class="markdown-content">${simpleMarkdown(obj.内容)}</div>`;if(obj&&typeof obj==='object'&&!Array.isArray(obj))return `<div class="detail-grid">${Object.entries(obj).map(([k,v])=>`<div class="detail-block"><h4>${esc(k)}</h4><div class="detail-value">${prettyValue(v)}</div></div>`).join('')}</div>`;return `<div class="detail-block"><div class="detail-value">${prettyValue(obj)}</div></div>`}
function simpleMarkdown(md){let s=esc(md||'');s=s.replace(/^### (.*)$/gm,'<h3>$1</h3>').replace(/^## (.*)$/gm,'<h2>$1</h2>').replace(/^# (.*)$/gm,'<h1>$1</h1>');s=s.replace(/\*\*(.*?)\*\*/g,'<strong>$1</strong>').replace(/`([^`]+)`/g,'<code>$1</code>');s=s.replace(/^[-*] (.*)$/gm,'<li>$1</li>').replace(/(?:<li>.*<\/li>\n?)+/g,m=>`<ul>${m}</ul>`);s=s.replace(/\n\n/g,'</p><p>').replace(/\n/g,'<br>');return `<p>${s}</p>`}
function closeModal(){document.getElementById('modalMask').classList.remove('open')}
function overview(){const s=D.统计数据;const domains=[['组织与单位',s.组织与单位],['舰级档案',s.舰级档案],['单舰履历',s.单舰履历],['非舰艇装备',s.非舰艇装备],['基地与保障',s.基地与保障],['建军项目',s.建军项目],['战略任务',s.战略任务]];const max=Math.max(...domains.map(x=>x[1]));document.getElementById('page-overview').innerHTML=`<div class="hero"><div><h2 class="hero-title">海上力量知识数据一体化展示</h2><p class="hero-desc">基于统一清单、单位档案、舰级与单舰履历、装备、基地、建军项目、战略任务和关系图谱,提供中文化、多标签、可查询、可钻取的业务展示。</p><div class="hero-tags"><span class="chip">公开来源</span><span class="chip">知识图谱</span><span class="chip">单位与舰艇双层档案</span><span class="chip">数据版本 v4.0 全文版</span></div></div><div class="hero-panel"><div class="mini-stat"><b>${s.知识条目}</b><span>知识条目</span></div><div class="mini-stat"><b>${s.关系数据}</b><span>实体关系</span></div><div class="mini-stat"><b>${s.组织与单位}</b><span>组织与单位</span></div><div class="mini-stat"><b>${s.舰级档案+s.单舰履历}</b><span>舰艇档案</span></div></div></div><div class="stat-grid">${domains.slice(0,5).map(([n,v])=>`<div class="stat-card"><strong>${v}</strong><span>${n}</span></div>`).join('')}</div><div class="grid-2"><div class="panel"><div class="panel-title"><h3>知识域覆盖</h3><small>条目数量</small></div><div class="domain-bars">${domains.map(([n,v])=>`<div class="bar-row"><span>${n}</span><div class="bar-track"><div class="bar-fill" style="width:${Math.max(6,v/max*100)}%"></div></div><b>${v}</b></div>`).join('')}</div></div><div class="panel"><div class="panel-title"><h3>组织体系</h3><button class="btn soft" onclick="switchTab('组织机构')">查看组织图</button></div><div class="grid-2"><div class="system-card"><h4>海军体系</h4><p>以海军司令部为根,覆盖舰队、陆战队、训练、保修、通信、监侦和造船发展。</p><div class="system-list"><span class="chip">舰队指挥</span><span class="chip">陆战队</span><span class="chip">训练保障</span></div></div><div class="system-card"><h4>海巡体系</h4><p>以海巡署为根,覆盖区域分署、舰队分署、侦防分署、巡防区及岸海侦防单位。</p><div class="system-list"><span class="chip">区域分署</span><span class="chip">巡防区</span><span class="chip">舰队分署</span></div></div></div></div></div><div class="section-title"><h3>重点建军项目</h3><button class="btn" onclick="switchTab('建军项目')">全部项目</button></div><div id="overviewProjects"></div><div class="section-title"><h3>战略任务入口</h3><button class="btn" onclick="switchTab('战略任务')">全部任务</button></div><div id="overviewTasks"></div><div class="notice">数据展示严格区分公开确认、分析归纳和待核信息;不展示实时部署、精确阵位、值班规律、弹药库存和非公开战时授权。</div><div class="footer-note">数据源:台湾海上作战力量 LLM WIKI v4.0(Markdown全文增强)</div>`;renderCards(document.getElementById('overviewProjects'),D.建军项目.slice(0,6),1,6);renderCards(document.getElementById('overviewTasks'),D.战略任务.slice(0,6),1,6)}
function renderOrg(){const types=[...new Set(D.单位档案.map(x=>x.单位类型).filter(Boolean))].sort();document.getElementById('page-org').innerHTML=`<div class="page-head"><div><h2>组织机构</h2><p>以组织树展示海军和海巡的行政层级;点击节点查看单位摘要,可进一步进入完整单位档案。行政隶属、勤务管制与协同关系分开表达。</p></div></div><div class="toolbar"><select class="control" id="orgSystem"><option value="全部">全部体系</option><option>海军</option><option>海军陆战队</option><option>海巡</option></select><select class="control" id="orgType"><option value="全部">全部单位类型</option>${types.map(x=>`<option>${esc(x)}</option>`).join('')}</select><input class="control" id="orgKeyword" placeholder="定位单位名称"><button class="btn soft" id="expandTwo">展开两级</button><button class="btn" id="collapseAll">全部收起</button></div><div class="org-layout"><div class="org-canvas"><ul class="org-tree" id="orgTree"></ul></div><aside class="org-side" id="orgSide"><div class="org-empty">点击组织图中的节点<br>查看单位摘要和关联信息</div></aside></div>`;drawOrg();document.getElementById('orgSystem').onchange=drawOrg;document.getElementById('orgType').onchange=drawOrg;document.getElementById('orgKeyword').oninput=drawOrg;document.getElementById('expandTwo').onclick=()=>{document.querySelectorAll('#orgTree ul').forEach((u,i)=>{if(Number(u.dataset.depth||9)<=2)u.style.display='block'})};document.getElementById('collapseAll').onclick=()=>document.querySelectorAll('#orgTree ul').forEach(u=>u.style.display='none')}
function drawOrg(){const sys=document.getElementById('orgSystem')?.value||'全部',typ=document.getElementById('orgType')?.value||'全部',kw=(document.getElementById('orgKeyword')?.value||'').trim();const filterNode=n=>{const rec=recById(n.编号);const own=(sys==='全部'||rec?.所属体系===sys)&&(typ==='全部'||rec?.单位类型===typ)&&(!kw||n.名称.includes(kw));const kids=(n.子节点||[]).map(filterNode).filter(Boolean);return own||kids.length?{...n,子节点:kids,_match:own}:null};let trees=D.组织树.map(filterNode).filter(Boolean);document.getElementById('orgTree').innerHTML=trees.map(n=>orgHtml(n,0)).join('')||'<li class="empty-state">未找到组织节点</li>';document.querySelectorAll('.expand-btn').forEach(b=>b.onclick=e=>{e.stopPropagation();const ul=b.closest('li').querySelector(':scope > ul');if(ul){const open=ul.style.display!=='none';ul.style.display=open?'none':'block';b.textContent=open?'+':'−'}});document.querySelectorAll('.org-node').forEach(n=>n.onclick=()=>selectOrg(n.dataset.id))}
function orgHtml(n,depth){const kids=n.子节点||[];return `<li><div class="org-node ${n._match?'selected':''}" data-id="${esc(n.编号)}">${kids.length?`<button class="expand-btn">${depth<1?'−':'+'}</button>`:'<span></span>'}<div><div class="org-node-name">${esc(n.名称)}</div><div class="org-node-type">${esc(n.单位类型)} · ${esc(n.单位层级)}</div></div><span class="status">${kids.length} 下级</span></div>${kids.length?`<ul data-depth="${depth+1}" style="display:${depth<1?'block':'none'}">${kids.map(k=>orgHtml(k,depth+1)).join('')}</ul>`:''}</li>`}
function selectOrg(id){const r=recById(id);if(!r)return;document.querySelectorAll('.org-node').forEach(n=>n.classList.toggle('selected',n.dataset.id===id));document.getElementById('orgSide').innerHTML=`<h3>${esc(r.名称)}</h3><div class="kv-grid"><div class="kv-card"><small>单位类型</small><b>${esc(r.单位类型)}</b></div><div class="kv-card"><small>单位层级</small><b>${esc(r.单位层级)}</b></div><div class="kv-card"><small>所属体系</small><b>${esc(r.所属体系)}</b></div><div class="kv-card"><small>可信度</small><b>${esc(r.可信度)}</b></div></div><p class="side-summary">${esc(r.摘要)}</p><div class="relation-list">${(r.关联关系||[]).slice(0,12).map(x=>`<span class="relation-chip">${esc(x.关系)} · ${esc(x.名称)}</span>`).join('')}</div><div style="margin-top:15px;display:flex;gap:8px"><button class="btn soft" onclick="openDrawer('${esc(id)}')">快速查看</button><button class="btn primary" onclick="openModal(recById('${esc(id)}'))">完整档案</button></div>`}
function unitPage(){const systems=[...new Set(D.单位档案.map(x=>x.所属体系).filter(Boolean))].sort(),types=[...new Set(D.单位档案.map(x=>x.单位类型).filter(Boolean))].sort();document.getElementById('page-units').innerHTML=`<div class="page-head"><div><h2>单位档案</h2><p>按统一档案结构查询组织与单位,支持卡片和表格视图;详情页包括沿革、组织、任务、装备、保障、训练、事件和资料边界。</p></div></div><div class="toolbar"><input class="control" id="unitKeyword" placeholder="搜索单位名称或任务"><select class="control" id="unitSystem"><option value="全部">全部体系</option>${systems.map(x=>`<option>${esc(x)}</option>`).join('')}</select><select class="control" id="unitType"><option value="全部">全部类型</option>${types.map(x=>`<option>${esc(x)}</option>`).join('')}</select><select class="control" id="unitConfidence"><option value="全部">全部可信度</option><option>A</option><option>A/B</option><option>B</option><option>C</option></select><div class="spacer"></div><div class="segmented"><button class="seg-btn active" data-v="card">卡片</button><button class="seg-btn" data-v="table">表格</button></div></div><div id="unitCards"></div><div class="table-wrap" id="unitTable"></div>`;['unitKeyword','unitSystem','unitType','unitConfidence'].forEach(id=>document.getElementById(id).oninput=()=>{state.unitPage=1;drawUnits()});document.querySelectorAll('#page-units .seg-btn').forEach(b=>b.onclick=()=>{state.unitView=b.dataset.v;document.querySelectorAll('#page-units .seg-btn').forEach(x=>x.classList.toggle('active',x===b));drawUnits()});drawUnits()}
function filteredUnits(){const kw=(document.getElementById('unitKeyword')?.value||'').trim(),sys=document.getElementById('unitSystem')?.value||'全部',typ=document.getElementById('unitType')?.value||'全部',conf=document.getElementById('unitConfidence')?.value||'全部';return D.单位档案.filter(r=>(!kw||`${r.名称}${r.摘要}${flat(r.详情)}${r.知识原文?.HTML||''}`.includes(kw))&&(sys==='全部'||r.所属体系===sys)&&(typ==='全部'||r.单位类型===typ)&&(conf==='全部'||r.可信度===conf))}
function drawUnits(){const records=filteredUnits(),cards=document.getElementById('unitCards'),table=document.getElementById('unitTable');if(state.unitView==='card'){cards.style.display='block';table.classList.remove('active');renderCards(cards,records,state.unitPage,12);cards.querySelectorAll('[data-page]').forEach(b=>b.onclick=()=>{state.unitPage=Number(b.dataset.page);drawUnits()})}else{cards.style.display='none';table.classList.add('active');table.innerHTML=`<table class="data-table"><thead><tr><th>单位名称</th><th>体系</th><th>单位类型</th><th>层级</th><th>上级单位</th><th>主要摘要</th><th>可信度</th><th>操作</th></tr></thead><tbody>${records.map(r=>`<tr><td><b>${esc(r.名称)}</b></td><td>${esc(r.所属体系)}</td><td>${esc(r.单位类型)}</td><td>${esc(r.单位层级)}</td><td>${esc(r.上级单位||'—')}</td><td>${esc(shortText(r.摘要,90))}</td><td>${esc(r.可信度)}</td><td><button class="link-btn" data-open="${esc(r.编号)}">详情</button></td></tr>`).join('')}</tbody></table>`;bindOpen(table)}}
function equipmentPage(){document.getElementById('page-equipment').innerHTML=`<div class="page-head"><div><h2>舰艇与装备</h2><p>舰级共性、单舰履历和非舰艇装备分层展示,避免将同型舰艇视为状态完全一致。</p></div></div><div class="subtabs"><button class="subtab-btn active" data-sub="舰级档案">舰级档案</button><button class="subtab-btn" data-sub="单舰履历">单舰履历</button><button class="subtab-btn" data-sub="非舰艇装备">非舰艇装备</button></div><div class="toolbar"><input class="control" id="equipKeyword" placeholder="搜索舰级、舰名或装备"><select class="control" id="equipStatus"><option value="全部">全部状态</option><option>现役</option><option>有限现役</option><option>测试中</option><option>在建</option><option>规划中</option><option>待核</option></select><select class="control" id="equipCategory"><option value="全部">全部类别</option></select></div><div id="equipCards"></div>`;document.querySelectorAll('.subtab-btn').forEach(b=>b.onclick=()=>{state.equipSub=b.dataset.sub;state.equipPage=1;document.querySelectorAll('.subtab-btn').forEach(x=>x.classList.toggle('active',x===b));fillEquipCategories();drawEquip()});document.getElementById('equipKeyword').oninput=()=>{state.equipPage=1;drawEquip()};document.getElementById('equipStatus').onchange=()=>{state.equipPage=1;drawEquip()};document.getElementById('equipCategory').onchange=()=>{state.equipPage=1;drawEquip()};fillEquipCategories();drawEquip()}
function equipSource(){return state.equipSub==='舰级档案'?D.舰级档案:state.equipSub==='单舰履历'?D.单舰履历:D.非舰艇装备}
function fillEquipCategories(){const src=equipSource(),key=state.equipSub==='舰级档案'?'平台类别':state.equipSub==='单舰履历'?'舰级':'装备类别';const vals=[...new Set(src.map(x=>x[key]).filter(Boolean))].sort();document.getElementById('equipCategory').innerHTML=`<option value="全部">全部类别</option>${vals.map(x=>`<option>${esc(x)}</option>`).join('')}`}
function drawEquip(){const kw=(document.getElementById('equipKeyword')?.value||'').trim(),st=document.getElementById('equipStatus')?.value||'全部',cat=document.getElementById('equipCategory')?.value||'全部',key=state.equipSub==='舰级档案'?'平台类别':state.equipSub==='单舰履历'?'舰级':'装备类别';const rec=equipSource().filter(r=>(!kw||`${r.名称}${r.摘要}${flat(r.详情)}${r.知识原文?.HTML||''}`.includes(kw))&&(st==='全部'||r.状态===st)&&(cat==='全部'||r[key]===cat));const el=document.getElementById('equipCards');renderCards(el,rec,state.equipPage,12);el.querySelectorAll('[data-page]').forEach(b=>b.onclick=()=>{state.equipPage=Number(b.dataset.page);drawEquip()})}
function thematicPage(target,data,title,desc){document.getElementById(target).innerHTML=`<div class="page-head"><div><h2>${title}</h2><p>${desc}</p></div></div><div class="toolbar"><input class="control" id="${target}Kw" placeholder="输入关键词进行筛选"><div class="spacer"></div><span class="chip">共 ${data.length} 条专题</span></div><div id="${target}Cards"></div>`;const draw=()=>{const kw=(document.getElementById(`${target}Kw`).value||'').trim();renderCards(document.getElementById(`${target}Cards`),data.filter(x=>!kw||`${x.名称}${x.摘要}${flat(x.详情)}${x.知识原文?.HTML||''}`.includes(kw)),1,18)};document.getElementById(`${target}Kw`).oninput=draw;draw()}
function searchPage(){const cats=[...new Set(getAll().map(x=>x.类别))];document.getElementById('page-search').innerHTML=`<div class="page-head"><div><h2>综合检索</h2><p>跨单位、舰级、单舰、装备、环境、基地、项目和任务进行全文检索,支持结果分类与关联跳转。</p></div></div><div class="search-hero"><div class="big-search"><input id="searchInput" placeholder="输入名称、任务、装备、基地或事件关键词"><button class="btn primary" id="searchBtn">检索</button></div><div class="search-groups"><aside class="facet"><h4>知识类别</h4>${cats.map(c=>`<label><input type="checkbox" value="${esc(c)}"> ${esc(c)} <span>(${getAll().filter(x=>x.类别===c).length})</span></label>`).join('')}</aside><div><div class="section-title"><h3 id="searchCount">检索结果</h3></div><div class="result-list" id="searchResults"></div></div></div></div>`;document.getElementById('searchBtn').onclick=drawSearch;document.getElementById('searchInput').onkeydown=e=>{if(e.key==='Enter')drawSearch()};document.querySelectorAll('.facet input').forEach(x=>x.onchange=drawSearch);drawSearch()}
function drawSearch(){const q=(document.getElementById('searchInput')?.value||state.searchQuery||'').trim();state.searchQuery=q;const checked=[...document.querySelectorAll('.facet input:checked')].map(x=>x.value);let rec=getAll().filter(r=>(!checked.length||checked.includes(r.类别))&&(!q||`${r.名称}${r.摘要}${flat(r.详情)}${r.知识原文?.HTML||''}${flat(r.关联关系)}`.toLowerCase().includes(q.toLowerCase()))).slice(0,100);document.getElementById('searchCount').textContent=`检索结果(${rec.length}${rec.length===100?'+':''})`;document.getElementById('searchResults').innerHTML=rec.length?rec.map(r=>`<article class="search-result"><div class="record-top"><h4>${highlight(r.名称,q)}</h4><span class="status">${esc(r.类别)}</span></div><p>${highlight(r.摘要||'',q)}</p><div class="record-foot"><div>${(r.标签||[]).slice(0,4).map(x=>`<span class="meta-pill">${esc(x)}</span>`).join('')}</div><button class="link-btn" data-open="${esc(r.编号)}">打开档案 →</button></div></article>`).join(''):'<div class="empty-state">没有检索到匹配内容</div>';bindOpen(document.getElementById('searchResults'))}
function highlight(s,q){s=esc(s);if(!q)return s;const safe=q.replace(/[.*+?^${}()|[\]\\]/g,'\\$&');return s.replace(new RegExp(safe,'ig'),m=>`<span class="mark">${m}</span>`)}
function init(){document.documentElement.dataset.appState='loading';const hash=decodeURIComponent(location.hash.slice(1));if(pageName[hash])state.tab=hash;renderTabbar();overview();renderOrg();unitPage();equipmentPage();thematicPage('page-bases',D.基地与保障,'基地与保障','以专题卡片呈现基地类型、基地体系、港口、船厂、后勤和韧性;详情中通过关系数据跳转关联单位与装备。');thematicPage('page-projects',D.建军项目,'建军项目','以项目专题展示建设目标、阶段、相关单位装备、形成战力条件和风险;复杂内容进入完整详情页。');thematicPage('page-tasks',D.战略任务,'战略任务','按平时、危机、战时和恢复任务展示任务目的、执行链、主责单位、装备基地与成功标准。');searchPage();switchTab(state.tab);document.getElementById('drawerClose').onclick=closeDrawer;document.getElementById('drawerCancel').onclick=closeDrawer;document.getElementById('drawerMask').onclick=e=>{if(e.target.id==='drawerMask')closeDrawer()};document.getElementById('modalMask').onclick=e=>{if(e.target.id==='modalMask')closeModal()};document.getElementById('modalClose').onclick=closeModal;document.getElementById('fullDetailBtn').onclick=()=>openModal();document.getElementById('globalSearch').onkeydown=e=>{if(e.key==='Enter'){state.searchQuery=e.target.value;switchTab('综合检索');document.getElementById('searchInput').value=state.searchQuery;drawSearch()}};document.getElementById('dataInfoBtn').onclick=()=>{const temp={编号:'DATA-INFO',名称:'数据说明',类别:'方法规范',状态:'公开数据',摘要:'本页面使用台湾海上作战力量 LLM WIKI v4.0 的结构化数据与415个Markdown原子知识页构建。',可信度:'A/B',标签:['公开来源','中文展示','不含实时敏感信息'],详情:{'数据范围':{'知识条目':D.统计数据.知识条目,'组织与单位':D.统计数据.组织与单位,'舰艇档案':D.统计数据.舰级档案+D.统计数据.单舰履历,'关系数据':D.统计数据.关系数据},'展示原则':{'原则':['舰级与单舰分层','行政隶属与勤务管制分开','未知信息明确标注','不展示实时部署与精确阵位']}}};state.selected=temp;openDrawerTemp(temp)}}
function openDrawerTemp(r){state.selected=r;document.getElementById('drawerHead').innerHTML=`<h3>${esc(r.名称)}</h3><p>${esc(r.类别)}</p>`;document.getElementById('drawerBody').innerHTML=`<p class="side-summary">${esc(r.摘要)}</p>${Object.entries(r.详情).map(([t,o])=>`<section class="quick-section"><h4>${esc(t)}</h4><p>${esc(shortText(flat(o),500))}</p></section>`).join('')}`;document.getElementById('drawerMask').classList.add('open')}
try{init();document.documentElement.dataset.appState='ready';const fb=document.getElementById('staticFallback');if(fb)fb.style.display='none';}catch(err){document.documentElement.dataset.appState='error';const eb=document.getElementById('runtimeError');if(eb){eb.hidden=false;eb.textContent='页面数据加载失败:'+(err&&err.message?err.message:String(err));}console.error(err);}
</script>
</body>
</html>
@@ -0,0 +1,382 @@
<!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 {
--bg1:#7f1d1d; --bg2:#450a0a; --card:#fffaf7; --card2:#ffffff; --text:#1f2937; --muted:#6b7280;
--red:#b91c1c; --red2:#dc2626; --gold:#b45309; --green:#047857; --blue:#1d4ed8; --purple:#7e22ce;
--line:#ead7d0; --soft:#fff1f2; --shadow:0 18px 45px rgba(69,10,10,.22);
}
*{box-sizing:border-box}
body{margin:0;font-family:"Microsoft YaHei", "PingFang SC", system-ui, -apple-system, sans-serif;background:radial-gradient(circle at 15% 10%, #b91c1c 0%, var(--bg1) 34%, var(--bg2) 100%);color:var(--text);min-height:100vh}
body::before{content:"";position:fixed;inset:0;background:linear-gradient(135deg,rgba(255,255,255,.08),transparent 35%),radial-gradient(circle at 85% 15%,rgba(255,255,255,.10),transparent 28%);pointer-events:none}
.header{position:sticky;top:0;z-index:20;backdrop-filter:blur(18px);background:rgba(69,10,10,.72);border-bottom:1px solid rgba(255,255,255,.16);padding:16px 28px;color:#fff}
.header-inner{display:flex;gap:18px;align-items:center;justify-content:space-between;max-width:1500px;margin:0 auto}
.title h1{margin:0;font-size:22px;letter-spacing:.04em}
.title p{margin:5px 0 0;font-size:12px;opacity:.78}
.search{display:flex;align-items:center;gap:8px;background:rgba(255,255,255,.14);border:1px solid rgba(255,255,255,.25);border-radius:999px;padding:8px 12px;min-width:340px}
.search input{background:transparent;border:none;outline:none;color:#fff;width:100%;font-size:14px}
.search input::placeholder{color:rgba(255,255,255,.68)}
.container{max-width:1500px;margin:24px auto 42px;padding:0 22px;position:relative;z-index:1}
.dashboard{display:grid;grid-template-columns:repeat(7,minmax(120px,1fr));gap:14px;margin-bottom:18px}
.stat{background:linear-gradient(180deg,rgba(255,255,255,.94),rgba(255,245,245,.92));border:1px solid rgba(255,255,255,.78);border-radius:18px;padding:14px 16px;box-shadow:var(--shadow)}
.stat .k{font-size:12px;color:var(--muted)}.stat .v{font-size:28px;font-weight:800;color:var(--red);margin-top:4px}
.tabs{display:flex;gap:8px;flex-wrap:wrap;margin:14px 0 18px}
.tab{border:none;border-radius:999px;background:rgba(255,255,255,.9);color:#7f1d1d;padding:10px 15px;font-weight:700;cursor:pointer;box-shadow:0 8px 18px rgba(0,0,0,.08)}
.tab.active{background:#7f1d1d;color:#fff;border:1px solid rgba(255,255,255,.5)}
.panel{background:rgba(255,250,247,.96);border:1px solid rgba(255,255,255,.7);border-radius:26px;box-shadow:var(--shadow);overflow:hidden}
.module-head{padding:22px 24px;border-bottom:1px solid var(--line);display:flex;justify-content:space-between;gap:12px;align-items:flex-start;background:linear-gradient(90deg,#fff8f8,#fff)}
.module-head h2{margin:0;color:#7f1d1d;font-size:24px}.module-head p{margin:8px 0 0;color:var(--muted);line-height:1.6}
.notice{font-size:12px;color:#7f1d1d;background:#fff1f2;border:1px solid #fecdd3;padding:10px 12px;border-radius:14px;max-width:520px}
.subtabs{display:flex;gap:8px;flex-wrap:wrap;padding:14px 18px;border-bottom:1px solid var(--line);background:#fff}
.subtab{border:1px solid var(--line);background:#fff;border-radius:12px;padding:8px 12px;cursor:pointer;color:#7f1d1d;font-weight:700}
.subtab.active{background:#7f1d1d;color:#fff;border-color:#7f1d1d}
.toolbar{display:flex;gap:10px;align-items:center;flex-wrap:wrap;padding:14px 18px;background:#fffdfb;border-bottom:1px solid var(--line)}
.toolbar select,.toolbar input{border:1px solid var(--line);background:#fff;border-radius:12px;padding:8px 10px;min-height:38px;outline:none}
.toolbar .hint{color:var(--muted);font-size:12px;margin-left:auto}
.content{padding:20px}
.circle-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:18px}
.circle-col{background:#fff;border:1px solid var(--line);border-radius:22px;padding:15px;min-height:500px}
.circle-col h3{margin:0 0 8px;color:#7f1d1d;display:flex;align-items:center;justify-content:space-between}
.circle-col .desc{font-size:12px;color:var(--muted);line-height:1.5;margin-bottom:12px}
.person-card{background:linear-gradient(180deg,#fff,#fff7f7);border:1px solid #f3d5d5;border-radius:16px;padding:12px;margin:10px 0;cursor:pointer;transition:.16s transform,.16s box-shadow}
.person-card:hover{transform:translateY(-2px);box-shadow:0 12px 24px rgba(127,29,29,.14)}
.person-card .name{font-weight:800;font-size:16px;color:#7f1d1d;display:flex;justify-content:space-between;gap:8px}
.person-card .role{font-size:12px;color:#374151;line-height:1.5;margin-top:5px}
.tags{display:flex;gap:5px;flex-wrap:wrap;margin-top:8px}
.tag{font-size:11px;border-radius:999px;padding:3px 8px;background:#fee2e2;color:#991b1b;border:1px solid #fecaca}
.tag.levelA{background:#064e3b;color:#fff;border-color:#064e3b}.tag.levelB{background:#d1fae5;color:#065f46;border-color:#a7f3d0}.tag.levelC{background:#ecfdf5;color:#047857;border-color:#d1fae5}
.tag.risk{background:#fee2e2;color:#b91c1c}.tag.blue{background:#dbeafe;color:#1d4ed8;border-color:#bfdbfe}.tag.gold{background:#fef3c7;color:#92400e;border-color:#fde68a}
.table-wrap{overflow:auto;background:#fff;border:1px solid var(--line);border-radius:18px}
table{width:100%;border-collapse:collapse;font-size:13px}th,td{border-bottom:1px solid #f1e2df;padding:10px 11px;text-align:left;vertical-align:top}th{position:sticky;top:0;background:#fff1f2;color:#7f1d1d;z-index:1;white-space:nowrap}td{max-width:320px;line-height:1.45}tr:hover td{background:#fff9f9}
.cards{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:14px}.card{background:#fff;border:1px solid var(--line);border-radius:18px;padding:14px;cursor:pointer}.card h3{margin:0 0 8px;color:#7f1d1d}.card p{font-size:13px;color:#4b5563;line-height:1.55}
.network-layout{display:grid;grid-template-columns:minmax(620px,1.3fr) minmax(350px,.7fr);gap:18px}
.network-box{background:#fff;border:1px solid var(--line);border-radius:20px;overflow:hidden;min-height:680px}
.network-svg{width:100%;height:680px;display:block;background:linear-gradient(180deg,#fff,#fff8f8)}
.node{cursor:pointer}.node text{font-size:11px;paint-order:stroke;stroke:#fff;stroke-width:4px;stroke-linejoin:round;fill:#1f2937}
.edge{stroke:#9ca3af;stroke-opacity:.5}.edge.high{stroke:#dc2626;stroke-opacity:.7;stroke-dasharray:5 4}
.edge.strong{stroke-width:2.2}.edge.weak{stroke-width:1}
.rel-list{background:#fff;border:1px solid var(--line);border-radius:20px;max-height:680px;overflow:auto;padding:12px}
.rel-item{border:1px solid #f1d7d7;border-radius:14px;padding:10px;margin-bottom:9px;background:#fffafa;cursor:pointer}
.rel-item b{color:#7f1d1d}.rel-item p{margin:6px 0;color:#4b5563;font-size:12px;line-height:1.5}
.gallery{display:grid;grid-template-columns:260px 1fr;gap:18px}
.map-list{display:flex;flex-direction:column;gap:10px}.map-thumb{background:#fff;border:1px solid var(--line);border-radius:16px;padding:10px;cursor:pointer}.map-thumb.active{border-color:#7f1d1d;background:#fff1f2}.map-thumb img{width:100%;border-radius:10px;border:1px solid #eee}
.map-view{background:#fff;border:1px solid var(--line);border-radius:20px;padding:14px;overflow:auto}.map-view img{width:100%;min-width:720px;border-radius:14px;border:1px solid #eee}
.legend{background:#fff7ed;border:1px solid #fed7aa;color:#7c2d12;padding:12px;border-radius:14px;font-size:12px;line-height:1.7;margin-top:12px}
.timeline{display:grid;gap:12px}.timeline-item{border-left:4px solid #b91c1c;background:#fff;border-radius:14px;padding:12px 14px}
.chart{background:#fff;border:1px solid var(--line);border-radius:18px;padding:16px}
.chart-row{display:flex;align-items:center;gap:8px;margin:8px 0}.chart-label{width:140px;font-size:12px;color:#4b5563}.bar{height:16px;border-radius:999px;background:#fee2e2;overflow:hidden;flex:1}.bar span{display:block;height:100%;background:#b91c1c;border-radius:999px}
.modal{position:fixed;inset:0;background:rgba(17,24,39,.56);display:none;align-items:center;justify-content:center;z-index:50;padding:22px}.modal.show{display:flex}
.modal-card{background:#fff;border-radius:24px;max-width:940px;width:100%;max-height:86vh;overflow:auto;box-shadow:0 30px 80px rgba(0,0,0,.34)}.modal-head{display:flex;justify-content:space-between;align-items:flex-start;gap:12px;padding:18px 22px;border-bottom:1px solid var(--line);background:#fff1f2}.modal-head h2{margin:0;color:#7f1d1d}.close{border:none;background:#7f1d1d;color:#fff;border-radius:999px;width:32px;height:32px;cursor:pointer;font-size:18px}
.detail-grid{display:grid;grid-template-columns:180px 1fr;gap:0;border-top:1px solid #f2e0de}.detail-grid .k{background:#fff8f8;color:#7f1d1d;font-weight:700;padding:10px;border-bottom:1px solid #f2e0de}.detail-grid .v{padding:10px;border-bottom:1px solid #f2e0de;line-height:1.6;white-space:pre-wrap;word-break:break-word}
.empty{padding:24px;color:var(--muted);text-align:center;background:#fff;border:1px dashed var(--line);border-radius:18px}
@media(max-width:1100px){.dashboard{grid-template-columns:repeat(3,1fr)}.circle-grid,.network-layout,.gallery{grid-template-columns:1fr}.search{min-width:260px}}
@media(max-width:700px){.dashboard{grid-template-columns:repeat(2,1fr)}.header-inner{flex-direction:column;align-items:stretch}.content{padding:14px}}
/* 后五栏页面化增强:保持第一栏设计不变,仅增强其他栏目 */
.compact-head{align-items:center;padding:18px 24px}.compact-head h2{font-size:23px}.compact-head p{display:none}
.module-summary{display:flex;gap:8px;flex-wrap:wrap;justify-content:flex-end}.mini-stat{background:#fff1f2;border:1px solid #fecdd3;color:#7f1d1d;border-radius:999px;padding:6px 10px;font-size:12px}.mini-stat b{font-size:16px;margin-right:4px}
.rich-cards{grid-template-columns:repeat(auto-fill,minmax(330px,1fr))}.rich-card{cursor:pointer;min-height:190px;display:flex;flex-direction:column;gap:8px;transition:.16s transform,.16s box-shadow;background:linear-gradient(180deg,#fff,#fff9f9)}.rich-card:hover{transform:translateY(-2px);box-shadow:0 14px 28px rgba(127,29,29,.13)}
.card-top{display:flex;justify-content:space-between;gap:10px;align-items:flex-start}.card-top h3{line-height:1.35}.pill{white-space:nowrap;border-radius:999px;background:#eef2ff;color:#3730a3;border:1px solid #c7d2fe;padding:3px 9px;font-size:12px;font-weight:700}.risk-pill{background:#fee2e2;color:#991b1b;border-color:#fecaca}.meta-line{font-size:12px;color:#6b7280;line-height:1.5}.summary{font-size:13px;color:#374151;line-height:1.65;margin:4px 0 0}.bullet-box{background:#fff7f7;border:1px solid #f7dada;border-radius:12px;padding:8px 12px;font-size:13px;color:#374151}.bullet-box ul{margin:0;padding-left:18px;line-height:1.65}.label-row{margin-top:6px}.label-row b{font-size:12px;color:#7f1d1d}
.mini-fields{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:8px;margin-top:auto}.mini-fields div{background:#fff7f7;border:1px solid #f1d7d7;border-radius:10px;padding:7px 9px}.mini-fields span{display:block;font-size:11px;color:#7f1d1d;font-weight:700;margin-bottom:3px}.mini-fields b{font-size:12px;color:#374151;font-weight:600;line-height:1.5}
.risk-bars{display:grid;gap:6px;margin:8px 0}.risk-row{display:grid;grid-template-columns:92px 1fr 36px;align-items:center;gap:8px;font-size:12px;color:#4b5563}.riskbar{height:9px;background:#fee2e2;border-radius:999px;overflow:hidden}.riskbar i{display:block;height:100%;background:linear-gradient(90deg,#f59e0b,#dc2626);border-radius:999px}.risk-row b{text-align:right;color:#7f1d1d}.inline-tags{margin:0}.rich-table td{font-size:12px}.mini-btn{border:1px solid #fecaca;background:#fff1f2;color:#991b1b;border-radius:999px;padding:5px 9px;cursor:pointer;font-weight:700}.mini-btn:hover{background:#991b1b;color:#fff}.muted{color:#9ca3af}.rich-timeline .timeline-item{cursor:pointer;position:relative;border-left:none;border:1px solid #f1d7d7}.time-dot{display:inline-block;background:#7f1d1d;color:#fff;border-radius:999px;padding:3px 9px;font-size:12px;margin-bottom:8px}.legend-row{font-size:12px;color:#6b7280;margin-bottom:10px;display:flex;gap:8px;align-items:center}.legend-dot{width:10px;height:10px;border-radius:50%;display:inline-block}.legend-dot.approve{background:#b91c1c}.legend-dot.disapprove{background:#f59e0b}.bar.two{position:relative}.bar.two span{background:#b91c1c}.bar.two em{display:block;height:100%;background:#f59e0b;border-radius:999px;position:absolute;right:0;top:0;opacity:.75}.chart-row small{width:92px;color:#6b7280}.edge-card-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(320px,1fr));gap:14px}.edge-card{background:#fff;border:1px solid #f1d7d7;border-radius:18px;padding:14px;cursor:pointer}.edge-title{display:flex;justify-content:space-between;gap:8px;color:#7f1d1d}.rich-detail{display:grid;gap:14px}.detail-section{border:1px solid #f1d7d7;background:#fff;border-radius:16px;padding:14px}.detail-section.emphasis{background:linear-gradient(180deg,#fff8f8,#fff)}.detail-section h3{margin:0 0 8px;color:#7f1d1d}.detail-section p{margin:0;line-height:1.75;color:#374151}.tag-group{margin:9px 0}.tag-group b{display:block;color:#7f1d1d;font-size:12px;margin-bottom:5px}.info-list{display:grid;gap:8px}.info-row{display:grid;grid-template-columns:170px 1fr;gap:10px;border-bottom:1px dashed #f1d7d7;padding-bottom:8px}.info-row b{color:#7f1d1d}.info-row span{line-height:1.65;color:#374151}.source-section{background:#fffdf5}
@media (max-width:900px){.rich-cards{grid-template-columns:1fr}.module-summary{justify-content:flex-start}.info-row{grid-template-columns:1fr}.risk-row{grid-template-columns:80px 1fr 32px}}
</style>
</head>
<body>
<header class="header"><div class="header-inner"><div class="title"><h1>台湾民进党政要与政治生态知识展示平台</h1><p>三圈人物增强版|A/B/C 159人|关系边表重建|6张关系图原图</p></div><div class="search"><span>🔎</span><input id="globalSearch" placeholder="搜索人物、事件、派系、地区、关系、政策标签……" /></div></div></header>
<div class="container">
<div id="dashboard" class="dashboard"></div>
<div id="tabs" class="tabs"></div>
<section id="main" class="panel"></section>
</div>
<div id="modal" class="modal"><div class="modal-card"><div class="modal-head"><div><h2 id="modalTitle"></h2><div id="modalSub" class="muted"></div></div><button class="close" onclick="closeModal()">×</button></div><div id="modalBody" style="padding:18px 22px"></div></div></div>
<script id="app-data" type="application/json">{"meta": {"project_name": "台湾民进党政要与政治生态知识展示平台", "version": "1.1-enhanced-three-circle-network", "language": "zh-CN", "description": "用于展示台湾民进党政要、派系、事件、制度、民调、舆情与认知域传播力量的结构化知识库。", "last_updated": "2026-06-24", "data_status": "prototype_ready", "source_workbook": "dpp_risk_database_with_relationships.xlsx", "notes": "本页面为研究型知识库原型。风险等级表示核验优先级,不代表定罪或违法事实。", "source_workbooks": ["dpp_politicians_overview.xlsx", "dpp_risk_database_with_relationships.xlsx", "dpp_network_maps_package.zip"], "enhancement_note": "已用三圈人物总览表补齐159名人物,并按姓名重建关系表映射,加入6张关系图原图。"}, "global_config": {"default_tab": "people_network", "enable_global_search": true, "enable_filters": true, "enable_modal_detail": true, "enable_secondary_pages": true, "enable_relation_jump": true, "enable_source_trace": true}, "dictionaries": {"entity_types": ["人物", "机构", "政党", "派系", "事件", "法规", "地区", "研究成果", "平台", "舆情主体"], "risk_levels": ["低", "中", "中高", "高", "极高"], "evidence_levels": ["S", "A", "B", "C", "D", "A/S", "A/B"], "parties": ["民进党", "国民党", "民众党", "时代力量", "无党籍", "其他"], "platforms": ["Facebook", "Threads", "PTT", "Dcard", "YouTube", "LINE", "TikTok", "电视政论", "新闻媒体"]}, "dashboard": {"人物数量": 159, "政治事件": 17, "高风险地区": 9, "研究成果": 7, "民调条目": 14, "认知域主体": 10, "第一圈A": 15, "第二圈B": 73, "第三圈C": 71, "关系数量": 60, "关系节点": 73, "关系图原图": 6}, "modules": [{"module_id": "people_network", "module_name": "政要人物与派系网络", "description": "展示民进党三圈人物(A/B/C)、职务经历、所属系统、派系倾向、核心人脉、政策标签,以及规范化关系图谱和6张关系图原图。", "icon": "人物", "datasets": [{"key": "people_full", "name": "三圈人物", "type": "people_circles", "idField": "人物ID", "titleField": "姓名"}, {"key": "people_full", "name": "人物总表", "type": "table", "idField": "人物ID", "titleField": "姓名"}, {"key": "relationships_enhanced", "name": "互动关系图", "type": "network", "idField": "关系ID", "titleField": "关系细类"}, {"key": "relationships_enhanced", "name": "关系边表", "type": "table", "idField": "关系ID", "titleField": "关系细类"}, {"key": "network_maps", "name": "关系图原图", "type": "map_gallery", "idField": "map_id", "titleField": "title"}], "filters": ["层级", "圈层", "地区", "所属系统", "派系倾向", "政策标签", "可信度"], "detail_mode": "modal_or_network_detail"}, {"module_id": "risk_business_judicial_region", "module_name": "政商司法与地区风险", "description": "展示政治献金、标案、企业组织、司法监察、地区风险评分与地方选举风险。", "icon": "风险", "datasets": [{"key": "region_risks", "name": "地区风险", "type": "cards", "idField": "地区ID", "titleField": "县市"}, {"key": "judicial_records", "name": "司法监察", "type": "table", "idField": "记录ID", "titleField": "人物"}, {"key": "donations", "name": "政治献金", "type": "table", "idField": "献金ID", "titleField": "候选人/政党"}, {"key": "bids", "name": "标案资料", "type": "table", "idField": "标案ID", "titleField": "标案名称/关键词"}], "filters": ["地区", "风险等级", "证据等级", "核验状态", "产业类别"], "detail_mode": "modal_or_detail"}, {"module_id": "political_system_laws", "module_name": "台湾政治制度与法规机制", "description": "展示台湾政治制度、政党制度、选举制度、独统议题、政策法规、权力机构和特殊政治机制。", "icon": "制度", "datasets": [{"key": "political_systems", "name": "制度总览", "type": "cards", "idField": "system_id", "titleField": "制度名称"}, {"key": "institutions", "name": "权力机构", "type": "cards", "idField": "institution_id", "titleField": "机构名称"}, {"key": "laws", "name": "政策法规", "type": "table", "idField": "law_id", "titleField": "法规名称"}, {"key": "election_mechanisms", "name": "选举与公投", "type": "table", "idField": "mechanism_id", "titleField": "机制名称"}, {"key": "cross_strait_policies", "name": "独统与两岸", "type": "cards", "idField": "cross_id", "titleField": "议题"}, {"key": "dpp_primary_mechanisms", "name": "党内初选与派系机制", "type": "table", "idField": "dpp_mech_id", "titleField": "机制名称"}, {"key": "special_mechanisms", "name": "特殊政治机制", "type": "cards", "idField": "spm_id", "titleField": "机制名称"}], "filters": ["风险等级", "关联栏目", "适用对象"], "detail_mode": "secondary_page"}, {"module_id": "reseaLine truncated
<script>
const DATA = JSON.parse(document.getElementById('app-data').textContent);
const state = { module: 'people_network', viewKey: null, query: '', filters: {}, relCategory:'全部', relMin:4, relRisk:false, relFocus:'' };
const $ = s => document.querySelector(s);
const el = (tag, cls='', html='') => { const e=document.createElement(tag); if(cls)e.className=cls; if(html!==undefined)e.innerHTML=html; return e; };
function esc(x){ return String(x ?? '').replace(/[&<>'"]/g, m=>({'&':'&amp;','<':'&lt;','>':'&gt;',"'":'&#39;','"':'&quot;'}[m])); }
function isEmpty(v){ return v===undefined || v===null || v==='' || (Array.isArray(v)&&v.length===0); }
function flatText(v){
if(Array.isArray(v)) return v.map(flatText).filter(Boolean).join('、');
if(v && typeof v==='object') return Object.entries(v).filter(([k,val])=>!isEmpty(val)&&k!=='data_uri').map(([k,val])=>`${k}:${flatText(val)}`).join(';');
return String(v ?? '');
}
function arrText(v){ return flatText(v); }
function getTitle(item, cfg){ return item?.[cfg?.titleField] || item?.['姓名'] || item?.['事件名'] || item?.['名称'] || item?.['标题'] || item?.['机制名称'] || item?.['主体名称'] || item?.['关系细类'] || item?.['title'] || item?.['name'] || '未命名'; }
function currentModule(){ return DATA.modules.find(m=>m.module_id===state.module) || DATA.modules[0]; }
function currentDatasetCfg(){ const m=currentModule(); return (m.datasets||[]).find(d=>d.key===state.viewKey) || (m.datasets||[])[0]; }
function splitTags(v){
if(Array.isArray(v)) return v.filter(Boolean).map(String);
if(isEmpty(v)) return [];
return String(v).split(/[、,,;;\/|]/).map(s=>s.trim()).filter(Boolean).slice(0,12);
}
function tagHtml(tags, cls='') { return splitTags(tags).map(t=>`<span class="tag ${cls}">${esc(t)}</span>`).join(''); }
function val(item, keys, fallback=''){
for(const k of keys){ if(!isEmpty(item[k])) return item[k]; }
return fallback;
}
function init(){ renderDashboard(); renderTabs(); bindSearch(); renderModule(); }
function renderDashboard(){
const d=DATA.dashboard||{}; const keys=['人物数量','第一圈A','第二圈B','第三圈C','关系数量','关系节点','关系图原图'];
$('#dashboard').innerHTML = keys.map(k=>`<div class="stat"><div class="k">${esc(k)}</div><div class="v">${esc(d[k]??0)}</div></div>`).join('');
}
function renderTabs(){
$('#tabs').innerHTML = DATA.modules.map(m=>`<button class="tab ${m.module_id===state.module?'active':''}" data-id="${m.module_id}">${esc(m.module_name)}</button>`).join('');
document.querySelectorAll('.tab').forEach(b=>b.onclick=()=>{state.module=b.dataset.id; state.viewKey=null; state.filters={}; renderTabs(); renderModule();});
}
function bindSearch(){ $('#globalSearch').oninput = e=>{state.query=e.target.value.trim(); renderModuleBody();}; }
function renderModule(){
const m=currentModule(); if(!state.viewKey) state.viewKey=(m.datasets&&m.datasets[0]&&m.datasets[0].key)||'';
if(m.module_id==='people_network'){
$('#main').innerHTML = `<div class="module-head"><div><h2>${esc(m.module_name)}</h2><p>${esc(m.description||'')}</p></div><div class="notice">风险线与关系线仅表示研究型关联、职务/派系/事件/议题连接,不等于违法认定。关系表已按姓名重建三圈人物ID,并保留原始ID作为溯源字段。</div></div><div id="subtabs" class="subtabs"></div><div id="toolbar" class="toolbar"></div><div id="content" class="content"></div>`;
} else {
$('#main').innerHTML = `<div class="module-head compact-head"><div><h2>${esc(m.module_name)}</h2></div><div id="moduleSummary" class="module-summary"></div></div><div id="subtabs" class="subtabs"></div><div id="toolbar" class="toolbar"></div><div id="content" class="content"></div>`;
}
renderSubtabs(); renderModuleBody();
}
function renderSubtabs(){
const m=currentModule(); $('#subtabs').innerHTML=(m.datasets||[]).map(d=>`<button class="subtab ${d.key===state.viewKey?'active':''}" data-key="${d.key}">${esc(d.name)}</button>`).join('');
document.querySelectorAll('.subtab').forEach(b=>b.onclick=()=>{state.viewKey=b.dataset.key; state.filters={}; renderSubtabs(); renderModuleBody();});
}
function renderModuleSummary(raw, cfg){
const box=$('#moduleSummary'); if(!box) return;
const m=currentModule();
if(m.module_id==='people_network') return;
const fields = {
risk_business_judicial_region: ['region_risks','judicial_records','donations','bids'],
political_system_laws: ['political_systems','institutions','laws','special_mechanisms'],
research_outputs: ['research_outputs'],
events_opinion_polling: ['events','poll_time_series','platform_metrics','impact_assessments'],
cognitive_public_opinion_forces: ['cognitive_actors','party_comm_units','gov_info_units','fact_check_network']
}[m.module_id] || [];
box.innerHTML = fields.map(k=>`<span class="mini-stat"><b>${esc((DATA.datasets[k]||[]).length)}</b>${esc(datasetLabel(k))}</span>`).join('');
}
function datasetLabel(k){ return ({region_risks:'地区',judicial_records:'司法',donations:'献金',bids:'标案',political_systems:'制度',institutions:'机构',laws:'法规',special_mechanisms:'特殊机制',research_outputs:'研究',events:'事件',poll_time_series:'民调',platform_metrics:'平台',impact_assessments:'影响',cognitive_actors:'主体',party_comm_units:'传播部门',gov_info_units:'信息机构',fact_check_network:'查核组织'}[k]||k); }
function renderToolbar(data, cfg){
const m=currentModule(); const fields = m.filters || [];
let html = '';
if(cfg.type==='network' && (cfg.key==='relationships_enhanced' || state.module==='people_network')){
const cats=[...new Set((DATA.datasets.relationships_enhanced||[]).map(r=>r['关系大类']).filter(Boolean))];
html += `<select id="relCategory"><option>全部</option>${cats.map(c=>`<option ${state.relCategory===c?'selected':''}>${esc(c)}</option>`).join('')}</select>`;
html += `<select id="relMin"><option value="1">强度≥1</option><option value="3" ${state.relMin==3?'selected':''}>强度≥3</option><option value="4" ${state.relMin==4?'selected':''}>强度≥4</option><option value="5" ${state.relMin==5?'selected':''}>强度=5</option></select>`;
html += `<label style="display:flex;gap:6px;align-items:center"><input id="relRisk" type="checkbox" ${state.relRisk?'checked':''}>只看高风险</label>`;
html += `<input id="relFocus" placeholder="聚焦人物/节点,如 赖清德、台南" value="${esc(state.relFocus)}" />`;
html += `<span class="hint">互动图默认显示强度≥4,可调低强度查看更多关系。</span>`;
$('#toolbar').innerHTML=html;
$('#relCategory').onchange=e=>{state.relCategory=e.target.value; renderModuleBody();};
$('#relMin').onchange=e=>{state.relMin=+e.target.value; renderModuleBody();};
$('#relRisk').onchange=e=>{state.relRisk=e.target.checked; renderModuleBody();};
$('#relFocus').oninput=e=>{state.relFocus=e.target.value.trim(); renderModuleBody(false);};
return;
}
fields.forEach(f=>{
const vals=[...new Set(data.map(x=>x[f]).filter(v=>v!==undefined&&v!==null&&v!==''))].slice(0,200);
if(vals.length>1 && vals.length<80) html += `<select data-filter="${esc(f)}"><option value="">${esc(f)}:全部</option>${vals.map(v=>`<option value="${esc(v)}" ${state.filters[f]===String(v)?'selected':''}>${esc(v)}</option>`).join('')}</select>`;
});
html += `<span class="hint">当前:${esc(data.length)} 条;搜索与筛选在当前子页内生效。</span>`;
$('#toolbar').innerHTML=html;
document.querySelectorAll('[data-filter]').forEach(s=>s.onchange=e=>{state.filters[e.target.dataset.filter]=e.target.value; renderModuleBody();});
}
function filterData(data){
const q=state.query.toLowerCase();
return data.filter(item=>{
const passFilters=Object.entries(state.filters).every(([k,v])=>!v || String(item[k]??'')===v);
if(!passFilters) return false;
if(!q) return true;
return flatText(item).toLowerCase().includes(q);
});
}
function renderModuleBody(updateToolbar=true){
const cfg=currentDatasetCfg(); const raw=DATA.datasets[cfg.key] || [];
renderModuleSummary(raw,cfg);
if(updateToolbar) renderToolbar(raw, cfg);
let data=filterData(raw);
if(cfg.type==='people_circles') return renderPeopleCircles(data);
if(cfg.type==='network'){
if(cfg.key==='event_entity_edges') return renderEventEntityNetwork(data,cfg);
return renderNetwork();
}
if(cfg.type==='map_gallery') return renderMapGallery(data);
if(cfg.type==='cards') return renderCards(data,cfg);
if(cfg.type==='timeline') return renderTimeline(data,cfg);
if(cfg.type==='chart_table') return renderPollChart(data,cfg);
return renderTable(data,cfg);
}
function levelClass(l){return l==='A'?'levelA':(l==='B'?'levelB':'levelC')}
function renderPeopleCircles(data){
const groups={A:[],B:[],C:[]}; data.forEach(p=>{ if(groups[p['层级']]) groups[p['层级']].push(p); });
const desc={A:'总统府、行政院、党主席、地方执政与国会核心。',B:'中常委、地方首长、立委、党务/传播/国安关键节点。',C:'地方立委、专业不分区、党务节点、历史人物与风险案例。'};
$('#content').innerHTML = `<div class="circle-grid">${['A','B','C'].map(level=>`<div class="circle-col"><h3>${esc(DATA.datasets.people_circles.find(x=>x.level===level)?.label||level)}<span class="tag ${levelClass(level)}">${groups[level].length}人</span></h3><div class="desc">${desc[level]}</div>${groups[level].map(personCard).join('')}</div>`).join('')}</div>`;
document.querySelectorAll('[data-person]').forEach(x=>x.onclick=()=>openDetail(DATA.datasets.people_full.find(p=>p['人物ID']===x.dataset.person), '人物详情'));
}
function personCard(p){
const tags=[p['地区'], p['所属系统'], p['派系倾向']].filter(Boolean).slice(0,3).map(t=>`<span class="tag">${esc(t)}</span>`).join('');
const rel=p['关系数量']?`<span class="tag blue">关系${p['关系数量']}</span>`:'';
const risk=p['高风险关系数量']?`<span class="tag risk">高风险${p['高风险关系数量']}</span>`:'';
return `<div class="person-card" data-person="${esc(p['人物ID'])}"><div class="name"><span>${esc(p['姓名'])}</span><span class="tag ${levelClass(p['层级'])}">${esc(p['层级'])}</span></div><div class="role">${esc(p['当前身份'])}</div><div class="tags">${tags}${rel}${risk}</div></div>`;
}
function renderCards(data,cfg){
if(state.module==='people_network'){
$('#content').innerHTML = data.length?`<div class="cards">${data.map(it=>`<div class="card" data-id="${esc(it[cfg.idField]||'')}"><h3>${esc(getTitle(it,cfg))}</h3><p>${esc(Object.entries(it).filter(([k,v])=>v&&k!==cfg.titleField&&k!==cfg.idField).slice(0,5).map(([k,v])=>`${k}:${arrText(v)}`).join('\n'))}</p></div>`).join('')}</div>`:`<div class="empty">没有匹配数据</div>`;
document.querySelectorAll('.card').forEach((x,i)=>x.onclick=()=>openDetail(data[i], getTitle(data[i],cfg)));
return;
}
$('#content').innerHTML = data.length?`<div class="cards rich-cards">${data.map((it,i)=>richCard(it,cfg,i)).join('')}</div>`:`<div class="empty">没有匹配数据</div>`;
document.querySelectorAll('.rich-card').forEach(x=>x.onclick=()=>openDetail(data[+x.dataset.i], getTitle(data[+x.dataset.i],cfg)));
}
function richCard(it,cfg,i){
const key=cfg.key, title=getTitle(it,cfg);
if(key==='region_risks') return regionCard(it,i);
if(key==='political_systems'||key==='institutions'||key==='cross_strait_policies'||key==='special_mechanisms') return systemCard(it,cfg,i);
if(key==='research_outputs') return researchCard(it,i);
if(key==='person_opinion_profiles'||key==='impact_assessments') return opinionCard(it,cfg,i);
if(key==='cognitive_actors'||key==='gov_info_units'||key==='fact_check_network'||key==='platform_mechanisms') return actorCard(it,cfg,i);
return genericCard(it,cfg,i);
}
function genericCard(it,cfg,i){
const title=getTitle(it,cfg); const subtitle=val(it,['类型','阵营','机构','地区','功能定位','当前状态','年份','风险等级'],'');
const tags=[...splitTags(val(it,['主题标签','主要平台','核心功能','主要风险类型','关联栏目','平台/渠道','内容特征'],''))].slice(0,6);
const desc=val(it,['核心内容','说明','风险摘要','主要职能','核心结论','政治影响','典型任务','认知域相关职责','短期影响'],'');
return `<article class="card rich-card" data-i="${i}"><div class="card-top"><h3>${esc(title)}</h3>${subtitle?`<span class="pill">${esc(subtitle)}</span>`:''}</div>${tags.length?`<div class="tags">${tagHtml(tags)}</div>`:''}${desc?`<p class="summary">${esc(flatText(desc))}</p>`:''}${miniFields(it,['证据等级','可信度','核验状态','风险等级','数据用途','适用对象'])}</article>`;
}
function regionCard(it,i){
const score=Number(it['综合分']||0); const risk=it['风险等级']||'';
const people=splitTags(it['核心人物']).slice(0,8);
const risks=splitTags(it['主要风险类型']).slice(0,8);
const bars=['司法风险(30%)','资金风险(20%)','土地/产业风险(20%)','派系/地方风险(15%)','舆情风险(10%)'].map(k=>riskBar(k,it[k])).join('');
return `<article class="card rich-card region-card" data-i="${i}"><div class="card-top"><h3>${esc(it['县市'])}</h3><span class="pill risk-pill">${esc(risk)}|${esc(it['综合分'])}</span></div><div class="meta-line">${esc(it['执政状态/政治属性']||'')}</div><div class="tags">${people.map(x=>`<span class="tag blue">${esc(x)}</span>`).join('')}${risks.map(x=>`<span class="tag risk">${esc(x)}</span>`).join('')}</div><div class="risk-bars">${bars}</div><p class="summary"><b>2026影响:</b>${esc(it['2026选举影响']||'待补充')}</p></article>`;
}
function riskBar(label,value){ const n=Math.max(0,Math.min(5,Number(value)||0)); return `<div class="risk-row"><span>${esc(label.replace(/\(.+?\)/,''))}</span><div class="riskbar"><i style="width:${n*20}%"></i></div><b>${n}/5</b></div>`; }
function systemCard(it,cfg,i){
const title=getTitle(it,cfg); const desc=val(it,['核心内容','主要职能','核心规则','触发条件','政治含义'],'');
const tags=val(it,['标签','关联栏目','适用对象','风险等级'],'');
return `<article class="card rich-card system-card" data-i="${i}"><div class="card-top"><h3>${esc(title)}</h3>${it['风险等级']?`<span class="pill risk-pill">${esc(it['风险等级'])}</span>`:''}</div>${tags?`<div class="tags">${tagHtml(tags)}</div>`:''}<p class="summary">${esc(flatText(desc))}</p>${miniFields(it,['机构定位','主管/适用','程序门槛','数据用途','来源'])}</article>`;
}
function researchCard(it,i){
return `<article class="card rich-card research-card" data-i="${i}"><div class="card-top"><h3>${esc(it['标题'])}</h3><span class="pill">${esc(it['可信度']||'')}</span></div><div class="meta-line">${esc(it['机构']||'')}|${esc(it['年份']||'')}|${esc(it['类型']||'')}</div><div class="tags">${tagHtml(it['主题标签'])}</div><div class="bullet-box">${toBullets(it['核心结论'])}</div>${miniFields(it,['可下载性','适用栏目'])}</article>`;
}
function opinionCard(it,cfg,i){
const title=getTitle(it,cfg); const risk=it['舆情风险']||it['风险评分']||'';
const pos=it['正面形象'], neg=it['负面标签'];
return `<article class="card rich-card opinion-card" data-i="${i}"><div class="card-top"><h3>${esc(title)}</h3>${risk?`<span class="pill risk-pill">风险 ${esc(risk)}</span>`:''}</div>${pos?`<div class="label-row"><b>正面形象</b><div class="tags">${tagHtml(pos,'blue')}</div></div>`:''}${neg?`<div class="label-row"><b>负面标签</b><div class="tags">${tagHtml(neg,'risk')}</div></div>`:''}<p class="summary">${esc(flatText(val(it,['短期影响','长期影响','平台敏感性','观察指标'],'')))}</p></article>`;
}
function actorCard(it,cfg,i){
const title=getTitle(it,cfg);
return `<article class="card rich-card actor-card" data-i="${i}"><div class="card-top"><h3>${esc(title)}</h3><span class="pill">${esc(val(it,['类型','功能定位','政党/阵营'],'主体'))}</span></div>${val(it,['阵营','政党'])?`<div class="meta-line">${esc(val(it,['阵营','政党']))}</div>`:''}<div class="tags">${tagHtml(val(it,['核心功能','主要平台','平台/渠道','平台'],''))}</div><p class="summary">${esc(flatText(val(it,['认知域相关职责','主要功能','政治传播作用','内容特征','争议风险'],'')))}</p>${miniFields(it,['典型场景','适合关联事件','数据可得性','关键指标','关联栏目'])}</article>`;
}
function miniFields(it, keys){ return `<div class="mini-fields">${keys.filter(k=>!isEmpty(it[k])).map(k=>`<div><span>${esc(k)}</span><b>${esc(flatText(it[k]))}</b></div>`).join('')}</div>`; }
function toBullets(v){ const arr=Array.isArray(v)?v:splitTags(v); if(!arr.length) return ''; return `<ul>${arr.slice(0,5).map(x=>`<li>${esc(x)}</li>`).join('')}</ul>`; }
function renderTable(data,cfg){
if(!data.length) return $('#content').innerHTML='<div class="empty">没有匹配数据</div>';
const keyMap={
judicial_records:['记录ID','人物','机关','类型','阶段','主要争点','结果/状态','证据等级','下一步跟踪'],
donations:['献金ID','年度/选举','候选人/政党','捐款人/企业','捐款人类型','产业类别','核验状态','风险信号'],
bids:['标案ID','标案名称/关键词','机关','地区','产业类别','得标厂商','关联人物','风险信号'],
laws:['law_id','法规名称','主管/适用','核心条款摘要','关联栏目','详情展示'],
election_mechanisms:['mechanism_id','机制名称','核心规则','适用对象','数据用途'],
dpp_primary_mechanisms:['dpp_mech_id','机制名称','核心内容','数据用途'],
narrative_frames:['frame_id','event_id','行动方','话术类型','核心话术','对抗话术'],
platform_metrics:['metric_id','event_id','平台范围','量化数据','说明','可信度'],
party_comm_units:['unit_id','政党','部门/机制','公开可见功能','典型任务','关联人物'],
party_media_matrix:['matrix_id','政党/阵营','主要平台','传播形态','主要受众','内容特征','观察指标'],
cognitive_risks:['risk_id','风险类型','相关主体','表现','评估指标','备注']
};
const priority=['人物ID','姓名','层级','圈层','当前身份','所属系统','派系倾向','地区','关系ID','Source_名称','Target_名称','关系大类','关系细类','关系强度(1-5)','是否高风险','证据等级','简要说明'];
let keys=keyMap[cfg.key] || [...new Set([...priority.filter(k=>k in data[0]), ...Object.keys(data[0])])].filter(k=>k!=='data_uri').slice(0,10);
$('#content').innerHTML = `<div class="table-wrap rich-table"><table><thead><tr>${keys.map(k=>`<th>${esc(k)}</th>`).join('')}<th>详情</th></tr></thead><tbody>${data.map((row,idx)=>`<tr data-row="${idx}">${keys.map(k=>`<td>${cellHtml(row[k],k)}</td>`).join('')}<td><button class="mini-btn" data-detail="${idx}">查看</button></td></tr>`).join('')}</tbody></table></div>`;
document.querySelectorAll('[data-detail]').forEach(btn=>btn.onclick=(e)=>{e.stopPropagation(); openDetail(data[+btn.dataset.detail], getTitle(data[+btn.dataset.detail],cfg));});
document.querySelectorAll('tbody tr').forEach(tr=>tr.onclick=()=>openDetail(data[+tr.dataset.row], getTitle(data[+tr.dataset.row],cfg)));
}
function cellHtml(v,k){
if(isEmpty(v)) return '<span class="muted">—</span>';
if(Array.isArray(v)) return `<div class="tags inline-tags">${tagHtml(v)}</div>`;
const s=String(v);
if(['证据等级','可信度','风险等级','核验状态','是否高风险'].some(x=>k.includes(x))) return `<span class="tag ${s.includes('高')||s==='是'?'risk':'blue'}">${esc(s)}</span>`;
return esc(s.length>95?s.slice(0,95)+'…':s);
}
function renderTimeline(data,cfg){
$('#content').innerHTML = data.length?`<div class="timeline rich-timeline">${data.map((it,i)=>timelineItem(it,cfg,i)).join('')}</div>`:`<div class="empty">没有匹配数据</div>`;
document.querySelectorAll('.timeline-item').forEach(x=>x.onclick=()=>openDetail(data[+x.dataset.i], getTitle(data[+x.dataset.i],cfg)));
}
function timelineItem(it,cfg,i){
const title=getTitle(it,cfg); const time=val(it,['起始/关键日期','时间','publish_date','start_date'],'');
const meta=[val(it,['类型','攻防主题','地区'],''), val(it,['当前状态','平台','司法/监察阶段'],'')].filter(Boolean).join('|');
const summary=val(it,['风险摘要','主要框架','核心争议','说明','2026选举影响'],'');
return `<div class="timeline-item" data-i="${i}"><div class="time-dot">${esc(time||'时间待补')}</div><b>${esc(title)}</b>${meta?`<div class="meta-line">${esc(meta)}</div>`:''}<p>${esc(flatText(summary))}</p>${tagHtml(val(it,['涉及人物','参与主体','舆情标签','平台'],''))}</div>`;
}
function renderPollChart(data,cfg){
const series=data.filter(x=>x.approve_value!==undefined && x.approve_value!=='' && !isNaN(+x.approve_value)); const max=Math.max(1,...series.map(x=>Math.max(+x.approve_value,+x.disapprove_value||0)));
const rows=series.slice(0,80).map((x,i)=>`<div class="chart-row" data-i="${i}"><div class="chart-label">${esc(x.publish_date||x.source)}<br>${esc(x.target||'')}</div><div class="bar two"><span style="width:${(+x.approve_value/max)*100}%"></span><em style="width:${((+x.disapprove_value||0)/max)*100}%"></em></div><b>${esc(x.approve_value)}%</b><small>不满意 ${esc(x.disapprove_value??'—')}%</small></div>`).join('');
const table=data.length?pollTable(data):'';
$('#content').innerHTML = `<div class="chart rich-chart"><h3 style="margin-top:0;color:#7f1d1d">民调趋势</h3><div class="legend-row"><span class="legend-dot approve"></span>满意/支持 <span class="legend-dot disapprove"></span>不满意/不支持</div>${rows||'<div class="empty">暂无可绘制数值</div>'}</div>${table}`;
document.querySelectorAll('.chart-row').forEach(x=>x.onclick=()=>openDetail(series[+x.dataset.i], '民调详情'));
document.querySelectorAll('[data-poll]').forEach(btn=>btn.onclick=(e)=>{e.stopPropagation();openDetail(data[+btn.dataset.poll],'民调详情');});
}
function pollTable(data){ const keys=['poll_id','source','publish_date','indicator_type','target','approve_value','disapprove_value','neutral_value','related_event_id','method','credibility_level']; return `<div class="table-wrap rich-table" style="margin-top:16px"><table><thead><tr>${keys.map(k=>`<th>${esc(k)}</th>`).join('')}<th>详情</th></tr></thead><tbody>${data.map((r,i)=>`<tr>${keys.map(k=>`<td>${cellHtml(r[k],k)}</td>`).join('')}<td><button class="mini-btn" data-poll="${i}">查看</button></td></tr>`).join('')}</tbody></table></div>`; }
function renderEventEntityNetwork(data,cfg){
if(!data.length) return $('#content').innerHTML='<div class="empty">没有匹配数据</div>';
$('#content').innerHTML=`<div class="edge-card-grid">${data.map((e,i)=>`<div class="edge-card" data-i="${i}"><div class="edge-title"><b>${esc(e.source_entity_name)} → ${esc(e.target_entity_name)}</b><span class="tag blue">强度${esc(e.relation_strength)}</span></div><div class="tags"><span class="tag">${esc(e.source_entity_type)}</span><span class="tag">${esc(e.target_entity_type)}</span><span class="tag risk">${esc(e.relation_type)}</span></div><p>${esc(e.relation_description||'')}</p><p class="summary"><b>风险影响:</b>${esc(e.risk_effect||'')}</p></div>`).join('')}</div>`;
document.querySelectorAll('.edge-card').forEach(x=>x.onclick=()=>openDetail(data[+x.dataset.i],'联动关系详情'));
}
function renderNetwork(){
const rels0=DATA.datasets.relationships_enhanced||[]; let rels=rels0.filter(r=>r['关系强度数值']>=state.relMin);
if(state.relCategory && state.relCategory!=='全部') rels=rels.filter(r=>r['关系大类']===state.relCategory);
if(state.relRisk) rels=rels.filter(r=>r['是否高风险布尔']);
const focus=(state.relFocus||state.query||'').toLowerCase(); if(focus) rels=rels.filter(r=>flatText(r).toLowerCase().includes(focus));
const nodesMap=new Map(); const allNodes=new Map((DATA.datasets.relationship_nodes||[]).map(n=>[n.node_id,n]));
rels.forEach(r=>{ [r.source_norm_id,r.target_norm_id].forEach(id=>{ if(allNodes.has(id)) nodesMap.set(id, allNodes.get(id)); }); });
const nodes=[...nodesMap.values()];
const svg=buildNetworkSVG(nodes, rels);
$('#content').innerHTML=`<div class="network-layout"><div class="network-box">${svg}</div><div class="rel-list"><h3 style="margin-top:0;color:#7f1d1d">关系边表摘要</h3><p style="color:#6b7280;font-size:12px">当前显示 ${rels.length} 条关系、${nodes.length} 个节点。点击节点可聚焦。</p>${rels.map((r,i)=>`<div class="rel-item" data-ri="${i}"><b>${esc(r.Source_名称)} → ${esc(r.Target_名称)}</b><div class="tags"><span class="tag">${esc(r['关系大类'])}</span><span class="tag blue">强度${esc(r['关系强度(1-5)'])}</span>${r['是否高风险']==='是'?'<span class="tag risk">高风险</span>':''}</div><p>${esc(r['关系细类']||'')}:${esc(r['简要说明']||'')}</p></div>`).join('')}</div></div>`;
document.querySelectorAll('.rel-item').forEach(x=>x.onclick=()=>openDetail(rels[+x.dataset.ri], '关系详情'));
document.querySelectorAll('.node').forEach(n=>n.onclick=()=>{state.relFocus=n.dataset.name; renderModuleBody(false);});
}
function nodeColor(n){ if(n.type.includes('地区')) return '#f59e0b'; if(n.type.includes('组织')||n.type.includes('机构')) return '#2563eb'; if(n.type.includes('事件')||n.type.includes('议题')) return '#dc2626'; if(n.type.includes('派系')) return '#7e22ce'; if(n.level==='A') return '#065f46'; if(n.level==='B') return '#10b981'; if(n.level==='C') return '#86efac'; return '#9ca3af'; }
function buildNetworkSVG(nodes, rels){
const w=1200,h=680,cx=w/2,cy=h/2; const rings={A:120,B:210,C:300,other:370}; const by={A:[],B:[],C:[],other:[]};
nodes.forEach(n=>{ if(n.level==='A')by.A.push(n); else if(n.level==='B')by.B.push(n); else if(n.level==='C')by.C.push(n); else by.other.push(n); });
const pos={}; Object.entries(by).forEach(([key,arr],ri)=>{ const r=rings[key]; const offset=(ri*.31); arr.forEach((n,i)=>{ const a=(Math.PI*2*i/Math.max(1,arr.length))+offset; pos[n.node_id]={x:cx+r*Math.cos(a),y:cy+r*Math.sin(a)}; }); });
const edgeEls=rels.map(r=>{ const a=pos[r.source_norm_id], b=pos[r.target_norm_id]; if(!a||!b)return ''; const cls=`edge ${r['是否高风险']==='是'?'high':''} ${r['关系强度数值']>=4?'strong':'weak'}`; return `<line class="${cls}" x1="${a.x.toFixed(1)}" y1="${a.y.toFixed(1)}" x2="${b.x.toFixed(1)}" y2="${b.y.toFixed(1)}"/>`; }).join('');
const nodeEls=nodes.map(n=>{ const p=pos[n.node_id]; if(!p)return ''; const color=nodeColor(n), rad=n.level==='A'?12:(n.level==='B'?9:(n.level==='C'?7:8)); const label=n.name.length>7?n.name.slice(0,7)+'…':n.name; return `<g class="node" data-name="${esc(n.name)}"><circle cx="${p.x.toFixed(1)}" cy="${p.y.toFixed(1)}" r="${rad}" fill="${color}" stroke="#fff" stroke-width="2"/><text x="${(p.x+rad+3).toFixed(1)}" y="${(p.y+4).toFixed(1)}">${esc(label)}</text><title>${esc(n.name+'|'+n.type+'|'+(n.circle||''))}</title></g>`; }).join('');
const legend=`<g transform="translate(24,22)"><rect width="245" height="112" rx="14" fill="rgba(255,255,255,.88)" stroke="#f1d5d5"/><text x="14" y="24" font-size="13" font-weight="700" fill="#7f1d1d">节点图例</text><circle cx="18" cy="46" r="7" fill="#065f46"/><text x="32" y="50" font-size="11">A核心</text><circle cx="92" cy="46" r="7" fill="#10b981"/><text x="106" y="50" font-size="11">B关键</text><circle cx="168" cy="46" r="7" fill="#86efac"/><text x="182" y="50" font-size="11">C外围</text><circle cx="18" cy="73" r="7" fill="#2563eb"/><text x="32" y="77" font-size="11">机构</text><circle cx="92" cy="73" r="7" fill="#dc2626"/><text x="106" y="77" font-size="11">事件/风险</text><circle cx="18" cy="100" r="7" fill="#7e22ce"/><text x="32" y="104" font-size="11">派系/系统</text></g>`;
return `<svg class="network-svg" viewBox="0 0 ${w} ${h}" xmlns="http://www.w3.org/2000/svg">${legend}<g>${edgeEls}</g><g>${nodeEls}</g></svg>`;
}
function renderMapGallery(data){
const maps=data||[]; if(!maps.length)return $('#content').innerHTML='<div class="empty">没有关系图原图</div>';
const active=maps[0];
$('#content').innerHTML=`<div class="gallery"><div><div class="map-list">${maps.map((m,i)=>`<div class="map-thumb ${i===0?'active':''}" data-mi="${i}"><b>${esc(m.title)}</b><p style="font-size:12px;color:#6b7280">${esc(m.description)}</p><img src="${m.data_uri}"></div>`).join('')}</div><div class="legend"><b>图例说明</b><br>${esc((DATA.datasets.network_map_legend&&DATA.datasets.network_map_legend[0]&&DATA.datasets.network_map_legend[0].readme)||'')}</div></div><div class="map-view"><h3 id="mapTitle" style="margin-top:0;color:#7f1d1d">${esc(active.title)}</h3><p id="mapDesc" style="color:#6b7280">${esc(active.description)}</p><img id="mapImg" src="${active.data_uri}"></div></div>`;
document.querySelectorAll('.map-thumb').forEach(x=>x.onclick=()=>{document.querySelectorAll('.map-thumb').forEach(y=>y.classList.remove('active'));x.classList.add('active');const m=maps[+x.dataset.mi];$('#mapTitle').textContent=m.title;$('#mapDesc').textContent=m.description;$('#mapImg').src=m.data_uri;});
}
function openDetail(obj,title='详情'){
if(!obj)return; $('#modalTitle').textContent=title || getTitle(obj,{});
if(state.module==='people_network'){
$('#modalSub').textContent='点击右上角关闭;字段名称保持原始中文。';
$('#modalBody').innerHTML=`<div class="detail-grid">${Object.entries(obj).filter(([k,v])=>v!==''&&v!==null&&v!==undefined&&k!=='data_uri').map(([k,v])=>`<div class="k">${esc(k)}</div><div class="v">${esc(arrText(v))}</div>`).join('')}</div>`;
} else {
$('#modalSub').textContent='结构化详情;已将原始字段转为页面语言展示。';
$('#modalBody').innerHTML=richDetail(obj,title);
}
$('#modal').classList.add('show');
}
function richDetail(obj,title){
const important=['风险摘要','核心内容','主要争点','核心条款摘要','政治影响','2026选举影响','短期影响','长期影响','说明','核心规则','触发条件','程序门槛','核心话术','对抗话术','认知域相关职责','争议风险','主要功能','风险信号'];
const summary=important.filter(k=>!isEmpty(obj[k])).map(k=>`<section class="detail-section emphasis"><h3>${esc(k)}</h3><p>${esc(flatText(obj[k]))}</p></section>`).join('');
const tags=Object.entries(obj).filter(([k,v])=>/(标签|平台|人物|地区|阵营|类型|等级|状态|可信度|风险)/.test(k)&&!important.includes(k)&&!isEmpty(v)).slice(0,12).map(([k,v])=>`<div class="tag-group"><b>${esc(k)}</b><div class="tags">${tagHtml(v)}</div></div>`).join('');
const source=Object.entries(obj).filter(([k,v])=>/(来源|URL|链接|证据|核验|更新|备注|ID)/.test(k)&&!isEmpty(v)&&k!=='data_uri').map(([k,v])=>`<div><span>${esc(k)}</span><b>${esc(flatText(v))}</b></div>`).join('');
const rest=Object.entries(obj).filter(([k,v])=>!isEmpty(v)&&k!=='data_uri'&&!important.includes(k)&&!/(标签|平台|人物|地区|阵营|类型|等级|状态|可信度|风险|来源|URL|链接|证据|核验|更新|备注|ID)/.test(k)).map(([k,v])=>`<div class="info-row"><b>${esc(k)}</b><span>${esc(flatText(v))}</span></div>`).join('');
return `<div class="rich-detail">${summary||`<section class="detail-section emphasis"><h3>概要</h3><p>${esc(flatText(obj).slice(0,800))}</p></section>`}${tags?`<section class="detail-section"><h3>标签与关联</h3>${tags}</section>`:''}${rest?`<section class="detail-section"><h3>详细字段</h3><div class="info-list">${rest}</div></section>`:''}${source?`<section class="detail-section source-section"><h3>来源与核验</h3><div class="mini-fields">${source}</div></section>`:''}</div>`;
}
function closeModal(){ $('#modal').classList.remove('show'); }
$('#modal').addEventListener('click',e=>{if(e.target.id==='modal')closeModal();});
init();
</script>
</body>
</html>
@@ -0,0 +1,100 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>台湾公务员群体知识数据展示系统 v2</title>
<style>
:root{
--red:#b4232d;--red2:#d94a54;--red3:#fff1f2;--red4:#78202a;--red5:#5d1420;
--cream:#fff8f6;--paper:#fffdfa;--bg:#f4f6f8;--white:#fff;--text:#20252d;--muted:#667085;
--line:#ead7d3;--line2:#e5e7eb;--soft:#f8fafc;--amber:#b4690e;--green:#14804a;--blue:#1d5f99;
--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:radial-gradient(circle at 20% 0%,rgba(180,35,45,.12),transparent 30%),linear-gradient(135deg,#f7eeee,#f6f7f9 45%,#fff7f5)}
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:82px;background:linear-gradient(120deg,var(--red5),var(--red));color:#fff;display:flex;align-items:center;justify-content:space-between;padding:0 30px;box-shadow:0 6px 22px rgba(126,22,32,.25);position:sticky;top:0;z-index:100}.brand{display:flex;align-items:center;gap:14px}.brand-mark{width:48px;height:48px;border:2px solid rgba(255,255,255,.75);border-radius:14px;display:grid;place-items:center;font-weight:900;font-size:22px;background:rgba(255,255,255,.12)}.brand h1{font-size:22px;margin:0 0 5px;letter-spacing:.3px}.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:360px;border:1px solid rgba(255,255,255,.25);background:rgba(255,255,255,.15);color:#fff;border-radius:12px;padding:11px 42px 11px 15px;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:50px;width:min(560px,92vw);max-height:480px;overflow:auto;background:#fff;color:var(--text);border:1px solid var(--line);border-radius:14px;box-shadow:var(--shadow);padding:8px;z-index:200}.search-item{display:flex;gap:12px;padding:12px;border-radius:10px;border-bottom:1px dashed #eee}.search-item:hover{background:var(--red3)}.search-kind{min-width:70px;color:var(--red);font-size:12px;font-weight:800}.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:10px;padding:10px 15px;font-weight:800;transition:.18s}.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.primary{background:var(--red);color:#fff}.btn.light{background:var(--red3);color:var(--red)}.btn.line{background:#fff;color:var(--red);border:1px solid #efb7bc}.btn.sm{padding:7px 10px;font-size:12px}.tabs{height:60px;background:#fff;display:flex;align-items:end;gap:2px;padding:0 24px;border-bottom:1px solid var(--line);position:sticky;top:82px;z-index:90;overflow-x:auto}.tab{border:0;background:transparent;color:#4b5563;font-weight:800;padding:18px 19px 15px;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 56px;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:26px;color:#531520}.page-head p{margin:0;color:var(--muted);line-height:1.65}.page-actions{display:flex;gap:9px;flex-wrap:wrap}.notice{background:linear-gradient(120deg,#fff4f4,#fff);border:1px solid #f2c6ca;border-left:5px solid var(--red);border-radius:13px;padding:14px 16px;line-height:1.75;color:#5e3034}.stats{display:grid;grid-template-columns:repeat(4,1fr);gap:14px}.stat{background:rgba(255,255,255,.94);border:1px solid var(--line);border-top:4px solid var(--red);border-radius:15px;padding:18px;box-shadow:0 6px 18px rgba(31,41,55,.045)}.stat .label{color:var(--muted);font-size:13px}.stat .num{font-size:30px;font-weight:900;margin:9px 0 3px;color:#54151e}.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,.card{background:rgba(255,255,255,.96);border:1px solid var(--line);border-radius:16px;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:14px}.panel h3,.panel h4{margin:0}.section-title{display:flex;justify-content:space-between;align-items:end;margin:25px 0 13px}.section-title h3{font-size:18px;margin:0;color:#5f1822}.section-title span{color:var(--muted);font-size:12px}.badge,.chip{display:inline-flex;align-items:center;border-radius:999px;padding:5px 9px;font-size:12px;font-weight:800;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.blue{background:#e9f2fb;color:var(--blue)}.badge.gray{background:#f1f3f5;color:#667085}.filters{display:grid;grid-template-columns:1.4fr repeat(4,1fr) auto;gap:10px;background:#fff;border:1px solid var(--line);border-radius:14px;padding:13px;margin-bottom:16px}.input,.select{width:100%;border:1px solid #d8dde4;border-radius:10px;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)}.card-item{background:#fff;border:1px solid var(--line);border-radius:16px;padding:17px;min-height:210px;display:flex;flex-directionLine truncated
@media(max-width:1120px){.stats{grid-template-columns:repeat(2,1fr)}.grid.cols3,.grid.cols4{grid-template-columns:repeat(2,1fr)}.filters{grid-template-columns:1fr 1fr 1fr}.two-col{grid-template-columns:1fr}.detail-hero{grid-template-columns:1fr}}
@media(max-width:760px){.header{height:auto;padding:14px 16px;align-items:flex-start}.brand p{display:none}.header-actions{display:none}.tabs{top:70px;padding-left:8px}.main{padding:17px 12px 45px}.stats,.grid.cols2,.grid.cols3,.grid.cols4{grid-template-columns:1fr}.filters{grid-template-columns:1fr}.modal-card{margin:3vh auto;max-height:94vh}.footer{display:block;line-height:1.8}.kv{grid-template-columns:1fr}.brand h1{font-size:18px}}
</style>
</head>
<body>
<div class="app">
<header class="header"><div class="brand"><div class="brand-mark">公</div><div><h1>台湾公务员群体知识数据展示系统</h1><p>基础画像 · 政策法规 · 研究成果 · 近月议题 · 动态监测</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" id="sourceBtn">来源索引</button><button class="btn ghost" id="resetBtn">重置</button></div></header>
<nav class="tabs" id="tabs"></nav>
<main class="main" id="main"></main>
<footer class="footer"><span>单文件离线原型 v2 · 中文字段与富详情版</span><span>数据用途:公共治理研究与政策分析;不用于针对个体或群体的操纵性应用</span></footer>
</div>
<div id="modal" class="modal hidden"><div class="backdrop" onclick="closeModal()"></div><div class="modal-card"><button class="close" onclick="closeModal()">×</button><div id="modalBody"></div></div></div>
<div id="detail" class="detail hidden"></div>
<script>
const EXTERNAL_JSON_URL = ""; // 后续可改为 "./taiwan_civil_service_display_data_v2.json"
let DATA = {"meta": {"title": "台湾公务员群体知识数据展示系统", "version": "2.0-rich-ui", "data_update_time": "2026-06-23", "observation_period": "2026-05-24 至 2026-06-23", "scope": "台湾公务员群体基础画像、政策法规、研究成果、关注议题与近月动态", "source_note": "基于已整理的台湾公务员群体技能文档、政策文献、研究成果与近月公开资料构建。", "usage_note": "本页面用于公共治理研究、政策分析和资料展示。涉及群体敏感点时,统一表述为制度敏感点、治理风险点和政策观察点。", "ui_note": "v2 修复字段英文化、详情空白和研究成果内容过少问题;前端以中文字段和富文本详情展示。"}, "navigation": {"tabs": [{"id": "basic_knowledge", "label": "公务员基础知识", "description": "定义、数量、任用、官等职等、薪资、年龄、性别、法规"}, {"id": "policy_changes", "label": "政策法规变动", "description": "赖清德上任后涉公务员政策法规时间线"}, {"id": "research_outputs", "label": "报告与研究成果", "description": "研究成果、文献索引、核心发现和治理敏感点"}, {"id": "recent_issues", "label": "近月关注议题", "description": "近一个月关注议题的来龙去脉、诉求和态度结构"}, {"id": "monthly_dynamics", "label": "近月动态监测", "description": "新闻、组织、公务员社群、大V与官方动态"}]}, "global_query": {"enabled": true, "search_fields": ["title", "summary", "description", "keywords", "tags", "affected_groups", "source_type", "policy_name", "issue_name", "research_title", "actor", "event_title", "page_summary", "governance_sensitivity", "group_demands"], "filter_dimensions": ["date", "topic_type", "source_type", "affected_groups", "evidence_level", "importance", "status", "heat_level", "policy_type", "issue_type", "theme_cluster"]}, "data": {"basic_knowledge": {"tab_title": "公务员基础知识", "layout": "dashboard", "summary_cards": [{"id": "total_count", "label": "公务员总数", "value": 360080, "unit": "人", "description": "全國公務人員口径,民国114年/2025年"}, {"id": "average_age", "label": "平均年龄", "value": 43.75, "unit": "岁", "description": "民国114年/2025年统计"}, {"id": "male_ratio", "label": "男性占比", "value": "56.34%", "description": "全國公務人員性别结构"}, {"id": "female_ratio", "label": "女性占比", "value": "43.66%", "description": "全國公務人員性别结构"}], "definition": {"title": "台湾公务员群体定义", "summary": "台湾公务员通常指依法考试、任用、铨叙或特别人事制度进入公部门并承担公共行政职责的人员。页面采用全國公務人員总口径。", "included_groups": ["行政机关公务人员", "公营事业机构相关公务人员", "卫生医疗机构职员", "公立学校职员", "特殊职系公务人员"], "excluded_groups": ["政务人员", "民选地方行政首长", "民意代表", "公立学校教师"], "detail_display": "modal"}, "identity_comparison": {"title": "政务官与事务官区分", "display": "comparison_table", "items": [{"dimension": "基本性质", "political_official": "政治任命或政策领导人员", "career_civil_servant": "职业文官、常任公务人员"}, {"dimension": "进入方式", "political_official": "任命、特任、特派、提名同意", "career_civil_servant": "考试及格、铨叙合格、升等合格"}, {"dimension": "主要功能", "political_official": "制定政策、决定施政方向、承担政治责任", "career_civil_servant": "执行政策、处理行政事务、承担行政责任"}, {"dimension": "任期稳定性", "political_official": "通常随执政团队或政治任命变化而进退", "career_civil_servant": "具有身份保障,非因法定原因不得任意免职"}, {"dimension": "行政中立", "political_official": "一般不是行政中立法的主要适用对象", "career_civil_servant": "行政中立法的核心适用对象"}, {"dimension": "典型职位", "political_official": "部会首长、政务次长、政务委员", "career_civil_servant": "科员、专员、科长、司长、常务次长"}], "detail": {"政务人员": "政策制定层,承担政治责任,随执政团队和政治任命变化。", "事务官": "职业文官体系主体,强调依法行政、行政中立、身份保障和长期行政执行。", "边界类别": ["机要人员", "约聘雇人员", "行政契约人员"]}, "detail_display": "secondary_page"}, "appointment_modes": [{"name": "考试任用", "description": "通过国家考试进入公务体系,是事务官的典型进入路径。"}, {"name": "铨叙任用", "description": "依法取得任用资格并经铨叙程序确认。"}, {"name": "升等任用", "description": "依资历、考绩、训练或升等考试取得更高职等任用资格。"}, {"name": "政务任用", "description": "政治任命性质,主要对应Line truncated
let activeTab = DATA.navigation.tabs[0].id;
let filterState = {};
const FIELD_LABELS = DATA.ui?.field_labels || {};
const THEME_LABELS = DATA.ui?.theme_labels || {};
const TAB_KIND = {basic_knowledge:'基础知识',policy_changes:'政策法规',research_outputs:'研究成果',recent_issues:'关注议题',monthly_dynamics:'动态监测'};
function esc(x){return String(x ?? '').replace(/[&<>"']/g,m=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[m]));}
function arr(x){return Array.isArray(x)?x:(x? [x]:[])}
function label(k){return FIELD_LABELS[k] || THEME_LABELS[k] || k}
function textOf(x){ if(x==null) return ''; if(typeof x==='string'||typeof x==='number'||typeof x==='boolean') return String(x); if(Array.isArray(x)) return x.map(textOf).join(' '); if(typeof x==='object') return Object.values(x).map(textOf).join(' '); return '' }
function tagList(xs, cls='red'){ return arr(xs).map(x=>`<span class="badge ${cls}">${esc(label(x))}</span>`).join('') }
function chips(xs){ return arr(xs).map(x=>`<span class="pill">${esc(label(x))}</span>`).join('') }
function sourceButton(url){ return url ? `<a class="source-link" target="_blank" href="${esc(url)}">打开来源</a>` : `<span class="badge gray">暂无来源链接</span>` }
function detailBlocks(sections){ return arr(sections).filter(s=>textOf(s.content).trim()).map(s=>`<div class="detail-block"><h4>${esc(s.title)}</h4><p>${esc(s.content)}</p></div>`).join('') }
function kv(labelText, value){ if(value==null || value==='' || (Array.isArray(value)&&!value.length)) return ''; return `<div class="kv"><div class="k">${esc(labelText)}</div><div class="v">${Array.isArray(value)?tagList(value):esc(label(value))}</div></div>`}
function setup(){renderTabs(); renderPage(); bindGlobalSearch(); document.getElementById('resetBtn').onclick=()=>{filterState={}; document.getElementById('globalSearch').value=''; renderPage();}; document.getElementById('sourceBtn').onclick=showSources;}
function renderTabs(){const box=document.getElementById('tabs'); box.innerHTML=DATA.navigation.tabs.map(t=>`<button class="tab ${t.id===activeTab?'active':''}" data-id="${t.id}">${esc(t.label)}</button>`).join(''); box.querySelectorAll('button').forEach(b=>b.onclick=()=>{activeTab=b.dataset.id; filterState={}; document.getElementById('searchPop').classList.add('hidden'); renderTabs(); renderPage();});}
function pageHead(title,desc){return `<div class="page-head"><div><h2>${esc(title)}</h2><p>${esc(desc||'')}</p></div><div class="page-actions"><button class="btn line sm" onclick="showSources()">来源索引</button><button class="btn primary sm" onclick="exportCurrentJSON()">导出本栏JSON</button></div></div>`}
function renderPage(){const main=document.getElementById('main'); const tab=DATA.navigation.tabs.find(t=>t.id===activeTab); let html=pageHead(tab.label, tab.description); if(activeTab==='basic_knowledge') html+=renderBasic(); if(activeTab==='policy_changes') html+=renderPolicy(); if(activeTab==='research_outputs') html+=renderResearch(); if(activeTab==='recent_issues') html+=renderIssues(); if(activeTab==='monthly_dynamics') html+=renderDynamics(); main.innerHTML=html; bindFilters();}
function renderBasic(){const d=DATA.data.basic_knowledge; const cards=d.summary_cards||[]; let html=`<div class="notice mb">${esc(d.rich_note?.paragraphs?.[0]||'')}</div>`; html+=`<div class="stats mb">${cards.map(c=>`<div class="stat"><div class="label">${esc(c.label)}</div><div class="num">${esc(c.value)}${c.unit?`<small style="font-size:14px"> ${esc(c.unit)}</small>`:''}</div><div class="hint">${esc(c.description||'')}</div></div>`).join('')}</div>`; html+=`<div class="grid cols2"><div class="panel"><div class="panel-head"><h3>台湾公务员群体定义</h3><button class="btn light sm" onclick="openBasicDetail('definition')">详情</button></div><p>${esc(d.definition.summary||d.definition.official_scope||'')}</p><h4>包含群体</h4><div>${tagList(d.definition.included_groups,'green')}</div><h4 class="mt">排除群体</h4><div>${tagList(d.definition.excluded_groups,'gray')}</div></div>`;
html+=`<div class="panel"><div class="panel-head"><h3>政务官与事务官区分</h3><button class="btn light sm" onclick="openBasicDetail('identity')">二级详情</button></div><div class="table-wrap"><table class="compare-table"><thead><tr><th>比较维度</th><th>政务官 / 政务人员</th><th>事务官 / 常任文官</th></tr></thead><tbody>${(d.identity_comparison.items||[]).map(r=>`<tr><td>${esc(r.dimension)}</td><td>${esc(r.political_official)}</td><td>${esc(r.career_civil_servant)}</td></tr>`).join('')}</tbody></table></div></div></div>`;
html+=`<div class="section-title"><h3>任用方式与官等职等</h3><span>点击卡片可查看说明</span></div><div class="grid cols3">${(d.appointment_modes||[]).map(m=>`<div class="card-item" onclick="openModalHTML('${esc(m.name)}','<p>${esc(m.description)}</p>')"><h4>${esc(m.name)}</h4><p>${esc(m.description)}</p></div>`).join('')}</div>`;
html+=`<div class="grid cols2 mt"><div class="panel"><div class="panel-head"><h3>官等职等结构</h3></div>${(d.rank_structure||[]).map(r=>`<div class="rank"><strong>${esc(r.rank)}</strong><p>${esc(r.grade_range)}|${esc(arr(r.typical_positions).join('、'))}</p></div>`).join('')}</div><div class="panel"><div class="panel-head"><h3>薪资待遇结构</h3></div>${(d.salary_structure||[]).map(s=>`<div class="detail-block"><h4>${esc(s.name)}</h4><p>${esc(s.description)}</p></div>`).join('')}</div></div>`;
html+=`<div class="grid cols2 mt"><div class="panel"><div class="panel-head"><h3>年龄结构</h3><span class="badge red">平均 ${esc(d.summary_cards?.find(c=>c.id==='average_age')?.value||'43.75')} 岁</span></div>${(d.age_structure||[]).map(a=>`<div class="bar-row"><span>${esc(a.age_group)}</span><div class="bar-track"><div class="bar-fill" style="width:${Math.max(2,a.ratio*4)}%"></div></div><b>${esc(a.ratio)}%</b></div>`).join('')}</div><div class="panel"><div class="panel-head"><h3>性别结构与法规框架</h3></div>${(d.gender_structure||[]).map(g=>`<div class="bar-row"><span>${esc(g.gender)}</span><div class="bar-track"><div class="bar-fill" style="width:${g.ratio}%"></div></div><b>${g.ratio}%</b></div>`).join('')}<h4>适用法规</h4><div class="law-grid">${tagList(d.legal_framework,'blue')}</div></div></div>`;
html+=`<div class="panel mt"><h3>结构性判断</h3><div class="grid cols3 mt">${(d.structure_judgement||[]).map(j=>`<div class="detail-block"><h4>${esc(j.label||j.observation||'观察项')}</h4><p>${esc(j.explanation||j.description||j.value||textOf(j))}</p></div>`).join('')}</div></div>`;
return html;}
function openBasicDetail(kind){const d=DATA.data.basic_knowledge; if(kind==='definition'){openModalHTML('台湾公务员群体定义', `<p>${esc(d.definition.summary||'')}</p><h4>包含群体</h4><div>${tagList(d.definition.included_groups,'green')}</div><h4>排除群体</h4><div>${tagList(d.definition.excluded_groups,'gray')}</div>${detailBlocks((d.rich_note?.paragraphs||[]).map((p,i)=>({title:'说明 '+(i+1),content:p})))}`)} else {openDetailPage('政务官与事务官区分', d.identity_comparison.title, `<div class="notice">政务官偏向政治任命与政策责任,事务官偏向考试任用、行政执行与身份保障。机要人员和约聘雇人员应作为边界类别单独识别。</div><div class="table-wrap mt"><table><thead><tr><th>比较维度</th><th>政务官 / 政务人员</th><th>事务官 / 常任文官</th></tr></thead><tbody>${d.identity_comparison.items.map(r=>`<tr><td>${esc(r.dimension)}</td><td>${esc(r.political_official)}</td><td>${esc(r.career_civil_servant)}</td></tr>`).join('')}</tbody></table></div>`)}}
function filterUI(type, selects){return `<div class="filters"><input class="input" data-filter="q" placeholder="在本栏搜索关键词" value="${esc(filterState.q||'')}">${selects.map(s=>selectHTML(s.key,s.label,s.options)).join('')}<button class="btn primary" onclick="applyFilters()">查询</button></div>`}
function selectHTML(key,labelText,options){return `<select class="select" data-filter="${key}"><option value="">${esc(labelText)}:全部</option>${options.map(o=>`<option ${filterState[key]===o?'selected':''} value="${esc(o)}">${esc(label(o))}</option>`).join('')}</select>`}
function bindFilters(){document.querySelectorAll('[data-filter]').forEach(el=>{el.onchange=()=>{filterState[el.dataset.filter]=el.value; renderPage();}; el.onkeydown=e=>{if(e.key==='Enter'){filterState[el.dataset.filter]=el.value; renderPage();}}});}
function applyFilters(){document.querySelectorAll('[data-filter]').forEach(el=>filterState[el.dataset.filter]=el.value); renderPage();}
function unique(items, getter){return [...new Set(items.flatMap(x=>arr(getter(x))).filter(Boolean))]}
function filterList(items, cfg){let q=(filterState.q||'').trim().toLowerCase(); return items.filter(it=>{if(q && !textOf(it).toLowerCase().includes(q)) return false; for(const [k,getter] of Object.entries(cfg||{})){if(filterState[k]){let vals=arr(getter(it)).map(String); if(!vals.includes(filterState[k])) return false;}} return true;})}
function renderPolicy(){const d=DATA.data.policy_changes, items=d.items||[]; const filtered=filterList(items,{type:x=>x.policy_type,status:x=>x.status,group:x=>x.affected_groups,importance:x=>x.importance}); let html=filterUI('policy',[{key:'type',label:'政策类型',options:unique(items,x=>x.policy_type)},{key:'status',label:'政策状态',options:unique(items,x=>x.status)},{key:'group',label:'影响群体',options:unique(items,x=>x.affected_groups)},{key:'importance',label:'重要性',options:unique(items,x=>x.importance)}]); html+=`<div class="stats mb"><div class="stat"><div class="label">政策总数</div><div class="num">${items.length}</div><div class="hint">赖清德上任至今</div></div><div class="stat"><div class="label">当前命中</div><div class="num">${filtered.length}</div><div class="hint">按筛选条件</div></div><div class="stat"><div class="label">高重要性</div><div class="num">${items.filter(x=>x.importance==='高').length}</div><div class="hint">重点跟踪</div></div><div class="stat"><div class="label">草案/审议</div><div class="num">${items.filter(x=>/草案|审议|函送/.test(x.status+x.summary)).length}</div><div class="hint">需后续观察</div></div></div>`; html+=`<div class="grid cols2"><div class="panel"><h3>政策法规时间线</h3><div class="timeline mt">${filtered.map(it=>eventHTML(it.date,it.policy_name,it.summary,`openPolicy('${it.id}')`)).join('')||'<div class="empty">无匹配数据</div>'}</div></div><div class="panel"><h3>政策卡片</h3><div class="grid cols2 mt">${filtered.map(policyCard).join('')||'<div class="empty">无匹配数据</div>'}</div></div></div>`; return html;}
function eventHTML(date,title,summary,click){return `<div class="event" onclick="${click}"><div class="date">${esc(date)}</div><h4>${esc(title)}</h4><p>${esc(summary)}</p></div>`}
function policyCard(it){return `<div class="card-item"><h4>${esc(it.policy_name)}</h4><div>${tagList([it.policy_type,it.status,it.importance])}</div><p>${esc(it.summary)}</p><div>${tagList(it.affected_groups,'gray')}</div><div class="card-foot"><button class="btn light sm" onclick="openPolicy('${it.id}')">弹框详情</button><button class="btn line sm" onclick="openPolicyPage('${it.id}')">二级详情</button></div></div>`}
function openPolicy(id){const it=DATA.data.policy_changes.items.find(x=>x.id===id); openModalHTML(it.policy_name, renderRecord(it, 'policy'))}
function openPolicyPage(id){const it=DATA.data.policy_changes.items.find(x=>x.id===id); openDetailPage('政策法规二级详情', it.policy_name, renderRecord(it,'policy',true))}
function renderResearch(){const d=DATA.data.research_outputs, items=d.items||[]; const filtered=filterList(items,{theme:x=>x.theme_cluster,source:x=>x.source_type,evidence:x=>x.evidence_strength,group:x=>x.target_groups}); let html=filterUI('research',[{key:'theme',label:'主题簇',options:unique(items,x=>x.theme_cluster)},{key:'source',label:'来源类型',options:unique(items,x=>x.source_type)},{key:'evidence',label:'证据强度',options:unique(items,x=>x.evidence_strength)},{key:'group',label:'对应群体',options:unique(items,x=>x.target_groups)}]); html+=`<div class="grid cols4 mb">${(d.theme_clusters||[]).map(c=>`<div class="panel" onclick="filterState.theme='${esc(c.id)}'; renderPage();"><h4>${esc(c.label)}</h4><p>${esc((c.keywords||[]).join('、'))}</p><span class="badge red">${esc(c.count||items.filter(x=>x.theme_cluster===c.id).length)} 条</span></div>`).join('')}</div>`; html+=`<div class="grid cols3">${filtered.map(researchCard).join('')||'<div class="empty">无匹配数据</div>'}</div>`; html+=`<div class="section-title"><h3>研究结论图谱</h3><span>点击查看支撑研究</span></div><div class="grid cols3">${(d.insight_map||[]).map(i=>`<div class="panel"><h4>${esc(i.theme)}</h4><p>${esc(i.core_conclusion)}</p><div>${tagList(i.related_groups,'gray')}</div><button class="btn light sm mt" onclick="openInsight('${esc(i.theme)}')">查看关联</button></div>`).join('')}</div>`; return html;}
function researchCard(it){return `<div class="card-item"><h4>${esc(it.title)}</h4><div>${tagList([THEME_LABELS[it.theme_cluster]||it.theme_cluster,it.source_type,it.evidence_strength])}</div><p>${esc(it.page_summary||it.core_finding)}</p><div>${tagList(it.target_groups,'gray')}</div><div class="card-foot"><button class="btn light sm" onclick="openResearch('${it.id}')">弹框详情</button><button class="btn line sm" onclick="openResearchPage('${it.id}')">二级详情</button></div></div>`}
function openResearch(id){const it=DATA.data.research_outputs.items.find(x=>x.id===id); openModalHTML(it.title, renderRecord(it,'research') + `<div class="mt">${sourceButton(it.source_url)}</div>`)}
function openResearchPage(id){const it=DATA.data.research_outputs.items.find(x=>x.id===id); openDetailPage('研究成果二级详情', it.title, renderRecord(it,'research',true)+`<div class="mt">${sourceButton(it.source_url)}</div>`)}
function openInsight(theme){const i=DATA.data.research_outputs.insight_map.find(x=>x.theme===theme); const rs=arr(i.related_research_ids).map(id=>DATA.data.research_outputs.items.find(x=>x.id===id)).filter(Boolean); openModalHTML(theme, `<p class="modal-sub">${esc(i.core_conclusion)}</p><div>${tagList(i.related_groups,'gray')}</div><div class="grid cols2 mt">${rs.map(researchCard).join('')}</div>`)}
function renderIssues(){const d=DATA.data.recent_issues, items=d.items||[]; const filtered=filterList(items,{heat:x=>x.heat_level,type:x=>x.issue_type,group:x=>x.affected_groups,evidence:x=>x.evidence_level}); let html=filterUI('issues',[{key:'heat',label:'热度',options:unique(items,x=>x.heat_level)},{key:'type',label:'议题类型',options:unique(items,x=>x.issue_type)},{key:'group',label:'影响群体',options:unique(items,x=>x.affected_groups)},{key:'evidence',label:'证据等级',options:unique(items,x=>x.evidence_level)}]); html+=`<div class="notice mb">近月议题栏展示的是“事件链—群体诉求—态度结构—制度敏感点”,不是简单榜单。</div><div class="grid cols3">${filtered.map(issueCard).join('')||'<div class="empty">无匹配数据</div>'}</div>`; const dm=d.demand_map||[]; if(dm.length) html+=`<div class="section-title"><h3>群体诉求图谱</h3></div><div class="grid cols4">${dm.map(x=>`<div class="panel"><h4>${esc(x.demand_type||x.type||x.title)}</h4><p>${esc(x.content||x.description||textOf(x))}</p></div>`).join('')}</div>`; return html;}
function issueCard(it){return `<div class="card-item"><h4>${esc(it.issue_name)}</h4><div>${tagList([it.heat_level,it.issue_type,it.evidence_level])}</div><p>${esc(it.page_summary)}</p><div>${tagList(it.affected_groups,'gray')}</div><div class="card-foot"><button class="btn light sm" onclick="openIssue('${it.id}')">弹框详情</button><button class="btn line sm" onclick="openIssuePage('${it.id}')">二级详情</button></div></div>`}
function openIssue(id){const it=DATA.data.recent_issues.items.find(x=>x.id===id); openModalHTML(it.issue_name, renderRecord(it,'issue') + `<div class="mt">${sourceButton(it.source_url)}</div>`)}
function openIssuePage(id){const it=DATA.data.recent_issues.items.find(x=>x.id===id); openDetailPage('议题深度二级详情', it.issue_name, renderRecord(it,'issue',true)+`<div class="mt">${sourceButton(it.source_url)}</div>`)}
function renderDynamics(){const d=DATA.data.monthly_dynamics, items=d.items||[]; const filtered=filterList(items,{source:x=>x.source_type,issue:x=>x.related_issue,importance:x=>x.importance,evidence:x=>x.evidence_level}); let html=filterUI('dynamics',[{key:'source',label:'来源类型',options:unique(items,x=>x.source_type)},{key:'issue',label:'关联议题',options:unique(items,x=>x.related_issue)},{key:'importance',label:'重要性',options:unique(items,x=>x.importance)},{key:'evidence',label:'证据等级',options:unique(items,x=>x.evidence_level)}]); html+=`<div class="grid cols2"><div class="panel"><h3>动态时间线</h3><div class="timeline mt">${filtered.map(it=>eventHTML(it.date,it.event_title,it.event_summary,`openDynamic('${it.id}')`)).join('')||'<div class="empty">无匹配数据</div>'}</div></div><div class="panel"><h3>月度综合判断</h3>${(d.monthly_summary?.main_lines||[]).map(x=>`<div class="detail-block"><h4>${esc(x.line)}</h4><p>${esc(x.summary)}</p></div>`).join('')}</div></div>`; html+=`<div class="section-title"><h3>动态卡片流</h3><span>${filtered.length} 条</span></div><div class="grid cols3">${filtered.map(dynamicCard).join('')}</div>`; return html;}
function dynamicCard(it){return `<div class="card-item"><h4>${esc(it.event_title)}</h4><div>${tagList([it.date,it.source_type,it.importance])}</div><p>${esc(it.page_summary||it.event_summary)}</p><div>${tagList(it.related_issue,'gray')}</div><div class="card-foot"><button class="btn light sm" onclick="openDynamic('${it.id}')">弹框详情</button><button class="btn line sm" onclick="openDynamicPage('${it.id}')">二级详情</button></div></div>`}
function openDynamic(id){const it=DATA.data.monthly_dynamics.items.find(x=>x.id===id); openModalHTML(it.event_title, renderRecord(it,'dynamic') + `<div class="mt">${sourceButton(it.source_url)}</div>`)}
function openDynamicPage(id){const it=DATA.data.monthly_dynamics.items.find(x=>x.id===id); openDetailPage('动态监测二级详情', it.event_title, renderRecord(it,'dynamic',true)+`<div class="mt">${sourceButton(it.source_url)}</div>`)}
function renderRecord(it, type, full=false){let top=''; if(type==='policy') top+=kv('编号',it.id)+kv('日期',it.date)+kv('政策类型',it.policy_type)+kv('政策状态',it.status)+kv('主管机关',it.authority)+kv('重要性',it.importance)+kv('影响群体',it.affected_groups); if(type==='research') top+=kv('编号',it.id)+kv('来源类型',it.source_type)+kv('机构',it.institution)+kv('作者',it.author)+kv('年份',it.year)+kv('文献类型',it.document_type)+kv('主题簇',THEME_LABELS[it.theme_cluster]||it.theme_cluster)+kv('对应群体',it.target_groups)+kv('证据强度',it.evidence_strength); if(type==='issue') top+=kv('编号',it.id)+kv('热度',it.heat_level)+kv('议题类型',it.issue_type)+kv('时间范围',it.time_range)+kv('影响群体',it.affected_groups)+kv('证据等级',it.evidence_level); if(type==='dynamic') top+=kv('编号',it.id)+kv('日期',it.date)+kv('来源类型',it.source_type)+kv('主体',it.actor)+kv('重要性',it.importance)+kv('关联议题',it.related_issue)+kv('影响群体',it.affected_groups)+kv('证据等级',it.evidence_level);
let html=`<div class="detail-block"><h4>基本信息</h4>${top||'<p>无基本信息</p>'}</div>`; html += detailBlocks(it.rich_detail||[]);
if(it.event_chain){ html+=`<div class="detail-block"><h4>事件链</h4><div class="timeline">${it.event_chain.map(e=>eventHTML(e.date,e.event,e.event,'')).join('')}</div></div>`}
if(it.attitude_structure){ html+=`<div class="detail-block"><h4>态度结构</h4>${it.attitude_structure.map(a=>`<p><b>${esc(a.attitude)}:</b>${esc(a.description)}</p>`).join('')}</div>`}
if(it.tags) html += `<div class="detail-block"><h4>标签</h4>${tagList(it.tags,'gray')}</div>`; return html;}
function openModalHTML(title, html){document.getElementById('modalBody').innerHTML=`<h2 class="modal-title">${title}</h2>${html}`; document.getElementById('modal').classList.remove('hidden')}
function closeModal(){document.getElementById('modal').classList.add('hidden')}
function openDetailPage(kind,title,html){document.getElementById('detail').innerHTML=`<div class="detail-top"><strong>${esc(kind)}</strong><button class="btn white" onclick="closeDetail()">返回</button></div><div class="detail-body"><div class="detail-hero"><div><h2>${esc(title)}</h2><p>本页用于展示较长字段、事件链、研究解释和关联信息,避免主列表过度拥挤。</p></div><div class="detail-summary"><b>展示原则</b><p>主卡片只显示摘要;本页显示完整结构化内容。字段均已中文化,不再直接展示技术字段名。</p></div></div><div class="mt">${html}</div></div>`; document.getElementById('detail').classList.remove('hidden')}
function closeDetail(){document.getElementById('detail').classList.add('hidden')}
function showSources(){const sources=DATA.source_index||[]; openModalHTML('来源索引', `<p class="modal-sub">本页保留来源索引,便于后续回溯和外部 JSON 长期更新。</p>${sources.map(s=>`<div class="source-card"><h4>${esc(s.name)}</h4><p>${esc(s.type||'')}|${esc(s.note||'')}</p><div class="mt">${sourceButton(s.url)}</div></div>`).join('')}`)}
function exportCurrentJSON(){const obj=DATA.data[activeTab]; const blob=new Blob([JSON.stringify(obj,null,2)],{type:'application/json'}); const a=document.createElement('a'); a.href=URL.createObjectURL(blob); a.download=activeTab+'.json'; a.click(); URL.revokeObjectURL(a.href)}
function bindGlobalSearch(){const inp=document.getElementById('globalSearch'), pop=document.getElementById('searchPop'); inp.addEventListener('input',()=>{const q=inp.value.trim().toLowerCase(); if(!q){pop.classList.add('hidden');return;} const rows=[]; for(const tab of DATA.navigation.tabs){const sec=DATA.data[tab.id]; let items=[]; if(tab.id==='basic_knowledge'){items=[{id:'basic',title:'公务员基础知识',summary:textOf(sec),tab:tab.id}];} else items=sec.items||[]; items.forEach(it=>{if(textOf(it).toLowerCase().includes(q)){rows.push({tab:tab.id, kind:tab.label, item:it, title:itemTitle(it,tab.id), summary:itemSummary(it,tab.id)});}})} pop.innerHTML=rows.slice(0,30).map((r,i)=>`<div class="search-item" onclick="openSearchResult('${r.tab}','${esc(r.item.id||'basic')}')"><div class="search-kind">${esc(r.kind)}</div><div><strong>${esc(r.title)}</strong><small>${esc(r.summary).slice(0,120)}</small></div></div>`).join('') || '<div class="empty">无匹配结果</div>'; pop.classList.remove('hidden');});}
function itemTitle(it,tab){return it.policy_name||it.title||it.issue_name||it.event_title||'基础知识'} function itemSummary(it,tab){return it.summary||it.page_summary||it.core_finding||it.event_summary||textOf(it)}
function openSearchResult(tab,id){activeTab=tab; filterState={}; renderTabs(); renderPage(); document.getElementById('searchPop').classList.add('hidden'); if(tab==='policy_changes') openPolicy(id); else if(tab==='research_outputs') openResearch(id); else if(tab==='recent_issues') openIssue(id); else if(tab==='monthly_dynamics') openDynamic(id); else openBasicDetail('definition')}
async function tryExternal(){ if(!EXTERNAL_JSON_URL) return; try{const r=await fetch(EXTERNAL_JSON_URL); if(r.ok){DATA=await r.json();}}catch(e){console.warn('外部JSON读取失败,继续使用内置数据',e);} }
tryExternal().then(setup);
</script>
</body></html>
@@ -0,0 +1,326 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>台湾后备力量与全社会防卫韧性专题库 v2</title>
<style>
:root {
--bg0:#4a1013;
--bg1:#6b181c;
--bg2:#8a2428;
--card:#fffaf7;
--card2:#fff5ee;
--ink:#211617;
--muted:#6f5959;
--line:#ead5cc;
--line2:#d6b8ac;
--accent:#a83b3f;
--accent2:#f3d3c5;
--gold:#b88a45;
--green:#617a51;
--blue:#496f8f;
--shadow:0 18px 50px rgba(40,0,0,.22);
--radius:18px;
}
*{box-sizing:border-box}
html,body{margin:0;padding:0;font-family:"Microsoft YaHei","PingFang SC",Arial,sans-serif;color:var(--ink);background:linear-gradient(145deg,var(--bg0),var(--bg1) 45%,#3b0d10);}
body{min-height:100vh;}
.header{position:relative;overflow:hidden;padding:34px 34px 26px;color:#fff;border-bottom:1px solid rgba(255,255,255,.18);}
.header:before{content:"";position:absolute;inset:-80px -80px auto auto;width:300px;height:300px;border-radius:50%;background:rgba(255,255,255,.08)}
.header:after{content:"";position:absolute;left:35px;bottom:-65px;width:180px;height:180px;border:1px solid rgba(255,255,255,.18);border-radius:50%;}
.header-inner{position:relative;z-index:2;max-width:1440px;margin:0 auto;}
.kicker{display:inline-flex;align-items:center;gap:8px;border:1px solid rgba(255,255,255,.32);border-radius:999px;padding:7px 12px;font-size:12px;letter-spacing:.08em;background:rgba(255,255,255,.08)}
.kicker .dot{width:8px;height:8px;background:#f7d4c3;border-radius:50%;display:inline-block;}
h1{margin:18px 0 8px;font-size:34px;letter-spacing:.04em;line-height:1.25}
.subtitle{max-width:1060px;color:#f7ddd3;font-size:15px;line-height:1.75}
.boundary{margin-top:16px;display:flex;flex-wrap:wrap;gap:10px;}
.boundary span{background:rgba(255,255,255,.10);border:1px solid rgba(255,255,255,.18);padding:7px 10px;border-radius:12px;font-size:12px;color:#fff1eb}
.shell{max-width:1440px;margin:0 auto;padding:20px 24px 42px;}
.panel{background:rgba(255,250,247,.96);border:1px solid rgba(255,255,255,.45);box-shadow:var(--shadow);border-radius:24px;overflow:hidden;}
.controls{padding:18px;display:grid;grid-template-columns:2.1fr 1fr 1fr 1fr auto;gap:12px;border-bottom:1px solid var(--line);background:linear-gradient(180deg,#fffaf8,#fff3ec);}
.input,select{width:100%;border:1px solid var(--line2);border-radius:14px;background:#fff;color:var(--ink);font-size:14px;padding:12px 13px;outline:none;}
.input:focus,select:focus{border-color:var(--accent);box-shadow:0 0 0 3px rgba(168,59,63,.12)}
.btn{border:1px solid var(--accent);background:var(--accent);color:#fff;border-radius:14px;padding:11px 14px;font-weight:700;cursor:pointer;white-space:nowrap}
.btn.secondary{background:#fff;color:var(--accent)}
.btn:hover{filter:brightness(.98)}
.stats{display:grid;grid-template-columns:repeat(6,1fr);gap:10px;padding:14px 18px;border-bottom:1px solid var(--line);background:#fffaf7;}
.stat{border:1px solid var(--line);border-radius:16px;padding:12px;background:#fff;}
.stat b{display:block;font-size:22px;color:var(--accent);}
.stat span{font-size:12px;color:var(--muted)}
.tabs{display:flex;gap:8px;overflow-x:auto;padding:12px 14px;border-bottom:1px solid var(--line);background:#fbefe7;}
.tab{border:1px solid var(--line2);background:#fff;border-radius:999px;padding:10px 14px;cursor:pointer;white-space:nowrap;font-weight:700;color:#5a3d3e;}
.tab.active{background:var(--accent);color:#fff;border-color:var(--accent);box-shadow:0 8px 18px rgba(168,59,63,.22)}
.tab small{display:block;font-weight:500;opacity:.75;font-size:10px;margin-top:2px}
.tab-head{display:flex;justify-content:space-between;gap:16px;align-items:flex-start;padding:18px 18px 10px;}
.tab-title h2{margin:0;font-size:24px;color:#321b1d}
.tab-title p{margin:6px 0 0;color:var(--muted);line-height:1.7}
.tab-actions{display:flex;gap:8px;flex-wrap:wrap;justify-content:flex-end}
.toolbar{padding:10px 18px 16px;display:grid;grid-template-columns:2fr repeat(3,1fr);gap:10px;border-bottom:1px solid var(--line);}
.subfilters{display:flex;flex-wrap:wrap;gap:9px;padding:13px 18px;background:#fffaf7;border-bottom:1px solid var(--line);}
.chip{border:1px solid var(--line2);background:#fff;border-radius:999px;padding:8px 12px;cursor:pointer;font-size:13px;color:#5d4444;}
.chip.active{background:#3f2225;color:#fff;border-color:#3f2225}
.content{padding:18px;}
.flow{display:grid;grid-template-columns:repeat(4,1fr);gap:12px;margin-bottom:18px;}
.flow-node{background:linear-gradient(180deg,#fff,#fff5ee);border:1px solid var(--line);border-radius:18px;padding:14px;position:relative;min-height:92px;}
.flow-node:after{content:"→";position:absolute;right:-13px;top:34px;color:var(--accent);font-weight:900;}
.flow-node:last-child:after{content:""}
.flow-node b{display:block;color:var(--accent);font-size:15px;margin-bottom:6px}
.flow-node span{font-size:12px;color:var(--muted);line-height:1.5}
.cards{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:16px;}
.card{background:var(--card);border:1px solid var(--line);border-radius:var(--radius);padding:16px;box-shadow:0 8px 20px rgba(76,25,18,.06);display:flex;flex-direction:column;gap:12px;min-height:230px;}
.card:hover{border-color:#c69d8d;transform:translateY(-1px);transition:.15s ease}
.card h3{margin:0;font-size:18px;line-height:1.35;color:#2b1719}
.meta-row{display:flex;flex-wrap:wrap;gap:7px;}
.badge{font-size:12px;border:1px solid var(--line2);background:#fff;border-radius:999px;padding:5px 8px;color:#684d4d;}
.badge.red{background:#fff0ee;border-color:#e5b6ae;color:#913135}
.badge.green{background:#f1f6ed;border-color:#c6d5bf;color:#516e3d}
.badge.blue{background:#eff6fb;border-color:#bfd2df;color:#426886}
.summary{color:#604a4a;font-size:13px;line-height:1.68;display:-webkit-box;-webkit-line-clamp:4;-webkit-box-orient:vertical;overflow:hidden;}
.section-mini{background:#fff;border:1px solid var(--line);border-radius:14px;padding:10px;}
.section-mini b{font-size:12px;color:#8c3337;display:block;margin-bottom:6px}
.section-mini ul{margin:0;padding-left:18px;color:#5e4a4a;font-size:12px;line-height:1.55}
.card-footer{margin-top:auto;display:flex;justify-content:space-between;gap:10px;align-items:center}
.link-btn{border:0;background:transparent;color:var(--accent);font-weight:800;cursor:pointer;padding:6px 0}
.warning{font-size:12px;color:#805f40}
.table-wrap{overflow:auto;border:1px solid var(--line);border-radius:18px;background:#fff}
table{width:100%;border-collapse:collapse;font-size:13px;}
th{position:sticky;top:0;background:#f5e3d9;color:#4b292a;text-align:left;padding:12px;border-bottom:1px solid var(--line2);white-space:nowrap}
td{padding:11px 12px;border-bottom:1px solid var(--line);vertical-align:top}
tr:hover td{background:#fff8f4}
.timeline{position:relative;padding-left:26px;display:grid;gap:14px}
.timeline:before{content:"";position:absolute;left:9px;top:5px;bottom:5px;width:2px;background:#d9b8ac}
.titem{position:relative;background:#fff;border:1px solid var(--line);border-radius:18px;padding:15px;}
.titem:before{content:"";position:absolute;left:-24px;top:18px;width:12px;height:12px;background:var(--accent);border-radius:50%;border:3px solid #fff}
.empty{text-align:center;color:var(--muted);padding:48px;border:1px dashed var(--line2);border-radius:18px;background:#fffaf7}
.modal-mask{position:fixed;inset:0;background:rgba(32,10,10,.62);display:none;align-items:center;justify-content:center;z-index:20;padding:26px;}
.modal{width:min(1060px,96vw);max-height:90vh;overflow:auto;background:#fffaf7;border-radius:24px;box-shadow:0 30px 90px rgba(0,0,0,.45);border:1px solid #fff;}
.modal-head{position:sticky;top:0;background:linear-gradient(180deg,#fffaf7,#fff2ea);border-bottom:1px solid var(--line);padding:18px;z-index:2;display:flex;justify-content:space-between;gap:14px;align-items:flex-start}
.modal-head h2{margin:0;font-size:24px}
.close{border:1px solid var(--line2);background:#fff;border-radius:12px;font-size:20px;width:40px;height:40px;cursor:pointer}
.modal-body{padding:18px;display:grid;gap:14px}
.detail-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px}
.detail-block{background:#fff;border:1px solid var(--line);border-radius:16px;padding:14px}
.detail-block h4{margin:0 0 10px;color:#8b3035;font-size:15px}
.detail-block p{margin:0;color:#4f3b3b;line-height:1.75}
.detail-block ul{margin:0;padding-left:20px;color:#4f3b3b;line-height:1.65}
.source-list a{color:#8b3035;text-decoration:none;border-bottom:1px dotted #8b3035}
.source-item{padding:9px 0;border-bottom:1px solid var(--line)}
.source-item:last-child{border-bottom:0}
.notice{border:1px solid #e2bd9a;background:#fff6e8;color:#74512b;border-radius:14px;padding:12px;line-height:1.6}
@media(max-width:1100px){.controls{grid-template-columns:1fr 1fr}.toolbar{grid-template-columns:1fr 1fr}.cards{grid-template-columns:repeat(2,1fr)}.stats{grid-template-columns:repeat(3,1fr)}.flow{grid-template-columns:repeat(2,1fr)}.flow-node:after{display:none}}
@media(max-width:720px){.header{padding:26px 18px}h1{font-size:26px}.shell{padding:14px}.controls,.toolbar{grid-template-columns:1fr}.cards{grid-template-columns:1fr}.stats{grid-template-columns:repeat(2,1fr)}.detail-grid{grid-template-columns:1fr}.flow{grid-template-columns:1fr}}
</style>
</head>
<body>
<header class="header">
<div class="header-inner">
<div class="kicker"><span class="dot"></span>公开资料专题库 · v2 综合重构版</div>
<h1>台湾后备力量与全社会防卫韧性专题库</h1>
<div class="subtitle">本版根据最新栏目思路重新生成:不新增栏目12,而是将“军种官科、装备类别、晋任制度、教召权益、退辅福利、制度短板”全部补充进「军方后备体系」栏目,形成更完整的军方后备主体数据库。</div>
<div class="boundary"><span>仅展示公开资料</span><span>不展示营区坐标、集结点、动员路线</span><span>不展示具体装备数量与仓储位置</span><span>不展示个人资料</span></div>
</div>
</header>
<div class="shell">
<div class="panel">
<div class="controls">
<input id="globalSearch" class="input" placeholder="全局搜索:概念、组织、法规、教召、装备、福利、争议……" />
<select id="circleFilter"><option value="">全部圈层</option></select>
<select id="reliabilityFilter"><option value="">全部可信度</option></select>
<select id="displayFilter"><option value="">全部展示等级</option></select>
<button id="resetBtn" class="btn secondary">重置</button>
</div>
<div class="stats" id="stats"></div>
<nav class="tabs" id="tabs"></nav>
<div class="tab-head">
<div class="tab-title"><h2 id="tabTitle"></h2><p id="tabSubtitle"></p></div>
<div class="tab-actions"><button class="btn secondary" id="exportBtn">导出当前筛选 JSON</button><button class="btn" id="topBtn">回到顶部</button></div>
</div>
<div class="toolbar">
<input id="localSearch" class="input" placeholder="当前栏目内搜索……" />
<select id="typeFilter"><option value="">全部类型</option></select>
<select id="agencyFilter"><option value="">全部机关/机构</option></select>
<select id="yearFilter"><option value="">全部年份</option></select>
</div>
<div class="subfilters" id="subfilters"></div>
<main class="content" id="content"></main>
</div>
</div>
<div class="modal-mask" id="modalMask">
<div class="modal">
<div class="modal-head"><div><h2 id="modalTitle"></h2><div class="meta-row" id="modalBadges"></div></div><button class="close" id="closeModal">×</button></div>
<div class="modal-body" id="modalBody"></div>
</div>
</div>
<script>
const DATA = {"meta": {"title": "台湾后备力量与全社会防卫韧性专题库", "version": "v2.0-integrated", "language": "zh-CN", "lastUpdated": "2026-06-24", "description": "围绕台湾后备军人、后备部队、教召制度、全民防卫动员、民防体系、社会韧性、地方执行、研究成果、舆情争议、装备类别、军种官科、晋任制度与退辅福利建立的公开资料专题库。", "displayBoundary": ["仅展示公开资料、制度信息、政策分析、社会影响与来源审查。", "不展示具体营区坐标、集结地点、动员路线、个人名单、具体战术部署、关键设施脆弱点。", "社交平台资料仅作为舆情样本,不代表整体民意。"]}, "tabs": [{"id": "conceptDefinitions", "name": "概念界定", "subtitle": "官方定义、研究边界与三圈模型", "viewType": "cards", "filters": ["概念层级", "对应圈层", "是否官方定义", "可信度"]}, {"id": "militaryReserveSystem", "name": "军方后备体系", "subtitle": "组织体系、后备人员、军种官科、装备类别、晋任制度、权益福利、退辅制度与制度短板", "viewType": "military-integrated", "filters": ["二级分类", "对象类型", "层级", "可信度", "展示等级"], "searchFields": ["名称", "类别", "摘要", "标签", "对象类型", "二级分类", "军种", "官科兵科", "公开数据", "制度短板", "社会争议"]}, {"id": "trainingAndMobilization", "name": "教召训练", "subtitle": "教召制度、14天新制、训练流程、权益与争议", "viewType": "timeline-cards", "filters": ["制度类型", "适用对象", "年份", "争议等级", "可信度"]}, {"id": "policyEvolution", "name": "政策沿革", "subtitle": "后备改革时间线、改革逻辑与政策关键词", "viewType": "timeline", "filters": ["年份", "政策类型", "政策主体", "核心关键词", "可信度"]}, {"id": "legalFramework", "name": "法规图谱", "subtitle": "兵役、后备管理、召集、动员、民防、灾防法规", "viewType": "law-table", "filters": ["法规类型", "主管机关", "对应圈层", "涉及地方政府", "可信度"]}, {"id": "civilDefensePublicSafety", "name": "民防公共安全", "subtitle": "民防、警消、替代役、医疗、工程与公用事业支援", "viewType": "matrix-cards", "filters": ["对象类型", "主管机关", "功能类型", "对应圈层", "可信度"]}, {"id": "societalResilienceNetwork", "name": "社会韧性网络", "subtitle": "民间组织、社区、学校、企业、宗教慈善、媒体与资安网络", "viewType": "network-cards", "filters": ["组织类型", "是否官方组织", "政治标签", "可信度"]}, {"id": "localImplementation", "name": "地方执行", "subtitle": "县市差异、地方动员、民防、灾防与城镇韧性演习", "viewType": "region-cards", "filters": ["区域类型", "县市", "演习年份", "主要议题", "可信度"]}, {"id": "researchExpertViews", "name": "研究成果", "subtitle": "官方报告、智库研究、学术论文、媒体深度报道与国际比较", "viewType": "research-list", "filters": ["资料类型", "机构", "年份", "主题", "可信度"]}, {"id": "publicOpinionControversies", "name": "舆情争议", "subtitle": "教召、女性后备、民防政治化、企业成本、社交平台反应", "viewType": "issue-cards", "filters": ["议题类型", "平台", "情绪标签", "影响人群", "可信度"]}, {"id": "sourceAudit", "name": "来源审查", "subtitle": "来源分级、可信度、交叉验证与展示限制", "viewType": "source-table", "filters": ["来源类型", "可信度评级", "是否原始来源", "是否交叉验证", "可公开展示等级"]}], "sharedDictionaries": {"circles": ["第一圈:军方后备力量", "第二圈:守备民防与公共安全支援", "第三圈:社会韧性网络", "跨圈"], "reliabilityLevels": ["高", "中高", "中", "中低", "低"], "sourceRatings": ["A1", "A2", "B1", "B2", "C1", "C2", "D"], "displayLevels": ["可公开", "摘要展示", "不展示细节"], "controversyTypes": ["制度公平", "民生成本", "训练成效", "政治标签", "社会军事化", "女性后备", "企业负担", "地方执行", "假讯息"], "militarySubcategories": ["全部", "组织体系", "后备人员", "军种官科", "装备类别", "晋任制度", "权益福利", "退辅制度", "制度短板"]}, "conceptDefinitions": [{"ID": "concept_001", "名称": "后备军人", "类别": "官方概念", "摘要": "依法转入后备役并接受后备管理的人员,是后备力量专题的狭义核心。", "标签": ["兵役", "后备役", "后备管理"], "对应圈层": "第一圈:军方后备力量", "可信度": "高", "展示等级": "可公开", "资料来源": [{"标题": "《兵役法》", "类型": "法规", "URL": "https://law.moj.gov.tw/LawClass/LawAll.aspx?PCode=F0040001", "可信度": "A1"}, {"标题": "《后备军人管理规则》", "类型": "法规", "URL": "httpLine truncated
const TAB_MAP = {
conceptDefinitions:'conceptDefinitions', militaryReserveSystem:'militaryReserveSystem', trainingAndMobilization:'trainingAndMobilization',
policyEvolution:'policyEvolution', legalFramework:'legalFramework', civilDefensePublicSafety:'civilDefensePublicSafety', societalResilienceNetwork:'societalResilienceNetwork',
localImplementation:'localImplementation', researchExpertViews:'researchExpertViews', publicOpinionControversies:'publicOpinionControversies', sourceAudit:'sourceAudit'
};
const state = { tab:'conceptDefinitions', global:'', local:'', circle:'', reliability:'', display:'', type:'', agency:'', year:'', sub:'全部' };
const militarySubs = ['全部','组织体系','后备人员','军种官科','装备类别','晋任制度','权益福利','退辅制度','制度短板'];
function $(id){return document.getElementById(id)}
function arr(v){ if(!v) return []; if(Array.isArray(v)) return v; if(typeof v==='object') return Object.values(v).flatMap(arr); return [String(v)]; }
function txt(v){ return arr(v).join(' '); }
function titleOf(item){ return item['名称']||item['标题']||item['法规名称']||item['组织名称']||item['县市']||item['议题名称']||item['资料标题']||item['sourceTitle']||item.name||item.title||'未命名条目'; }
function summaryOf(item){ return item['摘要']||item['定义或说明']||item['核心目的']||item['触发原因']||item['核心观点']?.[0]||item['description']||item['研究价值']||item['制度短板']?.[0]||'点击查看详情。'; }
function categoryOf(item){ return item['二级分类']||item['对象类型']||item['类别']||item['资料类型']||item['事件类型']||item['组织类型']||item['法规类型']||item['区域类型']||''; }
function circleOf(item){ return item['对应圈层']||item['相关圈层']||item['relatedCircle']||item['circle']||''; }
function reliabilityOf(item){ return item['可信度']||item['可信度评级']||item['credibilityRating']||item['reliability']||''; }
function displayOf(item){ return item['展示等级']||item['可公开展示等级']||item['displayLevel']||''; }
function yearOf(item){ const s=txt([item['发布时间'],item['发表时间'],item['时间'],item['年份'],item['事件时间'],item['更新时间'],item['publishYear'],item['year']]); const m=s.match(/20\d{2}|19\d{2}|11[0-9]年|民国\d+年/); return m?m[0]:''; }
function agencyOf(item){ return txt(item['主管机关']||item['competentAgency']||item['主管或关联机关']||item['机构']||item['institution']||item['执行机关']||item['权责机关']).split(' / ')[0]; }
function itemText(item){ return JSON.stringify(item).toLowerCase(); }
function isMilitaryOrg(item){ const t=item['对象类型']||item['类别']||''; return ['中央主管机关','动员整合机关','业务处室','后备监督机关','地区管理机关','地方执行机关','后备部队','训练支撑单位','服务协力组织'].some(x=>t.includes(x)); }
function militarySubOf(item){ if(item['二级分类']) return item['二级分类']; if(isMilitaryOrg(item)) return '组织体系'; return '组织体系'; }
function tabConfig(){ return DATA.tabs.find(t=>t.id===state.tab)||DATA.tabs[0]; }
function currentData(){ return DATA[TAB_MAP[state.tab]]||[]; }
function initSelects(){
const circles = new Set(), rels = new Set(), displays = new Set();
Object.keys(TAB_MAP).forEach(k=> (DATA[TAB_MAP[k]]||[]).forEach(item=>{ if(circleOf(item)) circles.add(circleOf(item)); if(reliabilityOf(item)) rels.add(reliabilityOf(item)); if(displayOf(item)) displays.add(displayOf(item)); }));
fillSelect('circleFilter', circles); fillSelect('reliabilityFilter', rels); fillSelect('displayFilter', displays);
}
function fillSelect(id, values){ const el=$(id); const first=el.options[0].outerHTML; el.innerHTML=first; [...values].filter(Boolean).sort().forEach(v=>{ const o=document.createElement('option'); o.value=v; o.textContent=v; el.appendChild(o); }); }
function renderStats(){
const total = Object.keys(TAB_MAP).reduce((s,k)=>s+(DATA[TAB_MAP[k]]||[]).length,0);
const mil = (DATA.militaryReserveSystem||[]).length;
const laws = (DATA.legalFramework||[]).length;
const local = (DATA.localImplementation||[]).length;
const issues = (DATA.publicOpinionControversies||[]).length;
const sources = (DATA.sourceAudit||[]).length;
$('stats').innerHTML = [ ['总条目',total], ['军方后备体系',mil], ['法规资料',laws], ['县市数据',local], ['争议议题',issues], ['来源审查',sources] ].map(x=>`<div class="stat"><b>${x[1]}</b><span>${x[0]}</span></div>`).join('');
}
function renderTabs(){
$('tabs').innerHTML = DATA.tabs.map(t=>`<button class="tab ${t.id===state.tab?'active':''}" data-tab="${t.id}">${t.name}<small>${(DATA[TAB_MAP[t.id]]||[]).length}条</small></button>`).join('');
document.querySelectorAll('.tab').forEach(b=>b.onclick=()=>{state.tab=b.dataset.tab; state.local=''; state.type=''; state.agency=''; state.year=''; state.sub='全部'; $('localSearch').value=''; render();})
}
function refreshDynamicFilters(data){
const types=new Set(), agencies=new Set(), years=new Set();
data.forEach(item=>{ const c=categoryOf(item); if(c) types.add(Array.isArray(c)?c.join('、'):c); const a=agencyOf(item); if(a) agencies.add(a); const y=yearOf(item); if(y) years.add(y); });
fillSelect('typeFilter', types); fillSelect('agencyFilter', agencies); fillSelect('yearFilter', years);
$('typeFilter').value=state.type; $('agencyFilter').value=state.agency; $('yearFilter').value=state.year;
}
function filteredData(){
const g=state.global.trim().toLowerCase(), l=state.local.trim().toLowerCase();
return currentData().filter(item=>{
const hay=itemText(item);
if(g && !hay.includes(g)) return false;
if(l && !hay.includes(l)) return false;
if(state.circle && !txt(circleOf(item)).includes(state.circle)) return false;
if(state.reliability && reliabilityOf(item)!==state.reliability) return false;
if(state.display && displayOf(item)!==state.display) return false;
if(state.type && !txt(categoryOf(item)).includes(state.type)) return false;
if(state.agency && !txt(agencyOf(item)).includes(state.agency)) return false;
if(state.year && yearOf(item)!==state.year) return false;
if(state.tab==='militaryReserveSystem' && state.sub && state.sub!=='全部' && militarySubOf(item)!==state.sub) return false;
return true;
});
}
function renderSubfilters(){
const box=$('subfilters');
if(state.tab==='militaryReserveSystem'){
const all=DATA.militaryReserveSystem||[];
box.innerHTML=militarySubs.map(s=>{ const count=s==='全部'?all.length:all.filter(i=>militarySubOf(i)===s).length; return `<button class="chip ${state.sub===s?'active':''}" data-sub="${s}">${s} · ${count}</button>`; }).join('');
box.querySelectorAll('.chip').forEach(ch=>ch.onclick=()=>{state.sub=ch.dataset.sub; renderContent(); renderSubfilters();});
} else { box.innerHTML='<span class="badge red">当前栏目支持全局搜索、栏目搜索、类型/机关/年份筛选,点击卡片可打开详情。</span>'; }
}
function renderFlow(){
if(state.tab!=='militaryReserveSystem') return '';
const nodes=[['谁在管','国防部、全动署、后备指挥部、地区/县市后备系统'],['管哪些人','后备军官、后备士官、后备士兵、教召人员'],['用什么支撑','军种官科、公开装备类别、晋任制度、权益福利'],['看哪些风险','装备整备、干部储备、专长相符、企业家庭成本']];
return `<div class="flow">${nodes.map(n=>`<div class="flow-node"><b>${n[0]}</b><span>${n[1]}</span></div>`).join('')}</div>`;
}
function renderContent(){
const data=filteredData();
$('content').innerHTML = renderFlow() + (data.length? renderByView(data): `<div class="empty">没有找到匹配条目。请调整搜索词或筛选条件。</div>`);
}
function renderByView(data){
const cfg=tabConfig();
if(['law-table','source-table'].includes(cfg.viewType)) return renderTable(data);
if(cfg.viewType==='timeline') return renderTimeline(data);
return `<div class="cards">${data.map(renderCard).join('')}</div>`;
}
function renderCard(item, idx){
const id = encodeURIComponent(item.ID||item.id||titleOf(item));
const tags=arr(item['标签']||item.tags).slice(0,5);
const rel=reliabilityOf(item), disp=displayOf(item), cat=categoryOf(item), cir=circleOf(item);
const minis=[];
const fields=['主要功能','公开数据','训练模块','法律依据','制度短板','社会争议','研究问题','涉及权益'];
fields.forEach(f=>{ if(item[f]) minis.push(`<div class="section-mini"><b>${f}</b><ul>${arr(item[f]).slice(0,3).map(v=>`<li>${escapeHTML(v)}</li>`).join('')}</ul></div>`); });
return `<article class="card">
<div><h3>${escapeHTML(titleOf(item))}</h3><div class="meta-row">${cat?`<span class="badge red">${escapeHTML(cat)}</span>`:''}${cir?`<span class="badge blue">${escapeHTML(cir)}</span>`:''}${rel?`<span class="badge green">可信度:${escapeHTML(rel)}</span>`:''}</div></div>
<div class="meta-row">${tags.map(t=>`<span class="badge">${escapeHTML(t)}</span>`).join('')}</div>
<div class="summary">${escapeHTML(txt(summaryOf(item)))}</div>
${minis.slice(0,2).join('')}
<div class="card-footer"><span class="warning">${disp?escapeHTML(disp):'可公开'}</span><button class="link-btn" onclick="openModalByKey('${id}')">查看详情 →</button></div>
</article>`;
}
function renderTable(data){
const rows=data.map(item=>`<tr onclick="openModalByKey('${encodeURIComponent(item.ID||item.id||titleOf(item))}')"><td><b>${escapeHTML(titleOf(item))}</b></td><td>${escapeHTML(categoryOf(item))}</td><td>${escapeHTML(agencyOf(item))}</td><td>${escapeHTML(reliabilityOf(item))}</td><td>${escapeHTML(txt(summaryOf(item)).slice(0,160))}</td></tr>`).join('');
return `<div class="table-wrap"><table><thead><tr><th>名称</th><th>类型</th><th>机关/机构</th><th>可信度</th><th>摘要</th></tr></thead><tbody>${rows}</tbody></table></div>`;
}
function renderTimeline(data){
return `<div class="timeline">${data.map(item=>`<div class="titem"><div class="meta-row"><span class="badge red">${escapeHTML(yearOf(item)||'时间未标注')}</span><span class="badge">${escapeHTML(categoryOf(item))}</span></div><h3>${escapeHTML(titleOf(item))}</h3><p class="summary">${escapeHTML(txt(summaryOf(item)))}</p><button class="link-btn" onclick="openModalByKey('${encodeURIComponent(item.ID||item.id||titleOf(item))}')">查看详情 →</button></div>`).join('')}</div>`;
}
function openModalByKey(key){
const decoded=decodeURIComponent(key);
let found=null;
Object.keys(TAB_MAP).some(k=> (DATA[TAB_MAP[k]]||[]).some(item=>{ const itemKey=item.ID||item.id||titleOf(item); if(itemKey===decoded){found=item; return true} return false; }));
if(found) openModal(found);
}
function openModal(item){
$('modalTitle').textContent=titleOf(item);
const badges=[categoryOf(item), circleOf(item), reliabilityOf(item)?'可信度:'+reliabilityOf(item):'', displayOf(item)].filter(Boolean);
$('modalBadges').innerHTML=badges.map((b,i)=>`<span class="badge ${i===0?'red':i===1?'blue':'green'}">${escapeHTML(b)}</span>`).join('');
$('modalBody').innerHTML=renderDetail(item);
$('modalMask').style.display='flex';
}
function renderDetail(item){
const priority=['摘要','定义或说明','官方定义','核心目的','触发原因','主管机关','上级单位','下级单位','层级','对象类型','二级分类','适用对象','军种','官科兵科','专业方向','装备类别','取得方式','训练关联','主要功能','公开数据','法律依据','关联制度','评选条件','作业流程','权责机关','涉及权益','福利边界','社会争议','制度短板','治理风险','研究问题','展示限制'];
const blocks=[];
priority.forEach(k=>{ if(item[k]) blocks.push(detailBlock(k,item[k])); });
const rest=Object.keys(item).filter(k=>!priority.includes(k)&&!['ID','id','名称','标题','标签','资料来源','publicSources','更新时间','可信度','展示等级','对应圈层','类别'].includes(k)&&item[k]);
rest.slice(0,8).forEach(k=>blocks.push(detailBlock(k,item[k])));
const sources=item['资料来源']||item.publicSources||item['来源']||[];
if(sources.length) blocks.push(sourceBlock(sources));
blocks.push(`<div class="notice"><b>展示边界:</b>本页面仅展示公开制度、公开类别和政策研究信息;涉及具体营区、集结点、动员路线、个人资料、具体装备数量、仓储位置、单位配属和可操作性脆弱信息的内容不予展示。</div>`);
return `<div class="detail-grid">${blocks.join('')}</div>`;
}
function detailBlock(k,v){
let content='';
if(Array.isArray(v)) content=`<ul>${v.map(x=>`<li>${escapeHTML(txt(x))}</li>`).join('')}</ul>`;
else if(typeof v==='object') content=objectToHTML(v);
else content=`<p>${escapeHTML(String(v))}</p>`;
return `<section class="detail-block"><h4>${escapeHTML(k)}</h4>${content}</section>`;
}
function objectToHTML(obj){
return `<ul>${Object.entries(obj).map(([k,v])=>`<li><b>${escapeHTML(k)}:</b>${Array.isArray(v)?v.map(x=>escapeHTML(txt(x))).join('、'):escapeHTML(txt(v))}</li>`).join('')}</ul>`;
}
function sourceBlock(sources){
const html=sources.map(s=>{ if(typeof s==='string') return `<div class="source-item">${escapeHTML(s)}</div>`; const url=s.URL||s.url||''; const name=s.标题||s.title||s.sourceTitle||s.来源名称||'来源'; return `<div class="source-item"><b>${escapeHTML(name)}</b> <span class="badge">${escapeHTML(s.类型||s.type||'')}</span> <span class="badge green">${escapeHTML(s.可信度||s.rating||'')}</span>${url?`<br><a href="${escapeAttr(url)}" target="_blank" rel="noopener">打开来源</a>`:''}</div>`; }).join('');
return `<section class="detail-block source-list" style="grid-column:1/-1"><h4>资料来源</h4>${html}</section>`;
}
function escapeHTML(s){ return String(s??'').replace(/[&<>"]/g, c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c])); }
function escapeAttr(s){ return escapeHTML(s).replace(/'/g,'&#39;'); }
function render(){
renderTabs();
const cfg=tabConfig(); $('tabTitle').textContent=cfg.name; $('tabSubtitle').textContent=cfg.subtitle||'';
refreshDynamicFilters(currentData()); renderSubfilters(); renderContent();
}
['globalSearch','localSearch'].forEach(id=>$(id).addEventListener('input',e=>{ if(id==='globalSearch') state.global=e.target.value; else state.local=e.target.value; renderContent(); }));
$('circleFilter').onchange=e=>{state.circle=e.target.value; renderContent();};
$('reliabilityFilter').onchange=e=>{state.reliability=e.target.value; renderContent();};
$('displayFilter').onchange=e=>{state.display=e.target.value; renderContent();};
$('typeFilter').onchange=e=>{state.type=e.target.value; renderContent();};
$('agencyFilter').onchange=e=>{state.agency=e.target.value; renderContent();};
$('yearFilter').onchange=e=>{state.year=e.target.value; renderContent();};
$('resetBtn').onclick=()=>{Object.assign(state,{global:'',local:'',circle:'',reliability:'',display:'',type:'',agency:'',year:'',sub:'全部'}); ['globalSearch','localSearch','circleFilter','reliabilityFilter','displayFilter','typeFilter','agencyFilter','yearFilter'].forEach(id=>$(id).value=''); render();};
$('closeModal').onclick=()=>$('modalMask').style.display='none';
$('modalMask').onclick=e=>{ if(e.target.id==='modalMask') $('modalMask').style.display='none'; };
$('topBtn').onclick=()=>window.scrollTo({top:0,behavior:'smooth'});
$('exportBtn').onclick=()=>{ const blob=new Blob([JSON.stringify(filteredData(),null,2)],{type:'application/json'}); const a=document.createElement('a'); a.href=URL.createObjectURL(blob); a.download=`${state.tab}_filtered.json`; a.click(); URL.revokeObjectURL(a.href); };
initSelects(); renderStats(); render();
</script>
</body>
</html>
@@ -0,0 +1,222 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>台湾民防与全社会防卫韧性知识展示平台 v3 横向Tab版</title>
<style>
:root{
--red-950:#4f0b16;--red-900:#68111f;--red-800:#7f1829;--red-700:#a11f35;--red-600:#bb3148;--red-100:#fff0f2;--red-50:#fff7f8;
--ink:#1f2937;--muted:#667085;--line:#e5e7eb;--soft:#f7f8fb;--card:#fff;--green:#166534;--orange:#9a3412;--blue:#1d4ed8;
--shadow:0 18px 45px rgba(38, 23, 31, .18);--radius:22px;
}
*{box-sizing:border-box} html{scroll-behavior:smooth}
body{margin:0;font-family:"Microsoft YaHei","PingFang SC","Noto Sans CJK SC",Arial,sans-serif;background:radial-gradient(circle at 10% 0%, #d66275 0, transparent 30%),linear-gradient(135deg,var(--red-950),var(--red-700));color:var(--ink);min-height:100vh;}
.page{max-width:1500px;margin:0 auto;padding:26px 22px 48px}.hero{color:#fff;border-radius:30px;border:1px solid rgba(255,255,255,.24);background:linear-gradient(135deg,rgba(255,255,255,.15),rgba(255,255,255,.06));box-shadow:var(--shadow);padding:24px 26px}.hero-top{display:flex;justify-content:space-between;gap:20px;align-items:flex-start}.hero h1{margin:0 0 10px;font-size:31px;letter-spacing:.4px}.hero p{margin:0;line-height:1.72;color:rgba(255,255,255,.92);max-width:1000px}.badge-line{display:flex;flex-wrap:wrap;gap:8px;margin-top:14px}.badge{display:inline-flex;align-items:center;gap:6px;padding:6px 10px;border-radius:999px;font-size:12px;font-weight:700;background:rgba(255,255,255,.16);border:1px solid rgba(255,255,255,.22);color:#fff}.safe{margin-top:14px;padding:12px 14px;border-radius:16px;background:rgba(255,255,255,.13);font-size:13px;line-height:1.65;color:#fff}.layout{margin-top:18px;display:grid;grid-template-columns:1fr;gap:18px}.sidebar{position:relative;top:0;align-self:stretch;background:#fff;border:1px solid rgba(255,255,255,.35);border-radius:var(--radius);box-shadow:var(--shadow);padding:14px 16px}.nav-title{font-size:13px;color:var(--muted);font-weight:900;margin:2px 4px 10px;letter-spacing:.08em}#tabs{display:flex;flex-wrap:wrap;gap:10px;align-items:center}
.tab{width:auto;min-width:138px;border:1px solid #f0d7dc;background:#fff;color:var(--ink);border-radius:999px;padding:11px 14px;margin:0;cursor:pointer;text-align:center;font-weight:900;display:flex;align-items:center;justify-content:center;gap:8px;white-space:nowrap;box-shadow:0 4px 12px rgba(15,23,42,.035)}.tab:hover{background:var(--red-50);border-color:#ffd8df;transform:translateY(-1px)}.tab.active{background:var(--red-700);color:#fff;border-color:var(--red-700);box-shadow:0 8px 18px rgba(161,31,53,.22)}.tab-count{font-size:11px;border-radius:999px;padding:2px 8px;background:#f3f4f6;color:#374151}.tab.active .tab-count{background:rgba(255,255,255,.2);color:#fff}.main{min-width:0}.panel{background:#fff;border-radius:var(--radius);box-shadow:var(--shadow);overflow:hidden;border:1px solid rgba(255,255,255,.45)}.toolbar{display:grid;grid-template-columns:minmax(250px,1fr) 180px 180px 160px 140px;gap:11px;padding:16px;border-bottom:1px solid var(--line);background:#fff}.input,.select,.viewbtn{width:100%;border:1px solid var(--line);border-radius:14px;padding:11px 12px;font-size:14px;outline:none;background:#fff}.viewbtn{font-weight:800;cursor:pointer;color:var(--red-700)}.input:focus,.select:focus{border-color:var(--red-600);box-shadow:0 0 0 3px rgba(187,49,72,.12)}.content{padding:20px;background:#f7f8fa;min-height:670px}.section-title{display:flex;justify-content:space-between;align-items:flex-start;gap:20px;margin-bottom:16px}.section-title h2{margin:0 0 8px;font-size:23px;color:#111827}.section-title .desc{max-width:760px;text-align:right;color:var(--muted);font-size:13px;line-height:1.65}.count{font-size:13px;color:var(--muted)}.metrics{display:grid;grid-template-columns:repeat(6,1fr);gap:13px;margin-bottom:16px}.metric{background:#fff;border:1px solid var(--line);border-radius:18px;padding:16px;box-shadow:0 8px 20px rgba(15,23,42,.04)}.metric .label{font-size:13px;color:var(--muted);margin-bottom:8px}.metric .value{font-size:31px;font-weight:900;color:var(--red-700)}.metric .sub{font-size:12px;color:#697386;line-height:1.48;margin-top:8px}.grid-2{display:grid;grid-template-columns:1fr 1fr;gap:14px}.box{background:#fff;border:1px solid var(--line);border-radius:18px;padding:16px}.box h3{margin:0 0 12px;font-size:16px}.bar-row{display:grid;grid-template-columns:175px 1fr 44px;gap:9px;align-items:center;margin:9px 0;font-size:13px}.bar{height:10px;border-radius:999px;background:#f1f5f9;overflow:hidden}.bar span{display:block;height:100%;border-radius:999px;background:linear-gradient(90deg,var(--red-700),#e96f82)}.cards{display:grid;grid-template-columns:repeat(3,1fr);gap:14px}.card{position:relative;background:#fff;border:1px solid var(--line);border-radius:20px;padding:16px;cursor:pointer;transition:.18s;min-height:230px;display:flex;flex-direction:column;overflow:hidden}.card:before{content:"";position:absolute;left:0;top:0;bottom:0;width:5px;background:var(--red-700);opacity:.88}.card:hover{transform:translateY(-2px);box-shadow:0 14px 30px rgba(15,23,42,.11);border-color:#e8b7c0}.card h3{margin:0 0 8px;padding-left:2px;font-size:17px;line-height:1.35;color:#111827}.card .subtitle{font-size:13px;color:#4b5563;line-height:1.55;display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden}.mini-fields{margin:12px 0 8px;border-top:1px dashed #edf0f3;padding-top:10px}.mini{display:grid;grid-template-columns:72px 1fr;gap:8px;font-size:12.5px;line-height:1.45;margin:5px 0}.mini .mk{color:#7a8291;font-weight:800}.mini .mv{color:#374151;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.chips{display:flex;flex-wrap:wrap;gap:6px;margin-top:auto;padding-top:12px}.chip{font-size:12px;border:1px solid #ead1d6;background:var(--red-50);color:var(--red-800);border-radius:999px;padding:4px 8px;max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.chip.gray{border-color:#e5e7eb;background:#f9fafb;color:#4b5563}.chip.high{border-color:#fecaca;background:#fef2f2;color:#991b1b}.chip.mid{border-color:#fed7aa;background:#fff7ed;color:#9a3412}.chip.ok{border-color:#bbf7d0;background:#f0fdf4;color:#166534}.table-wrap{overflow:auto;border-radius:18px;border:1px solid var(--line);background:#fff}table{width:100%;border-collapse:collapse;min-width:880px}th,td{padding:12px;border-bottom:1px solid var(--line);font-size:13px;text-align:left;vertical-align:top}th{background:#fbfbfc;color:#374151;font-weight:900;position:sticky;top:0}tr:last-child td{border-bottom:0}tr:hover td{background:#fff8f9}.empty{padding:44px;text-align:center;color:var(--muted);background:#fff;border:1px dashed #d1d5db;border-radius:18px}.modal-backdrop{position:fixed;inset:0;background:rgba(15,23,42,.60);display:none;align-items:center;justify-content:center;padding:24px;z-index:60}.modal-backdrop.show{display:flex}.modal{width:min(1160px,96vw);max-height:91vh;overflow:auto;background:#fff;border-radius:24px;box-shadow:0 30px 80px rgba(0,0,0,.36)}.modal-header{position:sticky;top:0Line truncated
@media(max-width:1180px){.toolbar{grid-template-columns:1fr 1fr}.metrics{grid-template-columns:repeat(3,1fr)}.cards{grid-template-columns:repeat(2,1fr)}}
@media(max-width:760px){.page{padding:14px}.hero-top{display:block}.hero h1{font-size:23px}.toolbar{grid-template-columns:1fr}.metrics{grid-template-columns:1fr}.cards{grid-template-columns:1fr}.grid-2,.kv-grid,.mini-card-grid,.columns{grid-template-columns:1fr}.section-title{display:block}.section-title .desc{text-align:left;margin-top:8px}.bar-row{grid-template-columns:1fr}.bar-row .bar{height:10px}.modal-backdrop{padding:10px}.modal-header{padding:15px}.modal-body{padding:14px}}
</style>
</head>
<body>
<div class="page">
<header class="hero">
<div class="hero-top">
<div>
<h1 id="appTitle">台湾民防与全社会防卫韧性知识展示平台</h1>
<p id="appDesc"></p>
</div>
</div>
<div class="badge-line" id="heroBadges"></div>
<div class="safe" id="safeNotice"></div>
</header>
<div class="layout">
<aside class="sidebar">
<div class="nav-title">横向栏目导航</div>
<nav id="tabs"></nav>
<div class="view-note">提示:点击任意卡片可进入结构化详情页,查看中文字段、标签、时间线、观点对照、风险边界和来源列表。</div>
</aside>
<main class="main panel">
<div class="toolbar">
<input id="searchInput" class="input" type="search" placeholder="输入关键词:组织、政策、争议、风险、来源……" />
<select id="categoryFilter" class="select"><option value="">全部分类</option></select>
<select id="levelFilter" class="select"><option value="">全部等级/层级</option></select>
<select id="evidenceFilter" class="select"><option value="">全部证据强度</option></select>
<button id="viewToggle" class="viewbtn" type="button">卡片视图</button>
</div>
<section class="content" id="content"></section>
</main>
</div>
<div class="footer-note">安全边界:仅展示公开资料研究、政策法源、组织画像、舆情争议和风险评估;不展示训练动作、敏感设施点位、私人信息、攻击破坏方法、定向影响或骚扰策略。</div>
</div>
<div class="modal-backdrop" id="modalBackdrop">
<article class="modal">
<div class="modal-header">
<div>
<h2 id="modalTitle"></h2>
<p id="modalSub"></p>
<div class="chips" id="modalChips"></div>
</div>
<button class="close" id="modalClose" aria-label="关闭">×</button>
</div>
<div class="modal-body" id="modalBody"></div>
</article>
</div>
<script id="PAGE_DATA" type="application/json">{"meta": {"title": "台湾民防与全社会防卫韧性知识展示平台", "version": "v2.0-rich-display", "generated_at": "2026-06-24", "language": "zh-CN", "description": "增强版页面展示 JSON:中文字段名、分区详情弹框、标签化摘要、时间线、观点对照、传播链和风险卡片。", "safe_display_notice": "本页面用于公开资料研究、政策展示与风险评估,不展示训练操作、敏感设施、个人隐私、定向影响或可用于攻击破坏的信息。", "data_boundary": ["仅展示公开资料研究、政策法源、组织画像、舆情争议和风险评估。", "不展示训练操作、敏感设施点位、个人隐私、攻击破坏方法、定向影响或骚扰策略。"]}, "overview": {"dataset_name": "taiwan_civil_defense_page_overview_metrics", "created_at": "2026-06-24", "last_verified_hint": "部分公共资料核验至2026-06-24;页面上线前应再次核验官方来源。", "core_metrics": {"organization_system_records": 35, "civil_group_profiles": 14, "policy_cases": 12, "controversy_cases": 16, "risk_assessment_items": 18, "source_catalog_items": 16}, "category_distribution": {"organizations_by_category": {"关键基础设施防护编组": 9, "民间训练组织": 8, "地方政府动员体系": 7, "法定民防团队": 6, "企业学校防护编组": 5}, "civil_groups_by_category": {"民防教育训练型": 1, "社会韧性与应急训练型": 1, "地方自训网络枢纽型": 1, "社区韧性与宗教网络型": 1, "去中心化地方自训型": 1, "地方自训组织案例": 1, "灾难医疗与救援型": 1, "人道救援与急救培训型": 1, "宗教慈善与灾害支援型": 1, "宗教社区网络型": 1, "资讯与国际传播韧性型": 1, "资讯韧性与民主研究型": 1, "资安与关键网络韧性型": 1, "全民国防教育型": 1}, "risks_by_level": {"高": 6, "中高": 10, "中": 2}, "policies_by_type": {"法律": 2, "施行细则": 1, "行政规则/办法": 1, "总统府任务编组/政策平台": 1, "演习机制": 1, "公众安全指引": 1, "教育/动员准备机制": 1, "教育法制": 1, "灾害治理法制": 1, "资安/关键系统治理": 1, "地方公众指引/手册体系": 1}}, "top_display_cards": [{"card_title": "组织体系", "value": 35, "subtitle": "法定民防、地方动员、关键设施、企业学校、民间训练五层结构"}, {"card_title": "政策法源", "value": 12, "subtitle": "法律、办法、委员会、演习、安全指引与地方手册"}, {"card_title": "民间组织", "value": 14, "subtitle": "教育训练、社会韧性、人道救援、资讯韧性等组织画像"}, {"card_title": "舆情争议", "value": 16, "subtitle": "学生服勤、军事化争议、资金争议、标签化、人道中立等"}, {"card_title": "风险评估", "value": 18, "subtitle": "国际人道法、隐私、社会心理、关键设施和治理风险"}, {"card_title": "资料来源", "value": 16, "subtitle": "官方法源、总统府资料、国防部资料、教育部澄清、国际人道法与媒体资料"}], "frontend_data_files": ["taiwan_civil_defense_overview_metrics_v1.json", "taiwan_civil_defense_policy_legal_cases_v1.json", "taiwan_civil_defense_organization_system_enriched_v1.json", "taiwan_civil_defense_risk_assessments_v1.json", "taiwan_civil_defense_controversy_propagation_v1.json", "taiwan_civil_groups_activity_funding_v1.json"], "recommended_home_sections": ["统计卡片", "五层组织体系图", "政策时间线", "高风险议题Top列表", "重点争议列表", "来源可信度说明"], "safe_display_notice": "本页面用于公开资料研究、政策展示与风险评估,不展示训练操作、敏感设施、个人隐私、定向影响或可用于攻击破坏的信息。"}, "global_query": {"enabled": true, "query_mode": "client_side_fuzzy_search", "supports": ["关键词搜索", "Tab 内筛选", "证据强度筛选", "风险/层级筛选", "详情弹框查看", "JSON 字段递归展示"]}, "tabs": [{"id": "overview", "label": "首页总览", "description": "展示核心统计、分类分布、重点卡片和安全边界说明。", "record_count": 1, "default_view": "dashboard", "records": [{"dataset_name": "taiwan_civil_defense_page_overview_metrics", "created_at": "2026-06-24", "last_verified_hint": "部分公共资料核验至2026-06-24;页面上线前应再次核验官方来源。", "core_metrics": {"organization_system_records": 35, "civil_group_profiles": 14, "policy_cases": 12, "controversy_cases": 16, "risk_assessment_items": 18, "source_catalog_items": 16}, "category_distribution": {"organizations_by_category": {"关键基础设施防护编组": 9, "民间训练组织": 8, "地方政府动员体系": 7, "法定民防团队": 6, "企业学校防护编组": 5}, "civil_groups_by_category": {"民防教育训练型": 1, "社会韧性与应急训练型": 1, "地方自训网络枢纽型": 1, "社区韧性与宗教网络型": 1, "Line truncated
<script>
const DATA = JSON.parse(document.getElementById('PAGE_DATA').textContent);
const LABELS = DATA.field_labels || {};
const LAYOUTS = DATA.detail_layouts || {};
const state = {activeTab:'overview', query:'', category:'', level:'', evidence:'', view:'cards'};
const $ = s => document.querySelector(s);
const tabsEl=$('#tabs'), contentEl=$('#content'), searchInput=$('#searchInput'), categoryFilter=$('#categoryFilter'), levelFilter=$('#levelFilter'), evidenceFilter=$('#evidenceFilter'), viewToggle=$('#viewToggle');
function esc(s){return String(s ?? '').replace(/[&<>\'\"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;',"'":'&#39;','"':'&quot;'}[c]));}
function label(k){return LABELS[k] || humanize(k)}
function humanize(k){return String(k).replace(/_/g,' ').replace(/\b\w/g,m=>m.toUpperCase())}
function textify(x){if(x==null)return''; if(['string','number','boolean'].includes(typeof x))return String(x); if(Array.isArray(x))return x.map(textify).join(' '); if(typeof x==='object')return Object.values(x).map(textify).join(' '); return String(x)}
function truncate(s,n=120){s=String(s||'');return s.length>n?s.slice(0,n)+'…':s}
function activeTab(){return DATA.tabs.find(t=>t.id===state.activeTab)||DATA.tabs[0]}
function recordsOf(t){return t.records||[]}
function unique(arr){return [...new Set(arr.filter(Boolean).map(String))]}
function chipClass(v){v=String(v||''); if(/高|重大|严重|high|高争议|易复燃/.test(v)) return 'high'; if(/中|mixed|medium|中高/.test(v)) return 'mid'; if(/低|low|confirmed/.test(v)) return 'ok'; return 'gray'}
function renderChips(arr,limit=12){return (arr||[]).filter(Boolean).slice(0,limit).map(t=>`<span class="chip ${chipClass(t)}">${esc(t)}</span>`).join('')}
function renderIdPills(arr){if(!arr)return '—'; if(!Array.isArray(arr))arr=[arr]; return `<div class="pill-list">${arr.map(x=>`<span class="id-pill">${esc(x)}</span>`).join('')}</div>`}
function init(){
$('#appTitle').textContent=DATA.meta.title;
$('#appDesc').textContent=DATA.meta.description;
$('#safeNotice').textContent=DATA.meta.safe_display_notice || (DATA.meta.data_boundary||[]).join(' ');
const metrics=(DATA.overview.top_display_cards||[]).slice(0,6);
$('#heroBadges').innerHTML=metrics.map(m=>`<span class="badge">${esc(m.card_title)}:${esc(m.value)}</span>`).join('') + `<span class="badge">版本:${esc(DATA.meta.version)}</span>`;
buildTabs(); updateFilters(); render();
}
function buildTabs(){
tabsEl.innerHTML=DATA.tabs.map(t=>`<button class="tab ${t.id===state.activeTab?'active':''}" data-tab="${esc(t.id)}"><span>${esc(t.label)}</span><span class="tab-count">${esc(t.record_count ?? (t.records||[]).length)}</span></button>`).join('');
tabsEl.querySelectorAll('.tab').forEach(btn=>btn.onclick=()=>{state.activeTab=btn.dataset.tab; state.category='';state.level='';state.evidence=''; buildTabs();updateFilters();render();});
}
function updateFilters(){
const tab=activeTab(), recs=recordsOf(tab);
const getVals=id=>tab.filters?.find(f=>f.id===id)?.values || unique(recs.map(r=>r[id]));
fillSelect(categoryFilter,'全部分类',getVals('category'),state.category);
fillSelect(levelFilter,'全部等级/层级',getVals('level'),state.level);
fillSelect(evidenceFilter,'全部证据强度',getVals('evidence'),state.evidence);
const ov=tab.id==='overview'; [categoryFilter,levelFilter,evidenceFilter].forEach(el=>el.disabled=ov); viewToggle.disabled=ov;
}
function fillSelect(el,first,vals,val){el.innerHTML=`<option value="">${first}</option>`+vals.map(v=>`<option value="${esc(v)}">${esc(v)}</option>`).join(''); el.value=val||'';}
function filteredRecords(){
const tab=activeTab(); if(tab.id==='overview')return recordsOf(tab);
const q=state.query.trim().toLowerCase();
return recordsOf(tab).filter(r=>(!q||textify(r).toLowerCase().includes(q))&&(!state.category||String(r.category||'')===state.category)&&(!state.level||String(r.level||'')===state.level)&&(!state.evidence||String(r.evidence||'')===state.evidence));
}
function render(){
buildTabs(); updateFilters(); const tab=activeTab(); if(tab.id==='overview') return renderOverview();
const recs=filteredRecords();
contentEl.innerHTML=`<div class="section-title"><div><h2>${esc(tab.label)}</h2><div class="count">当前显示 ${recs.length} / ${recordsOf(tab).length} 条记录</div></div><div class="desc">${esc(tab.description||'')}</div></div>` +
(recs.length ? (state.view==='cards'?`<div class="cards">${recs.map(cardHTML).join('')}</div>`:tableHTML(recs)) : `<div class="empty">没有匹配结果。请调整关键词或筛选条件。</div>`);
contentEl.querySelectorAll('[data-open-id]').forEach(el=>el.onclick=()=>openModal(el.dataset.openId));
}
function cardFieldsForTab(tabId){
return {
organizations:['类型','主管/协调体系','主要功能','证据强度'],
policies:['类型','状态','主管机关','风险提示'],
civil_groups:['分类','组织类型','核心功能','风险提示'],
controversies:['时间范围','状态','相关组织','风险等级'],
risks:['风险类型','风险等级','影响对象','触发条件']
}[tabId] || [];
}
function cardHTML(r){
const fields=cardFieldsForTab(state.activeTab);
const mini=fields.map(k=>`<div class="mini"><div class="mk">${esc(k)}</div><div class="mv">${esc(truncate(textify((r.summary||{})[k]),90))}</div></div>`).join('');
return `<article class="card" data-open-id="${esc(r.id)}"><h3>${esc(r.title)}</h3><div class="subtitle">${esc(truncate(r.subtitle,185))}</div><div class="mini-fields">${mini}</div><div class="chips">${renderChips(r.tags||[],5)}${r.evidence?`<span class="chip ${chipClass(r.evidence)}">证据:${esc(r.evidence)}</span>`:''}</div></article>`;
}
function tableHTML(recs){
return `<div class="table-wrap"><table><thead><tr><th>编号</th><th>标题</th><th>分类</th><th>状态</th><th>层级/等级</th><th>证据强度</th><th>摘要</th></tr></thead><tbody>${recs.map(r=>`<tr data-open-id="${esc(r.id)}"><td>${esc(r.id)}</td><td><b>${esc(r.title)}</b></td><td>${esc(r.category)}</td><td>${esc(r.status)}</td><td>${esc(r.level)}</td><td>${esc(r.evidence)}</td><td>${esc(truncate(r.subtitle,160))}</td></tr>`).join('')}</tbody></table></div>`
}
function renderOverview(){
const ov=DATA.overview, cards=ov.top_display_cards||[], dist=ov.category_distribution||{};
contentEl.innerHTML=`<div class="section-title"><div><h2>首页总览</h2><div class="count">生成日期:${esc(ov.created_at)}|${esc(ov.last_verified_hint)}</div></div><div class="desc">展示核心统计、分类分布、重点卡片和安全边界说明。</div></div>
<div class="metrics">${cards.map(c=>`<div class="metric"><div class="label">${esc(c.card_title)}</div><div class="value">${esc(c.value)}</div><div class="sub">${esc(c.subtitle)}</div></div>`).join('')}</div>
<div class="grid-2">${distributionBox('组织分类分布',dist.organizations_by_category)}${distributionBox('风险等级分布',dist.risks_by_level)}${distributionBox('政策类型分布',dist.policies_by_type)}${distributionBox('民间组织分类分布',dist.civil_groups_by_category)}</div>
<div class="box" style="margin-top:14px"><h3>推荐首页区块</h3>${renderValue(ov.recommended_home_sections||[])}</div>`;
}
function distributionBox(title,obj){obj=obj||{}; const max=Math.max(...Object.values(obj).map(Number),1); const rows=Object.entries(obj).map(([k,v])=>`<div class="bar-row"><div>${esc(k)}</div><div class="bar"><span style="width:${Math.max(4,Number(v)/max*100)}%"></span></div><div>${esc(v)}</div></div>`).join('');return `<div class="box"><h3>${esc(title)}</h3>${rows||'<div class="empty">暂无数据</div>'}</div>`}
function openModal(id){
const tab=activeTab(); const rec=recordsOf(tab).find(r=>String(r.id)===String(id)); if(!rec)return;
$('#modalTitle').textContent=rec.title||rec.id; $('#modalSub').textContent=rec.subtitle||''; $('#modalChips').innerHTML=renderChips(rec.tags||[],12);
$('#modalBody').innerHTML=renderDetail(tab.id,rec.detail||rec); $('#modalBackdrop').classList.add('show');
}
function renderDetail(tabId, detail){
const layout=LAYOUTS[tabId]||[]; const used=new Set();
const sections=layout.map(sec=>{const body=sec.keys.map(k=>{used.add(k); return renderField(k,detail[k],tabId)}).filter(Boolean).join(''); return body?section(sec.title,body):''}).join('');
const extras=Object.keys(detail).filter(k=>!used.has(k) && !/^_/.test(k));
const extraHtml=extras.length?section('其他补充信息',extras.map(k=>renderField(k,detail[k],tabId)).filter(Boolean).join('')):'';
return sections + extraHtml;
}
function section(title,body){return `<section class="detail-section"><div class="section-head"><h3>${esc(title)}</h3></div><div class="section-content">${body}</div></section>`}
function renderField(k,v,tabId){
if(v==null || v==='' || (Array.isArray(v)&&!v.length)) return '';
const val = specialized(k,v,tabId) || renderValue(v,k);
return `<div class="kv"><div class="k">${esc(label(k))}</div><div class="v">${val}</div></div>`;
}
function specialized(k,v,tabId){
if(k==='source_url' && v) return `<a class="source-link" href="${esc(v)}" target="_blank" rel="noopener">${esc(v)}</a>`;
if(['related_policy_ids','related_risk_ids','related_org_ids','related_controversy_ids','controversy_links','frontend_tags','source_ids'].includes(k)) return renderIdPills(v);
if(k==='sources' && Array.isArray(v)) return v.map(s=>typeof s==='string'?`<span class="id-pill">${esc(s)}</span>`:`<a class="source-link" href="${esc(s.url||'#')}" target="_blank" rel="noopener"><b>${esc(s.title||s.url||'资料来源')}</b><br>${esc(s.source_type||'')} ${s.confidence?`|可信度:${esc(s.confidence)}`:''}</a>`).join('');
if(k==='public_key_persons' && Array.isArray(v)) return `<div class="mini-card-grid">${v.map(p=>`<div class="mini-card"><div class="mini-title">${esc(p.name)}</div><div>${esc(p.public_role||'')}</div><div class="view-note">${esc(p.notes||'')}</div></div>`).join('')}</div>`;
if(k==='training_topics' && Array.isArray(v)) return `<div class="mini-card-grid">${v.map(t=>`<div class="mini-card"><div class="mini-title">${esc(t.topic)}</div><div>${esc(t.public_description||'')}</div><div class="chips"><span class="chip ${chipClass(t.operational_sensitivity)}">敏感度:${esc(t.operational_sensitivity||'')}</span></div></div>`).join('')}</div>`;
if(k==='policy_links' && Array.isArray(v)) return `<div class="mini-card-grid">${v.map(p=>`<div class="mini-card"><div class="mini-title">${esc(p.policy_or_agency)}</div><div>${esc(p.relationship_type||'')}</div><div class="view-note">${esc(p.evidence||'')}</div><div class="chips"><span class="chip ${chipClass(p.evidence_strength)}">${esc(p.evidence_strength||'')}</span></div></div>`).join('')}</div>`;
if(k==='controversies' && Array.isArray(v)) return `<div class="timeline">${v.map(c=>`<div class="tl-item"><div class="tl-date">${esc(c.date||c.event||'争议')}</div><div class="tl-text"><b>${esc(c.event||'')}</b><br>${esc(c.issue||'')}${c.public_response?`<br>公开回应:${esc(c.public_response)}`:''}</div><div class="chips">${c.risk_tag?`<span class="chip high">${esc(c.risk_tag)}</span>`:''}</div></div>`).join('')}</div>`;
if(k==='factual_anchors' && Array.isArray(v)) return `<div class="timeline">${v.map(a=>`<div class="tl-item"><div class="tl-date">${esc(a.date||'时间未明')}</div><div class="tl-text">${esc(a.fact||'')}</div><div>${renderIdPills(a.source_ids||[])}</div><div class="chips"><span class="chip ${chipClass(a.evidence_status)}">${esc(a.evidence_status||'')}</span></div></div>`).join('')}</div>`;
if(k==='position_map' && typeof v==='object') return `<div class="columns">${Object.entries(v).map(([kk,vv])=>`<div class="mini-card"><div class="mini-title">${esc(label(kk))}</div><div>${renderValue(vv,kk)}</div></div>`).join('')}</div>`;
if(k==='public_opinion_signals' && typeof v==='object') return `<div class="mini-card-grid">${Object.entries(v).map(([kk,vv])=>`<div class="mini-card"><div class="mini-title">${esc(label(kk))}</div>${Array.isArray(vv)?renderChips(vv,30):renderValue(vv,kk)}</div>`).join('')}</div>`;
if(k==='propagation' && typeof v==='object') return renderPropagation(v);
if(k==='activity_funding' && typeof v==='object') return renderActivityFunding(v);
if(k==='risk_assessment' && typeof v==='object') return `<div class="mini-card-grid">${Object.entries(v).map(([kk,vv])=>`<div class="mini-card"><div class="mini-title">${esc(label(kk))}</div>${renderValue(vv,kk)}</div>`).join('')}</div>`;
return null;
}
function renderPropagation(v){
const chain=v.propagation_chain||[]; const chainHtml=Array.isArray(chain)?`<div class="timeline">${chain.map(c=>`<div class="tl-item"><div class="tl-date">${esc(c.stage||c.date||'阶段')}</div><div class="tl-text">${renderValue(c)}</div></div>`).join('')}</div>`:renderValue(chain);
return `<div class="mini-card"><div class="mini-title">传播链</div>${chainHtml}</div><div class="mini-card"><div class="mini-title">可能平台扩散模式</div>${renderValue(v.likely_platform_pattern)}</div><div class="mini-card"><div class="mini-title">关联风险</div>${renderIdPills(v.related_risk_ids||[])}</div><div class="mini-card"><div class="mini-title">展示边界</div>${esc(v.safe_display_notes||'')}</div>`;
}
function renderActivityFunding(v){
const keys=['activity_records','funding_records','cooperation_records','media_exposure_records','related_controversy_ids','related_risk_ids','missing_fields_to_fill_next','safe_display_notes'];
return `<div class="mini-card-grid">${keys.filter(k=>v[k]!=null).map(k=>`<div class="mini-card"><div class="mini-title">${esc(label(k))}</div>${renderValue(v[k],k)}</div>`).join('')}</div>`;
}
function renderValue(v,k){
if(v==null || v==='') return '—';
if(typeof v==='string' || typeof v==='number' || typeof v==='boolean'){
const s=String(v);
if(/^https?:\/\//.test(s)) return `<a class="source-link" href="${esc(s)}" target="_blank" rel="noopener">${esc(s)}</a>`;
return esc(s);
}
if(Array.isArray(v)){
if(!v.length)return '—';
if(v.every(x=>typeof x==='string'||typeof x==='number')){
const asIds=v.every(x=>/^[A-Z]{2,}_|^TC_|^CON_|^POL_|^ORG_|^RISK_|^SRC_/.test(String(x)));
return asIds ? renderIdPills(v) : `<ul class="list">${v.map(x=>`<li>${esc(x)}</li>`).join('')}</ul>`;
}
return `<div class="mini-card-grid">${v.map(item=>`<div class="mini-card">${renderValue(item)}</div>`).join('')}</div>`;
}
if(typeof v==='object'){
return Object.entries(v).map(([kk,vv])=>`<div class="kv"><div class="k">${esc(label(kk))}</div><div class="v">${renderValue(vv,kk)}</div></div>`).join('');
}
return esc(v);
}
$('#modalClose').onclick=()=>$('#modalBackdrop').classList.remove('show');
$('#modalBackdrop').onclick=e=>{if(e.target.id==='modalBackdrop')$('#modalBackdrop').classList.remove('show')};
searchInput.oninput=e=>{state.query=e.target.value;render()};
categoryFilter.onchange=e=>{state.category=e.target.value;render()};
levelFilter.onchange=e=>{state.level=e.target.value;render()};
evidenceFilter.onchange=e=>{state.evidence=e.target.value;render()};
viewToggle.onclick=()=>{state.view=state.view==='cards'?'table':'cards'; viewToggle.textContent=state.view==='cards'?'卡片视图':'表格视图'; render()};
document.addEventListener('keydown',e=>{if(e.key==='Escape')$('#modalBackdrop').classList.remove('show')});
init();
</script>
</body>
</html>
@@ -0,0 +1,303 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>美国左翼退伍军人与关联组织专题展示平台</title>
<style>
:root{
--bg:#f6f1eb;
--panel:#fffaf4;
--panel2:#fffdf9;
--text:#251b17;
--muted:#776963;
--line:#d9c9bb;
--heavy:#4b241b;
--accent:#a33a2b;
--accent2:#cf7a45;
--soft:#f1dfd2;
--soft2:#ead1c0;
--green:#627b58;
--blue:#4f6f86;
--purple:#6f5d83;
--shadow:0 18px 50px rgba(66,35,20,.13);
--radius:22px;
}
*{box-sizing:border-box}
body{margin:0;font-family:"Microsoft YaHei","Noto Sans SC",system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;background:linear-gradient(180deg,#f8f1e8 0%,#f3e9dc 55%,#efe0d2 100%);color:var(--text);}
.app{max-width:1480px;margin:0 auto;padding:26px 24px 56px;}
.hero{position:relative;overflow:hidden;border:1px solid var(--line);border-radius:30px;background:radial-gradient(circle at 10% 10%,rgba(207,122,69,.24),transparent 28%),radial-gradient(circle at 90% 0%,rgba(163,58,43,.15),transparent 34%),linear-gradient(135deg,#fffaf4,#f3dfcd);box-shadow:var(--shadow);padding:34px 36px;margin-bottom:22px;}
.hero:before{content:"";position:absolute;right:-90px;top:-130px;width:310px;height:310px;border:32px solid rgba(163,58,43,.08);border-radius:50%;}
.hero:after{content:"";position:absolute;right:120px;bottom:-130px;width:260px;height:260px;border:1px solid rgba(75,36,27,.14);border-radius:50%;}
.hero-inner{position:relative;z-index:2;display:grid;grid-template-columns:minmax(0,1.25fr) minmax(360px,.75fr);gap:24px;align-items:end;}
.eyebrow{display:inline-flex;gap:8px;align-items:center;font-size:13px;letter-spacing:.12em;color:var(--accent);font-weight:800;text-transform:uppercase;margin-bottom:10px;}
.eyebrow:before{content:"";width:34px;height:2px;background:var(--accent);display:block;}
h1{font-size:38px;line-height:1.16;margin:0 0 12px;font-weight:900;letter-spacing:-.02em;color:#3e2018;}
.lead{font-size:16px;line-height:1.85;color:#5f4f48;max-width:980px;margin:0;}
.hero-actions{display:flex;gap:12px;flex-wrap:wrap;margin-top:22px;}
.btn{border:1px solid var(--heavy);border-radius:999px;background:#fffdf9;color:var(--heavy);padding:10px 16px;font-weight:800;cursor:pointer;transition:.18s;display:inline-flex;align-items:center;gap:8px;text-decoration:none;font-size:14px;}
.btn:hover{transform:translateY(-1px);box-shadow:0 8px 18px rgba(66,35,20,.12);background:var(--heavy);color:#fffaf4;}
.btn.accent{background:var(--accent);color:#fffaf4;border-color:var(--accent);}
.quick{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;}
.quick-card{border:1px solid rgba(75,36,27,.16);background:rgba(255,255,255,.58);border-radius:18px;padding:16px;backdrop-filter:blur(6px);}
.quick-card b{display:block;font-size:24px;color:var(--accent);margin-bottom:2px;}
.quick-card span{font-size:12px;color:var(--muted);line-height:1.45;}
.toolbar{position:sticky;top:0;z-index:10;margin:0 0 20px;padding:12px;border:1px solid var(--line);border-radius:24px;background:rgba(255,250,244,.86);backdrop-filter:blur(18px);box-shadow:0 12px 30px rgba(66,35,20,.08);display:flex;gap:12px;align-items:center;}
.tabs{display:flex;gap:8px;overflow-x:auto;padding:2px;flex:1;}
.tab{white-space:nowrap;border:1px solid transparent;background:transparent;border-radius:999px;padding:10px 14px;font-weight:800;color:#5a453c;cursor:pointer;transition:.16s;font-size:14px;}
.tab:hover{background:#f2dfd0;}
.tab.active{background:#4b241b;color:#fffaf4;box-shadow:0 7px 18px rgba(75,36,27,.18);}
.search{min-width:270px;display:flex;align-items:center;gap:8px;border:1px solid var(--line);background:#fffdf9;border-radius:999px;padding:9px 12px;}
.search input{border:0;outline:0;background:transparent;width:100%;font-size:14px;color:var(--text);}
.panel{display:none;animation:fade .22s ease;}
.panel.active{display:block;}
@keyframes fade{from{opacity:.5;transform:translateY(4px)}to{opacity:1;transform:translateY(0)}}
.section-title{display:flex;align-items:flex-end;justify-content:space-between;gap:16px;margin:24px 0 14px;}
.section-title h2{margin:0;font-size:24px;color:#3e2018;}
.section-title p{margin:6px 0 0;color:var(--muted);line-height:1.7;max-width:920px;}
.grid{display:grid;gap:16px;}
.cols-2{grid-template-columns:repeat(2,minmax(0,1fr));}.cols-3{grid-template-columns:repeat(3,minmax(0,1fr));}.cols-4{grid-template-columns:repeat(4,minmax(0,1fr));}
.card{border:1px solid var(--line);background:var(--panel2);border-radius:var(--radius);padding:20px;box-shadow:0 8px 28px rgba(66,35,20,.06);}
.card h3{margin:0 0 10px;font-size:18px;color:#44231a;line-height:1.35;}
.card p{margin:0;color:#66564f;line-height:1.75;font-size:14px;}
.mini{font-size:12px;color:var(--muted);line-height:1.6;}
.metric{position:relative;overflow:hidden;padding:18px;border-radius:20px;border:1px solid rgba(75,36,27,.16);background:linear-gradient(180deg,#fffdf9,#f5e7dc);}
.metric .value{font-size:27px;font-weight:900;color:var(--accent);margin-bottom:5px;}
.metric .label{font-size:13px;font-weight:800;color:#4b241b;margin-bottom:4px;}
.metric .desc{font-size:12px;line-height:1.45;color:var(--muted);}
.pill-row{display:flex;flex-wrap:wrap;gap:8px;margin-top:12px;}
.pill{display:inline-flex;align-items:center;border:1px solid rgba(75,36,27,.18);border-radius:999px;padding:6px 10px;background:#fff8f2;color:#5a433b;font-size:12px;font-weight:700;}
.pill.red{background:#f5dfd9;color:#7d251a;border-color:#e7bdb4}.pill.green{background:#e8eadb;color:#536b49;border-color:#c6cdb4}.pill.blue{background:#e4ecf0;color:#405f75;border-color:#bfd0d9}.pill.purple{background:#e8e0ed;color:#5f4f72;border-color:#d0c3da}
.org-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:16px;}
.org-card{border:1px solid var(--line);background:#fffdf9;border-radius:24px;padding:18px;position:relative;overflow:hidden;cursor:pointer;transition:.18s;min-height:270px;display:flex;flex-direction:column;}
.org-card:hover{transform:translateY(-3px);box-shadow:var(--shadow);border-color:#bd927c;}
.org-card:before{content:"";position:absolute;right:-40px;top:-48px;width:120px;height:120px;background:rgba(163,58,43,.08);border-radius:50%;}
.org-card .top{position:relative;display:flex;gap:12px;align-items:flex-start;margin-bottom:12px;}
.avatar{width:48px;height:48px;border-radius:50%;border:1px solid var(--heavy);display:grid;place-items:center;font-weight:900;color:#4b241b;background:#f4e0d1;flex:0 0 auto;}
.org-name{font-size:17px;font-weight:900;color:#3d2118;line-height:1.3;}.org-zh{font-size:12px;color:var(--muted);margin-top:3px;}
.org-card .summary{font-size:13px;line-height:1.7;color:#66564f;display:-webkit-box;-webkit-line-clamp:4;-webkit-box-orient:vertical;overflow:hidden;margin-bottom:12px;}
.org-meta{display:flex;flex-wrap:wrap;gap:7px;margin-top:auto;}
.badge{font-size:11px;font-weight:800;border-radius:999px;padding:5px 8px;border:1px solid rgba(75,36,27,.18);background:#f8ecdf;color:#5a433b;}
.badge.hot{background:#7d2e22;color:#fff;border-color:#7d2e22}.badge.ok{background:#607a55;color:#fff;border-color:#607a55}.badge.warn{background:#cf7a45;color:#fff;border-color:#cf7a45}
.timeline{position:relative;margin-top:14px;padding-left:24px;}
.timeline:before{content:"";position:absolute;left:8px;top:0;bottom:0;width:2px;background:linear-gradient(var(--accent),rgba(163,58,43,.1));}
.t-item{position:relative;border:1px solid var(--line);background:#fffdf9;border-radius:18px;margin:0 0 12px;padding:14px 15px;cursor:pointer;transition:.16s;}
.t-item:hover{transform:translateX(4px);box-shadow:0 8px 20px rgba(66,35,20,.08);}
.t-item:before{content:"";position:absolute;left:-22px;top:20px;width:11px;height:11px;border-radius:50%;background:var(--accent);border:3px solid #f8f1e8;}
.t-year{font-size:12px;font-weight:900;color:var(--accent);letter-spacing:.05em}.t-title{font-size:16px;font-weight:900;margin-top:2px;color:#422319}.t-meaning{font-size:13px;color:#66564f;margin-top:4px;line-height:1.55;}
table{width:100%;border-collapse:separate;border-spacing:0;overflow:hidden;border:1px solid var(--line);border-radius:18px;background:#fffdf9;}
th,td{padding:13px 14px;border-bottom:1px solid var(--line);text-align:left;vertical-align:top;font-size:13px;line-height:1.6;}
th{background:#4b241b;color:#fffaf4;font-size:12px;letter-spacing:.04em;}
tr:last-child td{border-bottom:0} tr:nth-child(even) td{background:#fff7ef;}
.matrix-card{border:1px solid var(--line);border-radius:20px;background:#fffdf9;padding:16px;cursor:pointer;transition:.16s;}
.matrix-card:hover{transform:translateY(-2px);box-shadow:0 8px 20px rgba(66,35,20,.08);}
.matrix-card h3{font-size:16px;margin:0 0 6px;}.matrix-card .orgs{font-size:12px;color:var(--accent);font-weight:900;margin-bottom:8px;}.matrix-card p{font-size:13px;line-height:1.7;color:#66564f;}
.route{border-left:5px solid var(--accent);padding:18px;border-radius:18px;background:#fffdf9;border-top:1px solid var(--line);border-right:1px solid var(--line);border-bottom:1px solid var(--line);}
.route h3{margin:0 0 8px;font-size:18px;}.route .orgs{font-size:12px;font-weight:900;color:var(--accent);margin-bottom:8px;}.route p{font-size:13px;line-height:1.75;color:#66564f;}
.notice{border:1px dashed rgba(75,36,27,.35);background:#fff6ed;border-radius:22px;padding:18px;color:#5f4f48;line-height:1.75;}
.source-box{font-size:13px;line-height:1.8;color:#5f4f48;}
.modal-backdrop{position:fixed;inset:0;background:rgba(39,26,20,.48);display:none;z-index:90;align-items:center;justify-content:center;padding:24px;}
.modal-backdrop.open{display:flex;}
.modal{width:min(1060px,96vw);max-height:88vh;overflow:auto;border-radius:28px;background:#fffdf9;border:1px solid #c7a995;box-shadow:0 30px 100px rgba(0,0,0,.28);}
.modal-head{position:sticky;top:0;background:linear-gradient(135deg,#4b241b,#7c2f24);color:#fffaf4;padding:22px 26px;z-index:3;display:flex;gap:16px;align-items:flex-start;justify-content:space-between;}
.modal-title h2{font-size:24px;line-height:1.25;margin:0 0 4px;}.modal-title p{margin:0;color:#f5d5c7;line-height:1.5;}
.close{background:rgba(255,255,255,.12);color:#fff;border:1px solid rgba(255,255,255,.3);width:40px;height:40px;border-radius:50%;font-size:22px;cursor:pointer;}
.modal-body{padding:24px 26px 30px;}
.detail-grid{display:grid;grid-template-columns:1fr 1fr;gap:14px;margin:16px 0;}
.detail-box{border:1px solid var(--line);border-radius:18px;padding:14px;background:#fff8f1;}
.detail-box .k{font-size:12px;font-weight:900;color:var(--accent);margin-bottom:4px;}.detail-box .v{font-size:14px;color:#4e3f38;line-height:1.6;}
.modal-section{margin:22px 0 0;}.modal-section h3{font-size:18px;margin:0 0 10px;color:#44231a;border-bottom:1px solid var(--line);padding-bottom:8px;}
.list{margin:0;padding-left:18px;color:#5f4f48;line-height:1.8;font-size:14px;}.list li{margin:4px 0;}
.empty{padding:28px;border:1px dashed var(--line);border-radius:20px;text-align:center;color:var(--muted);background:#fffaf4;}
@media (max-width:1100px){.hero-inner{grid-template-columns:1fr}.quick{grid-template-columns:repeat(3,1fr)}.cols-4,.cols-3{grid-template-columns:repeat(2,1fr)}.org-grid{grid-template-columns:repeat(2,1fr)}.search{min-width:190px}}
@media (max-width:760px){.app{padding:14px}.hero{padding:24px}.toolbar{align-items:stretch;flex-direction:column}.search{min-width:0}.quick,.cols-4,.cols-3,.cols-2,.org-grid,.detail-grid{grid-template-columns:1fr}h1{font-size:28px}.modal-backdrop{padding:10px}.modal-head{padding:18px}.modal-body{padding:18px}}
</style>
</head>
<body>
<div class="app">
<header class="hero">
<div class="hero-inner">
<div>
<div class="eyebrow">Research Dashboard</div>
<h1 id="pageTitle"></h1>
<p class="lead" id="pageSubtitle"></p>
<div class="hero-actions">
<button class="btn accent" onclick="switchTab('orgs')">查看组织资料库</button>
<button class="btn" onclick="switchTab('china')">查看涉中涉台分化</button>
<button class="btn" onclick="switchTab('narrative')">查看叙事框架</button>
</div>
</div>
<div class="quick" id="quickMetrics"></div>
</div>
</header>
<nav class="toolbar">
<div class="tabs" id="tabs"></div>
<label class="search"><span>⌕</span><input id="globalSearch" placeholder="搜索组织、议题、路线、关键词..." /></label>
</nav>
<main>
<section id="panel-overview" class="panel active"></section>
<section id="panel-ecology" class="panel"></section>
<section id="panel-orgs" class="panel"></section>
<section id="panel-history" class="panel"></section>
<section id="panel-identity" class="panel"></section>
<section id="panel-issues" class="panel"></section>
<section id="panel-china" class="panel"></section>
<section id="panel-narrative" class="panel"></section>
</main>
</div>
<div class="modal-backdrop" id="modalBackdrop" onclick="backdropClose(event)">
<article class="modal" id="modal"></article>
</div>
<script id="DATA" type="application/json">{"meta": {"title": "美国左翼退伍军人与关联组织专题展示平台", "subtitle": "组织生态 · 历史谱系 · 人员身份 · 议题矩阵 · 涉中涉台分化 · 爱国主义重构", "version": "enhanced_v1.0", "source_basis": ["us_left_wing_veterans_skill/SKILL.md", "docs/full_reference_current_version.md", "docs/answer_bank_three_priority_questions.md", "examples/veterans_for_peace_china_taiwan_profile.md", "examples/votevets_common_defense_compare.md", "本轮对话中补充的8个栏目研究数据"], "safety_note": "本专题仅用于公开资料研究、组织生态分析、政策与舆论研究,不包含定向影响、骚扰、欺骗、操纵或针对个人的行动建议。"}, "overview": {"definition": "美国左翼退伍军人群体不是一个统一组织,而是一个以退伍军人身份为共同象征资源,围绕反战、进步派政治、民主保护、劳工、福利、身份平权、人权与难民、反新冷战等议题形成的松散议题生态。", "judgments": ["该群体更像“议题联盟”,不是统一组织群体。", "其影响力不主要来自人数,而来自退伍军人身份在美国政治文化中的象征权威。", "它们共同挑战“军人天然保守、爱国等于支持战争”的传统叙事。", "它们在VA福利、反永久战争、反威权、民主保护、军属支持、心理健康等议题上有交集。", "它们在涉中涉台、人权、对华竞争、台湾民主、美国军事介入问题上存在明显分歧。"], "metrics": [{"label": "2023年美国退伍军人", "value": "约1580万", "desc": "占18岁及以上平民人口约6.1%"}, {"label": "2023年女性退伍军人", "value": "约170万", "desc": "约占退伍军人总数10.9%"}, {"label": "VA预测FY2024", "value": "约1790万", "desc": "VA VetPop2023预测口径"}, {"label": "VA预测FY2053", "value": "约1120万", "desc": "总体人数下降,结构更女性化、多元化"}, {"label": "2024退伍军人选民", "value": "63%偏共和党", "desc": "左翼退伍军人属于少数派反叙事力量"}, {"label": "VFP分会", "value": "120+", "desc": "美国各州和多个国家拥有成员与分会"}], "layers": [{"name": "反战和平主义圈", "orgs": "Veterans For Peace、VVAW、About Face", "logic": "反永久战争、反军事主义、反军工复合体"}, {"name": "民主党进步派圈", "orgs": "VoteVets、Common Defense", "logic": "选举动员、反威权、保护民主、保护VA"}, {"name": "服务福利圈", "orgs": "Swords to Plowshares", "logic": "住房、医疗、就业、法律和退伍军人服务"}, {"name": "身份平权圈", "orgs": "Minority Veterans of America", "logic": "少数族裔、女性、LGBTQ+、残障退伍军人权益"}, {"name": "劳工/人权/军属外围圈", "orgs": "UVC、VFAI、MFSO、Gold Star Families", "logic": "工会、人权、难民、军属和阵亡家庭反战"}], "comparison": [{"dimension": "组织属性", "traditional": "全国会员组织、福利服务、纪念传统", "left": "PAC、501(c)(3)/(c)(4)、工会项目、服务组织、军属网络并存"}, {"dimension": "核心话语", "traditional": "服务国家、荣誉、纪念、福利", "left": "支持军人不等于支持战争;誓言没有过期;VA不是商品"}, {"dimension": "政治取向", "traditional": "整体更接近主流退伍军人政治,偏保守", "left": "内部差异大,但更偏反战、进步派、劳工与平权"}, {"dimension": "研究重点", "traditional": "主流退伍军人政治与福利", "left": "少数派如何争夺爱国主义、国家安全和军人形象解释权"}]}, "ecology": {"core_judgment": "美国左翼退伍军人生态由反战、选举、服务、身份平权、劳工、人权、军属和社会主义外围网络构成。共同点是使用退伍军人身份参与公共议题,差异在于法律形态、行动方式、对外政策和政治路线。", "layers": [{"name": "老牌反战和平主义", "representative": "Veterans For Peace、VVAW", "nature": "会员制、分会型、和平主义退伍军人组织", "issues": ["反战", "反军事化", "反核", "反军工", "反新冷战"], "value": "研究涉中涉台反介入路线的核心入口。"}, {"name": "后9·11反永久战争", "representative": "About Face", "nature": "后9·11军人与退伍军人运动组织", "issues": ["伊拉克", "阿富汗", "永久战争", "国内军事化"], "value": "代表年轻一代左翼退伍军人运动。"}, {"name": "民主党/进步派选举", "representative": "VoteVets、Common Defense", "nature": "PAC、501(c)(4)、基层运动平台", "issues": ["候选人背书", "国家安全叙事", "保护民主", "保护VA"], "value": "解释退伍军人身份如何成为民主党和进步派政治资源。"}, {"name": "服务福利型", "representative": "Swords to Plowshares", "nature": "退伍军人服务型非营利组织", "issues": ["住房", "就业", "医疗", "法律援助"Line truncated
<script>
const DATA = JSON.parse(document.getElementById('DATA').textContent);
const tabs = [
['overview','群体总览'],['ecology','组织生态'],['orgs','组织资料库'],['history','历史演化'],['identity','人员画像'],['issues','议题主张'],['china','涉中涉台'],['narrative','叙事重构']
];
function esc(s){return String(s ?? '').replace(/[&<>"']/g, m=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[m]));}
function list(items, cls='list'){return `<ul class="${cls}">${(items||[]).map(x=>`<li>${esc(x)}</li>`).join('')}</ul>`}
function pills(items, color=''){return `<div class="pill-row">${(items||[]).map(x=>`<span class="pill ${color}">${esc(x)}</span>`).join('')}</div>`}
function kv(k,v){return `<div class="detail-box"><div class="k">${esc(k)}</div><div class="v">${esc(v)}</div></div>`}
function section(title, html){return `<div class="modal-section"><h3>${esc(title)}</h3>${html}</div>`}
function table(headers, rows){return `<table><thead><tr>${headers.map(h=>`<th>${esc(h)}</th>`).join('')}</tr></thead><tbody>${rows.map(r=>`<tr>${r.map(c=>`<td>${c}</td>`).join('')}</tr>`).join('')}</tbody></table>`}
function norm(s){return String(s||'').toLowerCase();}
function includesDeep(obj,q){return norm(JSON.stringify(obj)).includes(norm(q));}
function init(){
document.getElementById('pageTitle').textContent = DATA.meta.title;
document.getElementById('pageSubtitle').textContent = DATA.meta.subtitle + '。' + DATA.meta.safety_note;
document.getElementById('quickMetrics').innerHTML = DATA.overview.metrics.slice(0,4).map(m=>`<div class="quick-card"><b>${esc(m.value)}</b><span>${esc(m.label)}|${esc(m.desc)}</span></div>`).join('');
document.getElementById('tabs').innerHTML = tabs.map(([id,name])=>`<button class="tab ${id==='overview'?'active':''}" data-tab="${id}" onclick="switchTab('${id}')">${name}</button>`).join('');
renderAll();
document.getElementById('globalSearch').addEventListener('input', renderAll);
}
function switchTab(id){
document.querySelectorAll('.panel').forEach(p=>p.classList.remove('active'));
document.getElementById('panel-'+id).classList.add('active');
document.querySelectorAll('.tab').forEach(t=>t.classList.toggle('active',t.dataset.tab===id));
window.scrollTo({top:0,behavior:'smooth'});
}
function q(){return document.getElementById('globalSearch')?.value.trim() || ''}
function renderAll(){renderOverview();renderEcology();renderOrgs();renderHistory();renderIdentity();renderIssues();renderChina();renderNarrative();}
function renderOverview(){
const o = DATA.overview;
const rows = o.comparison.map(r=>[esc(r.dimension),esc(r.traditional),esc(r.left)]);
document.getElementById('panel-overview').innerHTML = `
<div class="section-title"><div><h2>群体总览与概念界定</h2><p>${esc(o.definition)}</p></div></div>
<div class="grid cols-3">${o.judgments.map(j=>`<div class="card"><h3>核心判断</h3><p>${esc(j)}</p></div>`).join('')}</div>
<div class="section-title"><div><h2>关键人口与政治背景</h2><p>这一部分用于说明:左翼退伍军人并非美国退伍军人主流,但其身份符号具有高传播权威。</p></div></div>
<div class="grid cols-3">${o.metrics.map(m=>`<div class="metric"><div class="value">${esc(m.value)}</div><div class="label">${esc(m.label)}</div><div class="desc">${esc(m.desc)}</div></div>`).join('')}</div>
<div class="section-title"><div><h2>议题联盟分层</h2><p>专题不把它们当成单一阵营,而是按议题、组织形态和行动方式分层展示。</p></div></div>
<div class="grid cols-3">${o.layers.map(l=>`<div class="card"><h3>${esc(l.name)}</h3><p><b>代表组织:</b>${esc(l.orgs)}</p><p><b>核心逻辑:</b>${esc(l.logic)}</p></div>`).join('')}</div>
<div class="section-title"><div><h2>与传统退伍军人组织的区别</h2></div></div>${table(['对比维度','传统退伍军人组织','左翼退伍军人生态'], rows)}
`;
}
function renderEcology(){
const e = DATA.ecology;
document.getElementById('panel-ecology').innerHTML = `
<div class="section-title"><div><h2>组织生态与类型分层</h2><p>${esc(e.core_judgment)}</p></div></div>
<div class="grid cols-3">${e.layers.map((l,i)=>`<div class="card"><h3>${esc(l.name)}</h3><p><b>代表组织:</b>${esc(l.representative)}</p><p><b>组织性质:</b>${esc(l.nature)}</p>${pills(l.issues,['red','blue','green'][i%3])}<p style="margin-top:12px"><b>研究价值:</b>${esc(l.value)}</p></div>`).join('')}</div>
<div class="section-title"><div><h2>组织关系类型</h2><p>这些关系可用于后续生成关系图谱,而不是简单的组织列表。</p></div></div>
<div class="grid cols-3">${e.relationship_types.map(r=>`<div class="card"><h3>${esc(r.type)}</h3><p>${esc(r.explain)}</p><div class="pill-row"><span class="pill red">${esc(r.example)}</span></div></div>`).join('')}</div>
<div class="section-title"><div><h2>关系网络文字版</h2></div></div>
<div class="notice"><b>核心反战谱系:</b>VVAW → Veterans For Peace → About Face,并与MFSO、Gold Star Families形成战争代价网络。<br><b>选举与进步派谱系:</b>VoteVets ↔ Common Defense,连接民主党候选人、国会、州级选举和基层动员。<br><b>服务与福利谱系:</b>Swords to Plowshares连接VA、地方政府、住房、法律和医疗网络。<br><b>人权与劳工谱系:</b>VFAI连接难民与人权,UVC连接工会、公共部门、VA就业与服务。</div>
`;
}
function renderOrgs(){
const query = q();
const orgs = DATA.organizations.filter(o=>!query || includesDeep(o,query));
document.getElementById('panel-orgs').innerHTML = `
<div class="section-title"><div><h2>核心组织资料库</h2><p>点击组织卡片进入结构化详情页。弹框不再展示JSON,而是按“基础资料—核心特征—议题—涉中涉台—叙事—研究重点—注意事项”展示。</p></div><span class="pill red">${orgs.length} 个结果</span></div>
<div class="org-grid">${orgs.map(o=>orgCard(o)).join('')}</div>
${orgs.length? '' : '<div class="empty">没有匹配的组织,请换一个关键词。</div>'}
`;
}
function orgCard(o){
const initials = o.abbr.split(/\s|\//)[0].slice(0,4);
const b = o.priority.includes('第一')?'hot':(o.priority.includes('第二')?'warn':'');
return `<div class="org-card" onclick="showOrg('${o.id}')"><div class="top"><div class="avatar">${esc(initials)}</div><div><div class="org-name">${esc(o.name)}</div><div class="org-zh">${esc(o.zh)}</div></div></div><div class="summary">${esc(o.positioning)}</div><div class="pill-row">${o.issues.slice(0,5).map(x=>`<span class="pill">${esc(x)}</span>`).join('')}</div><div class="org-meta"><span class="badge ${b}">${esc(o.priority)}</span><span class="badge ok">数据${esc(o.completeness)}</span><span class="badge">${esc(o.category)}</span></div></div>`
}
function showOrg(id){
const o = DATA.organizations.find(x=>x.id===id); if(!o) return;
openModal(`<div class="modal-head"><div class="modal-title"><h2>${esc(o.name)}</h2><p>${esc(o.zh)}|${esc(o.category)}|${esc(o.priority)}|数据完备度 ${esc(o.completeness)}</p></div><button class="close" onclick="closeModal()">×</button></div><div class="modal-body">
<div class="detail-grid">${kv('法律形态',o.legal)+kv('成立时间',o.founded)+kv('活动地区',o.area)+kv('规模口径',o.scale)}</div>
${section('基本定位', `<p>${esc(o.positioning)}</p>`)}
${section('核心特点', list(o.features))}
${section('核心议题', pills(o.issues,'red'))}
${section('涉中涉台与对外政策价值', `<p>${esc(o.china_taiwan)}</p>`)}
${section('政治叙事', `<p>${esc(o.narrative)}</p>`)}
${section('后续研究重点', list(o.research_focus))}
${section('研究注意事项', list(o.cautions))}
</div>`)
}
function renderHistory(){
const h = DATA.history;
document.getElementById('panel-history').innerHTML = `
<div class="section-title"><div><h2>历史演化与运动谱系</h2><p>${esc(h.core_judgment)}</p></div></div>
<div class="grid cols-3">${h.stages.map(s=>`<div class="card"><h3>${esc(s.name)}</h3><div class="pill-row"><span class="pill red">${esc(s.period)}</span>${s.orgs.map(o=>`<span class="pill">${esc(o)}</span>`).join('')}</div><p style="margin-top:12px">${esc(s.summary)}</p><p style="margin-top:8px"><b>核心叙事:</b>${esc(s.narrative)}</p></div>`).join('')}</div>
<div class="section-title"><div><h2>关键事件时间线</h2><p>点击事件查看历史意义和可入库字段。</p></div></div>
<div class="timeline">${h.events.map((ev,i)=>`<div class="t-item" onclick="showEvent(${i})"><div class="t-year">${esc(ev.year)}|${esc(ev.org)}</div><div class="t-title">${esc(ev.title)}</div><div class="t-meaning">${esc(ev.meaning)}</div></div>`).join('')}</div>
`;
}
function showEvent(i){const ev=DATA.history.events[i]; openModal(`<div class="modal-head"><div class="modal-title"><h2>${esc(ev.title)}</h2><p>${esc(ev.year)}|${esc(ev.org)}</p></div><button class="close" onclick="closeModal()">×</button></div><div class="modal-body">${section('阶段意义',`<p>${esc(ev.meaning)}</p>`)}${section('事件说明',`<p>${esc(ev.details)}</p>`)}${section('可入库字段', table(['字段','内容'], [['时间',esc(ev.year)],['组织',esc(ev.org)],['事件',esc(ev.title)],['意义',esc(ev.meaning)]]))}</div>`)}
function renderIdentity(){
const d = DATA.identity;
document.getElementById('panel-identity').innerHTML = `
<div class="section-title"><div><h2>人员结构与身份画像</h2><p>${esc(d.core_judgment)}</p></div></div>
<div class="grid cols-3">${d.metrics.map(m=>`<div class="metric"><div class="value">${esc(m.value)}</div><div class="label">${esc(m.label)}</div></div>`).join('')}</div>
<div class="section-title"><div><h2>重点人群分层</h2></div></div>
<div class="grid cols-3">${d.groups.map(g=>`<div class="card"><h3>${esc(g.name)}</h3><p><b>代表组织:</b>${esc(g.orgs)}</p><p><b>画像特征:</b>${esc(g.traits)}</p><p><b>议题倾向:</b>${esc(g.issues)}</p></div>`).join('')}</div>
<div class="section-title"><div><h2>心理结构</h2><p>这一部分用于解释为什么退伍军人身份能够转化为反战、福利、民主保护或身份平权叙事。</p></div></div>
<div class="grid cols-3">${d.psychology.map(p=>`<div class="card"><h3>${esc(p.name)}</h3><p>${esc(p.explain)}</p></div>`).join('')}</div>
`;
}
function renderIssues(){
const d = DATA.issues;
document.getElementById('panel-issues').innerHTML = `
<div class="section-title"><div><h2>核心议题与政策主张</h2><p>${esc(d.core_judgment)}</p></div></div>
<div class="notice"><b>最强共识议题:</b>${d.consensus.map(esc).join('、')}。<br><b>最易分歧议题:</b>${d.divisive.map(esc).join('、')}。</div>
<div class="section-title"><div><h2>组织—议题矩阵</h2><p>点击卡片查看该议题的组织分布、主张方向和分歧点。</p></div></div>
<div class="grid cols-3">${d.matrix.map((m,i)=>`<div class="matrix-card" onclick="showIssue(${i})"><h3>${esc(m.issue)}</h3><div class="orgs">${esc(m.orgs)}</div><p>${esc(m.position)}</p><div class="pill-row"><span class="pill ${m.consensus==='最高'?'green':m.consensus==='低'?'red':'blue'}">共识度:${esc(m.consensus)}</span></div></div>`).join('')}</div>
`;
}
function showIssue(i){const m=DATA.issues.matrix[i]; openModal(`<div class="modal-head"><div class="modal-title"><h2>${esc(m.issue)}</h2><p>${esc(m.orgs)}|共识度:${esc(m.consensus)}</p></div><button class="close" onclick="closeModal()">×</button></div><div class="modal-body">${section('主张方向',`<p>${esc(m.position)}</p>`)}${section('主要组织',`<p>${esc(m.orgs)}</p>`)}${section('潜在分歧',`<p>${esc(m.friction)}</p>`)}${section('页面展示建议',`<p>可在此议题下继续挂接组织声明、国会倡导、社媒发声、候选人广告、公开信和政策文件。</p>`)}</div>`)}
function renderChina(){
const d = DATA.china_taiwan;
document.getElementById('panel-china').innerHTML = `
<div class="section-title"><div><h2>涉中涉台与对外政策立场</h2><p>${esc(d.core_judgment)}</p></div></div>
<div class="grid cols-2">${d.routes.map(r=>`<div class="route"><h3>${esc(r.route)}</h3><div class="orgs">${esc(r.orgs)}</div><p><b>主要立场:</b>${esc(r.stance)}</p><p><b>重点事件:</b>${esc(r.events)}</p><p><b>研究风险:</b>${esc(r.risk)}</p></div>`).join('')}</div>
<div class="section-title"><div><h2>事件级数据库字段</h2><p>涉中涉台不能只按组织印象判断,后续应逐事件收集原文证据。</p></div></div>
${pills(d.event_fields,'blue')}
<div class="section-title"><div><h2>需要重点补充的事件类别</h2></div></div>
<div class="grid cols-3">${d.event_types.map(t=>`<div class="card"><h3>${esc(t.type)}</h3><p>${esc(t.need)}</p></div>`).join('')}</div>
`;
}
function renderNarrative(){
const d = DATA.narratives;
document.getElementById('panel-narrative').innerHTML = `
<div class="section-title"><div><h2>爱国主义重构与政治叙事</h2><p>${esc(d.core_judgment)}</p></div></div>
<div class="grid cols-3">${d.frames.map((f,i)=>`<div class="matrix-card" onclick="showNarrative(${i})"><h3>${esc(f.frame)}</h3><div class="orgs">${esc(f.orgs)}</div><p>${esc(f.meaning)}</p>${pills(f.keywords,'purple')}</div>`).join('')}</div>
<div class="section-title"><div><h2>传统爱国主义 vs 左翼退伍军人叙事</h2></div></div>
${table(['对比项','传统爱国主义叙事','左翼退伍军人叙事'], d.comparison.map(x=>[esc(x.dimension),esc(x.traditional),esc(x.left)]))}
<div class="section-title"><div><h2>社交媒体、认知安全、地方社会与趋势</h2><p>这些内容来自技能文件的扩展模块,用于增强页面信息密度。</p></div></div>
<div class="grid cols-3">
<div class="card"><h3>社交媒体生态</h3>${DATA.media_security_future.media_ecology.map(x=>`<p><b>${esc(x.type)}:</b>${esc(x.platforms)};${esc(x.style)}</p>`).join('')}</div>
<div class="card"><h3>认知安全风险</h3>${list(DATA.media_security_future.cognitive_security)}</div>
<div class="card"><h3>未来十年趋势</h3>${list(DATA.media_security_future.future_trends)}</div>
</div>
<div class="section-title"><div><h2>来源与核验提示</h2></div></div>
<div class="notice source-box">${DATA.sources.map(s=>`<b>${esc(s.name)}:</b>${s.items.map(esc).join('、')}`).join('<br>')}</div>
`;
}
function showNarrative(i){const f=DATA.narratives.frames[i]; openModal(`<div class="modal-head"><div class="modal-title"><h2>${esc(f.frame)}</h2><p>${esc(f.orgs)}</p></div><button class="close" onclick="closeModal()">×</button></div><div class="modal-body">${section('叙事含义',`<p>${esc(f.meaning)}</p>`)}${section('关键词',pills(f.keywords,'purple'))}${section('适合挂接的数据',`<p>可继续挂接组织口号、广告文本、官网使命表述、社媒高频词、演讲、公开信、活动海报和退伍军人证言。</p>`)}</div>`)}
function openModal(html){document.getElementById('modal').innerHTML = html; document.getElementById('modalBackdrop').classList.add('open'); document.body.style.overflow='hidden';}
function closeModal(){document.getElementById('modalBackdrop').classList.remove('open'); document.body.style.overflow='';}
function backdropClose(e){if(e.target.id==='modalBackdrop') closeModal();}
document.addEventListener('keydown',e=>{if(e.key==='Escape') closeModal();});
init();
</script>
</body>
</html>
@@ -0,0 +1,66 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>美国涉华决策咨询群体专题库</title>
<style>
:root{--bg:#f6f7f9;--panel:#fff;--text:#1f2933;--muted:#5b6573;--line:#d8dee8;--red:#8b1e1e;--red2:#5f1515;--gold:#c9a227;--green:#2f6f5e;--blue:#245c7a;--purple:#6b4e9b;--tag:#f1f3f6;--shadow:0 12px 30px rgba(25,35,55,.08);--radius:16px}*{box-sizing:border-box}body{margin:0;font-family:"Microsoft YaHei","PingFang SC","Noto Sans CJK SC",Arial,sans-serif;background:radial-gradient(circle at 12% 0%,rgba(139,30,30,.16),transparent 25%),linear-gradient(180deg,#fff,var(--bg) 280px);color:var(--text)}.app{max-width:1440px;margin:0 auto;padding:24px}.hero{background:linear-gradient(135deg,var(--red2),var(--red));color:#fff;border-radius:24px;padding:28px 32px;box-shadow:var(--shadow);position:relative;overflow:hidden}.hero:after{content:"";position:absolute;right:-80px;top:-80px;width:260px;height:260px;border:36px solid rgba(255,255,255,.08);border-radius:50%}.hero h1{margin:0 0 8px;font-size:32px;letter-spacing:.5px}.hero p{margin:0;color:rgba(255,255,255,.86);line-height:1.65;max-width:980px}.meta-row{display:flex;flex-wrap:wrap;gap:8px;margin-top:18px}.hero-badge{padding:6px 10px;border:1px solid rgba(255,255,255,.35);border-radius:999px;font-size:12px;background:rgba(255,255,255,.08)}.search-panel{margin:18px 0 14px;background:var(--panel);border:1px solid var(--line);border-radius:var(--radius);padding:14px;display:grid;grid-template-columns:1fr auto auto;gap:12px;box-shadow:var(--shadow);align-items:center}.search-panel input,.search-panel select{border:1px solid var(--line);border-radius:12px;padding:12px 14px;font-size:14px;outline:none;background:#fff}.search-panel input:focus,.search-panel select:focus{border-color:rgba(139,30,30,.55);box-shadow:0 0 0 3px rgba(139,30,30,.08)}.tab-bar{display:flex;gap:8px;overflow-x:auto;padding:6px 2px 12px;scrollbar-width:thin}.tab-btn{flex:0 0 auto;border:1px solid var(--line);background:#fff;color:var(--text);border-radius:999px;padding:10px 15px;cursor:pointer;font-size:14px;transition:.2s;white-space:nowrap}.tab-btn:hover{border-color:var(--red);color:var(--red)}.tab-btn.active{background:var(--red);color:#fff;border-color:var(--red)}.content{background:rgba(255,255,255,.74);border:1px solid var(--line);border-radius:24px;padding:18px;box-shadow:var(--shadow);min-height:680px}.summary{display:grid;grid-template-columns:minmax(0,1.2fr) minmax(260px,.8fr);gap:16px;margin-bottom:18px}.summary-card{background:#fff;border:1px solid var(--line);border-radius:var(--radius);padding:18px}.summary-card h2{margin:0 0 8px;font-size:22px}.summary-card p{margin:0;color:var(--muted);line-height:1.75}.stat-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:10px}.stat{border:1px solid var(--line);background:linear-gradient(180deg,#fff,#fafafa);border-radius:14px;padding:12px}.stat b{display:block;color:var(--red);font-size:24px}.stat span{color:var(--muted);font-size:12px}.grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:14px}.grid.two{grid-template-columns:repeat(2,minmax(0,1fr))}.card{background:#fff;border:1px solid var(--line);border-radius:var(--radius);padding:16px;box-shadow:0 4px 14px rgba(25,35,55,.04);transition:.18s;min-height:180px;display:flex;flex-direction:column;gap:10px}.card:hover{transform:translateY(-2px);border-color:rgba(139,30,30,.45);box-shadow:var(--shadow)}.card h3{margin:0;font-size:18px;line-height:1.35}.card .sub,.sub{color:var(--muted);font-size:13px;line-height:1.5}.card p{color:var(--muted);margin:0;line-height:1.7;font-size:14px}.tags{display:flex;flex-wrap:wrap;gap:6px}.tag{display:inline-flex;align-items:center;border-radius:999px;background:var(--tag);color:#3e4652;padding:5px 9px;font-size:12px;max-width:100%}.tag.red{background:rgba(139,30,30,.1);color:var(--red)}.tag.green{background:rgba(47,111,94,.12);color:var(--green)}.tag.blue{background:rgba(36,92,122,.12);color:var(--blue)}.tag.purple{background:rgba(107,78,155,.12);color:var(--purple)}.btn{margin-top:auto;align-self:flex-start;border:1px solid var(--red);color:var(--red);background:#fff;border-radius:999px;padding:8px 12px;cursor:pointer}.btn:hover{background:var(--red);color:#fff}.table-wrap{overflow:auto;border:1px solid var(--line);border-radius:var(--radius);background:#fff}table{width:100%;border-collapse:collapse;font-size:14px}th,td{padding:12px;border-bottom:1px solid var(--line);vertical-align:top;text-align:left}th{background:#fafafa;color:#333;position:sticky;top:0;z-index:1}tr:hover td{background:#fff8f8}.chain{background:#fff;border:1px solid var(--line);border-radius:var(--radius);padding:16px}.chain-title{display:flex;justify-content:space-between;gap:12px;align-items:center}.steps{display:flex;flex-wrap:wrap;gap:8px;margin-top:12px;align-items:center}.step{padding:8px 10px;border:1px solid var(--line);border-radius:10px;background:#fafafa;font-size:13px}.arrow{color:var(--red);font-weight:bold}.role-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:14px}.role-card{background:linear-gradient(180deg,#fff,#fbfbfb);border:1pxLine truncated
</style>
</head>
<body>
<div class="app">
<header class="hero" id="hero"></header>
<section class="search-panel">
<input id="globalSearch" placeholder="搜索机构、人物、议题、政策工具、地方议题..." />
<select id="scopeSelect"><option value="current">当前栏目搜索</option><option value="all">全库搜索</option></select>
<select id="quickFilter"><option value="">全部类型/标签</option></select>
</section>
<nav class="tab-bar" id="tabBar"></nav>
<main class="content"><section id="tabSummary"></section><section id="tabContent"></section></main>
</div>
<div id="detailModal" class="modal hidden"><div class="modal-content"><div class="modal-head"><div><h2 id="modalTitle">详情</h2><div id="modalSubtitle" style="opacity:.85;margin-top:6px;"></div></div><button class="modal-close" onclick="closeModal()">关闭</button></div><div class="modal-body" id="modalBody"></div></div></div>
<script>
const DATA = {"meta": {"title": "美国涉华决策咨询群体专题库", "subtitle": "机构、人物、资金、议题联盟、国家安全化、涉台政策想象、媒体传播与地方嵌入综合展示", "version": "v1.0", "language": "zh-CN", "lastUpdated": "2026-06-24", "coreJudgment": "美国涉华决策咨询群体不是统一指挥的单一集团,而是围绕台湾、科技、供应链、人权、军事安全、地方治理等议题形成的半制度化议题联盟生态。", "dataPrinciples": ["仅使用公开来源研究框架和可核验字段,不纳入个人隐私数据。", "资金关联不等于政策操控,需要结合公开披露、报告内容、听证记录和政策采纳路径交叉判断。", "避免简单使用“亲华/反华”二分标签,改用强竞争、克制、商业稳定、技术安全、人权优先、二轨交流等功能性标签。"], "dataScope": ["国会与准官方机构", "主流战略智库", "强硬派与保守派倡议机构", "克制派与外交派机构", "技术政策机构", "产业协会与商业组织", "人权与价值观组织", "二轨交流机构", "媒体传播节点", "地方政策网络"]}, "tabs": [{"id": "overview", "name": "总体生态", "description": "展示群体边界、生态分层、核心判断和总体运行机制。"}, {"id": "institutions", "name": "机构资料库", "description": "展示核心机构分类、职能、议题、政策倾向和影响路径。"}, {"id": "persons", "name": "人物与旋转门", "description": "展示核心人物、公开履历、机构流动和议题专长。"}, {"id": "funding", "name": "资金利益链", "description": "展示资金类型、产业关联、政府合同、基金会和议题关系。"}, {"id": "alliances", "name": "议题联盟", "description": "展示不同涉华议题上的主推联盟、制衡力量和政策工具。"}, {"id": "securitization", "name": "安全化机制", "description": "展示普通议题如何被转化为国家安全议题。"}, {"id": "taiwan", "name": "涉台政策想象", "description": "展示台湾在美国政策咨询群体中的八重角色。"}, {"id": "policyTools", "name": "政策工具", "description": "展示听证、报告、法案、行政规则、制裁、出口管制等制度化路径。"}, {"id": "media", "name": "媒体传播", "description": "展示智库报告、国会听证、媒体报道和社交平台之间的传播链。"}, {"id": "opinion", "name": "民意反应", "description": "展示美国公众、党派、年龄、企业、亚裔社群等不同群体反应。"}, {"id": "local", "name": "地方嵌入", "description": "展示涉华议题如何进入州议会、农地、大学、采购、港口和养老金。"}, {"id": "trends", "name": "危机与趋势", "description": "展示危机阶段作用、未来十年趋势和长期演化方向。"}], "overview": {"coreJudgment": "美国涉华决策咨询群体不是统一集团,而是由国会机构、准官方委员会、战略智库、保守派倡议网络、克制派智库、技术政策机构、产业协会、人权组织、二轨交流平台、媒体节点和地方政策网络共同构成的复杂生态。", "definition": {"broad": "通过报告、听证、证词、简报、媒体评论、产业游说、二轨交流、地方倡议等方式影响美国对华政策的机构与人物网络。", "narrow": "持续生产涉华政策知识并对国会、行政部门、产业界、媒体或地方政府具有可验证影响力的机构、专家和利益网络。"}, "inclusionCriteria": ["涉华议题持续性", "政策接口清晰", "公开输出稳定", "影响对象明确", "来源可验证", "议题代表性较强", "网络连接性较高"], "exclusionCriteria": ["普通社交媒体用户", "偶发评论者", "无公开资料私人顾问", "阴谋论型自媒体", "个人隐私数据"], "ecosystemLayers": [{"name": "国会与准官方政策知识层", "representativeEntities": ["USCC", "CECC", "CRS", "众议院中共问题特设委员会"], "functions": ["议题设置", "年度报告", "国会听证", "政策建议"], "influenceTargets": ["国会", "行政部门", "媒体", "政策圈"], "summary": "最接近正式政策流程,能够把涉华议题转化为听证、报告、建议和立法语言。"}, {"name": "主流战略智库层", "representativeEntities": ["CSIS", "RAND", "CNAS", "Brookings", "CFR", "Carnegie", "Stimson"], "functions": ["战略研究", "政策分析", "人才储备", "媒体解释"], "influenceTargets": ["政府", "国会", "盟友", "公众"], "summary": "承担政策知识生产、概念生产和专家解释功能,是涉华战略讨论的核心区。"}, {"name": "强硬派与保守派倡议层", "representativeEntities": ["Heritage", "Hudson", "AEI", "Hoover", "FDD", "Project 2049"], "functions": ["强硬政策倡议", "制裁", "脱钩", "军事威慑", "地方反制"], "influenceTargets": ["共和党网络", "州政府", "保守派媒体", "国会"], "summarLine truncated
let currentTab = 'overview', keyword = '', quickFilter = '';
const dataMap = {institutions:'institutions',persons:'persons',funding:'fundingChains',alliances:'issueAlliances',securitization:'securitizationCases',taiwan:'taiwanPolicyImaginations',policyTools:'policyTools',media:'mediaPropagation',opinion:'publicOpinion',local:'localEmbedding'};
function arr(v){return Array.isArray(v)?v:(v?[v]:[])}
function escapeHtml(str){return String(str??'').replace(/[&<>"']/g,s=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[s]))}
function tagClass(t){const s=String(t); if(s.includes('强')||s.includes('安全')||s.includes('威慑')||s.includes('制裁'))return'red'; if(s.includes('克制')||s.includes('稳定')||s.includes('交流')||s.includes('外交'))return'green'; if(s.includes('技术')||s.includes('AI')||s.includes('芯片')||s.includes('数据'))return'blue'; if(s.includes('人权')||s.includes('民主')||s.includes('价值'))return'purple'; return''}
function tags(list,limit=8){return `<div class="tags">${arr(list).slice(0,limit).map(x=>`<span class="tag ${tagClass(x)}">${escapeHtml(x)}</span>`).join('')}</div>`}
function includesKeyword(item){if(!keyword)return true; return JSON.stringify(item).toLowerCase().includes(keyword.toLowerCase())}
function matchesFilter(item){return !quickFilter || JSON.stringify(item).includes(quickFilter)}
function filterList(list){return list.filter(x=>includesKeyword(x)&&matchesFilter(x))}
function allItems(){let groups=['institutions','persons','fundingChains','issueAlliances','securitizationCases','taiwanPolicyImaginations','policyTools','mediaPropagation','publicOpinion','localEmbedding']; let all=[]; groups.forEach(g=>DATA[g].forEach(x=>all.push(x))); DATA.crisisAndTrends.futureTrends.forEach(x=>all.push(x)); return all}
function findItem(id){return allItems().find(x=>x.id===id)}
function titleOf(x){return x.name||x.issue||x.topic||x.role||x.group||x.trend||x.originalIssue||'详情'}
function init(){renderHero();renderTabs();renderQuickFilterOptions();document.getElementById('globalSearch').addEventListener('input',e=>{keyword=e.target.value.trim();renderTab()});document.getElementById('scopeSelect').addEventListener('change',renderTab);document.getElementById('quickFilter').addEventListener('change',e=>{quickFilter=e.target.value;renderTab()});renderTab()}
function renderHero(){const m=DATA.meta;document.getElementById('hero').innerHTML=`<h1>${m.title}</h1><p>${m.subtitle}</p><div class="meta-row"><span class="hero-badge">版本:${m.version}</span><span class="hero-badge">更新时间:${m.lastUpdated}</span><span class="hero-badge">数据范围:${m.dataScope.length} 类主体</span><span class="hero-badge">核心判断:半制度化议题联盟生态</span></div>`}
function renderTabs(){document.getElementById('tabBar').innerHTML=DATA.tabs.map(t=>`<button class="tab-btn ${t.id===currentTab?'active':''}" onclick="switchTab('${t.id}')">${t.name}</button>`).join('')}
function switchTab(id){currentTab=id;renderTabs();renderQuickFilterOptions();renderTab()}
function renderQuickFilterOptions(){const select=document.getElementById('quickFilter'); const key=dataMap[currentTab]; let list=key?DATA[key]:(currentTab==='trends'?DATA.crisisAndTrends.futureTrends:[]); const opts=new Set(); list.forEach(item=>{['type','subType','priority','securityLevel','localLevel','rotationStrength','status'].forEach(k=>item[k]&&opts.add(item[k])); arr(item.policyOrientation).forEach(x=>opts.add(x)); arr(item.personTypes).forEach(x=>opts.add(x));}); select.innerHTML='<option value="">全部类型/标签</option>'+[...opts].sort().map(o=>`<option value="${escapeHtml(o)}">${escapeHtml(o)}</option>`).join(''); quickFilter=''}
function renderSummary(){const t=DATA.tabs.find(x=>x.id===currentTab); const stat=Object.entries(DATA.statistics).slice(0,6).map(([k,v])=>`<div class="stat"><b>${v}</b><span>${k}</span></div>`).join(''); document.getElementById('tabSummary').innerHTML=`<div class="summary"><div class="summary-card"><h2>${t.name}</h2><p>${t.description}</p></div><div class="summary-card"><div class="stat-grid">${stat}</div></div></div>`}
function renderTab(){renderSummary(); if(document.getElementById('scopeSelect').value==='all'&&keyword){renderGlobalResults();return} if(currentTab==='overview')renderOverview(); else if(currentTab==='institutions')renderInstitutions(); else if(currentTab==='persons')renderPersons(); else if(currentTab==='funding')renderFunding(); else if(currentTab==='alliances')renderAlliances(); else if(currentTab==='securitization')renderSecuritization(); else if(currentTab==='taiwan')renderTaiwan(); else if(currentTab==='policyTools')renderPolicyTools(); else if(currentTab==='media')renderMedia(); else if(currentTab==='opinion')renderOpinion(); else if(currentTab==='local')renderLocal(); else if(currentTab==='trends')renderTrends()}
function renderOverview(){const o=DATA.overview; const layers=filterList(o.ecosystemLayers); document.getElementById('tabContent').innerHTML=`<div class="notice">${DATA.meta.dataPrinciples.join(' ')}</div><div class="grid">${layers.map((x,i)=>`<div class="card"><h3>${x.name}</h3><div class="sub">代表主体:${x.representativeEntities.join('、')}</div>${tags(x.functions)}<p>${x.summary}</p><button class="btn" onclick="openLayer(${i})">查看详情</button></div>`).join('')}</div><h2 style="margin:24px 0 12px;">运行机制</h2><div class="grid">${o.mechanisms.map(m=>`<div class="chain"><div class="chain-title"><h3>${m.name}</h3></div><div class="steps">${m.steps.map((s,i)=>`<span class="step">${s}</span>${i<m.steps.length-1?'<span class="arrow">→</span>':''}`).join('')}</div></div>`).join('')}</div>`}
function openLayer(i){openModal('生态层详情',DATA.overview.ecosystemLayers[i])}
function renderInstitutions(){const list=filterList(DATA.institutions); document.getElementById('tabContent').innerHTML=list.length?`<div class="grid">${list.map(x=>`<div class="card"><h3>${x.shortName}|${x.name}</h3><div class="sub">${x.englishName}</div>${tags([x.type,x.subType,x.priority])}${tags(x.policyOrientation)}<p>${x.summary}</p><div class="sub">涉华:${arr(x.chinaIssues).slice(0,5).join('、')}</div><div class="sub">涉台:${arr(x.taiwanIssues).slice(0,4).join('、')}</div><button class="btn" onclick="openById('机构详情','${x.id}')">查看详情</button></div>`).join('')}</div>`:'<div class="empty">没有匹配的机构数据</div>'}
function renderPersons(){const list=filterList(DATA.persons); document.getElementById('tabContent').innerHTML=list.length?`<div class="grid">${list.map(x=>`<div class="card"><h3>${x.name}${x.chineseName?'|'+x.chineseName:''}</h3><div class="sub">${x.currentRole}</div>${tags(x.personTypes)}${tags([x.rotationStrength,...arr(x.policyOrientation)],6)}<p>${x.summary}</p><div class="sub">关联机构:${arr(x.relatedInstitutions).slice(0,5).join('、')}</div><button class="btn" onclick="openById('人物详情','${x.id}')">查看详情</button></div>`).join('')}</div>`:'<div class="empty">没有匹配的人物数据</div>'}
function renderFunding(){const list=filterList(DATA.fundingChains); document.getElementById('tabContent').innerHTML=`<div class="notice">资金关联不等于政策操控。页面仅展示公开研究中可作为线索的资金、项目、机构与议题关系,具体影响需要结合公开披露、报告内容、听证记录和政策采纳路径交叉判断。</div><div class="funding-layout"><div class="funding-legend"><span class="tag red">资金来源</span><span class="tag blue">相关机构</span><span class="tag purple">关联议题</span><span class="tag green">政策输出</span></div>${list.map(x=>`<div class="funding-map-card"><div class="funding-map-head"><div><h3>${x.name}</h3><p class="funding-map-sub">${x.summary}</p></div><span class="tag ${tagClass(x.evidenceStrength)}">证据强度:${x.evidenceStrength}</span></div><div class="funding-topline">${tags([...(arr(x.fundingSources).slice(0,2).map(i=>'资金线索|'+i)),...(arr(x.relatedInstitutions).slice(0,2).map(i=>'机构节点|'+i)),...(arr(x.relatedIssues).slice(0,2).map(i=>'议题节点|'+i))],6)}</div><div class="funding-graph"><div class="funding-col col-source"><div class="funding-col-title"><span>资金来源</span><span class="funding-col-index">01</span></div><div class="funding-node-list">${arr(x.fundingSources).map(i=>`<span class="funding-node source">${i}</span>`).join('')}</div></div><div class="funding-link"><span>→</span></div><div class="funding-col col-inst"><div class="funding-col-title"><span>相关机构</span><span class="funding-col-index">02</span></div><div class="funding-node-list">${arr(x.relatedInstitutions).map(i=>`<span class="funding-node inst">${i}</span>`).join('')}</div></div><div class="funding-link"><span>→</span></div><div class="funding-col col-issue"><div class="funding-col-title"><span>关联议题</span><span class="funding-col-index">03</span></div><div class="funding-node-list">${arr(x.relatedIssues).map(i=>`<span class="funding-node issue">${i}</span>`).join('')}</div></div><div class="funding-link"><span>→</span></div><div class="funding-col col-output"><div class="funding-col-title"><span>政策输出</span><span class="funding-col-index">04</span></div><div class="funding-node-list">${arr(x.policyOutputs).map(i=>`<span class="funding-node output">${i}</span>`).join('')}</div></div></div><div class="funding-note"><b>分析提示:</b>${x.riskNotes}</div><div class="funding-actions"><button class="btn" onclick="openById('资金链详情','${x.id}')">查看详情</button></div></div>`).join('')}</div>`}
function renderAlliances(){const list=filterList(DATA.issueAlliances); document.getElementById('tabContent').innerHTML=`<div class="table-wrap"><table><thead><tr><th>议题</th><th>主推联盟</th><th>制衡联盟</th><th>代表机构</th><th>政策工具</th><th>分歧轴</th><th>详情</th></tr></thead><tbody>${list.map(x=>`<tr><td><b>${x.issue}</b><br><span class="sub">${x.summary}</span></td><td>${tags(x.mainAlliance)}</td><td>${tags(x.counterAlliance)}</td><td>${arr(x.representativeInstitutions).join('、')}</td><td>${tags(x.policyTools)}</td><td>${arr(x.disputeAxes).join('<br>')}</td><td><button class="btn" onclick="openById('议题联盟详情','${x.id}')">查看</button></td></tr>`).join('')}</tbody></table></div>`}
function renderSecuritization(){const list=filterList(DATA.securitizationCases); document.getElementById('tabContent').innerHTML=`<div class="grid two">${list.map(x=>`<div class="chain"><div class="chain-title"><h3>${x.originalIssue} → ${x.securitizedIssue}</h3><span class="tag red">${x.securityLevel}</span></div><p style="color:var(--muted);line-height:1.7;">${x.summary}</p><div class="steps">${arr(x.steps).map((s,i)=>`<span class="step">${s}</span>${i<x.steps.length-1?'<span class="arrow">→</span>':''}`).join('')}</div><button class="btn" onclick="openById('安全化案例详情','${x.id}')">查看详情</button></div>`).join('')}</div>`}
function renderTaiwan(){const list=filterList(DATA.taiwanPolicyImaginations); document.getElementById('tabContent').innerHTML=`<div class="role-grid">${list.map(x=>`<div class="role-card" onclick="openById('涉台角色详情','${x.id}')"><h3>${x.role}</h3><p style="color:var(--muted);line-height:1.7;">${x.meaning}</p>${tags(x.policyImplications,4)}<p style="margin-top:10px;color:var(--muted);">${x.summary}</p></div>`).join('')}</div>`}
function renderPolicyTools(){const list=filterList(DATA.policyTools); document.getElementById('tabContent').innerHTML=`<div class="grid">${list.map(x=>`<div class="card"><h3>${x.name}</h3>${tags([x.type])}${tags(x.applicableIssues,5)}<p>${x.summary}</p><div class="sub">路径:${arr(x.institutionalPath).join(' → ')}</div><button class="btn" onclick="openById('政策工具详情','${x.id}')">查看详情</button></div>`).join('')}</div>`}
function renderMedia(){const list=filterList(DATA.mediaPropagation); document.getElementById('tabContent').innerHTML=`<div class="grid two">${list.map(x=>`<div class="chain"><div class="chain-title"><h3>${x.topic}</h3></div><p style="color:var(--muted);line-height:1.7;">${x.summary}</p><div class="steps"><span class="step">${x.sourceStage}</span><span class="arrow">→</span><span class="step">媒体/平台</span><span class="arrow">→</span><span class="step">公众认知</span><span class="arrow">→</span><span class="step">政策反馈</span></div>${tags(x.commonFrames,6)}<button class="btn" onclick="openById('传播议题详情','${x.id}')">查看详情</button></div>`).join('')}</div>`}
function renderOpinion(){const list=filterList(DATA.publicOpinion); document.getElementById('tabContent').innerHTML=`<div class="grid">${list.map(x=>`<div class="card"><h3>${x.group}</h3><p>${x.attitude}</p>${tags(x.relatedIssues,6)}<div class="sub">可能反应:${arr(x.potentialReaction).join(';')}</div><button class="btn" onclick="openById('群体反应详情','${x.id}')">查看详情</button></div>`).join('')}</div>`}
function renderLocal(){const list=filterList(DATA.localEmbedding); document.getElementById('tabContent').innerHTML=`<div class="grid">${list.map(x=>`<div class="card"><h3>${x.issue}</h3>${tags([x.localLevel,x.status])}${tags(x.securityFrame,4)}<p>${x.summary}</p><div class="sub">政策工具:${arr(x.policyTools).join('、')}</div><button class="btn" onclick="openById('地方嵌入详情','${x.id}')">查看详情</button></div>`).join('')}</div>`}
function renderTrends(){const stages=DATA.crisisAndTrends.crisisStages.filter(x=>includesKeyword(x)&&matchesFilter(x)); const trends=DATA.crisisAndTrends.futureTrends.filter(x=>includesKeyword(x)&&matchesFilter(x)); document.getElementById('tabContent').innerHTML=`<h2>危机阶段作用</h2><div class="timeline">${stages.map(x=>`<div class="timeline-item"><h3>${x.stage}</h3><p style="color:var(--muted);line-height:1.7;">${x.summary}</p>${tags(x.consultingGroupRoles)}<div class="sub">代表主体:${arr(x.representativeActors).join('、')}</div></div>`).join('')}</div><h2 style="margin-top:24px;">未来十年趋势</h2><div class="grid">${trends.map(x=>`<div class="card"><h3>${x.trend}</h3><p>${x.description}</p>${tags(x.relatedIssues)}<div class="sub">代表机构:${arr(x.representativeInstitutions).join('、')}</div><button class="btn" onclick="openById('趋势详情','${x.id}')">查看详情</button></div>`).join('')}</div>`}
function renderGlobalResults(){const results=allItems().filter(x=>includesKeyword(x)&&matchesFilter(x)); document.getElementById('tabContent').innerHTML=`<div class="notice">全库搜索结果:${results.length} 条。点击卡片查看二级详情。</div><div class="grid">${results.map(x=>`<div class="card"><h3>${titleOf(x)}</h3>${tags([typeLabel(x)])}<p>${x.summary||x.description||x.attitude||''}</p><button class="btn" onclick="openById('${typeLabel(x)}详情','${x.id}')">查看详情</button></div>`).join('')||'<div class="empty">没有匹配结果</div>'}</div>`}
function typeLabel(x){if(DATA.institutions.includes(x))return'机构'; if(DATA.persons.includes(x))return'人物'; if(DATA.fundingChains.includes(x))return'资金链'; if(DATA.issueAlliances.includes(x))return'议题联盟'; if(DATA.securitizationCases.includes(x))return'安全化案例'; if(DATA.taiwanPolicyImaginations.includes(x))return'涉台角色'; if(DATA.policyTools.includes(x))return'政策工具'; if(DATA.mediaPropagation.includes(x))return'媒体传播'; if(DATA.publicOpinion.includes(x))return'民意反应'; if(DATA.localEmbedding.includes(x))return'地方嵌入'; return'趋势'}
function openById(kind,id){const item=findItem(id); if(item)openModal(kind,item)}
function openModal(title,item){document.getElementById('modalTitle').textContent=title+'|'+titleOf(item); document.getElementById('modalSubtitle').textContent=item.summary||item.description||item.attitude||''; const skip=new Set(['id','summary']); const body=[]; for(const [k,v] of Object.entries(item)){if(skip.has(k))continue; if(k==='careerPath'&&Array.isArray(v)){body.push(`<div class="detail-section"><h4>公开履历 / 旋转门路径</h4><div class="timeline">${v.map(c=>`<div class="timeline-item"><b>${escapeHtml(c.period)}</b>|${escapeHtml(c.institution)}|${escapeHtml(c.role)}<br><span class="sub">${escapeHtml(c.sector)}</span></div>`).join('')}</div></div>`)} else if(Array.isArray(v)){body.push(`<div class="detail-section"><h4>${fieldName(k)}</h4>${tags(v,80)}</div>`)} else if(typeof v==='object'&&v){body.push(`<div class="detail-section"><h4>${fieldName(k)}</h4><pre style="white-space:pre-wrap;line-height:1.6;background:#fafafa;border:1px solid var(--line);border-radius:12px;padding:12px;">${escapeHtml(JSON.stringify(v,null,2))}</pre></div>`)} else {body.push(`<div class="detail-section"><div class="kv"><div class="k">${fieldName(k)}</div><div>${escapeHtml(v)}</div></div></div>`)}} document.getElementById('modalBody').innerHTML=body.join(''); document.getElementById('detailModal').classList.remove('hidden')}
function closeModal(){document.getElementById('detailModal').classList.add('hidden')}
function fieldName(k){const map={name:'名称',englishName:'英文名称',shortName:'简称',type:'类型',subType:'子类型',policyOrientation:'政策倾向',chinaIssues:'涉华议题',taiwanIssues:'涉台议题',mainOutputs:'主要输出',influenceTargets:'影响对象',priority:'优先级',currentInstitution:'当前机构',currentRole:'当前职务',personTypes:'人物类型',careerPath:'公开履历',relatedInstitutions:'关联机构',influenceChannels:'影响渠道',rotationStrength:'旋转门强度',fundingSources:'资金来源',relatedIssues:'关联议题',policyOutputs:'政策输出',evidenceStrength:'证据强度',riskNotes:'风险提示',mainAlliance:'主推联盟',counterAlliance:'制衡联盟',representativeInstitutions:'代表机构',representativePersons:'代表人物',policyTools:'政策工具',disputeAxes:'分歧轴',originalIssue:'原始议题',securitizedIssue:'安全化后议题',steps:'过程链条',keyActors:'关键行为体',securityLevel:'安全化程度',role:'角色',meaning:'含义',representativeGroups:'代表群体',policyImplications:'政策含义',counterViews:'制衡观点',mainActors:'主要行为体',applicableIssues:'适用议题',institutionalPath:'制度化路径',policyEffects:'政策效果',topic:'主题',sourceStage:'传播起点',sourceActors:'源头行为体',amplificationChannels:'放大渠道',commonFrames:'常见框架',targetAudiences:'目标受众',feedbackToPolicy:'政策反馈',group:'群体',attitude:'态度',potentialReaction:'可能反应',balancingFactors:'制衡因素',localLevel:'地方层级',securityFrame:'安全化框架',counterActors:'反向制衡',controversies:'争议点',status:'状态',trend:'趋势',description:'说明'}; return map[k]||k}
document.getElementById('detailModal').addEventListener('click',e=>{if(e.target.id==='detailModal')closeModal()}); init();
</script>
</body>
</html>
@@ -31,12 +31,12 @@ dependencies = [
"ddgs>=9.10.0",
"duckdb>=1.4.4",
"langchain-google-genai>=4.2.1",
"langgraph-checkpoint-sqlite>=3.0.3",
"langgraph-checkpoint-postgres>=3.0.5",
"langgraph-sdk>=0.1.51",
"psycopg[binary]>=3.3.3",
"psycopg-pool>=3.3.0",
"sqlalchemy[asyncio]>=2.0,<3.0",
"langgraph-checkpoint-sqlite>=3.0.3",
"langgraph-checkpoint-postgres>=3.0.5",
"langgraph-sdk>=0.1.51",
"psycopg[binary]>=3.3.3",
"psycopg-pool>=3.3.0",
"sqlalchemy[asyncio]>=2.0,<3.0",
"aiosqlite>=0.19",
"alembic>=1.13",
]
@@ -44,14 +44,14 @@ dependencies = [
[project.optional-dependencies]
hindsight = ["hindsight-client>=0.4.22"]
ollama = ["langchain-ollama>=0.3.0"]
postgres = [
"asyncpg>=0.29",
"langgraph-checkpoint-postgres>=3.0.5",
"psycopg[binary]>=3.3.3",
"psycopg-pool>=3.3.0",
]
mysql = ["asyncmy>=0.2.10"]
pymupdf = ["pymupdf4llm>=0.0.17"]
postgres = [
"asyncpg>=0.29",
"langgraph-checkpoint-postgres>=3.0.5",
"psycopg[binary]>=3.3.3",
"psycopg-pool>=3.3.0",
]
mysql = ["asyncmy>=0.2.10"]
pymupdf = ["pymupdf4llm>=0.0.17"]
[build-system]
requires = ["hatchling"]
@@ -59,3 +59,8 @@ build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["deerflow"]
# 内置 HTML 模板(no.1/no.2 等,非 .py 资源)显式纳入打包,保证任何构建方式(wheel/sdist)
# 都带上,新机器装好即可直接看到这些模板。
[tool.hatch.build]
artifacts = ["deerflow/runtime/scheduler/templates/**/*.html"]