@waterwx/dsh-novel-forge 1.3.1 → 1.3.2-alpha

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 (161) hide show
  1. package/README.md +288 -279
  2. package/lib/client.js +56309 -12221
  3. package/lib/index.js +14239 -5595
  4. package/lib/types/action-guard.d.ts +61 -0
  5. package/lib/types/ai-scan.d.ts +36 -0
  6. package/lib/types/assets.d.ts +24 -9
  7. package/lib/types/assistant.d.ts +29 -0
  8. package/lib/types/author-assets.d.ts +25 -0
  9. package/lib/types/bookshelf.d.ts +11 -1
  10. package/lib/types/client/api.d.ts +186 -10
  11. package/lib/types/client/chip-prefs.d.ts +36 -0
  12. package/lib/types/client/docx.d.ts +2 -0
  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 +28 -67
  16. package/lib/types/client/panel/AdaptModeView.d.ts +5 -0
  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/AuthorAssetsView.d.ts +4 -0
  20. package/lib/types/client/panel/AuthorHome.d.ts +34 -0
  21. package/lib/types/client/panel/BookAnalysisView.d.ts +4 -0
  22. package/lib/types/client/panel/CmdPalette.d.ts +12 -0
  23. package/lib/types/client/panel/CreateBookView.d.ts +5 -1
  24. package/lib/types/client/panel/DirectorView.d.ts +7 -0
  25. package/lib/types/client/panel/GlobalAssetLibraryView.d.ts +4 -0
  26. package/lib/types/client/panel/IdeaInspirationView.d.ts +6 -0
  27. package/lib/types/client/panel/ImportModal.d.ts +7 -0
  28. package/lib/types/client/panel/KnowledgeBaseView.d.ts +4 -0
  29. package/lib/types/client/panel/LiveFeedLog.d.ts +13 -0
  30. package/lib/types/client/panel/MarketRadarView.d.ts +5 -0
  31. package/lib/types/client/panel/ModelManager.d.ts +14 -0
  32. package/lib/types/client/panel/NovelPanel.d.ts +6 -1
  33. package/lib/types/client/panel/PageHeader.d.ts +18 -0
  34. package/lib/types/client/panel/ProgressConsole.d.ts +17 -0
  35. package/lib/types/client/panel/ProgressHomeView.d.ts +5 -0
  36. package/lib/types/client/panel/PromptSlotsView.d.ts +11 -0
  37. package/lib/types/client/panel/ReasoningSection.d.ts +10 -0
  38. package/lib/types/client/panel/SettingsView.d.ts +19 -0
  39. package/lib/types/client/panel/ShelfView.d.ts +12 -1
  40. package/lib/types/client/panel/SubPage.d.ts +27 -0
  41. package/lib/types/client/panel/TensionView.d.ts +18 -0
  42. package/lib/types/client/panel/TimelineView.d.ts +18 -0
  43. package/lib/types/client/panel/helpers.d.ts +5 -3
  44. package/lib/types/client/panel/toast.d.ts +2 -0
  45. package/lib/types/client/panel/views.d.ts +25 -7
  46. package/lib/types/client/run-status.d.ts +21 -0
  47. package/lib/types/client/text.d.ts +9 -0
  48. package/lib/types/docx-core.d.ts +16 -0
  49. package/lib/types/docx.d.ts +2 -3
  50. package/lib/types/engine.d.ts +186 -45
  51. package/lib/types/global-assets.d.ts +6 -0
  52. package/lib/types/index.d.ts +22 -8
  53. package/lib/types/llm-live.d.ts +75 -0
  54. package/lib/types/llm-retry.d.ts +38 -0
  55. package/lib/types/market-radar-scan.d.ts +19 -0
  56. package/lib/types/market-radar-sources.d.ts +31 -0
  57. package/lib/types/migrate-output.d.ts +25 -0
  58. package/lib/types/novel-context.d.ts +80 -0
  59. package/lib/types/prompt-slots.d.ts +57 -0
  60. package/lib/types/prompt-utils.d.ts +35 -0
  61. package/lib/types/prompting.d.ts +10 -0
  62. package/lib/types/protocol.d.ts +1362 -166
  63. package/lib/types/review-policy.d.ts +97 -0
  64. package/lib/types/revision.d.ts +76 -0
  65. package/lib/types/routes.d.ts +4 -0
  66. package/lib/types/run.d.ts +23 -1
  67. package/lib/types/snapshots.d.ts +46 -0
  68. package/lib/types/stage-contract.d.ts +60 -0
  69. package/lib/types/tension.d.ts +53 -0
  70. package/lib/types/timeline.d.ts +37 -0
  71. package/package.json +52 -28
  72. package/src/action-guard.ts +127 -0
  73. package/src/ai-scan.ts +179 -0
  74. package/src/assets.ts +1806 -520
  75. package/src/assistant.ts +1098 -770
  76. package/src/author-assets.ts +149 -0
  77. package/src/bookshelf.ts +187 -151
  78. package/src/client/api.ts +378 -21
  79. package/src/client/chip-prefs.ts +121 -0
  80. package/src/client/docx.ts +37 -69
  81. package/src/client/index.ts +3 -1
  82. package/src/client/llmLive.ts +111 -0
  83. package/src/client/locales.ts +28 -253
  84. package/src/client/mount.tsx +1 -1
  85. package/src/client/panel/AdaptModeView.tsx +535 -0
  86. package/src/client/panel/AiTag.tsx +7 -0
  87. package/src/client/panel/AssetsTab.tsx +448 -458
  88. package/src/client/panel/AssistantTab.tsx +355 -301
  89. package/src/client/panel/AuthorAssetsView.tsx +206 -0
  90. package/src/client/panel/AuthorHome.tsx +309 -0
  91. package/src/client/panel/BookAnalysisView.tsx +63 -0
  92. package/src/client/panel/CmdPalette.tsx +102 -0
  93. package/src/client/panel/CreateBookView.tsx +366 -348
  94. package/src/client/panel/DirectorView.tsx +200 -0
  95. package/src/client/panel/GlobalAssetLibraryView.tsx +168 -0
  96. package/src/client/panel/IdeaInspirationView.tsx +76 -0
  97. package/src/client/panel/ImportModal.tsx +303 -0
  98. package/src/client/panel/KnowledgeBaseView.tsx +125 -0
  99. package/src/client/panel/LiveFeedLog.tsx +204 -0
  100. package/src/client/panel/MarketRadarView.tsx +377 -0
  101. package/src/client/panel/ModelManager.tsx +205 -0
  102. package/src/client/panel/NovelPanel.tsx +3135 -2651
  103. package/src/client/panel/PageHeader.tsx +37 -0
  104. package/src/client/panel/ProgressConsole.tsx +63 -0
  105. package/src/client/panel/PromptSlotsView.tsx +97 -0
  106. package/src/client/panel/ReaderView.tsx +306 -306
  107. package/src/client/panel/ReasoningSection.tsx +31 -0
  108. package/src/client/panel/RunPanel.tsx +64 -32
  109. package/src/client/panel/SettingsView.tsx +354 -0
  110. package/src/client/panel/ShelfView.tsx +266 -236
  111. package/src/client/panel/SubPage.tsx +42 -0
  112. package/src/client/panel/TensionView.tsx +212 -0
  113. package/src/client/panel/TimelineView.tsx +208 -0
  114. package/src/client/panel/WorldTab.tsx +202 -202
  115. package/src/client/panel/helpers.ts +7 -5
  116. package/src/client/panel/panel.module.css +4481 -1655
  117. package/src/client/panel/toast.ts +51 -0
  118. package/src/client/panel/views.tsx +401 -314
  119. package/src/client/run-status.ts +171 -0
  120. package/src/client/sidebar-entry.ts +1 -1
  121. package/src/client/text.ts +28 -0
  122. package/src/docx-core.ts +53 -0
  123. package/src/docx.ts +44 -83
  124. package/src/engine.ts +4672 -2943
  125. package/src/global-assets.ts +59 -0
  126. package/src/index.ts +89 -33
  127. package/src/llm-live.ts +260 -0
  128. package/src/llm-retry.ts +77 -0
  129. package/src/market-radar-scan.ts +41 -0
  130. package/src/market-radar-sources.ts +173 -0
  131. package/src/migrate-output.ts +76 -0
  132. package/src/novel-context.ts +286 -0
  133. package/src/prompt-slots.ts +125 -0
  134. package/src/prompting.ts +26 -0
  135. package/src/protocol.ts +1388 -158
  136. package/src/review-policy.ts +161 -0
  137. package/src/revision.ts +283 -0
  138. package/src/routes.ts +1934 -434
  139. package/src/run.ts +118 -21
  140. package/src/snapshots.ts +174 -0
  141. package/src/stage-contract.ts +189 -0
  142. package/src/tension.ts +251 -0
  143. package/src/timeline.ts +159 -0
  144. package/lib/client.js.map +0 -1
  145. package/lib/index.js.map +0 -1
  146. package/lib/types/comic-presets.d.ts +0 -12
  147. package/scripts/assistant-frames.txt +0 -8
  148. package/scripts/diagnose-adapter.mts +0 -81
  149. package/scripts/diagnose-assistant.mjs +0 -58
  150. package/scripts/diagnose-bible.mjs +0 -72
  151. package/scripts/diagnose-harness.mts +0 -75
  152. package/scripts/novel-forge-restart.log +0 -10
  153. package/scripts/novel-forge-web.stderr.log +0 -2
  154. package/scripts/novel-forge-web.stdout.log +0 -1
  155. package/scripts/outline-sample.txt +0 -449
  156. package/scripts/probe.mjs +0 -6
  157. package/scripts/restart-web.ps1 +0 -88
  158. package/scripts/smoke.mts +0 -72
  159. package/scripts/upload-github.ps1 +0 -51
  160. package/src/client/panel/BookshelfBar.tsx +0 -170
  161. package/src/comic-presets.ts +0 -67
@@ -1,236 +1,266 @@
1
- /**
2
- * 书架首页视图:书卡网格(封面/书名/简介/进度)+ 搜索筛选 + 开书入口。
3
- * 进入小说工坊默认展示;点击书卡进入该书工作台,+ 进入开书向导页。
4
- */
5
- import { useEffect, useMemo, useState } from 'react'
6
- import type { NovelApi } from '../api.ts'
7
- import type { BookshelfSnapshot } from '../../protocol.ts'
8
- import css from './panel.module.css'
9
-
10
- /** 相对时间(人性化:刚刚 / N 分钟前 / N 小时前 / N 天前 / 日期)。 */
11
- function relativeTime(iso: string): string {
12
- const t = new Date(iso).getTime()
13
- if (!Number.isFinite(t)) return '—'
14
- const diff = Date.now() - t
15
- if (diff < 60_000) return '刚刚'
16
- if (diff < 3_600_000) return `${Math.floor(diff / 60_000)} 分钟前`
17
- if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)} 小时前`
18
- if (diff < 7 * 86_400_000) return `${Math.floor(diff / 86_400_000)} 天前`
19
- return new Date(t).toLocaleDateString('zh-CN', { month: 'numeric', day: 'numeric' })
20
- }
21
-
22
- /** 一本书的状态标签。 */
23
- function bookStatus(book: BookshelfSnapshot['books'][number]): 'none' | 'done' | 'active' {
24
- if (!book.hasProject) return 'none'
25
- return book.total > 0 && book.done >= book.total ? 'done' : 'active'
26
- }
27
-
28
- /** 一张书卡(封面懒加载)。 */
29
- function BookCard({
30
- api,
31
- book,
32
- active,
33
- onOpen,
34
- onRead,
35
- }: {
36
- api: NovelApi
37
- book: BookshelfSnapshot['books'][number]
38
- active: boolean
39
- onOpen: () => void
40
- onRead: () => void
41
- }) {
42
- const [cover, setCover] = useState<string | null>(null)
43
-
44
- useEffect(() => {
45
- if (!book.hasCover) return
46
- let cancelled = false
47
- void api.coverGet(book.outputDir).then(result => {
48
- if (!cancelled) setCover(result.dataUrl)
49
- }).catch(() => { /* best-effort */ })
50
- return () => { cancelled = true }
51
- }, [api, book.hasCover, book.outputDir])
52
-
53
- const ratio = book.total > 0 ? Math.min(book.done / book.total, 1) : 0
54
- const status = bookStatus(book)
55
- const statusLabel = status === 'none' ? '未开书' : status === 'done' ? '已完结' : '进行中'
56
-
57
- return (
58
- <div
59
- className={`${css.bookCard} ${active ? css.bookCardActive : ''}`}
60
- onClick={onOpen}
61
- title={`打开《${book.bookName}》`}
62
- >
63
- <div className={css.bookCardCover}>
64
- {cover !== null ? (
65
- <img src={cover} alt={`《${book.bookName}》封面`} />
66
- ) : (
67
- <div className={css.bookCardCoverFallback}>
68
- <span className={css.bookCardCoverTitle}>{book.bookName.slice(0, 4)}</span>
69
- <span className={css.meta}>暂无封面</span>
70
- </div>
71
- )}
72
- </div>
73
- <div className={css.bookCardBody}>
74
- <div className={css.bookCardTitleRow}>
75
- <span className={css.bookCardName}>{book.bookName}</span>
76
- <span className={`${css.badge} ${status === 'none' ? css.badgePending : status === 'done' ? css.badgeDone : css.badgeWritten}`}>
77
- {statusLabel}
78
- </span>
79
- </div>
80
- <span className={`${css.meta} ${css.bookCardBlurb}`} title={book.blurb ?? ''}>
81
- {book.blurb !== undefined && book.blurb !== '' ? book.blurb : '暂无简介'}
82
- </span>
83
- <div className={css.bookCardProgressBar}>
84
- <div className={css.bookCardProgressFill} style={{ width: `${Math.round(ratio * 100)}%` }} />
85
- </div>
86
- <div className={css.bookCardMetaRow}>
87
- <span className={css.meta}>{book.total > 0 ? `已完成 ${book.done} / ${book.total} 章` : '尚未规划章节'}</span>
88
- {book.hasProject && <span className={css.meta} title="最近活动时间">更新于 {relativeTime(book.updatedAt)}</span>}
89
- </div>
90
- <div className={css.bookCardActions} onClick={e => { e.stopPropagation() }}>
91
- <button
92
- type="button"
93
- className={`${css.button} ${css.buttonSmall} ${css.buttonPrimary}`}
94
- onClick={onOpen}
95
- title="进入创作工作台(大纲/章节/审稿/设定)"
96
- >
97
- 📝 进入工作台
98
- </button>
99
- <button
100
- type="button"
101
- className={`${css.button} ${css.buttonSmall}`}
102
- disabled={!book.hasProject || book.done === 0}
103
- onClick={onRead}
104
- title={book.hasProject && book.done > 0 ? '沉浸式阅读已写章节' : '尚无已写章节,先去工作台创作'}
105
- >
106
- 📖 阅读
107
- </button>
108
- </div>
109
- </div>
110
- </div>
111
- )
112
- }
113
-
114
- /** 书架首页。 */
115
- export function ShelfView({
116
- api,
117
- shelf,
118
- onOpenBook,
119
- onReadBook,
120
- onAddBook,
121
- }: {
122
- api: NovelApi
123
- shelf: BookshelfSnapshot
124
- /** 点击书卡:激活该书并进入工作台。 */
125
- onOpenBook: (id: string) => void
126
- /** 点击「阅读」:激活该书并进入沉浸式阅读页。 */
127
- onReadBook: (id: string) => void
128
- /** 点击+:进入开书向导页。 */
129
- onAddBook: () => void
130
- }) {
131
- const [query, setQuery] = useState('')
132
- /** 筛选:all=全部 / active=进行中 / done=已完结 / none=未开书。 */
133
- const [filter, setFilter] = useState<'all' | 'active' | 'done' | 'none'>('all')
134
-
135
- const stats = useMemo(() => {
136
- let active = 0, done = 0, none = 0
137
- for (const b of shelf.books) {
138
- const s = bookStatus(b)
139
- if (s === 'none') none++
140
- else if (s === 'done') done++
141
- else active++
142
- }
143
- return { active, done, none }
144
- }, [shelf.books])
145
-
146
- const visible = useMemo(() => {
147
- const q = query.trim().toLowerCase()
148
- return shelf.books.filter(b => {
149
- if (filter !== 'all' && bookStatus(b) !== filter) return false
150
- if (q === '') return true
151
- return b.bookName.toLowerCase().includes(q) || (b.blurb ?? '').toLowerCase().includes(q)
152
- })
153
- }, [shelf.books, query, filter])
154
-
155
- const filters: Array<{ id: 'all' | 'active' | 'done' | 'none'; label: string; count: number }> = [
156
- { id: 'all', label: '全部', count: shelf.books.length },
157
- { id: 'active', label: '进行中', count: stats.active },
158
- { id: 'done', label: '已完结', count: stats.done },
159
- { id: 'none', label: '未开书', count: stats.none },
160
- ]
161
-
162
- return (
163
- <div className={css.shelfView}>
164
- <div className={css.shelfHeader}>
165
- <div className={css.shelfTitleRow}>
166
- <h2 className={css.panelTitle} style={{ margin: 0 }}>📚 书架</h2>
167
- {shelf.books.length > 0 && (
168
- <span className={css.meta}>
169
- {shelf.books.length} 本书 · {stats.active} 进行中 · {stats.done} 已完结 · {stats.none} 未开书
170
- </span>
171
- )}
172
- </div>
173
- <div className={css.shelfToolbar}>
174
- <input
175
- className={`${css.input} ${css.shelfSearch}`}
176
- type="search"
177
- placeholder="🔍 搜索书名 / 简介…"
178
- value={query}
179
- onChange={e => { setQuery(e.target.value) }}
180
- />
181
- </div>
182
- <div className={css.shelfFilters} style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
183
- {filters.map(f => (
184
- <button
185
- key={f.id}
186
- type="button"
187
- className={`${css.button} ${filter === f.id ? css.buttonPrimary : ''}`}
188
- style={{ fontSize: 14, flex: 1 }}
189
- onClick={() => { setFilter(f.id) }}
190
- >
191
- {f.label}{f.count > 0 ? `(${f.count})` : ''}
192
- </button>
193
- ))}
194
- </div>
195
- </div>
196
-
197
- {shelf.books.length === 0 ? (
198
- /* 空书架引导卡 */
199
- <div className={css.shelfEmpty}>
200
- <span className={css.shelfEmptyIcon}>📖</span>
201
- <span className={css.shelfEmptyTitle}>你的创作从这里开始</span>
202
- <span className={css.meta}>开一本书:粘贴大纲或导入 docx,书名自动识别,开书即建项目</span>
203
- <button type="button" className={`${css.button} ${css.buttonPrimary}`} onClick={onAddBook}>
204
- + 开第一本书
205
- </button>
206
- </div>
207
- ) : visible.length === 0 ? (
208
- <div className={css.shelfEmpty} style={{ minHeight: 160 }}>
209
- <span className={css.shelfEmptyIcon}>🔍</span>
210
- <span className={css.shelfEmptyTitle}>没有符合条件的书</span>
211
- <span className={css.meta}>换个关键词或筛选条件试试</span>
212
- </div>
213
- ) : (
214
- <div className={css.shelfGrid}>
215
- {visible.map(book => (
216
- <BookCard
217
- key={book.id}
218
- api={api}
219
- book={book}
220
- active={book.id === shelf.activeBookId}
221
- onOpen={() => { onOpenBook(book.id) }}
222
- onRead={() => { onReadBook(book.id) }}
223
- />
224
- ))}
225
-
226
- {/* 开书入口:进入独立向导页 */}
227
- <div className={`${css.bookCard} ${css.bookAddCard}`} onClick={onAddBook}>
228
- <div className={css.bookAddIcon}>+</div>
229
- <span>开一本新书</span>
230
- <span className={css.meta}>书名 + 大纲,开书即建项目</span>
231
- </div>
232
- </div>
233
- )}
234
- </div>
235
- )
236
- }
1
+ /**
2
+ * 书架首页视图:书卡网格(封面/书名/简介/进度)+ 搜索筛选 + 开书入口。
3
+ * 进入小说工坊默认展示;点击书卡进入该书工作台,+ 进入开书向导页。
4
+ */
5
+ import { useEffect, useMemo, useState, type ReactNode } from 'react'
6
+ import { BookOpen, Download, PenLine, Plus, Search, Wand2 } from 'lucide-react'
7
+ import type { NovelApi } from '../api.ts'
8
+ import type { BookshelfSnapshot } from '../../protocol.ts'
9
+ import css from './panel.module.css'
10
+
11
+ /** 相对时间(人性化:刚刚 / N 分钟前 / N 小时前 / N 天前 / 日期)。 */
12
+ function relativeTime(iso: string): string {
13
+ const t = new Date(iso).getTime()
14
+ if (!Number.isFinite(t)) return '—'
15
+ const diff = Date.now() - t
16
+ if (diff < 60_000) return '刚刚'
17
+ if (diff < 3_600_000) return `${Math.floor(diff / 60_000)} 分钟前`
18
+ if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)} 小时前`
19
+ if (diff < 7 * 86_400_000) return `${Math.floor(diff / 86_400_000)} 天前`
20
+ return new Date(t).toLocaleDateString('zh-CN', { month: 'numeric', day: 'numeric' })
21
+ }
22
+
23
+ /** 一本书的状态标签。 */
24
+ function bookStatus(book: BookshelfSnapshot['books'][number]): 'none' | 'done' | 'active' {
25
+ if (!book.hasProject) return 'none'
26
+ return book.total > 0 && book.done >= book.total ? 'done' : 'active'
27
+ }
28
+
29
+ /** 一张书卡(封面懒加载)。 */
30
+ function BookCard({
31
+ api,
32
+ book,
33
+ active,
34
+ onOpen,
35
+ onRead,
36
+ }: {
37
+ api: NovelApi
38
+ book: BookshelfSnapshot['books'][number]
39
+ active: boolean
40
+ onOpen: () => void
41
+ onRead: () => void
42
+ }) {
43
+ const [cover, setCover] = useState<string | null>(null)
44
+
45
+ useEffect(() => {
46
+ if (!book.hasCover) return
47
+ let cancelled = false
48
+ void api.coverGet(book.outputDir).then(result => {
49
+ if (!cancelled) setCover(result.dataUrl)
50
+ }).catch(() => { /* best-effort */ })
51
+ return () => { cancelled = true }
52
+ }, [api, book.hasCover, book.outputDir])
53
+
54
+ const ratio = book.total > 0 ? Math.min(book.done / book.total, 1) : 0
55
+ const status = bookStatus(book)
56
+ const statusLabel = status === 'none' ? '未开书' : status === 'done' ? '已完结' : '进行中'
57
+
58
+ return (
59
+ <div
60
+ className={`${css.bookCard} ${active ? css.bookCardActive : ''}`}
61
+ onClick={onOpen}
62
+ title={`打开《${book.bookName}》`}
63
+ >
64
+ <div className={css.bookCardCover}>
65
+ {cover !== null ? (
66
+ <img src={cover} alt={`《${book.bookName}》封面`} />
67
+ ) : (
68
+ <div className={css.bookCardCoverFallback}>
69
+ <span className={css.bookCardCoverTitle}>{book.bookName.slice(0, 4)}</span>
70
+ <span className={css.meta}>暂无封面</span>
71
+ </div>
72
+ )}
73
+ </div>
74
+ <div className={css.bookCardBody}>
75
+ <div className={css.bookCardTitleRow}>
76
+ <span className={css.bookCardName}>{book.bookName}</span>
77
+ <span className={`${css.badge} ${status === 'none' ? css.badgePending : status === 'done' ? css.badgeDone : css.badgeWritten}`}>
78
+ {statusLabel}
79
+ </span>
80
+ </div>
81
+ <span className={`${css.meta} ${css.bookCardBlurb}`} title={book.blurb ?? ''}>
82
+ {book.blurb !== undefined && book.blurb !== '' ? book.blurb : '暂无简介'}
83
+ </span>
84
+ <div className={css.bookCardProgressBar}>
85
+ <div className={css.bookCardProgressFill} style={{ width: `${Math.round(ratio * 100)}%` }} />
86
+ </div>
87
+ <div className={css.bookCardMetaRow}>
88
+ <span className={css.meta}>{book.total > 0 ? `已完成 ${book.done} / ${book.total} 章` : '尚未规划章节'}</span>
89
+ {book.hasProject && <span className={css.meta} title="最近活动时间">更新于 {relativeTime(book.updatedAt)}</span>}
90
+ </div>
91
+ <div className={css.bookCardActions} onClick={e => { e.stopPropagation() }}>
92
+ <button
93
+ type="button"
94
+ className={`${css.button} ${css.buttonSmall} ${css.buttonPrimary}`}
95
+ onClick={onOpen}
96
+ title="进入创作工作台(大纲/章节/审稿/设定)"
97
+ >
98
+ <PenLine size={13} style={{ verticalAlign: -2 }} /> 进入工作台
99
+ </button>
100
+ <button
101
+ type="button"
102
+ className={`${css.button} ${css.buttonSmall}`}
103
+ disabled={!book.hasProject || book.done === 0}
104
+ onClick={onRead}
105
+ title={book.hasProject && book.done > 0 ? '沉浸式阅读已写章节' : '尚无已写章节,先去工作台创作'}
106
+ >
107
+ <BookOpen size={13} style={{ verticalAlign: -2 }} /> 阅读
108
+ </button>
109
+ </div>
110
+ </div>
111
+ </div>
112
+ )
113
+ }
114
+
115
+ /** 书架首页。 */
116
+ export function ShelfView({
117
+ api,
118
+ shelf,
119
+ onOpenBook,
120
+ onReadBook,
121
+ onAddBook,
122
+ onImportBook,
123
+ onOpenAdapt,
124
+ toolbarExtra,
125
+ }: {
126
+ api: NovelApi
127
+ shelf: BookshelfSnapshot
128
+ /** 书架工具行右端插槽:组入口按钮由 AuthorHome 下放(创作 / 灵感策划 / 资产)。 */
129
+ toolbarExtra?: ReactNode
130
+ /** 点击书卡:激活该书并进入工作台。 */
131
+ onOpenBook: (id: string) => void
132
+ /** 点击「阅读」:激活该书并进入沉浸式阅读页。 */
133
+ onReadBook: (id: string) => void
134
+ /** 点击+:进入开书向导页。 */
135
+ onAddBook: () => void
136
+ /** 点击「导入」:打开导入弹窗(已有项目目录 / txt/md 全本)。 */
137
+ onImportBook: () => void
138
+ /** 点击「改编模式」:进入改编六步向导(入口收在书架页头,与导入并排)。 */
139
+ onOpenAdapt?: () => void
140
+ }) {
141
+ const [query, setQuery] = useState('')
142
+ /** 筛选:all=全部 / active=进行中 / done=已完结 / none=未开书。 */
143
+ const [filter, setFilter] = useState<'all' | 'active' | 'done' | 'none'>('all')
144
+
145
+ const stats = useMemo(() => {
146
+ let active = 0, done = 0, none = 0
147
+ for (const b of shelf.books) {
148
+ const s = bookStatus(b)
149
+ if (s === 'none') none++
150
+ else if (s === 'done') done++
151
+ else active++
152
+ }
153
+ return { active, done, none }
154
+ }, [shelf.books])
155
+
156
+ const visible = useMemo(() => {
157
+ const q = query.trim().toLowerCase()
158
+ return shelf.books.filter(b => {
159
+ if (filter !== 'all' && bookStatus(b) !== filter) return false
160
+ if (q === '') return true
161
+ return b.bookName.toLowerCase().includes(q) || (b.blurb ?? '').toLowerCase().includes(q)
162
+ })
163
+ }, [shelf.books, query, filter])
164
+
165
+ const activeName = shelf.books.find(b => b.id === shelf.activeBookId)?.bookName ?? ''
166
+
167
+ return (
168
+ <div className={css.subPage}>
169
+ <div className={css.subPageHead}>
170
+ <span className={css.subPageTitle}>书架</span>
171
+ <span className={css.subPageMeta}>
172
+ {shelf.books.length} 本书{activeName !== '' ? ` · 当前书《${activeName}》` : ''}
173
+ </span>
174
+ <div className={css.subPageActions}>
175
+ {onOpenAdapt !== undefined && (
176
+ <button type="button" className={css.button} onClick={onOpenAdapt} title="导入全文 → 六步改编成新书">
177
+ <Wand2 size={13} style={{ verticalAlign: -2 }} /> 改编模式
178
+ </button>
179
+ )}
180
+ <button type="button" className={css.button} onClick={onImportBook} title="导入已有项目目录或 txt / md 全本">
181
+ <Download size={13} style={{ verticalAlign: -2 }} /> 导入已有小说
182
+ </button>
183
+ <button type="button" className={`${css.button} ${css.buttonPrimary}`} onClick={onAddBook} title="书名 + 大纲,开书即建项目">
184
+ <Plus size={13} style={{ verticalAlign: -2 }} /> 开一本新书
185
+ </button>
186
+ </div>
187
+ </div>
188
+ <div className={css.subPageBody}>
189
+ <div className={css.shelfToolbar}>
190
+ <div style={{ position: 'relative', display: 'flex', alignItems: 'center' }}>
191
+ <Search size={15} style={{ position: 'absolute', left: 10, color: 'var(--nf-text-3)', pointerEvents: 'none' }} />
192
+ <input
193
+ className={`${css.input} ${css.shelfSearch}`}
194
+ type="search"
195
+ placeholder="搜索书名 / 简介…"
196
+ value={query}
197
+ onChange={e => { setQuery(e.target.value) }}
198
+ style={{ paddingLeft: 'var(--nf-space-30, 30px)' }}
199
+ />
200
+ </div>
201
+ {toolbarExtra !== undefined && (
202
+ <div className={css.shelfToolbarExtra} role="tablist" aria-label="创作组入口">
203
+ {toolbarExtra}
204
+ </div>
205
+ )}
206
+ </div>
207
+
208
+ {shelf.books.length > 0 && (
209
+ <div className={css.shelfStatGrid}>
210
+ {[
211
+ { id: 'all' as const, label: '总书数', value: shelf.books.length },
212
+ { id: 'active' as const, label: '进行中', value: stats.active },
213
+ { id: 'done' as const, label: '已完结', value: stats.done },
214
+ { id: 'none' as const, label: '未开书', value: stats.none },
215
+ ].map(s => (
216
+ <div
217
+ key={s.id}
218
+ className={css.shelfStatCard}
219
+ data-active={filter === s.id ? '' : undefined}
220
+ title={s.id === 'all' ? '显示全部书籍' : `只看「${s.label}」的书`}
221
+ onClick={() => { setFilter(s.id) }}
222
+ >
223
+ <span>{s.label}</span>
224
+ <b className={`${css.numSerif} ${css.tabular}`}>{s.value}</b>
225
+ </div>
226
+ ))}
227
+ </div>
228
+ )}
229
+
230
+ {shelf.books.length === 0 ? (
231
+ /* 空书架引导卡 */
232
+ <div className={css.shelfEmpty}>
233
+ <span className={css.shelfEmptyIcon}><BookOpen size={34} /></span>
234
+ <span className={css.shelfEmptyTitle}>你的创作从这里开始</span>
235
+ <span className={css.meta}>开一本书:粘贴大纲或导入 docx,书名自动识别,开书即建项目</span>
236
+ <button type="button" className={`${css.button} ${css.buttonPrimary}`} onClick={onAddBook}>
237
+ + 开第一本书
238
+ </button>
239
+ <button type="button" className={`${css.button}`} onClick={onImportBook}>
240
+ 导入已有小说
241
+ </button>
242
+ </div>
243
+ ) : visible.length === 0 ? (
244
+ <div className={css.shelfEmpty} style={{ minHeight: 160 }}>
245
+ <span className={css.shelfEmptyIcon}><Search size={34} /></span>
246
+ <span className={css.shelfEmptyTitle}>没有符合条件的书</span>
247
+ <span className={css.meta}>换个关键词或筛选条件试试</span>
248
+ </div>
249
+ ) : (
250
+ <div className={css.shelfGrid}>
251
+ {visible.map(book => (
252
+ <BookCard
253
+ key={book.id}
254
+ api={api}
255
+ book={book}
256
+ active={book.id === shelf.activeBookId}
257
+ onOpen={() => { onOpenBook(book.id) }}
258
+ onRead={() => { onReadBook(book.id) }}
259
+ />
260
+ ))}
261
+ </div>
262
+ )}
263
+ </div>
264
+ </div>
265
+ )
266
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * 统一子页骨架(1:1 对齐 demo-subpages.html 的 .subpage / .subpage-head / .subpage-body)。
3
+ *
4
+ * 所有作者级与书内子页都应套这层壳:
5
+ * - head:返回钮(可选)+ 大标题 + meta + 右侧操作区
6
+ * - body:页面内容(内部按 demo 用 .card / .box / .grid / .row 等块)
7
+ * 业务逻辑继续留在各自的 View 组件里,这里只负责「壳」。
8
+ */
9
+ import type { ReactNode } from 'react'
10
+ import css from './panel.module.css'
11
+
12
+ export function SubPage(props: {
13
+ /** 页面大标题(衬线 display 档)。 */
14
+ title: string
15
+ /** 标题右侧的说明行(如「4 本书 · 当前书《X》」)。 */
16
+ meta?: ReactNode
17
+ /** 返回钮:作者级返回书架,书内返回总编台。 */
18
+ back?: { label: string; onClick: () => void }
19
+ /** 页头右侧操作区(主 CTA 放最右)。 */
20
+ actions?: ReactNode
21
+ /** 主题系统局部模式(透传到根 div 的 data-nf-mode,供设置页等预览明暗)。 */
22
+ dataNfMode?: 'light' | 'dark'
23
+ /** 主题系统局部密度(透传到根 div 的 data-nf-density)。 */
24
+ dataNfDensity?: 'comfort' | 'compact' | 'spacious'
25
+ children: ReactNode
26
+ }): JSX.Element {
27
+ return (
28
+ <div className={css.subPage} data-nf-mode={props.dataNfMode} data-nf-density={props.dataNfDensity}>
29
+ <div className={css.subPageHead}>
30
+ {props.back !== undefined && (
31
+ <button type="button" className={css.subPageBack} onClick={props.back.onClick}>
32
+ ← {props.back.label}
33
+ </button>
34
+ )}
35
+ <span className={css.subPageTitle}>{props.title}</span>
36
+ {props.meta !== undefined && <span className={css.subPageMeta}>{props.meta}</span>}
37
+ {props.actions !== undefined && <div className={css.subPageActions}>{props.actions}</div>}
38
+ </div>
39
+ <div className={css.subPageBody}>{props.children}</div>
40
+ </div>
41
+ )
42
+ }