@waterwx/dsh-novel-forge 2.0.0-alpha.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 (206) hide show
  1. package/README.md +286 -285
  2. package/lib/client.js +19561 -19439
  3. package/lib/index.js +8159 -4456
  4. package/lib/types/action-guard.d.ts +61 -0
  5. package/lib/types/ai-scan.d.ts +0 -6
  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 +136 -27
  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/llmLive.d.ts +31 -0
  14. package/lib/types/client/locales.d.ts +15 -70
  15. package/lib/types/client/mount.d.ts +0 -2
  16. package/lib/types/client/panel/AiTag.d.ts +1 -0
  17. package/lib/types/client/panel/AssistantTab.d.ts +4 -1
  18. package/lib/types/client/panel/AuthorHome.d.ts +13 -11
  19. package/lib/types/client/panel/BookAnalysisView.d.ts +4 -0
  20. package/lib/types/client/panel/CmdPalette.d.ts +12 -0
  21. package/lib/types/client/panel/CreateBookView.d.ts +5 -1
  22. package/lib/types/client/panel/DirectorView.d.ts +7 -0
  23. package/lib/types/client/panel/IdeaInspirationView.d.ts +6 -0
  24. package/lib/types/client/panel/KnowledgeBaseView.d.ts +4 -0
  25. package/lib/types/client/panel/LiveFeedLog.d.ts +13 -0
  26. package/lib/types/client/panel/MarketRadarView.d.ts +5 -0
  27. package/lib/types/client/panel/ModelManager.d.ts +2 -2
  28. package/lib/types/client/panel/NovelPanel.d.ts +6 -1
  29. package/lib/types/client/panel/PageHeader.d.ts +18 -0
  30. package/lib/types/client/panel/PromptSlotsView.d.ts +11 -0
  31. package/lib/types/client/panel/SettingsView.d.ts +12 -9
  32. package/lib/types/client/panel/ShelfView.d.ts +10 -1
  33. package/lib/types/client/panel/SubPage.d.ts +27 -0
  34. package/lib/types/client/panel/TensionView.d.ts +18 -0
  35. package/lib/types/client/panel/TimelineView.d.ts +18 -0
  36. package/lib/types/client/panel/helpers.d.ts +5 -3
  37. package/lib/types/client/panel/market-radar-state.d.ts +56 -0
  38. package/lib/types/client/panel/toast.d.ts +2 -0
  39. package/lib/types/client/panel/views.d.ts +21 -35
  40. package/lib/types/client/run-status.d.ts +21 -0
  41. package/lib/types/client/sidebar-entry.d.ts +0 -2
  42. package/lib/types/docx-core.d.ts +16 -0
  43. package/lib/types/docx.d.ts +2 -3
  44. package/lib/types/engine.d.ts +78 -103
  45. package/lib/types/global-assets.d.ts +6 -0
  46. package/lib/types/home.d.ts +11 -0
  47. package/lib/types/index.d.ts +26 -12
  48. package/lib/types/llm-live.d.ts +70 -0
  49. package/lib/types/llm-retry.d.ts +38 -0
  50. package/lib/types/market-radar-scan.d.ts +18 -0
  51. package/lib/types/market-radar-sources.d.ts +31 -0
  52. package/lib/types/migrate-output.d.ts +25 -0
  53. package/lib/types/model-capability.d.ts +93 -0
  54. package/lib/types/output-budget.d.ts +86 -0
  55. package/lib/types/prompt-slots.d.ts +57 -0
  56. package/lib/types/prompting.d.ts +10 -0
  57. package/lib/types/protocol.d.ts +816 -601
  58. package/lib/types/revision.d.ts +76 -0
  59. package/lib/types/routes.d.ts +9 -3
  60. package/lib/types/run.d.ts +23 -1
  61. package/lib/types/settings-persist.d.ts +59 -0
  62. package/lib/types/snapshots.d.ts +46 -0
  63. package/lib/types/stage-contract.d.ts +58 -0
  64. package/lib/types/tension.d.ts +53 -0
  65. package/lib/types/timeline.d.ts +37 -0
  66. package/package.json +50 -25
  67. package/src/action-guard.ts +127 -0
  68. package/src/ai-scan.ts +0 -24
  69. package/src/assets.ts +1801 -879
  70. package/src/assistant.ts +1098 -770
  71. package/src/author-assets.ts +131 -147
  72. package/src/bookshelf.ts +193 -167
  73. package/src/client/api.ts +767 -617
  74. package/src/client/chip-prefs.ts +121 -0
  75. package/src/client/docx.ts +32 -69
  76. package/src/client/index.ts +2 -0
  77. package/src/client/llmLive.ts +111 -0
  78. package/src/client/locales.ts +15 -274
  79. package/src/client/mount.tsx +3 -2
  80. package/src/client/panel/AdaptModeView.tsx +535 -520
  81. package/src/client/panel/AiTag.tsx +7 -0
  82. package/src/client/panel/AssetsTab.tsx +448 -458
  83. package/src/client/panel/AssistantTab.tsx +355 -301
  84. package/src/client/panel/AuthorAssetsView.tsx +205 -207
  85. package/src/client/panel/AuthorHome.tsx +309 -204
  86. package/src/client/panel/BookAnalysisView.tsx +63 -0
  87. package/src/client/panel/CmdPalette.tsx +102 -0
  88. package/src/client/panel/CreateBookView.tsx +366 -352
  89. package/src/client/panel/DirectorView.tsx +200 -0
  90. package/src/client/panel/GlobalAssetLibraryView.tsx +168 -172
  91. package/src/client/panel/IdeaInspirationView.tsx +76 -0
  92. package/src/client/panel/ImportModal.tsx +302 -302
  93. package/src/client/panel/KnowledgeBaseView.tsx +125 -0
  94. package/src/client/panel/LiveFeedLog.tsx +204 -0
  95. package/src/client/panel/MarketRadarView.tsx +415 -0
  96. package/src/client/panel/ModelManager.tsx +205 -205
  97. package/src/client/panel/NovelPanel.tsx +3144 -2205
  98. package/src/client/panel/PageHeader.tsx +37 -0
  99. package/src/client/panel/ProgressConsole.tsx +63 -64
  100. package/src/client/panel/PromptSlotsView.tsx +97 -0
  101. package/src/client/panel/ReaderView.tsx +306 -306
  102. package/src/client/panel/RunPanel.tsx +56 -24
  103. package/src/client/panel/SettingsView.tsx +175 -106
  104. package/src/client/panel/ShelfView.tsx +63 -39
  105. package/src/client/panel/SubPage.tsx +42 -0
  106. package/src/client/panel/TensionView.tsx +212 -0
  107. package/src/client/panel/TimelineView.tsx +208 -0
  108. package/src/client/panel/WorldTab.tsx +202 -202
  109. package/src/client/panel/helpers.ts +7 -5
  110. package/src/client/panel/market-radar-state.ts +79 -0
  111. package/src/client/panel/panel.module.css +5767 -4360
  112. package/src/client/panel/toast.ts +51 -0
  113. package/src/client/panel/views.tsx +316 -314
  114. package/src/client/run-status.ts +171 -0
  115. package/src/client/sidebar-entry.ts +3 -3
  116. package/src/docx-core.ts +53 -0
  117. package/src/docx.ts +44 -83
  118. package/src/engine.ts +1197 -1638
  119. package/src/global-assets.ts +58 -0
  120. package/src/home.ts +33 -0
  121. package/src/index.ts +181 -101
  122. package/src/llm-live.ts +245 -0
  123. package/src/llm-retry.ts +77 -0
  124. package/src/market-radar-scan.ts +35 -0
  125. package/src/market-radar-sources.ts +173 -0
  126. package/src/migrate-output.ts +76 -0
  127. package/src/model-capability.ts +215 -0
  128. package/src/novel-context.ts +286 -287
  129. package/src/output-budget.ts +147 -0
  130. package/src/prompt-slots.ts +125 -0
  131. package/src/prompting.ts +26 -0
  132. package/src/protocol.ts +835 -614
  133. package/src/revision.ts +283 -0
  134. package/src/routes.ts +1286 -910
  135. package/src/run.ts +113 -17
  136. package/src/settings-persist.ts +114 -0
  137. package/src/snapshots.ts +174 -0
  138. package/src/stage-contract.ts +186 -0
  139. package/src/tension.ts +251 -0
  140. package/src/timeline.ts +159 -0
  141. package/assets/styles/arcane-thick-3d.webp +0 -0
  142. package/assets/styles/chinese-ink.webp +0 -0
  143. package/assets/styles/clay-stopmotion-3d.webp +0 -0
  144. package/assets/styles/dark-ghibli.webp +0 -0
  145. package/assets/styles/dark-gothic-3d.webp +0 -0
  146. package/assets/styles/film-photo.webp +0 -0
  147. package/assets/styles/guofeng-heavy.webp +0 -0
  148. package/assets/styles/guoman-2d-gufeng.webp +0 -0
  149. package/assets/styles/guoman-3d-tianyuan.webp +0 -0
  150. package/assets/styles/guoman-3d-xuanhuan.webp +0 -0
  151. package/assets/styles/horror-game-3d.webp +0 -0
  152. package/assets/styles/hyperreal-3d.webp +0 -0
  153. package/assets/styles/japan-cel-dark.webp +0 -0
  154. package/assets/styles/korean-webtoon.webp +0 -0
  155. package/assets/styles/low-cyberpunk-3d.webp +0 -0
  156. package/assets/styles/nextgen-cg.webp +0 -0
  157. package/assets/styles/noir-film.webp +0 -0
  158. package/assets/styles/openworld-survival.webp +0 -0
  159. package/assets/styles/paper-cut-3d.webp +0 -0
  160. package/assets/styles/pixar-adult-3d.webp +0 -0
  161. package/assets/styles/pop-marvel-2d.webp +0 -0
  162. package/assets/styles/sandiao-qban.webp +0 -0
  163. package/assets/styles/shinkai-anime.webp +0 -0
  164. package/assets/styles/tactical-stealth.webp +0 -0
  165. package/assets/styles/urban-lightreal-3d.webp +0 -0
  166. package/assets/styles/vaporwave-3d.webp +0 -0
  167. package/assets/styles/vhs90.webp +0 -0
  168. package/lib/types/client/panel/BookshelfBar.d.ts +0 -11
  169. package/lib/types/client/panel/FlowGuide.d.ts +0 -22
  170. package/lib/types/client/panel/MangaRoleLibrary.d.ts +0 -20
  171. package/lib/types/client/panel/MangaWorkspace.d.ts +0 -12
  172. package/lib/types/client/panel/ModelPicker.d.ts +0 -11
  173. package/lib/types/client/panel/ProgressHomeView.d.ts +0 -5
  174. package/lib/types/client/panel/PropLibrary.d.ts +0 -8
  175. package/lib/types/client/panel/SceneLibrary.d.ts +0 -16
  176. package/lib/types/client/panel/StoryboardTab.d.ts +0 -19
  177. package/lib/types/client/panel/StyleCard.d.ts +0 -8
  178. package/lib/types/client/panel/StyleGallery.d.ts +0 -1
  179. package/lib/types/comic-presets.d.ts +0 -12
  180. package/lib/types/manga-genre-rules.d.ts +0 -22
  181. package/lib/types/prompt-utils.d.ts +0 -35
  182. package/lib/types/review-policy.d.ts +0 -97
  183. package/lib/types/shot-language.d.ts +0 -46
  184. package/lib/types/story-beat-language.d.ts +0 -35
  185. package/lib/types/style-library.d.ts +0 -33
  186. package/skills/chapter-batch/SKILL.md +0 -81
  187. package/src/client/mount.tsx._new +0 -97
  188. package/src/client/mount.tsx._nf +0 -97
  189. package/src/client/mount.tsx._tmp +0 -97
  190. package/src/client/panel/BookshelfBar.tsx +0 -170
  191. package/src/client/panel/FlowGuide.tsx +0 -84
  192. package/src/client/panel/MangaRoleLibrary.tsx +0 -1002
  193. package/src/client/panel/MangaWorkspace.tsx +0 -715
  194. package/src/client/panel/ProgressHomeView.tsx +0 -87
  195. package/src/client/panel/PropLibrary.tsx +0 -175
  196. package/src/client/panel/SceneLibrary.tsx +0 -453
  197. package/src/client/panel/StoryboardTab.tsx +0 -472
  198. package/src/client/panel/StyleCard.tsx +0 -90
  199. package/src/client/panel/StyleGallery.tsx +0 -58
  200. package/src/comic-presets.ts +0 -67
  201. package/src/manga-genre-rules.ts +0 -87
  202. package/src/prompt-utils.ts +0 -73
  203. package/src/review-policy.ts +0 -161
  204. package/src/shot-language.ts +0 -181
  205. package/src/story-beat-language.ts +0 -130
  206. package/src/style-library.ts +0 -84
@@ -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
+ }