Files
writer-work-flow/apps/web/components/codex/CodexPage.tsx
Yaojia Wang 3d2a8b8cee feat(web): 角色关系图谱(灵感⑥缩减版/D4)
设定库人物 tab 新增 React Flow 关系图谱,仅用现有角色读端点数据
(CharacterRelationView={name,kind,note}):边按 name-join、kind 作中文
标签、节点按 role 上色,无 closeness/阵营(数据不存在,不硬造)。

- lib/characters/graphLayout.ts:纯逻辑(role→分组配色关键词映射 / 节点
  去空重名 / kind→标签+DEFAULT_RELATION_LABEL / 悬空边跳过并计数 / 自指
  跳过 / 互指无向去重 / @dagrejs/dagre 一次性确定性初始坐标、中心→左上、
  不可变更新)+22 单测。
- components/characters/RelationshipGraph.tsx:'use client',经 CodexPage
  dynamic(ssr:false) 挂载;显式容器高度 + import RF CSS + prefers-reduced-
  motion 关 fitView 动画 + 纸感 CSS 变量配色 + 悬空计数提示。
- 依赖:@xyflow/react@^12.11.2 + @dagrejs/dagre@^3.0.0。

无后端契约变更/迁移/gen:api。门禁:lint/typecheck 干净、vitest 565、
coverage lib 95.4%(graphLayout 97.6%)、build OK。
2026-07-06 17:38:58 +02:00

262 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { useMemo, useState } from "react";
import dynamic from "next/dynamic";
import { Clock3, Globe2, Share2, Sparkles, UserRound } from "lucide-react";
import { AppShell } from "@/components/AppShell";
import { CharacterGenerator } from "@/components/generation/CharacterGenerator";
import { WorldGenerator } from "@/components/generation/WorldGenerator";
import { Badge } from "@/components/ui/Badge";
import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card";
import { EmptyState } from "@/components/ui/EmptyState";
import { PageHeader } from "@/components/ui/PageHeader";
import { SegmentedControl } from "@/components/ui/SegmentedControl";
import type {
CharacterCardView,
ProjectResponse,
WorldEntityCardView,
} from "@/lib/api/types";
import {
mergeCharacterCards,
worldEntityRules,
} from "@/lib/generation/cards";
type CodexTab = "characters" | "world" | "timeline";
interface CodexPageProps {
project: ProjectResponse;
// 后端读端点GET .../characters / .../world_entities拉来的跨会话全量真源。
initialCharacters: CharacterCardView[];
initialWorldEntities: WorldEntityCardView[];
// 进页可经 ?gen=character|world 直接打开对应生成器(命令面板动作跳转)。
initialTab?: CodexTab;
}
const TABS: { key: CodexTab; label: string }[] = [
{ key: "characters", label: "人物" },
{ key: "world", label: "世界观" },
{ key: "timeline", label: "时间线" },
];
// 关系图谱React Flow只在客户端渲染ssr:false 绕开 canvas/window 依赖,
// 并把 React Flow 及其样式拆到独立 chunk进人物 tab 才加载。
const RelationshipGraph = dynamic(
() =>
import("@/components/characters/RelationshipGraph").then(
(mod) => mod.RelationshipGraph,
),
{
ssr: false,
loading: () => (
<div className="flex h-[28rem] items-center justify-center rounded border border-line bg-bg text-sm text-ink-soft">
</div>
),
},
);
// 设定库 CodexUX §6.5):管理人物 / 世界观 / 时间线。
// 初始列表来自后端读端点(跨会话全量真源);本会话新入库的角色卡再合并补显,
// 故刷新后不再为空、且不与真源重复(按 name 去重,见 mergeCharacterCards
// 世界观本期无入库端点(仅生成预览)→ 展示真源 + 生成器预览。时间线为 P2/派生,暂占位。
export function CodexPage({
project,
initialCharacters,
initialWorldEntities,
initialTab = "characters",
}: CodexPageProps) {
const [tab, setTab] = useState<CodexTab>(initialTab);
// 本会话内新入库的角色(即时回显,无需刷新即见)。
const [sessionCards, setSessionCards] = useState<CharacterCardView[]>([]);
// 真源(初始全量)+ 本会话新卡,按 name 去重合并。
const characters = useMemo(
() => mergeCharacterCards(initialCharacters, sessionCards),
[initialCharacters, sessionCards],
);
return (
<AppShell
title={`${project.title}`}
subtitle="设定库"
projectId={project.id}
activeNav="codex"
>
<div className="flex h-[calc(100vh-var(--chrome,4rem))] flex-col p-4">
<PageHeader
title="设定库"
description="人物、世界观与时间线共同构成写作时的真相源。AI 生成内容也要先预览、再确认入库。"
/>
<SegmentedControl
className="mb-4 w-fit"
ariaLabel="设定库分区"
value={tab}
onChange={setTab}
options={TABS.map((t) => ({ value: t.key, label: t.label }))}
/>
<div className="min-h-0 flex-1 overflow-auto">
{tab === "characters" ? (
<div className="flex flex-col gap-4">
<Card as="section" className="p-4">
<h3 className="mb-3 font-serif text-sm text-ink">
{characters.length}
</h3>
{characters.length > 0 ? (
<ul className="grid grid-cols-1 gap-3 lg:grid-cols-2">
{characters.map((c, i) => (
<li
key={`${c.name}-${i}`}
className="rounded border border-line bg-bg p-3 text-sm"
>
<div className="mb-2 flex items-center gap-2">
<span className="flex h-8 w-8 items-center justify-center rounded bg-[var(--color-cinnabar-wash)] text-cinnabar">
<UserRound className="h-4 w-4" aria-hidden="true" />
</span>
<div className="min-w-0">
<p className="truncate font-serif text-base text-ink">
{c.name}
</p>
<p className="text-xs text-ink-soft">{c.role}</p>
</div>
</div>
{c.relations && c.relations.length > 0 ? (
<ul className="mt-1 flex flex-wrap gap-1">
{c.relations.map((r, j) => (
<li
key={`${r.name}-${r.kind}-${j}`}
className="rounded bg-panel px-1.5 py-0.5 text-2xs"
title={r.note ?? undefined}
>
{r.kind} · {r.name}
</li>
))}
</ul>
) : null}
</li>
))}
</ul>
) : (
<EmptyState
icon={UserRound}
title="暂无入库人物"
description="先生成或手动整理主角、配角与关系,再让写作上下文稳定引用。"
action={
<Button
onClick={() =>
document
.getElementById("character-generator")
?.scrollIntoView({ block: "center" })
}
variant="primary"
size="sm"
>
<Sparkles className="h-4 w-4" aria-hidden="true" />
</Button>
}
className="bg-bg/50"
/>
)}
</Card>
{characters.length > 0 ? (
<Card as="section" className="p-4">
<h3 className="mb-3 flex items-center gap-2 font-serif text-sm text-ink">
<Share2 className="h-4 w-4 text-cinnabar" aria-hidden="true" />
</h3>
<RelationshipGraph characters={characters} />
</Card>
) : null}
<div id="character-generator">
<CharacterGenerator
projectId={project.id}
onIngested={(_, cards) =>
setSessionCards((prev) => [...prev, ...cards])
}
/>
</div>
</div>
) : null}
{tab === "world" ? (
<div className="flex flex-col gap-4">
<Card as="section" className="p-4">
<h3 className="mb-3 font-serif text-sm text-ink">
{initialWorldEntities.length}
</h3>
{initialWorldEntities.length > 0 ? (
<div className="grid grid-cols-1 gap-3 lg:grid-cols-2">
{initialWorldEntities.map((entity, i) => (
<article
key={`${entity.name}-${i}`}
className="rounded border border-line bg-bg p-3 text-sm"
>
<header className="mb-2 flex items-center gap-2">
<Globe2
className="h-4 w-4 text-cinnabar"
aria-hidden="true"
/>
<span className="font-serif text-base text-ink">
{entity.name}
</span>
<Badge>
{entity.type}
</Badge>
</header>
{worldEntityRules(entity).length > 0 ? (
<ul className="flex list-disc flex-col gap-1 pl-5 text-ink-soft">
{worldEntityRules(entity).map((rule, j) => (
<li key={j}>{rule}</li>
))}
</ul>
) : (
<p className="text-ink-soft"></p>
)}
</article>
))}
</div>
) : (
<EmptyState
icon={Globe2}
title="暂无入库世界观"
description="先沉淀硬规则、地理、势力与能力边界,减少后续章节设定漂移。"
action={
<Button
onClick={() =>
document
.getElementById("world-generator")
?.scrollIntoView({ block: "center" })
}
variant="primary"
size="sm"
>
<Sparkles className="h-4 w-4" aria-hidden="true" />
</Button>
}
className="bg-bg/50"
/>
)}
</Card>
<div id="world-generator">
<WorldGenerator projectId={project.id} />
</div>
</div>
) : null}
{tab === "timeline" ? (
<EmptyState
icon={Clock3}
title="时间线尚未启用"
description="时间线会从章节摘要、伏笔窗口和验收记录派生,后续可用于全书一致性扫描。"
/>
) : null}
</div>
</div>
</AppShell>
);
}