@waterwx/dsh-novel-forge 1.1.9 → 1.3.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.
- package/README.md +5 -0
- package/lib/client.js +3645 -1589
- package/lib/client.js.map +1 -1
- package/lib/index.js +626 -181
- package/lib/index.js.map +1 -1
- package/lib/types/client/api.d.ts +4 -4
- package/lib/types/client/locales.d.ts +8 -0
- package/lib/types/client/panel/NovelPanel.d.ts +1 -1
- package/lib/types/comic-presets.d.ts +12 -0
- package/lib/types/engine.d.ts +17 -14
- package/lib/types/index.d.ts +8 -0
- package/lib/types/protocol.d.ts +153 -89
- package/package.json +1 -1
- package/src/client/api.ts +10 -10
- package/src/client/locales.ts +16 -0
- package/src/client/panel/AssetsTab.tsx +19 -0
- package/src/client/panel/NovelPanel.tsx +1165 -390
- package/src/client/panel/ShelfView.tsx +13 -12
- package/src/client/panel/panel.module.css +192 -68
- package/src/comic-presets.ts +67 -0
- package/src/engine.ts +351 -187
- package/src/index.ts +21 -0
- package/src/protocol.ts +147 -96
- package/src/routes.ts +204 -56
|
@@ -19,6 +19,7 @@ import { WorldTab } from './WorldTab.tsx'
|
|
|
19
19
|
import { AuditIssueRow, PlotlineCard, PlotlineHealthPanel, PlotlinePlanPanel, PlotlineSuggestionPanel, RoleCandidateRow, RoleCard, StatCell, TodoRow } from './views.tsx'
|
|
20
20
|
import { extractDocxTextFromBuffer } from '../docx.ts'
|
|
21
21
|
import type {
|
|
22
|
+
AuditStatus,
|
|
22
23
|
BookshelfSnapshot,
|
|
23
24
|
ChapterPlan,
|
|
24
25
|
Foreshadow,
|
|
@@ -39,8 +40,10 @@ import css from './panel.module.css'
|
|
|
39
40
|
/** The panel's tab identifiers. */
|
|
40
41
|
export type NovelTab =
|
|
41
42
|
| 'workflow' | 'overview' | 'blurb' | 'plan' | 'bible' | 'world' | 'foreshadow' | 'assistant' | 'settings'
|
|
42
|
-
| 'characters' | 'roles' | 'facts' | 'plotlines' | 'reviews' | 'progress' | 'breakdown'
|
|
43
|
+
| 'characters' | 'roles' | 'facts' | 'plotlines' | 'reviews' | 'progress' | 'breakdown'
|
|
44
|
+
| 'roleImage' | 'scenes'
|
|
43
45
|
| 'assetsGenre' | 'assetsProgression' | 'assetsTemplates' | 'assetsRules' | 'assetsStyle' | 'run'
|
|
46
|
+
| 'book' | 'assets'
|
|
44
47
|
|
|
45
48
|
/** Panel shell props. */
|
|
46
49
|
export interface NovelPanelProps {
|
|
@@ -69,10 +72,10 @@ const NAV_GROUPS: ReadonlyArray<{ id: string; label: string; items: ReadonlyArra
|
|
|
69
72
|
items: [
|
|
70
73
|
{ id: 'workflow', label: tt('tab.workflow'), icon: '🛠️' },
|
|
71
74
|
{ id: 'overview', label: tt('tab.overview'), icon: '📄' },
|
|
72
|
-
{ id: 'blurb', label: '
|
|
75
|
+
{ id: 'blurb', label: '简介 / 封面', icon: '📖' },
|
|
73
76
|
{ id: 'plan', label: tt('tab.plan'), icon: '📚' },
|
|
74
|
-
{ id: 'plotlines', label:
|
|
75
|
-
{ id: '
|
|
77
|
+
{ id: 'plotlines', label: '长线管理', icon: '📜' },
|
|
78
|
+
{ id: 'book', label: '本书设定', icon: '📚' },
|
|
76
79
|
],
|
|
77
80
|
},
|
|
78
81
|
{
|
|
@@ -82,24 +85,16 @@ const NAV_GROUPS: ReadonlyArray<{ id: string; label: string; items: ReadonlyArra
|
|
|
82
85
|
{ id: 'assistant', label: tt('tab.assistant'), icon: '💬' },
|
|
83
86
|
{ id: 'progress', label: '工作进度', icon: '📊' },
|
|
84
87
|
{ id: 'breakdown', label: '拆书分析', icon: '🔍' },
|
|
85
|
-
{ id: '
|
|
88
|
+
{ id: 'roleImage', label: '角色形象', icon: '🖼️' },
|
|
89
|
+
{ id: 'scenes', label: '场景库', icon: '🏞️' },
|
|
90
|
+
{ id: 'run', label: '生产单', icon: '🏭' },
|
|
86
91
|
],
|
|
87
92
|
},
|
|
88
93
|
{
|
|
89
|
-
id: '
|
|
90
|
-
label: '
|
|
94
|
+
id: 'assets',
|
|
95
|
+
label: '资产',
|
|
91
96
|
items: [
|
|
92
|
-
{ id: '
|
|
93
|
-
{ id: 'world', label: '大世界', icon: '🌍' },
|
|
94
|
-
{ id: 'roles', label: '角色库', icon: '👥' },
|
|
95
|
-
{ id: 'foreshadow', label: tt('tab.foreshadow'), icon: '🔮' },
|
|
96
|
-
{ id: 'facts', label: tt('tab.facts'), icon: '📜' },
|
|
97
|
-
{ id: 'reviews', label: '复盘记录', icon: '📋' },
|
|
98
|
-
{ id: 'assetsGenre', label: '题材基底', icon: '🏷️' },
|
|
99
|
-
{ id: 'assetsProgression', label: '推进模式', icon: '📈' },
|
|
100
|
-
{ id: 'assetsTemplates', label: '笔法帖', icon: '🖋️' },
|
|
101
|
-
{ id: 'assetsRules', label: '文戒', icon: '🚫' },
|
|
102
|
-
{ id: 'assetsStyle', label: '心法', icon: '🎨' },
|
|
97
|
+
{ id: 'assets', label: '创作资产', icon: '🧰' },
|
|
103
98
|
],
|
|
104
99
|
},
|
|
105
100
|
]
|
|
@@ -107,6 +102,10 @@ const NAV_GROUPS: ReadonlyArray<{ id: string; label: string; items: ReadonlyArra
|
|
|
107
102
|
/** Settings tab — pinned to the bottom of the nav rail. */
|
|
108
103
|
const SETTINGS_TAB: { id: NovelTab; label: string; icon: string } = { id: 'settings', label: tt('tab.settings'), icon: '⚙️' }
|
|
109
104
|
|
|
105
|
+
/** Common DeepSeek model presets shown in settings; users can also type any model id. */
|
|
106
|
+
const MODEL_PRESETS = ['deepseek-v4-flash', 'deepseek-v4-pro', 'deepseek-chat', 'deepseek-reasoner'] as const
|
|
107
|
+
const REASONING_OPTIONS = ['off', 'low', 'high', 'max'] as const
|
|
108
|
+
|
|
110
109
|
/** 构建时注入的插件版本(tsdown define 替换为字符串字面量)。 */
|
|
111
110
|
declare const __NOVEL_FORGE_VERSION__: string | undefined
|
|
112
111
|
const PLUGIN_VERSION: string = typeof __NOVEL_FORGE_VERSION__ !== 'undefined' ? __NOVEL_FORGE_VERSION__ : '0.0.0'
|
|
@@ -319,9 +318,12 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
319
318
|
const [notice, setNotice] = useState('')
|
|
320
319
|
const [progress, setProgress] = useState<ProgressLine[]>([])
|
|
321
320
|
const [configDraft, setConfigDraft] = useState<NovelConfig | null>(null)
|
|
321
|
+
const [modelCustomMode, setModelCustomMode] = useState(false)
|
|
322
322
|
const [expandedChapter, setExpandedChapter] = useState<number | null>(null)
|
|
323
323
|
/** 复盘记录页:当前展开的章节号。 */
|
|
324
324
|
const [expandedReviewChapter, setExpandedReviewChapter] = useState<number | null>(null)
|
|
325
|
+
/** 复盘记录页:按卷折叠,卷号 → 是否展开(默认全部收起)。 */
|
|
326
|
+
const [expandedVolumes, setExpandedVolumes] = useState<Record<number, boolean>>({})
|
|
325
327
|
const [chapterText, setChapterText] = useState('')
|
|
326
328
|
const progressId = useRef(0)
|
|
327
329
|
/** id of the single live progress row (generation counter), if any. */
|
|
@@ -385,6 +387,8 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
385
387
|
const [updatingOutline, setUpdatingOutline] = useState(false)
|
|
386
388
|
/** 全书质检结果(null = 未运行)。 */
|
|
387
389
|
const [auditIssues, setAuditIssues] = useState<import('../../protocol.ts').AuditIssue[] | null>(null)
|
|
390
|
+
/** 全书质检实时状态(来自 /status,用于显示进度)。 */
|
|
391
|
+
const [auditStatus, setAuditStatus] = useState<AuditStatus | null>(null)
|
|
388
392
|
/** 角色卡(从事实库聚合)。 */
|
|
389
393
|
const [charCards, setCharCards] = useState<import('../../protocol.ts').RoleStatusCard[] | null>(null)
|
|
390
394
|
/** 世界观规则编辑草稿(bible tab,每行一条)。 */
|
|
@@ -412,6 +416,39 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
412
416
|
progress: string
|
|
413
417
|
status: Plotline['status']
|
|
414
418
|
} | null>(null)
|
|
419
|
+
/** 长线管理页:子页签(剧情线 / 伏笔),localStorage 记忆。 */
|
|
420
|
+
const [longlineTab, setLonglineTab] = useState<'plotlines' | 'foreshadow'>(() => {
|
|
421
|
+
try {
|
|
422
|
+
const v = window.localStorage.getItem('dsh-novel-forge.longline.tab')
|
|
423
|
+
return v === 'foreshadow' ? 'foreshadow' : 'plotlines'
|
|
424
|
+
} catch { return 'plotlines' }
|
|
425
|
+
})
|
|
426
|
+
const changeLonglineTab = (next: 'plotlines' | 'foreshadow'): void => {
|
|
427
|
+
setLonglineTab(next)
|
|
428
|
+
try { window.localStorage.setItem('dsh-novel-forge.longline.tab', next) } catch { /* ignore */ }
|
|
429
|
+
}
|
|
430
|
+
/** 编年 / 复盘页:子页签(编年录 / 复盘记录),localStorage 记忆。 */
|
|
431
|
+
const [archiveTab, setArchiveTab] = useState<'facts' | 'reviews'>(() => {
|
|
432
|
+
try {
|
|
433
|
+
const v = window.localStorage.getItem('dsh-novel-forge.archive.tab')
|
|
434
|
+
return v === 'reviews' ? 'reviews' : 'facts'
|
|
435
|
+
} catch { return 'facts' }
|
|
436
|
+
})
|
|
437
|
+
const changeArchiveTab = (next: 'facts' | 'reviews'): void => {
|
|
438
|
+
setArchiveTab(next)
|
|
439
|
+
try { window.localStorage.setItem('dsh-novel-forge.archive.tab', next) } catch { /* ignore */ }
|
|
440
|
+
}
|
|
441
|
+
/** 本书设定页:子页签(设定库 / 大世界 / 角色库 / 编年·复盘),localStorage 记忆。 */
|
|
442
|
+
const [bookTab, setBookTab] = useState<'bible' | 'world' | 'roles' | 'facts'>(() => {
|
|
443
|
+
try {
|
|
444
|
+
const v = window.localStorage.getItem('dsh-novel-forge.book.tab')
|
|
445
|
+
return v === 'world' || v === 'roles' || v === 'facts' ? v : 'bible'
|
|
446
|
+
} catch { return 'bible' }
|
|
447
|
+
})
|
|
448
|
+
const changeBookTab = (next: 'bible' | 'world' | 'roles' | 'facts'): void => {
|
|
449
|
+
setBookTab(next)
|
|
450
|
+
try { window.localStorage.setItem('dsh-novel-forge.book.tab', next) } catch { /* ignore */ }
|
|
451
|
+
}
|
|
415
452
|
/** 角色知情度编辑草稿(角色名 → 文本,每行一条)。 */
|
|
416
453
|
const [knowledgeDraft, setKnowledgeDraft] = useState<Record<string, string>>({})
|
|
417
454
|
/** 角色库:AI 提炼候选(null = 未运行;localStorage 持久化,刷新不丢)。 */
|
|
@@ -444,16 +481,23 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
444
481
|
const [breakdownResult, setBreakdownResult] = useState<import('../../protocol.ts').BreakdownResponse | null>(null)
|
|
445
482
|
const [breakdownScope, setBreakdownScope] = useState<'recent' | 'volume:2' | 'volume:3' | 'all'>('recent')
|
|
446
483
|
const [breakdownPreset, setBreakdownPreset] = useState<'quick' | 'standard'>('quick')
|
|
447
|
-
/**
|
|
448
|
-
const [
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
const [
|
|
455
|
-
/**
|
|
456
|
-
const [
|
|
484
|
+
/** 角色图集上传:当前等待上传的角色名。 */
|
|
485
|
+
const [roleImageTarget, setRoleImageTarget] = useState<string | null>(null)
|
|
486
|
+
/** 角色图集上传:当前用途标签(立绘/四视图/表情-x/场景/细节)。 */
|
|
487
|
+
const [roleImageLabel, setRoleImageLabel] = useState('立绘')
|
|
488
|
+
/** 角色形象详情:当前打开的角色名(null = 关闭)。 */
|
|
489
|
+
const [detailRoleName, setDetailRoleName] = useState<string | null>(null)
|
|
490
|
+
/** 详情面板:上传标签输入。 */
|
|
491
|
+
const [detailUploadLabel, setDetailUploadLabel] = useState('立绘')
|
|
492
|
+
/** 场景库:AI 提炼候选(null = 未运行)。 */
|
|
493
|
+
const [sceneCandidates, setSceneCandidates] = useState<import('../../protocol.ts').SceneCard[] | null>(null)
|
|
494
|
+
/** 场景库:当前打开的场景名。 */
|
|
495
|
+
const [sceneDetailName, setSceneDetailName] = useState<string | null>(null)
|
|
496
|
+
/** 场景库:上传标签输入。 */
|
|
497
|
+
const [sceneUploadLabel, setSceneUploadLabel] = useState('全景')
|
|
498
|
+
/** 场景库:编辑草稿(null = 不在编辑)。 */
|
|
499
|
+
const [sceneDraft, setSceneDraft] = useState<import('../../protocol.ts').SceneCard | null>(null)
|
|
500
|
+
const roleImageInputRef = useRef<HTMLInputElement | null>(null)
|
|
457
501
|
/** AI 建议的剧情线候选(null = 未运行)。 */
|
|
458
502
|
const [plotlineSuggestions, setPlotlineSuggestions] = useState<Plotline[] | null>(null)
|
|
459
503
|
/** 剧情健康检查报告(null = 未运行)。 */
|
|
@@ -615,6 +659,8 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
615
659
|
const status = await api.status()
|
|
616
660
|
setConfig(status.config)
|
|
617
661
|
setConfigDraft(status.config)
|
|
662
|
+
setModelCustomMode(!(MODEL_PRESETS as readonly string[]).includes(status.config.model))
|
|
663
|
+
setAuditStatus(status.audit ?? null)
|
|
618
664
|
setProject(status.project ?? null)
|
|
619
665
|
// 人物志存档同步:有 roleStatus 存档直接显示,无需重新刷新计算。
|
|
620
666
|
if (status.project?.roleStatus !== undefined) {
|
|
@@ -774,18 +820,32 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
774
820
|
setBusy(true)
|
|
775
821
|
setBusyLabel('全书一致性质检中…')
|
|
776
822
|
setError('')
|
|
823
|
+
setAuditStatus({ status: 'running', totalBatches: 0, completedBatches: 0 })
|
|
824
|
+
let stopped = false
|
|
825
|
+
const poll = window.setInterval(async () => {
|
|
826
|
+
if (stopped) return
|
|
827
|
+
try {
|
|
828
|
+
const s = await api.status()
|
|
829
|
+
if (s.audit !== undefined) setAuditStatus(s.audit)
|
|
830
|
+
} catch { /* 轮询失败忽略,主请求仍会给出最终结果 */ }
|
|
831
|
+
}, 1000)
|
|
777
832
|
try {
|
|
778
833
|
const result = await api.audit()
|
|
834
|
+
if (stopped) return
|
|
779
835
|
setAuditIssues(result.issues)
|
|
780
836
|
pushProgress(result.issues.length === 0
|
|
781
837
|
? `全书质检完成:${result.auditedChapters} 章未发现矛盾 🎉`
|
|
782
838
|
: `全书质检完成:发现 ${result.issues.length} 处疑似矛盾`, result.issues.length === 0 ? 'done' : 'error')
|
|
783
839
|
} catch (err) {
|
|
840
|
+
if (stopped) return
|
|
784
841
|
setError((err as Error).message)
|
|
785
842
|
pushProgress(`全书质检失败:${(err as Error).message}`, 'error')
|
|
786
843
|
} finally {
|
|
844
|
+
stopped = true
|
|
845
|
+
window.clearInterval(poll)
|
|
787
846
|
setBusy(false)
|
|
788
847
|
setBusyLabel('')
|
|
848
|
+
await refresh(false)
|
|
789
849
|
}
|
|
790
850
|
}
|
|
791
851
|
|
|
@@ -1082,36 +1142,80 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
1082
1142
|
}
|
|
1083
1143
|
}
|
|
1084
1144
|
|
|
1085
|
-
/**
|
|
1086
|
-
const
|
|
1145
|
+
/** 生成单个角色的形象锚点(复用角色库 visual 能力)。 */
|
|
1146
|
+
const handleRoleVisual = async (name: string): Promise<void> => {
|
|
1087
1147
|
setBusy(true)
|
|
1088
|
-
setBusyLabel(
|
|
1148
|
+
setBusyLabel(`生成「${name}」形象锚点…`)
|
|
1089
1149
|
setError('')
|
|
1090
1150
|
try {
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
pushProgress(
|
|
1151
|
+
await api.roles({ op: 'visual', name })
|
|
1152
|
+
await refresh(false)
|
|
1153
|
+
pushProgress(`已生成「${name}」形象锚点`, 'done')
|
|
1094
1154
|
} catch (err) {
|
|
1095
1155
|
setError((err as Error).message)
|
|
1096
|
-
pushProgress(
|
|
1156
|
+
pushProgress(`生成「${name}」形象锚点失败:${(err as Error).message}`, 'error')
|
|
1097
1157
|
} finally {
|
|
1098
1158
|
setBusy(false)
|
|
1099
1159
|
setBusyLabel('')
|
|
1100
1160
|
}
|
|
1101
1161
|
}
|
|
1102
1162
|
|
|
1103
|
-
/**
|
|
1104
|
-
const
|
|
1163
|
+
/** 上传角色图:读取本地图片为 dataURL 并写入角色卡(带用途标签 → 图集)。 */
|
|
1164
|
+
const handleRoleImageUpload = async (name: string, file: File, label = '立绘'): Promise<void> => {
|
|
1165
|
+
const dataUrl = await new Promise<string>((resolve, reject) => {
|
|
1166
|
+
const reader = new FileReader()
|
|
1167
|
+
reader.onload = () => resolve(String(reader.result))
|
|
1168
|
+
reader.onerror = () => reject(reader.error ?? new Error('读取图片失败'))
|
|
1169
|
+
reader.readAsDataURL(file)
|
|
1170
|
+
})
|
|
1105
1171
|
setBusy(true)
|
|
1106
|
-
setBusyLabel(
|
|
1172
|
+
setBusyLabel(`上传「${name}」${label}…`)
|
|
1107
1173
|
setError('')
|
|
1108
1174
|
try {
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
pushProgress(
|
|
1175
|
+
await api.roles({ op: 'image', name, dataUrl, label })
|
|
1176
|
+
await refresh(false)
|
|
1177
|
+
pushProgress(`已上传「${name}」${label}`, 'done')
|
|
1112
1178
|
} catch (err) {
|
|
1113
1179
|
setError((err as Error).message)
|
|
1114
|
-
pushProgress(
|
|
1180
|
+
pushProgress(`上传「${name}」${label}失败:${(err as Error).message}`, 'error')
|
|
1181
|
+
} finally {
|
|
1182
|
+
setBusy(false)
|
|
1183
|
+
setBusyLabel('')
|
|
1184
|
+
setRoleImageTarget(null)
|
|
1185
|
+
if (roleImageInputRef.current !== null) roleImageInputRef.current.value = ''
|
|
1186
|
+
}
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
/** 用豆包/Seedream 为角色生成参考图。 */
|
|
1190
|
+
const handleRoleImageGenerate = async (name: string): Promise<void> => {
|
|
1191
|
+
setBusy(true)
|
|
1192
|
+
setBusyLabel(`用豆包生成「${name}」参考图…`)
|
|
1193
|
+
setError('')
|
|
1194
|
+
try {
|
|
1195
|
+
await api.roles({ op: 'imageGenerate', name })
|
|
1196
|
+
await refresh(false)
|
|
1197
|
+
pushProgress(`已生成「${name}」参考图`, 'done')
|
|
1198
|
+
} catch (err) {
|
|
1199
|
+
setError((err as Error).message)
|
|
1200
|
+
pushProgress(`生成「${name}」参考图失败:${(err as Error).message}`, 'error')
|
|
1201
|
+
} finally {
|
|
1202
|
+
setBusy(false)
|
|
1203
|
+
setBusyLabel('')
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
/** 更新角色漫画重要性(main/support/extra)。 */
|
|
1208
|
+
const handleRoleImportance = async (role: RoleRecord, importance: RoleRecord['importance']): Promise<void> => {
|
|
1209
|
+
setBusy(true)
|
|
1210
|
+
setBusyLabel(`更新「${role.name}」重要性…`)
|
|
1211
|
+
setError('')
|
|
1212
|
+
try {
|
|
1213
|
+
await api.roles({ op: 'update', role: { ...role, importance } })
|
|
1214
|
+
await refresh(false)
|
|
1215
|
+
pushProgress(`已更新「${role.name}」重要性`, 'done')
|
|
1216
|
+
} catch (err) {
|
|
1217
|
+
setError((err as Error).message)
|
|
1218
|
+
pushProgress(`更新「${role.name}」重要性失败:${(err as Error).message}`, 'error')
|
|
1115
1219
|
} finally {
|
|
1116
1220
|
setBusy(false)
|
|
1117
1221
|
setBusyLabel('')
|
|
@@ -1120,7 +1224,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
1120
1224
|
|
|
1121
1225
|
/** 作者复盘补跑:全书缺失章节(流式)。 */
|
|
1122
1226
|
const handleAuthorBackfillAll = async (): Promise<void> => {
|
|
1123
|
-
const missing = chapters.filter(c => c.status !== 'pending' && c.status !== 'generating' && c.authorReview === undefined).length
|
|
1227
|
+
const missing = chapters.filter(c => c.status !== 'pending' && c.status !== 'generating' && c.status !== 'error' && c.authorReview === undefined).length
|
|
1124
1228
|
setBusy(true)
|
|
1125
1229
|
setBusyLabel(`补齐历史章节作者复盘(${missing} 章)…`)
|
|
1126
1230
|
setError('')
|
|
@@ -1966,12 +2070,16 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
1966
2070
|
outputDir: configDraft.outputDir,
|
|
1967
2071
|
provider: configDraft.provider,
|
|
1968
2072
|
model: configDraft.model,
|
|
2073
|
+
reasoningEffort: configDraft.reasoningEffort ?? 'off',
|
|
1969
2074
|
chapterChars: configDraft.chapterChars,
|
|
1970
2075
|
maxTokens: configDraft.maxTokens,
|
|
1971
2076
|
reviewPassScore: configDraft.reviewPassScore,
|
|
1972
2077
|
autoReview: configDraft.autoReview,
|
|
1973
2078
|
autoAuthorReview: configDraft.autoAuthorReview,
|
|
1974
2079
|
autoReviewAfterRevise: configDraft.autoReviewAfterRevise,
|
|
2080
|
+
imageApiKey: configDraft.imageApiKey,
|
|
2081
|
+
imageApiModel: configDraft.imageApiModel,
|
|
2082
|
+
imageApiEnabled: configDraft.imageApiEnabled ?? false,
|
|
1975
2083
|
})
|
|
1976
2084
|
setConfig(result.config)
|
|
1977
2085
|
setConfigDraft(result.config)
|
|
@@ -2057,6 +2165,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
2057
2165
|
// --------------------------------------------------- dashboard (workflow)
|
|
2058
2166
|
const approvedCount = chapters.filter(c => c.status === 'approved').length
|
|
2059
2167
|
const reviewPendingCount = chapters.filter(c => c.status === 'written' || c.status === 'rejected').length
|
|
2168
|
+
const writingNow = chapters.find(c => c.status === 'generating' || c.status === 'reviewing')
|
|
2060
2169
|
const totalChars = chapters.reduce((sum, c) => sum + (c.chars ?? 0), 0)
|
|
2061
2170
|
const firstChapter = chapters[0]
|
|
2062
2171
|
const currentVolumeName = (() => {
|
|
@@ -2081,6 +2190,24 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
2081
2190
|
const journeyPercent = Math.round((journeyDoneCount / journeyStages.length) * 100)
|
|
2082
2191
|
const currentStageId = journeyStages.find(s => !s.done)?.id
|
|
2083
2192
|
|
|
2193
|
+
/** 创作时间线阶段点击 → 跳转对应 tab。 */
|
|
2194
|
+
const jumpToStage = (id: string): void => {
|
|
2195
|
+
switch (id) {
|
|
2196
|
+
case 'outline': setActiveTab('overview'); break
|
|
2197
|
+
case 'bible': setActiveTab('book'); changeBookTab('bible'); break
|
|
2198
|
+
case 'volumes':
|
|
2199
|
+
case 'plan':
|
|
2200
|
+
case 'write': setActiveTab('plan'); break
|
|
2201
|
+
case 'review': setActiveTab('book'); changeBookTab('facts'); changeArchiveTab('reviews'); break
|
|
2202
|
+
}
|
|
2203
|
+
}
|
|
2204
|
+
|
|
2205
|
+
/** 侧栏当前书卡(demo 风格):书架激活书 → 封面首字 / 书名 / 进度。 */
|
|
2206
|
+
const activeBook = shelf?.books.find(b => b.id === shelf?.activeBookId) ?? null
|
|
2207
|
+
const activeBookName = activeBook?.bookName ?? project?.bookName ?? '未选书'
|
|
2208
|
+
const activeBookMeta = activeBook !== null ? `${activeBook.done}/${activeBook.total} 章 · 点按打开书架` : '点按打开书架'
|
|
2209
|
+
const activeBookLetter = activeBookName.trim().charAt(0) || '书'
|
|
2210
|
+
|
|
2084
2211
|
/** 主行动卡片:推荐下一步(AI-Novel-Writing-Assistant 首页主卡模式)。 */
|
|
2085
2212
|
const nextAction = useMemo((): { eyebrow: string; title: string; reason: string; actionLabel: string; onClick: () => void } | null => {
|
|
2086
2213
|
if (project === null) {
|
|
@@ -2267,48 +2394,20 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
2267
2394
|
/>
|
|
2268
2395
|
) : (
|
|
2269
2396
|
<>
|
|
2270
|
-
<div className={css.panelHeader}>
|
|
2271
|
-
<div style={{ display: 'flex', flexDirection: 'column', gap: 2, minWidth: 0 }}>
|
|
2272
|
-
<h2 className={css.panelTitle}>
|
|
2273
|
-
<button type="button" className={css.iconButton} title="返回书架" aria-label="返回书架" onClick={() => { setViewMode('shelf') }}>
|
|
2274
|
-
←
|
|
2275
|
-
</button>
|
|
2276
|
-
{tt('panel.title')}
|
|
2277
|
-
</h2>
|
|
2278
|
-
{(project?.bookName !== '' && project?.bookName !== undefined) && (
|
|
2279
|
-
<div className={css.panelSubtitle}>
|
|
2280
|
-
<span>📖 {project.bookName}</span>
|
|
2281
|
-
{chapters.length > 0 && (
|
|
2282
|
-
<span className={css.headerProgress}>
|
|
2283
|
-
已完成 <b>{doneCount}</b>/{chapters.length} 章
|
|
2284
|
-
<span className={css.headerProgressDot} />
|
|
2285
|
-
通过 <b>{chapters.filter(c => c.status === 'approved').length}</b>
|
|
2286
|
-
</span>
|
|
2287
|
-
)}
|
|
2288
|
-
</div>
|
|
2289
|
-
)}
|
|
2290
|
-
</div>
|
|
2291
|
-
<div style={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
|
2292
|
-
<button
|
|
2293
|
-
type="button"
|
|
2294
|
-
className={css.iconButton}
|
|
2295
|
-
title={navCollapsed ? '展开导航栏' : '收起导航栏'}
|
|
2296
|
-
aria-label={navCollapsed ? '展开导航栏' : '收起导航栏'}
|
|
2297
|
-
onClick={() => {
|
|
2298
|
-
setNavCollapsed(prev => {
|
|
2299
|
-
const next = !prev
|
|
2300
|
-
try { window.localStorage.setItem('dsh-novel-forge.nav.collapsed', String(next)) } catch { /* ignore */ }
|
|
2301
|
-
return next
|
|
2302
|
-
})
|
|
2303
|
-
}}
|
|
2304
|
-
>
|
|
2305
|
-
{navCollapsed ? '▸' : '◂'}
|
|
2306
|
-
</button>
|
|
2307
|
-
<button type="button" className={css.iconButton} title={tt('common.close')} aria-label={tt('common.close')} onClick={() => { controller.close() }}>×</button>
|
|
2308
|
-
</div>
|
|
2309
|
-
</div>
|
|
2310
2397
|
<div className={css.panelBody}>
|
|
2311
2398
|
<nav className={`${css.panelNav} ${navCollapsed ? css.panelNavCollapsed : ''}`} role="tablist" aria-label="工作台导航">
|
|
2399
|
+
{/* 标题区:小说工坊 + 当前书名(顶部标题栏取消后移入侧栏) */}
|
|
2400
|
+
<div className={css.navTitle}>
|
|
2401
|
+
<div className={css.navTitleLogo}>书</div>
|
|
2402
|
+
{!navCollapsed && (
|
|
2403
|
+
<div style={{ minWidth: 0 }}>
|
|
2404
|
+
<div className={css.navTitleName}>小说工坊</div>
|
|
2405
|
+
<div className={css.navTitleBook} title={project?.bookName ?? ''}>
|
|
2406
|
+
{project?.bookName !== undefined && project.bookName !== '' ? `📖 ${project.bookName}` : '未选书'}
|
|
2407
|
+
</div>
|
|
2408
|
+
</div>
|
|
2409
|
+
)}
|
|
2410
|
+
</div>
|
|
2312
2411
|
{/* 分组导航(创作 / 工具 / 数据库) */}
|
|
2313
2412
|
{NAV_GROUPS.map(group => (
|
|
2314
2413
|
<div key={group.id} className={css.navGroup}>
|
|
@@ -2340,7 +2439,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
2340
2439
|
{chapters.some(c => c.status === 'error') ? `!${pendingCount + reviewPendingCount}` : pendingCount + reviewPendingCount}
|
|
2341
2440
|
</span>
|
|
2342
2441
|
)}
|
|
2343
|
-
{tab.id === '
|
|
2442
|
+
{tab.id === 'plotlines' && foreshadows.some(f => f.status === 'planned') && (
|
|
2344
2443
|
<span className={css.navTabBadge}>{foreshadows.filter(f => f.status === 'planned').length}</span>
|
|
2345
2444
|
)}
|
|
2346
2445
|
{tab.id === 'workflow' && chapters.length > 0 && (
|
|
@@ -2374,6 +2473,22 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
2374
2473
|
<span className={css.navTabIcon}>{SETTINGS_TAB.icon}</span>
|
|
2375
2474
|
{!navCollapsed && <span className={css.navTabLabel}>{SETTINGS_TAB.label}</span>}
|
|
2376
2475
|
</button>
|
|
2476
|
+
{/* 当前书切换卡(demo 风格底部内容卡:封面首字 + 书名 + 进度,点击回书架) */}
|
|
2477
|
+
{!navCollapsed && shelf !== null && (
|
|
2478
|
+
<button
|
|
2479
|
+
type="button"
|
|
2480
|
+
className={css.bookSwitch}
|
|
2481
|
+
title={activeBook !== null ? `当前书:${activeBook.bookName}(${activeBook.outputDir})· 点击打开书架` : '打开书架'}
|
|
2482
|
+
onClick={() => { setViewMode('shelf') }}
|
|
2483
|
+
>
|
|
2484
|
+
<span className={css.bookSwitchCover}>{activeBookLetter}</span>
|
|
2485
|
+
<span style={{ minWidth: 0, display: 'flex', flexDirection: 'column' }}>
|
|
2486
|
+
<span className={css.bookSwitchName}>{activeBookName}</span>
|
|
2487
|
+
<span className={css.bookSwitchMeta}>{activeBookMeta}</span>
|
|
2488
|
+
</span>
|
|
2489
|
+
<span className={css.bookSwitchArrow}>⌄</span>
|
|
2490
|
+
</button>
|
|
2491
|
+
)}
|
|
2377
2492
|
{/* 关于:版本 + GitHub + 更新检测 */}
|
|
2378
2493
|
<div className={css.navAbout}>
|
|
2379
2494
|
<button
|
|
@@ -2400,6 +2515,33 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
2400
2515
|
</button>
|
|
2401
2516
|
)}
|
|
2402
2517
|
</div>
|
|
2518
|
+
{/* 操作行:收起导航 / 关闭面板(原顶部按钮下放,收起态保留图标) */}
|
|
2519
|
+
<div className={css.navActions}>
|
|
2520
|
+
<button
|
|
2521
|
+
type="button"
|
|
2522
|
+
className={css.navActionBtn}
|
|
2523
|
+
title={navCollapsed ? '展开导航栏' : '收起导航栏'}
|
|
2524
|
+
aria-label={navCollapsed ? '展开导航栏' : '收起导航栏'}
|
|
2525
|
+
onClick={() => {
|
|
2526
|
+
setNavCollapsed(prev => {
|
|
2527
|
+
const next = !prev
|
|
2528
|
+
try { window.localStorage.setItem('dsh-novel-forge.nav.collapsed', String(next)) } catch { /* ignore */ }
|
|
2529
|
+
return next
|
|
2530
|
+
})
|
|
2531
|
+
}}
|
|
2532
|
+
>
|
|
2533
|
+
{navCollapsed ? '▸' : '◂'}
|
|
2534
|
+
</button>
|
|
2535
|
+
<button
|
|
2536
|
+
type="button"
|
|
2537
|
+
className={css.navActionBtn}
|
|
2538
|
+
title={tt('common.close')}
|
|
2539
|
+
aria-label={tt('common.close')}
|
|
2540
|
+
onClick={() => { controller.close() }}
|
|
2541
|
+
>
|
|
2542
|
+
×
|
|
2543
|
+
</button>
|
|
2544
|
+
</div>
|
|
2403
2545
|
</nav>
|
|
2404
2546
|
<div className={css.panelContent}>
|
|
2405
2547
|
{error !== '' && <div className={css.card} style={{ borderColor: 'var(--nf-error)' }}><span style={{ color: 'var(--nf-error)' }}>{tt('common.error')}: {error}</span></div>}
|
|
@@ -2634,29 +2776,64 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
2634
2776
|
<span className={css.meta} style={{ fontWeight: 600 }}>创作旅程</span>
|
|
2635
2777
|
<span className={css.meta}>{journeyPercent}% · 已完成 {journeyDoneCount}/{journeyStages.length} 步</span>
|
|
2636
2778
|
</div>
|
|
2637
|
-
<div className={css.
|
|
2638
|
-
<div className={css.dashJourneyFill} style={{ width: `${journeyPercent}%` }} />
|
|
2639
|
-
</div>
|
|
2640
|
-
<div className={css.dashJourneyStages}>
|
|
2779
|
+
<div className={css.tlBar}>
|
|
2641
2780
|
{journeyStages.map(stage => (
|
|
2642
|
-
<
|
|
2781
|
+
<button
|
|
2643
2782
|
key={stage.id}
|
|
2644
|
-
|
|
2783
|
+
type="button"
|
|
2784
|
+
className={`${css.tlSeg} ${stage.done ? css.tlSegDone : stage.id === currentStageId ? css.tlSegCurrent : css.tlSegTodo}`}
|
|
2785
|
+
title={`${stage.label}${stage.done ? ' · 已完成' : stage.id === currentStageId ? ' · 进行中' : ' · 未开始'}(点击跳转)`}
|
|
2786
|
+
onClick={() => { jumpToStage(stage.id) }}
|
|
2645
2787
|
>
|
|
2646
|
-
<span className={css.
|
|
2647
|
-
{stage.label}
|
|
2648
|
-
</
|
|
2788
|
+
<span className={css.tlSegTrack}><span className={css.tlSegFill} /></span>
|
|
2789
|
+
<span className={css.tlSegLabel}>{stage.done ? '✓ ' : ''}{stage.label}</span>
|
|
2790
|
+
</button>
|
|
2649
2791
|
))}
|
|
2650
2792
|
</div>
|
|
2651
2793
|
</div>
|
|
2652
2794
|
</div>
|
|
2653
2795
|
|
|
2654
|
-
{/*
|
|
2796
|
+
{/* 统计卡(demo 风格:今日待写 / 写作中 / 已通过 / 待审稿) */}
|
|
2797
|
+
<div className={css.statRowD}>
|
|
2798
|
+
<div className={css.statCardD}>
|
|
2799
|
+
<div style={{ minWidth: 0 }}>
|
|
2800
|
+
<div className={css.statCardDLabel}>今日待写</div>
|
|
2801
|
+
<div className={css.statCardDValue}>{pendingCount} <span className={css.statCardDUnit}>章</span></div>
|
|
2802
|
+
<div className={css.statCardDDetail}>
|
|
2803
|
+
{chapters.some(c => c.status === 'error') ? `含 error ${chapters.filter(c => c.status === 'error').length} 章` : '待生成队列'}
|
|
2804
|
+
</div>
|
|
2805
|
+
</div>
|
|
2806
|
+
<div className={css.statCardDIcon}>📝</div>
|
|
2807
|
+
</div>
|
|
2808
|
+
<div className={css.statCardD}>
|
|
2809
|
+
<div style={{ minWidth: 0 }}>
|
|
2810
|
+
<div className={css.statCardDLabel}>写作中</div>
|
|
2811
|
+
<div className={css.statCardDValue}>{chapters.filter(c => c.status === 'generating' || c.status === 'reviewing').length} <span className={css.statCardDUnit}>章</span></div>
|
|
2812
|
+
<div className={css.statCardDDetail}>{writingNow !== undefined ? `第 ${writingNow.no} 章 · ${writingNow.title}` : '无进行中任务'}</div>
|
|
2813
|
+
</div>
|
|
2814
|
+
<div className={css.statCardDIcon}>✍️</div>
|
|
2815
|
+
</div>
|
|
2816
|
+
<div className={css.statCardD}>
|
|
2817
|
+
<div style={{ minWidth: 0 }}>
|
|
2818
|
+
<div className={css.statCardDLabel}>已通过</div>
|
|
2819
|
+
<div className={css.statCardDValue}>{approvedCount} <span className={css.statCardDUnit}>章</span></div>
|
|
2820
|
+
<div className={css.statCardDDetail}>{chapters.length > 0 ? `占全书 ${Math.round((approvedCount / chapters.length) * 100)}%` : '尚无已通过章节'}</div>
|
|
2821
|
+
</div>
|
|
2822
|
+
<div className={css.statCardDIcon}>✅</div>
|
|
2823
|
+
</div>
|
|
2824
|
+
<div className={css.statCardD}>
|
|
2825
|
+
<div style={{ minWidth: 0 }}>
|
|
2826
|
+
<div className={css.statCardDLabel}>待审稿</div>
|
|
2827
|
+
<div className={css.statCardDValue}>{reviewPendingCount} <span className={css.statCardDUnit}>章</span></div>
|
|
2828
|
+
<div className={`${css.statCardDDetail} ${reviewPendingCount > 0 ? css.statCardDDown : ''}`}>
|
|
2829
|
+
{`written ${chapters.filter(c => c.status === 'written').length} · rejected ${chapters.filter(c => c.status === 'rejected').length}`}
|
|
2830
|
+
</div>
|
|
2831
|
+
</div>
|
|
2832
|
+
<div className={`${css.statCardDIcon} ${css.statCardDIconRed}`}>⚠️</div>
|
|
2833
|
+
</div>
|
|
2834
|
+
</div>
|
|
2835
|
+
{/* 补充统计:总字数 / 当前卷 / 最近创作(原有数据保留) */}
|
|
2655
2836
|
<div className={css.assetGrid}>
|
|
2656
|
-
<StatCell label="全书进度" value={`${doneCount}/${chapters.length} 章`} valueColor="var(--nf-accent)" detail={`${chapters.length > 0 ? Math.round((doneCount / chapters.length) * 100) : 0}%`} />
|
|
2657
|
-
<StatCell label="已通过审稿" value={String(approvedCount)} valueColor={approvedCount > 0 ? 'var(--nf-success)' : undefined} detail="章节已 approved" />
|
|
2658
|
-
<StatCell label="待生成" value={String(pendingCount)} valueColor={pendingCount > 0 ? 'var(--nf-warn)' : undefined} detail="pending + error" />
|
|
2659
|
-
<StatCell label="待审稿" value={String(reviewPendingCount)} valueColor={reviewPendingCount > 0 ? 'var(--nf-info)' : undefined} detail="written + rejected" />
|
|
2660
2837
|
<StatCell label="总字数" value={String(totalChars)} detail="已生成正文累计" />
|
|
2661
2838
|
<StatCell label="当前卷" value={currentVolumeName} valueFontSize={13} detail="正在推进的卷" />
|
|
2662
2839
|
<StatCell label="最近创作" value={lastUpdated} valueFontSize={13} detail="最近生成/编辑时间" />
|
|
@@ -2670,6 +2847,19 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
2670
2847
|
🔍 全书质检
|
|
2671
2848
|
</button>
|
|
2672
2849
|
</div>
|
|
2850
|
+
{auditStatus?.status === 'running' && (
|
|
2851
|
+
<div className={css.meta} style={{ marginTop: 8 }}>
|
|
2852
|
+
🔍 全书质检中:{auditStatus.completedBatches}/{auditStatus.totalBatches > 0 ? auditStatus.totalBatches : '…'} 批
|
|
2853
|
+
{auditStatus.totalBatches > 0 && (
|
|
2854
|
+
<div className={css.dashJourneyBar} style={{ marginTop: 4 }}>
|
|
2855
|
+
<div className={css.dashJourneyFill} style={{ width: `${Math.round((auditStatus.completedBatches / auditStatus.totalBatches) * 100)}%` }} />
|
|
2856
|
+
</div>
|
|
2857
|
+
)}
|
|
2858
|
+
</div>
|
|
2859
|
+
)}
|
|
2860
|
+
{auditStatus?.status === 'error' && auditStatus.error !== undefined && (
|
|
2861
|
+
<div className={css.meta} style={{ color: 'var(--nf-error)', marginTop: 8 }}>全书质检失败:{auditStatus.error}</div>
|
|
2862
|
+
)}
|
|
2673
2863
|
<div className={css.assetGrid}>
|
|
2674
2864
|
<StatCell
|
|
2675
2865
|
label="道藏"
|
|
@@ -2732,7 +2922,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
2732
2922
|
<span className={css.cardTitle}>📋 作者复盘(最近 {Math.min(6, chapters.filter(c => c.authorReview !== undefined).length)} 章)</span>
|
|
2733
2923
|
<div className={css.row}>
|
|
2734
2924
|
{(() => {
|
|
2735
|
-
const missing = chapters.filter(c => c.status !== 'pending' && c.status !== 'generating' && c.authorReview === undefined).length
|
|
2925
|
+
const missing = chapters.filter(c => c.status !== 'pending' && c.status !== 'generating' && c.status !== 'error' && c.authorReview === undefined).length
|
|
2736
2926
|
return missing > 0 && (
|
|
2737
2927
|
<button
|
|
2738
2928
|
type="button"
|
|
@@ -2745,7 +2935,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
2745
2935
|
</button>
|
|
2746
2936
|
)
|
|
2747
2937
|
})()}
|
|
2748
|
-
<button type="button" className={`${css.button} ${css.buttonSmall}`} onClick={() => { setActiveTab('reviews') }} title="查看按卷分组的全部复盘记录">
|
|
2938
|
+
<button type="button" className={`${css.button} ${css.buttonSmall}`} onClick={() => { setActiveTab('book'); changeBookTab('facts'); changeArchiveTab('reviews') }} title="查看按卷分组的全部复盘记录">
|
|
2749
2939
|
查看全部 →
|
|
2750
2940
|
</button>
|
|
2751
2941
|
</div>
|
|
@@ -2918,7 +3108,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
2918
3108
|
{activeTab === 'blurb' && (
|
|
2919
3109
|
<div className={css.card}>
|
|
2920
3110
|
<div className={css.row} style={{ justifyContent: 'space-between' }}>
|
|
2921
|
-
<span className={css.cardTitle}
|
|
3111
|
+
<span className={css.cardTitle}>📖 简介 / 封面</span>
|
|
2922
3112
|
<div className={css.row}>
|
|
2923
3113
|
<button type="button" className={`${css.button} ${css.buttonSmall} ${css.buttonPrimary}`} disabled={busy || project === null} onClick={() => { void handleBlurbGenerate() }}>
|
|
2924
3114
|
✨ AI 生成
|
|
@@ -3183,7 +3373,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
3183
3373
|
)}
|
|
3184
3374
|
</div>
|
|
3185
3375
|
)}
|
|
3186
|
-
{chapter.authorReview === undefined && chapter.status !== 'pending' && chapter.status !== 'generating' && (
|
|
3376
|
+
{chapter.authorReview === undefined && chapter.status !== 'pending' && chapter.status !== 'generating' && chapter.status !== 'error' && (
|
|
3187
3377
|
<div className={css.row}>
|
|
3188
3378
|
<button
|
|
3189
3379
|
type="button"
|
|
@@ -3299,85 +3489,53 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
3299
3489
|
</>
|
|
3300
3490
|
)}
|
|
3301
3491
|
|
|
3302
|
-
{activeTab === '
|
|
3303
|
-
<div className={css.card} style={{
|
|
3492
|
+
{activeTab === 'book' && (
|
|
3493
|
+
<div className={css.card} style={{ gap: 12 }}>
|
|
3304
3494
|
<div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}>
|
|
3305
|
-
<span className={css.cardTitle}
|
|
3306
|
-
<
|
|
3307
|
-
{(() => {
|
|
3308
|
-
const missing = chapters.filter(c => c.status !== 'pending' && c.status !== 'generating' && c.authorReview === undefined).length
|
|
3309
|
-
return missing > 0 && (
|
|
3310
|
-
<button
|
|
3311
|
-
type="button"
|
|
3312
|
-
className={`${css.button} ${css.buttonSmall} ${css.buttonPrimary}`}
|
|
3313
|
-
disabled={busy}
|
|
3314
|
-
onClick={() => { void handleAuthorBackfillAll() }}
|
|
3315
|
-
title="对历史已写章节逐章补跑作者复盘"
|
|
3316
|
-
>
|
|
3317
|
-
↻ 补齐缺失复盘({missing})
|
|
3318
|
-
</button>
|
|
3319
|
-
)
|
|
3320
|
-
})()}
|
|
3321
|
-
</div>
|
|
3495
|
+
<span className={css.cardTitle} style={{ fontSize: 17, fontWeight: 700 }}>📚 本书设定</span>
|
|
3496
|
+
<span className={css.meta}>当前书的知识与资料(参考 AI-Novel-Writing-Assistant:世界观/角色准备收进书内)</span>
|
|
3322
3497
|
</div>
|
|
3323
|
-
{
|
|
3324
|
-
|
|
3325
|
-
|
|
3326
|
-
|
|
3327
|
-
|
|
3328
|
-
|
|
3329
|
-
|
|
3330
|
-
|
|
3331
|
-
|
|
3332
|
-
|
|
3333
|
-
|
|
3334
|
-
|
|
3335
|
-
|
|
3336
|
-
|
|
3337
|
-
|
|
3338
|
-
|
|
3339
|
-
|
|
3340
|
-
|
|
3341
|
-
|
|
3342
|
-
|
|
3343
|
-
|
|
3344
|
-
|
|
3345
|
-
|
|
3346
|
-
|
|
3347
|
-
|
|
3348
|
-
|
|
3349
|
-
|
|
3350
|
-
|
|
3351
|
-
|
|
3352
|
-
|
|
3353
|
-
|
|
3354
|
-
|
|
3355
|
-
|
|
3356
|
-
|
|
3357
|
-
|
|
3358
|
-
|
|
3359
|
-
|
|
3360
|
-
{ar.plotlineProgress !== '' && <span className={css.meta} style={{ marginLeft: 4 }}>{ar.plotlineProgress.slice(0, 40)}{ar.plotlineProgress.length > 40 ? '…' : ''}</span>}
|
|
3361
|
-
</button>
|
|
3362
|
-
{expanded && (
|
|
3363
|
-
<div style={{ padding: '4px 10px 8px', fontSize: 12, display: 'flex', flexDirection: 'column', gap: 3, borderTop: '1px solid var(--nf-border)' }}>
|
|
3364
|
-
{ar.hookNote !== '' && <span className={css.meta}><b>钩子:</b>{ar.hookNote}</span>}
|
|
3365
|
-
{ar.plotlineProgress !== '' && <span className={css.meta}><b>推进:</b>{ar.plotlineProgress}</span>}
|
|
3366
|
-
{ar.continuity !== '' && <span className={css.meta}><b>衔接:</b>{ar.continuity}</span>}
|
|
3367
|
-
{ar.trend !== '' && <span className={css.meta}><b>趋势:</b>{ar.trend}</span>}
|
|
3368
|
-
</div>
|
|
3369
|
-
)}
|
|
3370
|
-
</div>
|
|
3371
|
-
)
|
|
3372
|
-
})}
|
|
3373
|
-
</div>
|
|
3374
|
-
)
|
|
3375
|
-
})}
|
|
3498
|
+
<div className={css.row} style={{ flexWrap: 'wrap', gap: 6 }}>
|
|
3499
|
+
<button
|
|
3500
|
+
type="button"
|
|
3501
|
+
className={`${css.button} ${bookTab === 'bible' ? css.buttonPrimary : ''}`}
|
|
3502
|
+
style={{ fontSize: 14, flex: 1 }}
|
|
3503
|
+
onClick={() => { changeBookTab('bible') }}
|
|
3504
|
+
title="设定库:题材 / 世界观规则 / 人物摘要 / 红线 / 文风"
|
|
3505
|
+
>
|
|
3506
|
+
📖 设定库
|
|
3507
|
+
</button>
|
|
3508
|
+
<button
|
|
3509
|
+
type="button"
|
|
3510
|
+
className={`${css.button} ${bookTab === 'world' ? css.buttonPrimary : ''}`}
|
|
3511
|
+
style={{ fontSize: 14, flex: 1 }}
|
|
3512
|
+
onClick={() => { changeBookTab('world') }}
|
|
3513
|
+
title="大世界:境界体系 / 地理区域 / 势力分布(注入生成与审稿提示词)"
|
|
3514
|
+
>
|
|
3515
|
+
🌍 大世界
|
|
3516
|
+
</button>
|
|
3517
|
+
<button
|
|
3518
|
+
type="button"
|
|
3519
|
+
className={`${css.button} ${bookTab === 'roles' ? css.buttonPrimary : ''}`}
|
|
3520
|
+
style={{ fontSize: 14, flex: 1 }}
|
|
3521
|
+
onClick={() => { changeBookTab('roles') }}
|
|
3522
|
+
title="角色库:全书角色主表(定位 / 关系网 / 成长线 / 知情度)"
|
|
3523
|
+
>
|
|
3524
|
+
👥 角色库({project?.roles?.length ?? 0})
|
|
3525
|
+
</button>
|
|
3526
|
+
<button
|
|
3527
|
+
type="button"
|
|
3528
|
+
className={`${css.button} ${bookTab === 'facts' ? css.buttonPrimary : ''}`}
|
|
3529
|
+
style={{ fontSize: 14, flex: 1 }}
|
|
3530
|
+
onClick={() => { changeBookTab('facts') }}
|
|
3531
|
+
title="编年录与复盘记录"
|
|
3532
|
+
>
|
|
3533
|
+
📚 编年 / 复盘
|
|
3534
|
+
</button>
|
|
3376
3535
|
</div>
|
|
3377
3536
|
</div>
|
|
3378
3537
|
)}
|
|
3379
|
-
|
|
3380
|
-
{activeTab === 'bible' && (
|
|
3538
|
+
{activeTab === 'book' && bookTab === 'bible' && (
|
|
3381
3539
|
<>
|
|
3382
3540
|
<div className={css.card}>
|
|
3383
3541
|
<div className={css.row} style={{ justifyContent: 'space-between' }}>
|
|
@@ -3457,7 +3615,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
3457
3615
|
)}
|
|
3458
3616
|
|
|
3459
3617
|
{/* 大世界:境界体系 / 区域 / 势力 */}
|
|
3460
|
-
{activeTab === 'world' && (
|
|
3618
|
+
{activeTab === 'book' && bookTab === 'world' && (
|
|
3461
3619
|
<WorldTab
|
|
3462
3620
|
api={api}
|
|
3463
3621
|
world={project?.world}
|
|
@@ -3469,8 +3627,8 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
3469
3627
|
)}
|
|
3470
3628
|
|
|
3471
3629
|
{/* 角色库:全书角色主表(独立导航页) */}
|
|
3472
|
-
{activeTab === 'roles' && (
|
|
3473
|
-
<div className={css.card}
|
|
3630
|
+
{activeTab === 'book' && bookTab === 'roles' && (
|
|
3631
|
+
<div className={css.card}>
|
|
3474
3632
|
<div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}>
|
|
3475
3633
|
<span className={css.cardTitle}>👥 角色库({(project?.roles ?? []).length} 个)</span>
|
|
3476
3634
|
<div className={css.row}>
|
|
@@ -3583,63 +3741,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
3583
3741
|
)}
|
|
3584
3742
|
|
|
3585
3743
|
{/* 写作资产 5 个子分类(左侧导航直达) */}
|
|
3586
|
-
{activeTab === '
|
|
3587
|
-
{activeTab === 'assetsProgression' && <AssetsTab api={api} initialTab="progression" />}
|
|
3588
|
-
{activeTab === 'assetsTemplates' && <AssetsTab api={api} initialTab="templates" />}
|
|
3589
|
-
{activeTab === 'assetsRules' && <AssetsTab api={api} initialTab="rules" />}
|
|
3590
|
-
{activeTab === 'assetsStyle' && <AssetsTab api={api} initialTab="style" />}
|
|
3591
|
-
|
|
3592
|
-
{activeTab === 'foreshadow' && (
|
|
3593
|
-
<div className={css.card}>
|
|
3594
|
-
<div className={css.row} style={{ justifyContent: 'space-between' }}>
|
|
3595
|
-
<span className={css.cardTitle}>{tt('foreshadow.title')}({foreshadows.length})</span>
|
|
3596
|
-
<button type="button" className={`${css.button} ${css.buttonPrimary}`} disabled={busy} onClick={() => { void handleSuggestForeshadows() }}>
|
|
3597
|
-
{tt('foreshadow.suggest')}
|
|
3598
|
-
</button>
|
|
3599
|
-
</div>
|
|
3600
|
-
{foreshadows.length === 0 ? (
|
|
3601
|
-
<span className={css.meta}>{tt('foreshadow.none')}</span>
|
|
3602
|
-
) : (
|
|
3603
|
-
<div className={css.chapterList}>
|
|
3604
|
-
{foreshadows.map(f => {
|
|
3605
|
-
const statusLabel = { planned: tt('foreshadow.planned'), planted: tt('foreshadow.planted'), progressing: tt('foreshadow.progressing'), resolved: tt('foreshadow.resolved'), abandoned: tt('foreshadow.abandoned') }[f.status]
|
|
3606
|
-
const statusColor = f.status === 'resolved' ? 'var(--nf-success)' : f.status === 'planted' || f.status === 'progressing' ? 'var(--nf-accent)' : f.status === 'abandoned' ? 'var(--nf-text-3)' : 'var(--nf-info)'
|
|
3607
|
-
return (
|
|
3608
|
-
<div key={f.id} className={css.chapter}>
|
|
3609
|
-
<div className={css.chapterMain}>
|
|
3610
|
-
<div className={css.chapterTitle}>
|
|
3611
|
-
<span>{f.description}</span>
|
|
3612
|
-
</div>
|
|
3613
|
-
<div className={css.meta}>
|
|
3614
|
-
{f.plantedChapter !== undefined && <span>{tt('foreshadow.plantedAt')} 第{f.plantedChapter}章 · </span>}
|
|
3615
|
-
{f.targetChapter !== undefined && <span>{tt('foreshadow.target')} 第{f.targetChapter}章 · </span>}
|
|
3616
|
-
{f.resolvedNote !== undefined && f.resolvedNote !== '' && <span>回收:{f.resolvedNote} · </span>}
|
|
3617
|
-
</div>
|
|
3618
|
-
</div>
|
|
3619
|
-
<span className={css.badge} style={{ borderColor: statusColor, color: statusColor }}>{statusLabel}</span>
|
|
3620
|
-
<div className={css.row} style={{ gap: 4 }}>
|
|
3621
|
-
{f.status === 'planned' && (
|
|
3622
|
-
<button type="button" className={`${css.button} ${css.buttonSmall}`} disabled={busy} onClick={() => {
|
|
3623
|
-
void api.foreshadow({ id: f.id, status: 'planted', plantedChapter: doneCount + 1 }).then(r => setProject(prev => prev === null ? prev : { ...prev, foreshadows: r.foreshadows }))
|
|
3624
|
-
}}>
|
|
3625
|
-
{tt('foreshadow.setPlanted')}
|
|
3626
|
-
</button>
|
|
3627
|
-
)}
|
|
3628
|
-
{(f.status === 'planted' || f.status === 'progressing') && (
|
|
3629
|
-
<button type="button" className={`${css.button} ${css.buttonSmall}`} disabled={busy} onClick={() => {
|
|
3630
|
-
void api.foreshadow({ id: f.id, status: 'resolved', resolvedNote: `第${doneCount}章回收` }).then(r => setProject(prev => prev === null ? prev : { ...prev, foreshadows: r.foreshadows }))
|
|
3631
|
-
}}>
|
|
3632
|
-
{tt('foreshadow.setResolved')}
|
|
3633
|
-
</button>
|
|
3634
|
-
)}
|
|
3635
|
-
</div>
|
|
3636
|
-
</div>
|
|
3637
|
-
)
|
|
3638
|
-
})}
|
|
3639
|
-
</div>
|
|
3640
|
-
)}
|
|
3641
|
-
</div>
|
|
3642
|
-
)}
|
|
3744
|
+
{activeTab === 'assets' && <AssetsTab api={api} />}
|
|
3643
3745
|
|
|
3644
3746
|
{activeTab === 'settings' && configDraft !== null && (
|
|
3645
3747
|
<div className={css.card}>
|
|
@@ -3652,14 +3754,71 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
3652
3754
|
<label className={css.fieldLabel}>{tt('settings.outputDir')}</label>
|
|
3653
3755
|
<input className={css.input} value={configDraft.outputDir} onChange={e => { setConfigDraft({ ...configDraft, outputDir: e.target.value }) }} />
|
|
3654
3756
|
</div>
|
|
3655
|
-
<div className={css.row}>
|
|
3757
|
+
<div className={css.row} style={{ alignItems: 'flex-start' }}>
|
|
3656
3758
|
<div className={css.field} style={{ flex: 1 }}>
|
|
3657
3759
|
<label className={css.fieldLabel}>{tt('settings.provider')}</label>
|
|
3658
3760
|
<input className={css.input} value={configDraft.provider} onChange={e => { setConfigDraft({ ...configDraft, provider: e.target.value }) }} />
|
|
3659
3761
|
</div>
|
|
3660
|
-
<div className={css.field} style={{ flex: 1 }}>
|
|
3762
|
+
<div className={css.field} style={{ flex: 1.4 }}>
|
|
3661
3763
|
<label className={css.fieldLabel}>{tt('settings.model')}</label>
|
|
3662
|
-
<
|
|
3764
|
+
<select
|
|
3765
|
+
className={css.input}
|
|
3766
|
+
value={modelCustomMode ? '__custom__' : configDraft.model}
|
|
3767
|
+
onChange={e => {
|
|
3768
|
+
const v = e.target.value
|
|
3769
|
+
if (v === '__custom__') {
|
|
3770
|
+
setModelCustomMode(true)
|
|
3771
|
+
} else {
|
|
3772
|
+
setModelCustomMode(false)
|
|
3773
|
+
setConfigDraft({ ...configDraft, model: v })
|
|
3774
|
+
}
|
|
3775
|
+
}}
|
|
3776
|
+
>
|
|
3777
|
+
{MODEL_PRESETS.map(m => <option key={m} value={m}>{m}</option>)}
|
|
3778
|
+
<option value="__custom__">{tt('settings.modelCustom')}</option>
|
|
3779
|
+
</select>
|
|
3780
|
+
{modelCustomMode && (
|
|
3781
|
+
<input
|
|
3782
|
+
className={css.input}
|
|
3783
|
+
style={{ marginTop: 6 }}
|
|
3784
|
+
value={configDraft.model}
|
|
3785
|
+
placeholder={tt('settings.modelCustomPlaceholder')}
|
|
3786
|
+
onChange={e => { setConfigDraft({ ...configDraft, model: e.target.value }) }}
|
|
3787
|
+
/>
|
|
3788
|
+
)}
|
|
3789
|
+
</div>
|
|
3790
|
+
<div className={css.field} style={{ flex: 1 }}>
|
|
3791
|
+
<label className={css.fieldLabel}>{tt('settings.reasoningEffort')}</label>
|
|
3792
|
+
<select
|
|
3793
|
+
className={css.input}
|
|
3794
|
+
value={configDraft.reasoningEffort ?? 'off'}
|
|
3795
|
+
onChange={e => { setConfigDraft({ ...configDraft, reasoningEffort: e.target.value as NovelConfig['reasoningEffort'] }) }}
|
|
3796
|
+
>
|
|
3797
|
+
{REASONING_OPTIONS.map(v => <option key={v} value={v}>{tt(`settings.reasoning.${v}`)}</option>)}
|
|
3798
|
+
</select>
|
|
3799
|
+
<span className={css.meta}>{tt('settings.reasoningHint')}</span>
|
|
3800
|
+
</div>
|
|
3801
|
+
</div>
|
|
3802
|
+
<div className={css.row}>
|
|
3803
|
+
<div className={css.field} style={{ flex: 1 }}>
|
|
3804
|
+
<label className={css.fieldLabel}>豆包 API Key(生图)</label>
|
|
3805
|
+
<input className={css.input} type="password" placeholder="ark-..." value={configDraft.imageApiKey ?? ''} onChange={e => { setConfigDraft({ ...configDraft, imageApiKey: e.target.value }) }} />
|
|
3806
|
+
</div>
|
|
3807
|
+
<div className={css.field} style={{ flex: 1 }}>
|
|
3808
|
+
<label className={css.fieldLabel}>豆包生图模型</label>
|
|
3809
|
+
<input className={css.input} placeholder="doubao-seedream-5-0-pro-260628" value={configDraft.imageApiModel ?? ''} onChange={e => { setConfigDraft({ ...configDraft, imageApiModel: e.target.value }) }} />
|
|
3810
|
+
</div>
|
|
3811
|
+
<div className={css.field} style={{ flex: 1 }}>
|
|
3812
|
+
<label className={css.fieldLabel}>启用豆包生图</label>
|
|
3813
|
+
<select
|
|
3814
|
+
className={css.input}
|
|
3815
|
+
value={configDraft.imageApiEnabled ? '1' : '0'}
|
|
3816
|
+
onChange={e => { setConfigDraft({ ...configDraft, imageApiEnabled: e.target.value === '1' }) }}
|
|
3817
|
+
>
|
|
3818
|
+
<option value="0">关(默认)</option>
|
|
3819
|
+
<option value="1">开</option>
|
|
3820
|
+
</select>
|
|
3821
|
+
<span className={css.meta}>开启后漫画工坊才显示「豆包生成」按钮</span>
|
|
3663
3822
|
</div>
|
|
3664
3823
|
</div>
|
|
3665
3824
|
<div className={css.row}>
|
|
@@ -3780,8 +3939,142 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
3780
3939
|
</div>
|
|
3781
3940
|
)}
|
|
3782
3941
|
|
|
3783
|
-
{activeTab === 'facts' && (
|
|
3942
|
+
{activeTab === 'book' && bookTab === 'facts' && (
|
|
3784
3943
|
<div className={css.card}>
|
|
3944
|
+
<div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}>
|
|
3945
|
+
<span className={css.cardTitle} style={{ fontSize: 17, fontWeight: 700 }}>📚 编年 / 复盘</span>
|
|
3946
|
+
</div>
|
|
3947
|
+
<div className={css.row} style={{ flexWrap: 'wrap', gap: 6 }}>
|
|
3948
|
+
<button
|
|
3949
|
+
type="button"
|
|
3950
|
+
className={`${css.button} ${archiveTab === 'facts' ? css.buttonPrimary : ''}`}
|
|
3951
|
+
style={{ fontSize: 14, flex: 1 }}
|
|
3952
|
+
onClick={() => { changeArchiveTab('facts') }}
|
|
3953
|
+
title="编年录:客观事实流水(第 N 章 · 事件),生成/审稿查证用"
|
|
3954
|
+
>
|
|
3955
|
+
📜 编年录({(project?.facts ?? []).length})
|
|
3956
|
+
</button>
|
|
3957
|
+
<button
|
|
3958
|
+
type="button"
|
|
3959
|
+
className={`${css.button} ${archiveTab === 'reviews' ? css.buttonPrimary : ''}`}
|
|
3960
|
+
style={{ fontSize: 14, flex: 1 }}
|
|
3961
|
+
onClick={() => { changeArchiveTab('reviews') }}
|
|
3962
|
+
title="复盘记录:每章作者复盘(钩子 / 推进 / 衔接 / 趋势)"
|
|
3963
|
+
>
|
|
3964
|
+
📋 复盘记录({chapters.filter(c => c.authorReview !== undefined).length} 章)
|
|
3965
|
+
</button>
|
|
3966
|
+
</div>
|
|
3967
|
+
|
|
3968
|
+
{archiveTab === 'reviews' ? (
|
|
3969
|
+
<>
|
|
3970
|
+
<div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}>
|
|
3971
|
+
<span className={css.cardTitle}>📋 复盘记录({chapters.filter(c => c.authorReview !== undefined).length} 章已复盘)</span>
|
|
3972
|
+
<div className={css.row}>
|
|
3973
|
+
{(() => {
|
|
3974
|
+
const missing = chapters.filter(c => c.status !== 'pending' && c.status !== 'generating' && c.status !== 'error' && c.authorReview === undefined).length
|
|
3975
|
+
return missing > 0 && (
|
|
3976
|
+
<button
|
|
3977
|
+
type="button"
|
|
3978
|
+
className={`${css.button} ${css.buttonSmall} ${css.buttonPrimary}`}
|
|
3979
|
+
disabled={busy}
|
|
3980
|
+
onClick={() => { void handleAuthorBackfillAll() }}
|
|
3981
|
+
title="对历史已写章节逐章补跑作者复盘"
|
|
3982
|
+
>
|
|
3983
|
+
↻ 补齐缺失复盘({missing})
|
|
3984
|
+
</button>
|
|
3985
|
+
)
|
|
3986
|
+
})()}
|
|
3987
|
+
</div>
|
|
3988
|
+
</div>
|
|
3989
|
+
{(() => {
|
|
3990
|
+
const reviewed = chapters.filter(c => c.authorReview !== undefined)
|
|
3991
|
+
if (reviewed.length === 0) {
|
|
3992
|
+
return <span className={css.meta}>尚无作者复盘——生成/审稿后自动生成,或点「补齐缺失复盘」为已写章节补跑。</span>
|
|
3993
|
+
}
|
|
3994
|
+
const honored = reviewed.filter(c => c.authorReview!.hookHonored).length
|
|
3995
|
+
const avg = Math.round(reviewed.reduce((s, c) => s + c.authorReview!.endingHook, 0) / reviewed.length * 10) / 10
|
|
3996
|
+
return (
|
|
3997
|
+
<span className={css.meta} style={{ fontWeight: 600 }}>
|
|
3998
|
+
钩子兑现 {honored}/{reviewed.length} · 结尾钩子均分 {avg}/10
|
|
3999
|
+
</span>
|
|
4000
|
+
)
|
|
4001
|
+
})()}
|
|
4002
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, overflowY: 'auto', flex: 1, minHeight: 0 }}>
|
|
4003
|
+
{(() => {
|
|
4004
|
+
const groupsWithReviews = chapterGroups.filter(group => group.chapters.some(c => c.authorReview !== undefined))
|
|
4005
|
+
if (groupsWithReviews.length === 0) return null
|
|
4006
|
+
return (
|
|
4007
|
+
<div className={css.row} style={{ justifyContent: 'flex-end', gap: 6 }}>
|
|
4008
|
+
<button
|
|
4009
|
+
type="button"
|
|
4010
|
+
className={`${css.button} ${css.buttonSmall}`}
|
|
4011
|
+
onClick={() => { setExpandedVolumes(Object.fromEntries(groupsWithReviews.map(g => [g.no, true]))) }}
|
|
4012
|
+
>
|
|
4013
|
+
全部展开
|
|
4014
|
+
</button>
|
|
4015
|
+
<button
|
|
4016
|
+
type="button"
|
|
4017
|
+
className={`${css.button} ${css.buttonSmall}`}
|
|
4018
|
+
onClick={() => { setExpandedVolumes({}) }}
|
|
4019
|
+
>
|
|
4020
|
+
全部折叠
|
|
4021
|
+
</button>
|
|
4022
|
+
</div>
|
|
4023
|
+
)
|
|
4024
|
+
})()}
|
|
4025
|
+
{chapterGroups.map(group => {
|
|
4026
|
+
const groupReviewed = group.chapters.filter(c => c.authorReview !== undefined)
|
|
4027
|
+
if (groupReviewed.length === 0) return null
|
|
4028
|
+
const volumeExpanded = expandedVolumes[group.no] === true
|
|
4029
|
+
return (
|
|
4030
|
+
<div key={group.no} style={{ border: '1px solid var(--nf-border)', borderRadius: 10, overflow: 'hidden' }}>
|
|
4031
|
+
<button
|
|
4032
|
+
type="button"
|
|
4033
|
+
style={{ width: '100%', textAlign: 'left', border: 'none', background: 'transparent', cursor: 'pointer', padding: '8px 12px', fontSize: 13, display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}
|
|
4034
|
+
onClick={() => { setExpandedVolumes(prev => ({ ...prev, [group.no]: !volumeExpanded })) }}
|
|
4035
|
+
>
|
|
4036
|
+
<span style={{ color: 'var(--nf-text-3)' }}>{volumeExpanded ? '▾' : '▸'}</span>
|
|
4037
|
+
<b>{group.title}</b>
|
|
4038
|
+
<span className={css.meta}>已复盘 {groupReviewed.length}/{group.chapters.length} 章</span>
|
|
4039
|
+
</button>
|
|
4040
|
+
{volumeExpanded && (
|
|
4041
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, padding: '4px 8px 8px', borderTop: '1px solid var(--nf-border)' }}>
|
|
4042
|
+
{[...groupReviewed].reverse().map(c => {
|
|
4043
|
+
const ar = c.authorReview!
|
|
4044
|
+
const expanded = expandedReviewChapter === c.no
|
|
4045
|
+
return (
|
|
4046
|
+
<div key={c.no} style={{ border: '1px solid var(--nf-border)', borderRadius: 8, overflow: 'hidden' }}>
|
|
4047
|
+
<button
|
|
4048
|
+
type="button"
|
|
4049
|
+
style={{ width: '100%', textAlign: 'left', border: 'none', background: 'transparent', cursor: 'pointer', padding: '6px 10px', fontSize: 12, display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}
|
|
4050
|
+
onClick={() => { setExpandedReviewChapter(expanded ? null : c.no) }}
|
|
4051
|
+
>
|
|
4052
|
+
<span style={{ color: 'var(--nf-text-3)' }}>{expanded ? '▾' : '▸'}</span>
|
|
4053
|
+
<b>第{c.no}章《{c.title}》</b>
|
|
4054
|
+
<span style={{ color: ar.hookHonored ? 'var(--nf-success)' : 'var(--nf-warn)' }}>钩子{ar.hookHonored ? '✓' : '✗'}</span>
|
|
4055
|
+
<span style={{ color: ar.endingHook >= 6 ? 'var(--nf-success)' : 'var(--nf-error)' }}>结尾钩子 {ar.endingHook}/10</span>
|
|
4056
|
+
{ar.plotlineProgress !== '' && <span className={css.meta} style={{ marginLeft: 4 }}>{ar.plotlineProgress.slice(0, 40)}{ar.plotlineProgress.length > 40 ? '…' : ''}</span>}
|
|
4057
|
+
</button>
|
|
4058
|
+
{expanded && (
|
|
4059
|
+
<div style={{ padding: '4px 10px 8px', fontSize: 12, display: 'flex', flexDirection: 'column', gap: 3, borderTop: '1px solid var(--nf-border)' }}>
|
|
4060
|
+
{ar.hookNote !== '' && <span className={css.meta}><b>钩子:</b>{ar.hookNote}</span>}
|
|
4061
|
+
{ar.plotlineProgress !== '' && <span className={css.meta}><b>推进:</b>{ar.plotlineProgress}</span>}
|
|
4062
|
+
{ar.continuity !== '' && <span className={css.meta}><b>衔接:</b>{ar.continuity}</span>}
|
|
4063
|
+
{ar.trend !== '' && <span className={css.meta}><b>趋势:</b>{ar.trend}</span>}
|
|
4064
|
+
</div>
|
|
4065
|
+
)}
|
|
4066
|
+
</div>
|
|
4067
|
+
)
|
|
4068
|
+
})}
|
|
4069
|
+
</div>
|
|
4070
|
+
)}
|
|
4071
|
+
</div>
|
|
4072
|
+
)
|
|
4073
|
+
})}
|
|
4074
|
+
</div>
|
|
4075
|
+
</>
|
|
4076
|
+
) : (
|
|
4077
|
+
<>
|
|
3785
4078
|
<div className={css.busyRow} style={{ flexWrap: 'wrap' }}>
|
|
3786
4079
|
<span className={css.cardTitle}>{tt('facts.title', { n: (project?.facts ?? []).length })}</span>
|
|
3787
4080
|
<button
|
|
@@ -3809,14 +4102,92 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
3809
4102
|
) : (
|
|
3810
4103
|
<span className={css.meta}>暂无事实条目——写一章后会自动生成,或点击上方「回填」。</span>
|
|
3811
4104
|
)}
|
|
4105
|
+
</>
|
|
4106
|
+
)}
|
|
3812
4107
|
</div>
|
|
3813
4108
|
)}
|
|
3814
4109
|
|
|
3815
4110
|
{activeTab === 'plotlines' && (
|
|
3816
|
-
<div className={css.card}
|
|
4111
|
+
<div className={css.card}>
|
|
3817
4112
|
<div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}>
|
|
3818
|
-
<span className={css.cardTitle}
|
|
3819
|
-
|
|
4113
|
+
<span className={css.cardTitle} style={{ fontSize: 17, fontWeight: 700 }}>📜 长线管理</span>
|
|
4114
|
+
</div>
|
|
4115
|
+
<div className={css.row} style={{ flexWrap: 'wrap', gap: 6 }}>
|
|
4116
|
+
<button
|
|
4117
|
+
type="button"
|
|
4118
|
+
className={`${css.button} ${longlineTab === 'plotlines' ? css.buttonPrimary : ''}`}
|
|
4119
|
+
style={{ fontSize: 14, flex: 1 }}
|
|
4120
|
+
onClick={() => { changeLonglineTab('plotlines') }}
|
|
4121
|
+
title="剧情线:故事明线(主线 / 支线 / 人物 / 悬念)"
|
|
4122
|
+
>
|
|
4123
|
+
🧵 剧情线({project?.plotlines?.length ?? 0})
|
|
4124
|
+
</button>
|
|
4125
|
+
<button
|
|
4126
|
+
type="button"
|
|
4127
|
+
className={`${css.button} ${longlineTab === 'foreshadow' ? css.buttonPrimary : ''}`}
|
|
4128
|
+
style={{ fontSize: 14, flex: 1 }}
|
|
4129
|
+
onClick={() => { changeLonglineTab('foreshadow') }}
|
|
4130
|
+
title="伏笔:道具 / 事件级暗线(埋设 → 回收)"
|
|
4131
|
+
>
|
|
4132
|
+
🔮 伏笔({foreshadows.length})
|
|
4133
|
+
</button>
|
|
4134
|
+
</div>
|
|
4135
|
+
|
|
4136
|
+
{longlineTab === 'foreshadow' ? (
|
|
4137
|
+
<>
|
|
4138
|
+
<div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}>
|
|
4139
|
+
<span className={css.cardTitle}>🔮 {tt('foreshadow.title')}({foreshadows.length})</span>
|
|
4140
|
+
<button type="button" className={`${css.button} ${css.buttonPrimary}`} disabled={busy} onClick={() => { void handleSuggestForeshadows() }}>
|
|
4141
|
+
{tt('foreshadow.suggest')}
|
|
4142
|
+
</button>
|
|
4143
|
+
</div>
|
|
4144
|
+
{foreshadows.length === 0 ? (
|
|
4145
|
+
<span className={css.meta}>{tt('foreshadow.none')}</span>
|
|
4146
|
+
) : (
|
|
4147
|
+
<div className={css.chapterList}>
|
|
4148
|
+
{foreshadows.map(f => {
|
|
4149
|
+
const statusLabel = { planned: tt('foreshadow.planned'), planted: tt('foreshadow.planted'), progressing: tt('foreshadow.progressing'), resolved: tt('foreshadow.resolved'), abandoned: tt('foreshadow.abandoned') }[f.status]
|
|
4150
|
+
const statusColor = f.status === 'resolved' ? 'var(--nf-success)' : f.status === 'planted' || f.status === 'progressing' ? 'var(--nf-accent)' : f.status === 'abandoned' ? 'var(--nf-text-3)' : 'var(--nf-info)'
|
|
4151
|
+
return (
|
|
4152
|
+
<div key={f.id} className={css.chapter}>
|
|
4153
|
+
<div className={css.chapterMain}>
|
|
4154
|
+
<div className={css.chapterTitle}>
|
|
4155
|
+
<span>{f.description}</span>
|
|
4156
|
+
</div>
|
|
4157
|
+
<div className={css.meta}>
|
|
4158
|
+
{f.plantedChapter !== undefined && <span>{tt('foreshadow.plantedAt')} 第{f.plantedChapter}章 · </span>}
|
|
4159
|
+
{f.targetChapter !== undefined && <span>{tt('foreshadow.target')} 第{f.targetChapter}章 · </span>}
|
|
4160
|
+
{f.resolvedNote !== undefined && f.resolvedNote !== '' && <span>回收:{f.resolvedNote} · </span>}
|
|
4161
|
+
</div>
|
|
4162
|
+
</div>
|
|
4163
|
+
<span className={css.badge} style={{ borderColor: statusColor, color: statusColor }}>{statusLabel}</span>
|
|
4164
|
+
<div className={css.row} style={{ gap: 4 }}>
|
|
4165
|
+
{f.status === 'planned' && (
|
|
4166
|
+
<button type="button" className={`${css.button} ${css.buttonSmall}`} disabled={busy} onClick={() => {
|
|
4167
|
+
void api.foreshadow({ id: f.id, status: 'planted', plantedChapter: doneCount + 1 }).then(r => setProject(prev => prev === null ? prev : { ...prev, foreshadows: r.foreshadows }))
|
|
4168
|
+
}}>
|
|
4169
|
+
{tt('foreshadow.setPlanted')}
|
|
4170
|
+
</button>
|
|
4171
|
+
)}
|
|
4172
|
+
{(f.status === 'planted' || f.status === 'progressing') && (
|
|
4173
|
+
<button type="button" className={`${css.button} ${css.buttonSmall}`} disabled={busy} onClick={() => {
|
|
4174
|
+
void api.foreshadow({ id: f.id, status: 'resolved', resolvedNote: `第${doneCount}章回收` }).then(r => setProject(prev => prev === null ? prev : { ...prev, foreshadows: r.foreshadows }))
|
|
4175
|
+
}}>
|
|
4176
|
+
{tt('foreshadow.setResolved')}
|
|
4177
|
+
</button>
|
|
4178
|
+
)}
|
|
4179
|
+
</div>
|
|
4180
|
+
</div>
|
|
4181
|
+
)
|
|
4182
|
+
})}
|
|
4183
|
+
</div>
|
|
4184
|
+
)}
|
|
4185
|
+
</>
|
|
4186
|
+
) : (
|
|
4187
|
+
<>
|
|
4188
|
+
<div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}>
|
|
4189
|
+
<span className={css.cardTitle}>🧵 {tt('tab.plotlines')}({project?.plotlines?.length ?? 0})</span>
|
|
4190
|
+
<div className={css.row} style={{ flexWrap: 'wrap' }}>
|
|
3820
4191
|
<button
|
|
3821
4192
|
type="button"
|
|
3822
4193
|
className={`${css.button} ${css.buttonSmall}`}
|
|
@@ -3945,6 +4316,8 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
3945
4316
|
))}
|
|
3946
4317
|
</div>
|
|
3947
4318
|
)}
|
|
4319
|
+
</>
|
|
4320
|
+
)}
|
|
3948
4321
|
</div>
|
|
3949
4322
|
)}
|
|
3950
4323
|
|
|
@@ -3959,7 +4332,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
3959
4332
|
<div className={css.row} style={{ flexWrap: 'wrap' }}>
|
|
3960
4333
|
<select
|
|
3961
4334
|
className={css.input}
|
|
3962
|
-
style={{ width:
|
|
4335
|
+
style={{ width: 160 }}
|
|
3963
4336
|
value={breakdownScope}
|
|
3964
4337
|
onChange={e => { setBreakdownScope(e.target.value as typeof breakdownScope) }}
|
|
3965
4338
|
title="分析范围"
|
|
@@ -3972,13 +4345,13 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
3972
4345
|
</select>
|
|
3973
4346
|
<select
|
|
3974
4347
|
className={css.input}
|
|
3975
|
-
style={{ width:
|
|
4348
|
+
style={{ width: 150 }}
|
|
3976
4349
|
value={breakdownPreset}
|
|
3977
4350
|
onChange={e => { setBreakdownPreset(e.target.value as 'quick' | 'standard') }}
|
|
3978
4351
|
title="分析档位"
|
|
3979
4352
|
>
|
|
3980
4353
|
<option value="quick">快速(4 维)</option>
|
|
3981
|
-
<option value="standard"
|
|
4354
|
+
<option value="standard">标准+卖点</option>
|
|
3982
4355
|
</select>
|
|
3983
4356
|
<button type="button" className={`${css.button} ${css.buttonPrimary}`} disabled={busy} onClick={() => { void handleBreakdown() }} title="对已写章节做结构/人物/文风/卖点体检(约 1-3 分钟,消耗 LLM 额度)">
|
|
3984
4357
|
{busy ? '⏳ 分析中…' : '✨ 开始拆书'}
|
|
@@ -4019,171 +4392,573 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
4019
4392
|
</div>
|
|
4020
4393
|
)}
|
|
4021
4394
|
|
|
4022
|
-
{activeTab === '
|
|
4023
|
-
<div className={css.card}>
|
|
4395
|
+
{activeTab === 'roleImage' && (
|
|
4396
|
+
<div className={css.card} style={{ flex: 1, minHeight: 0 }}>
|
|
4024
4397
|
<div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}>
|
|
4025
|
-
<span className={css.cardTitle}
|
|
4026
|
-
<
|
|
4027
|
-
<button
|
|
4028
|
-
type="button"
|
|
4029
|
-
className={`${css.button} ${css.buttonSmall} ${storyboardView === 'single' ? css.buttonPrimary : ''}`}
|
|
4030
|
-
onClick={() => { setStoryboardView('single') }}
|
|
4031
|
-
>
|
|
4032
|
-
🎞️ 单集分镜
|
|
4033
|
-
</button>
|
|
4034
|
-
<button
|
|
4035
|
-
type="button"
|
|
4036
|
-
className={`${css.button} ${css.buttonSmall} ${storyboardView === 'plan' ? css.buttonPrimary : ''}`}
|
|
4037
|
-
onClick={() => { setStoryboardView('plan') }}
|
|
4038
|
-
>
|
|
4039
|
-
📺 按卷规划
|
|
4040
|
-
</button>
|
|
4041
|
-
</div>
|
|
4398
|
+
<span className={css.cardTitle} style={{ fontSize: 17, fontWeight: 700 }}>🖼️ 角色形象</span>
|
|
4399
|
+
<span className={css.meta}>角色形象锚点 / 参考图(漫画功能已移除,形象保留用于立绘与一致性)</span>
|
|
4042
4400
|
</div>
|
|
4043
4401
|
|
|
4044
|
-
|
|
4045
|
-
|
|
4046
|
-
|
|
4047
|
-
|
|
4048
|
-
|
|
4049
|
-
|
|
4050
|
-
|
|
4051
|
-
|
|
4052
|
-
|
|
4053
|
-
|
|
4054
|
-
|
|
4055
|
-
|
|
4056
|
-
|
|
4057
|
-
|
|
4058
|
-
|
|
4059
|
-
|
|
4060
|
-
|
|
4061
|
-
|
|
4402
|
+
<input
|
|
4403
|
+
ref={roleImageInputRef}
|
|
4404
|
+
type="file"
|
|
4405
|
+
accept="image/*"
|
|
4406
|
+
style={{ display: 'none' }}
|
|
4407
|
+
onChange={e => {
|
|
4408
|
+
const file = e.target.files?.[0]
|
|
4409
|
+
const target = roleImageTarget
|
|
4410
|
+
if (file === undefined || target === null) return
|
|
4411
|
+
const readDataUrl = (): Promise<string> => new Promise((resolve, reject) => {
|
|
4412
|
+
const reader = new FileReader()
|
|
4413
|
+
reader.onload = () => resolve(String(reader.result))
|
|
4414
|
+
reader.onerror = () => reject(reader.error ?? new Error('读取图片失败'))
|
|
4415
|
+
reader.readAsDataURL(file!)
|
|
4416
|
+
})
|
|
4417
|
+
void (async () => {
|
|
4418
|
+
const dataUrl = await readDataUrl()
|
|
4419
|
+
if (target.endsWith('||scene')) {
|
|
4420
|
+
const sceneName = target.slice(0, -'||scene'.length)
|
|
4421
|
+
setBusy(true)
|
|
4422
|
+
setBusyLabel(`上传场景图「${sceneName}」${roleImageLabel}…`)
|
|
4423
|
+
setError('')
|
|
4424
|
+
try {
|
|
4425
|
+
await api.scenes({ op: 'image', name: sceneName, dataUrl, label: roleImageLabel })
|
|
4426
|
+
await refresh(false)
|
|
4427
|
+
pushProgress(`已上传场景「${sceneName}」${roleImageLabel}`, 'done')
|
|
4428
|
+
} catch (err) {
|
|
4429
|
+
setError((err as Error).message)
|
|
4430
|
+
pushProgress(`场景图上传失败:${(err as Error).message}`, 'error')
|
|
4431
|
+
} finally {
|
|
4432
|
+
setBusy(false)
|
|
4433
|
+
setBusyLabel('')
|
|
4434
|
+
setRoleImageTarget(null)
|
|
4435
|
+
if (roleImageInputRef.current !== null) roleImageInputRef.current.value = ''
|
|
4436
|
+
}
|
|
4437
|
+
} else {
|
|
4438
|
+
await handleRoleImageUpload(target, file, roleImageLabel)
|
|
4439
|
+
}
|
|
4440
|
+
})()
|
|
4441
|
+
}}
|
|
4442
|
+
/>
|
|
4443
|
+
|
|
4444
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, flex: 1, minHeight: 0, overflowY: 'auto' }}>
|
|
4445
|
+
<div className={css.row} style={{ flexWrap: 'wrap', gap: 6, justifyContent: 'space-between' }}>
|
|
4446
|
+
<span className={css.row} style={{ gap: 6 }}>
|
|
4447
|
+
<button type="button" className={`${css.button} ${css.buttonSmall}`} disabled={busy} onClick={() => { void (async () => {
|
|
4448
|
+
setBusy(true)
|
|
4449
|
+
setBusyLabel('⚠️ 提炼本书视觉规则…')
|
|
4450
|
+
setError('')
|
|
4451
|
+
try {
|
|
4452
|
+
const result = await api.visualRules({ op: 'extract' })
|
|
4453
|
+
await refresh(false)
|
|
4454
|
+
pushProgress(`已提炼 ${result.rules.length} 条视觉规则(已注入所有提示词)`, 'done')
|
|
4455
|
+
} catch (err) {
|
|
4456
|
+
setError((err as Error).message)
|
|
4457
|
+
pushProgress(`视觉规则提炼失败:${(err as Error).message}`, 'error')
|
|
4458
|
+
} finally {
|
|
4459
|
+
setBusy(false)
|
|
4460
|
+
setBusyLabel('')
|
|
4461
|
+
}
|
|
4462
|
+
})() }}>⚠️ 提炼视觉规则</button>
|
|
4463
|
+
<button type="button" className={`${css.button} ${css.buttonSmall}`} onClick={() => { void refresh(false); pushProgress('角色库已刷新', 'done') }}>🔄 刷新角色库</button>
|
|
4464
|
+
</span>
|
|
4062
4465
|
</div>
|
|
4063
|
-
<span className={css.meta}>分集原则:高潮章单独成集或两章一集;过渡章 2-3 章合并;断点选在钩子最强处;每集 60-120 秒。生成后逐集确认,满意再用「单集分镜」为某集生成分镜。</span>
|
|
4064
4466
|
|
|
4065
|
-
{
|
|
4066
|
-
<div
|
|
4067
|
-
<span className={css.
|
|
4068
|
-
<span
|
|
4069
|
-
<span className={css.meta}>选卷后点「生成分集计划」,AI 把整卷按故事弧线分成漫剧集</span>
|
|
4467
|
+
{(project?.visualRules ?? []).length > 0 && (
|
|
4468
|
+
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4, alignItems: 'center', marginTop: 6 }}>
|
|
4469
|
+
<span className={css.meta} style={{ fontSize: 11 }}>⚠️ 本书视觉规则:</span>
|
|
4470
|
+
{(project?.visualRules ?? []).map((r, i) => <span key={i} style={{ border: '1px solid var(--nf-warn, #b8860b)', borderRadius: 999, padding: '1px 8px', fontSize: 10, color: 'var(--nf-warn, #b8860b)' }}>{r}</span>)}
|
|
4070
4471
|
</div>
|
|
4071
|
-
)
|
|
4072
|
-
|
|
4073
|
-
|
|
4074
|
-
|
|
4075
|
-
|
|
4076
|
-
|
|
4077
|
-
|
|
4078
|
-
|
|
4079
|
-
|
|
4080
|
-
|
|
4081
|
-
|
|
4472
|
+
)}
|
|
4473
|
+
|
|
4474
|
+
{(() => {
|
|
4475
|
+
const roles = project?.roles ?? []
|
|
4476
|
+
if (roles.length === 0) {
|
|
4477
|
+
return (
|
|
4478
|
+
<div className={css.shelfEmpty} style={{ minHeight: 140, flex: 1 }}>
|
|
4479
|
+
<span className={css.shelfEmptyIcon}>🎭</span>
|
|
4480
|
+
<span className={css.shelfEmptyTitle}>角色库为空</span>
|
|
4481
|
+
<span className={css.meta}>请先到「角色库」提炼角色并生成形象锚点</span>
|
|
4482
|
+
<button type="button" className={`${css.button} ${css.buttonSmall}`} onClick={() => { setActiveTab('roles') }}>去角色库</button>
|
|
4483
|
+
</div>
|
|
4484
|
+
)
|
|
4485
|
+
}
|
|
4486
|
+
const detailRole = detailRoleName !== null ? roles.find(r => r.name === detailRoleName) : undefined
|
|
4487
|
+
const group = (role: RoleRecord) => {
|
|
4488
|
+
const g = role.gallery ?? []
|
|
4489
|
+
const byLabel = (key: string) => g.filter(x => x.label === key)
|
|
4490
|
+
const byPrefix = (pre: string) => g.filter(x => x.label.startsWith(pre))
|
|
4491
|
+
return {
|
|
4492
|
+
portrait: byLabel('立绘')[0],
|
|
4493
|
+
sheet: byLabel('四视图')[0],
|
|
4494
|
+
expressions: byPrefix('表情-'),
|
|
4495
|
+
others: g.filter(x => x.label !== '立绘' && x.label !== '四视图' && !x.label.startsWith('表情-')),
|
|
4496
|
+
}
|
|
4497
|
+
}
|
|
4498
|
+
const uploadInDetail = (role: RoleRecord) => {
|
|
4499
|
+
setRoleImageLabel(detailUploadLabel.trim() !== '' ? detailUploadLabel.trim() : '立绘')
|
|
4500
|
+
setRoleImageTarget(role.name)
|
|
4501
|
+
roleImageInputRef.current?.click()
|
|
4502
|
+
}
|
|
4503
|
+
return (
|
|
4504
|
+
<>
|
|
4505
|
+
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(130px, 1fr))', gap: 10 }}>
|
|
4506
|
+
{roles.map(r => (
|
|
4507
|
+
<div key={r.name} onClick={() => { setDetailRoleName(r.name); setDetailUploadLabel('立绘') }} style={{ cursor: 'pointer', border: '1px solid var(--nf-border)', borderRadius: 12, overflow: 'hidden', background: 'var(--nf-bg-inset)', display: 'flex', flexDirection: 'column' }}>
|
|
4508
|
+
<div style={{ aspectRatio: '3/4', display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'var(--nf-bg-inset)', borderBottom: '1px solid var(--nf-border)', position: 'relative', overflow: 'hidden' }}>
|
|
4509
|
+
{r.imageUrl !== undefined
|
|
4510
|
+
? <img src={r.imageUrl} alt={r.name} style={{ width: '100%', height: '100%', objectFit: 'cover', objectPosition: 'center 20%' }} />
|
|
4511
|
+
: <span className={css.meta}>暂无形象</span>}
|
|
4512
|
+
<span style={{ position: 'absolute', bottom: 4, right: 4, background: 'rgba(0,0,0,0.6)', color: '#fff', fontSize: 10, borderRadius: 6, padding: '1px 6px' }}>🖼 {(r.gallery ?? []).length}</span>
|
|
4513
|
+
</div>
|
|
4514
|
+
<div style={{ padding: 6, display: 'flex', flexDirection: 'column', gap: 2 }}>
|
|
4515
|
+
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
|
4516
|
+
<b style={{ fontSize: 13 }}>{r.name}</b>
|
|
4517
|
+
<span className={css.meta} style={{ fontSize: 10 }}>{r.roleLabel}</span>
|
|
4518
|
+
</div>
|
|
4519
|
+
<div className={css.meta} style={{ fontSize: 10, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
|
4520
|
+
{r.imagePrompt !== undefined ? '✓ 锚点' : '⚠ 无锚点'} · {(r.identity ?? '').slice(0, 16)}
|
|
4521
|
+
</div>
|
|
4522
|
+
</div>
|
|
4523
|
+
</div>
|
|
4524
|
+
))}
|
|
4525
|
+
</div>
|
|
4526
|
+
|
|
4527
|
+
{detailRole !== undefined && (
|
|
4528
|
+
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.55)', zIndex: 1000, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 24 }} onClick={() => { setDetailRoleName(null) }}>
|
|
4529
|
+
<div style={{ background: 'var(--nf-bg)', border: '1px solid var(--nf-border)', borderRadius: 16, width: 'min(960px, 100%)', maxHeight: '92vh', overflowY: 'auto', padding: 20, position: 'relative' }} onClick={e => e.stopPropagation()}>
|
|
4530
|
+
<button type="button" className={css.iconButton} style={{ position: 'absolute', top: 12, right: 12, fontSize: 18 }} title="关闭" aria-label="关闭" onClick={() => { setDetailRoleName(null) }}>✕</button>
|
|
4531
|
+
<div style={{ marginBottom: 12 }}>
|
|
4532
|
+
<div style={{ display: 'flex', alignItems: 'baseline', gap: 10, flexWrap: 'wrap' }}>
|
|
4533
|
+
<b style={{ fontSize: 22 }}>{detailRole.name}</b>
|
|
4534
|
+
<span className={css.badge} style={{ borderColor: 'var(--nf-accent)', color: 'var(--nf-accent)' }}>{detailRole.roleLabel}</span>
|
|
4535
|
+
</div>
|
|
4536
|
+
<div className={css.meta} style={{ marginTop: 4 }}>{detailRole.identity}</div>
|
|
4537
|
+
</div>
|
|
4538
|
+
|
|
4539
|
+
{(project?.visualRules ?? []).length > 0 && (
|
|
4540
|
+
<div style={{ border: '1px solid var(--nf-warn, #b8860b)', borderRadius: 10, padding: '6px 10px', marginBottom: 10, fontSize: 11 }}>
|
|
4541
|
+
<b>⚠️ 本书视觉规则(已内嵌到下方所有提示词)</b>
|
|
4542
|
+
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4, marginTop: 4 }}>
|
|
4543
|
+
{(project?.visualRules ?? []).map((r, i) => <span key={i} style={{ border: '1px solid var(--nf-border)', borderRadius: 999, padding: '1px 8px', fontSize: 10 }}>{r}</span>)}
|
|
4544
|
+
</div>
|
|
4545
|
+
</div>
|
|
4546
|
+
)}
|
|
4547
|
+
|
|
4548
|
+
<details style={{ marginBottom: 10, fontSize: 12 }}>
|
|
4549
|
+
<summary style={{ cursor: 'pointer', color: 'var(--nf-text-2)' }}>🖼 图集({(detailRole.gallery ?? []).length} 张 · 点击展开/收起)</summary>
|
|
4550
|
+
<div style={{ marginTop: 8 }}>
|
|
4551
|
+
{(() => {
|
|
4552
|
+
const g = group(detailRole)
|
|
4553
|
+
const hasMain = g.portrait !== undefined || g.sheet !== undefined
|
|
4554
|
+
return (
|
|
4555
|
+
<>
|
|
4556
|
+
{hasMain && (
|
|
4557
|
+
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(240px, 1fr))', gap: 10, marginBottom: 12 }}>
|
|
4558
|
+
{g.portrait !== undefined && (
|
|
4559
|
+
<div style={{ border: '1px solid var(--nf-border)', borderRadius: 12, overflow: 'hidden' }}>
|
|
4560
|
+
<img src={g.portrait.dataUrl} alt="立绘" style={{ width: '100%', maxHeight: 380, objectFit: 'contain', display: 'block', background: '#111' }} />
|
|
4561
|
+
<div className={css.meta} style={{ padding: '2px 8px', fontSize: 11 }}>立绘</div>
|
|
4562
|
+
</div>
|
|
4563
|
+
)}
|
|
4564
|
+
{g.sheet !== undefined && (
|
|
4565
|
+
<div style={{ border: '1px solid var(--nf-border)', borderRadius: 12, overflow: 'hidden' }}>
|
|
4566
|
+
<img src={g.sheet.dataUrl} alt="四视图" style={{ width: '100%', maxHeight: 380, objectFit: 'contain', display: 'block', background: '#111' }} />
|
|
4567
|
+
<div className={css.meta} style={{ padding: '2px 8px', fontSize: 11 }}>四视图</div>
|
|
4568
|
+
</div>
|
|
4569
|
+
)}
|
|
4570
|
+
</div>
|
|
4571
|
+
)}
|
|
4572
|
+
{g.expressions.length > 0 && (
|
|
4573
|
+
<div style={{ marginBottom: 12 }}>
|
|
4574
|
+
<b style={{ fontSize: 13 }}>表情设定</b>
|
|
4575
|
+
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(110px, 1fr))', gap: 8, marginTop: 6 }}>
|
|
4576
|
+
{g.expressions.map(img => (
|
|
4577
|
+
<div key={img.label} style={{ border: '1px solid var(--nf-border)', borderRadius: 10, overflow: 'hidden', position: 'relative' }}>
|
|
4578
|
+
<img src={img.dataUrl} alt={img.label} style={{ width: '100%', height: 110, objectFit: 'cover', display: 'block' }} />
|
|
4579
|
+
<div style={{ background: 'rgba(0,0,0,0.55)', color: '#fff', fontSize: 10, padding: '1px 6px', textAlign: 'center' }}>{img.label.replace('表情-', '')}</div>
|
|
4580
|
+
<button type="button" className={css.iconButton} style={{ position: 'absolute', top: 2, right: 2, fontSize: 11 }} title="删除" aria-label="删除" onClick={() => { void (async () => {
|
|
4581
|
+
try {
|
|
4582
|
+
await api.roles({ op: 'removeImage', name: detailRole.name, label: img.label })
|
|
4583
|
+
await refresh(false)
|
|
4584
|
+
pushProgress(`已删除「${detailRole.name}」${img.label}`, 'done')
|
|
4585
|
+
} catch (err) { setError((err as Error).message) }
|
|
4586
|
+
})() }}>×</button>
|
|
4587
|
+
</div>
|
|
4588
|
+
))}
|
|
4589
|
+
</div>
|
|
4590
|
+
</div>
|
|
4591
|
+
)}
|
|
4592
|
+
{g.others.length > 0 && (
|
|
4593
|
+
<div style={{ marginBottom: 12 }}>
|
|
4594
|
+
<b style={{ fontSize: 13 }}>场景 / 细节</b>
|
|
4595
|
+
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(140px, 1fr))', gap: 8, marginTop: 6 }}>
|
|
4596
|
+
{g.others.map(img => (
|
|
4597
|
+
<div key={img.label} style={{ border: '1px solid var(--nf-border)', borderRadius: 10, overflow: 'hidden', position: 'relative' }}>
|
|
4598
|
+
<img src={img.dataUrl} alt={img.label} style={{ width: '100%', height: 90, objectFit: 'cover', display: 'block' }} />
|
|
4599
|
+
<div style={{ background: 'rgba(0,0,0,0.55)', color: '#fff', fontSize: 10, padding: '1px 6px', textAlign: 'center' }}>{img.label}</div>
|
|
4600
|
+
<button type="button" className={css.iconButton} style={{ position: 'absolute', top: 2, right: 2, fontSize: 11 }} title="删除" aria-label="删除" onClick={() => { void (async () => {
|
|
4601
|
+
try {
|
|
4602
|
+
await api.roles({ op: 'removeImage', name: detailRole.name, label: img.label })
|
|
4603
|
+
await refresh(false)
|
|
4604
|
+
pushProgress(`已删除「${detailRole.name}」${img.label}`, 'done')
|
|
4605
|
+
} catch (err) { setError((err as Error).message) }
|
|
4606
|
+
})() }}>×</button>
|
|
4607
|
+
</div>
|
|
4608
|
+
))}
|
|
4609
|
+
</div>
|
|
4610
|
+
</div>
|
|
4611
|
+
)}
|
|
4612
|
+
</>
|
|
4613
|
+
)
|
|
4614
|
+
})()}
|
|
4615
|
+
</div>
|
|
4616
|
+
</details>
|
|
4617
|
+
|
|
4618
|
+
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(280px, 1fr))', gap: 14, marginBottom: 12 }}>
|
|
4619
|
+
<div>
|
|
4620
|
+
<b style={{ fontSize: 13 }}>基本信息</b>
|
|
4621
|
+
<div style={{ fontSize: 12, lineHeight: 1.8, marginTop: 4 }}>
|
|
4622
|
+
<div><span className={css.meta}>性格:</span>{(detailRole.traits ?? []).join('、') || '—'}</div>
|
|
4623
|
+
<div><span className={css.meta}>目标:</span>{detailRole.goals || '—'}</div>
|
|
4624
|
+
<div><span className={css.meta}>关系:</span>{(detailRole.relations ?? []).join(';') || '—'}</div>
|
|
4625
|
+
<div><span className={css.meta}>成长线:</span>{(detailRole.arc ?? []).join(';') || '—'}</div>
|
|
4626
|
+
</div>
|
|
4627
|
+
</div>
|
|
4628
|
+
<div>
|
|
4629
|
+
<div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}>
|
|
4630
|
+
<b style={{ fontSize: 13 }}>📄 提示词包</b>
|
|
4631
|
+
{detailRole.imagePrompt !== undefined && detailRole.promptKit === undefined && (
|
|
4632
|
+
<button type="button" className={`${css.button} ${css.buttonSmall}`} disabled={busy} onClick={() => { void (async () => {
|
|
4633
|
+
setBusy(true)
|
|
4634
|
+
setBusyLabel(`精修「${detailRole.name}」提示词…`)
|
|
4635
|
+
setError('')
|
|
4636
|
+
try {
|
|
4637
|
+
await api.roles({ op: 'promptKit', name: detailRole.name })
|
|
4638
|
+
await refresh(false)
|
|
4639
|
+
pushProgress(`已生成「${detailRole.name}」四类精修提示词`, 'done')
|
|
4640
|
+
} catch (err) {
|
|
4641
|
+
setError((err as Error).message)
|
|
4642
|
+
pushProgress(`提示词精修失败:${(err as Error).message}`, 'error')
|
|
4643
|
+
} finally {
|
|
4644
|
+
setBusy(false)
|
|
4645
|
+
setBusyLabel('')
|
|
4646
|
+
}
|
|
4647
|
+
})() }}>✨ 生成精修版</button>
|
|
4648
|
+
)}
|
|
4649
|
+
</div>
|
|
4650
|
+
{detailRole.imagePrompt === undefined && <span className={css.meta}>未生成锚点,先点「✨ 生成锚点」</span>}
|
|
4651
|
+
{(() => {
|
|
4652
|
+
const anchor = detailRole.imagePrompt
|
|
4653
|
+
if (anchor === undefined) return null
|
|
4654
|
+
const rules = project?.visualRules ?? []
|
|
4655
|
+
const rulesZh = rules.length > 0 ? '\n【本书视觉规则】' + rules.join(';') : ''
|
|
4656
|
+
const expressions = detailRole.expressions ?? ['平静']
|
|
4657
|
+
const expName = (n: string) => n.replace(/^表情-/, '')
|
|
4658
|
+
const kit = detailRole.promptKit
|
|
4659
|
+
const blocks: Array<{ key: string; title: string; zh: string; en: string }> = []
|
|
4660
|
+
if (kit !== undefined) {
|
|
4661
|
+
blocks.push({ key: 'portrait', title: '立绘', zh: kit.portrait.zh, en: kit.portrait.en })
|
|
4662
|
+
blocks.push({ key: 'sheet', title: '四视图', zh: kit.sheet.zh, en: kit.sheet.en })
|
|
4663
|
+
for (const e of kit.expressions) blocks.push({ key: 'exp-' + e.name, title: `表情·${e.name}`, zh: e.zh, en: e.en })
|
|
4664
|
+
blocks.push({ key: 'details', title: '细节', zh: kit.details.zh, en: kit.details.en })
|
|
4665
|
+
} else {
|
|
4666
|
+
blocks.push({ key: 'portrait', title: '立绘', zh: anchor.zh + '。全身/半身正视图,写实电影感。' + rulesZh, en: anchor.en + ', full body, front view, plain background' })
|
|
4667
|
+
blocks.push({ key: 'sheet', title: '四视图', zh: anchor.zh + '。同一角色的正面/左侧面/右侧面/背面四视图设定表,纯白背景,四个视角分别描述。' + rulesZh, en: anchor.en + ', character sheet, front view, left side view, right side view, back view, full body, plain white background' })
|
|
4668
|
+
for (const n of expressions) blocks.push({ key: 'exp-' + n, title: `表情·${expName(n)}`, zh: anchor.zh + `。表情:${expName(n)},脸部特写(头部到锁骨),纯白背景,五官与角色定稿完全一致,皮肤纹理细节完整,无多余杂物。` + rulesZh, en: anchor.en + `, facial close-up, head to collarbone, expression: ${expName(n)}, plain white background, consistent with character design, detailed skin texture, no extra objects` })
|
|
4669
|
+
blocks.push({ key: 'details', title: '细节', zh: `多组局部细节集合参考图,纯白背景:${anchor.tags.map(t => t + '特写').join(';')}。细节清晰锐利,角色细节参考稿,无多余杂物。` + rulesZh, en: anchor.tags.join(', ') + ', multi-panel detail reference sheet, plain white background, macro close-up, sharp details, character detail sheet, no extra objects' })
|
|
4670
|
+
}
|
|
4671
|
+
return (
|
|
4672
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginTop: 8 }}>
|
|
4673
|
+
{blocks.map(b => (
|
|
4674
|
+
<div key={b.key} style={{ border: '1px solid var(--nf-border)', borderRadius: 8, padding: '6px 8px' }}>
|
|
4675
|
+
<div className={css.row} style={{ justifyContent: 'space-between' }}>
|
|
4676
|
+
<b style={{ fontSize: 11 }}>{b.title}</b>
|
|
4677
|
+
<span className={css.row} style={{ gap: 4 }}>
|
|
4678
|
+
<button type="button" className={`${css.button} ${css.buttonSmall}`} style={{ fontSize: 10 }} onClick={() => {
|
|
4679
|
+
void navigator.clipboard?.writeText(b.zh).then(() => { pushProgress(`已复制「${detailRole.name}」${b.title}中文提示词`, 'done') }).catch(() => { /* ignore */ })
|
|
4680
|
+
}}>复制</button>
|
|
4681
|
+
<button type="button" className={`${css.button} ${css.buttonSmall}`} style={{ fontSize: 10 }} onClick={() => {
|
|
4682
|
+
void navigator.clipboard?.writeText(b.en).then(() => { pushProgress(`已复制「${detailRole.name}」${b.title}英文标签`, 'done') }).catch(() => { /* ignore */ })
|
|
4683
|
+
}}>EN</button>
|
|
4684
|
+
</span>
|
|
4685
|
+
</div>
|
|
4686
|
+
<div className={css.meta} style={{ fontSize: 11, lineHeight: 1.5, marginTop: 3 }}>{b.zh}</div>
|
|
4687
|
+
<details style={{ marginTop: 3 }}>
|
|
4688
|
+
<summary style={{ cursor: 'pointer', fontSize: 10, color: 'var(--nf-text-2)' }}>🌐 英文标签(点击展开)</summary>
|
|
4689
|
+
<div className={css.meta} style={{ fontSize: 9, fontFamily: 'monospace', marginTop: 2, wordBreak: 'break-all', color: 'var(--nf-text-2)' }}>{b.en}</div>
|
|
4690
|
+
</details>
|
|
4691
|
+
</div>
|
|
4692
|
+
))}
|
|
4693
|
+
<div className={css.meta} style={{ fontSize: 10 }}>关键词:{(anchor.tags ?? []).join('、')}</div>
|
|
4694
|
+
</div>
|
|
4695
|
+
)
|
|
4696
|
+
})()}
|
|
4697
|
+
</div>
|
|
4698
|
+
</div>
|
|
4699
|
+
|
|
4700
|
+
<div style={{ borderTop: '1px solid var(--nf-border)', paddingTop: 10, display: 'flex', flexWrap: 'wrap', gap: 6, alignItems: 'center' }}>
|
|
4701
|
+
{detailRole.imagePrompt === undefined && (
|
|
4702
|
+
<button type="button" className={`${css.button} ${css.buttonSmall}`} disabled={busy} onClick={() => { void handleRoleVisual(detailRole.name) }}>✨ 生成锚点</button>
|
|
4703
|
+
)}
|
|
4704
|
+
{detailRole.imagePrompt !== undefined && config?.imageApiEnabled === true && (
|
|
4705
|
+
<button type="button" className={`${css.button} ${css.buttonSmall}`} disabled={busy} onClick={() => { void handleRoleImageGenerate(detailRole.name) }}>豆包生成</button>
|
|
4706
|
+
)}
|
|
4707
|
+
{detailRole.imagePrompt !== undefined && (
|
|
4708
|
+
<button type="button" className={`${css.button} ${css.buttonSmall}`} onClick={() => {
|
|
4709
|
+
const text = [detailRole.imagePrompt!.en, detailRole.imagePrompt!.tags.join(', ')].filter(Boolean).join('\n')
|
|
4710
|
+
void navigator.clipboard?.writeText(text).then(() => { pushProgress(`已复制「${detailRole.name}」英文生图提示词`, 'done') }).catch(() => { /* ignore */ })
|
|
4711
|
+
}}>复制提示词</button>
|
|
4712
|
+
)}
|
|
4713
|
+
<input
|
|
4714
|
+
className={css.input}
|
|
4715
|
+
style={{ width: 140, fontSize: 12, padding: '3px 6px' }}
|
|
4716
|
+
placeholder="图集标签"
|
|
4717
|
+
value={detailUploadLabel}
|
|
4718
|
+
onChange={e => { setDetailUploadLabel(e.target.value) }}
|
|
4719
|
+
/>
|
|
4720
|
+
<button type="button" className={`${css.button} ${css.buttonSmall}`} disabled={busy} onClick={() => { uploadInDetail(detailRole) }}>📤 上传图</button>
|
|
4721
|
+
<select
|
|
4722
|
+
className={css.input}
|
|
4723
|
+
style={{ fontSize: 12, padding: '3px 4px', width: 90 }}
|
|
4724
|
+
value={detailRole.importance ?? 'support'}
|
|
4725
|
+
onChange={e => { void handleRoleImportance(detailRole, e.target.value as RoleRecord['importance']) }}
|
|
4726
|
+
title="漫剧重要性"
|
|
4727
|
+
>
|
|
4728
|
+
<option value="main">重要</option>
|
|
4729
|
+
<option value="support">配角</option>
|
|
4730
|
+
<option value="extra">路人</option>
|
|
4731
|
+
</select>
|
|
4732
|
+
</div>
|
|
4082
4733
|
</div>
|
|
4083
|
-
<div className={css.meta}>{ep.narrativeJob}</div>
|
|
4084
|
-
<div className={css.meta} style={{ color: 'var(--nf-info)' }}>开头钩子:{ep.openingHook}</div>
|
|
4085
|
-
<div className={css.meta} style={{ color: 'var(--nf-warn)' }}>结尾钩子:{ep.endingHook}</div>
|
|
4086
4734
|
</div>
|
|
4087
|
-
)
|
|
4735
|
+
)}
|
|
4736
|
+
</>
|
|
4737
|
+
)
|
|
4738
|
+
})()}
|
|
4739
|
+
</div>
|
|
4740
|
+
</div>
|
|
4741
|
+
)}
|
|
4742
|
+
|
|
4743
|
+
{activeTab === 'scenes' && (
|
|
4744
|
+
<div className={css.card} style={{ flex: 1, minHeight: 0 }}>
|
|
4745
|
+
<div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}>
|
|
4746
|
+
<span className={css.cardTitle} style={{ fontSize: 17, fontWeight: 700 }}>🏞️ 场景库</span>
|
|
4747
|
+
<span className={css.meta}>场景视觉锚点:提炼自正文,供漫剧分镜/生图锁定"在哪、什么氛围"</span>
|
|
4748
|
+
</div>
|
|
4749
|
+
|
|
4750
|
+
<div className={css.row} style={{ flexWrap: 'wrap', gap: 6, margin: '8px 0' }}>
|
|
4751
|
+
<button type="button" className={`${css.button} ${css.buttonPrimary}`} disabled={busy} onClick={() => { void (async () => {
|
|
4752
|
+
setBusy(true)
|
|
4753
|
+
setBusyLabel('✨ 从全书提炼场景…')
|
|
4754
|
+
setError('')
|
|
4755
|
+
try {
|
|
4756
|
+
const result = await api.scenes({ op: 'extract' })
|
|
4757
|
+
setSceneCandidates(result.candidates ?? [])
|
|
4758
|
+
pushProgress(`提炼出 ${(result.candidates ?? []).length} 个候选场景,可采纳或修改后采纳`, 'done')
|
|
4759
|
+
} catch (err) {
|
|
4760
|
+
setError((err as Error).message)
|
|
4761
|
+
pushProgress(`场景提炼失败:${(err as Error).message}`, 'error')
|
|
4762
|
+
} finally {
|
|
4763
|
+
setBusy(false)
|
|
4764
|
+
setBusyLabel('')
|
|
4765
|
+
}
|
|
4766
|
+
})() }}>{busy ? '⏳ 提炼中…' : '✨ 从全书提炼场景'}</button>
|
|
4767
|
+
<button type="button" className={`${css.button} ${css.buttonSmall}`} onClick={() => { void refresh(false); pushProgress('场景库已刷新', 'done') }}>🔄 刷新</button>
|
|
4768
|
+
{sceneCandidates !== null && (
|
|
4769
|
+
<button type="button" className={`${css.button} ${css.buttonSmall}`} onClick={() => { setSceneCandidates(null) }}>收起候选</button>
|
|
4770
|
+
)}
|
|
4771
|
+
</div>
|
|
4772
|
+
|
|
4773
|
+
{sceneDraft !== null && (
|
|
4774
|
+
<div style={{ border: '1px solid var(--nf-accent)', borderRadius: 10, padding: 10, marginBottom: 10, display: 'flex', flexDirection: 'column', gap: 6 }}>
|
|
4775
|
+
<b style={{ fontSize: 13 }}>编辑场景</b>
|
|
4776
|
+
<input className={css.input} style={{ fontSize: 12, padding: '4px 8px' }} placeholder="场景名" value={sceneDraft.name} onChange={e => { setSceneDraft({ ...sceneDraft, name: e.target.value }) }} />
|
|
4777
|
+
<input className={css.input} style={{ fontSize: 12, padding: '4px 8px' }} placeholder="一句话定位" value={sceneDraft.summary} onChange={e => { setSceneDraft({ ...sceneDraft, summary: e.target.value }) }} />
|
|
4778
|
+
<div className={css.row} style={{ gap: 6 }}>
|
|
4779
|
+
<input className={css.input} style={{ fontSize: 12, padding: '4px 8px', flex: 1 }} placeholder="幕归属(第一幕后场…)" value={sceneDraft.act ?? ''} onChange={e => { setSceneDraft({ ...sceneDraft, act: e.target.value }) }} />
|
|
4780
|
+
<input className={css.input} style={{ fontSize: 12, padding: '4px 8px', flex: 1 }} placeholder="时间光态(雨夜/闭店后…)" value={sceneDraft.moment ?? ''} onChange={e => { setSceneDraft({ ...sceneDraft, moment: e.target.value }) }} />
|
|
4781
|
+
</div>
|
|
4782
|
+
<textarea className={css.input} style={{ fontSize: 12, padding: '4px 8px', minHeight: 48 }} placeholder="关键镜头(每行一条:人物动作+情绪+镜头)" value={(sceneDraft.beats ?? []).join('\n')} onChange={e => { setSceneDraft({ ...sceneDraft, beats: e.target.value.split(/\n+/).map(x => x.trim()).filter(Boolean) }) }} />
|
|
4783
|
+
<input className={css.input} style={{ fontSize: 12, padding: '4px 8px' }} placeholder="人物在场状态(含标志物细节)" value={sceneDraft.characterState ?? ''} onChange={e => { setSceneDraft({ ...sceneDraft, characterState: e.target.value }) }} />
|
|
4784
|
+
<textarea className={css.input} style={{ fontSize: 12, padding: '4px 8px', minHeight: 60 }} placeholder="环境构成(每行一项)" value={(sceneDraft.elements ?? []).join('\n')} onChange={e => { setSceneDraft({ ...sceneDraft, elements: e.target.value.split(/\n+/).map(x => x.trim()).filter(Boolean) }) }} />
|
|
4785
|
+
<textarea className={css.input} style={{ fontSize: 12, padding: '4px 8px', minHeight: 60 }} placeholder="中文生图提示词" value={sceneDraft.zh} onChange={e => { setSceneDraft({ ...sceneDraft, zh: e.target.value }) }} />
|
|
4786
|
+
<textarea className={css.input} style={{ fontSize: 12, padding: '4px 8px', minHeight: 60 }} placeholder="英文生图提示词" value={sceneDraft.en} onChange={e => { setSceneDraft({ ...sceneDraft, en: e.target.value }) }} />
|
|
4787
|
+
<div className={css.row} style={{ gap: 6 }}>
|
|
4788
|
+
<button type="button" className={`${css.button} ${css.buttonPrimary}`} disabled={busy || sceneDraft.name.trim() === ''} onClick={() => { void (async () => {
|
|
4789
|
+
setBusy(true)
|
|
4790
|
+
try {
|
|
4791
|
+
await api.scenes({ op: 'update', scene: { ...sceneDraft, name: sceneDraft.name.trim() } })
|
|
4792
|
+
await refresh(false)
|
|
4793
|
+
setSceneDraft(null)
|
|
4794
|
+
pushProgress(`已保存场景「${sceneDraft.name.trim()}」`, 'done')
|
|
4795
|
+
} catch (err) {
|
|
4796
|
+
setError((err as Error).message)
|
|
4797
|
+
} finally {
|
|
4798
|
+
setBusy(false)
|
|
4799
|
+
}
|
|
4800
|
+
})() }}>保存</button>
|
|
4801
|
+
<button type="button" className={`${css.button} ${css.buttonSmall}`} onClick={() => { setSceneDraft(null) }}>取消</button>
|
|
4802
|
+
</div>
|
|
4803
|
+
</div>
|
|
4804
|
+
)}
|
|
4805
|
+
|
|
4806
|
+
{sceneCandidates !== null && sceneCandidates.length > 0 && (
|
|
4807
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, marginBottom: 10 }}>
|
|
4808
|
+
{sceneCandidates.map(s => (
|
|
4809
|
+
<div key={s.name} style={{ border: '1px solid var(--nf-accent)', borderRadius: 10, padding: '8px 10px', fontSize: 12 }}>
|
|
4810
|
+
<div className={css.row} style={{ flexWrap: 'wrap', gap: 6, justifyContent: 'space-between' }}>
|
|
4811
|
+
<span><b>{s.name}</b> <span className={css.meta}>· {(s.moods ?? []).join('、')}</span></span>
|
|
4812
|
+
<span className={css.row} style={{ gap: 4 }}>
|
|
4813
|
+
<button type="button" className={`${css.button} ${css.buttonSmall}`} onClick={() => { void (async () => {
|
|
4814
|
+
try {
|
|
4815
|
+
await api.scenes({ op: 'adopt', scene: s })
|
|
4816
|
+
setSceneCandidates((sceneCandidates ?? []).filter(x => x.name !== s.name))
|
|
4817
|
+
await refresh(false)
|
|
4818
|
+
pushProgress(`已采纳场景「${s.name}」`, 'done')
|
|
4819
|
+
} catch (err) { setError((err as Error).message) }
|
|
4820
|
+
})() }}>采纳</button>
|
|
4821
|
+
<button type="button" className={`${css.button} ${css.buttonSmall}`} onClick={() => { setSceneDraft(s); setSceneCandidates(null) }}>修改后采纳</button>
|
|
4822
|
+
</span>
|
|
4088
4823
|
</div>
|
|
4824
|
+
<div className={css.meta} style={{ marginTop: 2 }}>{s.summary}</div>
|
|
4825
|
+
<div className={css.meta} style={{ marginTop: 2, fontSize: 11 }}>要素:{(s.elements ?? []).join(';')}</div>
|
|
4089
4826
|
</div>
|
|
4090
|
-
)}
|
|
4827
|
+
))}
|
|
4091
4828
|
</div>
|
|
4092
4829
|
)}
|
|
4093
4830
|
|
|
4094
|
-
{
|
|
4095
|
-
|
|
4096
|
-
|
|
4097
|
-
|
|
4098
|
-
|
|
4099
|
-
|
|
4100
|
-
|
|
4101
|
-
|
|
4102
|
-
|
|
4103
|
-
|
|
4104
|
-
|
|
4105
|
-
|
|
4106
|
-
|
|
4107
|
-
|
|
4108
|
-
|
|
4109
|
-
|
|
4110
|
-
|
|
4111
|
-
|
|
4112
|
-
|
|
4113
|
-
|
|
4114
|
-
|
|
4115
|
-
|
|
4116
|
-
|
|
4117
|
-
|
|
4118
|
-
|
|
4119
|
-
|
|
4120
|
-
|
|
4121
|
-
|
|
4122
|
-
|
|
4123
|
-
|
|
4124
|
-
|
|
4831
|
+
{(() => {
|
|
4832
|
+
const scenes = project?.scenes ?? []
|
|
4833
|
+
const detail = sceneDetailName !== null ? scenes.find(s => s.name === sceneDetailName) : undefined
|
|
4834
|
+
if (scenes.length === 0 && sceneCandidates === null) {
|
|
4835
|
+
return (
|
|
4836
|
+
<div className={css.shelfEmpty} style={{ minHeight: 140, flex: 1 }}>
|
|
4837
|
+
<span className={css.shelfEmptyIcon}>🏞️</span>
|
|
4838
|
+
<span className={css.shelfEmptyTitle}>场景库为空</span>
|
|
4839
|
+
<span className={css.meta}>点「✨ 从全书提炼场景」自动建立,或手动新增</span>
|
|
4840
|
+
</div>
|
|
4841
|
+
)
|
|
4842
|
+
}
|
|
4843
|
+
return (
|
|
4844
|
+
<>
|
|
4845
|
+
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(180px, 1fr))', gap: 10 }}>
|
|
4846
|
+
{scenes.map(s => {
|
|
4847
|
+
const cover = (s.gallery ?? [])[0]
|
|
4848
|
+
return (
|
|
4849
|
+
<div key={s.name} onClick={() => { setSceneDetailName(s.name); setSceneUploadLabel('全景') }} style={{ cursor: 'pointer', border: '1px solid var(--nf-border)', borderRadius: 12, overflow: 'hidden', background: 'var(--nf-bg-inset)', display: 'flex', flexDirection: 'column' }}>
|
|
4850
|
+
<div style={{ height: 100, display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'var(--nf-bg-inset)', borderBottom: '1px solid var(--nf-border)', position: 'relative' }}>
|
|
4851
|
+
{cover !== undefined
|
|
4852
|
+
? <img src={cover.dataUrl} alt={s.name} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
|
4853
|
+
: <span className={css.meta}>暂无场景图</span>}
|
|
4854
|
+
<span style={{ position: 'absolute', bottom: 4, right: 4, background: 'rgba(0,0,0,0.6)', color: '#fff', fontSize: 10, borderRadius: 6, padding: '1px 6px' }}>🖼 {(s.gallery ?? []).length}</span>
|
|
4855
|
+
</div>
|
|
4856
|
+
<div style={{ padding: 6, display: 'flex', flexDirection: 'column', gap: 2 }}>
|
|
4857
|
+
<b style={{ fontSize: 13 }}>{s.name}</b>
|
|
4858
|
+
<div className={css.meta} style={{ fontSize: 10 }}>{(s.moods ?? []).join('、') || s.summary.slice(0, 16)}</div>
|
|
4859
|
+
</div>
|
|
4860
|
+
</div>
|
|
4861
|
+
)
|
|
4862
|
+
})}
|
|
4125
4863
|
</div>
|
|
4126
|
-
</div>
|
|
4127
4864
|
|
|
4128
|
-
|
|
4129
|
-
|
|
4130
|
-
|
|
4131
|
-
|
|
4132
|
-
|
|
4133
|
-
|
|
4134
|
-
|
|
4135
|
-
|
|
4136
|
-
<div className={css.meta}><b>本集:</b>{storyboardResult.title}</div>
|
|
4137
|
-
<div className={css.meta}><b>赛道节奏:</b>{storyboardResult.pacingNote}</div>
|
|
4138
|
-
<div className={css.meta}><b>本集钩子:</b>{storyboardResult.hook}</div>
|
|
4865
|
+
{detail !== undefined && (
|
|
4866
|
+
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.55)', zIndex: 1000, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 24 }} onClick={() => { setSceneDetailName(null) }}>
|
|
4867
|
+
<div style={{ background: 'var(--nf-bg)', border: '1px solid var(--nf-border)', borderRadius: 16, width: 'min(860px, 100%)', maxHeight: '92vh', overflowY: 'auto', padding: 20, position: 'relative' }} onClick={e => e.stopPropagation()}>
|
|
4868
|
+
<button type="button" className={css.iconButton} style={{ position: 'absolute', top: 12, right: 12, fontSize: 18 }} title="关闭" aria-label="关闭" onClick={() => { setSceneDetailName(null) }}>✕</button>
|
|
4869
|
+
<div style={{ marginBottom: 12 }}>
|
|
4870
|
+
<b style={{ fontSize: 20 }}>{detail.name}</b>
|
|
4871
|
+
<div className={css.meta} style={{ marginTop: 4 }}>{detail.summary}</div>
|
|
4872
|
+
</div>
|
|
4139
4873
|
|
|
4140
|
-
|
|
4141
|
-
|
|
4142
|
-
|
|
4143
|
-
|
|
4144
|
-
|
|
4145
|
-
|
|
4146
|
-
|
|
4147
|
-
|
|
4148
|
-
|
|
4149
|
-
|
|
4150
|
-
|
|
4151
|
-
|
|
4874
|
+
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(160px, 1fr))', gap: 8, marginBottom: 12 }}>
|
|
4875
|
+
{(detail.gallery ?? []).map(img => (
|
|
4876
|
+
<div key={img.label} style={{ border: '1px solid var(--nf-border)', borderRadius: 10, overflow: 'hidden', position: 'relative' }}>
|
|
4877
|
+
<img src={img.dataUrl} alt={img.label} style={{ width: '100%', height: 100, objectFit: 'cover', display: 'block' }} />
|
|
4878
|
+
<div style={{ background: 'rgba(0,0,0,0.55)', color: '#fff', fontSize: 10, padding: '1px 6px', textAlign: 'center' }}>{img.label}</div>
|
|
4879
|
+
<button type="button" className={css.iconButton} style={{ position: 'absolute', top: 2, right: 2, fontSize: 11 }} title="删除" aria-label="删除" onClick={() => { void (async () => {
|
|
4880
|
+
try {
|
|
4881
|
+
await api.scenes({ op: 'removeImage', name: detail.name, label: img.label })
|
|
4882
|
+
await refresh(false)
|
|
4883
|
+
pushProgress(`已删除「${detail.name}」${img.label}`, 'done')
|
|
4884
|
+
} catch (err) { setError((err as Error).message) }
|
|
4885
|
+
})() }}>×</button>
|
|
4886
|
+
</div>
|
|
4887
|
+
))}
|
|
4888
|
+
{(detail.gallery ?? []).length === 0 && <span className={css.meta}>暂无场景图</span>}
|
|
4889
|
+
</div>
|
|
4152
4890
|
|
|
4153
|
-
|
|
4154
|
-
|
|
4155
|
-
|
|
4156
|
-
|
|
4157
|
-
|
|
4158
|
-
|
|
4159
|
-
|
|
4160
|
-
|
|
4161
|
-
|
|
4162
|
-
|
|
4163
|
-
|
|
4164
|
-
|
|
4165
|
-
|
|
4166
|
-
|
|
4167
|
-
|
|
4168
|
-
|
|
4169
|
-
|
|
4170
|
-
|
|
4171
|
-
|
|
4172
|
-
|
|
4173
|
-
|
|
4891
|
+
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(260px, 1fr))', gap: 14, marginBottom: 12 }}>
|
|
4892
|
+
<div style={{ fontSize: 12, lineHeight: 1.8 }}>
|
|
4893
|
+
{(detail.act !== undefined || detail.moment !== undefined) && (
|
|
4894
|
+
<div style={{ marginBottom: 6 }}>
|
|
4895
|
+
{(detail.act !== undefined && detail.act !== '') && <span className={css.badge} style={{ borderColor: 'var(--nf-accent)', color: 'var(--nf-accent)', marginRight: 6 }}>{detail.act}</span>}
|
|
4896
|
+
{(detail.moment !== undefined && detail.moment !== '') && <span className={css.meta}>{detail.moment}</span>}
|
|
4897
|
+
</div>
|
|
4898
|
+
)}
|
|
4899
|
+
{(detail.beats ?? []).length > 0 && (
|
|
4900
|
+
<div style={{ marginBottom: 8 }}>
|
|
4901
|
+
<b style={{ fontSize: 13 }}>关键镜头</b>
|
|
4902
|
+
<div style={{ marginTop: 4 }}>{(detail.beats ?? []).map((b, i) => <div key={i} style={{ fontSize: 11, color: 'var(--nf-text-2)', marginTop: 2 }}>🎬 {b}</div>)}</div>
|
|
4903
|
+
</div>
|
|
4904
|
+
)}
|
|
4905
|
+
{detail.characterState !== undefined && detail.characterState !== '' && (
|
|
4906
|
+
<div style={{ marginBottom: 8 }}>
|
|
4907
|
+
<b style={{ fontSize: 13 }}>人物状态</b>
|
|
4908
|
+
<div className={css.meta} style={{ marginTop: 2 }}>{detail.characterState}</div>
|
|
4909
|
+
</div>
|
|
4910
|
+
)}
|
|
4911
|
+
<b style={{ fontSize: 13 }}>环境构成</b>
|
|
4912
|
+
<div style={{ marginTop: 4 }}>{(detail.elements ?? []).map(e => <span key={e} style={{ border: '1px solid var(--nf-border)', borderRadius: 999, padding: '1px 8px', fontSize: 11, margin: '0 4px 4px 0', display: 'inline-block' }}>{e}</span>)}</div>
|
|
4913
|
+
<div style={{ marginTop: 8 }}><b style={{ fontSize: 13 }}>色调光影</b></div>
|
|
4914
|
+
<div style={{ marginTop: 4 }}>{(detail.palette ?? []).join(';') || '—'}</div>
|
|
4915
|
+
<div style={{ marginTop: 8 }}><b style={{ fontSize: 13 }}>氛围</b></div>
|
|
4916
|
+
<div style={{ marginTop: 4 }}>{(detail.moods ?? []).join('、') || '—'}</div>
|
|
4917
|
+
<div style={{ marginTop: 8 }}><b style={{ fontSize: 13 }}>依据</b></div>
|
|
4918
|
+
<div className={css.meta} style={{ marginTop: 4 }}>{detail.source || '—'}</div>
|
|
4919
|
+
</div>
|
|
4920
|
+
<div>
|
|
4921
|
+
<b style={{ fontSize: 13 }}>生图提示词</b>
|
|
4922
|
+
<div className={css.meta} style={{ fontSize: 11, lineHeight: 1.6, marginTop: 4 }}>{detail.zh}</div>
|
|
4923
|
+
<div className={css.meta} style={{ fontSize: 10, fontFamily: 'monospace', marginTop: 4, wordBreak: 'break-all' }}>{detail.en}</div>
|
|
4924
|
+
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4, marginTop: 8 }}>
|
|
4925
|
+
{(detail.tags ?? []).map(t => <span key={t} style={{ border: '1px solid var(--nf-border)', borderRadius: 999, padding: '2px 8px', fontSize: 11 }}>{t}</span>)}
|
|
4926
|
+
</div>
|
|
4927
|
+
</div>
|
|
4928
|
+
</div>
|
|
4929
|
+
|
|
4930
|
+
<div style={{ borderTop: '1px solid var(--nf-border)', paddingTop: 10, display: 'flex', flexWrap: 'wrap', gap: 6, alignItems: 'center' }}>
|
|
4931
|
+
<button type="button" className={`${css.button} ${css.buttonSmall}`} onClick={() => {
|
|
4932
|
+
const text = [detail.en, detail.tags.join(', ')].filter(Boolean).join('\n')
|
|
4933
|
+
void navigator.clipboard?.writeText(text).then(() => { pushProgress(`已复制「${detail.name}」英文生图提示词`, 'done') }).catch(() => { /* ignore */ })
|
|
4934
|
+
}}>复制提示词</button>
|
|
4935
|
+
<input className={css.input} style={{ width: 130, fontSize: 12, padding: '3px 6px' }} placeholder="图集标签" value={sceneUploadLabel} onChange={e => { setSceneUploadLabel(e.target.value) }} />
|
|
4936
|
+
<button type="button" className={`${css.button} ${css.buttonSmall}`} disabled={busy} onClick={() => {
|
|
4937
|
+
setRoleImageLabel(sceneUploadLabel.trim() !== '' ? sceneUploadLabel.trim() : '全景')
|
|
4938
|
+
setSceneUploadLabel(sceneUploadLabel.trim() !== '' ? sceneUploadLabel.trim() : '全景')
|
|
4939
|
+
setRoleImageTarget(detail.name + '||scene')
|
|
4940
|
+
roleImageInputRef.current?.click()
|
|
4941
|
+
}}>📤 上传场景图</button>
|
|
4942
|
+
<button type="button" className={`${css.button} ${css.buttonSmall}`} onClick={() => { setSceneDraft(detail); setSceneDetailName(null) }}>✏️ 编辑</button>
|
|
4943
|
+
<button type="button" className={`${css.button} ${css.buttonSmall}`} style={{ color: 'var(--nf-danger, #e05)' }} onClick={() => { void (async () => {
|
|
4944
|
+
if (!window.confirm(`确定删除场景「${detail.name}」?`)) return
|
|
4945
|
+
try {
|
|
4946
|
+
await api.scenes({ op: 'remove', name: detail.name })
|
|
4947
|
+
await refresh(false)
|
|
4948
|
+
setSceneDetailName(null)
|
|
4949
|
+
pushProgress(`已删除场景「${detail.name}」`, 'done')
|
|
4950
|
+
} catch (err) { setError((err as Error).message) }
|
|
4951
|
+
})() }}>🗑 删除</button>
|
|
4952
|
+
</div>
|
|
4174
4953
|
</div>
|
|
4175
|
-
<div className={css.meta}>{p.visual}</div>
|
|
4176
|
-
{p.dialogue !== '' && <div className={css.meta} style={{ color: 'var(--nf-info)' }}>💬 {p.dialogue}</div>}
|
|
4177
|
-
<div className={css.meta} style={{ fontFamily: 'monospace', fontSize: 11, color: 'var(--nf-text-2)' }}>{p.prompt}</div>
|
|
4178
4954
|
</div>
|
|
4179
|
-
)
|
|
4180
|
-
|
|
4181
|
-
|
|
4182
|
-
)}
|
|
4183
|
-
</>
|
|
4184
|
-
)}
|
|
4955
|
+
)}
|
|
4956
|
+
</>
|
|
4957
|
+
)
|
|
4958
|
+
})()}
|
|
4185
4959
|
</div>
|
|
4186
4960
|
)}
|
|
4961
|
+
|
|
4187
4962
|
</>)}
|
|
4188
4963
|
</div>
|
|
4189
4964
|
</div>
|