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>
This commit is contained in:
@@ -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<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 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**
|
||||||
Reference in New Issue
Block a user