@waterwx/dsh-novel-forge 1.10.1 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (194) hide show
  1. package/README.md +287 -281
  2. package/lib/client.js +19572 -18127
  3. package/lib/index.js +12171 -6932
  4. package/lib/types/action-guard.d.ts +61 -0
  5. package/lib/types/ai-scan.d.ts +30 -0
  6. package/lib/types/assets.d.ts +20 -9
  7. package/lib/types/assistant.d.ts +29 -0
  8. package/lib/types/author-assets.d.ts +1 -3
  9. package/lib/types/bookshelf.d.ts +13 -3
  10. package/lib/types/client/api.d.ts +137 -21
  11. package/lib/types/client/chip-prefs.d.ts +36 -0
  12. package/lib/types/client/docx.d.ts +2 -2
  13. package/lib/types/client/index.d.ts +1 -1
  14. package/lib/types/client/llmLive.d.ts +31 -0
  15. package/lib/types/client/locales.d.ts +15 -70
  16. package/lib/types/client/mount.d.ts +0 -2
  17. package/lib/types/client/panel/AiTag.d.ts +1 -0
  18. package/lib/types/client/panel/AssistantTab.d.ts +4 -1
  19. package/lib/types/client/panel/AuthorHome.d.ts +13 -9
  20. package/lib/types/client/panel/BookAnalysisView.d.ts +4 -0
  21. package/lib/types/client/panel/CmdPalette.d.ts +12 -0
  22. package/lib/types/client/panel/CreateBookView.d.ts +5 -1
  23. package/lib/types/client/panel/DirectorView.d.ts +7 -0
  24. package/lib/types/client/panel/IdeaInspirationView.d.ts +6 -0
  25. package/lib/types/client/panel/KnowledgeBaseView.d.ts +4 -0
  26. package/lib/types/client/panel/LiveFeedLog.d.ts +13 -0
  27. package/lib/types/client/panel/MarketRadarView.d.ts +5 -0
  28. package/lib/types/client/panel/ModelManager.d.ts +2 -2
  29. package/lib/types/client/panel/NovelPanel.d.ts +6 -1
  30. package/lib/types/client/panel/PageHeader.d.ts +18 -0
  31. package/lib/types/client/panel/PromptSlotsView.d.ts +11 -0
  32. package/lib/types/client/panel/SettingsView.d.ts +12 -7
  33. package/lib/types/client/panel/ShelfView.d.ts +10 -1
  34. package/lib/types/client/panel/SubPage.d.ts +27 -0
  35. package/lib/types/client/panel/TensionView.d.ts +18 -0
  36. package/lib/types/client/panel/TimelineView.d.ts +18 -0
  37. package/lib/types/client/panel/helpers.d.ts +5 -3
  38. package/lib/types/client/panel/market-radar-state.d.ts +56 -0
  39. package/lib/types/client/panel/toast.d.ts +2 -0
  40. package/lib/types/client/panel/views.d.ts +21 -35
  41. package/lib/types/client/run-status.d.ts +21 -0
  42. package/lib/types/client/sidebar-entry.d.ts +0 -2
  43. package/lib/types/docx-core.d.ts +16 -0
  44. package/lib/types/docx.d.ts +2 -3
  45. package/lib/types/engine.d.ts +83 -79
  46. package/lib/types/global-assets.d.ts +6 -0
  47. package/lib/types/home.d.ts +11 -0
  48. package/lib/types/index.d.ts +27 -13
  49. package/lib/types/llm-live.d.ts +70 -0
  50. package/lib/types/llm-retry.d.ts +38 -0
  51. package/lib/types/market-radar-scan.d.ts +18 -0
  52. package/lib/types/market-radar-sources.d.ts +31 -0
  53. package/lib/types/migrate-output.d.ts +25 -0
  54. package/lib/types/model-capability.d.ts +93 -0
  55. package/lib/types/novel-context.d.ts +80 -0
  56. package/lib/types/output-budget.d.ts +86 -0
  57. package/lib/types/prompt-slots.d.ts +57 -0
  58. package/lib/types/prompting.d.ts +10 -0
  59. package/lib/types/protocol.d.ts +829 -522
  60. package/lib/types/revision.d.ts +76 -0
  61. package/lib/types/routes.d.ts +9 -3
  62. package/lib/types/run.d.ts +23 -1
  63. package/lib/types/settings-persist.d.ts +59 -0
  64. package/lib/types/snapshots.d.ts +46 -0
  65. package/lib/types/stage-contract.d.ts +58 -0
  66. package/lib/types/tension.d.ts +53 -0
  67. package/lib/types/timeline.d.ts +37 -0
  68. package/package.json +50 -28
  69. package/src/action-guard.ts +127 -0
  70. package/src/ai-scan.ts +155 -0
  71. package/src/assets.ts +1801 -535
  72. package/src/assistant.ts +1098 -770
  73. package/src/author-assets.ts +131 -147
  74. package/src/bookshelf.ts +193 -167
  75. package/src/client/api.ts +767 -607
  76. package/src/client/chip-prefs.ts +121 -0
  77. package/src/client/docx.ts +32 -69
  78. package/src/client/index.ts +3 -1
  79. package/src/client/llmLive.ts +111 -0
  80. package/src/client/locales.ts +15 -274
  81. package/src/client/mount.tsx +4 -3
  82. package/src/client/panel/AdaptModeView.tsx +535 -520
  83. package/src/client/panel/AiTag.tsx +7 -0
  84. package/src/client/panel/AssetsTab.tsx +448 -458
  85. package/src/client/panel/AssistantTab.tsx +355 -301
  86. package/src/client/panel/AuthorAssetsView.tsx +205 -207
  87. package/src/client/panel/AuthorHome.tsx +309 -202
  88. package/src/client/panel/BookAnalysisView.tsx +63 -0
  89. package/src/client/panel/CmdPalette.tsx +102 -0
  90. package/src/client/panel/CreateBookView.tsx +366 -352
  91. package/src/client/panel/DirectorView.tsx +200 -0
  92. package/src/client/panel/GlobalAssetLibraryView.tsx +168 -172
  93. package/src/client/panel/IdeaInspirationView.tsx +76 -0
  94. package/src/client/panel/ImportModal.tsx +302 -302
  95. package/src/client/panel/KnowledgeBaseView.tsx +125 -0
  96. package/src/client/panel/LiveFeedLog.tsx +204 -0
  97. package/src/client/panel/MarketRadarView.tsx +415 -0
  98. package/src/client/panel/ModelManager.tsx +205 -205
  99. package/src/client/panel/NovelPanel.tsx +5559 -4655
  100. package/src/client/panel/PageHeader.tsx +37 -0
  101. package/src/client/panel/ProgressConsole.tsx +63 -64
  102. package/src/client/panel/PromptSlotsView.tsx +97 -0
  103. package/src/client/panel/ReaderView.tsx +306 -306
  104. package/src/client/panel/RunPanel.tsx +56 -24
  105. package/src/client/panel/SettingsView.tsx +175 -112
  106. package/src/client/panel/ShelfView.tsx +63 -39
  107. package/src/client/panel/SubPage.tsx +42 -0
  108. package/src/client/panel/TensionView.tsx +212 -0
  109. package/src/client/panel/TimelineView.tsx +208 -0
  110. package/src/client/panel/WorldTab.tsx +202 -202
  111. package/src/client/panel/helpers.ts +7 -5
  112. package/src/client/panel/market-radar-state.ts +79 -0
  113. package/src/client/panel/panel.module.css +5767 -4199
  114. package/src/client/panel/toast.ts +51 -0
  115. package/src/client/panel/views.tsx +316 -314
  116. package/src/client/run-status.ts +171 -0
  117. package/src/client/sidebar-entry.ts +3 -3
  118. package/src/docx-core.ts +53 -0
  119. package/src/docx.ts +44 -83
  120. package/src/engine.ts +1610 -1541
  121. package/src/global-assets.ts +58 -0
  122. package/src/home.ts +33 -0
  123. package/src/index.ts +184 -103
  124. package/src/llm-live.ts +245 -0
  125. package/src/llm-retry.ts +77 -0
  126. package/src/market-radar-scan.ts +35 -0
  127. package/src/market-radar-sources.ts +173 -0
  128. package/src/migrate-output.ts +76 -0
  129. package/src/model-capability.ts +215 -0
  130. package/src/novel-context.ts +286 -0
  131. package/src/output-budget.ts +147 -0
  132. package/src/prompt-slots.ts +125 -0
  133. package/src/prompting.ts +26 -0
  134. package/src/protocol.ts +2454 -2148
  135. package/src/revision.ts +283 -0
  136. package/src/routes.ts +3795 -3359
  137. package/src/run.ts +113 -17
  138. package/src/settings-persist.ts +114 -0
  139. package/src/snapshots.ts +174 -0
  140. package/src/stage-contract.ts +186 -0
  141. package/src/tension.ts +251 -0
  142. package/src/timeline.ts +159 -0
  143. package/assets/styles/arcane-thick-3d.webp +0 -0
  144. package/assets/styles/chinese-ink.webp +0 -0
  145. package/assets/styles/clay-stopmotion-3d.webp +0 -0
  146. package/assets/styles/dark-ghibli.webp +0 -0
  147. package/assets/styles/dark-gothic-3d.webp +0 -0
  148. package/assets/styles/film-photo.webp +0 -0
  149. package/assets/styles/guofeng-heavy.webp +0 -0
  150. package/assets/styles/horror-game-3d.webp +0 -0
  151. package/assets/styles/japan-cel-dark.webp +0 -0
  152. package/assets/styles/korean-webtoon.webp +0 -0
  153. package/assets/styles/low-cyberpunk-3d.webp +0 -0
  154. package/assets/styles/nextgen-cg.webp +0 -0
  155. package/assets/styles/noir-film.webp +0 -0
  156. package/assets/styles/openworld-survival.webp +0 -0
  157. package/assets/styles/paper-cut-3d.webp +0 -0
  158. package/assets/styles/pixar-adult-3d.webp +0 -0
  159. package/assets/styles/pop-marvel-2d.webp +0 -0
  160. package/assets/styles/shinkai-anime.webp +0 -0
  161. package/assets/styles/tactical-stealth.webp +0 -0
  162. package/assets/styles/vaporwave-3d.webp +0 -0
  163. package/assets/styles/vhs90.webp +0 -0
  164. package/lib/types/client/panel/BookshelfBar.d.ts +0 -11
  165. package/lib/types/client/panel/FlowGuide.d.ts +0 -21
  166. package/lib/types/client/panel/MangaRoleLibrary.d.ts +0 -18
  167. package/lib/types/client/panel/MangaWorkspace.d.ts +0 -15
  168. package/lib/types/client/panel/ModelPicker.d.ts +0 -11
  169. package/lib/types/client/panel/ProgressHomeView.d.ts +0 -5
  170. package/lib/types/client/panel/SceneLibrary.d.ts +0 -14
  171. package/lib/types/client/panel/StoryboardTab.d.ts +0 -19
  172. package/lib/types/client/panel/StyleCard.d.ts +0 -8
  173. package/lib/types/client/panel/StyleGallery.d.ts +0 -1
  174. package/lib/types/comic-presets.d.ts +0 -12
  175. package/lib/types/shot-language.d.ts +0 -46
  176. package/lib/types/story-beat-language.d.ts +0 -35
  177. package/lib/types/style-library.d.ts +0 -33
  178. package/skills/chapter-batch/SKILL.md +0 -81
  179. package/src/client/mount.tsx._new +0 -97
  180. package/src/client/mount.tsx._nf +0 -97
  181. package/src/client/mount.tsx._tmp +0 -97
  182. package/src/client/panel/BookshelfBar.tsx +0 -170
  183. package/src/client/panel/FlowGuide.tsx +0 -83
  184. package/src/client/panel/MangaRoleLibrary.tsx +0 -810
  185. package/src/client/panel/MangaWorkspace.tsx +0 -359
  186. package/src/client/panel/ProgressHomeView.tsx +0 -87
  187. package/src/client/panel/SceneLibrary.tsx +0 -396
  188. package/src/client/panel/StoryboardTab.tsx +0 -466
  189. package/src/client/panel/StyleCard.tsx +0 -90
  190. package/src/client/panel/StyleGallery.tsx +0 -58
  191. package/src/comic-presets.ts +0 -67
  192. package/src/shot-language.ts +0 -181
  193. package/src/story-beat-language.ts +0 -130
  194. package/src/style-library.ts +0 -78
@@ -1,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
  }