@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,303 +1,303 @@
1
- /**
2
- * 导入小说弹窗:两种模式 ——
3
- * A) 已有项目目录(含 novel-project.json)→ 登记/激活书架(粘贴服务器本地路径);
4
- * B) txt/md 全本 → 点击选择文件,浏览器读取内容 → 服务器拆章预览 → 确认后导入。
5
- * Mode B 也保留「服务器本地路径」高级选项(大文件或服务器端已有文件时用)。
6
- */
7
- import { useEffect, useRef, useState } from 'react'
8
- import type { NovelApi } from '../api.ts'
9
- import type { BookImportTextPreviewResponse } from '../../protocol.ts'
10
- import { readFileTextSmart } from '../text.ts'
11
- import css from './panel.module.css'
12
-
13
- type ImportMode = 'dir' | 'text'
14
-
15
- /** 导入结果(成功态展示)。 */
16
- interface ImportResult {
17
- kind: 'dir' | 'text'
18
- bookName: string
19
- existed?: boolean
20
- chapters?: number
21
- skipped?: string[]
22
- }
23
-
24
- export function ImportModal({
25
- api,
26
- onClose,
27
- onImported,
28
- }: {
29
- api: NovelApi
30
- onClose: () => void
31
- /** 导入成功并已激活该书后回调(刷新书架)。 */
32
- onImported: () => void | Promise<void>
33
- }) {
34
- const [mode, setMode] = useState<ImportMode>('dir')
35
- // Mode A
36
- const [dir, setDir] = useState('')
37
- // Mode B:文件选择 + 预览
38
- const fileInputRef = useRef<HTMLInputElement | null>(null)
39
- const [fileName, setFileName] = useState('')
40
- const [fileText, setFileText] = useState<string | null>(null)
41
- const [previewing, setPreviewing] = useState(false)
42
- const [preview, setPreview] = useState<BookImportTextPreviewResponse | null>(null)
43
- // Mode B:服务器本地路径(高级)
44
- const [showPath, setShowPath] = useState(false)
45
- const [filePath, setFilePath] = useState('')
46
- // 通用
47
- const [outDir, setOutDir] = useState('')
48
- const [busy, setBusy] = useState(false)
49
- const [error, setError] = useState('')
50
- const [result, setResult] = useState<ImportResult | null>(null)
51
-
52
- /** 选择文件 → 读取内容 → 调服务器预览拆章。 */
53
- const onPickFile = async (file: File | undefined) => {
54
- if (file === undefined) return
55
- setError('')
56
- setResult(null)
57
- setPreview(null)
58
- setFileName(file.name)
59
- setPreviewing(true)
60
- try {
61
- const text = await readFileTextSmart(file)
62
- setFileText(text)
63
- const p = await api.bookImportTextPreview(text, file.name)
64
- setPreview(p)
65
- } catch (err) {
66
- setError((err as Error).message)
67
- } finally {
68
- setPreviewing(false)
69
- }
70
- }
71
-
72
- const runImport = async () => {
73
- setError('')
74
- setResult(null)
75
- if (mode === 'dir') {
76
- const d = dir.trim()
77
- if (d === '') { setError('请输入项目目录(含 novel-project.json 的文件夹)'); return }
78
- setBusy(true)
79
- try {
80
- const r = await api.bookImportDir(d)
81
- setResult({ kind: 'dir', bookName: r.book.bookName, existed: r.existed })
82
- await onImported()
83
- } catch (err) {
84
- setError((err as Error).message)
85
- } finally {
86
- setBusy(false)
87
- }
88
- return
89
- }
90
- // Mode B:优先上传内容;未选文件时退回服务器路径
91
- if (fileText !== null && fileText.length > 0) {
92
- setBusy(true)
93
- try {
94
- const o = outDir.trim()
95
- const r = await api.bookImportTextContent(fileText, fileName, o !== '' ? o : undefined)
96
- setResult({ kind: 'text', bookName: r.bookName, chapters: r.chapters, skipped: r.skipped })
97
- await onImported()
98
- } catch (err) {
99
- setError((err as Error).message)
100
- } finally {
101
- setBusy(false)
102
- }
103
- return
104
- }
105
- const f = filePath.trim()
106
- if (f === '') { setError('请先选择文件,或输入服务器本地文件路径'); return }
107
- setBusy(true)
108
- try {
109
- const o = outDir.trim()
110
- const r = await api.bookImportText(f, o !== '' ? o : undefined)
111
- setResult({ kind: 'text', bookName: r.bookName, chapters: r.chapters, skipped: r.skipped })
112
- await onImported()
113
- } catch (err) {
114
- setError((err as Error).message)
115
- } finally {
116
- setBusy(false)
117
- }
118
- }
119
-
120
- const switchMode = (m: ImportMode) => { setMode(m); setError(''); setResult(null); setPreview(null) }
121
-
122
- // Esc 关闭弹窗。
123
- useEffect(() => {
124
- const onKey = (e: KeyboardEvent): void => {
125
- if (e.key === 'Escape') onClose()
126
- }
127
- window.addEventListener('keydown', onKey)
128
- return () => { window.removeEventListener('keydown', onKey) }
129
- }, [onClose])
130
-
131
- return (
132
- <div className={css.importModalOverlay} role="dialog" aria-modal="true" aria-label="导入小说" onClick={e => { if (e.target === e.currentTarget) onClose() }}>
133
- <div className={css.importModal}>
134
- <div className={css.importModalHead}>
135
- <span className={css.panelTitle} style={{ margin: 0 }}>📥 导入小说</span>
136
- <button type="button" className={`${css.button} ${css.buttonSmall}`} onClick={onClose} title="关闭">✕</button>
137
- </div>
138
-
139
- <div className={css.importModalTabs} role="tablist">
140
- <button
141
- type="button"
142
- role="tab"
143
- aria-selected={mode === 'dir'}
144
- className={`${css.button} ${mode === 'dir' ? css.buttonPrimary : ''}`}
145
- onClick={() => { switchMode('dir') }}
146
- >
147
- 📂 A · 已有项目目录
148
- </button>
149
- <button
150
- type="button"
151
- role="tab"
152
- aria-selected={mode === 'text'}
153
- className={`${css.button} ${mode === 'text' ? css.buttonPrimary : ''}`}
154
- onClick={() => { switchMode('text') }}
155
- >
156
- 📄 B · txt/md 全本
157
- </button>
158
- </div>
159
-
160
- <div className={css.importModalBody}>
161
- {mode === 'dir' ? (
162
- <>
163
- <label className={css.importField}>
164
- <span>项目目录(绝对路径)</span>
165
- <input
166
- className={css.input}
167
- type="text"
168
- placeholder="例如 D:\novels\我的小说"
169
- value={dir}
170
- onChange={e => { setDir(e.target.value) }}
171
- />
172
- </label>
173
- <div className={css.importHint}>
174
- 目录需含 novel-project.json。已在书架中的目录会直接切换激活,不会重复登记。
175
- </div>
176
- </>
177
- ) : (
178
- <>
179
- {/* 文件选择 + 预览 */}
180
- <input
181
- ref={fileInputRef}
182
- type="file"
183
- accept=".txt,.md,text/plain,text/markdown"
184
- style={{ display: 'none' }}
185
- onChange={e => { void onPickFile(e.target.files?.[0]); e.target.value = '' }}
186
- />
187
- <div className={css.importField}>
188
- <span>全本文本文件(txt / md)</span>
189
- <button
190
- type="button"
191
- className={`${css.button} ${css.buttonPrimary}`}
192
- onClick={() => { fileInputRef.current?.click() }}
193
- >
194
- 📄 选择文件
195
- </button>
196
- </div>
197
- {fileName !== '' && (
198
- <div className={css.importFileInfo}>
199
- <span>已选择:{fileName}</span>
200
- {previewing && <span className={css.meta}>正在拆章预览…</span>}
201
- </div>
202
- )}
203
- {preview !== null && (
204
- <div className={css.importPreview}>
205
- <span>📖 《{preview.bookName}》 · 识别到 {preview.chapters.length} 章</span>
206
- {preview.chapters.length > 0 && (
207
- <ul className={css.importPreviewList}>
208
- {preview.chapters.slice(0, 5).map(c => (
209
- <li key={c.no}>第{c.no}章 {c.title}({c.chars} 字)</li>
210
- ))}
211
- {preview.chapters.length > 5 && <li className={css.meta}>… 共 {preview.chapters.length} 章</li>}
212
- </ul>
213
- )}
214
- {preview.skipped.length > 0 && (
215
- <span className={css.meta}>
216
- 跳过 {preview.skipped.length} 个过短章节:{preview.skipped.slice(0, 5).join('、')}
217
- {preview.skipped.length > 5 ? ' 等' : ''}
218
- </span>
219
- )}
220
- </div>
221
- )}
222
-
223
- {/* 输出目录 + 高级路径选项 */}
224
- <label className={css.importField}>
225
- <span>输出目录(可选,默认 ~/.dsh/novels/书名)</span>
226
- <input
227
- className={css.input}
228
- type="text"
229
- placeholder="留空使用默认目录"
230
- value={outDir}
231
- onChange={e => { setOutDir(e.target.value) }}
232
- />
233
- </label>
234
- <button
235
- type="button"
236
- className={`${css.button} ${css.buttonSmall}`}
237
- onClick={() => { setShowPath(!showPath) }}
238
- >
239
- {showPath ? '▾ 收起高级选项' : '▸ 高级:使用服务器本地文件路径'}
240
- </button>
241
- {showPath && (
242
- <label className={css.importField}>
243
- <span>服务器本地文件绝对路径(大文件或服务器端已有文件时用)</span>
244
- <input
245
- className={css.input}
246
- type="text"
247
- placeholder="例如 D:\novels\全本.txt"
248
- value={filePath}
249
- onChange={e => { setFilePath(e.target.value) }}
250
- />
251
- </label>
252
- )}
253
- <div className={css.importHint}>
254
- 服务器按「第X章/第X回/第X节/第X卷」、中文数字章节(一、二、三…)、序章/楔子/尾声、英文 Chapter N 拆章;正文过短(&lt;50 字)的章节会跳过并列出。
255
- </div>
256
- </>
257
- )}
258
-
259
- {error !== '' && <div className={css.importError}>{error}</div>}
260
-
261
- {result !== null && (
262
- <div className={css.importResult}>
263
- {result.kind === 'dir' ? (
264
- <>
265
- <span>✅ 已{result.existed === true ? '重新激活' : '登记'}《{result.bookName}》</span>
266
- <span className={css.meta}>
267
- {result.existed === true
268
- ? '该目录已在书架中,现已切换为当前书;工作台数据按该书目录加载。'
269
- : '已加入书架并设为当前书,进入工作台即可看到全部模块。'
270
- }
271
- </span>
272
- </>
273
- ) : (
274
- <>
275
- <span>✅ 《{result.bookName}》导入完成:{result.chapters} 章</span>
276
- {(result.skipped ?? []).length > 0 && (
277
- <span className={css.meta}>
278
- 跳过 {result.skipped!.length} 个过短章节:{result.skipped!.slice(0, 8).join('、')}
279
- {result.skipped!.length > 8 ? ` 等 ${result.skipped!.length} 个` : ''}
280
- </span>
281
- )}
282
- <span className={css.meta}>章节已按「written」登记,进入工作台后逐章审稿/补设定即可。</span>
283
- </>
284
- )}
285
- </div>
286
- )}
287
- </div>
288
-
289
- <div className={css.importModalActions}>
290
- <button type="button" className={`${css.button} ${css.buttonSmall}`} onClick={onClose}>关闭</button>
291
- <button
292
- type="button"
293
- className={`${css.button} ${css.buttonPrimary}`}
294
- disabled={busy}
295
- onClick={runImport}
296
- >
297
- {busy ? '导入中…' : '开始导入'}
298
- </button>
299
- </div>
300
- </div>
301
- </div>
302
- )
1
+ /**
2
+ * 导入小说弹窗:两种模式 ——
3
+ * A) 已有项目目录(含 novel-project.json)→ 登记/激活书架(粘贴服务器本地路径);
4
+ * B) txt/md 全本 → 点击选择文件,浏览器读取内容 → 服务器拆章预览 → 确认后导入。
5
+ * Mode B 也保留「服务器本地路径」高级选项(大文件或服务器端已有文件时用)。
6
+ */
7
+ import { useEffect, useRef, useState } from 'react'
8
+ import type { NovelApi } from '../api.ts'
9
+ import type { BookImportTextPreviewResponse } from '../../protocol.ts'
10
+ import { readFileTextSmart } from '../text.ts'
11
+ import css from './panel.module.css'
12
+
13
+ type ImportMode = 'dir' | 'text'
14
+
15
+ /** 导入结果(成功态展示)。 */
16
+ interface ImportResult {
17
+ kind: 'dir' | 'text'
18
+ bookName: string
19
+ existed?: boolean
20
+ chapters?: number
21
+ skipped?: string[]
22
+ }
23
+
24
+ export function ImportModal({
25
+ api,
26
+ onClose,
27
+ onImported,
28
+ }: {
29
+ api: NovelApi
30
+ onClose: () => void
31
+ /** 导入成功并已激活该书后回调(刷新书架)。 */
32
+ onImported: () => void | Promise<void>
33
+ }) {
34
+ const [mode, setMode] = useState<ImportMode>('dir')
35
+ // Mode A
36
+ const [dir, setDir] = useState('')
37
+ // Mode B:文件选择 + 预览
38
+ const fileInputRef = useRef<HTMLInputElement | null>(null)
39
+ const [fileName, setFileName] = useState('')
40
+ const [fileText, setFileText] = useState<string | null>(null)
41
+ const [previewing, setPreviewing] = useState(false)
42
+ const [preview, setPreview] = useState<BookImportTextPreviewResponse | null>(null)
43
+ // Mode B:服务器本地路径(高级)
44
+ const [showPath, setShowPath] = useState(false)
45
+ const [filePath, setFilePath] = useState('')
46
+ // 通用
47
+ const [outDir, setOutDir] = useState('')
48
+ const [busy, setBusy] = useState(false)
49
+ const [error, setError] = useState('')
50
+ const [result, setResult] = useState<ImportResult | null>(null)
51
+
52
+ /** 选择文件 → 读取内容 → 调服务器预览拆章。 */
53
+ const onPickFile = async (file: File | undefined) => {
54
+ if (file === undefined) return
55
+ setError('')
56
+ setResult(null)
57
+ setPreview(null)
58
+ setFileName(file.name)
59
+ setPreviewing(true)
60
+ try {
61
+ const text = await readFileTextSmart(file)
62
+ setFileText(text)
63
+ const p = await api.bookImportTextPreview(text, file.name)
64
+ setPreview(p)
65
+ } catch (err) {
66
+ setError((err as Error).message)
67
+ } finally {
68
+ setPreviewing(false)
69
+ }
70
+ }
71
+
72
+ const runImport = async () => {
73
+ setError('')
74
+ setResult(null)
75
+ if (mode === 'dir') {
76
+ const d = dir.trim()
77
+ if (d === '') { setError('请输入项目目录(含 novel-project.json 的文件夹)'); return }
78
+ setBusy(true)
79
+ try {
80
+ const r = await api.bookImportDir(d)
81
+ setResult({ kind: 'dir', bookName: r.book.bookName, existed: r.existed })
82
+ await onImported()
83
+ } catch (err) {
84
+ setError((err as Error).message)
85
+ } finally {
86
+ setBusy(false)
87
+ }
88
+ return
89
+ }
90
+ // Mode B:优先上传内容;未选文件时退回服务器路径
91
+ if (fileText !== null && fileText.length > 0) {
92
+ setBusy(true)
93
+ try {
94
+ const o = outDir.trim()
95
+ const r = await api.bookImportTextContent(fileText, fileName, o !== '' ? o : undefined)
96
+ setResult({ kind: 'text', bookName: r.bookName, chapters: r.chapters, skipped: r.skipped })
97
+ await onImported()
98
+ } catch (err) {
99
+ setError((err as Error).message)
100
+ } finally {
101
+ setBusy(false)
102
+ }
103
+ return
104
+ }
105
+ const f = filePath.trim()
106
+ if (f === '') { setError('请先选择文件,或输入服务器本地文件路径'); return }
107
+ setBusy(true)
108
+ try {
109
+ const o = outDir.trim()
110
+ const r = await api.bookImportText(f, o !== '' ? o : undefined)
111
+ setResult({ kind: 'text', bookName: r.bookName, chapters: r.chapters, skipped: r.skipped })
112
+ await onImported()
113
+ } catch (err) {
114
+ setError((err as Error).message)
115
+ } finally {
116
+ setBusy(false)
117
+ }
118
+ }
119
+
120
+ const switchMode = (m: ImportMode) => { setMode(m); setError(''); setResult(null); setPreview(null) }
121
+
122
+ // Esc 关闭弹窗。
123
+ useEffect(() => {
124
+ const onKey = (e: KeyboardEvent): void => {
125
+ if (e.key === 'Escape') onClose()
126
+ }
127
+ window.addEventListener('keydown', onKey)
128
+ return () => { window.removeEventListener('keydown', onKey) }
129
+ }, [onClose])
130
+
131
+ return (
132
+ <div className={css.importModalOverlay} role="dialog" aria-modal="true" aria-label="导入小说" onClick={e => { if (e.target === e.currentTarget) onClose() }}>
133
+ <div className={css.importModal}>
134
+ <div className={css.importModalHead}>
135
+ <span className={css.panelTitle} style={{ margin: 0 }}> 导入小说</span>
136
+ <button type="button" className={`${css.button} ${css.buttonSmall}`} onClick={onClose} title="关闭">✕</button>
137
+ </div>
138
+
139
+ <div className={css.importModalTabs} role="tablist">
140
+ <button
141
+ type="button"
142
+ role="tab"
143
+ aria-selected={mode === 'dir'}
144
+ className={`${css.button} ${mode === 'dir' ? css.buttonPrimary : ''}`}
145
+ onClick={() => { switchMode('dir') }}
146
+ >
147
+ A · 已有项目目录
148
+ </button>
149
+ <button
150
+ type="button"
151
+ role="tab"
152
+ aria-selected={mode === 'text'}
153
+ className={`${css.button} ${mode === 'text' ? css.buttonPrimary : ''}`}
154
+ onClick={() => { switchMode('text') }}
155
+ >
156
+ B · txt/md 全本
157
+ </button>
158
+ </div>
159
+
160
+ <div className={css.importModalBody}>
161
+ {mode === 'dir' ? (
162
+ <>
163
+ <label className={css.importField}>
164
+ <span>项目目录(绝对路径)</span>
165
+ <input
166
+ className={css.input}
167
+ type="text"
168
+ placeholder="例如 D:\novels\我的小说"
169
+ value={dir}
170
+ onChange={e => { setDir(e.target.value) }}
171
+ />
172
+ </label>
173
+ <div className={css.importHint}>
174
+ 目录需含 novel-project.json。已在书架中的目录会直接切换激活,不会重复登记。
175
+ </div>
176
+ </>
177
+ ) : (
178
+ <>
179
+ {/* 文件选择 + 预览 */}
180
+ <input
181
+ ref={fileInputRef}
182
+ type="file"
183
+ accept=".txt,.md,text/plain,text/markdown"
184
+ style={{ display: 'none' }}
185
+ onChange={e => { void onPickFile(e.target.files?.[0]); e.target.value = '' }}
186
+ />
187
+ <div className={css.importField}>
188
+ <span>全本文本文件(txt / md)</span>
189
+ <button
190
+ type="button"
191
+ className={`${css.button} ${css.buttonPrimary}`}
192
+ onClick={() => { fileInputRef.current?.click() }}
193
+ >
194
+ 选择文件
195
+ </button>
196
+ </div>
197
+ {fileName !== '' && (
198
+ <div className={css.importFileInfo}>
199
+ <span>已选择:{fileName}</span>
200
+ {previewing && <span className={css.meta}>正在拆章预览…</span>}
201
+ </div>
202
+ )}
203
+ {preview !== null && (
204
+ <div className={css.importPreview}>
205
+ <span> 《{preview.bookName}》 · 识别到 {preview.chapters.length} 章</span>
206
+ {preview.chapters.length > 0 && (
207
+ <ul className={css.importPreviewList}>
208
+ {preview.chapters.slice(0, 5).map(c => (
209
+ <li key={c.no}>第{c.no}章 {c.title}({c.chars} 字)</li>
210
+ ))}
211
+ {preview.chapters.length > 5 && <li className={css.meta}>… 共 {preview.chapters.length} 章</li>}
212
+ </ul>
213
+ )}
214
+ {preview.skipped.length > 0 && (
215
+ <span className={css.meta}>
216
+ 跳过 {preview.skipped.length} 个过短章节:{preview.skipped.slice(0, 5).join('、')}
217
+ {preview.skipped.length > 5 ? ' 等' : ''}
218
+ </span>
219
+ )}
220
+ </div>
221
+ )}
222
+
223
+ {/* 输出目录 + 高级路径选项 */}
224
+ <label className={css.importField}>
225
+ <span>输出目录(可选,默认「输出目录」设置下的书名子目录)</span>
226
+ <input
227
+ className={css.input}
228
+ type="text"
229
+ placeholder="留空使用默认目录"
230
+ value={outDir}
231
+ onChange={e => { setOutDir(e.target.value) }}
232
+ />
233
+ </label>
234
+ <button
235
+ type="button"
236
+ className={`${css.button} ${css.buttonSmall}`}
237
+ onClick={() => { setShowPath(!showPath) }}
238
+ >
239
+ {showPath ? '▾ 收起高级选项' : '▸ 高级:使用服务器本地文件路径'}
240
+ </button>
241
+ {showPath && (
242
+ <label className={css.importField}>
243
+ <span>服务器本地文件绝对路径(大文件或服务器端已有文件时用)</span>
244
+ <input
245
+ className={css.input}
246
+ type="text"
247
+ placeholder="例如 D:\novels\全本.txt"
248
+ value={filePath}
249
+ onChange={e => { setFilePath(e.target.value) }}
250
+ />
251
+ </label>
252
+ )}
253
+ <div className={css.importHint}>
254
+ 服务器按「第X章/第X回/第X节/第X卷」、中文数字章节(一、二、三…)、序章/楔子/尾声、英文 Chapter N 拆章;正文过短(&lt;50 字)的章节会跳过并列出。
255
+ </div>
256
+ </>
257
+ )}
258
+
259
+ {error !== '' && <div className={css.importError}>{error}</div>}
260
+
261
+ {result !== null && (
262
+ <div className={css.importResult}>
263
+ {result.kind === 'dir' ? (
264
+ <>
265
+ <span> 已{result.existed === true ? '重新激活' : '登记'}《{result.bookName}》</span>
266
+ <span className={css.meta}>
267
+ {result.existed === true
268
+ ? '该目录已在书架中,现已切换为当前书;工作台数据按该书目录加载。'
269
+ : '已加入书架并设为当前书,进入工作台即可看到全部模块。'
270
+ }
271
+ </span>
272
+ </>
273
+ ) : (
274
+ <>
275
+ <span> 《{result.bookName}》导入完成:{result.chapters} 章</span>
276
+ {(result.skipped ?? []).length > 0 && (
277
+ <span className={css.meta}>
278
+ 跳过 {result.skipped!.length} 个过短章节:{result.skipped!.slice(0, 8).join('、')}
279
+ {result.skipped!.length > 8 ? ` 等 ${result.skipped!.length} 个` : ''}
280
+ </span>
281
+ )}
282
+ <span className={css.meta}>章节已按「written」登记,进入工作台后逐章审稿/补设定即可。</span>
283
+ </>
284
+ )}
285
+ </div>
286
+ )}
287
+ </div>
288
+
289
+ <div className={css.importModalActions}>
290
+ <button type="button" className={`${css.button} ${css.buttonSmall}`} onClick={onClose}>关闭</button>
291
+ <button
292
+ type="button"
293
+ className={`${css.button} ${css.buttonPrimary}`}
294
+ disabled={busy}
295
+ onClick={runImport}
296
+ >
297
+ {busy ? '导入中…' : '开始导入'}
298
+ </button>
299
+ </div>
300
+ </div>
301
+ </div>
302
+ )
303
303
  }