@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
@@ -6,6 +6,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
6
6
  import type { NovelApi } from '../api.ts'
7
7
  import type { RunState } from '../../protocol.ts'
8
8
  import css from './panel.module.css'
9
+ import { PageHeader } from './PageHeader.tsx'
9
10
 
10
11
  export function RunPanel({ api, totalChapters }: { api: NovelApi; totalChapters: number }) {
11
12
  const [run, setRun] = useState<RunState | null>(null)
@@ -42,7 +43,6 @@ export function RunPanel({ api, totalChapters }: { api: NovelApi; totalChapters:
42
43
  logRef.current?.scrollTo({ top: logRef.current.scrollHeight })
43
44
  }, [run?.log.length])
44
45
 
45
- const effectiveEnd = mode === 'count' ? startNo + count - 1 : endNo
46
46
 
47
47
  const handleStart = async (): Promise<void> => {
48
48
  setBusy(true)
@@ -76,11 +76,11 @@ export function RunPanel({ api, totalChapters }: { api: NovelApi; totalChapters:
76
76
  const statusLabel = useMemo(() => {
77
77
  if (run === null) return '未启动'
78
78
  return {
79
- running: '🏭 生产中',
79
+ running: ' 生产中',
80
80
  paused: '⏸ 已暂停',
81
- done: '✅ 已完成',
81
+ done: ' 已完成',
82
82
  stopped: '⏹ 已停止',
83
- error: '❌ 异常',
83
+ error: ' 异常',
84
84
  }[run.status] ?? run.status
85
85
  }, [run])
86
86
 
@@ -90,15 +90,45 @@ export function RunPanel({ api, totalChapters }: { api: NovelApi; totalChapters:
90
90
 
91
91
  return (
92
92
  <div className={css.card} style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-10)' }}>
93
- <div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}>
94
- <span className={css.cardTitle}>🏭 生产单(标准流水线:计划 → 生成 → 审稿 → 分级处理)</span>
95
- <span className={`${css.badge} ${run?.status === 'running' ? css.badgeWritten : run?.status === 'done' ? css.badgeDone : css.badgePending}`}>{statusLabel}</span>
96
- </div>
97
-
98
- <div className={css.meta}>
99
- 一键下生产单:自动补计划 → 逐章生成(完整质量门:生成→摘要+事实→审稿→作者复盘)→ 被拒章分级处理(无 high 豁免 / 有 high 按意见修订+验证 / 两轮不过转待人工)。中断后可从断点继续。
100
- </div>
101
-
93
+ <PageHeader
94
+ eyebrow="Production"
95
+ title="生产单"
96
+ actions={(
97
+ <span className={`${css.badge} ${run?.status === 'running' ? css.badgeWritten : run?.status === 'done' ? css.badgeDone : css.badgePending}`}>{statusLabel}</span>
98
+ )}
99
+ />
100
+ {run === null || run.status !== 'running' ? (
101
+ <>
102
+ <span className={css.meta}>标准流水线:计划 → 生成 → 审稿 → 分级处理。一键下单,自动补计划,逐章过质量门(生成→摘要+事实→审稿→作者复盘),被拒章分级处理,中断可断点续跑。</span>
103
+ {/* 运行约束说明:并发/绑定/暂停语义/同章冲突,都是操作上会踩的点,放在下单区之前 */}
104
+ <details className={css.meta} style={{ fontSize: 'var(--nf-fs-12)' }}>
105
+ <summary style={{ cursor: 'pointer' }}>运行说明(并发 · 绑定书目 · 暂停续跑)</summary>
106
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 2, marginTop: 4, lineHeight: 1.6 }}>
107
+ <span>· <b>一次一本</b>:生产单是全局单实例,逐章串行;换书不会打断,也不会切换到别的批次。</span>
108
+ <span>· <b>绑定下单时的书</b>:本批次只读写下单时那本书的目录,与当前激活书无关。</span>
109
+ <span>· 起点默认 = 最后一章 +1;区间内<b>已通过审稿</b>的章会自动快进跳过。</span>
110
+ <span>· <b>暂停</b>可随时续跑(从断点章继续);<b>停止</b>会终结本批次,再次下单即新建批次。</span>
111
+ <span>· ⚠️ 批次运行中仍可手动生成/修订<b>其它章</b>,但请勿对正在处理的<b>同一章</b>同时手动操作——两个写入会互相覆盖。</span>
112
+ </div>
113
+ </details>
114
+ </>
115
+ ) : null}
116
+
117
+ {/* v4 B4 控制台折叠:生产中下单区收起为一行状态摘要,进度与日志成为主体。 */}
118
+ {run !== null && run.status === 'running' ? (
119
+ <div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap', gap: 'var(--nf-space-8)' }}>
120
+ <span className={css.meta}> 生产中 · 第 {run.startNo}-{run.endNo} 章 · 当前第 {run.currentNo} 章 · {Math.round(ratio * 100)}%</span>
121
+ <div className={css.row} style={{ gap: 'var(--nf-space-6)' }}>
122
+ <button type="button" className={`${css.button} ${css.buttonSmall}`} disabled={busy} onClick={() => { void handleControl('pause') }}>
123
+ ⏸ 暂停
124
+ </button>
125
+ <button type="button" className={`${css.button} ${css.buttonSmall}`} disabled={busy} onClick={() => { void handleControl('stop') }}>
126
+ ⏹ 停止
127
+ </button>
128
+ </div>
129
+ </div>
130
+ ) : (
131
+ <>
102
132
  {/* 下单区 */}
103
133
  <div className={css.row} style={{ flexWrap: 'wrap', gap: 'var(--nf-space-8)', alignItems: 'flex-end' }}>
104
134
  <div style={{ display: 'flex', gap: 'var(--nf-space-4)', alignItems: 'center' }}>
@@ -161,6 +191,8 @@ export function RunPanel({ api, totalChapters }: { api: NovelApi; totalChapters:
161
191
  </button>
162
192
  )}
163
193
  </div>
194
+ </>
195
+ )}
164
196
 
165
197
  {err !== '' && <div style={{ color: 'var(--nf-error)', fontSize: 'var(--nf-fs-12)' }}>{err}</div>}
166
198
 
@@ -168,7 +200,7 @@ export function RunPanel({ api, totalChapters }: { api: NovelApi; totalChapters:
168
200
  {run !== null && (
169
201
  <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-6)' }}>
170
202
  <div className={css.row} style={{ flexWrap: 'wrap', gap: 'var(--nf-space-8)' }}>
171
- <span className={css.meta}>范围:第 {run.startNo} - {run.endNo} 章 · 当前:第 {run.currentNo} 章</span>
203
+ <span className={css.meta}>{run.bookName !== undefined && run.bookName !== '' ? `本书:《${run.bookName}》 · ` : ''}范围:第 {run.startNo} - {run.endNo} 章 · 当前:第 {run.currentNo} 章</span>
172
204
  <span className={css.meta}>新生成 {run.stats?.generated ?? 0} · 修订通过 {run.stats?.revised ?? 0} · 豁免 {run.stats?.exempted ?? 0} · 重生成 {run.stats?.regenerated ?? 0} · 失败 {run.stats?.error ?? 0}</span>
173
205
  </div>
174
206
  <div className={css.bigProgressBar}>
@@ -176,25 +208,25 @@ export function RunPanel({ api, totalChapters }: { api: NovelApi; totalChapters:
176
208
  </div>
177
209
  {run.pendingManual.length > 0 && (
178
210
  <div className={css.meta} style={{ color: 'var(--nf-warn)' }}>
179
- ⚠️ 待人工:第 {run.pendingManual.join('、')} 章(两轮修订仍不过,保留草稿,可在章节列表处理)
211
+ 待人工:第 {run.pendingManual.join('、')} 章(两轮修订仍不过,保留草稿,可在章节列表处理)
180
212
  </div>
181
213
  )}
182
- {run.status === 'done' && <div className={css.meta} style={{ color: 'var(--nf-success)' }}>✅ 生产单完成:{run.startNo}-{run.endNo} 章处理完毕,待人工 {run.pendingManual.length} 章。</div>}
214
+ {run.status === 'done' && <div className={css.meta} style={{ color: 'var(--nf-success)' }}> 生产单完成:{run.startNo}-{run.endNo} 章处理完毕,待人工 {run.pendingManual.length} 章。</div>}
183
215
  {run.error !== undefined && <div className={css.meta} style={{ color: 'var(--nf-error)' }}>异常:{run.error}</div>}
184
216
  </div>
185
217
  )}
186
218
 
187
- {/* 日志区 */}
188
- <div className={css.meta} style={{ fontWeight: 600 }}>运行日志({run?.log.length ?? 0})</div>
189
- <div
190
- ref={logRef}
191
- style={{ flex: 1, minHeight: 120, overflowY: 'auto', border: '1px solid var(--nf-border)', borderRadius: 'var(--nf-radius-10)', background: 'var(--nf-bg-inset)', padding: 'var(--nf-space-8)', display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-2)', fontSize: 'var(--nf-fs-12)' }}
192
- >
219
+ {/* 日志区(终端风) */}
220
+ <div className={`${css.meta} ${css.titleBlock}`} style={{ gap: 0 }}>
221
+ <span className={css.eyebrow}>Run Log</span>
222
+ <span className={css.cardTitle}>运行日志({run?.log.length ?? 0})</span>
223
+ </div>
224
+ <div ref={logRef} className={css.runLog}>
193
225
  {run === null || run.log.length === 0 ? (
194
226
  <span className={css.meta}>尚无日志——下单后这里会实时显示每章进度。</span>
195
227
  ) : run.log.map((l, i) => (
196
- <div key={i} style={{ display: 'flex', gap: 'var(--nf-space-6)', opacity: 0.85 }}>
197
- <span style={{ color: 'var(--nf-text-3)', flex: 'none' }}>{new Date(l.at).toLocaleTimeString('zh-CN', { hour12: false })}</span>
228
+ <div key={i} className={css.runLogLine}>
229
+ <span className={css.runLogTime}>{new Date(l.at).toLocaleTimeString('zh-CN', { hour12: false })}</span>
198
230
  <span>{l.text}</span>
199
231
  </div>
200
232
  ))}
@@ -4,26 +4,25 @@
4
4
  * 保存走 api.patchConfig。不与某个书绑定,因此新装用户无书也能用。
5
5
  */
6
6
  import { useEffect, useRef, useState } from 'react'
7
- import { Brain, PenLine, Palette, Folder, Sparkles, RotateCcw, PlugZap, Plus, Trash2, Upload, X, Image } from 'lucide-react'
7
+ import { Brain, Folder, Sparkles, RotateCcw, Upload, X, Image } from 'lucide-react'
8
8
  import type { NovelApi } from '../api.ts'
9
- import type { NovelConfig, ImageModelConfig } from '../../protocol.ts'
9
+ import type { MoveOutputDirResponse, NovelConfig } from '../../protocol.ts'
10
10
  import { tt } from './helpers.ts'
11
+ import { RUN_CHIP_MODE_OPTIONS, readRunChipMode, writeRunChipMode, type RunChipMode } from '../chip-prefs.ts'
11
12
  import { ModelManager } from './ModelManager.tsx'
12
13
  import { ReasoningSection } from './ReasoningSection.tsx'
14
+ import { SubPage } from './SubPage.tsx'
13
15
  import css from './panel.module.css'
14
16
 
15
17
 
16
- const SETTINGS_SECTIONS: Array<{ id: 'model' | 'writing' | 'image' | 'files' | 'appearance'; label: string; icon: JSX.Element }> = [
17
- { id: 'model', label: '模型与推理', icon: <Brain size={16} /> },
18
- { id: 'writing', label: '写作与审稿', icon: <PenLine size={16} /> },
18
+ const SETTINGS_SECTIONS: Array<{ id: 'model' | 'files' | 'appearance'; label: string; icon: JSX.Element }> = [
19
+ { id: 'model', label: '模型', icon: <Brain size={16} /> },
19
20
  { id: 'files', label: '路径与文件', icon: <Folder size={16} /> },
20
21
  { id: 'appearance', label: '外观与主题', icon: <Sparkles size={16} /> },
21
22
  ];
22
23
 
23
- type SettingsTab = 'model' | 'writing' | 'image' | 'files' | 'appearance'
24
+ type SettingsTab = 'model' | 'files' | 'appearance'
24
25
  type ThemeMode = 'system' | 'light' | 'dark'
25
- type ThemeName = 'liquid' | 'neumorph' | 'macos' | 'clay' | 'endfield'
26
- type EndfieldAccent = 'valley' | 'wuling'
27
26
  type ThemeDensity = 'comfort' | 'compact' | 'spacious'
28
27
 
29
28
  function readLS<T extends string>(key: string, fallback: T, allowed: readonly T[]): T {
@@ -33,28 +32,37 @@ function readLS<T extends string>(key: string, fallback: T, allowed: readonly T[
33
32
  } catch { return fallback }
34
33
  }
35
34
 
36
- export function SettingsView({ api, onTheme, onSettingsTab, onEditorFontSize, onBackground, onOpacity, onEndfieldAccent }: {
35
+ export function SettingsView({ api, variant = 'page', onSettingsTab, onEditorFontSize, onBackground, themeMode, themeDensity, onChangeThemeMode, onChangeThemeDensity }: {
37
36
  api: NovelApi;
38
- onTheme?: (theme: ThemeName, mode: ThemeMode, density: ThemeDensity) => void;
37
+ /** page = 全屏 SubPage(保留给旧入口);drawer = 右滑抽屉内容(无壳)。 */
38
+ variant?: 'page' | 'drawer';
39
39
  onSettingsTab?: (tab: SettingsTab) => void;
40
40
  onEditorFontSize?: (n: number) => void;
41
41
  onBackground?: (bg: string | undefined, blur: number) => void;
42
- onOpacity?: (n: number) => void;
43
- onEndfieldAccent?: (accent: EndfieldAccent) => void;
42
+ /** 全局显示模式(联动整个面板,NovelPanel 落 localStorage)。 */
43
+ themeMode: 'system' | 'light' | 'dark';
44
+ /** 全局界面密度。 */
45
+ themeDensity: 'comfort' | 'compact' | 'spacious';
46
+ /** 改变全局显示模式。 */
47
+ onChangeThemeMode: (m: 'system' | 'light' | 'dark') => void;
48
+ /** 改变全局界面密度。 */
49
+ onChangeThemeDensity: (d: 'comfort' | 'compact' | 'spacious') => void;
44
50
  }) {
51
+ /** 右上角生成状态芯片的显示偏好(三态;存 localStorage,选择即时生效)。 */
52
+ const [runChipMode, setRunChipMode] = useState<RunChipMode>(() => readRunChipMode())
45
53
  const [config, setConfig] = useState<NovelConfig | null>(null);
46
54
  const [configDraft, setConfigDraft] = useState<NovelConfig | null>(null);
47
55
  const [busy, setBusy] = useState(false);
48
56
  const [error, setError] = useState('');
49
57
  const [notice, setNotice] = useState('');
50
- const [settingsTab, setSettingsTabState] = useState<SettingsTab>(() => readLS<SettingsTab>('dsh-novel-forge.settings.tab', 'model', ['model','writing','image','files','appearance'] as const));
51
- const [imageTestState, setImageTestState] = useState<Record<string, { testing: boolean; ok?: boolean; ms?: number; message?: string; modelFound?: boolean }>>({});
58
+ // —— 输出目录迁移弹窗 ——
59
+ const [moveOpen, setMoveOpen] = useState(false);
60
+ const [moveTarget, setMoveTarget] = useState('');
61
+ const [movePreview, setMovePreview] = useState<MoveOutputDirResponse | null>(null);
62
+ const [moveBusy, setMoveBusy] = useState(false);
63
+ const [moveError, setMoveError] = useState('');
64
+ const [settingsTab, setSettingsTabState] = useState<SettingsTab>(() => readLS<SettingsTab>('dsh-novel-forge.settings.tab', 'model', ['model','files','appearance'] as const));
52
65
  const [editorFontSize, setEditorFontSize] = useState<number>(() => { try { const v = Number(window.localStorage.getItem('dsh-novel-forge.editor.fontSize')); return v >= 12 && v <= 24 ? v : 14 } catch { return 14 } });
53
- const [panelTheme, setPanelTheme] = useState<ThemeName>(() => readLS<ThemeName>('dsh-novel-forge.theme', 'liquid', ['liquid','neumorph','macos','clay','endfield'] as const));
54
- const [themeMode, setThemeMode] = useState<ThemeMode>(() => readLS<ThemeMode>('dsh-novel-forge.theme.mode', 'system', ['system','light','dark'] as const));
55
- const [themeDensity, setThemeDensity] = useState<ThemeDensity>(() => readLS<ThemeDensity>('dsh-novel-forge.theme.density', 'comfort', ['comfort','compact','spacious'] as const));
56
- const [endfieldAccent, setEndfieldAccent] = useState<EndfieldAccent>(() => readLS<EndfieldAccent>('dsh-novel-forge.theme.endfield.accent', 'valley', ['valley','wuling'] as const));
57
-
58
66
  const loadConfig = async (): Promise<void> => {
59
67
  try {
60
68
  const res = await api.status();
@@ -68,6 +76,27 @@ export function SettingsView({ api, onTheme, onSettingsTab, onEditorFontSize, on
68
76
 
69
77
  const changeSettingsTab = (next: SettingsTab): void => { setSettingsTabState(next); onSettingsTab?.(next) };
70
78
 
79
+ /** 打开输出目录迁移弹窗:先 dryRun 预览当前目录内容。 */
80
+ const openMoveDialog = async (): Promise<void> => {
81
+ setMoveOpen(true); setMoveTarget(''); setMovePreview(null); setMoveError('');
82
+ try { setMovePreview(await api.moveOutputDir(undefined, true)) } catch (err) { setMoveError((err as Error).message) }
83
+ };
84
+
85
+ /** 确认迁移:搬迁文件 → 服务端联动书架/默认目录 → 刷新配置。 */
86
+ const confirmMove = async (): Promise<void> => {
87
+ const to = moveTarget.trim();
88
+ if (to === '') { setMoveError('请填写新目录的完整路径'); return }
89
+ setMoveBusy(true); setMoveError('');
90
+ try {
91
+ const r = await api.moveOutputDir(to);
92
+ setMoveOpen(false);
93
+ setNotice(`目录迁移完成:${r.files.length} 个条目 → ${r.to}${r.movedBooks > 0 ? `(书架联动重指向 ${r.movedBooks} 本)` : ''}`);
94
+ await loadConfig();
95
+ } catch (err) { setMoveError((err as Error).message) } finally { setMoveBusy(false) }
96
+ };
97
+
98
+ const formatBytes = (n: number): string => n >= 1024 * 1024 ? `${(n / 1024 / 1024).toFixed(1)} MB` : n >= 1024 ? `${(n / 1024).toFixed(1)} KB` : `${n} B`;
99
+
71
100
  const save = async (): Promise<void> => {
72
101
  if (configDraft === null) return;
73
102
  setBusy(true); setError(''); setNotice('');
@@ -77,6 +106,11 @@ export function SettingsView({ api, onTheme, onSettingsTab, onEditorFontSize, on
77
106
  outputDir: configDraft.outputDir,
78
107
  provider: configDraft.provider,
79
108
  model: configDraft.model,
109
+ generateModel: configDraft.generateModel,
110
+ reviewModel: configDraft.reviewModel,
111
+ auditModel: configDraft.auditModel,
112
+ fallbackModel: configDraft.fallbackModel ?? '',
113
+ autoTimeline: configDraft.autoTimeline ?? true,
80
114
  reasoningEffort: configDraft.reasoningEffort ?? 'off',
81
115
  chapterChars: configDraft.chapterChars,
82
116
  maxTokens: configDraft.maxTokens,
@@ -84,55 +118,18 @@ export function SettingsView({ api, onTheme, onSettingsTab, onEditorFontSize, on
84
118
  autoReview: configDraft.autoReview,
85
119
  autoAuthorReview: configDraft.autoAuthorReview,
86
120
  autoReviewAfterRevise: configDraft.autoReviewAfterRevise,
87
- imageApiKey: configDraft.imageApiKey,
88
- imageApiModel: configDraft.imageApiModel,
89
- imageApiEnabled: configDraft.imageApiEnabled ?? false,
90
121
  themeBackground: configDraft.themeBackground ?? '',
91
122
  themeBackgroundBlur: configDraft.themeBackgroundBlur ?? 0,
92
- themeOpacity: configDraft.themeOpacity ?? 100,
93
- imageModels: configDraft.imageModels ?? [],
94
123
  savedModels: configDraft.savedModels ?? [],
95
124
  });
96
125
  setConfig(result.config);
97
126
  setConfigDraft(result.config);
98
- setNotice(tt('settings.saved'));
127
+ // 未持久化时如实告知:改动只在本进程内生效,不能让作者以为已经存住了。
128
+ setNotice(result.settingsWarning === undefined ? tt('settings.saved') : tt('settings.savedUnpersisted'));
99
129
  } catch (err) { setError((err as Error).message) } finally { setBusy(false) }
100
130
  };
101
131
 
102
- // 生图模型操作
103
- const patchImageModel = (id: string, patch: Partial<ImageModelConfig>): void => {
104
- setConfigDraft(prev => prev === null ? prev : { ...prev, imageModels: (prev.imageModels ?? []).map(m => m.id === id ? { ...m, ...patch } : m) })
105
- };
106
- const addImageModel = (): void => {
107
- setConfigDraft(prev => prev === null ? prev : { ...prev, imageModels: [...(prev.imageModels ?? []), { id: 'img-' + Date.now().toString(36), name: '', baseURL: '', apiKey: '', model: '', enabled: (prev.imageModels ?? []).length === 0 }] })
108
- };
109
- const removeImageModel = (id: string): void => {
110
- setConfigDraft(prev => prev === null ? prev : { ...prev, imageModels: (prev.imageModels ?? []).filter(m => m.id !== id) })
111
- };
112
- const enableImageModel = (id: string): void => {
113
- setConfigDraft(prev => prev === null ? prev : { ...prev, imageModels: (prev.imageModels ?? []).map(m => ({ ...m, enabled: m.id === id })) })
114
- };
115
- const testImageModel = async (m: ImageModelConfig): Promise<void> => {
116
- if (m.baseURL.trim() === '' || m.apiKey.trim() === '') { setImageTestState(prev => ({ ...prev, [m.id]: { testing: false, ok: false, message: '请先填写接口地址和 API Key' } })); return }
117
- setImageTestState(prev => ({ ...prev, [m.id]: { testing: true } }));
118
- try { const r = await api.imageTest({ baseURL: m.baseURL, apiKey: m.apiKey, model: m.model }); setImageTestState(prev => ({ ...prev, [m.id]: { testing: false, ok: r.ok, ms: r.ms, message: r.message } })) } catch (err) { setImageTestState(prev => ({ ...prev, [m.id]: { testing: false, ok: false, message: (err as Error).message } })) }
119
- };
120
-
121
- // 主题/密度/字号(写 localStorage,可选通知父级)
122
- const changeTheme = (theme: ThemeName, mode: ThemeMode, density: ThemeDensity): void => {
123
- setPanelTheme(theme); setThemeMode(mode); setThemeDensity(density);
124
- try {
125
- window.localStorage.setItem('dsh-novel-forge.theme', theme);
126
- window.localStorage.setItem('dsh-novel-forge.theme.mode', mode);
127
- window.localStorage.setItem('dsh-novel-forge.theme.density', density);
128
- } catch { /* ignore */ }
129
- onTheme?.(theme, mode, density);
130
- };
131
- const changeEndfieldAccent = (accent: EndfieldAccent): void => {
132
- setEndfieldAccent(accent);
133
- try { window.localStorage.setItem('dsh-novel-forge.theme.endfield.accent', accent) } catch { /* ignore */ }
134
- onEndfieldAccent?.(accent);
135
- };
132
+ // 显示模式/密度由全局(NovelPanel)统一管理,设置页只调用 onChange 回调。
136
133
  const changeEditorFont = (n: number): void => {
137
134
  const v = Math.min(24, Math.max(12, n)); setEditorFontSize(v);
138
135
  try { window.localStorage.setItem('dsh-novel-forge.editor.fontSize', String(v)) } catch { /* ignore */ }
@@ -147,10 +144,6 @@ export function SettingsView({ api, onTheme, onSettingsTab, onEditorFontSize, on
147
144
  setConfigDraft(prev => prev === null ? prev : { ...prev, themeBackgroundBlur: blur });
148
145
  onBackground?.(configDraft?.themeBackground ?? undefined, blur);
149
146
  };
150
- const applyOpacity = (n: number): void => {
151
- setConfigDraft(prev => prev === null ? prev : { ...prev, themeOpacity: n });
152
- onOpacity?.(n);
153
- };
154
147
  const onBgFile = async (file: File | undefined): Promise<void> => {
155
148
  if (file === undefined) return;
156
149
  const dataUrl = await new Promise<string>((resolve, reject) => {
@@ -168,35 +161,31 @@ export function SettingsView({ api, onTheme, onSettingsTab, onEditorFontSize, on
168
161
  }
169
162
  };
170
163
  const resetTheme = (): void => {
171
- try {
172
- window.localStorage.removeItem('dsh-novel-forge.theme');
173
- window.localStorage.removeItem('dsh-novel-forge.theme.mode');
174
- window.localStorage.removeItem('dsh-novel-forge.theme.density');
175
- window.localStorage.removeItem('dsh-novel-forge.theme.endfield.accent');
176
- } catch { /* ignore */ }
177
- setEndfieldAccent('valley');
178
- onEndfieldAccent?.('valley');
179
- changeTheme('liquid', 'system', 'comfort');
164
+ onChangeThemeMode('system');
165
+ onChangeThemeDensity('comfort');
180
166
  };
181
167
 
182
168
  if (configDraft === null) {
169
+ if (variant === 'drawer') {
170
+ return <div style={{ padding: 'var(--nf-space-16)' }}><div className={css.meta}>正在加载设置…</div>{error !== '' && <div className={css.noticeError}>{error}</div>}</div>
171
+ }
183
172
  return (
184
- <div className={css.authorPageBody}><div className={css.meta}>正在加载设置…</div>{error !== '' && <div className={css.noticeError}>{error}</div>}</div>
173
+ <SubPage title="设置" meta="正在加载…">
174
+ <div className={css.meta}>正在加载设置…</div>
175
+ {error !== '' && <div className={css.noticeError}>{error}</div>}
176
+ </SubPage>
185
177
  );
186
178
  }
187
179
 
188
- return (
189
- <div className={css.authorPageBody} data-nf-theme={panelTheme} data-nf-mode={themeMode === 'system' ? undefined : themeMode} data-nf-density={themeDensity} data-nf-endfield-accent={endfieldAccent}>
190
- <div className={css.card + ' ' + css.settingsCard} style={{ gap: 'var(--nf-space-6)' }}>
191
- <h2 className={css.panelTitle} style={{ margin: 0 }}>⚙️ 设置</h2>
192
- <span className={css.meta}>当前模型:{config?.provider} / {config?.model}</span>
193
- <span className={css.meta}>输出目录:{config?.outputDir}</span>
194
- </div>
195
-
196
- <div className={css.row} style={{ gap: 'var(--nf-space-8)', flexWrap: 'wrap' }}>
180
+ const content = (
181
+ <>
182
+ <div className={css.shelfToolbar} style={{ gap: 'var(--nf-space-8)', flexWrap: 'wrap' }}>
197
183
  {SETTINGS_SECTIONS.map(s => (
198
184
  <button key={s.id} type="button" className={css.button + (settingsTab === s.id ? ' ' + css.buttonPrimary : '')} style={{ flex: 1, minWidth: 104, justifyContent: 'center' }} onClick={() => changeSettingsTab(s.id)}>{s.icon} {s.label}</button>
199
185
  ))}
186
+ {variant === 'drawer' && (
187
+ <button type="button" className={css.button + ' ' + css.buttonPrimary} disabled={busy} onClick={() => { void save() }}>{busy ? '保存中…' : tt('settings.save')}</button>
188
+ )}
200
189
  </div>
201
190
 
202
191
  {error !== '' && <div className={css.noticeError}>{error}</div>}
@@ -216,6 +205,39 @@ export function SettingsView({ api, onTheme, onSettingsTab, onEditorFontSize, on
216
205
  onSavedModels={models => setConfigDraft({ ...configDraft, savedModels: models })}
217
206
  />
218
207
  </div>
208
+ <div className={css.card + ' ' + css.settingsCard} style={{ gap: 'var(--nf-space-12)' }}>
209
+ <span className={css.cardTitle}>备用模型(故障自动切换)</span>
210
+ <div className={css.field}>
211
+ <label className={css.fieldLabel}>主模型调用失败(限流 / 超时 / 额度 / 服务不可用)时,自动换它重试一次</label>
212
+ <select
213
+ className={css.input}
214
+ value={configDraft.fallbackModel ?? ''}
215
+ onChange={e => setConfigDraft({ ...configDraft, fallbackModel: e.target.value })}
216
+ >
217
+ <option value="">不切换(关闭)</option>
218
+ {[...new Set([configDraft.model, ...(configDraft.savedModels ?? []).map(m => m.model)])]
219
+ .filter(m => m !== '')
220
+ .map(m => <option key={m} value={m}>{m}</option>)}
221
+ </select>
222
+ </div>
223
+ <span className={css.meta}>
224
+ 只对可重试的失败生效:用户取消、参数错误不会切换;正文生成只在「一个字都没产出」时才换模型重试,避免重复生成。
225
+ </span>
226
+ </div>
227
+ <div className={css.card + ' ' + css.settingsCard} style={{ gap: 'var(--nf-space-12)' }}>
228
+ <span className={css.cardTitle}>故事时间线</span>
229
+ <label className={css.row} style={{ gap: 'var(--nf-space-8)', alignItems: 'center' }}>
230
+ <input
231
+ type="checkbox"
232
+ checked={configDraft.autoTimeline ?? true}
233
+ onChange={e => setConfigDraft({ ...configDraft, autoTimeline: e.target.checked })}
234
+ />
235
+ <span>出章后自动抽取时间线事件(时间 / 地点 / 在场角色 / 事件)</span>
236
+ </label>
237
+ <span className={css.meta}>
238
+ 抽取失败不会阻断出章;可在侧柜「故事时间线」里手工抽取、修正,并一键检查时间倒流 / 地点瞬移等矛盾。
239
+ </span>
240
+ </div>
219
241
  <ReasoningSection
220
242
  reasoningEffort={configDraft.reasoningEffort ?? 'off'}
221
243
  analysisReasoning={configDraft.analysisReasoning ?? 'low'}
@@ -224,24 +246,17 @@ export function SettingsView({ api, onTheme, onSettingsTab, onEditorFontSize, on
224
246
  </>
225
247
  )}
226
248
 
227
- {settingsTab === 'writing' && (
228
- <div className={css.card + ' ' + css.settingsCard} style={{ gap: 'var(--nf-space-24)' }}>
229
- <span className={css.cardTitle}><PenLine size={18} style={{ verticalAlign: -3 }} /> 写作与审稿</span>
230
- <div className={css.field}><label className={css.fieldLabel}>{tt('settings.chapterChars')}</label><input className={css.input} type="number" min={1000} max={20000} value={configDraft.chapterChars} onChange={e => setConfigDraft({ ...configDraft, chapterChars: Number(e.target.value) })} /></div>
231
- <div className={css.field}><label className={css.fieldLabel}>{tt('settings.maxTokens')}</label><input className={css.input} type="number" min={2000} max={64000} value={configDraft.maxTokens} onChange={e => setConfigDraft({ ...configDraft, maxTokens: Number(e.target.value) })} /></div>
232
- <div className={css.field}><label className={css.fieldLabel}>{tt('settings.reviewPassScore')}</label><input className={css.input} type="number" min={0} max={100} value={configDraft.reviewPassScore} onChange={e => setConfigDraft({ ...configDraft, reviewPassScore: Number(e.target.value) })} /><span className={css.meta}>通过判定:综合评分 ≥ 此分数(默认 70),或无 high 级问题且评分 ≥ 60;建议设 60-80。</span></div>
233
- <div className={css.field}><label className={css.fieldLabel}>{tt('settings.autoReview')}</label><select className={css.input} value={configDraft.autoReview ? '1' : '0'} onChange={e => setConfigDraft({ ...configDraft, autoReview: e.target.value === '1' })}><option value="1">✓ 是</option><option value="0">✗ 否</option></select></div>
234
- <div className={css.field}><label className={css.fieldLabel}>{tt('settings.autoAuthorReview')}</label><select className={css.input} value={configDraft.autoAuthorReview ? '1' : '0'} onChange={e => setConfigDraft({ ...configDraft, autoAuthorReview: e.target.value === '1' })}><option value="1">✓ 是</option><option value="0">✗ 否</option></select><span className={css.meta}>{tt('settings.autoAuthorReviewHint')}</span></div>
235
- <div className={css.field}><label className={css.fieldLabel}>{tt('settings.autoReviewAfterRevise')}</label><select className={css.input} value={configDraft.autoReviewAfterRevise ? '1' : '0'} onChange={e => setConfigDraft({ ...configDraft, autoReviewAfterRevise: e.target.value === '1' })}><option value="1">✓ 是</option><option value="0">✗ 否</option></select><span className={css.meta}>{tt('settings.autoReviewAfterReviseHint')}</span></div>
236
- <div className={css.field}><label className={css.fieldLabel}>编辑器字号(正文编辑 / 工作区)</label><select className={css.input} value={editorFontSize} onChange={e => changeEditorFont(Number(e.target.value))}>{[12,13,14,15,16,18,20,22,24].map(v => <option key={v} value={v}>{v}px</option>)}</select></div>
237
- </div>
238
- )}
239
-
240
249
  {settingsTab === 'files' && (
241
250
  <div className={css.card + ' ' + css.settingsCard} style={{ gap: 'var(--nf-space-24)' }}>
242
251
  <span className={css.cardTitle}><Folder size={18} style={{ verticalAlign: -3 }} /> 路径与文件</span>
243
- <div className={css.field}><label className={css.fieldLabel}>{tt('settings.outlinePath')}</label><input className={css.input} value={configDraft.outlinePath} onChange={e => setConfigDraft({ ...configDraft, outlinePath: e.target.value })} /></div>
244
- <div className={css.field}><label className={css.fieldLabel}>{tt('settings.outputDir')}</label><input className={css.input} value={configDraft.outputDir} onChange={e => setConfigDraft({ ...configDraft, outputDir: e.target.value })} /></div>
252
+ <div className={css.field}>
253
+ <label className={css.fieldLabel}>{tt('settings.outputDir')}</label>
254
+ <div className={css.row} style={{ gap: 'var(--nf-space-8)', flexWrap: 'wrap' }}>
255
+ <input className={css.input} style={{ flex: 1, minWidth: 220 }} value={configDraft.outputDir} onChange={e => setConfigDraft({ ...configDraft, outputDir: e.target.value })} />
256
+ <button type="button" className={css.button} onClick={() => { void openMoveDialog() }} title="把当前输出目录的全部文件搬到新位置,书架与配置自动联动">更改位置…</button>
257
+ </div>
258
+ <span className={css.meta}>含章节、项目状态、助手历史等;「更改位置」会整目录搬迁并联动书架,直接改路径仅更新默认值。</span>
259
+ </div>
245
260
  <div className={css.row}><button type="button" className={css.button} onClick={() => { void api.openFolder() }}>{tt('settings.openFolder')}</button></div>
246
261
  </div>
247
262
  )}
@@ -250,13 +265,21 @@ export function SettingsView({ api, onTheme, onSettingsTab, onEditorFontSize, on
250
265
  <>
251
266
  <div className={css.card + ' ' + css.settingsCard}>
252
267
  <span className={css.cardTitle}><Sparkles size={18} style={{ verticalAlign: -3 }} /> 外观与主题</span>
253
- <div className={css.field}><label className={css.fieldLabel}>显示模式</label><select className={css.input} value={themeMode} onChange={e => changeTheme(panelTheme, e.target.value as ThemeMode, themeDensity)}><option value="system">跟随系统</option><option value="light">浅色</option><option value="dark">深色</option></select></div>
254
- <div className={css.field}><label className={css.fieldLabel}>主题风格</label><select className={css.input} value={panelTheme} onChange={e => changeTheme(e.target.value as ThemeName, themeMode, themeDensity)}><option value="liquid">液态玻璃 · 清新绿(默认)</option><option value="neumorph">新拟物 · 柔和浅色</option><option value="macos">macOS · 玻璃(蓝,随外观浅/深)</option><option value="clay">粘土拟态 · 柔和黏土</option><option value="endfield">终末地 · 纸墨工业风</option></select></div>
255
- {panelTheme === 'endfield' && (
256
- <div className={css.field}><label className={css.fieldLabel}>终末地强调色</label><select className={css.input} value={endfieldAccent} onChange={e => changeEndfieldAccent(e.target.value as EndfieldAccent)}><option value="valley">谷地黄(默认)</option><option value="wuling">武陵青</option></select><span className={css.meta}>参考明日方舟:终末地官网,亮色自动使用深色档保证可读性。</span></div>
257
- )}
258
- <div className={css.field}><label className={css.fieldLabel}>界面密度</label><select className={css.input} value={themeDensity} onChange={e => changeTheme(panelTheme, themeMode, e.target.value as ThemeDensity)}><option value="comfort">舒适(默认)</option><option value="compact">紧凑</option><option value="spacious">宽松</option></select></div>
259
- <div className={css.field}><label className={css.fieldLabel}>玻璃透明度(0-100)</label><input className={css.input} type="range" min={0} max={100} value={configDraft.themeOpacity ?? 100} onChange={e => applyOpacity(Number(e.target.value))} /><span className={css.meta}>100=当前主题原样;调低会让玻璃/卡片变透,新拟态、黏土等实色主题能透出背景图。</span></div>
268
+ <div className={css.field}><label className={css.fieldLabel}>显示模式</label><select className={css.input} value={themeMode} onChange={e => onChangeThemeMode(e.target.value as ThemeMode)}><option value="system">跟随系统</option><option value="light">浅色</option><option value="dark">深色</option></select></div>
269
+ <div className={css.field}><label className={css.fieldLabel}>主题风格</label><span className={css.input} style={{ display: 'inline-flex', alignItems: 'center' }}>墨纸 · 暖编辑案头</span><span className={css.meta}>统一墨纸风格,无需再选皮肤;切换明暗即达纸白/墨色。</span></div>
270
+ <div className={css.field}><label className={css.fieldLabel}>界面密度</label><select className={css.input} value={themeDensity} onChange={e => onChangeThemeDensity(e.target.value as ThemeDensity)}><option value="comfort">舒适(默认)</option><option value="compact">紧凑</option><option value="spacious">宽松</option></select></div>
271
+ <div className={css.field}><label className={css.fieldLabel}>编辑器字号(正文编辑 / 工作区)</label><select className={css.input} value={editorFontSize} onChange={e => changeEditorFont(Number(e.target.value))}>{[12,13,14,15,16,18,20,22,24].map(v => <option key={v} value={v}>{v}px</option>)}</select></div>
272
+ <div className={css.field}>
273
+ <label className={css.fieldLabel}>生成状态芯片(右上角)</label>
274
+ <select
275
+ className={css.input}
276
+ value={runChipMode}
277
+ onChange={e => { const next = e.target.value as RunChipMode; setRunChipMode(next); writeRunChipMode(next) }}
278
+ >
279
+ {RUN_CHIP_MODE_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
280
+ </select>
281
+ <span className={css.meta}>固定在窗口右上角,显示生产单 生成中 / 已暂停 / 批完成 / 出错,点击可打开小说工坊。「关闭」时同时停掉状态轮询。存于浏览器本地,即时生效。</span>
282
+ </div>
260
283
  <div className={css.row} style={{ justifyContent: 'flex-end' }}><button type="button" className={css.button} onClick={resetTheme}><RotateCcw size={14} style={{ verticalAlign: -2 }} /> 恢复默认主题</button></div>
261
284
  </div>
262
285
 
@@ -280,7 +303,53 @@ export function SettingsView({ api, onTheme, onSettingsTab, onEditorFontSize, on
280
303
  </>
281
304
  )}
282
305
 
283
- <div className={css.row}><button type="button" className={css.button + ' ' + css.buttonPrimary} disabled={busy} onClick={() => { void save() }}>{busy ? '保存中…' : tt('settings.save')}</button></div>
284
- </div>
306
+ {moveOpen && (
307
+ <div className={css.importModalOverlay} role="dialog" aria-modal="true" aria-label="更改输出目录" onClick={e => { if (e.target === e.currentTarget && !moveBusy) setMoveOpen(false) }}>
308
+ <div className={css.importModal}>
309
+ <div className={css.importModalHead}>
310
+ <span className={css.cardTitle}>更改输出目录</span>
311
+ <button type="button" className={css.buttonSmall + ' ' + css.button} disabled={moveBusy} onClick={() => { setMoveOpen(false) }}><X size={14} /></button>
312
+ </div>
313
+ <div className={css.importModalBody}>
314
+ <div className={css.field}>
315
+ <label className={css.fieldLabel}>当前目录</label>
316
+ <span className={css.meta}>{movePreview?.from ?? configDraft.outputDir}</span>
317
+ </div>
318
+ {movePreview !== null && (
319
+ <div className={css.field}>
320
+ <label className={css.fieldLabel}>将被搬走的内容</label>
321
+ <span className={css.meta}>{movePreview.files.length} 个条目 · 约 {formatBytes(movePreview.bytes)}{movePreview.files.length > 0 ? `(如 ${movePreview.files.slice(0, 3).join('、')}${movePreview.files.length > 3 ? ' …' : ''})` : '(空目录)'}</span>
322
+ </div>
323
+ )}
324
+ <div className={css.field}>
325
+ <label className={css.fieldLabel}>新目录(绝对路径)</label>
326
+ <input className={css.input} placeholder="例如 D:\\Novels\\我的书" value={moveTarget} onChange={e => setMoveTarget(e.target.value)} disabled={moveBusy} autoFocus />
327
+ <span className={css.meta}>目标必须为空或不存在;文件搬迁后书架与默认配置自动联动,成功后才删除旧目录,中途失败数据无损。</span>
328
+ </div>
329
+ {moveError !== '' && <span className={css.meta} style={{ color: 'var(--nf-danger, #e5484d)' }}>{moveError}</span>}
330
+ </div>
331
+ <div className={css.importModalActions}>
332
+ <button type="button" className={css.button} disabled={moveBusy} onClick={() => { setMoveOpen(false) }}>取消</button>
333
+ <button type="button" className={css.button} disabled={moveBusy || moveTarget.trim() === ''} onClick={() => { void confirmMove() }}>{moveBusy ? '搬迁中…' : '确认搬迁'}</button>
334
+ </div>
335
+ </div>
336
+ </div>
337
+ )}
338
+ </>
339
+ );
340
+
341
+ if (variant === 'drawer') {
342
+ return <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-12)', padding: 'var(--nf-space-16)' }}>{content}</div>;
343
+ }
344
+ return (
345
+ <SubPage
346
+ title="设置"
347
+ meta={`当前模型:${config?.provider} / ${config?.model} · 输出目录:${config?.outputDir}`}
348
+ actions={(
349
+ <button type="button" className={css.button + ' ' + css.buttonPrimary} disabled={busy} onClick={() => { void save() }}>{busy ? '保存中…' : tt('settings.save')}</button>
350
+ )}
351
+ >
352
+ {content}
353
+ </SubPage>
285
354
  );
286
355
  }