@waterwx/dsh-novel-forge 2.0.1-alpha → 2.1.0-alpha
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.
- package/README.md +97 -2
- package/lib/client.js +3291 -752
- package/lib/index.js +3557 -237
- package/lib/types/assets.d.ts +16 -3
- package/lib/types/assistant.d.ts +4 -0
- package/lib/types/client/api.d.ts +89 -0
- package/lib/types/client/llmLive.d.ts +33 -0
- package/lib/types/client/locales.d.ts +3 -3
- package/lib/types/client/panel/AdaptModeView.d.ts +1 -1
- package/lib/types/client/panel/AssetsTab.d.ts +1 -1
- package/lib/types/client/panel/AssistantTab.d.ts +1 -1
- package/lib/types/client/panel/AuthorAssetsView.d.ts +1 -1
- package/lib/types/client/panel/AuthorHome.d.ts +4 -2
- package/lib/types/client/panel/BookAnalysisView.d.ts +4 -0
- package/lib/types/client/panel/BookshelfBar.d.ts +1 -1
- package/lib/types/client/panel/CreateBookView.d.ts +6 -2
- package/lib/types/client/panel/DirectorView.d.ts +7 -0
- package/lib/types/client/panel/FlowGuide.d.ts +1 -1
- package/lib/types/client/panel/GlobalAssetLibraryView.d.ts +1 -1
- package/lib/types/client/panel/IdeaInspirationView.d.ts +6 -0
- package/lib/types/client/panel/ImportModal.d.ts +1 -1
- package/lib/types/client/panel/KnowledgeBaseView.d.ts +4 -0
- package/lib/types/client/panel/LiveFeedLog.d.ts +5 -0
- package/lib/types/client/panel/MangaRoleLibrary.d.ts +1 -1
- package/lib/types/client/panel/MangaWorkspace.d.ts +1 -1
- package/lib/types/client/panel/MarketRadarView.d.ts +5 -0
- package/lib/types/client/panel/NovelPanel.d.ts +2 -2
- package/lib/types/client/panel/ProgressConsole.d.ts +1 -1
- package/lib/types/client/panel/ProgressHomeView.d.ts +1 -1
- package/lib/types/client/panel/PropLibrary.d.ts +1 -1
- package/lib/types/client/panel/ReaderView.d.ts +1 -1
- package/lib/types/client/panel/RunPanel.d.ts +1 -1
- package/lib/types/client/panel/SceneLibrary.d.ts +1 -1
- package/lib/types/client/panel/SettingsView.d.ts +1 -1
- package/lib/types/client/panel/ShelfView.d.ts +1 -1
- package/lib/types/client/panel/StoryboardTab.d.ts +1 -1
- package/lib/types/client/panel/StyleCard.d.ts +1 -1
- package/lib/types/client/panel/StyleGallery.d.ts +1 -1
- package/lib/types/client/panel/WorldTab.d.ts +1 -1
- package/lib/types/engine.d.ts +29 -1
- package/lib/types/global-assets.d.ts +6 -0
- package/lib/types/index.d.ts +6 -0
- package/lib/types/llm-live.d.ts +44 -0
- package/lib/types/market-radar-scan.d.ts +19 -0
- package/lib/types/market-radar-sources.d.ts +31 -0
- package/lib/types/prompting.d.ts +35 -0
- package/lib/types/protocol.d.ts +364 -8
- package/lib/types/run.d.ts +1 -1
- package/lib/types/src/ai-scan.d.ts +36 -0
- package/lib/types/src/assets.d.ts +37 -0
- package/lib/types/src/assistant.d.ts +45 -0
- package/lib/types/src/author-assets.d.ts +25 -0
- package/lib/types/src/bookshelf.d.ts +42 -0
- package/lib/types/src/client/api.d.ts +234 -0
- package/lib/types/src/client/docx.d.ts +15 -0
- package/lib/types/src/client/index.d.ts +14 -0
- package/lib/types/src/client/locales.d.ts +212 -0
- package/lib/types/src/client/mount.d.ts +9 -0
- package/lib/types/src/client/panel/AdaptModeView.d.ts +5 -0
- package/lib/types/src/client/panel/AssetsTab.d.ts +12 -0
- package/lib/types/src/client/panel/AssistantTab.d.ts +7 -0
- package/lib/types/src/client/panel/AuthorAssetsView.d.ts +4 -0
- package/lib/types/src/client/panel/AuthorHome.d.ts +32 -0
- package/lib/types/src/client/panel/BookshelfBar.d.ts +11 -0
- package/lib/types/src/client/panel/CreateBookView.d.ts +9 -0
- package/lib/types/src/client/panel/FlowGuide.d.ts +22 -0
- package/lib/types/src/client/panel/GlobalAssetLibraryView.d.ts +4 -0
- package/lib/types/src/client/panel/ImportModal.d.ts +7 -0
- package/lib/types/src/client/panel/MangaRoleLibrary.d.ts +20 -0
- package/lib/types/src/client/panel/MangaWorkspace.d.ts +12 -0
- package/lib/types/src/client/panel/ModelManager.d.ts +14 -0
- package/lib/types/src/client/panel/NovelPanel.d.ts +13 -0
- package/lib/types/src/client/panel/ProgressConsole.d.ts +17 -0
- package/lib/types/src/client/panel/ProgressHomeView.d.ts +5 -0
- package/lib/types/src/client/panel/PropLibrary.d.ts +8 -0
- package/lib/types/src/client/panel/ReaderView.d.ts +8 -0
- package/lib/types/src/client/panel/ReasoningSection.d.ts +10 -0
- package/lib/types/src/client/panel/RunPanel.d.ts +5 -0
- package/lib/types/src/client/panel/SceneLibrary.d.ts +16 -0
- package/lib/types/src/client/panel/SettingsView.d.ts +16 -0
- package/lib/types/src/client/panel/ShelfView.d.ts +15 -0
- package/lib/types/src/client/panel/StoryboardTab.d.ts +19 -0
- package/lib/types/src/client/panel/StyleCard.d.ts +8 -0
- package/lib/types/src/client/panel/StyleGallery.d.ts +1 -0
- package/lib/types/src/client/panel/WorldTab.d.ts +10 -0
- package/lib/types/src/client/panel/controller.d.ts +19 -0
- package/lib/types/src/client/panel/helpers.d.ts +18 -0
- package/lib/types/src/client/panel/views.d.ts +77 -0
- package/lib/types/src/client/sidebar-entry.d.ts +13 -0
- package/lib/types/src/client/text.d.ts +9 -0
- package/lib/types/src/comic-presets.d.ts +12 -0
- package/lib/types/src/docx.d.ts +19 -0
- package/lib/types/src/engine.d.ts +402 -0
- package/lib/types/src/index.d.ts +79 -0
- package/lib/types/src/manga-genre-rules.d.ts +22 -0
- package/lib/types/src/novel-context.d.ts +80 -0
- package/lib/types/src/prompt-utils.d.ts +35 -0
- package/lib/types/src/protocol.d.ts +2176 -0
- package/lib/types/src/review-policy.d.ts +97 -0
- package/lib/types/src/routes.d.ts +29 -0
- package/lib/types/src/run.d.ts +32 -0
- package/lib/types/src/shot-language.d.ts +46 -0
- package/lib/types/src/story-beat-language.d.ts +35 -0
- package/lib/types/src/style-library.d.ts +33 -0
- package/package.json +1 -1
- package/src/assets.ts +1774 -879
- package/src/assistant.ts +154 -9
- package/src/client/api.ts +105 -6
- package/src/client/llmLive.ts +101 -0
- package/src/client/locales.ts +4 -4
- package/src/client/panel/AdaptModeView.tsx +18 -3
- package/src/client/panel/AssistantTab.tsx +41 -3
- package/src/client/panel/AuthorHome.tsx +51 -20
- package/src/client/panel/BookAnalysisView.tsx +60 -0
- package/src/client/panel/CreateBookView.tsx +22 -3
- package/src/client/panel/DirectorView.tsx +200 -0
- package/src/client/panel/IdeaInspirationView.tsx +71 -0
- package/src/client/panel/KnowledgeBaseView.tsx +73 -0
- package/src/client/panel/LiveFeedLog.tsx +76 -0
- package/src/client/panel/MarketRadarView.tsx +374 -0
- package/src/client/panel/NovelPanel.tsx +117 -16
- package/src/client/panel/ProgressConsole.tsx +5 -6
- package/src/client/panel/SettingsView.tsx +3 -0
- package/src/client/panel/panel.module.css +2 -3
- package/src/engine.ts +459 -23
- package/src/global-assets.ts +59 -0
- package/src/index.ts +17 -1
- package/src/llm-live.ts +68 -0
- package/src/market-radar-scan.ts +41 -0
- package/src/market-radar-sources.ts +173 -0
- package/src/prompting.ts +159 -0
- package/src/protocol.ts +340 -8
- package/src/routes.ts +579 -59
- package/src/run.ts +15 -8
- package/skills/chapter-batch/SKILL.md +0 -81
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 书内知识库:添加/删除参考资料,生成/规划时自动检索注入。
|
|
3
|
+
*/
|
|
4
|
+
import { useEffect, useState } from 'react'
|
|
5
|
+
import type { NovelApi } from '../api.ts'
|
|
6
|
+
import type { KnowledgeDoc } from '../../protocol.ts'
|
|
7
|
+
import css from './panel.module.css'
|
|
8
|
+
|
|
9
|
+
export default function KnowledgeBaseView({ api }: { api: NovelApi }): JSX.Element {
|
|
10
|
+
const [docs, setDocs] = useState<KnowledgeDoc[]>([])
|
|
11
|
+
const [title, setTitle] = useState('')
|
|
12
|
+
const [content, setContent] = useState('')
|
|
13
|
+
const [busy, setBusy] = useState(false)
|
|
14
|
+
const [notice, setNotice] = useState('')
|
|
15
|
+
const [error, setError] = useState('')
|
|
16
|
+
|
|
17
|
+
const load = async (): Promise<void> => {
|
|
18
|
+
try {
|
|
19
|
+
const r = await api.knowledgeList()
|
|
20
|
+
setDocs(r.docs)
|
|
21
|
+
} catch (e) { setError((e as Error).message) }
|
|
22
|
+
}
|
|
23
|
+
useEffect(() => { void load() }, [])
|
|
24
|
+
|
|
25
|
+
const add = async (): Promise<void> => {
|
|
26
|
+
if (title.trim() === '') { setError('标题不能为空'); return }
|
|
27
|
+
setBusy(true); setError(''); setNotice('')
|
|
28
|
+
try {
|
|
29
|
+
const r = await api.knowledgeAdd({ title: title.trim(), content: content.trim() })
|
|
30
|
+
setDocs(r.docs); setTitle(''); setContent(''); setNotice('✅ 已添加')
|
|
31
|
+
} catch (e) { setError((e as Error).message) } finally { setBusy(false) }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const remove = async (id: string): Promise<void> => {
|
|
35
|
+
setBusy(true); setError('')
|
|
36
|
+
try {
|
|
37
|
+
const r = await api.knowledgeRemove(id)
|
|
38
|
+
setDocs(r.docs)
|
|
39
|
+
} catch (e) { setError((e as Error).message) } finally { setBusy(false) }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
<div className={css.authorPageBody}>
|
|
44
|
+
<div className={`${css.card} ${css.settingsCard}`} style={{ gap: 'var(--nf-space-10)' }}>
|
|
45
|
+
<span className={css.cardTitle}>📚 书内知识库</span>
|
|
46
|
+
<span className={css.meta}>自由参考文档:把零散设定/资料/参考放这里,正文生成会按当前章节检索注入。结构化设定(世界观/角色/境界/红线)请放「本书设定」,这里是补充/碎片资料。</span>
|
|
47
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-6)' }}>
|
|
48
|
+
<input className={css.input} value={title} onChange={e => setTitle(e.target.value)} placeholder="标题(如:天机锁设定 / 止水驿关系表)" />
|
|
49
|
+
<textarea className={css.input} style={{ minHeight: 100, resize: 'vertical' }} value={content} onChange={e => setContent(e.target.value)} placeholder="内容(将被检索注入生成提示)" />
|
|
50
|
+
</div>
|
|
51
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
52
|
+
<button type="button" className={`${css.button} ${css.buttonPrimary}`} disabled={busy} onClick={() => { void add() }}>添加</button>
|
|
53
|
+
{notice !== '' && <span style={{ fontSize: 12, color: 'var(--nf-success)' }}>{notice}</span>}
|
|
54
|
+
</div>
|
|
55
|
+
</div>
|
|
56
|
+
|
|
57
|
+
{error !== '' && <div style={{ color: 'var(--nf-error)', fontSize: 13 }}>⚠ {error}</div>}
|
|
58
|
+
|
|
59
|
+
<div className={`${css.card} ${css.settingsCard}`} style={{ gap: 'var(--nf-space-8)' }}>
|
|
60
|
+
<span className={css.cardTitle}>已有文档({docs.length})</span>
|
|
61
|
+
{docs.length === 0 ? <div style={{ color: 'var(--nf-text-2)', fontSize: 13 }}>暂无知识库文档。</div> : docs.map(d => (
|
|
62
|
+
<div key={d.id} style={{ border: '1px solid var(--nf-border)', borderRadius: 8, padding: 'var(--nf-space-8)' }}>
|
|
63
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
64
|
+
<b style={{ flex: 1 }}>{d.title}</b>
|
|
65
|
+
<button type="button" className={`${css.button} ${css.buttonSmall}`} onClick={() => { void remove(d.id) }}>删除</button>
|
|
66
|
+
</div>
|
|
67
|
+
<div style={{ fontSize: 12, color: 'var(--nf-text-2)', marginTop: 4, whiteSpace: 'pre-wrap' }}>{d.content}</div>
|
|
68
|
+
</div>
|
|
69
|
+
))}
|
|
70
|
+
</div>
|
|
71
|
+
</div>
|
|
72
|
+
)
|
|
73
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 「实时调用 / LIVE」—— 内嵌到 AI 进度窗的下半区(详情层),不再独立浮窗。
|
|
3
|
+
* 连接 /llm-live/stream,按 LLM 调用分组展示模型实时输出。
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { useMemo, useState } from 'react'
|
|
7
|
+
import { useLlmLiveFeed, type LlmLiveSession } from '../llmLive.ts'
|
|
8
|
+
|
|
9
|
+
const PHASE_COLOR: Record<string, string> = {
|
|
10
|
+
requesting: '#9aa8c0',
|
|
11
|
+
streaming: '#e891b5',
|
|
12
|
+
completed: '#4fbf8b',
|
|
13
|
+
failed: '#d4634f',
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const PHASE_BG: Record<string, string> = {
|
|
17
|
+
requesting: 'rgba(154,168,192,0.14)',
|
|
18
|
+
streaming: 'rgba(232,145,181,0.16)',
|
|
19
|
+
completed: 'rgba(79,191,139,0.16)',
|
|
20
|
+
failed: 'rgba(212,99,79,0.18)',
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function PhasePill({ s }: { s: LlmLiveSession }): JSX.Element {
|
|
24
|
+
return (
|
|
25
|
+
<span style={{ fontSize: 10, fontWeight: 600, padding: '1px 7px', borderRadius: 99, color: PHASE_COLOR[s.phase] ?? '#b0b7c3', backgroundColor: PHASE_BG[s.phase] ?? 'rgba(154,168,192,0.14)' }}>
|
|
26
|
+
{s.phase === 'requesting' ? '请求中' : s.phase === 'streaming' ? '生成中' : s.phase === 'failed' ? '失败' : '完成'}
|
|
27
|
+
</span>
|
|
28
|
+
)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function SessionCard({ s, brief }: { s: LlmLiveSession; brief: boolean }): JSX.Element {
|
|
32
|
+
const active = s.phase === 'requesting' || s.phase === 'streaming'
|
|
33
|
+
return (
|
|
34
|
+
<div style={{ borderBottom: '1px solid var(--nf-border)', padding: '7px 6px' }}>
|
|
35
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 12 }}>
|
|
36
|
+
<span style={{ width: 6, height: 6, borderRadius: 99, backgroundColor: active ? '#e891b5' : (s.phase === 'failed' ? '#d4634f' : '#4fbf8b'), flexShrink: 0 }} />
|
|
37
|
+
<b style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{s.label}</b>
|
|
38
|
+
{s.model !== undefined && <span style={{ color: 'var(--nf-text-2)', fontSize: 10, maxWidth: 130, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{s.model}</span>}
|
|
39
|
+
<PhasePill s={s} />
|
|
40
|
+
</div>
|
|
41
|
+
<div style={{ fontSize: 10, color: 'var(--nf-text-2)', marginTop: 2, display: 'flex', gap: 8 }}>
|
|
42
|
+
<span>{s.totalChars} 字符</span>
|
|
43
|
+
{s.phaseMessage !== '' && <span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{s.phaseMessage}</span>}
|
|
44
|
+
</div>
|
|
45
|
+
{!brief && s.preview !== '' && (
|
|
46
|
+
<pre style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word', fontSize: 11, lineHeight: 1.5, color: 'var(--nf-text)', backgroundColor: 'var(--nf-bg-inset)', borderRadius: 8, padding: 6, marginTop: 6, marginBottom: 0 }}>{s.preview}</pre>
|
|
47
|
+
)}
|
|
48
|
+
</div>
|
|
49
|
+
)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export default function LiveFeedLog(): JSX.Element {
|
|
53
|
+
const [brief, setBrief] = useState(true)
|
|
54
|
+
const [cleared, setCleared] = useState(false)
|
|
55
|
+
const { connected, sessions } = useLlmLiveFeed(true)
|
|
56
|
+
const active = sessions.filter(s => s.phase === 'requesting' || s.phase === 'streaming').length
|
|
57
|
+
const shown = useMemo(() => (cleared ? sessions.slice(-8) : sessions), [sessions, cleared])
|
|
58
|
+
return (
|
|
59
|
+
<div style={{ borderTop: '1px solid var(--nf-border)', marginTop: 8, paddingTop: 6, flex: '1 1 auto', minHeight: 140, display: 'flex', flexDirection: 'column' }}>
|
|
60
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 12, padding: '2px 4px' }}>
|
|
61
|
+
<span style={{ width: 7, height: 7, borderRadius: 99, backgroundColor: connected ? '#4fbf8b' : '#b0b7c3' }} />
|
|
62
|
+
<b style={{ flex: 1 }}>实时调用 / LIVE</b>
|
|
63
|
+
<span style={{ fontSize: 10, fontWeight: 600, padding: '1px 7px', borderRadius: 99, color: active > 0 ? '#e891b5' : '#9aa8c0', backgroundColor: active > 0 ? 'rgba(232,145,181,0.14)' : 'rgba(154,168,192,0.14)' }}>
|
|
64
|
+
{active > 0 ? `${active} 项进行中` : connected ? '等待生成' : '正在连接'}
|
|
65
|
+
</span>
|
|
66
|
+
<button type="button" title={brief ? '详细' : '简略'} onClick={() => setBrief(b => !b)} style={{ border: '1px solid var(--nf-border)', background: 'var(--nf-bg)', color: 'var(--nf-text)', borderRadius: 6, fontSize: 10, padding: '1px 6px', cursor: 'pointer' }}>{brief ? '详细' : '简略'}</button>
|
|
67
|
+
<button type="button" title="清空当前窗口" onClick={() => setCleared(true)} style={{ border: '1px solid var(--nf-border)', background: 'var(--nf-bg)', color: 'var(--nf-text)', borderRadius: 6, fontSize: 10, padding: '1px 6px', cursor: 'pointer' }}>清空</button>
|
|
68
|
+
</div>
|
|
69
|
+
<div style={{ flex: 1, minHeight: 0, overflowY: 'auto', display: 'flex', flexDirection: 'column' }}>
|
|
70
|
+
{shown.length === 0 ? (
|
|
71
|
+
<div style={{ padding: 10, fontSize: 12, color: 'var(--nf-text-2)' }}>{connected ? '暂无实时调用。开始一次生成/审稿/计划即可看到。' : '正在连接 AI 实况服务…'}</div>
|
|
72
|
+
) : shown.map(s => <SessionCard key={s.sessionId} s={s} brief={brief} />)}
|
|
73
|
+
</div>
|
|
74
|
+
</div>
|
|
75
|
+
)
|
|
76
|
+
}
|
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 「热门题材雷达」:对齐上游市场雷达页(真实榜单扫榜 → 候选勾选 → AI 分析 → 信号卡片 → 影响模式 → 用信号创作)。
|
|
3
|
+
*/
|
|
4
|
+
import { useMemo, useState } from 'react'
|
|
5
|
+
import type { NovelApi } from '../api.ts'
|
|
6
|
+
import type { MarketRadarResult, MarketCreativeBrief, IdeaInspirationResult } from '../../protocol.ts'
|
|
7
|
+
import css from './panel.module.css'
|
|
8
|
+
|
|
9
|
+
const PLATFORMS = ['fanqie', 'qidian', 'jinjiang']
|
|
10
|
+
const PLATFORM_LABELS: Record<string, string> = { fanqie: '番茄小说', qidian: '起点中文网', jinjiang: '晋江文学城' }
|
|
11
|
+
const MAX_SIGNALS = 5
|
|
12
|
+
|
|
13
|
+
interface ScanItem { rank: number; title: string; author?: string; tags?: string[]; synopsis?: string; category?: string; heatLabel?: string }
|
|
14
|
+
interface ScanGroup { platform: string; platformLabel: string; listKey: string; listLabel: string; status: 'ok' | 'error'; error?: string; items: ScanItem[] }
|
|
15
|
+
|
|
16
|
+
const KIND_LABELS: Record<string, string> = {
|
|
17
|
+
genre: '题材', protagonist: '主角', advantage: '金手指', opening: '开篇', relationship: '关系', title_pattern: '标题', opportunity: '机会', crowding: '拥挤',
|
|
18
|
+
}
|
|
19
|
+
const MODE_LABELS: Record<string, string> = {
|
|
20
|
+
follow_hot: '跟随热门',
|
|
21
|
+
differentiate: '差异化',
|
|
22
|
+
light: '轻度参考',
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export default function MarketRadarView({ api, bookId }: { api: NovelApi; bookId?: string }): JSX.Element {
|
|
26
|
+
const [platforms, setPlatforms] = useState<string[]>(['fanqie', 'qidian', 'jinjiang'])
|
|
27
|
+
const [feedText, setFeedText] = useState('')
|
|
28
|
+
const [scanGroups, setScanGroups] = useState<ScanGroup[] | null>(null)
|
|
29
|
+
const [selectedCandidates, setSelectedCandidates] = useState<Set<string>>(new Set())
|
|
30
|
+
const [viewPlatform, setViewPlatform] = useState('fanqie')
|
|
31
|
+
const [scanning, setScanning] = useState(false)
|
|
32
|
+
const [analyzing, setAnalyzing] = useState(false)
|
|
33
|
+
const [error, setError] = useState('')
|
|
34
|
+
const [result, setResult] = useState<MarketRadarResult | null>(null)
|
|
35
|
+
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
|
36
|
+
const [influenceMode, setInfluenceMode] = useState<'follow_hot' | 'differentiate' | 'light'>('differentiate')
|
|
37
|
+
const [briefBusy, setBriefBusy] = useState(false)
|
|
38
|
+
const [brief, setBrief] = useState<MarketCreativeBrief | null>(null)
|
|
39
|
+
const [ideas, setIdeas] = useState<IdeaInspirationResult['ideas'] | null>(null)
|
|
40
|
+
const [ideaBusy, setIdeaBusy] = useState(false)
|
|
41
|
+
const [applyBusy, setApplyBusy] = useState(false)
|
|
42
|
+
const [applied, setApplied] = useState('')
|
|
43
|
+
const [syncBusy, setSyncBusy] = useState(false)
|
|
44
|
+
const [syncedMsg, setSyncedMsg] = useState('')
|
|
45
|
+
|
|
46
|
+
const candKey = (g: ScanGroup, item: ScanItem): string => `${g.platform}:${g.listKey}:${item.rank}`
|
|
47
|
+
const togglePlatform = (p: string): void => {
|
|
48
|
+
setPlatforms(prev => prev.includes(p) ? (prev.length === 1 ? prev : prev.filter(x => x !== p)) : [...prev, p])
|
|
49
|
+
}
|
|
50
|
+
const platformList = useMemo(() => {
|
|
51
|
+
const set = new Set((scanGroups ?? []).map(g => g.platform))
|
|
52
|
+
return PLATFORMS.filter(p => set.has(p))
|
|
53
|
+
}, [scanGroups])
|
|
54
|
+
|
|
55
|
+
const scan = async (): Promise<void> => {
|
|
56
|
+
setScanning(true)
|
|
57
|
+
setError('')
|
|
58
|
+
setResult(null)
|
|
59
|
+
setBrief(null)
|
|
60
|
+
try {
|
|
61
|
+
const r = await api.marketRadarScan({ platforms })
|
|
62
|
+
const groups = r.result.groups
|
|
63
|
+
setScanGroups(groups)
|
|
64
|
+
const firstP = PLATFORMS.find(p => groups.some(g => g.platform === p))
|
|
65
|
+
if (firstP !== undefined) setViewPlatform(firstP)
|
|
66
|
+
// 默认不自动全选,交给用户手动勾选或用「全选」按钮。
|
|
67
|
+
setSelectedCandidates(new Set())
|
|
68
|
+
} catch (err) {
|
|
69
|
+
setError((err as Error).message)
|
|
70
|
+
} finally {
|
|
71
|
+
setScanning(false)
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const toggleCandidate = (key: string): void => {
|
|
76
|
+
setSelectedCandidates(prev => {
|
|
77
|
+
const next = new Set(prev)
|
|
78
|
+
if (next.has(key)) next.delete(key)
|
|
79
|
+
else next.add(key)
|
|
80
|
+
return next
|
|
81
|
+
})
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const selectAll = (): void => {
|
|
85
|
+
const next = new Set<string>()
|
|
86
|
+
for (const g of scanGroups ?? []) if (g.platform === viewPlatform && g.status === 'ok') for (const it of g.items) next.add(candKey(g, it))
|
|
87
|
+
setSelectedCandidates(next)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const clearSelection = (): void => setSelectedCandidates(new Set())
|
|
91
|
+
|
|
92
|
+
const selectGroup = (g: ScanGroup): void => {
|
|
93
|
+
setSelectedCandidates(prev => {
|
|
94
|
+
const next = new Set(prev)
|
|
95
|
+
for (const it of g.items) next.add(candKey(g, it))
|
|
96
|
+
return next
|
|
97
|
+
})
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const analyze = async (): Promise<void> => {
|
|
101
|
+
if (scanGroups === null) return
|
|
102
|
+
const candidates: Array<{ title: string; author?: string; tags?: string[]; synopsis?: string; category?: string }> = []
|
|
103
|
+
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 })
|
|
104
|
+
setAnalyzing(true)
|
|
105
|
+
setError('')
|
|
106
|
+
setBrief(null)
|
|
107
|
+
try {
|
|
108
|
+
const r = await api.marketRadar({ candidates, feedText: feedText.trim() })
|
|
109
|
+
setResult(r.result)
|
|
110
|
+
const rec = r.result.signals.filter(s => s.recommended === true).map(s => s.id)
|
|
111
|
+
setSelectedIds(new Set(rec.slice(0, MAX_SIGNALS)))
|
|
112
|
+
} catch (err) {
|
|
113
|
+
setError((err as Error).message)
|
|
114
|
+
} finally {
|
|
115
|
+
setAnalyzing(false)
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const toggleSignal = (id: string): void => {
|
|
120
|
+
setSelectedIds(prev => {
|
|
121
|
+
const next = new Set(prev)
|
|
122
|
+
if (next.has(id)) next.delete(id)
|
|
123
|
+
else if (next.size < MAX_SIGNALS) next.add(id)
|
|
124
|
+
return next
|
|
125
|
+
})
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const createBrief = async (): Promise<void> => {
|
|
129
|
+
if (result === null) return
|
|
130
|
+
const signals = result.signals.filter(s => selectedIds.has(s.id))
|
|
131
|
+
setBriefBusy(true)
|
|
132
|
+
setError('')
|
|
133
|
+
try {
|
|
134
|
+
const r = await api.marketRadarBrief({ influenceMode, signals })
|
|
135
|
+
setBrief(r.creativeBrief)
|
|
136
|
+
} catch (err) {
|
|
137
|
+
setError((err as Error).message)
|
|
138
|
+
} finally {
|
|
139
|
+
setBriefBusy(false)
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const createIdeas = async (): Promise<void> => {
|
|
144
|
+
if (result === null) return
|
|
145
|
+
const signals = result.signals.filter(s => selectedIds.has(s.id))
|
|
146
|
+
setIdeaBusy(true)
|
|
147
|
+
setError('')
|
|
148
|
+
try {
|
|
149
|
+
const r = await api.marketIdeaInspiration({ signals, foundation: result.productionFoundation, brief, count: 5 })
|
|
150
|
+
setIdeas(r.result.ideas)
|
|
151
|
+
} catch (err) {
|
|
152
|
+
setError((err as Error).message)
|
|
153
|
+
} finally {
|
|
154
|
+
setIdeaBusy(false)
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const apply = async (): Promise<void> => {
|
|
159
|
+
if (result === null) return
|
|
160
|
+
setApplyBusy(true)
|
|
161
|
+
setError('')
|
|
162
|
+
setApplied('')
|
|
163
|
+
try {
|
|
164
|
+
const r = await api.marketRadarApply({ bookId, foundation: result.productionFoundation })
|
|
165
|
+
setApplied(`✅ 已应用到《${r.bookName}》:后续规划/生成将按此题材与推进模式。`)
|
|
166
|
+
} catch (err) {
|
|
167
|
+
setError((err as Error).message)
|
|
168
|
+
} finally {
|
|
169
|
+
setApplyBusy(false)
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const sync = async (): Promise<void> => {
|
|
174
|
+
if (result === null) return
|
|
175
|
+
setSyncBusy(true)
|
|
176
|
+
setError('')
|
|
177
|
+
setSyncedMsg('')
|
|
178
|
+
try {
|
|
179
|
+
const r = await api.marketRadarSync({ foundation: result.productionFoundation })
|
|
180
|
+
const parts: string[] = []
|
|
181
|
+
if (r.synced.genre) parts.push('题材')
|
|
182
|
+
if (r.synced.primaryMode) parts.push('主推进')
|
|
183
|
+
if (r.synced.secondaryMode) parts.push('辅推进')
|
|
184
|
+
setSyncedMsg(parts.length > 0 ? `✅ 已同步到全局资源库:${parts.join('、')}` : '已在资源库中,无新增。')
|
|
185
|
+
} catch (err) {
|
|
186
|
+
setError((err as Error).message)
|
|
187
|
+
} finally {
|
|
188
|
+
setSyncBusy(false)
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const badge = (kind: string): string => (kind === 'opportunity' ? 'var(--nf-success)' : kind === 'crowding' ? 'var(--nf-error)' : 'var(--nf-accent)')
|
|
193
|
+
|
|
194
|
+
return (
|
|
195
|
+
<div className={css.authorPageBody}>
|
|
196
|
+
{/* 平台扫榜 */}
|
|
197
|
+
<div className={`${css.card} ${css.settingsCard}`} style={{ gap: 'var(--nf-space-12)' }}>
|
|
198
|
+
<span className={css.cardTitle}>📡 热门题材雷达</span>
|
|
199
|
+
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 'var(--nf-space-6)' }}>
|
|
200
|
+
{PLATFORMS.map(p => (
|
|
201
|
+
<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 }}>
|
|
202
|
+
{PLATFORM_LABELS[p]}
|
|
203
|
+
</button>
|
|
204
|
+
))}
|
|
205
|
+
</div>
|
|
206
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-6)' }}>
|
|
207
|
+
<label className={css.fieldLabel}>榜单文本兜底(可选,粘贴公开榜单文本;扫榜失败时可用于人工候选)</label>
|
|
208
|
+
<textarea className={css.input} style={{ minHeight: 90, resize: 'vertical' }} value={feedText} onChange={e => setFeedText(e.target.value)} placeholder="粘贴一份公开榜单文本…" />
|
|
209
|
+
</div>
|
|
210
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
|
211
|
+
<button type="button" className={`${css.button} ${css.buttonPrimary}`} disabled={scanning} onClick={() => { void scan() }}>
|
|
212
|
+
{scanning ? '正在扫榜…' : '扫榜'}
|
|
213
|
+
</button>
|
|
214
|
+
{scanning && <span style={{ fontSize: 12, color: 'var(--nf-text-2)' }}>正在抓取公开榜单({platforms.map(p => PLATFORM_LABELS[p]).join('、')}),多平台并行,约 10–30 秒…</span>}
|
|
215
|
+
{scanGroups !== null && (
|
|
216
|
+
<button type="button" className={css.button} style={{ marginLeft: 8 }} disabled={analyzing || selectedCandidates.size === 0} onClick={() => { void analyze() }}>
|
|
217
|
+
{analyzing ? 'AI 分析中…' : `开始 AI 分析(${selectedCandidates.size} 本)`}
|
|
218
|
+
</button>
|
|
219
|
+
)}
|
|
220
|
+
</div>
|
|
221
|
+
</div>
|
|
222
|
+
|
|
223
|
+
{error !== '' && <div style={{ color: 'var(--nf-error)', fontSize: 13 }}>⚠ {error}</div>}
|
|
224
|
+
|
|
225
|
+
{/* 榜单候选:平台 tabs 切换 + 平台内榜单横排(最多三列) + 每列 10 条可滚 */}
|
|
226
|
+
{scanGroups !== null && (
|
|
227
|
+
<div className={`${css.card} ${css.settingsCard}`} style={{ gap: 'var(--nf-space-10)' }}>
|
|
228
|
+
<span className={css.cardTitle}>公开榜单候选</span>
|
|
229
|
+
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 'var(--nf-space-6)' }}>
|
|
230
|
+
{platformList.map(p => {
|
|
231
|
+
const active = viewPlatform === p
|
|
232
|
+
const count = (scanGroups ?? []).filter(g => g.platform === p && g.status === 'ok').reduce((a, g) => a + g.items.length, 0)
|
|
233
|
+
return (
|
|
234
|
+
<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 }}>
|
|
235
|
+
{PLATFORM_LABELS[p]} ({count})
|
|
236
|
+
</button>
|
|
237
|
+
)
|
|
238
|
+
})}
|
|
239
|
+
</div>
|
|
240
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
|
241
|
+
<button type="button" className={`${css.button} ${css.buttonSmall}`} onClick={selectAll}>全选当前平台</button>
|
|
242
|
+
<button type="button" className={`${css.button} ${css.buttonSmall}`} onClick={clearSelection}>清空</button>
|
|
243
|
+
<span style={{ fontSize: 11, color: 'var(--nf-text-2)' }}>已选 {selectedCandidates.size} 条</span>
|
|
244
|
+
</div>
|
|
245
|
+
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(340px, 1fr))', gap: 'var(--nf-space-10)' }}>
|
|
246
|
+
{(scanGroups ?? []).filter(g => g.platform === viewPlatform).map(g => (
|
|
247
|
+
<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 }}>
|
|
248
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13, fontWeight: 600 }}>
|
|
249
|
+
{g.listLabel}
|
|
250
|
+
<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>
|
|
251
|
+
{g.status === 'ok' && <button type="button" className={`${css.button} ${css.buttonSmall}`} onClick={() => selectGroup(g)}>全选本榜</button>}
|
|
252
|
+
</div>
|
|
253
|
+
{g.status === 'error' && <div style={{ fontSize: 11, color: 'var(--nf-error)', marginTop: 4 }}>{g.error}</div>}
|
|
254
|
+
{g.status === 'ok' && (
|
|
255
|
+
<div style={{ marginTop: 6, maxHeight: 400, overflowY: 'auto', display: 'flex', flexDirection: 'column' }}>
|
|
256
|
+
{g.items.map(it => {
|
|
257
|
+
const key = candKey(g, it)
|
|
258
|
+
const sel = selectedCandidates.has(key)
|
|
259
|
+
return (
|
|
260
|
+
<label key={key} onClick={e => { if ((e.target as HTMLElement).tagName !== 'INPUT') toggleCandidate(key) }} style={{ display: 'flex', alignItems: 'flex-start', gap: 7, padding: '5px 2px', fontSize: 13.5, cursor: 'pointer' }}>
|
|
261
|
+
<input type="checkbox" checked={sel} onChange={() => toggleCandidate(key)} onClick={e => e.stopPropagation()} style={{ marginTop: 3 }} />
|
|
262
|
+
<span style={{ lineHeight: 1.5 }}>
|
|
263
|
+
<b style={{ fontSize: 14 }}>{it.rank}. {it.title}</b>
|
|
264
|
+
{it.author !== undefined && it.author !== '' ? <span style={{ color: 'var(--nf-text-2)', fontSize: 12.5 }}>({it.author})</span> : null}
|
|
265
|
+
{(it.tags ?? []).length > 0 ? <span style={{ color: 'var(--nf-info)', fontSize: 12.5 }}> [{it.tags!.slice(0, 3).join('、')}]</span> : null}
|
|
266
|
+
</span>
|
|
267
|
+
</label>
|
|
268
|
+
)
|
|
269
|
+
})}
|
|
270
|
+
</div>
|
|
271
|
+
)}
|
|
272
|
+
</div>
|
|
273
|
+
))}
|
|
274
|
+
</div>
|
|
275
|
+
</div>
|
|
276
|
+
)}
|
|
277
|
+
|
|
278
|
+
{/* 本期判断 + 信号 */}
|
|
279
|
+
{result !== null && (
|
|
280
|
+
<>
|
|
281
|
+
<div className={`${css.card} ${css.settingsCard}`} style={{ gap: 'var(--nf-space-10)' }}>
|
|
282
|
+
<span className={css.cardTitle}>本期判断</span>
|
|
283
|
+
<div style={{ fontSize: 13, lineHeight: 1.7 }}>
|
|
284
|
+
<div><span style={{ color: 'var(--nf-text-2)' }}>题材基底:</span>{result.productionFoundation.genre.name}({result.productionFoundation.genre.description})</div>
|
|
285
|
+
<div><span style={{ color: 'var(--nf-text-2)' }}>主要推进:</span>{result.productionFoundation.primaryStoryMode.name} —— {result.productionFoundation.primaryStoryMode.driver}</div>
|
|
286
|
+
{result.productionFoundation.secondaryStoryMode !== undefined && (
|
|
287
|
+
<div><span style={{ color: 'var(--nf-text-2)' }}>辅助推进:</span>{result.productionFoundation.secondaryStoryMode.name} —— {result.productionFoundation.secondaryStoryMode.driver}</div>
|
|
288
|
+
)}
|
|
289
|
+
</div>
|
|
290
|
+
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 'var(--nf-space-8)', flexWrap: 'wrap' }}>
|
|
291
|
+
<button type="button" className={`${css.button} ${css.buttonPrimary}`} disabled={applyBusy} onClick={() => { void apply() }}>
|
|
292
|
+
{applyBusy ? '应用…' : '应用到当前书'}
|
|
293
|
+
</button>
|
|
294
|
+
<button type="button" className={css.button} disabled={syncBusy} onClick={() => { void sync() }}>
|
|
295
|
+
{syncBusy ? '同步…' : '同步到全局资源库'}
|
|
296
|
+
</button>
|
|
297
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
|
298
|
+
{applied !== '' && <span style={{ fontSize: 12, color: 'var(--nf-success)' }}>{applied}</span>}
|
|
299
|
+
{syncedMsg !== '' && <span style={{ fontSize: 12, color: 'var(--nf-info)' }}>{syncedMsg}</span>}
|
|
300
|
+
</div>
|
|
301
|
+
</div>
|
|
302
|
+
</div>
|
|
303
|
+
|
|
304
|
+
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(240px, 1fr))', gap: 'var(--nf-space-10)' }}>
|
|
305
|
+
{result.signals.map(s => {
|
|
306
|
+
const selected = selectedIds.has(s.id)
|
|
307
|
+
return (
|
|
308
|
+
<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' }}>
|
|
309
|
+
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
|
|
310
|
+
<span style={{ fontSize: 11, fontWeight: 600, padding: '1px 7px', borderRadius: 99, color: '#fff', background: badge(s.kind) }}>{KIND_LABELS[s.kind] ?? s.kind}</span>
|
|
311
|
+
{selected && <span style={{ fontSize: 11, color: 'var(--nf-accent)', fontWeight: 600 }}>已选</span>}
|
|
312
|
+
</div>
|
|
313
|
+
<div style={{ marginTop: 8, fontSize: 15, fontWeight: 600 }}>{s.title}</div>
|
|
314
|
+
<p style={{ marginTop: 6, fontSize: 12, color: 'var(--nf-text-2)', lineHeight: 1.6 }}>{s.detail}</p>
|
|
315
|
+
<div style={{ marginTop: 8, display: 'flex', gap: 10, fontSize: 11, color: 'var(--nf-text-2)' }}>
|
|
316
|
+
<span>{s.direction === 'rising' ? '正在升温' : s.direction === 'falling' ? '正在降温' : s.direction === 'stable' ? '相对稳定' : '当前高频'}</span>
|
|
317
|
+
{s.recommended === true && <span style={{ color: 'var(--nf-success)' }}>★推荐</span>}
|
|
318
|
+
</div>
|
|
319
|
+
</article>
|
|
320
|
+
)
|
|
321
|
+
})}
|
|
322
|
+
</div>
|
|
323
|
+
</>
|
|
324
|
+
)}
|
|
325
|
+
|
|
326
|
+
{/* 底部:影响模式 + 用信号创作 */}
|
|
327
|
+
{result !== null && (
|
|
328
|
+
<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)' }}>
|
|
329
|
+
<div>
|
|
330
|
+
<div style={{ fontSize: 13, fontWeight: 600 }}>已选 {selectedIds.size}/{MAX_SIGNALS} 项市场信号</div>
|
|
331
|
+
<div style={{ fontSize: 11, color: 'var(--nf-text-2)' }}>AI 推荐已自动勾选,可替换后再开书。</div>
|
|
332
|
+
</div>
|
|
333
|
+
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
|
|
334
|
+
<select className={css.input} style={{ width: 'auto', padding: '4px 8px' }} value={influenceMode} onChange={e => setInfluenceMode(e.target.value as 'follow_hot' | 'differentiate' | 'light')}>
|
|
335
|
+
{Object.entries(MODE_LABELS).map(([v, l]) => <option key={v} value={v}>{l}</option>)}
|
|
336
|
+
</select>
|
|
337
|
+
<button type="button" className={`${css.button} ${css.buttonPrimary}`} disabled={selectedIds.size === 0 || briefBusy} onClick={() => { void createBrief() }}>
|
|
338
|
+
{briefBusy ? '生成中…' : '用这些信号创作'}
|
|
339
|
+
</button>
|
|
340
|
+
</div>
|
|
341
|
+
</div>
|
|
342
|
+
)}
|
|
343
|
+
|
|
344
|
+
{brief !== null && (
|
|
345
|
+
<div className={`${css.card} ${css.settingsCard}`} style={{ gap: 'var(--nf-space-10)' }}>
|
|
346
|
+
<span className={css.cardTitle}>✍ 开书创意简报</span>
|
|
347
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-8)', fontSize: 13, lineHeight: 1.7 }}>
|
|
348
|
+
<div><b>创作约束</b>:{brief.promptBlock}</div>
|
|
349
|
+
<div><b>开篇想法</b>:{brief.openingIdea}</div>
|
|
350
|
+
<div><b>核心优势</b>:{brief.coreAdvantage}</div>
|
|
351
|
+
<div><b>追读卖点</b>:{brief.bookSellingPoint}</div>
|
|
352
|
+
<div><b>前30章承诺</b>:{brief.first30ChapterPromise}</div>
|
|
353
|
+
</div>
|
|
354
|
+
<button type="button" className={`${css.button} ${css.buttonPrimary}`} disabled={ideaBusy || selectedIds.size === 0} onClick={() => { void createIdeas() }}>
|
|
355
|
+
{ideaBusy ? '生成灵感中…' : '✨ 用这些信号生成灵感'}
|
|
356
|
+
</button>
|
|
357
|
+
</div>
|
|
358
|
+
)}
|
|
359
|
+
|
|
360
|
+
{ideas !== null && ideas.length > 0 && (
|
|
361
|
+
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(260px, 1fr))', gap: 'var(--nf-space-10)' }}>
|
|
362
|
+
{ideas.map((it, i) => (
|
|
363
|
+
<div key={i} className={`${css.card} ${css.settingsCard}`} style={{ gap: 'var(--nf-space-6)' }}>
|
|
364
|
+
<div style={{ fontSize: 15, fontWeight: 600 }}>{it.title}</div>
|
|
365
|
+
<div style={{ fontSize: 12, color: 'var(--nf-success)' }}>{it.genre} · {it.pov}</div>
|
|
366
|
+
<div style={{ fontSize: 13 }}>钩子:{it.hook}</div>
|
|
367
|
+
<div style={{ fontSize: 12, color: 'var(--nf-text-2)' }}>兑现:{it.payoff}</div>
|
|
368
|
+
</div>
|
|
369
|
+
))}
|
|
370
|
+
</div>
|
|
371
|
+
)}
|
|
372
|
+
</div>
|
|
373
|
+
)
|
|
374
|
+
}
|