@waterwx/dsh-novel-forge 1.10.1 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (194) hide show
  1. package/README.md +287 -281
  2. package/lib/client.js +19572 -18127
  3. package/lib/index.js +12171 -6932
  4. package/lib/types/action-guard.d.ts +61 -0
  5. package/lib/types/ai-scan.d.ts +30 -0
  6. package/lib/types/assets.d.ts +20 -9
  7. package/lib/types/assistant.d.ts +29 -0
  8. package/lib/types/author-assets.d.ts +1 -3
  9. package/lib/types/bookshelf.d.ts +13 -3
  10. package/lib/types/client/api.d.ts +137 -21
  11. package/lib/types/client/chip-prefs.d.ts +36 -0
  12. package/lib/types/client/docx.d.ts +2 -2
  13. package/lib/types/client/index.d.ts +1 -1
  14. package/lib/types/client/llmLive.d.ts +31 -0
  15. package/lib/types/client/locales.d.ts +15 -70
  16. package/lib/types/client/mount.d.ts +0 -2
  17. package/lib/types/client/panel/AiTag.d.ts +1 -0
  18. package/lib/types/client/panel/AssistantTab.d.ts +4 -1
  19. package/lib/types/client/panel/AuthorHome.d.ts +13 -9
  20. package/lib/types/client/panel/BookAnalysisView.d.ts +4 -0
  21. package/lib/types/client/panel/CmdPalette.d.ts +12 -0
  22. package/lib/types/client/panel/CreateBookView.d.ts +5 -1
  23. package/lib/types/client/panel/DirectorView.d.ts +7 -0
  24. package/lib/types/client/panel/IdeaInspirationView.d.ts +6 -0
  25. package/lib/types/client/panel/KnowledgeBaseView.d.ts +4 -0
  26. package/lib/types/client/panel/LiveFeedLog.d.ts +13 -0
  27. package/lib/types/client/panel/MarketRadarView.d.ts +5 -0
  28. package/lib/types/client/panel/ModelManager.d.ts +2 -2
  29. package/lib/types/client/panel/NovelPanel.d.ts +6 -1
  30. package/lib/types/client/panel/PageHeader.d.ts +18 -0
  31. package/lib/types/client/panel/PromptSlotsView.d.ts +11 -0
  32. package/lib/types/client/panel/SettingsView.d.ts +12 -7
  33. package/lib/types/client/panel/ShelfView.d.ts +10 -1
  34. package/lib/types/client/panel/SubPage.d.ts +27 -0
  35. package/lib/types/client/panel/TensionView.d.ts +18 -0
  36. package/lib/types/client/panel/TimelineView.d.ts +18 -0
  37. package/lib/types/client/panel/helpers.d.ts +5 -3
  38. package/lib/types/client/panel/market-radar-state.d.ts +56 -0
  39. package/lib/types/client/panel/toast.d.ts +2 -0
  40. package/lib/types/client/panel/views.d.ts +21 -35
  41. package/lib/types/client/run-status.d.ts +21 -0
  42. package/lib/types/client/sidebar-entry.d.ts +0 -2
  43. package/lib/types/docx-core.d.ts +16 -0
  44. package/lib/types/docx.d.ts +2 -3
  45. package/lib/types/engine.d.ts +83 -79
  46. package/lib/types/global-assets.d.ts +6 -0
  47. package/lib/types/home.d.ts +11 -0
  48. package/lib/types/index.d.ts +27 -13
  49. package/lib/types/llm-live.d.ts +70 -0
  50. package/lib/types/llm-retry.d.ts +38 -0
  51. package/lib/types/market-radar-scan.d.ts +18 -0
  52. package/lib/types/market-radar-sources.d.ts +31 -0
  53. package/lib/types/migrate-output.d.ts +25 -0
  54. package/lib/types/model-capability.d.ts +93 -0
  55. package/lib/types/novel-context.d.ts +80 -0
  56. package/lib/types/output-budget.d.ts +86 -0
  57. package/lib/types/prompt-slots.d.ts +57 -0
  58. package/lib/types/prompting.d.ts +10 -0
  59. package/lib/types/protocol.d.ts +829 -522
  60. package/lib/types/revision.d.ts +76 -0
  61. package/lib/types/routes.d.ts +9 -3
  62. package/lib/types/run.d.ts +23 -1
  63. package/lib/types/settings-persist.d.ts +59 -0
  64. package/lib/types/snapshots.d.ts +46 -0
  65. package/lib/types/stage-contract.d.ts +58 -0
  66. package/lib/types/tension.d.ts +53 -0
  67. package/lib/types/timeline.d.ts +37 -0
  68. package/package.json +50 -28
  69. package/src/action-guard.ts +127 -0
  70. package/src/ai-scan.ts +155 -0
  71. package/src/assets.ts +1801 -535
  72. package/src/assistant.ts +1098 -770
  73. package/src/author-assets.ts +131 -147
  74. package/src/bookshelf.ts +193 -167
  75. package/src/client/api.ts +767 -607
  76. package/src/client/chip-prefs.ts +121 -0
  77. package/src/client/docx.ts +32 -69
  78. package/src/client/index.ts +3 -1
  79. package/src/client/llmLive.ts +111 -0
  80. package/src/client/locales.ts +15 -274
  81. package/src/client/mount.tsx +4 -3
  82. package/src/client/panel/AdaptModeView.tsx +535 -520
  83. package/src/client/panel/AiTag.tsx +7 -0
  84. package/src/client/panel/AssetsTab.tsx +448 -458
  85. package/src/client/panel/AssistantTab.tsx +355 -301
  86. package/src/client/panel/AuthorAssetsView.tsx +205 -207
  87. package/src/client/panel/AuthorHome.tsx +309 -202
  88. package/src/client/panel/BookAnalysisView.tsx +63 -0
  89. package/src/client/panel/CmdPalette.tsx +102 -0
  90. package/src/client/panel/CreateBookView.tsx +366 -352
  91. package/src/client/panel/DirectorView.tsx +200 -0
  92. package/src/client/panel/GlobalAssetLibraryView.tsx +168 -172
  93. package/src/client/panel/IdeaInspirationView.tsx +76 -0
  94. package/src/client/panel/ImportModal.tsx +302 -302
  95. package/src/client/panel/KnowledgeBaseView.tsx +125 -0
  96. package/src/client/panel/LiveFeedLog.tsx +204 -0
  97. package/src/client/panel/MarketRadarView.tsx +415 -0
  98. package/src/client/panel/ModelManager.tsx +205 -205
  99. package/src/client/panel/NovelPanel.tsx +5559 -4655
  100. package/src/client/panel/PageHeader.tsx +37 -0
  101. package/src/client/panel/ProgressConsole.tsx +63 -64
  102. package/src/client/panel/PromptSlotsView.tsx +97 -0
  103. package/src/client/panel/ReaderView.tsx +306 -306
  104. package/src/client/panel/RunPanel.tsx +56 -24
  105. package/src/client/panel/SettingsView.tsx +175 -112
  106. package/src/client/panel/ShelfView.tsx +63 -39
  107. package/src/client/panel/SubPage.tsx +42 -0
  108. package/src/client/panel/TensionView.tsx +212 -0
  109. package/src/client/panel/TimelineView.tsx +208 -0
  110. package/src/client/panel/WorldTab.tsx +202 -202
  111. package/src/client/panel/helpers.ts +7 -5
  112. package/src/client/panel/market-radar-state.ts +79 -0
  113. package/src/client/panel/panel.module.css +5767 -4199
  114. package/src/client/panel/toast.ts +51 -0
  115. package/src/client/panel/views.tsx +316 -314
  116. package/src/client/run-status.ts +171 -0
  117. package/src/client/sidebar-entry.ts +3 -3
  118. package/src/docx-core.ts +53 -0
  119. package/src/docx.ts +44 -83
  120. package/src/engine.ts +1610 -1541
  121. package/src/global-assets.ts +58 -0
  122. package/src/home.ts +33 -0
  123. package/src/index.ts +184 -103
  124. package/src/llm-live.ts +245 -0
  125. package/src/llm-retry.ts +77 -0
  126. package/src/market-radar-scan.ts +35 -0
  127. package/src/market-radar-sources.ts +173 -0
  128. package/src/migrate-output.ts +76 -0
  129. package/src/model-capability.ts +215 -0
  130. package/src/novel-context.ts +286 -0
  131. package/src/output-budget.ts +147 -0
  132. package/src/prompt-slots.ts +125 -0
  133. package/src/prompting.ts +26 -0
  134. package/src/protocol.ts +2454 -2148
  135. package/src/revision.ts +283 -0
  136. package/src/routes.ts +3795 -3359
  137. package/src/run.ts +113 -17
  138. package/src/settings-persist.ts +114 -0
  139. package/src/snapshots.ts +174 -0
  140. package/src/stage-contract.ts +186 -0
  141. package/src/tension.ts +251 -0
  142. package/src/timeline.ts +159 -0
  143. package/assets/styles/arcane-thick-3d.webp +0 -0
  144. package/assets/styles/chinese-ink.webp +0 -0
  145. package/assets/styles/clay-stopmotion-3d.webp +0 -0
  146. package/assets/styles/dark-ghibli.webp +0 -0
  147. package/assets/styles/dark-gothic-3d.webp +0 -0
  148. package/assets/styles/film-photo.webp +0 -0
  149. package/assets/styles/guofeng-heavy.webp +0 -0
  150. package/assets/styles/horror-game-3d.webp +0 -0
  151. package/assets/styles/japan-cel-dark.webp +0 -0
  152. package/assets/styles/korean-webtoon.webp +0 -0
  153. package/assets/styles/low-cyberpunk-3d.webp +0 -0
  154. package/assets/styles/nextgen-cg.webp +0 -0
  155. package/assets/styles/noir-film.webp +0 -0
  156. package/assets/styles/openworld-survival.webp +0 -0
  157. package/assets/styles/paper-cut-3d.webp +0 -0
  158. package/assets/styles/pixar-adult-3d.webp +0 -0
  159. package/assets/styles/pop-marvel-2d.webp +0 -0
  160. package/assets/styles/shinkai-anime.webp +0 -0
  161. package/assets/styles/tactical-stealth.webp +0 -0
  162. package/assets/styles/vaporwave-3d.webp +0 -0
  163. package/assets/styles/vhs90.webp +0 -0
  164. package/lib/types/client/panel/BookshelfBar.d.ts +0 -11
  165. package/lib/types/client/panel/FlowGuide.d.ts +0 -21
  166. package/lib/types/client/panel/MangaRoleLibrary.d.ts +0 -18
  167. package/lib/types/client/panel/MangaWorkspace.d.ts +0 -15
  168. package/lib/types/client/panel/ModelPicker.d.ts +0 -11
  169. package/lib/types/client/panel/ProgressHomeView.d.ts +0 -5
  170. package/lib/types/client/panel/SceneLibrary.d.ts +0 -14
  171. package/lib/types/client/panel/StoryboardTab.d.ts +0 -19
  172. package/lib/types/client/panel/StyleCard.d.ts +0 -8
  173. package/lib/types/client/panel/StyleGallery.d.ts +0 -1
  174. package/lib/types/comic-presets.d.ts +0 -12
  175. package/lib/types/shot-language.d.ts +0 -46
  176. package/lib/types/story-beat-language.d.ts +0 -35
  177. package/lib/types/style-library.d.ts +0 -33
  178. package/skills/chapter-batch/SKILL.md +0 -81
  179. package/src/client/mount.tsx._new +0 -97
  180. package/src/client/mount.tsx._nf +0 -97
  181. package/src/client/mount.tsx._tmp +0 -97
  182. package/src/client/panel/BookshelfBar.tsx +0 -170
  183. package/src/client/panel/FlowGuide.tsx +0 -83
  184. package/src/client/panel/MangaRoleLibrary.tsx +0 -810
  185. package/src/client/panel/MangaWorkspace.tsx +0 -359
  186. package/src/client/panel/ProgressHomeView.tsx +0 -87
  187. package/src/client/panel/SceneLibrary.tsx +0 -396
  188. package/src/client/panel/StoryboardTab.tsx +0 -466
  189. package/src/client/panel/StyleCard.tsx +0 -90
  190. package/src/client/panel/StyleGallery.tsx +0 -58
  191. package/src/comic-presets.ts +0 -67
  192. package/src/shot-language.ts +0 -181
  193. package/src/story-beat-language.ts +0 -130
  194. package/src/style-library.ts +0 -78
@@ -1,306 +1,306 @@
1
- /**
2
- * 阅读页:沉浸式章节阅读(纯只读,零 LLM 消耗)。
3
- * 布局:顶部工具栏 + 左侧可折叠目录栏(按卷分组)+ 阅读区。
4
- * 轻量渲染:去掉 md 首行标题,按行分段;字号/主题/阅读进度 localStorage 记忆。
5
- */
6
- import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
7
- import type { NovelApi } from '../api.ts'
8
- import type { ProjectState, ChapterPlan } from '../../protocol.ts'
9
- import css from './panel.module.css'
10
-
11
- const FONT_SIZES = [
12
- { id: 'sm', px: 15, label: 'A-' },
13
- { id: 'md', px: 17, label: 'A' },
14
- { id: 'lg', px: 20, label: 'A+' },
15
- ] as const
16
-
17
- const THEMES = [
18
- { id: 'paper', label: '📄 纸白', bg: '#f7f1e3', fg: '#3b3226', accent: '#8b6f47', dim: '#8a7d66' },
19
- { id: 'eye', label: '🌿 护眼', bg: '#e8f0e3', fg: '#2f3d2e', accent: '#5f7d5a', dim: '#6f836b' },
20
- { id: 'night', label: '🌙 夜间', bg: '#1d2226', fg: '#c9d1d9', accent: '#8aa5c0', dim: '#6d7882' },
21
- ] as const
22
-
23
- type FontId = typeof FONT_SIZES[number]['id']
24
- type ThemeId = typeof THEMES[number]['id']
25
-
26
- function readPref<T>(key: string, fallback: T): T {
27
- try {
28
- const raw = window.localStorage.getItem(key)
29
- if (raw !== null) return JSON.parse(raw) as T
30
- } catch { /* ignore */ }
31
- return fallback
32
- }
33
-
34
- function writePref(key: string, value: unknown): void {
35
- try { window.localStorage.setItem(key, JSON.stringify(value)) } catch { /* ignore */ }
36
- }
37
-
38
- /** 章节状态小标(阅读视角)。 */
39
- function statusBadge(ch: ChapterPlan): { label: string; cls: string } {
40
- switch (ch.status) {
41
- case 'approved': return { label: '✅', cls: css.badgeDone }
42
- case 'written': return { label: '📝', cls: css.badgeWritten }
43
- case 'rejected': return { label: '⚠️', cls: css.badgePending }
44
- default: return { label: '', cls: css.badgePending }
45
- }
46
- }
47
-
48
- /** 目录分组:按卷区间过滤可读章节;卷外章节归入「未分卷」。 */
49
- function buildGroups(project: ProjectState, readable: ChapterPlan[]): Array<{ title: string; no: number; chapters: ChapterPlan[] }> {
50
- const vols = project.volumes ?? []
51
- if (vols.length === 0) return [{ title: '全部章节', no: 0, chapters: readable }]
52
- const groups: Array<{ title: string; no: number; chapters: ChapterPlan[] }> = []
53
- for (const v of vols) {
54
- const inVol = readable.filter(c => c.no >= v.chapterStart && c.no <= v.chapterEnd)
55
- if (inVol.length > 0) groups.push({ title: `第${v.no}卷 ${v.title}`, no: v.no, chapters: inVol })
56
- }
57
- const rest = readable.filter(c => !vols.some(v => c.no >= v.chapterStart && c.no <= v.chapterEnd))
58
- if (rest.length > 0) groups.push({ title: '未分卷', no: 0, chapters: rest })
59
- return groups
60
- }
61
-
62
- export function ReaderView({
63
- api,
64
- project,
65
- onBack,
66
- onOpenWorkspace,
67
- }: {
68
- api: NovelApi
69
- project: ProjectState
70
- onBack: () => void
71
- onOpenWorkspace: () => void
72
- }) {
73
- const prefKey = useMemo(() => {
74
- const id = project.bookName || 'book'
75
- return `dsh-novel-forge.reader.${id}`
76
- }, [project.bookName])
77
-
78
- /** 可读章节(有正文落盘):approved / written / rejected。 */
79
- const readable = useMemo(() => project.chapters.filter(c =>
80
- c.status === 'approved' || c.status === 'written' || c.status === 'rejected',
81
- ), [project.chapters])
82
-
83
- const groups = useMemo(() => buildGroups(project, readable), [project, readable])
84
-
85
- const [currentNo, setCurrentNo] = useState<number>(() => {
86
- const saved = readPref<number | null>(`${prefKey}.no`, null)
87
- if (saved !== null && readable.some(c => c.no === saved)) return saved
88
- // 无记忆 → 最后一章已通过
89
- const lastApproved = [...readable].reverse().find(c => c.status === 'approved')
90
- return lastApproved?.no ?? readable[0]?.no ?? 0
91
- })
92
- const [markdown, setMarkdown] = useState<string>('')
93
- const [loading, setLoading] = useState(false)
94
- const [loadError, setLoadError] = useState<string>('')
95
- const [font, setFont] = useState<FontId>(() => readPref(`${prefKey}.font`, 'md'))
96
- const [theme, setTheme] = useState<ThemeId>(() => readPref(`${prefKey}.theme`, 'paper'))
97
- const [navOpen, setNavOpen] = useState<boolean>(() => readPref(`${prefKey}.nav`, true))
98
- /** 折叠的卷号(点击卷标题切换;记忆在 localStorage)。 */
99
- const [collapsedVols, setCollapsedVols] = useState<number[]>(() => readPref(`${prefKey}.collapsed`, []))
100
- const sidebarRef = useRef<HTMLDivElement>(null)
101
-
102
- const toggleVol = useCallback((no: number) => {
103
- setCollapsedVols(prev => {
104
- const next = prev.includes(no) ? prev.filter(v => v !== no) : [...prev, no]
105
- writePref(`${prefKey}.collapsed`, next)
106
- return next
107
- })
108
- }, [prefKey])
109
-
110
- const current = useMemo(() => project.chapters.find(c => c.no === currentNo), [project.chapters, currentNo])
111
- const idx = readable.findIndex(c => c.no === currentNo)
112
- const prevCh = idx > 0 ? readable[idx - 1] : undefined
113
- const nextCh = idx >= 0 && idx < readable.length - 1 ? readable[idx + 1] : undefined
114
-
115
- const load = useCallback(async (no: number) => {
116
- if (no === 0) return
117
- setLoading(true)
118
- setLoadError('')
119
- try {
120
- const res = await api.chapter(no)
121
- setMarkdown(res.markdown)
122
- setCurrentNo(no)
123
- writePref(`${prefKey}.no`, no)
124
- } catch (err) {
125
- setLoadError((err as Error).message)
126
- } finally {
127
- setLoading(false)
128
- }
129
- }, [api, prefKey])
130
-
131
- // 初始加载
132
- const initialNo = useRef<number>(0)
133
- useEffect(() => {
134
- if (initialNo.current === currentNo && markdown !== '') return
135
- if (currentNo === 0) return
136
- initialNo.current = currentNo
137
- void load(currentNo)
138
- }, [currentNo, load, markdown])
139
-
140
- // 键盘翻章
141
- useEffect(() => {
142
- const onKey = (e: KeyboardEvent) => {
143
- if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return
144
- if (e.key === 'ArrowLeft' && prevCh !== undefined) void load(prevCh.no)
145
- if (e.key === 'ArrowRight' && nextCh !== undefined) void load(nextCh.no)
146
- }
147
- window.addEventListener('keydown', onKey)
148
- return () => window.removeEventListener('keydown', onKey)
149
- }, [prevCh, nextCh, load])
150
-
151
- // 当前章滚动到可见(目录栏内)
152
- useEffect(() => {
153
- sidebarRef.current?.querySelector<HTMLElement>(`[data-no="${currentNo}"]`)?.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
154
- }, [currentNo])
155
-
156
- const themeMeta = THEMES.find(t => t.id === theme) ?? THEMES[0]
157
- const fontMeta = FONT_SIZES.find(f => f.id === font) ?? FONT_SIZES[1]
158
-
159
- /** 轻量渲染:去标题行、按行分段(网文每行一段)。 */
160
- const paragraphs = useMemo(() => {
161
- return markdown
162
- .split(/\r?\n/)
163
- .map(l => l.replace(/^#{1,6}\s*/, '').trim())
164
- .filter(Boolean)
165
- }, [markdown])
166
-
167
- const itemBg = theme === 'night' ? 'rgba(255,255,255,0.03)' : 'rgba(0,0,0,0.025)'
168
-
169
- return (
170
- <div className={css.readerView} style={{ background: themeMeta.bg, color: themeMeta.fg }}>
171
- {/* 顶部栏 */}
172
- <div className={css.readerHeader} style={{ borderColor: themeMeta.dim }}>
173
- <button type="button" className={css.iconButton} title="返回书架" aria-label="返回书架" onClick={onBack}>←</button>
174
- <span className={css.readerTitle}>📖 《{project.bookName}》</span>
175
- <span className={css.meta} style={{ color: themeMeta.dim }}>
176
- {readable.length > 0 && currentNo > 0 ? `第 ${currentNo} 章 · ${idx + 1}/${readable.length}` : ''}
177
- </span>
178
- <button
179
- type="button"
180
- className={`${css.button} ${css.buttonSmall} ${navOpen ? css.buttonPrimary : ''}`}
181
- style={{ marginLeft: 'var(--nf-space-4)' }}
182
- onClick={() => { setNavOpen(v => { const next = !v; writePref(`${prefKey}.nav`, next); return next }) }}
183
- title={navOpen ? '收起目录' : '展开目录'}
184
- >
185
- ☰ 目录
186
- </button>
187
- <div style={{ flex: 1 }} />
188
- <span className={css.meta} style={{ color: themeMeta.dim }}>字号</span>
189
- <div className={css.readerSeg}>
190
- {FONT_SIZES.map(f => (
191
- <button
192
- key={f.id}
193
- type="button"
194
- className={`${css.readerSegBtn} ${font === f.id ? css.readerSegActive : ''}`}
195
- style={font === f.id ? { background: themeMeta.accent, color: themeMeta.bg } : undefined}
196
- onClick={() => { setFont(f.id); writePref(`${prefKey}.font`, f.id) }}
197
- title={`字号 ${f.label}`}
198
- >
199
- {f.label}
200
- </button>
201
- ))}
202
- </div>
203
- <div className={css.readerSeg} style={{ marginLeft: 'var(--nf-space-8)' }}>
204
- {THEMES.map(t => (
205
- <button
206
- key={t.id}
207
- type="button"
208
- className={`${css.readerSegBtn} ${theme === t.id ? css.readerSegActive : ''}`}
209
- style={theme === t.id ? { background: themeMeta.accent, color: themeMeta.bg } : undefined}
210
- onClick={() => { setTheme(t.id); writePref(`${prefKey}.theme`, t.id) }}
211
- title={t.label}
212
- >
213
- {t.label}
214
- </button>
215
- ))}
216
- </div>
217
- <button type="button" className={`${css.button} ${css.buttonSmall}`} style={{ marginLeft: 'var(--nf-space-10)' }} onClick={onOpenWorkspace} title="回到创作工作台">
218
- ✏️ 去工作台
219
- </button>
220
- </div>
221
-
222
- {/* 主体:左目录 + 右阅读区 */}
223
- <div className={css.readerBody}>
224
- {navOpen && (
225
- <aside className={css.readerSidebar} style={{ borderColor: themeMeta.dim, background: theme === 'night' ? 'rgba(0,0,0,0.25)' : 'rgba(255,255,255,0.35)' }} ref={sidebarRef}>
226
- {groups.length === 0 ? (
227
- <div className={css.meta} style={{ color: themeMeta.dim, padding: 'var(--nf-space-12)' }}>还没有已写章节,先去工作台创作吧</div>
228
- ) : groups.map(g => {
229
- const collapsed = collapsedVols.includes(g.no)
230
- return (
231
- <div key={g.no} className={css.readerGroup}>
232
- <button
233
- type="button"
234
- className={css.readerGroupTitle}
235
- style={{ color: themeMeta.accent }}
236
- onClick={() => { toggleVol(g.no) }}
237
- title={collapsed ? `展开${g.title}` : `收起${g.title}`}
238
- >
239
- <span className={css.readerGroupArrow}>{collapsed ? '▸' : '▾'}</span>
240
- <span>{g.title}</span>
241
- <span className={css.readerGroupCount}>{g.chapters.length}</span>
242
- </button>
243
- {!collapsed && g.chapters.map(ch => {
244
- const badge = statusBadge(ch)
245
- const active = ch.no === currentNo
246
- return (
247
- <button
248
- key={ch.no}
249
- type="button"
250
- data-no={ch.no}
251
- className={`${css.readerItem} ${active ? css.readerItemActive : ''}`}
252
- style={active ? { background: themeMeta.accent, color: themeMeta.bg, borderColor: themeMeta.accent } : { borderColor: 'transparent', color: themeMeta.fg }}
253
- onClick={() => { void load(ch.no) }}
254
- title={`第${ch.no}章 ${ch.title}`}
255
- >
256
- <span className={css.readerItemNo}>{ch.no}</span>
257
- <span className={css.readerItemTitle}>{ch.title}</span>
258
- {badge.label !== '' && <span className={css.readerItemBadge}>{badge.label}</span>}
259
- </button>
260
- )
261
- })}
262
- </div>
263
- )
264
- })}
265
- </aside>
266
- )}
267
-
268
- {/* 阅读区 */}
269
- <div className={css.readerMain} style={{ background: itemBg }}>
270
- {loadError !== '' ? (
271
- <div className={css.readerError}>{loadError}</div>
272
- ) : loading && markdown === '' ? (
273
- <div className={css.readerLoading}>⏳ 加载中…</div>
274
- ) : current !== undefined && currentNo > 0 ? (
275
- <article className={css.readerArticle} style={{ maxWidth: 720 }}>
276
- <h1 className={css.readerChapterTitle} style={{ color: themeMeta.accent }}>{current.title}</h1>
277
- <div className={css.readerChapterMeta} style={{ color: themeMeta.dim }}>
278
- <span>{statusBadge(current).label}</span>
279
- <span>第 {current.no} 章</span>
280
- {current.chars !== undefined && <span> · {current.chars.toLocaleString()} 字</span>}
281
- </div>
282
- <div className={css.readerText} style={{ fontSize: fontMeta.px, lineHeight: 1.9 }}>
283
- {paragraphs.map((p, i) => <p key={i} className={css.readerPara}>{p}</p>)}
284
- </div>
285
- <div className={css.readerFoot}>
286
- {prevCh !== undefined && (
287
- <button type="button" className={`${css.button} ${css.buttonSmall}`} style={{ borderColor: themeMeta.dim, color: themeMeta.fg }} onClick={() => { void load(prevCh.no) }} title="← 上一章">
288
- ← 第{prevCh.no}章
289
- </button>
290
- )}
291
- <span className={css.meta} style={{ color: themeMeta.dim }}>← → 键盘翻章</span>
292
- {nextCh !== undefined && (
293
- <button type="button" className={`${css.button} ${css.buttonSmall}`} style={{ borderColor: themeMeta.accent, color: themeMeta.accent }} onClick={() => { void load(nextCh.no) }} title="下一章 →">
294
- 第{nextCh.no}章 →
295
- </button>
296
- )}
297
- </div>
298
- </article>
299
- ) : (
300
- <div className={css.readerLoading}>暂无内容</div>
301
- )}
302
- </div>
303
- </div>
304
- </div>
305
- )
306
- }
1
+ /**
2
+ * 阅读页:沉浸式章节阅读(纯只读,零 LLM 消耗)。
3
+ * 布局:顶部工具栏 + 左侧可折叠目录栏(按卷分组)+ 阅读区。
4
+ * 轻量渲染:去掉 md 首行标题,按行分段;字号/主题/阅读进度 localStorage 记忆。
5
+ */
6
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
7
+ import type { NovelApi } from '../api.ts'
8
+ import type { ProjectState, ChapterPlan } from '../../protocol.ts'
9
+ import css from './panel.module.css'
10
+
11
+ const FONT_SIZES = [
12
+ { id: 'sm', px: 15, label: 'A-' },
13
+ { id: 'md', px: 17, label: 'A' },
14
+ { id: 'lg', px: 20, label: 'A+' },
15
+ ] as const
16
+
17
+ const THEMES = [
18
+ { id: 'paper', label: ' 纸白', bg: '#f7f1e3', fg: '#3b3226', accent: '#8b6f47', dim: '#8a7d66' },
19
+ { id: 'eye', label: ' 护眼', bg: '#e8f0e3', fg: '#2f3d2e', accent: '#5f7d5a', dim: '#6f836b' },
20
+ { id: 'night', label: ' 夜间', bg: '#1d2226', fg: '#c9d1d9', accent: '#8aa5c0', dim: '#6d7882' },
21
+ ] as const
22
+
23
+ type FontId = typeof FONT_SIZES[number]['id']
24
+ type ThemeId = typeof THEMES[number]['id']
25
+
26
+ function readPref<T>(key: string, fallback: T): T {
27
+ try {
28
+ const raw = window.localStorage.getItem(key)
29
+ if (raw !== null) return JSON.parse(raw) as T
30
+ } catch { /* ignore */ }
31
+ return fallback
32
+ }
33
+
34
+ function writePref(key: string, value: unknown): void {
35
+ try { window.localStorage.setItem(key, JSON.stringify(value)) } catch { /* ignore */ }
36
+ }
37
+
38
+ /** 章节状态小标(阅读视角)。 */
39
+ function statusBadge(ch: ChapterPlan): { label: string; cls: string } {
40
+ switch (ch.status) {
41
+ case 'approved': return { label: '', cls: css.badgeDone }
42
+ case 'written': return { label: '', cls: css.badgeWritten }
43
+ case 'rejected': return { label: '', cls: css.badgePending }
44
+ default: return { label: '', cls: css.badgePending }
45
+ }
46
+ }
47
+
48
+ /** 目录分组:按卷区间过滤可读章节;卷外章节归入「未分卷」。 */
49
+ function buildGroups(project: ProjectState, readable: ChapterPlan[]): Array<{ title: string; no: number; chapters: ChapterPlan[] }> {
50
+ const vols = project.volumes ?? []
51
+ if (vols.length === 0) return [{ title: '全部章节', no: 0, chapters: readable }]
52
+ const groups: Array<{ title: string; no: number; chapters: ChapterPlan[] }> = []
53
+ for (const v of vols) {
54
+ const inVol = readable.filter(c => c.no >= v.chapterStart && c.no <= v.chapterEnd)
55
+ if (inVol.length > 0) groups.push({ title: `第${v.no}卷 ${v.title}`, no: v.no, chapters: inVol })
56
+ }
57
+ const rest = readable.filter(c => !vols.some(v => c.no >= v.chapterStart && c.no <= v.chapterEnd))
58
+ if (rest.length > 0) groups.push({ title: '未分卷', no: 0, chapters: rest })
59
+ return groups
60
+ }
61
+
62
+ export function ReaderView({
63
+ api,
64
+ project,
65
+ onBack,
66
+ onOpenWorkspace,
67
+ }: {
68
+ api: NovelApi
69
+ project: ProjectState
70
+ onBack: () => void
71
+ onOpenWorkspace: () => void
72
+ }) {
73
+ const prefKey = useMemo(() => {
74
+ const id = project.bookName || 'book'
75
+ return `dsh-novel-forge.reader.${id}`
76
+ }, [project.bookName])
77
+
78
+ /** 可读章节(有正文落盘):approved / written / rejected。 */
79
+ const readable = useMemo(() => project.chapters.filter(c =>
80
+ c.status === 'approved' || c.status === 'written' || c.status === 'rejected',
81
+ ), [project.chapters])
82
+
83
+ const groups = useMemo(() => buildGroups(project, readable), [project, readable])
84
+
85
+ const [currentNo, setCurrentNo] = useState<number>(() => {
86
+ const saved = readPref<number | null>(`${prefKey}.no`, null)
87
+ if (saved !== null && readable.some(c => c.no === saved)) return saved
88
+ // 无记忆 → 最后一章已通过
89
+ const lastApproved = [...readable].reverse().find(c => c.status === 'approved')
90
+ return lastApproved?.no ?? readable[0]?.no ?? 0
91
+ })
92
+ const [markdown, setMarkdown] = useState<string>('')
93
+ const [loading, setLoading] = useState(false)
94
+ const [loadError, setLoadError] = useState<string>('')
95
+ const [font, setFont] = useState<FontId>(() => readPref(`${prefKey}.font`, 'md'))
96
+ const [theme, setTheme] = useState<ThemeId>(() => readPref(`${prefKey}.theme`, 'paper'))
97
+ const [navOpen, setNavOpen] = useState<boolean>(() => readPref(`${prefKey}.nav`, true))
98
+ /** 折叠的卷号(点击卷标题切换;记忆在 localStorage)。 */
99
+ const [collapsedVols, setCollapsedVols] = useState<number[]>(() => readPref(`${prefKey}.collapsed`, []))
100
+ const sidebarRef = useRef<HTMLDivElement>(null)
101
+
102
+ const toggleVol = useCallback((no: number) => {
103
+ setCollapsedVols(prev => {
104
+ const next = prev.includes(no) ? prev.filter(v => v !== no) : [...prev, no]
105
+ writePref(`${prefKey}.collapsed`, next)
106
+ return next
107
+ })
108
+ }, [prefKey])
109
+
110
+ const current = useMemo(() => project.chapters.find(c => c.no === currentNo), [project.chapters, currentNo])
111
+ const idx = readable.findIndex(c => c.no === currentNo)
112
+ const prevCh = idx > 0 ? readable[idx - 1] : undefined
113
+ const nextCh = idx >= 0 && idx < readable.length - 1 ? readable[idx + 1] : undefined
114
+
115
+ const load = useCallback(async (no: number) => {
116
+ if (no === 0) return
117
+ setLoading(true)
118
+ setLoadError('')
119
+ try {
120
+ const res = await api.chapter(no)
121
+ setMarkdown(res.markdown)
122
+ setCurrentNo(no)
123
+ writePref(`${prefKey}.no`, no)
124
+ } catch (err) {
125
+ setLoadError((err as Error).message)
126
+ } finally {
127
+ setLoading(false)
128
+ }
129
+ }, [api, prefKey])
130
+
131
+ // 初始加载
132
+ const initialNo = useRef<number>(0)
133
+ useEffect(() => {
134
+ if (initialNo.current === currentNo && markdown !== '') return
135
+ if (currentNo === 0) return
136
+ initialNo.current = currentNo
137
+ void load(currentNo)
138
+ }, [currentNo, load, markdown])
139
+
140
+ // 键盘翻章
141
+ useEffect(() => {
142
+ const onKey = (e: KeyboardEvent) => {
143
+ if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return
144
+ if (e.key === 'ArrowLeft' && prevCh !== undefined) void load(prevCh.no)
145
+ if (e.key === 'ArrowRight' && nextCh !== undefined) void load(nextCh.no)
146
+ }
147
+ window.addEventListener('keydown', onKey)
148
+ return () => window.removeEventListener('keydown', onKey)
149
+ }, [prevCh, nextCh, load])
150
+
151
+ // 当前章滚动到可见(目录栏内)
152
+ useEffect(() => {
153
+ sidebarRef.current?.querySelector<HTMLElement>(`[data-no="${currentNo}"]`)?.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
154
+ }, [currentNo])
155
+
156
+ const themeMeta = THEMES.find(t => t.id === theme) ?? THEMES[0]
157
+ const fontMeta = FONT_SIZES.find(f => f.id === font) ?? FONT_SIZES[1]
158
+
159
+ /** 轻量渲染:去标题行、按行分段(网文每行一段)。 */
160
+ const paragraphs = useMemo(() => {
161
+ return markdown
162
+ .split(/\r?\n/)
163
+ .map(l => l.replace(/^#{1,6}\s*/, '').trim())
164
+ .filter(Boolean)
165
+ }, [markdown])
166
+
167
+ const itemBg = theme === 'night' ? 'rgba(255,255,255,0.03)' : 'rgba(0,0,0,0.025)'
168
+
169
+ return (
170
+ <div className={css.readerView} style={{ background: themeMeta.bg, color: themeMeta.fg }}>
171
+ {/* 顶部栏 */}
172
+ <div className={css.readerHeader} style={{ borderColor: themeMeta.dim }}>
173
+ <button type="button" className={css.iconButton} title="返回书架" aria-label="返回书架" onClick={onBack}>←</button>
174
+ <span className={css.readerTitle}> 《{project.bookName}》</span>
175
+ <span className={css.meta} style={{ color: themeMeta.dim }}>
176
+ {readable.length > 0 && currentNo > 0 ? `第 ${currentNo} 章 · ${idx + 1}/${readable.length}` : ''}
177
+ </span>
178
+ <button
179
+ type="button"
180
+ className={`${css.button} ${css.buttonSmall} ${navOpen ? css.buttonPrimary : ''}`}
181
+ style={{ marginLeft: 'var(--nf-space-4)' }}
182
+ onClick={() => { setNavOpen(v => { const next = !v; writePref(`${prefKey}.nav`, next); return next }) }}
183
+ title={navOpen ? '收起目录' : '展开目录'}
184
+ >
185
+ ☰ 目录
186
+ </button>
187
+ <div style={{ flex: 1 }} />
188
+ <span className={css.meta} style={{ color: themeMeta.dim }}>字号</span>
189
+ <div className={css.readerSeg}>
190
+ {FONT_SIZES.map(f => (
191
+ <button
192
+ key={f.id}
193
+ type="button"
194
+ className={`${css.readerSegBtn} ${font === f.id ? css.readerSegActive : ''}`}
195
+ style={font === f.id ? { background: themeMeta.accent, color: themeMeta.bg } : undefined}
196
+ onClick={() => { setFont(f.id); writePref(`${prefKey}.font`, f.id) }}
197
+ title={`字号 ${f.label}`}
198
+ >
199
+ {f.label}
200
+ </button>
201
+ ))}
202
+ </div>
203
+ <div className={css.readerSeg} style={{ marginLeft: 'var(--nf-space-8)' }}>
204
+ {THEMES.map(t => (
205
+ <button
206
+ key={t.id}
207
+ type="button"
208
+ className={`${css.readerSegBtn} ${theme === t.id ? css.readerSegActive : ''}`}
209
+ style={theme === t.id ? { background: themeMeta.accent, color: themeMeta.bg } : undefined}
210
+ onClick={() => { setTheme(t.id); writePref(`${prefKey}.theme`, t.id) }}
211
+ title={t.label}
212
+ >
213
+ {t.label}
214
+ </button>
215
+ ))}
216
+ </div>
217
+ <button type="button" className={`${css.button} ${css.buttonSmall}`} style={{ marginLeft: 'var(--nf-space-10)' }} onClick={onOpenWorkspace} title="回到创作工作台">
218
+ 去工作台
219
+ </button>
220
+ </div>
221
+
222
+ {/* 主体:左目录 + 右阅读区 */}
223
+ <div className={css.readerBody}>
224
+ {navOpen && (
225
+ <aside className={css.readerSidebar} style={{ borderColor: themeMeta.dim, background: theme === 'night' ? 'rgba(0,0,0,0.25)' : 'rgba(255,255,255,0.35)' }} ref={sidebarRef}>
226
+ {groups.length === 0 ? (
227
+ <div className={css.meta} style={{ color: themeMeta.dim, padding: 'var(--nf-space-12)' }}>还没有已写章节,先去工作台创作吧</div>
228
+ ) : groups.map(g => {
229
+ const collapsed = collapsedVols.includes(g.no)
230
+ return (
231
+ <div key={g.no} className={css.readerGroup}>
232
+ <button
233
+ type="button"
234
+ className={css.readerGroupTitle}
235
+ style={{ color: themeMeta.accent }}
236
+ onClick={() => { toggleVol(g.no) }}
237
+ title={collapsed ? `展开${g.title}` : `收起${g.title}`}
238
+ >
239
+ <span className={css.readerGroupArrow}>{collapsed ? '▸' : '▾'}</span>
240
+ <span>{g.title}</span>
241
+ <span className={css.readerGroupCount}>{g.chapters.length}</span>
242
+ </button>
243
+ {!collapsed && g.chapters.map(ch => {
244
+ const badge = statusBadge(ch)
245
+ const active = ch.no === currentNo
246
+ return (
247
+ <button
248
+ key={ch.no}
249
+ type="button"
250
+ data-no={ch.no}
251
+ className={`${css.readerItem} ${active ? css.readerItemActive : ''}`}
252
+ style={active ? { background: themeMeta.accent, color: themeMeta.bg, borderColor: themeMeta.accent } : { borderColor: 'transparent', color: themeMeta.fg }}
253
+ onClick={() => { void load(ch.no) }}
254
+ title={`第${ch.no}章 ${ch.title}`}
255
+ >
256
+ <span className={css.readerItemNo}>{ch.no}</span>
257
+ <span className={css.readerItemTitle}>{ch.title}</span>
258
+ {badge.label !== '' && <span className={css.readerItemBadge}>{badge.label}</span>}
259
+ </button>
260
+ )
261
+ })}
262
+ </div>
263
+ )
264
+ })}
265
+ </aside>
266
+ )}
267
+
268
+ {/* 阅读区 */}
269
+ <div className={css.readerMain} style={{ background: itemBg }}>
270
+ {loadError !== '' ? (
271
+ <div className={css.readerError}>{loadError}</div>
272
+ ) : loading && markdown === '' ? (
273
+ <div className={css.readerLoading}>⏳ 加载中…</div>
274
+ ) : current !== undefined && currentNo > 0 ? (
275
+ <article className={css.readerArticle} style={{ maxWidth: 720 }}>
276
+ <h1 className={css.readerChapterTitle} style={{ color: themeMeta.accent }}>{current.title}</h1>
277
+ <div className={css.readerChapterMeta} style={{ color: themeMeta.dim }}>
278
+ <span>{statusBadge(current).label}</span>
279
+ <span>第 {current.no} 章</span>
280
+ {current.chars !== undefined && <span> · {current.chars.toLocaleString()} 字</span>}
281
+ </div>
282
+ <div className={css.readerText} style={{ fontSize: fontMeta.px, lineHeight: 1.9 }}>
283
+ {paragraphs.map((p, i) => <p key={i} className={css.readerPara}>{p}</p>)}
284
+ </div>
285
+ <div className={css.readerFoot}>
286
+ {prevCh !== undefined && (
287
+ <button type="button" className={`${css.button} ${css.buttonSmall}`} style={{ borderColor: themeMeta.dim, color: themeMeta.fg }} onClick={() => { void load(prevCh.no) }} title="← 上一章">
288
+ ← 第{prevCh.no}章
289
+ </button>
290
+ )}
291
+ <span className={css.meta} style={{ color: themeMeta.dim }}>← → 键盘翻章</span>
292
+ {nextCh !== undefined && (
293
+ <button type="button" className={`${css.button} ${css.buttonSmall}`} style={{ borderColor: themeMeta.accent, color: themeMeta.accent }} onClick={() => { void load(nextCh.no) }} title="下一章 →">
294
+ 第{nextCh.no}章 →
295
+ </button>
296
+ )}
297
+ </div>
298
+ </article>
299
+ ) : (
300
+ <div className={css.readerLoading}>暂无内容</div>
301
+ )}
302
+ </div>
303
+ </div>
304
+ </div>
305
+ )
306
+ }