@waterwx/dsh-novel-forge 2.1.1-alpha → 2.1.1

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 (253) hide show
  1. package/README.md +368 -472
  2. package/lib/client.js +19578 -21391
  3. package/lib/index.js +4640 -3932
  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 +7 -9
  7. package/lib/types/assistant.d.ts +25 -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 +49 -29
  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 +12 -14
  14. package/lib/types/client/locales.d.ts +12 -67
  15. package/lib/types/client/mount.d.ts +0 -2
  16. package/lib/types/client/panel/AdaptModeView.d.ts +1 -1
  17. package/lib/types/client/panel/AiTag.d.ts +1 -0
  18. package/lib/types/client/panel/AssetsTab.d.ts +1 -1
  19. package/lib/types/client/panel/AssistantTab.d.ts +4 -1
  20. package/lib/types/client/panel/AuthorAssetsView.d.ts +1 -1
  21. package/lib/types/client/panel/AuthorHome.d.ts +12 -12
  22. package/lib/types/client/panel/CmdPalette.d.ts +12 -0
  23. package/lib/types/client/panel/CreateBookView.d.ts +1 -1
  24. package/lib/types/client/panel/GlobalAssetLibraryView.d.ts +1 -1
  25. package/lib/types/client/panel/ImportModal.d.ts +1 -1
  26. package/lib/types/client/panel/LiveFeedLog.d.ts +8 -0
  27. package/lib/types/client/panel/ModelManager.d.ts +2 -2
  28. package/lib/types/client/panel/NovelPanel.d.ts +7 -2
  29. package/lib/types/client/panel/PageHeader.d.ts +18 -0
  30. package/lib/types/client/panel/ProgressConsole.d.ts +1 -1
  31. package/lib/types/client/panel/PromptSlotsView.d.ts +11 -0
  32. package/lib/types/client/panel/ReaderView.d.ts +1 -1
  33. package/lib/types/client/panel/RunPanel.d.ts +1 -1
  34. package/lib/types/client/panel/SettingsView.d.ts +13 -10
  35. package/lib/types/client/panel/ShelfView.d.ts +11 -2
  36. package/lib/types/client/panel/SubPage.d.ts +27 -0
  37. package/lib/types/client/panel/TensionView.d.ts +18 -0
  38. package/lib/types/client/panel/TimelineView.d.ts +18 -0
  39. package/lib/types/client/panel/WorldTab.d.ts +1 -1
  40. package/lib/types/client/panel/helpers.d.ts +5 -3
  41. package/lib/types/client/panel/market-radar-state.d.ts +56 -0
  42. package/lib/types/client/panel/outline-markdown.d.ts +112 -0
  43. package/lib/types/client/panel/status-badge.d.ts +13 -0
  44. package/lib/types/client/panel/storage-parse.d.ts +46 -0
  45. package/lib/types/client/panel/toast.d.ts +2 -0
  46. package/lib/types/client/panel/usePersistentState.d.ts +17 -0
  47. package/lib/types/client/panel/views.d.ts +21 -35
  48. package/lib/types/client/run-status.d.ts +21 -0
  49. package/lib/types/client/sidebar-entry.d.ts +0 -2
  50. package/lib/types/digest-parse.d.ts +29 -0
  51. package/lib/types/docx-core.d.ts +16 -0
  52. package/lib/types/docx.d.ts +2 -3
  53. package/lib/types/engine.d.ts +122 -103
  54. package/lib/types/home.d.ts +11 -0
  55. package/lib/types/index.d.ts +21 -13
  56. package/lib/types/llm-live.d.ts +62 -36
  57. package/lib/types/llm-retry.d.ts +38 -0
  58. package/lib/types/market-radar-scan.d.ts +0 -1
  59. package/lib/types/market-radar-sources.d.ts +1 -1
  60. package/lib/types/migrate-output.d.ts +25 -0
  61. package/lib/types/model-capability.d.ts +119 -0
  62. package/lib/types/model-request.d.ts +36 -0
  63. package/lib/types/output-budget.d.ts +86 -0
  64. package/lib/types/plotline-align.d.ts +35 -0
  65. package/lib/types/prompt-slots.d.ts +57 -0
  66. package/lib/types/prompting.d.ts +2 -27
  67. package/lib/types/protocol.d.ts +479 -595
  68. package/lib/types/revision.d.ts +76 -0
  69. package/lib/types/routes.d.ts +9 -3
  70. package/lib/types/run-selection.d.ts +51 -0
  71. package/lib/types/run.d.ts +31 -0
  72. package/lib/types/settings-persist.d.ts +59 -0
  73. package/lib/types/snapshots.d.ts +46 -0
  74. package/lib/types/stage-contract.d.ts +58 -0
  75. package/lib/types/stream-fallback.d.ts +60 -0
  76. package/lib/types/strip-headings.d.ts +33 -0
  77. package/lib/types/tension.d.ts +53 -0
  78. package/lib/types/timeline.d.ts +37 -0
  79. package/package.json +41 -28
  80. package/assets/styles/arcane-thick-3d.webp +0 -0
  81. package/assets/styles/chinese-ink.webp +0 -0
  82. package/assets/styles/clay-stopmotion-3d.webp +0 -0
  83. package/assets/styles/dark-ghibli.webp +0 -0
  84. package/assets/styles/dark-gothic-3d.webp +0 -0
  85. package/assets/styles/film-photo.webp +0 -0
  86. package/assets/styles/guofeng-heavy.webp +0 -0
  87. package/assets/styles/guoman-2d-gufeng.webp +0 -0
  88. package/assets/styles/guoman-3d-tianyuan.webp +0 -0
  89. package/assets/styles/guoman-3d-xuanhuan.webp +0 -0
  90. package/assets/styles/horror-game-3d.webp +0 -0
  91. package/assets/styles/hyperreal-3d.webp +0 -0
  92. package/assets/styles/japan-cel-dark.webp +0 -0
  93. package/assets/styles/korean-webtoon.webp +0 -0
  94. package/assets/styles/low-cyberpunk-3d.webp +0 -0
  95. package/assets/styles/nextgen-cg.webp +0 -0
  96. package/assets/styles/noir-film.webp +0 -0
  97. package/assets/styles/openworld-survival.webp +0 -0
  98. package/assets/styles/paper-cut-3d.webp +0 -0
  99. package/assets/styles/pixar-adult-3d.webp +0 -0
  100. package/assets/styles/pop-marvel-2d.webp +0 -0
  101. package/assets/styles/sandiao-qban.webp +0 -0
  102. package/assets/styles/shinkai-anime.webp +0 -0
  103. package/assets/styles/tactical-stealth.webp +0 -0
  104. package/assets/styles/urban-lightreal-3d.webp +0 -0
  105. package/assets/styles/vaporwave-3d.webp +0 -0
  106. package/assets/styles/vhs90.webp +0 -0
  107. package/lib/types/client/panel/BookshelfBar.d.ts +0 -11
  108. package/lib/types/client/panel/FlowGuide.d.ts +0 -22
  109. package/lib/types/client/panel/MangaRoleLibrary.d.ts +0 -20
  110. package/lib/types/client/panel/MangaWorkspace.d.ts +0 -12
  111. package/lib/types/client/panel/ModelPicker.d.ts +0 -11
  112. package/lib/types/client/panel/ProgressHomeView.d.ts +0 -5
  113. package/lib/types/client/panel/PropLibrary.d.ts +0 -8
  114. package/lib/types/client/panel/SceneLibrary.d.ts +0 -16
  115. package/lib/types/client/panel/StoryboardTab.d.ts +0 -19
  116. package/lib/types/client/panel/StyleCard.d.ts +0 -8
  117. package/lib/types/client/panel/StyleGallery.d.ts +0 -1
  118. package/lib/types/comic-presets.d.ts +0 -12
  119. package/lib/types/manga-genre-rules.d.ts +0 -22
  120. package/lib/types/prompt-utils.d.ts +0 -35
  121. package/lib/types/review-policy.d.ts +0 -97
  122. package/lib/types/shot-language.d.ts +0 -46
  123. package/lib/types/src/ai-scan.d.ts +0 -36
  124. package/lib/types/src/assets.d.ts +0 -37
  125. package/lib/types/src/assistant.d.ts +0 -45
  126. package/lib/types/src/author-assets.d.ts +0 -25
  127. package/lib/types/src/bookshelf.d.ts +0 -42
  128. package/lib/types/src/client/api.d.ts +0 -234
  129. package/lib/types/src/client/docx.d.ts +0 -15
  130. package/lib/types/src/client/index.d.ts +0 -14
  131. package/lib/types/src/client/locales.d.ts +0 -212
  132. package/lib/types/src/client/mount.d.ts +0 -9
  133. package/lib/types/src/client/panel/AdaptModeView.d.ts +0 -5
  134. package/lib/types/src/client/panel/AssetsTab.d.ts +0 -12
  135. package/lib/types/src/client/panel/AssistantTab.d.ts +0 -7
  136. package/lib/types/src/client/panel/AuthorAssetsView.d.ts +0 -4
  137. package/lib/types/src/client/panel/AuthorHome.d.ts +0 -32
  138. package/lib/types/src/client/panel/BookshelfBar.d.ts +0 -11
  139. package/lib/types/src/client/panel/CreateBookView.d.ts +0 -9
  140. package/lib/types/src/client/panel/FlowGuide.d.ts +0 -22
  141. package/lib/types/src/client/panel/GlobalAssetLibraryView.d.ts +0 -4
  142. package/lib/types/src/client/panel/ImportModal.d.ts +0 -7
  143. package/lib/types/src/client/panel/MangaRoleLibrary.d.ts +0 -20
  144. package/lib/types/src/client/panel/MangaWorkspace.d.ts +0 -12
  145. package/lib/types/src/client/panel/ModelManager.d.ts +0 -14
  146. package/lib/types/src/client/panel/NovelPanel.d.ts +0 -13
  147. package/lib/types/src/client/panel/ProgressConsole.d.ts +0 -17
  148. package/lib/types/src/client/panel/ProgressHomeView.d.ts +0 -5
  149. package/lib/types/src/client/panel/PropLibrary.d.ts +0 -8
  150. package/lib/types/src/client/panel/ReaderView.d.ts +0 -8
  151. package/lib/types/src/client/panel/ReasoningSection.d.ts +0 -10
  152. package/lib/types/src/client/panel/RunPanel.d.ts +0 -5
  153. package/lib/types/src/client/panel/SceneLibrary.d.ts +0 -16
  154. package/lib/types/src/client/panel/SettingsView.d.ts +0 -16
  155. package/lib/types/src/client/panel/ShelfView.d.ts +0 -15
  156. package/lib/types/src/client/panel/StoryboardTab.d.ts +0 -19
  157. package/lib/types/src/client/panel/StyleCard.d.ts +0 -8
  158. package/lib/types/src/client/panel/StyleGallery.d.ts +0 -1
  159. package/lib/types/src/client/panel/WorldTab.d.ts +0 -10
  160. package/lib/types/src/client/panel/controller.d.ts +0 -19
  161. package/lib/types/src/client/panel/helpers.d.ts +0 -18
  162. package/lib/types/src/client/panel/views.d.ts +0 -77
  163. package/lib/types/src/client/sidebar-entry.d.ts +0 -13
  164. package/lib/types/src/client/text.d.ts +0 -9
  165. package/lib/types/src/comic-presets.d.ts +0 -12
  166. package/lib/types/src/docx.d.ts +0 -19
  167. package/lib/types/src/engine.d.ts +0 -402
  168. package/lib/types/src/index.d.ts +0 -79
  169. package/lib/types/src/manga-genre-rules.d.ts +0 -22
  170. package/lib/types/src/novel-context.d.ts +0 -80
  171. package/lib/types/src/prompt-utils.d.ts +0 -35
  172. package/lib/types/src/protocol.d.ts +0 -2176
  173. package/lib/types/src/review-policy.d.ts +0 -97
  174. package/lib/types/src/routes.d.ts +0 -29
  175. package/lib/types/src/run.d.ts +0 -32
  176. package/lib/types/src/shot-language.d.ts +0 -46
  177. package/lib/types/src/story-beat-language.d.ts +0 -35
  178. package/lib/types/src/style-library.d.ts +0 -33
  179. package/lib/types/story-beat-language.d.ts +0 -35
  180. package/lib/types/style-library.d.ts +0 -33
  181. package/src/ai-scan.ts +0 -179
  182. package/src/assets.ts +0 -1774
  183. package/src/assistant.ts +0 -915
  184. package/src/author-assets.ts +0 -147
  185. package/src/bookshelf.ts +0 -167
  186. package/src/client/api.ts +0 -716
  187. package/src/client/css-modules.d.ts +0 -8
  188. package/src/client/docx.ts +0 -69
  189. package/src/client/index.ts +0 -34
  190. package/src/client/llmLive.ts +0 -101
  191. package/src/client/locales.ts +0 -418
  192. package/src/client/mount.tsx +0 -97
  193. package/src/client/mount.tsx._new +0 -97
  194. package/src/client/mount.tsx._nf +0 -97
  195. package/src/client/mount.tsx._tmp +0 -97
  196. package/src/client/panel/AdaptModeView.tsx +0 -535
  197. package/src/client/panel/AssetsTab.tsx +0 -458
  198. package/src/client/panel/AssistantTab.tsx +0 -339
  199. package/src/client/panel/AuthorAssetsView.tsx +0 -208
  200. package/src/client/panel/AuthorHome.tsx +0 -234
  201. package/src/client/panel/BookAnalysisView.tsx +0 -60
  202. package/src/client/panel/BookshelfBar.tsx +0 -170
  203. package/src/client/panel/CreateBookView.tsx +0 -371
  204. package/src/client/panel/DirectorView.tsx +0 -200
  205. package/src/client/panel/FlowGuide.tsx +0 -84
  206. package/src/client/panel/GlobalAssetLibraryView.tsx +0 -172
  207. package/src/client/panel/IdeaInspirationView.tsx +0 -71
  208. package/src/client/panel/ImportModal.tsx +0 -303
  209. package/src/client/panel/KnowledgeBaseView.tsx +0 -73
  210. package/src/client/panel/LiveFeedLog.tsx +0 -76
  211. package/src/client/panel/MangaRoleLibrary.tsx +0 -1002
  212. package/src/client/panel/MangaWorkspace.tsx +0 -715
  213. package/src/client/panel/MarketRadarView.tsx +0 -374
  214. package/src/client/panel/ModelManager.tsx +0 -205
  215. package/src/client/panel/NovelPanel.tsx +0 -4721
  216. package/src/client/panel/ProgressConsole.tsx +0 -63
  217. package/src/client/panel/ProgressHomeView.tsx +0 -87
  218. package/src/client/panel/PropLibrary.tsx +0 -175
  219. package/src/client/panel/ReaderView.tsx +0 -306
  220. package/src/client/panel/ReasoningSection.tsx +0 -31
  221. package/src/client/panel/RunPanel.tsx +0 -204
  222. package/src/client/panel/SceneLibrary.tsx +0 -453
  223. package/src/client/panel/SettingsView.tsx +0 -289
  224. package/src/client/panel/ShelfView.tsx +0 -242
  225. package/src/client/panel/StoryboardTab.tsx +0 -472
  226. package/src/client/panel/StyleCard.tsx +0 -90
  227. package/src/client/panel/StyleGallery.tsx +0 -58
  228. package/src/client/panel/WorldTab.tsx +0 -202
  229. package/src/client/panel/controller.ts +0 -45
  230. package/src/client/panel/helpers.ts +0 -67
  231. package/src/client/panel/panel.module.css +0 -4359
  232. package/src/client/panel/views.tsx +0 -314
  233. package/src/client/sidebar-entry.ts +0 -122
  234. package/src/client/text.ts +0 -28
  235. package/src/comic-presets.ts +0 -67
  236. package/src/docx.ts +0 -83
  237. package/src/engine.ts +0 -5636
  238. package/src/global-assets.ts +0 -59
  239. package/src/index.ts +0 -346
  240. package/src/llm-live.ts +0 -68
  241. package/src/manga-genre-rules.ts +0 -87
  242. package/src/market-radar-scan.ts +0 -41
  243. package/src/market-radar-sources.ts +0 -173
  244. package/src/novel-context.ts +0 -287
  245. package/src/prompt-utils.ts +0 -73
  246. package/src/prompting.ts +0 -159
  247. package/src/protocol.ts +0 -2565
  248. package/src/review-policy.ts +0 -161
  249. package/src/routes.ts +0 -3939
  250. package/src/run.ts +0 -338
  251. package/src/shot-language.ts +0 -181
  252. package/src/story-beat-language.ts +0 -130
  253. package/src/style-library.ts +0 -84
@@ -1,289 +0,0 @@
1
- /**
2
- * 共享设置页(作者首页「设置」与书内「设置」同入口)。
3
- * 自给自足:挂载时通过 api.status() 拉取全局配置,支持 model/writing/image/files/appearance 五组,
4
- * 保存走 api.patchConfig。不与某个书绑定,因此新装用户无书也能用。
5
- */
6
- import { useEffect, useRef, useState } from 'react'
7
- import { Brain, PenLine, Palette, Folder, Sparkles, RotateCcw, PlugZap, Plus, Trash2, Upload, X, Image } from 'lucide-react'
8
- import type { NovelApi } from '../api.ts'
9
- import type { NovelConfig, ImageModelConfig } from '../../protocol.ts'
10
- import { tt } from './helpers.ts'
11
- import { ModelManager } from './ModelManager.tsx'
12
- import { ReasoningSection } from './ReasoningSection.tsx'
13
- import css from './panel.module.css'
14
-
15
-
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} /> },
19
- { id: 'files', label: '路径与文件', icon: <Folder size={16} /> },
20
- { id: 'appearance', label: '外观与主题', icon: <Sparkles size={16} /> },
21
- ];
22
-
23
- type SettingsTab = 'model' | 'writing' | 'image' | 'files' | 'appearance'
24
- type ThemeMode = 'system' | 'light' | 'dark'
25
- type ThemeName = 'liquid' | 'neumorph' | 'macos' | 'clay' | 'endfield'
26
- type EndfieldAccent = 'valley' | 'wuling'
27
- type ThemeDensity = 'comfort' | 'compact' | 'spacious'
28
-
29
- function readLS<T extends string>(key: string, fallback: T, allowed: readonly T[]): T {
30
- try {
31
- const v = window.localStorage.getItem(key) as T | null
32
- return v !== null && (allowed as readonly string[]).includes(v) ? v : fallback
33
- } catch { return fallback }
34
- }
35
-
36
- export function SettingsView({ api, onTheme, onSettingsTab, onEditorFontSize, onBackground, onOpacity, onEndfieldAccent }: {
37
- api: NovelApi;
38
- onTheme?: (theme: ThemeName, mode: ThemeMode, density: ThemeDensity) => void;
39
- onSettingsTab?: (tab: SettingsTab) => void;
40
- onEditorFontSize?: (n: number) => void;
41
- onBackground?: (bg: string | undefined, blur: number) => void;
42
- onOpacity?: (n: number) => void;
43
- onEndfieldAccent?: (accent: EndfieldAccent) => void;
44
- }) {
45
- const [config, setConfig] = useState<NovelConfig | null>(null);
46
- const [configDraft, setConfigDraft] = useState<NovelConfig | null>(null);
47
- const [busy, setBusy] = useState(false);
48
- const [error, setError] = useState('');
49
- 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 }>>({});
52
- 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
- const loadConfig = async (): Promise<void> => {
59
- try {
60
- const res = await api.status();
61
- setConfig(res.config);
62
- setConfigDraft(res.config);
63
- } catch (err) { setError((err as Error).message) }
64
- };
65
- useEffect(() => { void loadConfig() }, []);
66
-
67
- useEffect(() => { try { window.localStorage.setItem('dsh-novel-forge.settings.tab', settingsTab) } catch { /* ignore */ } }, [settingsTab]);
68
-
69
- const changeSettingsTab = (next: SettingsTab): void => { setSettingsTabState(next); onSettingsTab?.(next) };
70
-
71
- const save = async (): Promise<void> => {
72
- if (configDraft === null) return;
73
- setBusy(true); setError(''); setNotice('');
74
- try {
75
- const result = await api.patchConfig({
76
- outlinePath: configDraft.outlinePath,
77
- outputDir: configDraft.outputDir,
78
- provider: configDraft.provider,
79
- model: configDraft.model,
80
- generateModel: configDraft.generateModel,
81
- reviewModel: configDraft.reviewModel,
82
- auditModel: configDraft.auditModel,
83
- reasoningEffort: configDraft.reasoningEffort ?? 'off',
84
- chapterChars: configDraft.chapterChars,
85
- maxTokens: configDraft.maxTokens,
86
- reviewPassScore: configDraft.reviewPassScore,
87
- autoReview: configDraft.autoReview,
88
- autoAuthorReview: configDraft.autoAuthorReview,
89
- autoReviewAfterRevise: configDraft.autoReviewAfterRevise,
90
- imageApiKey: configDraft.imageApiKey,
91
- imageApiModel: configDraft.imageApiModel,
92
- imageApiEnabled: configDraft.imageApiEnabled ?? false,
93
- themeBackground: configDraft.themeBackground ?? '',
94
- themeBackgroundBlur: configDraft.themeBackgroundBlur ?? 0,
95
- themeOpacity: configDraft.themeOpacity ?? 100,
96
- imageModels: configDraft.imageModels ?? [],
97
- savedModels: configDraft.savedModels ?? [],
98
- });
99
- setConfig(result.config);
100
- setConfigDraft(result.config);
101
- setNotice(tt('settings.saved'));
102
- } catch (err) { setError((err as Error).message) } finally { setBusy(false) }
103
- };
104
-
105
- // 生图模型操作
106
- const patchImageModel = (id: string, patch: Partial<ImageModelConfig>): void => {
107
- setConfigDraft(prev => prev === null ? prev : { ...prev, imageModels: (prev.imageModels ?? []).map(m => m.id === id ? { ...m, ...patch } : m) })
108
- };
109
- const addImageModel = (): void => {
110
- setConfigDraft(prev => prev === null ? prev : { ...prev, imageModels: [...(prev.imageModels ?? []), { id: 'img-' + Date.now().toString(36), name: '', baseURL: '', apiKey: '', model: '', enabled: (prev.imageModels ?? []).length === 0 }] })
111
- };
112
- const removeImageModel = (id: string): void => {
113
- setConfigDraft(prev => prev === null ? prev : { ...prev, imageModels: (prev.imageModels ?? []).filter(m => m.id !== id) })
114
- };
115
- const enableImageModel = (id: string): void => {
116
- setConfigDraft(prev => prev === null ? prev : { ...prev, imageModels: (prev.imageModels ?? []).map(m => ({ ...m, enabled: m.id === id })) })
117
- };
118
- const testImageModel = async (m: ImageModelConfig): Promise<void> => {
119
- if (m.baseURL.trim() === '' || m.apiKey.trim() === '') { setImageTestState(prev => ({ ...prev, [m.id]: { testing: false, ok: false, message: '请先填写接口地址和 API Key' } })); return }
120
- setImageTestState(prev => ({ ...prev, [m.id]: { testing: true } }));
121
- 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 } })) }
122
- };
123
-
124
- // 主题/密度/字号(写 localStorage,可选通知父级)
125
- const changeTheme = (theme: ThemeName, mode: ThemeMode, density: ThemeDensity): void => {
126
- setPanelTheme(theme); setThemeMode(mode); setThemeDensity(density);
127
- try {
128
- window.localStorage.setItem('dsh-novel-forge.theme', theme);
129
- window.localStorage.setItem('dsh-novel-forge.theme.mode', mode);
130
- window.localStorage.setItem('dsh-novel-forge.theme.density', density);
131
- } catch { /* ignore */ }
132
- onTheme?.(theme, mode, density);
133
- };
134
- const changeEndfieldAccent = (accent: EndfieldAccent): void => {
135
- setEndfieldAccent(accent);
136
- try { window.localStorage.setItem('dsh-novel-forge.theme.endfield.accent', accent) } catch { /* ignore */ }
137
- onEndfieldAccent?.(accent);
138
- };
139
- const changeEditorFont = (n: number): void => {
140
- const v = Math.min(24, Math.max(12, n)); setEditorFontSize(v);
141
- try { window.localStorage.setItem('dsh-novel-forge.editor.fontSize', String(v)) } catch { /* ignore */ }
142
- onEditorFontSize?.(v);
143
- };
144
- const bgFileRef = useRef<HTMLInputElement | null>(null);
145
- const applyBackground = (bg: string | undefined): void => {
146
- setConfigDraft(prev => prev === null ? prev : { ...prev, themeBackground: bg ?? '' });
147
- onBackground?.(bg ?? undefined, configDraft?.themeBackgroundBlur ?? 0);
148
- };
149
- const applyBlur = (blur: number): void => {
150
- setConfigDraft(prev => prev === null ? prev : { ...prev, themeBackgroundBlur: blur });
151
- onBackground?.(configDraft?.themeBackground ?? undefined, blur);
152
- };
153
- const applyOpacity = (n: number): void => {
154
- setConfigDraft(prev => prev === null ? prev : { ...prev, themeOpacity: n });
155
- onOpacity?.(n);
156
- };
157
- const onBgFile = async (file: File | undefined): Promise<void> => {
158
- if (file === undefined) return;
159
- const dataUrl = await new Promise<string>((resolve, reject) => {
160
- const fr = new FileReader();
161
- fr.onload = () => resolve(fr.result as string);
162
- fr.onerror = () => reject(fr.error ?? new Error('读取失败'));
163
- fr.readAsDataURL(file);
164
- });
165
- try {
166
- // 上传到服务端存盘,返回可访问 URL(不往 settings.yaml 塞大 base64)。
167
- const res = await api.themeBackgroundUpload(dataUrl);
168
- applyBackground(res.url);
169
- } catch (err) {
170
- setError((err as Error).message);
171
- }
172
- };
173
- const resetTheme = (): void => {
174
- try {
175
- window.localStorage.removeItem('dsh-novel-forge.theme');
176
- window.localStorage.removeItem('dsh-novel-forge.theme.mode');
177
- window.localStorage.removeItem('dsh-novel-forge.theme.density');
178
- window.localStorage.removeItem('dsh-novel-forge.theme.endfield.accent');
179
- } catch { /* ignore */ }
180
- setEndfieldAccent('valley');
181
- onEndfieldAccent?.('valley');
182
- changeTheme('liquid', 'system', 'comfort');
183
- };
184
-
185
- if (configDraft === null) {
186
- return (
187
- <div className={css.authorPageBody}><div className={css.meta}>正在加载设置…</div>{error !== '' && <div className={css.noticeError}>{error}</div>}</div>
188
- );
189
- }
190
-
191
- return (
192
- <div className={css.authorPageBody} data-nf-theme={panelTheme} data-nf-mode={themeMode === 'system' ? undefined : themeMode} data-nf-density={themeDensity} data-nf-endfield-accent={endfieldAccent}>
193
- <div className={css.card + ' ' + css.settingsCard} style={{ gap: 'var(--nf-space-6)' }}>
194
- <h2 className={css.panelTitle} style={{ margin: 0 }}>⚙️ 设置</h2>
195
- <span className={css.meta}>当前模型:{config?.provider} / {config?.model}</span>
196
- <span className={css.meta}>输出目录:{config?.outputDir}</span>
197
- </div>
198
-
199
- <div className={css.row} style={{ gap: 'var(--nf-space-8)', flexWrap: 'wrap' }}>
200
- {SETTINGS_SECTIONS.map(s => (
201
- <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>
202
- ))}
203
- </div>
204
-
205
- {error !== '' && <div className={css.noticeError}>{error}</div>}
206
- {notice !== '' && <div className={css.meta}>{notice}</div>}
207
-
208
- {settingsTab === 'model' && (
209
- <>
210
- <div className={css.card + ' ' + css.settingsCard} style={{ gap: 'var(--nf-space-24)' }}>
211
- <span className={css.cardTitle}><Brain size={18} style={{ verticalAlign: -3 }} /> 模型与推理</span>
212
- <ModelManager
213
- api={api}
214
- provider={configDraft.provider}
215
- model={configDraft.model}
216
- savedModels={configDraft.savedModels ?? []}
217
- onProvider={v => setConfigDraft({ ...configDraft, provider: v })}
218
- onModel={v => setConfigDraft({ ...configDraft, model: v })}
219
- onSavedModels={models => setConfigDraft({ ...configDraft, savedModels: models })}
220
- />
221
- </div>
222
- <ReasoningSection
223
- reasoningEffort={configDraft.reasoningEffort ?? 'off'}
224
- analysisReasoning={configDraft.analysisReasoning ?? 'low'}
225
- onChange={patch => setConfigDraft({ ...configDraft, ...patch })}
226
- />
227
- </>
228
- )}
229
-
230
- {settingsTab === 'writing' && (
231
- <div className={css.card + ' ' + css.settingsCard} style={{ gap: 'var(--nf-space-24)' }}>
232
- <span className={css.cardTitle}><PenLine size={18} style={{ verticalAlign: -3 }} /> 写作与审稿</span>
233
- <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>
234
- <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>
235
- <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>
236
- <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>
237
- <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>
238
- <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>
239
- <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>
240
- </div>
241
- )}
242
-
243
- {settingsTab === 'files' && (
244
- <div className={css.card + ' ' + css.settingsCard} style={{ gap: 'var(--nf-space-24)' }}>
245
- <span className={css.cardTitle}><Folder size={18} style={{ verticalAlign: -3 }} /> 路径与文件</span>
246
- <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>
247
- <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>
248
- <div className={css.row}><button type="button" className={css.button} onClick={() => { void api.openFolder() }}>{tt('settings.openFolder')}</button></div>
249
- </div>
250
- )}
251
-
252
- {settingsTab === 'appearance' && (
253
- <>
254
- <div className={css.card + ' ' + css.settingsCard}>
255
- <span className={css.cardTitle}><Sparkles size={18} style={{ verticalAlign: -3 }} /> 外观与主题</span>
256
- <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>
257
- <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>
258
- {panelTheme === 'endfield' && (
259
- <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>
260
- )}
261
- <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>
262
- <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>
263
- <div className={css.row} style={{ justifyContent: 'flex-end' }}><button type="button" className={css.button} onClick={resetTheme}><RotateCcw size={14} style={{ verticalAlign: -2 }} /> 恢复默认主题</button></div>
264
- </div>
265
-
266
- <div className={css.card + ' ' + css.settingsCard} style={{ gap: 'var(--nf-space-12)' }}>
267
- <span className={css.cardTitle}><Image size={18} style={{ verticalAlign: -3 }} /> 自定义背景</span>
268
- <span className={css.meta}>填图片 URL 或上传图片作为小说工坊背景(首页 + 书内)。留空 = 使用主题默认背景。</span>
269
- <div className={css.row} style={{ gap: 'var(--nf-space-8)', flexWrap: 'wrap' }}>
270
- <input className={css.input} style={{ flex: 1, minWidth: 220 }} placeholder="https://… 图片地址" value={(configDraft.themeBackground ?? '').startsWith('data:') ? '' : (configDraft.themeBackground ?? '')} onChange={e => applyBackground(e.target.value.trim() !== '' ? e.target.value.trim() : undefined)} />
271
- <button type="button" className={css.button + ' ' + css.buttonSmall} onClick={() => bgFileRef.current?.click()}><Upload size={13} style={{ verticalAlign: -2 }} /> 上传图片</button>
272
- <button type="button" className={css.button + ' ' + css.buttonSmall} onClick={() => applyBackground(undefined)}><X size={13} style={{ verticalAlign: -2 }} /> 清除</button>
273
- </div>
274
- <input ref={bgFileRef} type="file" accept="image/*" style={{ display: 'none' }} onChange={e => { void onBgFile(e.target.files?.[0]); e.target.value = '' }} />
275
- {(configDraft.themeBackground ?? '') !== '' && (
276
- <div className={css.adaptInputCard} style={{ padding: 8 }}>
277
- <div style={{ width: 180, height: 100, borderRadius: 10, backgroundImage: 'url(' + configDraft.themeBackground + ')', backgroundSize: 'cover', backgroundPosition: 'center' }} />
278
- <div className={css.meta}>已设置背景(预览)</div>
279
- </div>
280
- )}
281
- <div className={css.field}><label className={css.fieldLabel}>遮罩 / 模糊强度(0-80)</label><input className={css.input} type="range" min={0} max={80} value={configDraft.themeBackgroundBlur ?? 0} onChange={e => applyBlur(Number(e.target.value))} /><span className={css.meta}>越大背景越暗,保证文字可读(图很花时调高)。</span></div>
282
- </div>
283
- </>
284
- )}
285
-
286
- <div className={css.row}><button type="button" className={css.button + ' ' + css.buttonPrimary} disabled={busy} onClick={() => { void save() }}>{busy ? '保存中…' : tt('settings.save')}</button></div>
287
- </div>
288
- );
289
- }
@@ -1,242 +0,0 @@
1
- /**
2
- * 书架首页视图:书卡网格(封面/书名/简介/进度)+ 搜索筛选 + 开书入口。
3
- * 进入小说工坊默认展示;点击书卡进入该书工作台,+ 进入开书向导页。
4
- */
5
- import { useEffect, useMemo, useState } from 'react'
6
- import { BookOpen, Download, Library, PenLine, Plus, Search } from 'lucide-react'
7
- import type { NovelApi } from '../api.ts'
8
- import type { BookshelfSnapshot } from '../../protocol.ts'
9
- import css from './panel.module.css'
10
-
11
- /** 相对时间(人性化:刚刚 / N 分钟前 / N 小时前 / N 天前 / 日期)。 */
12
- function relativeTime(iso: string): string {
13
- const t = new Date(iso).getTime()
14
- if (!Number.isFinite(t)) return '—'
15
- const diff = Date.now() - t
16
- if (diff < 60_000) return '刚刚'
17
- if (diff < 3_600_000) return `${Math.floor(diff / 60_000)} 分钟前`
18
- if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)} 小时前`
19
- if (diff < 7 * 86_400_000) return `${Math.floor(diff / 86_400_000)} 天前`
20
- return new Date(t).toLocaleDateString('zh-CN', { month: 'numeric', day: 'numeric' })
21
- }
22
-
23
- /** 一本书的状态标签。 */
24
- function bookStatus(book: BookshelfSnapshot['books'][number]): 'none' | 'done' | 'active' {
25
- if (!book.hasProject) return 'none'
26
- return book.total > 0 && book.done >= book.total ? 'done' : 'active'
27
- }
28
-
29
- /** 一张书卡(封面懒加载)。 */
30
- function BookCard({
31
- api,
32
- book,
33
- active,
34
- onOpen,
35
- onRead,
36
- }: {
37
- api: NovelApi
38
- book: BookshelfSnapshot['books'][number]
39
- active: boolean
40
- onOpen: () => void
41
- onRead: () => void
42
- }) {
43
- const [cover, setCover] = useState<string | null>(null)
44
-
45
- useEffect(() => {
46
- if (!book.hasCover) return
47
- let cancelled = false
48
- void api.coverGet(book.outputDir).then(result => {
49
- if (!cancelled) setCover(result.dataUrl)
50
- }).catch(() => { /* best-effort */ })
51
- return () => { cancelled = true }
52
- }, [api, book.hasCover, book.outputDir])
53
-
54
- const ratio = book.total > 0 ? Math.min(book.done / book.total, 1) : 0
55
- const status = bookStatus(book)
56
- const statusLabel = status === 'none' ? '未开书' : status === 'done' ? '已完结' : '进行中'
57
-
58
- return (
59
- <div
60
- className={`${css.bookCard} ${active ? css.bookCardActive : ''}`}
61
- onClick={onOpen}
62
- title={`打开《${book.bookName}》`}
63
- >
64
- <div className={css.bookCardCover}>
65
- {cover !== null ? (
66
- <img src={cover} alt={`《${book.bookName}》封面`} />
67
- ) : (
68
- <div className={css.bookCardCoverFallback}>
69
- <span className={css.bookCardCoverTitle}>{book.bookName.slice(0, 4)}</span>
70
- <span className={css.meta}>暂无封面</span>
71
- </div>
72
- )}
73
- </div>
74
- <div className={css.bookCardBody}>
75
- <div className={css.bookCardTitleRow}>
76
- <span className={css.bookCardName}>{book.bookName}</span>
77
- <span className={`${css.badge} ${status === 'none' ? css.badgePending : status === 'done' ? css.badgeDone : css.badgeWritten}`}>
78
- {statusLabel}
79
- </span>
80
- </div>
81
- <span className={`${css.meta} ${css.bookCardBlurb}`} title={book.blurb ?? ''}>
82
- {book.blurb !== undefined && book.blurb !== '' ? book.blurb : '暂无简介'}
83
- </span>
84
- <div className={css.bookCardProgressBar}>
85
- <div className={css.bookCardProgressFill} style={{ width: `${Math.round(ratio * 100)}%` }} />
86
- </div>
87
- <div className={css.bookCardMetaRow}>
88
- <span className={css.meta}>{book.total > 0 ? `已完成 ${book.done} / ${book.total} 章` : '尚未规划章节'}</span>
89
- {book.hasProject && <span className={css.meta} title="最近活动时间">更新于 {relativeTime(book.updatedAt)}</span>}
90
- </div>
91
- <div className={css.bookCardActions} onClick={e => { e.stopPropagation() }}>
92
- <button
93
- type="button"
94
- className={`${css.button} ${css.buttonSmall} ${css.buttonPrimary}`}
95
- onClick={onOpen}
96
- title="进入创作工作台(大纲/章节/审稿/设定)"
97
- >
98
- <PenLine size={13} style={{ verticalAlign: -2 }} /> 进入工作台
99
- </button>
100
- <button
101
- type="button"
102
- className={`${css.button} ${css.buttonSmall}`}
103
- disabled={!book.hasProject || book.done === 0}
104
- onClick={onRead}
105
- title={book.hasProject && book.done > 0 ? '沉浸式阅读已写章节' : '尚无已写章节,先去工作台创作'}
106
- >
107
- <BookOpen size={13} style={{ verticalAlign: -2 }} /> 阅读
108
- </button>
109
- </div>
110
- </div>
111
- </div>
112
- )
113
- }
114
-
115
- /** 书架首页。 */
116
- export function ShelfView({
117
- api,
118
- shelf,
119
- onOpenBook,
120
- onReadBook,
121
- onAddBook,
122
- onImportBook,
123
- }: {
124
- api: NovelApi
125
- shelf: BookshelfSnapshot
126
- /** 点击书卡:激活该书并进入工作台。 */
127
- onOpenBook: (id: string) => void
128
- /** 点击「阅读」:激活该书并进入沉浸式阅读页。 */
129
- onReadBook: (id: string) => void
130
- /** 点击+:进入开书向导页。 */
131
- onAddBook: () => void
132
- /** 点击「导入」:打开导入弹窗(已有项目目录 / txt/md 全本)。 */
133
- onImportBook: () => void
134
- }) {
135
- const [query, setQuery] = useState('')
136
- /** 筛选:all=全部 / active=进行中 / done=已完结 / none=未开书。 */
137
- const [filter, setFilter] = useState<'all' | 'active' | 'done' | 'none'>('all')
138
-
139
- const stats = useMemo(() => {
140
- let active = 0, done = 0, none = 0
141
- for (const b of shelf.books) {
142
- const s = bookStatus(b)
143
- if (s === 'none') none++
144
- else if (s === 'done') done++
145
- else active++
146
- }
147
- return { active, done, none }
148
- }, [shelf.books])
149
-
150
- const visible = useMemo(() => {
151
- const q = query.trim().toLowerCase()
152
- return shelf.books.filter(b => {
153
- if (filter !== 'all' && bookStatus(b) !== filter) return false
154
- if (q === '') return true
155
- return b.bookName.toLowerCase().includes(q) || (b.blurb ?? '').toLowerCase().includes(q)
156
- })
157
- }, [shelf.books, query, filter])
158
-
159
- return (
160
- <div className={css.shelfView}>
161
- <div className={css.shelfHero}>
162
- <div className={css.shelfTitleRow}>
163
- <h2 className={css.panelTitle} style={{ margin: 0 }}><Library size={20} style={{ verticalAlign: -3 }} /> AI小说工坊 · 书架</h2>
164
- <input
165
- className={`${css.input} ${css.shelfSearch}`}
166
- type="search"
167
- placeholder="🔍 搜索书名 / 简介…"
168
- value={query}
169
- onChange={e => { setQuery(e.target.value) }}
170
- />
171
- </div>
172
- {shelf.books.length > 0 && (
173
- <div className={css.shelfStats}>
174
- {[
175
- { id: 'all' as const, label: '总书数', value: shelf.books.length },
176
- { id: 'active' as const, label: '进行中', value: stats.active },
177
- { id: 'done' as const, label: '已完结', value: stats.done },
178
- { id: 'none' as const, label: '未开书', value: stats.none },
179
- ].map(s => (
180
- <div
181
- key={s.id}
182
- className={`${css.shelfStat} ${filter === s.id ? css.shelfStatActive : ''}`}
183
- title={s.id === 'all' ? '显示全部书籍' : `只看「${s.label}」的书(再点一次取消筛选)`}
184
- onClick={() => { setFilter(prev => prev === s.id ? 'all' : s.id) }}
185
- >
186
- <b>{s.value}</b><span>{s.label}</span>
187
- </div>
188
- ))}
189
- </div>
190
- )}
191
- </div>
192
-
193
- {shelf.books.length === 0 ? (
194
- /* 空书架引导卡 */
195
- <div className={css.shelfEmpty}>
196
- <span className={css.shelfEmptyIcon}><BookOpen size={34} /></span>
197
- <span className={css.shelfEmptyTitle}>你的创作从这里开始</span>
198
- <span className={css.meta}>开一本书:粘贴大纲或导入 docx,书名自动识别,开书即建项目</span>
199
- <button type="button" className={`${css.button} ${css.buttonPrimary}`} onClick={onAddBook}>
200
- + 开第一本书
201
- </button>
202
- <button type="button" className={`${css.button}`} onClick={onImportBook}>
203
- 📥 导入已有小说
204
- </button>
205
- </div>
206
- ) : visible.length === 0 ? (
207
- <div className={css.shelfEmpty} style={{ minHeight: 160 }}>
208
- <span className={css.shelfEmptyIcon}><Search size={34} /></span>
209
- <span className={css.shelfEmptyTitle}>没有符合条件的书</span>
210
- <span className={css.meta}>换个关键词或筛选条件试试</span>
211
- </div>
212
- ) : (
213
- <div className={css.shelfGrid}>
214
- {visible.map(book => (
215
- <BookCard
216
- key={book.id}
217
- api={api}
218
- book={book}
219
- active={book.id === shelf.activeBookId}
220
- onOpen={() => { onOpenBook(book.id) }}
221
- onRead={() => { onReadBook(book.id) }}
222
- />
223
- ))}
224
-
225
- {/* 开书入口:进入独立向导页 */}
226
- <div className={`${css.bookCard} ${css.bookAddCard}`} onClick={onAddBook} role="button" tabIndex={0} aria-label="开一本新书" onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onAddBook() } }}>
227
- <div className={css.bookAddIcon}><Plus size={28} /></div>
228
- <span>开一本新书</span>
229
- <span className={css.meta}>书名 + 大纲,开书即建项目</span>
230
- </div>
231
-
232
- {/* 导入入口:已有项目目录 / txt/md 全本 */}
233
- <div className={`${css.bookCard} ${css.bookAddCard}`} onClick={onImportBook} role="button" tabIndex={0} aria-label="导入已有小说" onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onImportBook() } }}>
234
- <div className={css.bookAddIcon}><Download size={28} /></div>
235
- <span>导入已有小说</span>
236
- <span className={css.meta}>项目目录或 txt/md 全本</span>
237
- </div>
238
- </div>
239
- )}
240
- </div>
241
- )
242
- }