From 2c9b034a29a74864dd9b53349e0985fd9bb42f9b Mon Sep 17 00:00:00 2001 From: kun1h Date: Wed, 8 Jul 2026 10:07:51 +0800 Subject: [PATCH] docs: add P7 character graph and P9 writing analytics implementation plan for v1.0.0 Break down writing_sessions telemetry, Cytoscape graph UI, and cockpit analytics into twelve implementation tasks. Co-authored-by: Cursor --- .../2026-07-08-bilin-p7-graph-p9-analytics.md | 741 ++++++++++++++++++ 1 file changed, 741 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-08-bilin-p7-graph-p9-analytics.md diff --git a/docs/superpowers/plans/2026-07-08-bilin-p7-graph-p9-analytics.md b/docs/superpowers/plans/2026-07-08-bilin-p7-graph-p9-analytics.md new file mode 100644 index 0000000..2655ba2 --- /dev/null +++ b/docs/superpowers/plans/2026-07-08-bilin-p7-graph-p9-analytics.md @@ -0,0 +1,741 @@ +# 笔临 P7 角色关系图谱 + P9 个人写作分析 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 交付 v1.0.0:§7 角色关系图谱(Cytoscape + 设定关系 CRUD)+ §9 个人写作分析(`writing_sessions` 驱动速度/时段热力 + POV/卷分布),驾驶舱内嵌展示。 + +**Architecture:** 全局 `writing_logs.sqlite` 扩展 `writing_sessions` 表;`ChapterWritingTracker.recordDelta` 同步写 session;`CharacterGraphService` 从 `settings.properties.relationships` 构建图 DTO;`WritingAnalyticsService` 聚合 sessions/logs/pomodoro/章节;渲染进程 Cytoscape 模态 + 驾驶舱分析组件并行 IPC 请求。 + +**Tech Stack:** Electron 36、electron-vite、React 18、TypeScript、node:sqlite、Cytoscape.js、cytoscape-cose-bilkent、Zustand、i18next、Vitest、Playwright + +**Spec:** `docs/superpowers/specs/2026-07-08-bilin-p7-graph-p9-analytics-design.md` + +--- + +## File Map + +| 文件 | 职责 | +|------|------| +| `src/shared/types.ts` | `CharacterRelationship`、`CharacterGraph*`、`WritingAnalyticsSummary` | +| `src/shared/ipc-channels.ts` | graph.* / analytics.summary | +| `src/main/db/repositories/writing-log.repo.ts` | migrate 扩展 writing_sessions | +| `src/main/db/repositories/writing-session.repo.ts` | session CRUD | +| `src/main/db/repositories/chapter.repo.ts` | povCharacterId mapRow/update | +| `src/main/services/writing-session.service.ts` | 5min idle 状态机 | +| `src/main/services/writing-analytics.service.ts` | getSummary 聚合 | +| `src/main/services/character-graph.service.ts` | 图谱 DTO + 关系 CRUD | +| `src/main/services/chapter-writing-tracker.service.ts` | recordDelta → session | +| `src/main/ipc/handlers/graph.handler.ts` | 图谱 IPC | +| `src/main/ipc/handlers/analytics.handler.ts` | 分析 IPC | +| `src/main/ipc/register.ts` | 注入新服务 | +| `src/preload/index.ts` + `electron-api.d.ts` | graph / analytics API | +| `src/renderer/components/graph/CharacterGraphModal.tsx` | Cytoscape UI | +| `src/renderer/stores/useGraphStore.ts` | 图谱模态状态 | +| `src/renderer/components/setting/SettingRelationshipsEditor.tsx` | 关系 CRUD | +| `src/renderer/components/cockpit/CockpitAnalytics.tsx` | 分析卡片 | +| `src/renderer/components/cockpit/HourlyHeatmap.tsx` | 24 槽热力 | +| `src/renderer/components/cockpit/CockpitModal.tsx` | 嵌入分析区 + 打开图谱 | +| `src/renderer/components/layout/EditorLayout.tsx` | chapter-pov-select | +| `src/renderer/components/setting/SettingList.tsx` | setting-open-graph | +| `src/renderer/styles/layout.css` | graph / analytics 样式 | +| `public/locales/*/translation.json` | graph / analytics / relationship / chapter.pov | +| `tests/main/writing-session.test.ts` | UT-SESSION-* | +| `tests/main/character-graph.test.ts` | UT-GRAPH-* / IT-GRAPH-01 | +| `tests/main/writing-analytics.test.ts` | UT-ANALYTICS-* / IT-ANALYTICS-01 | +| `e2e/character-graph.spec.ts` | E2E-GRAPH-* | +| `e2e/writing-analytics.spec.ts` | E2E-ANALYTICS-* | + +--- + +## Task 1: 类型 + IPC 通道 + +**Files:** +- Modify: `src/shared/types.ts` +- Modify: `src/shared/ipc-channels.ts` + +- [ ] **Step 1: 扩展 types** + +```typescript +export interface CharacterRelationship { + id: string + targetId: string + label: string + intimacy: 1 | 2 | 3 | 4 | 5 +} + +export interface CharacterGraphNode { + id: string + label: string +} + +export interface CharacterGraphEdge { + id: string + source: string + target: string + label: string + intimacy: 1 | 2 | 3 | 4 | 5 +} + +export interface CharacterGraphData { + nodes: CharacterGraphNode[] + edges: CharacterGraphEdge[] + truncated: boolean + nodeCount: number +} + +export interface WritingAnalyticsSummary { + todayWords: number + todaySpeedWpm: number | null + weekTrend: { date: string; words: number }[] + hourlyHeatmap: number[] + pomodoroToday: number + pomodoroAvgWords: number | null + povDistribution: { characterId: string; name: string; words: number }[] + volumeDistribution: { volumeId: string; title: string; words: number }[] +} +``` + +在 `SettingEntry.properties` 使用文档约定 `relationships?: CharacterRelationship[]`(JSDoc 或 inline 注释即可)。 + +- [ ] **Step 2: IPC 通道** + +```typescript +GRAPH_GET_DATA: 'graph:getData', +GRAPH_UPSERT_RELATIONSHIP: 'graph:upsertRelationship', +GRAPH_DELETE_RELATIONSHIP: 'graph:deleteRelationship', +ANALYTICS_SUMMARY: 'analytics:summary', +``` + +- [ ] **Step 3: build 验证** + +```bash +npm run build +``` + +--- + +## Task 2: writing_sessions 表 + Repository + +**Files:** +- Modify: `src/main/db/repositories/writing-log.repo.ts` +- Create: `src/main/db/repositories/writing-session.repo.ts` +- Create: `tests/main/writing-session.test.ts`(repo 部分) + +- [ ] **Step 1: writing-log.repo migrate 扩展** + +在 `migrate()` 的 `CREATE TABLE` 块追加: + +```sql +CREATE TABLE IF NOT EXISTS writing_sessions ( + id TEXT PRIMARY KEY, + book_id TEXT NOT NULL, + chapter_id TEXT, + started_at TEXT NOT NULL, + ended_at TEXT NOT NULL, + word_delta INTEGER NOT NULL DEFAULT 0, + date TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_sessions_book_date ON writing_sessions (book_id, date); +CREATE INDEX IF NOT EXISTS idx_sessions_book_started ON writing_sessions (book_id, started_at); +``` + +- [ ] **Step 2: WritingSessionRepository** + +```typescript +export class WritingSessionRepository { + constructor(private db: DatabaseSync) {} + + insert(row: { id: string; bookId: string; chapterId?: string; startedAt: string; endedAt: string; wordDelta: number; date: string }): void + updateActivity(id: string, endedAt: string, wordDelta: number): void + listForBookDate(bookId: string, date: string): WritingSessionRow[] + listSince(bookId: string, sinceIso: string): WritingSessionRow[] + getById(id: string): WritingSessionRow | null +} +``` + +- [ ] **Step 3: UT repo smoke** + +```typescript +it('UT-SESSION-03: insert and updateActivity accumulates word_delta', () => { + const repo = new WritingSessionRepository(db) + const id = randomUUID() + repo.insert({ id, bookId: 'b1', chapterId: 'c1', startedAt: '2026-07-08T10:00:00', endedAt: '2026-07-08T10:05:00', wordDelta: 50, date: '2026-07-08' }) + repo.updateActivity(id, '2026-07-08T10:10:00', 30) + expect(repo.getById(id)!.wordDelta).toBe(80) +}) +``` + +- [ ] **Step 4: 运行** + +```bash +npm run test -- tests/main/writing-session.test.ts +``` + +--- + +## Task 3: WritingSessionService 状态机 + +**Files:** +- Create: `src/main/services/writing-session.service.ts` +- Modify: `tests/main/writing-session.test.ts` + +- [ ] **Step 1: 实现** + +```typescript +const IDLE_MS = 5 * 60 * 1000 + +export class WritingSessionService { + private active = new Map() + + constructor(private repo: WritingSessionRepository) {} + + recordActivity(bookId: string, chapterId: string | undefined, delta: number): void { + if (delta === 0) return + const now = new Date() + const nowIso = now.toISOString() + const cached = this.active.get(bookId) + const idle = !cached || now.getTime() - cached.lastEndedAt > IDLE_MS + + if (idle) { + const id = randomUUID() + this.repo.insert({ + id, bookId, chapterId, + startedAt: nowIso, endedAt: nowIso, + wordDelta: delta, + date: localDateString() + }) + this.active.set(bookId, { sessionId: id, lastEndedAt: now.getTime() }) + } else { + this.repo.updateActivity(cached!.sessionId, nowIso, delta) + this.active.set(bookId, { sessionId: cached!.sessionId, lastEndedAt: now.getTime() }) + } + } + + endActive(bookId: string): void { + this.active.delete(bookId) + } +} +``` + +- [ ] **Step 2: UT-SESSION-01 / UT-SESSION-02** + +使用 `vi.useFakeTimers()`: + +```typescript +it('UT-SESSION-01: within 5min merges into one session', () => { /* advance 2min, recordActivity twice, expect 1 row */ }) +it('UT-SESSION-02: after 6min creates two sessions', () => { /* advance 6min between calls */ }) +``` + +- [ ] **Step 3: 运行** + +```bash +npm run test -- tests/main/writing-session.test.ts +``` + +--- + +## Task 4: ChapterWritingTracker + chapter POV 字段 + +**Files:** +- Modify: `src/main/services/chapter-writing-tracker.service.ts` +- Modify: `src/main/db/repositories/chapter.repo.ts` +- Modify: `src/main/ipc/handlers/chapter.handler.ts`(UpdateChapterParams 含 povCharacterId) +- Modify: `src/shared/types.ts`(UpdateChapterParams) +- Modify: `src/main/ipc/register.ts`(trackerFor 注入 sessions) + +- [ ] **Step 1: chapter.repo mapRow + update** + +```typescript +// mapRow 增加 +povCharacterId: (row.pov_character_id as string) || null, + +// update patch 增加 povCharacterId?: string | null +// UPDATE SQL 增加 pov_character_id = ? +``` + +- [ ] **Step 2: trackerFor 签名** + +```typescript +export function trackerFor( + registry: BookRegistryService, + bookId: string, + writingLogs: WritingLogService, + sessions: WritingSessionService +): ChapterWritingTracker + +recordDelta(...) { + if (delta !== 0) { + this.writingLogs.addToday(this.bookId, delta) + this.sessions.recordActivity(this.bookId, chapterId, delta) + } + // ... +} + +supplementOnFinish(...) { + if (gap > 0) { + this.writingLogs.addToday(this.bookId, gap) + this.sessions.recordActivity(this.bookId, chapterId, gap) + } +} +``` + +- [ ] **Step 3: register.ts** + +```typescript +const writingSessionRepo = new WritingSessionRepository(writingLogRepo.getDb()) +const writingSessions = new WritingSessionService(writingSessionRepo) +// registerChapterHandlers(books, writingLogs, writingSessions) +``` + +- [ ] **Step 4: build** + +```bash +npm run build +``` + +--- + +## Task 5: CharacterGraphService + +**Files:** +- Create: `src/main/services/character-graph.service.ts` +- Create: `tests/main/character-graph.test.ts` + +- [ ] **Step 1: getData 边去重** + +```typescript +export class CharacterGraphService { + constructor(private registry: BookRegistryService) {} + + getData(bookId: string, filter: 'all' | 'connected' = 'all'): CharacterGraphData { + const settings = new SettingRepository(this.registry.getDb(bookId)).list() + const characters = settings.filter((s) => s.type === 'character') + const truncated = characters.length > 100 + // 收集 raw edges from properties.relationships + // dedupeEdges: key = sorted([source,target]).join('|'), keep max intimacy + // filter connected: nodes with degree > 0 + // if truncated: slice nodes to 100 by sortOrder/name + } +} +``` + +- [ ] **Step 2: upsertRelationship / deleteRelationship** + +```typescript +upsertRelationship(bookId, sourceId, input: Partial & { targetId: string; label: string; intimacy: number }): CharacterRelationship { + // validate source/target are character; targetId !== sourceId + // merge into properties.relationships by id or push new uuid + // settingRepo.update(sourceId, { properties: { ...existing, relationships } }) +} + +deleteRelationship(bookId, sourceId, relationshipId): void +``` + +- [ ] **Step 3: UT-GRAPH-01 / UT-GRAPH-02 / UT-GRAPH-03** + +内存 DB + migrate + 创建 3 character settings + 2 relationships。 + +- [ ] **Step 4: 运行** + +```bash +npm run test -- tests/main/character-graph.test.ts +``` + +--- + +## Task 6: WritingAnalyticsService + +**Files:** +- Create: `src/main/services/writing-analytics.service.ts` +- Create: `tests/main/writing-analytics.test.ts` + +- [ ] **Step 1: getSummary 实现** + +```typescript +export class WritingAnalyticsService { + constructor( + private writingLogs: WritingLogService, + private sessionRepo: WritingSessionRepository, + private pomodoroDaily: PomodoroDailyRepository, + private registry: BookRegistryService + ) {} + + getSummary(bookId: string): WritingAnalyticsSummary { + const today = localDateString() + const todaySessions = this.sessionRepo.listForBookDate(bookId, today) + const todayWords = this.writingLogs.getStats(bookId).todayWords + const todaySpeedWpm = computeWpm(todaySessions) // null if zero duration + const weekTrend = buildWeekTrend(this.writingLogs, bookId) + const hourlyHeatmap = buildHourlyHeatmap(this.sessionRepo.listSince(bookId, thirtyDaysAgoIso)) + // povDistribution from ChapterRepository + SettingRepository names + // volumeDistribution from VolumeRepository titles + // pov 未指定 → characterId: '__none__' + } +} +``` + +辅助函数: + +```typescript +function computeWpm(sessions: { wordDelta: number; startedAt: string; endedAt: string }[]): number | null { + const totalWords = sessions.reduce((s, x) => s + x.wordDelta, 0) + const totalMin = sessions.reduce((s, x) => s + minutesBetween(x.startedAt, x.endedAt), 0) + if (totalMin <= 0) return null + return Math.round(totalWords / totalMin) +} +``` + +- [ ] **Step 2: UT-ANALYTICS-01 / UT-ANALYTICS-02** + +- [ ] **Step 3: IT-ANALYTICS-01** + +```typescript +it('IT-ANALYTICS-01: recordDelta feeds session then getSummary', () => { + const tracker = trackerFor(registry, bookId, writingLogs, sessions) + tracker.recordDelta(chapterId, 100) + const summary = analytics.getSummary(bookId) + expect(summary.todaySpeedWpm).toBeGreaterThan(0) +}) +``` + +- [ ] **Step 4: 运行** + +```bash +npm run test -- tests/main/writing-analytics.test.ts +``` + +--- + +## Task 7: IPC + register + preload + +**Files:** +- Create: `src/main/ipc/handlers/graph.handler.ts` +- Create: `src/main/ipc/handlers/analytics.handler.ts` +- Modify: `src/main/ipc/register.ts` +- Modify: `src/main/ipc/handlers/chapter.handler.ts` +- Modify: `src/preload/index.ts` +- Modify: `src/shared/electron-api.d.ts` + +- [ ] **Step 1: graph.handler** + +```typescript +export function registerGraphHandlers(registry: BookRegistryService): void { + const svc = () => new CharacterGraphService(registry) + ipcMain.handle(IPC.GRAPH_GET_DATA, (_e, { bookId, filter }) => wrap(() => svc().getData(bookId, filter))) + ipcMain.handle(IPC.GRAPH_UPSERT_RELATIONSHIP, (_e, { bookId, sourceId, relationship }) => + wrap(() => svc().upsertRelationship(bookId, sourceId, relationship))) + ipcMain.handle(IPC.GRAPH_DELETE_RELATIONSHIP, (_e, { bookId, sourceId, relationshipId }) => + wrap(() => { svc().deleteRelationship(bookId, sourceId, relationshipId) })) +} +``` + +- [ ] **Step 2: analytics.handler** + +```typescript +export function registerAnalyticsHandlers( + registry: BookRegistryService, + writingLogs: WritingLogService, + sessionRepo: WritingSessionRepository, + pomodoroDaily: PomodoroDailyRepository +): void { + const svc = new WritingAnalyticsService(writingLogs, sessionRepo, pomodoroDaily, registry) + ipcMain.handle(IPC.ANALYTICS_SUMMARY, (_e, { bookId }) => wrap(() => svc.getSummary(bookId))) +} +``` + +- [ ] **Step 3: register.ts 串联** + +```typescript +const writingSessionRepo = new WritingSessionRepository(writingLogRepo.getDb()) +const writingSessions = new WritingSessionService(writingSessionRepo) +registerGraphHandlers(books) +registerAnalyticsHandlers(books, writingLogs, writingSessionRepo, pomodoroDailyRepo) +registerChapterHandlers(books, writingLogs, writingSessions) +``` + +- [ ] **Step 4: preload** + +```typescript +graph: { + getData: (bookId, filter?) => ipcRenderer.invoke(IPC.GRAPH_GET_DATA, { bookId, filter }), + upsertRelationship: (bookId, sourceId, relationship) => ..., + deleteRelationship: (bookId, sourceId, relationshipId) => ... +}, +analytics: { + getSummary: (bookId) => ipcRenderer.invoke(IPC.ANALYTICS_SUMMARY, { bookId }) +} +``` + +- [ ] **Step 5: build** + +```bash +npm run build +``` + +--- + +## Task 8: Cytoscape 依赖 + CharacterGraphModal + +**Files:** +- Modify: `package.json` +- Create: `src/renderer/stores/useGraphStore.ts` +- Create: `src/renderer/components/graph/CharacterGraphModal.tsx` +- Create: `src/renderer/lib/graph-cytoscape.ts`(init / layout / intimacy 颜色) +- Modify: `src/renderer/components/layout/EditorLayout.tsx`(挂载模态) +- Modify: `src/renderer/styles/layout.css` + +- [ ] **Step 1: 安装依赖** + +```bash +npm install cytoscape cytoscape-cose-bilkent --legacy-peer-deps +npm install -D @types/cytoscape --legacy-peer-deps +``` + +- [ ] **Step 2: useGraphStore** + +```typescript +interface GraphStore { + open: boolean + data: CharacterGraphData | null + loading: boolean + openModal: () => void + close: () => void + load: (bookId: string, filter?: 'all' | 'connected') => Promise +} +``` + +- [ ] **Step 3: CharacterGraphModal 骨架** + +```tsx +export function CharacterGraphModal(): React.JSX.Element | null { + const { open, data, loading, close, load } = useGraphStore() + const bookId = useBookStore((s) => s.currentBookId) + const cyRef = useRef(null) + const containerRef = useRef(null) + + useEffect(() => { + if (!open || !bookId) return + void load(bookId) + }, [open, bookId, load]) + + useEffect(() => { + if (!open || !data || !containerRef.current) return + cyRef.current = initCytoscape(containerRef.current, data) + return () => { cyRef.current?.destroy(); cyRef.current = null } + }, [open, data]) + + // 布局按钮 graph-layout-cose/grid/circle + // tap node → sidebar; tap edge → sidebar edit intimacy + // truncated toast +} +``` + +`initCytoscape` 注册 `cytoscape.use(coseBilkent)`,边颜色按 intimacy 映射 spec §6.1。 + +- [ ] **Step 4: EditorLayout 挂载 ``** + +- [ ] **Step 5: build** + +```bash +npm run build +``` + +--- + +## Task 9: SettingRelationshipsEditor + 入口 + +**Files:** +- Create: `src/renderer/components/setting/SettingRelationshipsEditor.tsx` +- Modify: `src/renderer/components/editor/TipTapEditor.tsx`(character setting 下方嵌入) +- Modify: `src/renderer/components/setting/SettingList.tsx`(setting-open-graph 按钮) + +- [ ] **Step 1: SettingRelationshipsEditor** + +当 `target.kind === 'setting'` 且 entry.type === 'character': + +```tsx +
+ {relationships.map((r) => ( +
{targetName(r.targetId)} · {r.label} · {r.intimacy} + +
+ ))} + +
+``` + +保存调用 `window.electronAPI.graph.upsertRelationship`。 + +- [ ] **Step 2: SettingList 工具栏** + +```tsx + +``` + +- [ ] **Step 3: build** + +```bash +npm run build +``` + +--- + +## Task 10: CockpitAnalytics + POV 选择 + +**Files:** +- Create: `src/renderer/components/cockpit/CockpitAnalytics.tsx` +- Create: `src/renderer/components/cockpit/HourlyHeatmap.tsx` +- Modify: `src/renderer/components/cockpit/CockpitModal.tsx` +- Modify: `src/renderer/components/layout/EditorLayout.tsx`(chapter-pov-select) +- Modify: `src/renderer/stores/useCockpitStore.ts`(可选:loadAnalytics) + +- [ ] **Step 1: CockpitModal 并行加载** + +```typescript +useEffect(() => { + if (!open || !bookId) return + void Promise.all([ + loadSummary(bookId, activeVolumeId ?? undefined), + ipcCall(() => window.electronAPI.analytics.getSummary(bookId)).then(setAnalytics) + ]) +}, [open, bookId, activeVolumeId]) +``` + +- [ ] **Step 2: CockpitAnalytics 组件** + +```tsx +
+
{speedLabel}
+ +
{/* top 5 bars */}
+
{/* volume bars */}
+
{/* 7 mini bars */}
+
+ +``` + +- [ ] **Step 3: chapter-pov-select** + +在 `#editor-statusbar` 当 `isChapterTarget`: + +```tsx + +``` + +`updatePov` → `chapter.update(bookId, chapterId, { povCharacterId })`. + +- [ ] **Step 4: build** + +```bash +npm run build +``` + +--- + +## Task 11: i18n + 样式 + +**Files:** +- Modify: `public/locales/zh-CN/translation.json` +- Modify: `public/locales/en/translation.json` +- Modify: `src/renderer/styles/layout.css` + +- [ ] **Step 1: 添加 spec §7 全部 i18n 键(graph / relationship / analytics / chapter.pov / cockpit.openGraph)** + +- [ ] **Step 2: CSS** + +```css +.character-graph-modal .modal-content { max-width: 95vw; width: 1100px; } +#character-graph-cy { min-height: 480px; flex: 1; } +.graph-sidebar { width: 260px; ... } +.cockpit-analytics { margin-top: 16px; display: grid; gap: 12px; } +.hourly-heatmap { display: flex; gap: 2px; height: 32px; } +.hourly-heatmap-cell { flex: 1; border-radius: 2px; background: var(--bg-tertiary); } +.pov-bar, .volume-bar { height: 8px; background: var(--accent); border-radius: 4px; } +``` + +- [ ] **Step 3: build** + +```bash +npm run build +``` + +--- + +## Task 12: E2E + v1.0.0 交付 + +**Files:** +- Create: `e2e/character-graph.spec.ts` +- Create: `e2e/writing-analytics.spec.ts` +- Modify: `package.json` +- Modify: `README.md` +- Modify: `src/renderer/components/settings/SettingsPage.tsx` + +- [ ] **Step 1: E2E-GRAPH-01~04** + +```typescript +// 创建书 → 新建 character 设定 A/B → SettingRelationshipsEditor 添加关系 +// → setting-open-graph → expect character-graph-cy visible +// → tap node → sidebar 含角色名 +// → graph-layout-circle click +``` + +- [ ] **Step 2: E2E-ANALYTICS-01~02** + +```typescript +// E2E-ANALYTICS-01: 写作保存 → topbar-cockpit → cockpit-speed-today 非空 +// E2E-ANALYTICS-02: chapter-pov-select 选角色 → 刷新驾驶舱 → cockpit-pov-chart 含角色名 +``` + +- [ ] **Step 3: 全量测试** + +```bash +npm run build +npm run test +npm run test:e2e -- e2e/character-graph.spec.ts e2e/writing-analytics.spec.ts +``` + +- [ ] **Step 4: 版本 1.0.0** + +`package.json` / `README.md` / Settings 关于页 → `v1.0.0` + +- [ ] **Step 5: 勾选本 plan 全部 checkbox** + +--- + +## Spec Coverage Checklist + +| Spec 要求 | Task | +|-----------|------| +| writing_sessions + 5min 合并 | Task 2, 3, 4 | +| recordDelta 写 session | Task 4 | +| CharacterGraphService + 边去重 | Task 5 | +| WritingAnalyticsService | Task 6 | +| graph / analytics IPC | Task 7 | +| Cytoscape 模态 + 三布局 | Task 8 | +| SettingRelationshipsEditor | Task 9 | +| 驾驶舱分析区 | Task 10 | +| chapter POV | Task 4, 10 | +| i18n | Task 11 | +| UT/IT/E2E | Task 2–6, 12 | +| v1.0.0 DoD | Task 12 | + +--- + +## Execution Notes + +- `CharacterGraphService` 与 `SettingRelationshipsEditor` 共用 `graph.upsertRelationship` IPC,避免 renderer 直接 patch `properties` JSON +- Cytoscape 仅在 modal open 时 mount,close 必须 `destroy()` 防内存泄漏 +- 书籍切换时调用 `writingSessions.endActive(oldBookId)`(在 `useBookStore.openBook` 或 main 层 hook) +- POV 分布 `__none__` 桶在 i18n 显示为 `analytics.povUnassigned` +- E2E 图谱/分析 **禁止 Mock AI**