Files
bilin/docs/superpowers/plans/2026-07-08-bilin-p7-graph-p9-analytics.md
T
bing 2c9b034a29 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 <cursoragent@cursor.com>
2026-07-08 10:07:51 +08:00

742 lines
23 KiB
Markdown
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.
# 笔临 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<string, { sessionId: string; lastEndedAt: number }>()
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<CharacterRelationship> & { 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<void>
}
```
- [ ] **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<cytoscape.Core | null>(null)
const containerRef = useRef<HTMLDivElement>(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 挂载 `<CharacterGraphModal />`**
- [ ] **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
<div data-testid="setting-relationships">
{relationships.map((r) => (
<div key={r.id}>{targetName(r.targetId)} · {r.label} · {r.intimacy}
<button onClick={() => void deleteRel(r.id)}></button>
</div>
))}
<button data-testid="setting-relationship-add" onClick={openAddForm}>+</button>
</div>
```
保存调用 `window.electronAPI.graph.upsertRelationship`
- [ ] **Step 2: SettingList 工具栏**
```tsx
<button data-testid="setting-open-graph" onClick={() => useGraphStore.getState().openModal()}>
{t('graph.title')}
</button>
```
- [ ] **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
<div className="cockpit-analytics" data-testid="cockpit-analytics">
<div data-testid="cockpit-speed-today">{speedLabel}</div>
<HourlyHeatmap data={analytics.hourlyHeatmap} testId="cockpit-hourly-heatmap" />
<div data-testid="cockpit-pov-chart">{/* top 5 bars */}</div>
<div data-testid="cockpit-volume-chart">{/* volume bars */}</div>
<div data-testid="cockpit-week-trend">{/* 7 mini bars */}</div>
</div>
<button data-testid="cockpit-open-graph" onClick={() => useGraphStore.getState().openModal()}>
{t('cockpit.openGraph')}
</button>
```
- [ ] **Step 3: chapter-pov-select**
`#editor-statusbar``isChapterTarget`
```tsx
<select
data-testid="chapter-pov-select"
value={chapter.povCharacterId ?? ''}
onChange={(e) => void updatePov(e.target.value || null)}
>
<option value="">{t('analytics.povUnassigned')}</option>
{characters.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
```
`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 26, 12 |
| v1.0.0 DoD | Task 12 |
---
## Execution Notes
- `CharacterGraphService``SettingRelationshipsEditor` 共用 `graph.upsertRelationship` IPC,避免 renderer 直接 patch `properties` JSON
- Cytoscape 仅在 modal open 时 mountclose 必须 `destroy()` 防内存泄漏
- 书籍切换时调用 `writingSessions.endActive(oldBookId)`(在 `useBookStore.openBook` 或 main 层 hook
- POV 分布 `__none__` 桶在 i18n 显示为 `analytics.povUnassigned`
- E2E 图谱/分析 **禁止 Mock AI**