@waterwx/dsh-novel-forge 1.4.0 → 1.5.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 (49) hide show
  1. package/assets/styles/arcane-thick-3d.webp +0 -0
  2. package/assets/styles/chinese-ink.webp +0 -0
  3. package/assets/styles/clay-stopmotion-3d.webp +0 -0
  4. package/assets/styles/dark-ghibli.webp +0 -0
  5. package/assets/styles/dark-gothic-3d.webp +0 -0
  6. package/assets/styles/film-photo.webp +0 -0
  7. package/assets/styles/guofeng-heavy.webp +0 -0
  8. package/assets/styles/horror-game-3d.webp +0 -0
  9. package/assets/styles/japan-cel-dark.webp +0 -0
  10. package/assets/styles/korean-webtoon.webp +0 -0
  11. package/assets/styles/low-cyberpunk-3d.webp +0 -0
  12. package/assets/styles/nextgen-cg.webp +0 -0
  13. package/assets/styles/noir-film.webp +0 -0
  14. package/assets/styles/openworld-survival.webp +0 -0
  15. package/assets/styles/paper-cut-3d.webp +0 -0
  16. package/assets/styles/pixar-adult-3d.webp +0 -0
  17. package/assets/styles/pop-marvel-2d.webp +0 -0
  18. package/assets/styles/shinkai-anime.webp +0 -0
  19. package/assets/styles/tactical-stealth.webp +0 -0
  20. package/assets/styles/vaporwave-3d.webp +0 -0
  21. package/assets/styles/vhs90.webp +0 -0
  22. package/lib/client.js +2073 -658
  23. package/lib/client.js.map +1 -1
  24. package/lib/index.js +919 -64
  25. package/lib/index.js.map +1 -1
  26. package/lib/types/client/api.d.ts +15 -1
  27. package/lib/types/client/locales.d.ts +4 -2
  28. package/lib/types/client/panel/MangaWorkspace.d.ts +9 -0
  29. package/lib/types/client/panel/NovelPanel.d.ts +1 -1
  30. package/lib/types/client/panel/StoryboardTab.d.ts +13 -0
  31. package/lib/types/client/panel/StyleGallery.d.ts +1 -0
  32. package/lib/types/engine.d.ts +46 -4
  33. package/lib/types/index.d.ts +2 -0
  34. package/lib/types/protocol.d.ts +194 -3
  35. package/lib/types/style-library.d.ts +33 -0
  36. package/package.json +2 -1
  37. package/src/client/api.ts +36 -1
  38. package/src/client/locales.ts +7 -3
  39. package/src/client/panel/ImportModal.tsx +123 -21
  40. package/src/client/panel/MangaWorkspace.tsx +185 -0
  41. package/src/client/panel/NovelPanel.tsx +239 -150
  42. package/src/client/panel/StoryboardTab.tsx +287 -0
  43. package/src/client/panel/StyleGallery.tsx +109 -0
  44. package/src/client/panel/panel.module.css +77 -0
  45. package/src/engine.ts +457 -39
  46. package/src/index.ts +6 -0
  47. package/src/protocol.ts +200 -3
  48. package/src/routes.ts +280 -13
  49. package/src/style-library.ts +78 -0
@@ -1,11 +1,12 @@
1
1
  /**
2
2
  * 导入小说弹窗:两种模式 ——
3
- * A) 已有项目目录(含 novel-project.json)→ 登记/激活书架;
4
- * B) txt/md 全本 → 服务器拆章建项目后登记书架。
5
- * 浏览器无法直接浏览宿主机目录,故两种模式均输入绝对路径(粘贴或手输)。
3
+ * A) 已有项目目录(含 novel-project.json)→ 登记/激活书架(粘贴服务器本地路径);
4
+ * B) txt/md 全本 → 点击选择文件,浏览器读取内容 → 服务器拆章预览 → 确认后导入。
5
+ * Mode B 也保留「服务器本地路径」高级选项(大文件或服务器端已有文件时用)。
6
6
  */
7
- import { useState } from 'react'
7
+ import { useRef, useState } from 'react'
8
8
  import type { NovelApi } from '../api.ts'
9
+ import type { BookImportTextPreviewResponse } from '../../protocol.ts'
9
10
  import css from './panel.module.css'
10
11
 
11
12
  type ImportMode = 'dir' | 'text'
@@ -30,13 +31,43 @@ export function ImportModal({
30
31
  onImported: () => void | Promise<void>
31
32
  }) {
32
33
  const [mode, setMode] = useState<ImportMode>('dir')
34
+ // Mode A
33
35
  const [dir, setDir] = useState('')
36
+ // Mode B:文件选择 + 预览
37
+ const fileInputRef = useRef<HTMLInputElement | null>(null)
38
+ const [fileName, setFileName] = useState('')
39
+ const [fileText, setFileText] = useState<string | null>(null)
40
+ const [previewing, setPreviewing] = useState(false)
41
+ const [preview, setPreview] = useState<BookImportTextPreviewResponse | null>(null)
42
+ // Mode B:服务器本地路径(高级)
43
+ const [showPath, setShowPath] = useState(false)
34
44
  const [filePath, setFilePath] = useState('')
45
+ // 通用
35
46
  const [outDir, setOutDir] = useState('')
36
47
  const [busy, setBusy] = useState(false)
37
48
  const [error, setError] = useState('')
38
49
  const [result, setResult] = useState<ImportResult | null>(null)
39
50
 
51
+ /** 选择文件 → 读取内容 → 调服务器预览拆章。 */
52
+ const onPickFile = async (file: File | undefined) => {
53
+ if (file === undefined) return
54
+ setError('')
55
+ setResult(null)
56
+ setPreview(null)
57
+ setFileName(file.name)
58
+ setPreviewing(true)
59
+ try {
60
+ const text = await file.text()
61
+ setFileText(text)
62
+ const p = await api.bookImportTextPreview(text, file.name)
63
+ setPreview(p)
64
+ } catch (err) {
65
+ setError((err as Error).message)
66
+ } finally {
67
+ setPreviewing(false)
68
+ }
69
+ }
70
+
40
71
  const runImport = async () => {
41
72
  setError('')
42
73
  setResult(null)
@@ -53,13 +84,14 @@ export function ImportModal({
53
84
  } finally {
54
85
  setBusy(false)
55
86
  }
56
- } else {
57
- const f = filePath.trim()
58
- if (f === '') { setError('请输入 txt/md 文件路径'); return }
87
+ return
88
+ }
89
+ // Mode B:优先上传内容;未选文件时退回服务器路径
90
+ if (fileText !== null && fileText.length > 0) {
59
91
  setBusy(true)
60
92
  try {
61
93
  const o = outDir.trim()
62
- const r = await api.bookImportText(f, o !== '' ? o : undefined)
94
+ const r = await api.bookImportTextContent(fileText, fileName, o !== '' ? o : undefined)
63
95
  setResult({ kind: 'text', bookName: r.bookName, chapters: r.chapters, skipped: r.skipped })
64
96
  await onImported()
65
97
  } catch (err) {
@@ -67,9 +99,25 @@ export function ImportModal({
67
99
  } finally {
68
100
  setBusy(false)
69
101
  }
102
+ return
103
+ }
104
+ const f = filePath.trim()
105
+ if (f === '') { setError('请先选择文件,或输入服务器本地文件路径'); return }
106
+ setBusy(true)
107
+ try {
108
+ const o = outDir.trim()
109
+ const r = await api.bookImportText(f, o !== '' ? o : undefined)
110
+ setResult({ kind: 'text', bookName: r.bookName, chapters: r.chapters, skipped: r.skipped })
111
+ await onImported()
112
+ } catch (err) {
113
+ setError((err as Error).message)
114
+ } finally {
115
+ setBusy(false)
70
116
  }
71
117
  }
72
118
 
119
+ const switchMode = (m: ImportMode) => { setMode(m); setError(''); setResult(null); setPreview(null) }
120
+
73
121
  return (
74
122
  <div className={css.importModalOverlay} onClick={e => { if (e.target === e.currentTarget) onClose() }}>
75
123
  <div className={css.importModal}>
@@ -84,7 +132,7 @@ export function ImportModal({
84
132
  role="tab"
85
133
  aria-selected={mode === 'dir'}
86
134
  className={`${css.button} ${mode === 'dir' ? css.buttonPrimary : ''}`}
87
- onClick={() => { setMode('dir'); setError(''); setResult(null) }}
135
+ onClick={() => { switchMode('dir') }}
88
136
  >
89
137
  📂 A · 已有项目目录
90
138
  </button>
@@ -93,7 +141,7 @@ export function ImportModal({
93
141
  role="tab"
94
142
  aria-selected={mode === 'text'}
95
143
  className={`${css.button} ${mode === 'text' ? css.buttonPrimary : ''}`}
96
- onClick={() => { setMode('text'); setError(''); setResult(null) }}
144
+ onClick={() => { switchMode('text') }}
97
145
  >
98
146
  📄 B · txt/md 全本
99
147
  </button>
@@ -118,16 +166,51 @@ export function ImportModal({
118
166
  </>
119
167
  ) : (
120
168
  <>
121
- <label className={css.importField}>
122
- <span>全本文本文件(绝对路径,txt / md)</span>
123
- <input
124
- className={css.input}
125
- type="text"
126
- placeholder="例如 D:\novels\全本.txt"
127
- value={filePath}
128
- onChange={e => { setFilePath(e.target.value) }}
129
- />
130
- </label>
169
+ {/* 文件选择 + 预览 */}
170
+ <input
171
+ ref={fileInputRef}
172
+ type="file"
173
+ accept=".txt,.md,text/plain,text/markdown"
174
+ style={{ display: 'none' }}
175
+ onChange={e => { void onPickFile(e.target.files?.[0]); e.target.value = '' }}
176
+ />
177
+ <div className={css.importField}>
178
+ <span>全本文本文件(txt / md)</span>
179
+ <button
180
+ type="button"
181
+ className={`${css.button} ${css.buttonPrimary}`}
182
+ onClick={() => { fileInputRef.current?.click() }}
183
+ >
184
+ 📄 选择文件
185
+ </button>
186
+ </div>
187
+ {fileName !== '' && (
188
+ <div className={css.importFileInfo}>
189
+ <span>已选择:{fileName}</span>
190
+ {previewing && <span className={css.meta}>正在拆章预览…</span>}
191
+ </div>
192
+ )}
193
+ {preview !== null && (
194
+ <div className={css.importPreview}>
195
+ <span>📖 《{preview.bookName}》 · 识别到 {preview.chapters.length} 章</span>
196
+ {preview.chapters.length > 0 && (
197
+ <ul className={css.importPreviewList}>
198
+ {preview.chapters.slice(0, 5).map(c => (
199
+ <li key={c.no}>第{c.no}章 {c.title}({c.chars} 字)</li>
200
+ ))}
201
+ {preview.chapters.length > 5 && <li className={css.meta}>… 共 {preview.chapters.length} 章</li>}
202
+ </ul>
203
+ )}
204
+ {preview.skipped.length > 0 && (
205
+ <span className={css.meta}>
206
+ 跳过 {preview.skipped.length} 个过短章节:{preview.skipped.slice(0, 5).join('、')}
207
+ {preview.skipped.length > 5 ? ' 等' : ''}
208
+ </span>
209
+ )}
210
+ </div>
211
+ )}
212
+
213
+ {/* 输出目录 + 高级路径选项 */}
131
214
  <label className={css.importField}>
132
215
  <span>输出目录(可选,默认 ~/.dsh/novels/书名)</span>
133
216
  <input
@@ -138,8 +221,27 @@ export function ImportModal({
138
221
  onChange={e => { setOutDir(e.target.value) }}
139
222
  />
140
223
  </label>
224
+ <button
225
+ type="button"
226
+ className={`${css.button} ${css.buttonSmall}`}
227
+ onClick={() => { setShowPath(!showPath) }}
228
+ >
229
+ {showPath ? '▾ 收起高级选项' : '▸ 高级:使用服务器本地文件路径'}
230
+ </button>
231
+ {showPath && (
232
+ <label className={css.importField}>
233
+ <span>服务器本地文件绝对路径(大文件或服务器端已有文件时用)</span>
234
+ <input
235
+ className={css.input}
236
+ type="text"
237
+ placeholder="例如 D:\novels\全本.txt"
238
+ value={filePath}
239
+ onChange={e => { setFilePath(e.target.value) }}
240
+ />
241
+ </label>
242
+ )}
141
243
  <div className={css.importHint}>
142
- 服务器按「第X章 / 第X回 / 第X节 / 第X卷」(可带 # 前缀)拆章;正文过短(&lt;50 字)的章节会跳过并列出。
244
+ 服务器按「第X章/第X回/第X节/第X卷」、中文数字章节(一、二、三…)、序章/楔子/尾声、英文 Chapter N 拆章;正文过短(&lt;50 字)的章节会跳过并列出。
143
245
  </div>
144
246
  </>
145
247
  )}
@@ -0,0 +1,185 @@
1
+ /**
2
+ * 漫剧工作台:方案管理(基底风格 + 可选滤镜)→ 分镜(骨架/分镜表,按方案风格生成)。
3
+ * 骨架为故事层(所有方案共享);分镜表画面措辞随方案风格。
4
+ */
5
+ import { useMemo, useState } from 'react'
6
+ import type { NovelApi } from '../api.ts'
7
+ import type { ChapterPlan, MangaPlan, ProjectState } from '../../protocol.ts'
8
+ import { STYLE_LIBRARY, findStyle } from '../../style-library.ts'
9
+ import { StoryboardTab } from './StoryboardTab.tsx'
10
+ import css from './panel.module.css'
11
+
12
+ export function MangaWorkspace({
13
+ api,
14
+ project,
15
+ chapters,
16
+ onProjectChanged,
17
+ }: {
18
+ api: NovelApi
19
+ project: ProjectState | null
20
+ chapters: ChapterPlan[]
21
+ /** 方案变更已持久化后触发(刷新项目)。 */
22
+ onProjectChanged?: () => void | Promise<void>
23
+ }) {
24
+ const plans = useMemo(() => project?.mangaPlans ?? [], [project?.mangaPlans])
25
+ const activePlan = plans.find(p => p.active) ?? null
26
+ const [showCreate, setShowCreate] = useState(false)
27
+ const [name, setName] = useState('')
28
+ const [styleId, setStyleId] = useState('')
29
+ const [filterId, setFilterId] = useState('')
30
+ const [busy, setBusy] = useState(false)
31
+ const [error, setError] = useState('')
32
+
33
+ // 基底候选:非 stackable 风格按 weight 降序(推荐池),+ 全部基底风格
34
+ const baseStyles = useMemo(() => STYLE_LIBRARY.filter(s => s.stackable !== true).sort((a, b) => (b.weight ?? 0) - (a.weight ?? 0)), [])
35
+ const filterStyles = useMemo(() => STYLE_LIBRARY.filter(s => s.stackable === true), [])
36
+
37
+ const mutate = async (req: import('../../protocol.ts').MangaPlansRequest): Promise<boolean> => {
38
+ setBusy(true)
39
+ setError('')
40
+ try {
41
+ await api.manhuaPlans(req)
42
+ return true
43
+ } catch (err) {
44
+ setError((err as Error).message)
45
+ return false
46
+ } finally {
47
+ setBusy(false)
48
+ }
49
+ }
50
+
51
+ const createPlan = async (): Promise<void> => {
52
+ const n = name.trim()
53
+ if (n === '' || styleId === '') {
54
+ setError('请填写方案名并选择基底风格')
55
+ return
56
+ }
57
+ const ok = await mutate({ op: 'create', name: n, styleId, filterId: filterId !== '' ? filterId : undefined })
58
+ if (ok) {
59
+ setShowCreate(false)
60
+ setName('')
61
+ setStyleId('')
62
+ setFilterId('')
63
+ await refreshProject()
64
+ }
65
+ }
66
+
67
+ const refreshProject = async (): Promise<void> => {
68
+ await onProjectChanged?.()
69
+ }
70
+
71
+ if (project === null) {
72
+ return <div className={css.card}><span className={css.meta}>请先开书或选择一本书,再进入漫剧工作台。</span></div>
73
+ }
74
+
75
+ return (
76
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
77
+ {/* ① 方案管理 */}
78
+ <div className={css.card}>
79
+ <div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}>
80
+ <span className={css.cardTitle}>🎬 漫剧方案</span>
81
+ <button type="button" className={`${css.button} ${css.buttonPrimary}`} disabled={busy} onClick={() => { setShowCreate(!showCreate) }}>
82
+ {showCreate ? '收起' : '+ 新建方案'}
83
+ </button>
84
+ </div>
85
+ <span className={css.meta}>同一本书可建多套视觉演绎(基底风格 + 可选滤镜);分镜画面措辞随激活方案。</span>
86
+
87
+ {showCreate && (
88
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 8, border: '1px solid var(--nf-info)', borderRadius: 12, padding: 10, marginTop: 8 }}>
89
+ <div className={css.field}>
90
+ <label className={css.fieldLabel}>方案名</label>
91
+ <input className={css.input} value={name} placeholder="例如:《保质期》3D 皮克斯版" onChange={e => { setName(e.target.value) }} />
92
+ </div>
93
+ <div className={css.field}>
94
+ <label className={css.fieldLabel}>基底风格(按推荐度排序)</label>
95
+ <div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
96
+ {baseStyles.map(s => (
97
+ <button
98
+ key={s.id}
99
+ type="button"
100
+ className={`${css.button} ${css.buttonSmall} ${styleId === s.id ? css.buttonPrimary : ''}`}
101
+ title={s.traits}
102
+ onClick={() => { setStyleId(s.id) }}
103
+ >
104
+ {s.name}
105
+ </button>
106
+ ))}
107
+ </div>
108
+ </div>
109
+ {filterStyles.length > 0 && (
110
+ <div className={css.field}>
111
+ <label className={css.fieldLabel}>叠加滤镜(可选)</label>
112
+ <div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
113
+ <button type="button" className={`${css.button} ${css.buttonSmall} ${filterId === '' ? css.buttonPrimary : ''}`} onClick={() => { setFilterId('') }}>无</button>
114
+ {filterStyles.map(s => (
115
+ <button
116
+ key={s.id}
117
+ type="button"
118
+ className={`${css.button} ${css.buttonSmall} ${filterId === s.id ? css.buttonPrimary : ''}`}
119
+ title={s.traits}
120
+ onClick={() => { setFilterId(s.id) }}
121
+ >
122
+ {s.name}
123
+ </button>
124
+ ))}
125
+ </div>
126
+ </div>
127
+ )}
128
+ {error !== '' && <div className={css.importError}>{error}</div>}
129
+ <div className={css.row}>
130
+ <button type="button" className={`${css.button} ${css.buttonSmall} ${css.buttonPrimary}`} disabled={busy || name.trim() === '' || styleId === ''} onClick={() => { void createPlan() }}>
131
+ 创建方案
132
+ </button>
133
+ </div>
134
+ </div>
135
+ )}
136
+
137
+ {plans.length === 0 ? (
138
+ <div className={css.meta} style={{ marginTop: 8 }}>尚无漫剧方案——新建一个方案,选择基底风格开始。</div>
139
+ ) : (
140
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 6, marginTop: 8 }}>
141
+ {plans.map(p => {
142
+ const style = findStyle(p.styleId)
143
+ const filter = p.filterId !== undefined ? findStyle(p.filterId) : undefined
144
+ return (
145
+ <div
146
+ key={p.id}
147
+ style={{
148
+ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap',
149
+ border: `1px solid ${p.active ? 'var(--nf-accent)' : 'var(--nf-border)'}`, borderRadius: 10, padding: '8px 10px',
150
+ }}
151
+ >
152
+ <b>{p.name}</b>
153
+ <span className={css.badge}>{style?.name ?? p.styleId}</span>
154
+ {filter !== undefined && <span className={css.badge}>+{filter.name}</span>}
155
+ {p.active && <span className={`${css.badge} ${css.badgeDone}`}>激活中</span>}
156
+ <div style={{ flex: 1 }} />
157
+ {!p.active && (
158
+ <button type="button" className={`${css.button} ${css.buttonSmall}`} disabled={busy} onClick={async () => { if (await mutate({ op: 'activate', id: p.id })) await refreshProject() }}>
159
+ 激活
160
+ </button>
161
+ )}
162
+ <button type="button" className={`${css.button} ${css.buttonSmall}`} disabled={busy} onClick={async () => { if (await mutate({ op: 'remove', id: p.id })) await refreshProject() }}>
163
+ 删除
164
+ </button>
165
+ </div>
166
+ )
167
+ })}
168
+ </div>
169
+ )}
170
+ </div>
171
+
172
+ {/* ② 分镜(按激活方案风格) */}
173
+ {plans.length === 0 ? null : (
174
+ <StoryboardTab
175
+ api={api}
176
+ project={project}
177
+ chapters={chapters}
178
+ onProjectChanged={onProjectChanged}
179
+ styleId={activePlan?.styleId}
180
+ filterId={activePlan?.filterId}
181
+ />
182
+ )}
183
+ </div>
184
+ )
185
+ }