@waterwx/dsh-novel-forge 1.10.1 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (194) hide show
  1. package/README.md +287 -281
  2. package/lib/client.js +19572 -18127
  3. package/lib/index.js +12171 -6932
  4. package/lib/types/action-guard.d.ts +61 -0
  5. package/lib/types/ai-scan.d.ts +30 -0
  6. package/lib/types/assets.d.ts +20 -9
  7. package/lib/types/assistant.d.ts +29 -0
  8. package/lib/types/author-assets.d.ts +1 -3
  9. package/lib/types/bookshelf.d.ts +13 -3
  10. package/lib/types/client/api.d.ts +137 -21
  11. package/lib/types/client/chip-prefs.d.ts +36 -0
  12. package/lib/types/client/docx.d.ts +2 -2
  13. package/lib/types/client/index.d.ts +1 -1
  14. package/lib/types/client/llmLive.d.ts +31 -0
  15. package/lib/types/client/locales.d.ts +15 -70
  16. package/lib/types/client/mount.d.ts +0 -2
  17. package/lib/types/client/panel/AiTag.d.ts +1 -0
  18. package/lib/types/client/panel/AssistantTab.d.ts +4 -1
  19. package/lib/types/client/panel/AuthorHome.d.ts +13 -9
  20. package/lib/types/client/panel/BookAnalysisView.d.ts +4 -0
  21. package/lib/types/client/panel/CmdPalette.d.ts +12 -0
  22. package/lib/types/client/panel/CreateBookView.d.ts +5 -1
  23. package/lib/types/client/panel/DirectorView.d.ts +7 -0
  24. package/lib/types/client/panel/IdeaInspirationView.d.ts +6 -0
  25. package/lib/types/client/panel/KnowledgeBaseView.d.ts +4 -0
  26. package/lib/types/client/panel/LiveFeedLog.d.ts +13 -0
  27. package/lib/types/client/panel/MarketRadarView.d.ts +5 -0
  28. package/lib/types/client/panel/ModelManager.d.ts +2 -2
  29. package/lib/types/client/panel/NovelPanel.d.ts +6 -1
  30. package/lib/types/client/panel/PageHeader.d.ts +18 -0
  31. package/lib/types/client/panel/PromptSlotsView.d.ts +11 -0
  32. package/lib/types/client/panel/SettingsView.d.ts +12 -7
  33. package/lib/types/client/panel/ShelfView.d.ts +10 -1
  34. package/lib/types/client/panel/SubPage.d.ts +27 -0
  35. package/lib/types/client/panel/TensionView.d.ts +18 -0
  36. package/lib/types/client/panel/TimelineView.d.ts +18 -0
  37. package/lib/types/client/panel/helpers.d.ts +5 -3
  38. package/lib/types/client/panel/market-radar-state.d.ts +56 -0
  39. package/lib/types/client/panel/toast.d.ts +2 -0
  40. package/lib/types/client/panel/views.d.ts +21 -35
  41. package/lib/types/client/run-status.d.ts +21 -0
  42. package/lib/types/client/sidebar-entry.d.ts +0 -2
  43. package/lib/types/docx-core.d.ts +16 -0
  44. package/lib/types/docx.d.ts +2 -3
  45. package/lib/types/engine.d.ts +83 -79
  46. package/lib/types/global-assets.d.ts +6 -0
  47. package/lib/types/home.d.ts +11 -0
  48. package/lib/types/index.d.ts +27 -13
  49. package/lib/types/llm-live.d.ts +70 -0
  50. package/lib/types/llm-retry.d.ts +38 -0
  51. package/lib/types/market-radar-scan.d.ts +18 -0
  52. package/lib/types/market-radar-sources.d.ts +31 -0
  53. package/lib/types/migrate-output.d.ts +25 -0
  54. package/lib/types/model-capability.d.ts +93 -0
  55. package/lib/types/novel-context.d.ts +80 -0
  56. package/lib/types/output-budget.d.ts +86 -0
  57. package/lib/types/prompt-slots.d.ts +57 -0
  58. package/lib/types/prompting.d.ts +10 -0
  59. package/lib/types/protocol.d.ts +829 -522
  60. package/lib/types/revision.d.ts +76 -0
  61. package/lib/types/routes.d.ts +9 -3
  62. package/lib/types/run.d.ts +23 -1
  63. package/lib/types/settings-persist.d.ts +59 -0
  64. package/lib/types/snapshots.d.ts +46 -0
  65. package/lib/types/stage-contract.d.ts +58 -0
  66. package/lib/types/tension.d.ts +53 -0
  67. package/lib/types/timeline.d.ts +37 -0
  68. package/package.json +50 -28
  69. package/src/action-guard.ts +127 -0
  70. package/src/ai-scan.ts +155 -0
  71. package/src/assets.ts +1801 -535
  72. package/src/assistant.ts +1098 -770
  73. package/src/author-assets.ts +131 -147
  74. package/src/bookshelf.ts +193 -167
  75. package/src/client/api.ts +767 -607
  76. package/src/client/chip-prefs.ts +121 -0
  77. package/src/client/docx.ts +32 -69
  78. package/src/client/index.ts +3 -1
  79. package/src/client/llmLive.ts +111 -0
  80. package/src/client/locales.ts +15 -274
  81. package/src/client/mount.tsx +4 -3
  82. package/src/client/panel/AdaptModeView.tsx +535 -520
  83. package/src/client/panel/AiTag.tsx +7 -0
  84. package/src/client/panel/AssetsTab.tsx +448 -458
  85. package/src/client/panel/AssistantTab.tsx +355 -301
  86. package/src/client/panel/AuthorAssetsView.tsx +205 -207
  87. package/src/client/panel/AuthorHome.tsx +309 -202
  88. package/src/client/panel/BookAnalysisView.tsx +63 -0
  89. package/src/client/panel/CmdPalette.tsx +102 -0
  90. package/src/client/panel/CreateBookView.tsx +366 -352
  91. package/src/client/panel/DirectorView.tsx +200 -0
  92. package/src/client/panel/GlobalAssetLibraryView.tsx +168 -172
  93. package/src/client/panel/IdeaInspirationView.tsx +76 -0
  94. package/src/client/panel/ImportModal.tsx +302 -302
  95. package/src/client/panel/KnowledgeBaseView.tsx +125 -0
  96. package/src/client/panel/LiveFeedLog.tsx +204 -0
  97. package/src/client/panel/MarketRadarView.tsx +415 -0
  98. package/src/client/panel/ModelManager.tsx +205 -205
  99. package/src/client/panel/NovelPanel.tsx +5559 -4655
  100. package/src/client/panel/PageHeader.tsx +37 -0
  101. package/src/client/panel/ProgressConsole.tsx +63 -64
  102. package/src/client/panel/PromptSlotsView.tsx +97 -0
  103. package/src/client/panel/ReaderView.tsx +306 -306
  104. package/src/client/panel/RunPanel.tsx +56 -24
  105. package/src/client/panel/SettingsView.tsx +175 -112
  106. package/src/client/panel/ShelfView.tsx +63 -39
  107. package/src/client/panel/SubPage.tsx +42 -0
  108. package/src/client/panel/TensionView.tsx +212 -0
  109. package/src/client/panel/TimelineView.tsx +208 -0
  110. package/src/client/panel/WorldTab.tsx +202 -202
  111. package/src/client/panel/helpers.ts +7 -5
  112. package/src/client/panel/market-radar-state.ts +79 -0
  113. package/src/client/panel/panel.module.css +5767 -4199
  114. package/src/client/panel/toast.ts +51 -0
  115. package/src/client/panel/views.tsx +316 -314
  116. package/src/client/run-status.ts +171 -0
  117. package/src/client/sidebar-entry.ts +3 -3
  118. package/src/docx-core.ts +53 -0
  119. package/src/docx.ts +44 -83
  120. package/src/engine.ts +1610 -1541
  121. package/src/global-assets.ts +58 -0
  122. package/src/home.ts +33 -0
  123. package/src/index.ts +184 -103
  124. package/src/llm-live.ts +245 -0
  125. package/src/llm-retry.ts +77 -0
  126. package/src/market-radar-scan.ts +35 -0
  127. package/src/market-radar-sources.ts +173 -0
  128. package/src/migrate-output.ts +76 -0
  129. package/src/model-capability.ts +215 -0
  130. package/src/novel-context.ts +286 -0
  131. package/src/output-budget.ts +147 -0
  132. package/src/prompt-slots.ts +125 -0
  133. package/src/prompting.ts +26 -0
  134. package/src/protocol.ts +2454 -2148
  135. package/src/revision.ts +283 -0
  136. package/src/routes.ts +3795 -3359
  137. package/src/run.ts +113 -17
  138. package/src/settings-persist.ts +114 -0
  139. package/src/snapshots.ts +174 -0
  140. package/src/stage-contract.ts +186 -0
  141. package/src/tension.ts +251 -0
  142. package/src/timeline.ts +159 -0
  143. package/assets/styles/arcane-thick-3d.webp +0 -0
  144. package/assets/styles/chinese-ink.webp +0 -0
  145. package/assets/styles/clay-stopmotion-3d.webp +0 -0
  146. package/assets/styles/dark-ghibli.webp +0 -0
  147. package/assets/styles/dark-gothic-3d.webp +0 -0
  148. package/assets/styles/film-photo.webp +0 -0
  149. package/assets/styles/guofeng-heavy.webp +0 -0
  150. package/assets/styles/horror-game-3d.webp +0 -0
  151. package/assets/styles/japan-cel-dark.webp +0 -0
  152. package/assets/styles/korean-webtoon.webp +0 -0
  153. package/assets/styles/low-cyberpunk-3d.webp +0 -0
  154. package/assets/styles/nextgen-cg.webp +0 -0
  155. package/assets/styles/noir-film.webp +0 -0
  156. package/assets/styles/openworld-survival.webp +0 -0
  157. package/assets/styles/paper-cut-3d.webp +0 -0
  158. package/assets/styles/pixar-adult-3d.webp +0 -0
  159. package/assets/styles/pop-marvel-2d.webp +0 -0
  160. package/assets/styles/shinkai-anime.webp +0 -0
  161. package/assets/styles/tactical-stealth.webp +0 -0
  162. package/assets/styles/vaporwave-3d.webp +0 -0
  163. package/assets/styles/vhs90.webp +0 -0
  164. package/lib/types/client/panel/BookshelfBar.d.ts +0 -11
  165. package/lib/types/client/panel/FlowGuide.d.ts +0 -21
  166. package/lib/types/client/panel/MangaRoleLibrary.d.ts +0 -18
  167. package/lib/types/client/panel/MangaWorkspace.d.ts +0 -15
  168. package/lib/types/client/panel/ModelPicker.d.ts +0 -11
  169. package/lib/types/client/panel/ProgressHomeView.d.ts +0 -5
  170. package/lib/types/client/panel/SceneLibrary.d.ts +0 -14
  171. package/lib/types/client/panel/StoryboardTab.d.ts +0 -19
  172. package/lib/types/client/panel/StyleCard.d.ts +0 -8
  173. package/lib/types/client/panel/StyleGallery.d.ts +0 -1
  174. package/lib/types/comic-presets.d.ts +0 -12
  175. package/lib/types/shot-language.d.ts +0 -46
  176. package/lib/types/story-beat-language.d.ts +0 -35
  177. package/lib/types/style-library.d.ts +0 -33
  178. package/skills/chapter-batch/SKILL.md +0 -81
  179. package/src/client/mount.tsx._new +0 -97
  180. package/src/client/mount.tsx._nf +0 -97
  181. package/src/client/mount.tsx._tmp +0 -97
  182. package/src/client/panel/BookshelfBar.tsx +0 -170
  183. package/src/client/panel/FlowGuide.tsx +0 -83
  184. package/src/client/panel/MangaRoleLibrary.tsx +0 -810
  185. package/src/client/panel/MangaWorkspace.tsx +0 -359
  186. package/src/client/panel/ProgressHomeView.tsx +0 -87
  187. package/src/client/panel/SceneLibrary.tsx +0 -396
  188. package/src/client/panel/StoryboardTab.tsx +0 -466
  189. package/src/client/panel/StyleCard.tsx +0 -90
  190. package/src/client/panel/StyleGallery.tsx +0 -58
  191. package/src/comic-presets.ts +0 -67
  192. package/src/shot-language.ts +0 -181
  193. package/src/story-beat-language.ts +0 -130
  194. package/src/style-library.ts +0 -78
@@ -0,0 +1,415 @@
1
+ /**
2
+ * 「热门题材雷达」:真实榜单扫榜 → 候选勾选 → AI 分析 → 信号卡片 → 影响模式 → 用信号创作。
3
+ *
4
+ * 界面状态规则见 `market-radar-state.ts`(其中写明了 R1-R4 四条验收条件):
5
+ * - 扫榜**不销毁**已完成的产物:上一份分析与创意简报在新分析产出前继续可用,
6
+ * 作者不必为了改选范围而重扫一遍;
7
+ * - 信号勾选只在**换了报告**时重置,重渲染不得冲掉手选;
8
+ * - 三个入口的可用性统一由一个纯函数判定,避免多处各写一份条件而漂移。
9
+ */
10
+ import { useMemo, useRef, useState } from 'react'
11
+ import type { NovelApi } from '../api.ts'
12
+ import type { MarketRadarResult, MarketCreativeBrief, IdeaInspirationResult } from '../../protocol.ts'
13
+ import css from './panel.module.css'
14
+ import { SubPage } from './SubPage.tsx'
15
+ import { isDifferentReport, radarControls } from './market-radar-state.ts'
16
+
17
+ const PLATFORMS = ['fanqie', 'qidian', 'jinjiang']
18
+ const PLATFORM_LABELS: Record<string, string> = { fanqie: '番茄小说', qidian: '起点中文网', jinjiang: '晋江文学城' }
19
+ const MAX_SIGNALS = 5
20
+
21
+ interface ScanItem { rank: number; title: string; author?: string; tags?: string[]; synopsis?: string; category?: string; heatLabel?: string }
22
+ interface ScanGroup { platform: string; platformLabel: string; listKey: string; listLabel: string; status: 'ok' | 'error'; error?: string; items: ScanItem[] }
23
+
24
+ const KIND_LABELS: Record<string, string> = {
25
+ genre: '题材', protagonist: '主角', advantage: '金手指', opening: '开篇', relationship: '关系', title_pattern: '标题', opportunity: '机会', crowding: '拥挤',
26
+ }
27
+ const MODE_LABELS: Record<string, string> = {
28
+ follow_hot: '跟随热门',
29
+ differentiate: '差异化',
30
+ light: '轻度参考',
31
+ }
32
+
33
+ export default function MarketRadarView({ api, bookId }: { api: NovelApi; bookId?: string }): JSX.Element {
34
+ const [platforms, setPlatforms] = useState<string[]>(['fanqie', 'qidian', 'jinjiang'])
35
+ const [feedText, setFeedText] = useState('')
36
+ const [scanGroups, setScanGroups] = useState<ScanGroup[] | null>(null)
37
+ const [selectedCandidates, setSelectedCandidates] = useState<Set<string>>(new Set())
38
+ const [viewPlatform, setViewPlatform] = useState('fanqie')
39
+ const [scanning, setScanning] = useState(false)
40
+ const [analyzing, setAnalyzing] = useState(false)
41
+ const [error, setError] = useState('')
42
+ const [result, setResult] = useState<MarketRadarResult | null>(null)
43
+ const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
44
+ const [influenceMode, setInfluenceMode] = useState<'follow_hot' | 'differentiate' | 'light'>('differentiate')
45
+ const [briefBusy, setBriefBusy] = useState(false)
46
+ const [brief, setBrief] = useState<MarketCreativeBrief | null>(null)
47
+ const [ideas, setIdeas] = useState<IdeaInspirationResult['ideas'] | null>(null)
48
+ const [ideaBusy, setIdeaBusy] = useState(false)
49
+ const [applyBusy, setApplyBusy] = useState(false)
50
+ const [applied, setApplied] = useState('')
51
+ const [syncBusy, setSyncBusy] = useState(false)
52
+ const [syncedMsg, setSyncedMsg] = useState('')
53
+ /**
54
+ * 已应用报告 id。用 ref 而不是 state:它只用于「要不要复位勾选」的判断,
55
+ * 放进 state 会让每次分析都多一次重渲染,且可能触发不必要的复位。
56
+ */
57
+ const appliedReportId = useRef('')
58
+
59
+ /** 三个入口的可用性统一由纯函数判定,避免多处条件漂移。 */
60
+ const controls = useMemo(
61
+ () => radarControls({
62
+ scanning,
63
+ analyzing,
64
+ pickedCount: selectedCandidates.size,
65
+ hasReport: result !== null,
66
+ }),
67
+ [scanning, analyzing, selectedCandidates, result],
68
+ )
69
+
70
+ const candKey = (g: ScanGroup, item: ScanItem): string => `${g.platform}:${g.listKey}:${item.rank}`
71
+ const togglePlatform = (p: string): void => {
72
+ setPlatforms(prev => prev.includes(p) ? (prev.length === 1 ? prev : prev.filter(x => x !== p)) : [...prev, p])
73
+ }
74
+ const platformList = useMemo(() => {
75
+ const set = new Set((scanGroups ?? []).map(g => g.platform))
76
+ return PLATFORMS.filter(p => set.has(p))
77
+ }, [scanGroups])
78
+
79
+ const scan = async (): Promise<void> => {
80
+ setScanning(true)
81
+ setError('')
82
+ try {
83
+ const r = await api.marketRadarScan({ platforms })
84
+ const groups = r.result.groups
85
+ setScanGroups(groups)
86
+ const firstP = PLATFORMS.find(p => groups.some(g => g.platform === p))
87
+ if (firstP !== undefined) setViewPlatform(firstP)
88
+ // 勾选在扫榜后清空(换了候选范围),但**已完成的 AI 分析与创意简报保留**:
89
+ // 作者改选范围时不必丢掉上一份结论重新分析一次。
90
+ setSelectedCandidates(new Set())
91
+ } catch (err) {
92
+ setError((err as Error).message)
93
+ } finally {
94
+ setScanning(false)
95
+ }
96
+ }
97
+
98
+ const toggleCandidate = (key: string): void => {
99
+ setSelectedCandidates(prev => {
100
+ const next = new Set(prev)
101
+ if (next.has(key)) next.delete(key)
102
+ else next.add(key)
103
+ return next
104
+ })
105
+ }
106
+
107
+ const selectAll = (): void => {
108
+ const next = new Set<string>()
109
+ for (const g of scanGroups ?? []) if (g.platform === viewPlatform && g.status === 'ok') for (const it of g.items) next.add(candKey(g, it))
110
+ setSelectedCandidates(next)
111
+ }
112
+
113
+ const clearSelection = (): void => setSelectedCandidates(new Set())
114
+
115
+ const selectGroup = (g: ScanGroup): void => {
116
+ setSelectedCandidates(prev => {
117
+ const next = new Set(prev)
118
+ for (const it of g.items) next.add(candKey(g, it))
119
+ return next
120
+ })
121
+ }
122
+
123
+ const analyze = async (): Promise<void> => {
124
+ if (scanGroups === null) return
125
+ const candidates: Array<{ title: string; author?: string; tags?: string[]; synopsis?: string; category?: string }> = []
126
+ for (const g of scanGroups) if (g.status === 'ok') for (const it of g.items) if (selectedCandidates.has(candKey(g, it))) candidates.push({ title: it.title, author: it.author, tags: it.tags, synopsis: it.synopsis, category: it.category })
127
+ setAnalyzing(true)
128
+ setError('')
129
+ try {
130
+ const r = await api.marketRadar({ candidates, feedText: feedText.trim() })
131
+ const nextResult = r.result
132
+ // 只有换了报告才重置信号勾选:重渲染/重复分析不得冲掉作者手选。
133
+ if (isDifferentReport(appliedReportId.current, nextResult.reportId ?? '')) {
134
+ appliedReportId.current = nextResult.reportId ?? ''
135
+ const rec = nextResult.signals.filter(s => s.recommended === true).map(s => s.id)
136
+ setSelectedIds(new Set(rec.slice(0, MAX_SIGNALS)))
137
+ }
138
+ setResult(nextResult)
139
+ // 简报是绑定旧报告结论的产物,报告一换即失效(避免与信号不匹配)。
140
+ setBrief(null)
141
+ setIdeas(null)
142
+ } catch (err) {
143
+ setError((err as Error).message)
144
+ } finally {
145
+ setAnalyzing(false)
146
+ }
147
+ }
148
+
149
+ const toggleSignal = (id: string): void => {
150
+ setSelectedIds(prev => {
151
+ const next = new Set(prev)
152
+ if (next.has(id)) next.delete(id)
153
+ else if (next.size < MAX_SIGNALS) next.add(id)
154
+ return next
155
+ })
156
+ }
157
+
158
+ const createBrief = async (): Promise<void> => {
159
+ if (result === null) return
160
+ const signals = result.signals.filter(s => selectedIds.has(s.id))
161
+ setBriefBusy(true)
162
+ setError('')
163
+ try {
164
+ const r = await api.marketRadarBrief({ influenceMode, signals })
165
+ setBrief(r.creativeBrief)
166
+ } catch (err) {
167
+ setError((err as Error).message)
168
+ } finally {
169
+ setBriefBusy(false)
170
+ }
171
+ }
172
+
173
+ const createIdeas = async (): Promise<void> => {
174
+ if (result === null) return
175
+ const signals = result.signals.filter(s => selectedIds.has(s.id))
176
+ setIdeaBusy(true)
177
+ setError('')
178
+ try {
179
+ const r = await api.marketIdeaInspiration({ signals, foundation: result.productionFoundation, brief: brief ?? undefined, count: 5 })
180
+ setIdeas(r.result.ideas)
181
+ } catch (err) {
182
+ setError((err as Error).message)
183
+ } finally {
184
+ setIdeaBusy(false)
185
+ }
186
+ }
187
+
188
+ const apply = async (): Promise<void> => {
189
+ if (result === null) return
190
+ setApplyBusy(true)
191
+ setError('')
192
+ setApplied('')
193
+ try {
194
+ const r = await api.marketRadarApply({ bookId, foundation: result.productionFoundation })
195
+ setApplied(` 已应用到《${r.bookName}》:后续规划/生成将按此题材与推进模式。`)
196
+ } catch (err) {
197
+ setError((err as Error).message)
198
+ } finally {
199
+ setApplyBusy(false)
200
+ }
201
+ }
202
+
203
+ const sync = async (): Promise<void> => {
204
+ if (result === null) return
205
+ setSyncBusy(true)
206
+ setError('')
207
+ setSyncedMsg('')
208
+ try {
209
+ const r = await api.marketRadarSync({ foundation: result.productionFoundation })
210
+ const parts: string[] = []
211
+ if (r.synced.genre) parts.push('题材')
212
+ if (r.synced.primaryMode) parts.push('主推进')
213
+ if (r.synced.secondaryMode) parts.push('辅推进')
214
+ setSyncedMsg(parts.length > 0 ? ` 已同步到全局资源库:${parts.join('、')}` : '已在资源库中,无新增。')
215
+ } catch (err) {
216
+ setError((err as Error).message)
217
+ } finally {
218
+ setSyncBusy(false)
219
+ }
220
+ }
221
+
222
+ const badge = (kind: string): string => (kind === 'opportunity' ? 'var(--nf-success)' : kind === 'crowding' ? 'var(--nf-error)' : 'var(--nf-accent)')
223
+
224
+ return (
225
+ <SubPage
226
+ title="热门题材雷达"
227
+ meta="扫榜 → 选候选 → 分析 → 拿本期判断与可用信号,影响后续开书的题材与推进。"
228
+ >
229
+ {/* 平台扫榜 */}
230
+ <div className={`${css.card} ${css.settingsCard}`} style={{ gap: 'var(--nf-space-12)' }}>
231
+ <div style={{ display: 'flex', flexWrap: 'wrap', gap: 'var(--nf-space-6)' }}>
232
+ {PLATFORMS.map(p => (
233
+ <button key={p} type="button" onClick={() => togglePlatform(p)} style={{ padding: '4px 12px', borderRadius: 999, border: '1px solid var(--nf-border)', cursor: 'pointer', background: platforms.includes(p) ? 'var(--nf-accent-soft)' : 'var(--nf-bg-inset)', color: 'var(--nf-text)', fontWeight: platforms.includes(p) ? 600 : 400 }}>
234
+ {PLATFORM_LABELS[p]}
235
+ </button>
236
+ ))}
237
+ </div>
238
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-6)' }}>
239
+ <label className={css.fieldLabel}>榜单文本兜底(可选,粘贴公开榜单文本;扫榜失败时可用于人工候选)</label>
240
+ <textarea className={css.input} style={{ minHeight: 90, resize: 'vertical' }} value={feedText} onChange={e => setFeedText(e.target.value)} placeholder="粘贴一份公开榜单文本…" />
241
+ </div>
242
+ <div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
243
+ <button type="button" className={`${css.button} ${css.buttonPrimary}`} disabled={!controls.canScan} onClick={() => { void scan() }}>
244
+ {scanning ? '正在扫榜…' : '扫榜'}
245
+ </button>
246
+ {scanning && <span style={{ fontSize: 12, color: 'var(--nf-text-2)' }}>正在抓取公开榜单({platforms.map(p => PLATFORM_LABELS[p]).join('、')}),多平台并行,约 10–30 秒…</span>}
247
+ {scanGroups !== null && (
248
+ <button type="button" className={css.button} style={{ marginLeft: 8 }} disabled={!controls.canAnalyze} onClick={() => { void analyze() }}>
249
+ {analyzing
250
+ ? '分析中…'
251
+ : controls.analyzeLabel === 'again'
252
+ ? `生成新分析(${selectedCandidates.size} 本)`
253
+ : `开始 分析(${selectedCandidates.size} 本)`}
254
+ </button>
255
+ )}
256
+ </div>
257
+ {scanGroups !== null && controls.analyzeLabel === 'pick' && (
258
+ <span style={{ fontSize: 12, color: 'var(--nf-text-2)' }}>
259
+ {result !== null ? '上一份分析仍在下方可查看;改选作品后可生成新的分析(无需重新扫榜)。' : '请先勾选要分析的作品。'}
260
+ </span>
261
+ )}
262
+ </div>
263
+
264
+ {error !== '' && <div style={{ color: 'var(--nf-error)', fontSize: 13 }}> {error}</div>}
265
+
266
+ {/* 榜单候选:平台 tabs 切换 + 平台内榜单横排(最多三列) + 每列 10 条可滚 */}
267
+ {scanGroups !== null && (
268
+ <div className={`${css.card} ${css.settingsCard}`} style={{ gap: 'var(--nf-space-10)' }}>
269
+ <span className={css.cardTitle}>公开榜单候选</span>
270
+ <div style={{ display: 'flex', flexWrap: 'wrap', gap: 'var(--nf-space-6)' }}>
271
+ {platformList.map(p => {
272
+ const active = viewPlatform === p
273
+ const count = (scanGroups ?? []).filter(g => g.platform === p && g.status === 'ok').reduce((a, g) => a + g.items.length, 0)
274
+ return (
275
+ <button key={p} type="button" onClick={() => setViewPlatform(p)} style={{ padding: '4px 12px', borderRadius: 999, border: '1px solid ' + (active ? 'var(--nf-accent)' : 'var(--nf-border)'), cursor: 'pointer', background: active ? 'var(--nf-accent-soft)' : 'var(--nf-bg-inset)', color: 'var(--nf-text)', fontWeight: active ? 600 : 400 }}>
276
+ {PLATFORM_LABELS[p]} ({count})
277
+ </button>
278
+ )
279
+ })}
280
+ </div>
281
+ <div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
282
+ <button type="button" className={`${css.button} ${css.buttonSmall}`} disabled={!controls.canSelectCandidates} onClick={selectAll}>全选当前平台</button>
283
+ <button type="button" className={`${css.button} ${css.buttonSmall}`} disabled={!controls.canSelectCandidates} onClick={clearSelection}>清空</button>
284
+ <span style={{ fontSize: 11, color: 'var(--nf-text-2)' }}>已选 {selectedCandidates.size} 条</span>
285
+ </div>
286
+ <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(340px, 1fr))', gap: 'var(--nf-space-10)' }}>
287
+ {(scanGroups ?? []).filter(g => g.platform === viewPlatform).map(g => (
288
+ <div key={`${g.platform}:${g.listKey}`} style={{ border: '1px solid var(--nf-border)', borderRadius: 10, padding: 'var(--nf-space-8)', background: 'var(--nf-bg-inset)', display: 'flex', flexDirection: 'column', minHeight: 0 }}>
289
+ <div style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13, fontWeight: 600 }}>
290
+ {g.listLabel}
291
+ <span style={{ fontSize: 10, padding: '1px 6px', borderRadius: 99, color: g.status === 'ok' ? 'var(--nf-success)' : 'var(--nf-error)', background: g.status === 'ok' ? 'rgba(79,191,139,0.14)' : 'rgba(212,99,79,0.16)' }}>{g.status === 'ok' ? `${g.items.length} 条` : '读取失败'}</span>
292
+ {g.status === 'ok' && <button type="button" className={`${css.button} ${css.buttonSmall}`} disabled={!controls.canSelectCandidates} onClick={() => selectGroup(g)}>全选本榜</button>}
293
+ </div>
294
+ {g.status === 'error' && <div style={{ fontSize: 11, color: 'var(--nf-error)', marginTop: 4 }}>{g.error}</div>}
295
+ {g.status === 'ok' && (
296
+ <div style={{ marginTop: 6, maxHeight: 400, overflowY: 'auto', display: 'flex', flexDirection: 'column' }}>
297
+ {g.items.map(it => {
298
+ const key = candKey(g, it)
299
+ const sel = selectedCandidates.has(key)
300
+ return (
301
+ <label key={key} onClick={e => { if (!controls.canSelectCandidates) return; if ((e.target as HTMLElement).tagName !== 'INPUT') toggleCandidate(key) }} style={{ display: 'flex', alignItems: 'flex-start', gap: 7, padding: '5px 2px', fontSize: 13.5, cursor: controls.canSelectCandidates ? 'pointer' : 'not-allowed', opacity: controls.canSelectCandidates ? 1 : 0.6 }}>
302
+ <input type="checkbox" checked={sel} disabled={!controls.canSelectCandidates} onChange={() => toggleCandidate(key)} onClick={e => e.stopPropagation()} style={{ marginTop: 3 }} />
303
+ <span style={{ lineHeight: 1.5 }}>
304
+ <b style={{ fontSize: 14 }}>{it.rank}. {it.title}</b>
305
+ {it.author !== undefined && it.author !== '' ? <span style={{ color: 'var(--nf-text-2)', fontSize: 12.5 }}>({it.author})</span> : null}
306
+ {(it.tags ?? []).length > 0 ? <span style={{ color: 'var(--nf-info)', fontSize: 12.5 }}> [{it.tags!.slice(0, 3).join('、')}]</span> : null}
307
+ </span>
308
+ </label>
309
+ )
310
+ })}
311
+ </div>
312
+ )}
313
+ </div>
314
+ ))}
315
+ </div>
316
+ </div>
317
+ )}
318
+
319
+ {/* 本期判断 + 信号 */}
320
+ {result !== null && (
321
+ <>
322
+ <div className={`${css.card} ${css.settingsCard}`} style={{ gap: 'var(--nf-space-10)' }}>
323
+ <span className={css.cardTitle}>本期判断</span>
324
+ <div style={{ fontSize: 13, lineHeight: 1.7 }}>
325
+ <div><span style={{ color: 'var(--nf-text-2)' }}>题材基底:</span>{result.productionFoundation.genre.name}({result.productionFoundation.genre.description})</div>
326
+ <div><span style={{ color: 'var(--nf-text-2)' }}>主要推进:</span>{result.productionFoundation.primaryStoryMode.name} —— {result.productionFoundation.primaryStoryMode.driver}</div>
327
+ {result.productionFoundation.secondaryStoryMode !== undefined && (
328
+ <div><span style={{ color: 'var(--nf-text-2)' }}>辅助推进:</span>{result.productionFoundation.secondaryStoryMode.name} —— {result.productionFoundation.secondaryStoryMode.driver}</div>
329
+ )}
330
+ </div>
331
+ <div style={{ display: 'flex', alignItems: 'flex-start', gap: 'var(--nf-space-8)', flexWrap: 'wrap' }}>
332
+ <button type="button" className={`${css.button} ${css.buttonPrimary}`} disabled={applyBusy} onClick={() => { void apply() }}>
333
+ {applyBusy ? '应用…' : '应用到当前书'}
334
+ </button>
335
+ <button type="button" className={css.button} disabled={syncBusy} onClick={() => { void sync() }}>
336
+ {syncBusy ? '同步…' : '同步到全局资源库'}
337
+ </button>
338
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
339
+ {applied !== '' && <span style={{ fontSize: 12, color: 'var(--nf-success)' }}>{applied}</span>}
340
+ {syncedMsg !== '' && <span style={{ fontSize: 12, color: 'var(--nf-info)' }}>{syncedMsg}</span>}
341
+ </div>
342
+ </div>
343
+ </div>
344
+
345
+ <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(240px, 1fr))', gap: 'var(--nf-space-10)' }}>
346
+ {result.signals.map(s => {
347
+ const selected = selectedIds.has(s.id)
348
+ return (
349
+ <article key={s.id} onClick={() => toggleSignal(s.id)} style={{ border: '1px solid var(--nf-border)', borderRadius: 12, padding: 'var(--nf-space-12)', cursor: 'pointer', background: selected ? 'var(--nf-accent-soft)' : 'var(--nf-bg)', boxShadow: selected ? '0 0 0 2px var(--nf-accent)' : 'none' }}>
350
+ <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
351
+ <span style={{ fontSize: 11, fontWeight: 600, padding: '1px 7px', borderRadius: 99, color: '#fff', background: badge(s.kind) }}>{KIND_LABELS[s.kind] ?? s.kind}</span>
352
+ {selected && <span style={{ fontSize: 11, color: 'var(--nf-accent)', fontWeight: 600 }}>已选</span>}
353
+ </div>
354
+ <div style={{ marginTop: 8, fontSize: 15, fontWeight: 600 }}>{s.title}</div>
355
+ <p style={{ marginTop: 6, fontSize: 12, color: 'var(--nf-text-2)', lineHeight: 1.6 }}>{s.detail}</p>
356
+ <div style={{ marginTop: 8, display: 'flex', gap: 10, fontSize: 11, color: 'var(--nf-text-2)' }}>
357
+ <span>{s.direction === 'rising' ? '正在升温' : s.direction === 'falling' ? '正在降温' : s.direction === 'stable' ? '相对稳定' : '当前高频'}</span>
358
+ {s.recommended === true && <span style={{ color: 'var(--nf-success)' }}>★推荐</span>}
359
+ </div>
360
+ </article>
361
+ )
362
+ })}
363
+ </div>
364
+ </>
365
+ )}
366
+
367
+ {/* 底部:影响模式 + 用信号创作 */}
368
+ {result !== null && (
369
+ <div style={{ position: 'sticky', bottom: 12, display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap', padding: 'var(--nf-space-10) var(--nf-space-12)', border: '1px solid var(--nf-accent)', borderRadius: 12, background: 'color-mix(in srgb, var(--nf-bg) 92%, transparent)', backdropFilter: 'blur(6px)', boxShadow: 'var(--nf-shadow)' }}>
370
+ <div>
371
+ <div style={{ fontSize: 13, fontWeight: 600 }}>已选 {selectedIds.size}/{MAX_SIGNALS} 项市场信号</div>
372
+ <div style={{ fontSize: 11, color: 'var(--nf-text-2)' }}>推荐已自动勾选,可替换后再开书。</div>
373
+ </div>
374
+ <div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
375
+ <select className={css.input} style={{ width: 'auto', padding: '4px 8px' }} value={influenceMode} onChange={e => setInfluenceMode(e.target.value as 'follow_hot' | 'differentiate' | 'light')}>
376
+ {Object.entries(MODE_LABELS).map(([v, l]) => <option key={v} value={v}>{l}</option>)}
377
+ </select>
378
+ <button type="button" className={`${css.button} ${css.buttonPrimary}`} disabled={selectedIds.size === 0 || briefBusy} onClick={() => { void createBrief() }}>
379
+ {briefBusy ? '生成中…' : '用这些信号创作'}
380
+ </button>
381
+ </div>
382
+ </div>
383
+ )}
384
+
385
+ {brief !== null && (
386
+ <div className={`${css.card} ${css.settingsCard}`} style={{ gap: 'var(--nf-space-10)' }}>
387
+ <span className={css.cardTitle}> 开书创意简报</span>
388
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-8)', fontSize: 13, lineHeight: 1.7 }}>
389
+ <div><b>创作约束</b>:{brief.promptBlock}</div>
390
+ <div><b>开篇想法</b>:{brief.openingIdea}</div>
391
+ <div><b>核心优势</b>:{brief.coreAdvantage}</div>
392
+ <div><b>追读卖点</b>:{brief.bookSellingPoint}</div>
393
+ <div><b>前30章承诺</b>:{brief.first30ChapterPromise}</div>
394
+ </div>
395
+ <button type="button" className={`${css.button} ${css.buttonPrimary}`} disabled={ideaBusy || selectedIds.size === 0} onClick={() => { void createIdeas() }}>
396
+ {ideaBusy ? '生成灵感中…' : ' 用这些信号生成灵感'}
397
+ </button>
398
+ </div>
399
+ )}
400
+
401
+ {ideas !== null && ideas.length > 0 && (
402
+ <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(260px, 1fr))', gap: 'var(--nf-space-10)' }}>
403
+ {ideas.map((it, i) => (
404
+ <div key={i} className={`${css.card} ${css.settingsCard}`} style={{ gap: 'var(--nf-space-6)' }}>
405
+ <div style={{ fontSize: 15, fontWeight: 600 }}>{it.title}</div>
406
+ <div style={{ fontSize: 12, color: 'var(--nf-success)' }}>{it.genre} · {it.pov}</div>
407
+ <div style={{ fontSize: 13 }}>钩子:{it.hook}</div>
408
+ <div style={{ fontSize: 12, color: 'var(--nf-text-2)' }}>兑现:{it.payoff}</div>
409
+ </div>
410
+ ))}
411
+ </div>
412
+ )}
413
+ </SubPage>
414
+ )
415
+ }