@waterwx/dsh-novel-forge 1.4.1 → 1.6.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/assets/styles/arcane-thick-3d.webp +0 -0
- package/assets/styles/chinese-ink.webp +0 -0
- package/assets/styles/clay-stopmotion-3d.webp +0 -0
- package/assets/styles/dark-ghibli.webp +0 -0
- package/assets/styles/dark-gothic-3d.webp +0 -0
- package/assets/styles/film-photo.webp +0 -0
- package/assets/styles/guofeng-heavy.webp +0 -0
- package/assets/styles/horror-game-3d.webp +0 -0
- package/assets/styles/japan-cel-dark.webp +0 -0
- package/assets/styles/korean-webtoon.webp +0 -0
- package/assets/styles/low-cyberpunk-3d.webp +0 -0
- package/assets/styles/nextgen-cg.webp +0 -0
- package/assets/styles/noir-film.webp +0 -0
- package/assets/styles/openworld-survival.webp +0 -0
- package/assets/styles/paper-cut-3d.webp +0 -0
- package/assets/styles/pixar-adult-3d.webp +0 -0
- package/assets/styles/pop-marvel-2d.webp +0 -0
- package/assets/styles/shinkai-anime.webp +0 -0
- package/assets/styles/tactical-stealth.webp +0 -0
- package/assets/styles/vaporwave-3d.webp +0 -0
- package/assets/styles/vhs90.webp +0 -0
- package/lib/client.js +10541 -8910
- package/lib/client.js.map +1 -1
- package/lib/index.js +763 -36
- package/lib/index.js.map +1 -1
- package/lib/types/client/api.d.ts +8 -0
- package/lib/types/client/panel/MangaWorkspace.d.ts +13 -0
- package/lib/types/client/panel/NovelPanel.d.ts +1 -1
- package/lib/types/client/panel/RoleVisualPanel.d.ts +13 -0
- package/lib/types/client/panel/SceneLibrary.d.ts +14 -0
- package/lib/types/client/panel/StoryboardTab.d.ts +15 -0
- package/lib/types/client/panel/StyleCard.d.ts +8 -0
- package/lib/types/client/panel/StyleGallery.d.ts +1 -0
- package/lib/types/engine.d.ts +20 -4
- package/lib/types/protocol.d.ts +159 -0
- package/lib/types/style-library.d.ts +33 -0
- package/package.json +2 -1
- package/src/client/api.ts +20 -0
- package/src/client/panel/MangaWorkspace.tsx +293 -0
- package/src/client/panel/NovelPanel.tsx +8 -638
- package/src/client/panel/RoleVisualPanel.tsx +468 -0
- package/src/client/panel/SceneLibrary.tsx +395 -0
- package/src/client/panel/StoryboardTab.tsx +370 -0
- package/src/client/panel/StyleCard.tsx +90 -0
- package/src/client/panel/StyleGallery.tsx +58 -0
- package/src/client/panel/panel.module.css +12 -12
- package/src/engine.ts +377 -25
- package/src/protocol.ts +174 -0
- package/src/routes.ts +193 -3
- package/src/style-library.ts +78 -0
|
@@ -17,6 +17,8 @@ import { RunPanel } from './RunPanel.tsx'
|
|
|
17
17
|
import { CreateBookView } from './CreateBookView.tsx'
|
|
18
18
|
import { ImportModal } from './ImportModal.tsx'
|
|
19
19
|
import { WorldTab } from './WorldTab.tsx'
|
|
20
|
+
import { StoryboardTab } from './StoryboardTab.tsx'
|
|
21
|
+
import { MangaWorkspace } from './MangaWorkspace.tsx'
|
|
20
22
|
import { AuditIssueRow, PlotlineCard, PlotlineHealthPanel, PlotlinePlanPanel, PlotlineSuggestionPanel, RoleCandidateRow, RoleCard, StatCell, TodoRow } from './views.tsx'
|
|
21
23
|
import { extractDocxTextFromBuffer } from '../docx.ts'
|
|
22
24
|
import type {
|
|
@@ -42,8 +44,7 @@ import css from './panel.module.css'
|
|
|
42
44
|
export type NovelTab =
|
|
43
45
|
| 'workflow' | 'overview' | 'blurb' | 'plan' | 'bible' | 'world' | 'foreshadow' | 'assistant' | 'settings'
|
|
44
46
|
| 'characters' | 'roles' | 'facts' | 'plotlines' | 'reviews' | 'progress' | 'breakdown'
|
|
45
|
-
| '
|
|
46
|
-
| 'assetsGenre' | 'assetsProgression' | 'assetsTemplates' | 'assetsRules' | 'assetsStyle' | 'run'
|
|
47
|
+
| 'assetsGenre' | 'assetsProgression' | 'assetsTemplates' | 'assetsRules' | 'assetsStyle' | 'run' | 'manhua'
|
|
47
48
|
| 'book' | 'assets'
|
|
48
49
|
|
|
49
50
|
/** Panel shell props. */
|
|
@@ -86,8 +87,7 @@ const NAV_GROUPS: ReadonlyArray<{ id: string; label: string; items: ReadonlyArra
|
|
|
86
87
|
{ id: 'assistant', label: tt('tab.assistant'), icon: '💬' },
|
|
87
88
|
{ id: 'progress', label: '工作进度', icon: '📊' },
|
|
88
89
|
{ id: 'breakdown', label: '拆书分析', icon: '🔍' },
|
|
89
|
-
{ id: '
|
|
90
|
-
{ id: 'scenes', label: '场景库', icon: '🏞️' },
|
|
90
|
+
{ id: 'manhua', label: '漫剧工作台', icon: '🎬' },
|
|
91
91
|
{ id: 'run', label: '生产单', icon: '🏭' },
|
|
92
92
|
],
|
|
93
93
|
},
|
|
@@ -482,23 +482,6 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
482
482
|
const [breakdownResult, setBreakdownResult] = useState<import('../../protocol.ts').BreakdownResponse | null>(null)
|
|
483
483
|
const [breakdownScope, setBreakdownScope] = useState<'recent' | 'volume:2' | 'volume:3' | 'all'>('recent')
|
|
484
484
|
const [breakdownPreset, setBreakdownPreset] = useState<'quick' | 'standard'>('quick')
|
|
485
|
-
/** 角色图集上传:当前等待上传的角色名。 */
|
|
486
|
-
const [roleImageTarget, setRoleImageTarget] = useState<string | null>(null)
|
|
487
|
-
/** 角色图集上传:当前用途标签(立绘/四视图/表情-x/场景/细节)。 */
|
|
488
|
-
const [roleImageLabel, setRoleImageLabel] = useState('立绘')
|
|
489
|
-
/** 角色形象详情:当前打开的角色名(null = 关闭)。 */
|
|
490
|
-
const [detailRoleName, setDetailRoleName] = useState<string | null>(null)
|
|
491
|
-
/** 详情面板:上传标签输入。 */
|
|
492
|
-
const [detailUploadLabel, setDetailUploadLabel] = useState('立绘')
|
|
493
|
-
/** 场景库:AI 提炼候选(null = 未运行)。 */
|
|
494
|
-
const [sceneCandidates, setSceneCandidates] = useState<import('../../protocol.ts').SceneCard[] | null>(null)
|
|
495
|
-
/** 场景库:当前打开的场景名。 */
|
|
496
|
-
const [sceneDetailName, setSceneDetailName] = useState<string | null>(null)
|
|
497
|
-
/** 场景库:上传标签输入。 */
|
|
498
|
-
const [sceneUploadLabel, setSceneUploadLabel] = useState('全景')
|
|
499
|
-
/** 场景库:编辑草稿(null = 不在编辑)。 */
|
|
500
|
-
const [sceneDraft, setSceneDraft] = useState<import('../../protocol.ts').SceneCard | null>(null)
|
|
501
|
-
const roleImageInputRef = useRef<HTMLInputElement | null>(null)
|
|
502
485
|
/** AI 建议的剧情线候选(null = 未运行)。 */
|
|
503
486
|
const [plotlineSuggestions, setPlotlineSuggestions] = useState<Plotline[] | null>(null)
|
|
504
487
|
/** 剧情健康检查报告(null = 未运行)。 */
|
|
@@ -1169,67 +1152,6 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
1169
1152
|
}
|
|
1170
1153
|
|
|
1171
1154
|
/** 生成单个角色的形象锚点(复用角色库 visual 能力)。 */
|
|
1172
|
-
const handleRoleVisual = async (name: string): Promise<void> => {
|
|
1173
|
-
setBusy(true)
|
|
1174
|
-
setBusyLabel(`生成「${name}」形象锚点…`)
|
|
1175
|
-
setError('')
|
|
1176
|
-
try {
|
|
1177
|
-
await api.roles({ op: 'visual', name })
|
|
1178
|
-
await refresh(false)
|
|
1179
|
-
pushProgress(`已生成「${name}」形象锚点`, 'done')
|
|
1180
|
-
} catch (err) {
|
|
1181
|
-
setError((err as Error).message)
|
|
1182
|
-
pushProgress(`生成「${name}」形象锚点失败:${(err as Error).message}`, 'error')
|
|
1183
|
-
} finally {
|
|
1184
|
-
setBusy(false)
|
|
1185
|
-
setBusyLabel('')
|
|
1186
|
-
}
|
|
1187
|
-
}
|
|
1188
|
-
|
|
1189
|
-
/** 上传角色图:读取本地图片为 dataURL 并写入角色卡(带用途标签 → 图集)。 */
|
|
1190
|
-
const handleRoleImageUpload = async (name: string, file: File, label = '立绘'): Promise<void> => {
|
|
1191
|
-
const dataUrl = await new Promise<string>((resolve, reject) => {
|
|
1192
|
-
const reader = new FileReader()
|
|
1193
|
-
reader.onload = () => resolve(String(reader.result))
|
|
1194
|
-
reader.onerror = () => reject(reader.error ?? new Error('读取图片失败'))
|
|
1195
|
-
reader.readAsDataURL(file)
|
|
1196
|
-
})
|
|
1197
|
-
setBusy(true)
|
|
1198
|
-
setBusyLabel(`上传「${name}」${label}…`)
|
|
1199
|
-
setError('')
|
|
1200
|
-
try {
|
|
1201
|
-
await api.roles({ op: 'image', name, dataUrl, label })
|
|
1202
|
-
await refresh(false)
|
|
1203
|
-
pushProgress(`已上传「${name}」${label}`, 'done')
|
|
1204
|
-
} catch (err) {
|
|
1205
|
-
setError((err as Error).message)
|
|
1206
|
-
pushProgress(`上传「${name}」${label}失败:${(err as Error).message}`, 'error')
|
|
1207
|
-
} finally {
|
|
1208
|
-
setBusy(false)
|
|
1209
|
-
setBusyLabel('')
|
|
1210
|
-
setRoleImageTarget(null)
|
|
1211
|
-
if (roleImageInputRef.current !== null) roleImageInputRef.current.value = ''
|
|
1212
|
-
}
|
|
1213
|
-
}
|
|
1214
|
-
|
|
1215
|
-
/** 用豆包/Seedream 为角色生成参考图。 */
|
|
1216
|
-
const handleRoleImageGenerate = async (name: string): Promise<void> => {
|
|
1217
|
-
setBusy(true)
|
|
1218
|
-
setBusyLabel(`用豆包生成「${name}」参考图…`)
|
|
1219
|
-
setError('')
|
|
1220
|
-
try {
|
|
1221
|
-
await api.roles({ op: 'imageGenerate', name })
|
|
1222
|
-
await refresh(false)
|
|
1223
|
-
pushProgress(`已生成「${name}」参考图`, 'done')
|
|
1224
|
-
} catch (err) {
|
|
1225
|
-
setError((err as Error).message)
|
|
1226
|
-
pushProgress(`生成「${name}」参考图失败:${(err as Error).message}`, 'error')
|
|
1227
|
-
} finally {
|
|
1228
|
-
setBusy(false)
|
|
1229
|
-
setBusyLabel('')
|
|
1230
|
-
}
|
|
1231
|
-
}
|
|
1232
|
-
|
|
1233
1155
|
/** 作者复盘补跑:全书缺失章节(流式)。 */
|
|
1234
1156
|
const handleAuthorBackfillAll = async (): Promise<void> => {
|
|
1235
1157
|
const missing = chapters.filter(c => c.status !== 'pending' && c.status !== 'generating' && c.status !== 'error' && c.authorReview === undefined).length
|
|
@@ -4384,6 +4306,10 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
4384
4306
|
<RunPanel api={api} totalChapters={chapters.length} />
|
|
4385
4307
|
)}
|
|
4386
4308
|
|
|
4309
|
+
{activeTab === 'manhua' && (
|
|
4310
|
+
<MangaWorkspace api={api} project={project} chapters={chapters} onProjectChanged={() => refresh(false)} imageApiEnabled={config?.imageApiEnabled === true} onProgress={(text, kind) => pushProgress(text, kind ?? 'info')} />
|
|
4311
|
+
)}
|
|
4312
|
+
|
|
4387
4313
|
{activeTab === 'breakdown' && (
|
|
4388
4314
|
<div className={css.card}>
|
|
4389
4315
|
<div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}>
|
|
@@ -4451,562 +4377,6 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
4451
4377
|
</div>
|
|
4452
4378
|
)}
|
|
4453
4379
|
|
|
4454
|
-
{activeTab === 'roleImage' && (
|
|
4455
|
-
<div className={css.card} style={{ flex: 1, minHeight: 0 }}>
|
|
4456
|
-
<div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}>
|
|
4457
|
-
<span className={css.cardTitle} style={{ fontSize: 17, fontWeight: 700 }}>🖼️ 角色形象</span>
|
|
4458
|
-
<span className={css.meta}>角色形象锚点 / 生图提示词包 / 参考图集——复制提示词即可在即梦、豆包等工具出图</span>
|
|
4459
|
-
</div>
|
|
4460
|
-
|
|
4461
|
-
<input
|
|
4462
|
-
ref={roleImageInputRef}
|
|
4463
|
-
type="file"
|
|
4464
|
-
accept="image/*"
|
|
4465
|
-
style={{ display: 'none' }}
|
|
4466
|
-
onChange={e => {
|
|
4467
|
-
const file = e.target.files?.[0]
|
|
4468
|
-
const target = roleImageTarget
|
|
4469
|
-
if (file === undefined || target === null) return
|
|
4470
|
-
const readDataUrl = (): Promise<string> => new Promise((resolve, reject) => {
|
|
4471
|
-
const reader = new FileReader()
|
|
4472
|
-
reader.onload = () => resolve(String(reader.result))
|
|
4473
|
-
reader.onerror = () => reject(reader.error ?? new Error('读取图片失败'))
|
|
4474
|
-
reader.readAsDataURL(file!)
|
|
4475
|
-
})
|
|
4476
|
-
void (async () => {
|
|
4477
|
-
const dataUrl = await readDataUrl()
|
|
4478
|
-
if (target.endsWith('||scene')) {
|
|
4479
|
-
const sceneName = target.slice(0, -'||scene'.length)
|
|
4480
|
-
setBusy(true)
|
|
4481
|
-
setBusyLabel(`上传场景图「${sceneName}」${roleImageLabel}…`)
|
|
4482
|
-
setError('')
|
|
4483
|
-
try {
|
|
4484
|
-
await api.scenes({ op: 'image', name: sceneName, dataUrl, label: roleImageLabel })
|
|
4485
|
-
await refresh(false)
|
|
4486
|
-
pushProgress(`已上传场景「${sceneName}」${roleImageLabel}`, 'done')
|
|
4487
|
-
} catch (err) {
|
|
4488
|
-
setError((err as Error).message)
|
|
4489
|
-
pushProgress(`场景图上传失败:${(err as Error).message}`, 'error')
|
|
4490
|
-
} finally {
|
|
4491
|
-
setBusy(false)
|
|
4492
|
-
setBusyLabel('')
|
|
4493
|
-
setRoleImageTarget(null)
|
|
4494
|
-
if (roleImageInputRef.current !== null) roleImageInputRef.current.value = ''
|
|
4495
|
-
}
|
|
4496
|
-
} else {
|
|
4497
|
-
await handleRoleImageUpload(target, file, roleImageLabel)
|
|
4498
|
-
}
|
|
4499
|
-
})()
|
|
4500
|
-
}}
|
|
4501
|
-
/>
|
|
4502
|
-
|
|
4503
|
-
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, flex: 1, minHeight: 0, overflowY: 'auto' }}>
|
|
4504
|
-
<div className={css.row} style={{ flexWrap: 'wrap', gap: 6, justifyContent: 'space-between' }}>
|
|
4505
|
-
<span className={css.row} style={{ gap: 6 }}>
|
|
4506
|
-
<button type="button" className={`${css.button} ${css.buttonSmall}`} disabled={busy} onClick={() => { void (async () => {
|
|
4507
|
-
setBusy(true)
|
|
4508
|
-
setBusyLabel('⚠️ 提炼本书视觉规则…')
|
|
4509
|
-
setError('')
|
|
4510
|
-
try {
|
|
4511
|
-
const result = await api.visualRules({ op: 'extract' })
|
|
4512
|
-
await refresh(false)
|
|
4513
|
-
pushProgress(`已提炼 ${result.rules.length} 条视觉规则(已注入所有提示词)`, 'done')
|
|
4514
|
-
} catch (err) {
|
|
4515
|
-
setError((err as Error).message)
|
|
4516
|
-
pushProgress(`视觉规则提炼失败:${(err as Error).message}`, 'error')
|
|
4517
|
-
} finally {
|
|
4518
|
-
setBusy(false)
|
|
4519
|
-
setBusyLabel('')
|
|
4520
|
-
}
|
|
4521
|
-
})() }}>⚠️ 提炼视觉规则</button>
|
|
4522
|
-
<button type="button" className={`${css.button} ${css.buttonSmall}`} onClick={() => { void refresh(false); pushProgress('角色库已刷新', 'done') }}>🔄 刷新角色库</button>
|
|
4523
|
-
</span>
|
|
4524
|
-
</div>
|
|
4525
|
-
|
|
4526
|
-
{(project?.visualRules ?? []).length > 0 && (
|
|
4527
|
-
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4, alignItems: 'center', marginTop: 6 }}>
|
|
4528
|
-
<span className={css.meta} style={{ fontSize: 11 }}>⚠️ 本书视觉规则:</span>
|
|
4529
|
-
{(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>)}
|
|
4530
|
-
</div>
|
|
4531
|
-
)}
|
|
4532
|
-
|
|
4533
|
-
{(() => {
|
|
4534
|
-
const roles = project?.roles ?? []
|
|
4535
|
-
if (roles.length === 0) {
|
|
4536
|
-
return (
|
|
4537
|
-
<div className={css.shelfEmpty} style={{ minHeight: 140, flex: 1 }}>
|
|
4538
|
-
<span className={css.shelfEmptyIcon}>🎭</span>
|
|
4539
|
-
<span className={css.shelfEmptyTitle}>角色库为空</span>
|
|
4540
|
-
<span className={css.meta}>请先到「角色库」提炼角色并生成形象锚点</span>
|
|
4541
|
-
<button type="button" className={`${css.button} ${css.buttonSmall}`} onClick={() => { setActiveTab('roles') }}>去角色库</button>
|
|
4542
|
-
</div>
|
|
4543
|
-
)
|
|
4544
|
-
}
|
|
4545
|
-
const detailRole = detailRoleName !== null ? roles.find(r => r.name === detailRoleName) : undefined
|
|
4546
|
-
const group = (role: RoleRecord) => {
|
|
4547
|
-
const g = role.gallery ?? []
|
|
4548
|
-
const byLabel = (key: string) => g.filter(x => x.label === key)
|
|
4549
|
-
const byPrefix = (pre: string) => g.filter(x => x.label.startsWith(pre))
|
|
4550
|
-
return {
|
|
4551
|
-
portrait: byLabel('立绘')[0],
|
|
4552
|
-
sheet: byLabel('四视图')[0],
|
|
4553
|
-
expressions: byPrefix('表情-'),
|
|
4554
|
-
others: g.filter(x => x.label !== '立绘' && x.label !== '四视图' && !x.label.startsWith('表情-')),
|
|
4555
|
-
}
|
|
4556
|
-
}
|
|
4557
|
-
const uploadInDetail = (role: RoleRecord) => {
|
|
4558
|
-
setRoleImageLabel(detailUploadLabel.trim() !== '' ? detailUploadLabel.trim() : '立绘')
|
|
4559
|
-
setRoleImageTarget(role.name)
|
|
4560
|
-
roleImageInputRef.current?.click()
|
|
4561
|
-
}
|
|
4562
|
-
return (
|
|
4563
|
-
<>
|
|
4564
|
-
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(130px, 1fr))', gap: 10 }}>
|
|
4565
|
-
{roles.map(r => (
|
|
4566
|
-
<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' }}>
|
|
4567
|
-
<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' }}>
|
|
4568
|
-
{r.imageUrl !== undefined
|
|
4569
|
-
? <img src={r.imageUrl} alt={r.name} style={{ width: '100%', height: '100%', objectFit: 'cover', objectPosition: 'center 20%' }} />
|
|
4570
|
-
: <span className={css.meta}>暂无形象</span>}
|
|
4571
|
-
<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>
|
|
4572
|
-
</div>
|
|
4573
|
-
<div style={{ padding: 6, display: 'flex', flexDirection: 'column', gap: 2 }}>
|
|
4574
|
-
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
|
4575
|
-
<b style={{ fontSize: 13 }}>{r.name}</b>
|
|
4576
|
-
<span className={css.meta} style={{ fontSize: 10 }}>{r.roleLabel}</span>
|
|
4577
|
-
</div>
|
|
4578
|
-
<div className={css.meta} style={{ fontSize: 10, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
|
4579
|
-
{r.imagePrompt !== undefined ? '✓ 锚点' : '⚠ 无锚点'} · {(r.identity ?? '').slice(0, 16)}
|
|
4580
|
-
</div>
|
|
4581
|
-
</div>
|
|
4582
|
-
</div>
|
|
4583
|
-
))}
|
|
4584
|
-
</div>
|
|
4585
|
-
|
|
4586
|
-
{detailRole !== undefined && (
|
|
4587
|
-
<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) }}>
|
|
4588
|
-
<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()}>
|
|
4589
|
-
<button type="button" className={css.iconButton} style={{ position: 'absolute', top: 12, right: 12, fontSize: 18 }} title="关闭" aria-label="关闭" onClick={() => { setDetailRoleName(null) }}>✕</button>
|
|
4590
|
-
<div style={{ marginBottom: 12 }}>
|
|
4591
|
-
<div style={{ display: 'flex', alignItems: 'baseline', gap: 10, flexWrap: 'wrap' }}>
|
|
4592
|
-
<b style={{ fontSize: 22 }}>{detailRole.name}</b>
|
|
4593
|
-
<span className={css.badge} style={{ borderColor: 'var(--nf-accent)', color: 'var(--nf-accent)' }}>{detailRole.roleLabel}</span>
|
|
4594
|
-
</div>
|
|
4595
|
-
<div className={css.meta} style={{ marginTop: 4 }}>{detailRole.identity}</div>
|
|
4596
|
-
</div>
|
|
4597
|
-
|
|
4598
|
-
{(project?.visualRules ?? []).length > 0 && (
|
|
4599
|
-
<div style={{ border: '1px solid var(--nf-warn, #b8860b)', borderRadius: 10, padding: '6px 10px', marginBottom: 10, fontSize: 11 }}>
|
|
4600
|
-
<b>⚠️ 本书视觉规则(已内嵌到下方所有提示词)</b>
|
|
4601
|
-
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4, marginTop: 4 }}>
|
|
4602
|
-
{(project?.visualRules ?? []).map((r, i) => <span key={i} style={{ border: '1px solid var(--nf-border)', borderRadius: 999, padding: '1px 8px', fontSize: 10 }}>{r}</span>)}
|
|
4603
|
-
</div>
|
|
4604
|
-
</div>
|
|
4605
|
-
)}
|
|
4606
|
-
|
|
4607
|
-
<details style={{ marginBottom: 10, fontSize: 12 }}>
|
|
4608
|
-
<summary style={{ cursor: 'pointer', color: 'var(--nf-text-2)' }}>🖼 图集({(detailRole.gallery ?? []).length} 张 · 点击展开/收起)</summary>
|
|
4609
|
-
<div style={{ marginTop: 8 }}>
|
|
4610
|
-
{(() => {
|
|
4611
|
-
const g = group(detailRole)
|
|
4612
|
-
const hasMain = g.portrait !== undefined || g.sheet !== undefined
|
|
4613
|
-
return (
|
|
4614
|
-
<>
|
|
4615
|
-
{hasMain && (
|
|
4616
|
-
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(240px, 1fr))', gap: 10, marginBottom: 12 }}>
|
|
4617
|
-
{g.portrait !== undefined && (
|
|
4618
|
-
<div style={{ border: '1px solid var(--nf-border)', borderRadius: 12, overflow: 'hidden' }}>
|
|
4619
|
-
<img src={g.portrait.dataUrl} alt="立绘" style={{ width: '100%', maxHeight: 380, objectFit: 'contain', display: 'block', background: '#111' }} />
|
|
4620
|
-
<div className={css.meta} style={{ padding: '2px 8px', fontSize: 11 }}>立绘</div>
|
|
4621
|
-
</div>
|
|
4622
|
-
)}
|
|
4623
|
-
{g.sheet !== undefined && (
|
|
4624
|
-
<div style={{ border: '1px solid var(--nf-border)', borderRadius: 12, overflow: 'hidden' }}>
|
|
4625
|
-
<img src={g.sheet.dataUrl} alt="四视图" style={{ width: '100%', maxHeight: 380, objectFit: 'contain', display: 'block', background: '#111' }} />
|
|
4626
|
-
<div className={css.meta} style={{ padding: '2px 8px', fontSize: 11 }}>四视图</div>
|
|
4627
|
-
</div>
|
|
4628
|
-
)}
|
|
4629
|
-
</div>
|
|
4630
|
-
)}
|
|
4631
|
-
{g.expressions.length > 0 && (
|
|
4632
|
-
<div style={{ marginBottom: 12 }}>
|
|
4633
|
-
<b style={{ fontSize: 13 }}>表情设定</b>
|
|
4634
|
-
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(110px, 1fr))', gap: 8, marginTop: 6 }}>
|
|
4635
|
-
{g.expressions.map(img => (
|
|
4636
|
-
<div key={img.label} style={{ border: '1px solid var(--nf-border)', borderRadius: 10, overflow: 'hidden', position: 'relative' }}>
|
|
4637
|
-
<img src={img.dataUrl} alt={img.label} style={{ width: '100%', height: 110, objectFit: 'cover', display: 'block' }} />
|
|
4638
|
-
<div style={{ background: 'rgba(0,0,0,0.55)', color: '#fff', fontSize: 10, padding: '1px 6px', textAlign: 'center' }}>{img.label.replace('表情-', '')}</div>
|
|
4639
|
-
<button type="button" className={css.iconButton} style={{ position: 'absolute', top: 2, right: 2, fontSize: 11 }} title="删除" aria-label="删除" onClick={() => { void (async () => {
|
|
4640
|
-
try {
|
|
4641
|
-
await api.roles({ op: 'removeImage', name: detailRole.name, label: img.label })
|
|
4642
|
-
await refresh(false)
|
|
4643
|
-
pushProgress(`已删除「${detailRole.name}」${img.label}`, 'done')
|
|
4644
|
-
} catch (err) { setError((err as Error).message) }
|
|
4645
|
-
})() }}>×</button>
|
|
4646
|
-
</div>
|
|
4647
|
-
))}
|
|
4648
|
-
</div>
|
|
4649
|
-
</div>
|
|
4650
|
-
)}
|
|
4651
|
-
{g.others.length > 0 && (
|
|
4652
|
-
<div style={{ marginBottom: 12 }}>
|
|
4653
|
-
<b style={{ fontSize: 13 }}>场景 / 细节</b>
|
|
4654
|
-
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(140px, 1fr))', gap: 8, marginTop: 6 }}>
|
|
4655
|
-
{g.others.map(img => (
|
|
4656
|
-
<div key={img.label} style={{ border: '1px solid var(--nf-border)', borderRadius: 10, overflow: 'hidden', position: 'relative' }}>
|
|
4657
|
-
<img src={img.dataUrl} alt={img.label} style={{ width: '100%', height: 90, objectFit: 'cover', display: 'block' }} />
|
|
4658
|
-
<div style={{ background: 'rgba(0,0,0,0.55)', color: '#fff', fontSize: 10, padding: '1px 6px', textAlign: 'center' }}>{img.label}</div>
|
|
4659
|
-
<button type="button" className={css.iconButton} style={{ position: 'absolute', top: 2, right: 2, fontSize: 11 }} title="删除" aria-label="删除" onClick={() => { void (async () => {
|
|
4660
|
-
try {
|
|
4661
|
-
await api.roles({ op: 'removeImage', name: detailRole.name, label: img.label })
|
|
4662
|
-
await refresh(false)
|
|
4663
|
-
pushProgress(`已删除「${detailRole.name}」${img.label}`, 'done')
|
|
4664
|
-
} catch (err) { setError((err as Error).message) }
|
|
4665
|
-
})() }}>×</button>
|
|
4666
|
-
</div>
|
|
4667
|
-
))}
|
|
4668
|
-
</div>
|
|
4669
|
-
</div>
|
|
4670
|
-
)}
|
|
4671
|
-
</>
|
|
4672
|
-
)
|
|
4673
|
-
})()}
|
|
4674
|
-
</div>
|
|
4675
|
-
</details>
|
|
4676
|
-
|
|
4677
|
-
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(280px, 1fr))', gap: 14, marginBottom: 12 }}>
|
|
4678
|
-
<div>
|
|
4679
|
-
<b style={{ fontSize: 13 }}>基本信息</b>
|
|
4680
|
-
<div style={{ fontSize: 12, lineHeight: 1.8, marginTop: 4 }}>
|
|
4681
|
-
<div><span className={css.meta}>性格:</span>{(detailRole.traits ?? []).join('、') || '—'}</div>
|
|
4682
|
-
<div><span className={css.meta}>目标:</span>{detailRole.goals || '—'}</div>
|
|
4683
|
-
<div><span className={css.meta}>关系:</span>{(detailRole.relations ?? []).join(';') || '—'}</div>
|
|
4684
|
-
<div><span className={css.meta}>成长线:</span>{(detailRole.arc ?? []).join(';') || '—'}</div>
|
|
4685
|
-
</div>
|
|
4686
|
-
</div>
|
|
4687
|
-
<div>
|
|
4688
|
-
<div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}>
|
|
4689
|
-
<b style={{ fontSize: 13 }}>📄 提示词包</b>
|
|
4690
|
-
{detailRole.imagePrompt !== undefined && detailRole.promptKit === undefined && (
|
|
4691
|
-
<button type="button" className={`${css.button} ${css.buttonSmall}`} disabled={busy} onClick={() => { void (async () => {
|
|
4692
|
-
setBusy(true)
|
|
4693
|
-
setBusyLabel(`精修「${detailRole.name}」提示词…`)
|
|
4694
|
-
setError('')
|
|
4695
|
-
try {
|
|
4696
|
-
await api.roles({ op: 'promptKit', name: detailRole.name })
|
|
4697
|
-
await refresh(false)
|
|
4698
|
-
pushProgress(`已生成「${detailRole.name}」四类精修提示词`, 'done')
|
|
4699
|
-
} catch (err) {
|
|
4700
|
-
setError((err as Error).message)
|
|
4701
|
-
pushProgress(`提示词精修失败:${(err as Error).message}`, 'error')
|
|
4702
|
-
} finally {
|
|
4703
|
-
setBusy(false)
|
|
4704
|
-
setBusyLabel('')
|
|
4705
|
-
}
|
|
4706
|
-
})() }}>✨ 生成精修版</button>
|
|
4707
|
-
)}
|
|
4708
|
-
</div>
|
|
4709
|
-
{detailRole.imagePrompt === undefined && <span className={css.meta}>未生成锚点,先点「✨ 生成锚点」</span>}
|
|
4710
|
-
{(() => {
|
|
4711
|
-
const anchor = detailRole.imagePrompt
|
|
4712
|
-
if (anchor === undefined) return null
|
|
4713
|
-
const rules = project?.visualRules ?? []
|
|
4714
|
-
const rulesZh = rules.length > 0 ? '\n【本书视觉规则】' + rules.join(';') : ''
|
|
4715
|
-
const expressions = detailRole.expressions ?? ['平静']
|
|
4716
|
-
const expName = (n: string) => n.replace(/^表情-/, '')
|
|
4717
|
-
const kit = detailRole.promptKit
|
|
4718
|
-
const blocks: Array<{ key: string; title: string; zh: string; en: string }> = []
|
|
4719
|
-
if (kit !== undefined) {
|
|
4720
|
-
blocks.push({ key: 'portrait', title: '立绘', zh: kit.portrait.zh, en: kit.portrait.en })
|
|
4721
|
-
blocks.push({ key: 'sheet', title: '四视图', zh: kit.sheet.zh, en: kit.sheet.en })
|
|
4722
|
-
for (const e of kit.expressions) blocks.push({ key: 'exp-' + e.name, title: `表情·${e.name}`, zh: e.zh, en: e.en })
|
|
4723
|
-
blocks.push({ key: 'details', title: '细节', zh: kit.details.zh, en: kit.details.en })
|
|
4724
|
-
} else {
|
|
4725
|
-
blocks.push({ key: 'portrait', title: '立绘', zh: '正面站立全身人像,3D动漫,超精细建模,纯白纯色背景,' + anchor.zh + '。角色设计稿,服装磨损、标志物细节完整展示,无多余杂物,全身完整无裁切。' + rulesZh, en: anchor.en + ', full body, front view, plain white background, 3d render, character design sheet' })
|
|
4726
|
-
blocks.push({ key: 'sheet', title: '四视图', zh: '角色设定四视图,3D动漫,超精细建模,纯白纯色背景,' + anchor.zh + '。同一角色正面/左侧面/右侧面/背面,四个视角分别描述,全身完整,服装与标志物细节完整展示,无多余杂物。' + rulesZh, en: anchor.en + ', character sheet, front view, left side view, right side view, back view, full body, plain white background, 3d render' })
|
|
4727
|
-
for (const n of expressions) blocks.push({ key: 'exp-' + n, title: `表情·${expName(n)}`, zh: '脸部特写(头部到锁骨),3D动漫,超精细建模,纯白纯色背景,' + anchor.zh + `。表情:${expName(n)},五官与角色定稿完全一致,皮肤纹理细节完整,无多余杂物。` + rulesZh, en: anchor.en + `, facial close-up, head to collarbone, expression: ${expName(n)}, plain white background, 3d render, consistent with character design, detailed skin texture, no extra objects` })
|
|
4728
|
-
blocks.push({ key: 'details', title: '细节', zh: `多组局部细节集合参考图,3D动漫,超精细建模,纯白纯色背景:${anchor.tags.map(t => t + '特写').join(';')}。细节清晰锐利,角色细节参考稿,无多余杂物。` + rulesZh, en: anchor.tags.join(', ') + ', multi-panel detail reference sheet, plain white background, 3d render, macro close-up, sharp details, character detail sheet, no extra objects' })
|
|
4729
|
-
}
|
|
4730
|
-
return (
|
|
4731
|
-
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginTop: 8 }}>
|
|
4732
|
-
{blocks.map(b => (
|
|
4733
|
-
<div key={b.key} style={{ border: '1px solid var(--nf-border)', borderRadius: 8, padding: '6px 8px' }}>
|
|
4734
|
-
<div className={css.row} style={{ justifyContent: 'space-between' }}>
|
|
4735
|
-
<b style={{ fontSize: 11 }}>{b.title}</b>
|
|
4736
|
-
<span className={css.row} style={{ gap: 4 }}>
|
|
4737
|
-
<button type="button" className={`${css.button} ${css.buttonSmall}`} style={{ fontSize: 10 }} onClick={() => {
|
|
4738
|
-
void navigator.clipboard?.writeText(b.zh).then(() => { pushProgress(`已复制「${detailRole.name}」${b.title}中文提示词`, 'done') }).catch(() => { /* ignore */ })
|
|
4739
|
-
}}>复制</button>
|
|
4740
|
-
<button type="button" className={`${css.button} ${css.buttonSmall}`} style={{ fontSize: 10 }} onClick={() => {
|
|
4741
|
-
void navigator.clipboard?.writeText(b.en).then(() => { pushProgress(`已复制「${detailRole.name}」${b.title}英文标签`, 'done') }).catch(() => { /* ignore */ })
|
|
4742
|
-
}}>EN</button>
|
|
4743
|
-
</span>
|
|
4744
|
-
</div>
|
|
4745
|
-
<div className={css.meta} style={{ fontSize: 11, lineHeight: 1.5, marginTop: 3 }}>{b.zh}</div>
|
|
4746
|
-
<details style={{ marginTop: 3 }}>
|
|
4747
|
-
<summary style={{ cursor: 'pointer', fontSize: 10, color: 'var(--nf-text-2)' }}>🌐 英文标签(点击展开)</summary>
|
|
4748
|
-
<div className={css.meta} style={{ fontSize: 9, fontFamily: 'monospace', marginTop: 2, wordBreak: 'break-all', color: 'var(--nf-text-2)' }}>{b.en}</div>
|
|
4749
|
-
</details>
|
|
4750
|
-
</div>
|
|
4751
|
-
))}
|
|
4752
|
-
<div className={css.meta} style={{ fontSize: 10 }}>关键词:{(anchor.tags ?? []).join('、')}</div>
|
|
4753
|
-
</div>
|
|
4754
|
-
)
|
|
4755
|
-
})()}
|
|
4756
|
-
</div>
|
|
4757
|
-
</div>
|
|
4758
|
-
|
|
4759
|
-
<div style={{ borderTop: '1px solid var(--nf-border)', paddingTop: 10, display: 'flex', flexWrap: 'wrap', gap: 6, alignItems: 'center' }}>
|
|
4760
|
-
{detailRole.imagePrompt === undefined && (
|
|
4761
|
-
<button type="button" className={`${css.button} ${css.buttonSmall}`} disabled={busy} onClick={() => { void handleRoleVisual(detailRole.name) }}>✨ 生成锚点</button>
|
|
4762
|
-
)}
|
|
4763
|
-
{detailRole.imagePrompt !== undefined && config?.imageApiEnabled === true && (
|
|
4764
|
-
<button type="button" className={`${css.button} ${css.buttonSmall}`} disabled={busy} onClick={() => { void handleRoleImageGenerate(detailRole.name) }}>豆包生成</button>
|
|
4765
|
-
)}
|
|
4766
|
-
{detailRole.imagePrompt !== undefined && (
|
|
4767
|
-
<button type="button" className={`${css.button} ${css.buttonSmall}`} onClick={() => {
|
|
4768
|
-
const text = [detailRole.imagePrompt!.en, detailRole.imagePrompt!.tags.join(', ')].filter(Boolean).join('\n')
|
|
4769
|
-
void navigator.clipboard?.writeText(text).then(() => { pushProgress(`已复制「${detailRole.name}」英文生图提示词`, 'done') }).catch(() => { /* ignore */ })
|
|
4770
|
-
}}>复制提示词</button>
|
|
4771
|
-
)}
|
|
4772
|
-
<input
|
|
4773
|
-
className={css.input}
|
|
4774
|
-
style={{ width: 140, fontSize: 12, padding: '3px 6px' }}
|
|
4775
|
-
placeholder="图集标签"
|
|
4776
|
-
value={detailUploadLabel}
|
|
4777
|
-
onChange={e => { setDetailUploadLabel(e.target.value) }}
|
|
4778
|
-
/>
|
|
4779
|
-
<button type="button" className={`${css.button} ${css.buttonSmall}`} disabled={busy} onClick={() => { uploadInDetail(detailRole) }}>📤 上传图</button>
|
|
4780
|
-
</div>
|
|
4781
|
-
</div>
|
|
4782
|
-
</div>
|
|
4783
|
-
)}
|
|
4784
|
-
</>
|
|
4785
|
-
)
|
|
4786
|
-
})()}
|
|
4787
|
-
</div>
|
|
4788
|
-
</div>
|
|
4789
|
-
)}
|
|
4790
|
-
|
|
4791
|
-
{activeTab === 'scenes' && (
|
|
4792
|
-
<div className={css.card} style={{ flex: 1, minHeight: 0 }}>
|
|
4793
|
-
<div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}>
|
|
4794
|
-
<span className={css.cardTitle} style={{ fontSize: 17, fontWeight: 700 }}>🏞️ 场景库</span>
|
|
4795
|
-
<span className={css.meta}>场景视觉锚点:提炼自正文,供漫剧分镜/生图锁定"在哪、什么氛围"</span>
|
|
4796
|
-
</div>
|
|
4797
|
-
|
|
4798
|
-
<div className={css.row} style={{ flexWrap: 'wrap', gap: 6, margin: '8px 0' }}>
|
|
4799
|
-
<button type="button" className={`${css.button} ${css.buttonPrimary}`} disabled={busy} onClick={() => { void (async () => {
|
|
4800
|
-
setBusy(true)
|
|
4801
|
-
setBusyLabel('✨ 从全书提炼场景…')
|
|
4802
|
-
setError('')
|
|
4803
|
-
try {
|
|
4804
|
-
const result = await api.scenes({ op: 'extract' })
|
|
4805
|
-
setSceneCandidates(result.candidates ?? [])
|
|
4806
|
-
pushProgress(`提炼出 ${(result.candidates ?? []).length} 个候选场景,可采纳或修改后采纳`, 'done')
|
|
4807
|
-
} catch (err) {
|
|
4808
|
-
setError((err as Error).message)
|
|
4809
|
-
pushProgress(`场景提炼失败:${(err as Error).message}`, 'error')
|
|
4810
|
-
} finally {
|
|
4811
|
-
setBusy(false)
|
|
4812
|
-
setBusyLabel('')
|
|
4813
|
-
}
|
|
4814
|
-
})() }}>{busy ? '⏳ 提炼中…' : '✨ 从全书提炼场景'}</button>
|
|
4815
|
-
<button type="button" className={`${css.button} ${css.buttonSmall}`} onClick={() => { void refresh(false); pushProgress('场景库已刷新', 'done') }}>🔄 刷新</button>
|
|
4816
|
-
{sceneCandidates !== null && (
|
|
4817
|
-
<button type="button" className={`${css.button} ${css.buttonSmall}`} onClick={() => { setSceneCandidates(null) }}>收起候选</button>
|
|
4818
|
-
)}
|
|
4819
|
-
</div>
|
|
4820
|
-
|
|
4821
|
-
{sceneDraft !== null && (
|
|
4822
|
-
<div style={{ border: '1px solid var(--nf-accent)', borderRadius: 10, padding: 10, marginBottom: 10, display: 'flex', flexDirection: 'column', gap: 6 }}>
|
|
4823
|
-
<b style={{ fontSize: 13 }}>编辑场景</b>
|
|
4824
|
-
<input className={css.input} style={{ fontSize: 12, padding: '4px 8px' }} placeholder="场景名" value={sceneDraft.name} onChange={e => { setSceneDraft({ ...sceneDraft, name: e.target.value }) }} />
|
|
4825
|
-
<input className={css.input} style={{ fontSize: 12, padding: '4px 8px' }} placeholder="一句话定位" value={sceneDraft.summary} onChange={e => { setSceneDraft({ ...sceneDraft, summary: e.target.value }) }} />
|
|
4826
|
-
<div className={css.row} style={{ gap: 6 }}>
|
|
4827
|
-
<input className={css.input} style={{ fontSize: 12, padding: '4px 8px', flex: 1 }} placeholder="幕归属(第一幕后场…)" value={sceneDraft.act ?? ''} onChange={e => { setSceneDraft({ ...sceneDraft, act: e.target.value }) }} />
|
|
4828
|
-
<input className={css.input} style={{ fontSize: 12, padding: '4px 8px', flex: 1 }} placeholder="时间光态(雨夜/闭店后…)" value={sceneDraft.moment ?? ''} onChange={e => { setSceneDraft({ ...sceneDraft, moment: e.target.value }) }} />
|
|
4829
|
-
</div>
|
|
4830
|
-
<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) }) }} />
|
|
4831
|
-
<input className={css.input} style={{ fontSize: 12, padding: '4px 8px' }} placeholder="人物在场状态(含标志物细节)" value={sceneDraft.characterState ?? ''} onChange={e => { setSceneDraft({ ...sceneDraft, characterState: e.target.value }) }} />
|
|
4832
|
-
<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) }) }} />
|
|
4833
|
-
<textarea className={css.input} style={{ fontSize: 12, padding: '4px 8px', minHeight: 60 }} placeholder="中文生图提示词" value={sceneDraft.zh} onChange={e => { setSceneDraft({ ...sceneDraft, zh: e.target.value }) }} />
|
|
4834
|
-
<textarea className={css.input} style={{ fontSize: 12, padding: '4px 8px', minHeight: 60 }} placeholder="英文生图提示词" value={sceneDraft.en} onChange={e => { setSceneDraft({ ...sceneDraft, en: e.target.value }) }} />
|
|
4835
|
-
<div className={css.row} style={{ gap: 6 }}>
|
|
4836
|
-
<button type="button" className={`${css.button} ${css.buttonPrimary}`} disabled={busy || sceneDraft.name.trim() === ''} onClick={() => { void (async () => {
|
|
4837
|
-
setBusy(true)
|
|
4838
|
-
try {
|
|
4839
|
-
await api.scenes({ op: 'update', scene: { ...sceneDraft, name: sceneDraft.name.trim() } })
|
|
4840
|
-
await refresh(false)
|
|
4841
|
-
setSceneDraft(null)
|
|
4842
|
-
pushProgress(`已保存场景「${sceneDraft.name.trim()}」`, 'done')
|
|
4843
|
-
} catch (err) {
|
|
4844
|
-
setError((err as Error).message)
|
|
4845
|
-
} finally {
|
|
4846
|
-
setBusy(false)
|
|
4847
|
-
}
|
|
4848
|
-
})() }}>保存</button>
|
|
4849
|
-
<button type="button" className={`${css.button} ${css.buttonSmall}`} onClick={() => { setSceneDraft(null) }}>取消</button>
|
|
4850
|
-
</div>
|
|
4851
|
-
</div>
|
|
4852
|
-
)}
|
|
4853
|
-
|
|
4854
|
-
{sceneCandidates !== null && sceneCandidates.length > 0 && (
|
|
4855
|
-
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, marginBottom: 10 }}>
|
|
4856
|
-
{sceneCandidates.map(s => (
|
|
4857
|
-
<div key={s.name} style={{ border: '1px solid var(--nf-accent)', borderRadius: 10, padding: '8px 10px', fontSize: 12 }}>
|
|
4858
|
-
<div className={css.row} style={{ flexWrap: 'wrap', gap: 6, justifyContent: 'space-between' }}>
|
|
4859
|
-
<span><b>{s.name}</b> <span className={css.meta}>· {(s.moods ?? []).join('、')}</span></span>
|
|
4860
|
-
<span className={css.row} style={{ gap: 4 }}>
|
|
4861
|
-
<button type="button" className={`${css.button} ${css.buttonSmall}`} onClick={() => { void (async () => {
|
|
4862
|
-
try {
|
|
4863
|
-
await api.scenes({ op: 'adopt', scene: s })
|
|
4864
|
-
setSceneCandidates((sceneCandidates ?? []).filter(x => x.name !== s.name))
|
|
4865
|
-
await refresh(false)
|
|
4866
|
-
pushProgress(`已采纳场景「${s.name}」`, 'done')
|
|
4867
|
-
} catch (err) { setError((err as Error).message) }
|
|
4868
|
-
})() }}>采纳</button>
|
|
4869
|
-
<button type="button" className={`${css.button} ${css.buttonSmall}`} onClick={() => { setSceneDraft(s); setSceneCandidates(null) }}>修改后采纳</button>
|
|
4870
|
-
</span>
|
|
4871
|
-
</div>
|
|
4872
|
-
<div className={css.meta} style={{ marginTop: 2 }}>{s.summary}</div>
|
|
4873
|
-
<div className={css.meta} style={{ marginTop: 2, fontSize: 11 }}>要素:{(s.elements ?? []).join(';')}</div>
|
|
4874
|
-
</div>
|
|
4875
|
-
))}
|
|
4876
|
-
</div>
|
|
4877
|
-
)}
|
|
4878
|
-
|
|
4879
|
-
{(() => {
|
|
4880
|
-
const scenes = project?.scenes ?? []
|
|
4881
|
-
const detail = sceneDetailName !== null ? scenes.find(s => s.name === sceneDetailName) : undefined
|
|
4882
|
-
if (scenes.length === 0 && sceneCandidates === null) {
|
|
4883
|
-
return (
|
|
4884
|
-
<div className={css.shelfEmpty} style={{ minHeight: 140, flex: 1 }}>
|
|
4885
|
-
<span className={css.shelfEmptyIcon}>🏞️</span>
|
|
4886
|
-
<span className={css.shelfEmptyTitle}>场景库为空</span>
|
|
4887
|
-
<span className={css.meta}>点「✨ 从全书提炼场景」自动建立,或手动新增</span>
|
|
4888
|
-
</div>
|
|
4889
|
-
)
|
|
4890
|
-
}
|
|
4891
|
-
return (
|
|
4892
|
-
<>
|
|
4893
|
-
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(180px, 1fr))', gap: 10 }}>
|
|
4894
|
-
{scenes.map(s => {
|
|
4895
|
-
const cover = (s.gallery ?? [])[0]
|
|
4896
|
-
return (
|
|
4897
|
-
<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' }}>
|
|
4898
|
-
<div style={{ height: 100, display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'var(--nf-bg-inset)', borderBottom: '1px solid var(--nf-border)', position: 'relative' }}>
|
|
4899
|
-
{cover !== undefined
|
|
4900
|
-
? <img src={cover.dataUrl} alt={s.name} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
|
4901
|
-
: <span className={css.meta}>暂无场景图</span>}
|
|
4902
|
-
<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>
|
|
4903
|
-
</div>
|
|
4904
|
-
<div style={{ padding: 6, display: 'flex', flexDirection: 'column', gap: 2 }}>
|
|
4905
|
-
<b style={{ fontSize: 13 }}>{s.name}</b>
|
|
4906
|
-
<div className={css.meta} style={{ fontSize: 10 }}>{(s.moods ?? []).join('、') || s.summary.slice(0, 16)}</div>
|
|
4907
|
-
</div>
|
|
4908
|
-
</div>
|
|
4909
|
-
)
|
|
4910
|
-
})}
|
|
4911
|
-
</div>
|
|
4912
|
-
|
|
4913
|
-
{detail !== undefined && (
|
|
4914
|
-
<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) }}>
|
|
4915
|
-
<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()}>
|
|
4916
|
-
<button type="button" className={css.iconButton} style={{ position: 'absolute', top: 12, right: 12, fontSize: 18 }} title="关闭" aria-label="关闭" onClick={() => { setSceneDetailName(null) }}>✕</button>
|
|
4917
|
-
<div style={{ marginBottom: 12 }}>
|
|
4918
|
-
<b style={{ fontSize: 20 }}>{detail.name}</b>
|
|
4919
|
-
<div className={css.meta} style={{ marginTop: 4 }}>{detail.summary}</div>
|
|
4920
|
-
</div>
|
|
4921
|
-
|
|
4922
|
-
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(160px, 1fr))', gap: 8, marginBottom: 12 }}>
|
|
4923
|
-
{(detail.gallery ?? []).map(img => (
|
|
4924
|
-
<div key={img.label} style={{ border: '1px solid var(--nf-border)', borderRadius: 10, overflow: 'hidden', position: 'relative' }}>
|
|
4925
|
-
<img src={img.dataUrl} alt={img.label} style={{ width: '100%', height: 100, objectFit: 'cover', display: 'block' }} />
|
|
4926
|
-
<div style={{ background: 'rgba(0,0,0,0.55)', color: '#fff', fontSize: 10, padding: '1px 6px', textAlign: 'center' }}>{img.label}</div>
|
|
4927
|
-
<button type="button" className={css.iconButton} style={{ position: 'absolute', top: 2, right: 2, fontSize: 11 }} title="删除" aria-label="删除" onClick={() => { void (async () => {
|
|
4928
|
-
try {
|
|
4929
|
-
await api.scenes({ op: 'removeImage', name: detail.name, label: img.label })
|
|
4930
|
-
await refresh(false)
|
|
4931
|
-
pushProgress(`已删除「${detail.name}」${img.label}`, 'done')
|
|
4932
|
-
} catch (err) { setError((err as Error).message) }
|
|
4933
|
-
})() }}>×</button>
|
|
4934
|
-
</div>
|
|
4935
|
-
))}
|
|
4936
|
-
{(detail.gallery ?? []).length === 0 && <span className={css.meta}>暂无场景图</span>}
|
|
4937
|
-
</div>
|
|
4938
|
-
|
|
4939
|
-
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(260px, 1fr))', gap: 14, marginBottom: 12 }}>
|
|
4940
|
-
<div style={{ fontSize: 12, lineHeight: 1.8 }}>
|
|
4941
|
-
{(detail.act !== undefined || detail.moment !== undefined) && (
|
|
4942
|
-
<div style={{ marginBottom: 6 }}>
|
|
4943
|
-
{(detail.act !== undefined && detail.act !== '') && <span className={css.badge} style={{ borderColor: 'var(--nf-accent)', color: 'var(--nf-accent)', marginRight: 6 }}>{detail.act}</span>}
|
|
4944
|
-
{(detail.moment !== undefined && detail.moment !== '') && <span className={css.meta}>{detail.moment}</span>}
|
|
4945
|
-
</div>
|
|
4946
|
-
)}
|
|
4947
|
-
{(detail.beats ?? []).length > 0 && (
|
|
4948
|
-
<div style={{ marginBottom: 8 }}>
|
|
4949
|
-
<b style={{ fontSize: 13 }}>关键镜头</b>
|
|
4950
|
-
<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>
|
|
4951
|
-
</div>
|
|
4952
|
-
)}
|
|
4953
|
-
{detail.characterState !== undefined && detail.characterState !== '' && (
|
|
4954
|
-
<div style={{ marginBottom: 8 }}>
|
|
4955
|
-
<b style={{ fontSize: 13 }}>人物状态</b>
|
|
4956
|
-
<div className={css.meta} style={{ marginTop: 2 }}>{detail.characterState}</div>
|
|
4957
|
-
</div>
|
|
4958
|
-
)}
|
|
4959
|
-
<b style={{ fontSize: 13 }}>环境构成</b>
|
|
4960
|
-
<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>
|
|
4961
|
-
<div style={{ marginTop: 8 }}><b style={{ fontSize: 13 }}>色调光影</b></div>
|
|
4962
|
-
<div style={{ marginTop: 4 }}>{(detail.palette ?? []).join(';') || '—'}</div>
|
|
4963
|
-
<div style={{ marginTop: 8 }}><b style={{ fontSize: 13 }}>氛围</b></div>
|
|
4964
|
-
<div style={{ marginTop: 4 }}>{(detail.moods ?? []).join('、') || '—'}</div>
|
|
4965
|
-
<div style={{ marginTop: 8 }}><b style={{ fontSize: 13 }}>依据</b></div>
|
|
4966
|
-
<div className={css.meta} style={{ marginTop: 4 }}>{detail.source || '—'}</div>
|
|
4967
|
-
</div>
|
|
4968
|
-
<div>
|
|
4969
|
-
<b style={{ fontSize: 13 }}>生图提示词</b>
|
|
4970
|
-
<div className={css.meta} style={{ fontSize: 11, lineHeight: 1.6, marginTop: 4 }}>{detail.zh}</div>
|
|
4971
|
-
<div className={css.meta} style={{ fontSize: 10, fontFamily: 'monospace', marginTop: 4, wordBreak: 'break-all' }}>{detail.en}</div>
|
|
4972
|
-
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4, marginTop: 8 }}>
|
|
4973
|
-
{(detail.tags ?? []).map(t => <span key={t} style={{ border: '1px solid var(--nf-border)', borderRadius: 999, padding: '2px 8px', fontSize: 11 }}>{t}</span>)}
|
|
4974
|
-
</div>
|
|
4975
|
-
</div>
|
|
4976
|
-
</div>
|
|
4977
|
-
|
|
4978
|
-
<div style={{ borderTop: '1px solid var(--nf-border)', paddingTop: 10, display: 'flex', flexWrap: 'wrap', gap: 6, alignItems: 'center' }}>
|
|
4979
|
-
<button type="button" className={`${css.button} ${css.buttonSmall}`} onClick={() => {
|
|
4980
|
-
const text = [detail.en, detail.tags.join(', ')].filter(Boolean).join('\n')
|
|
4981
|
-
void navigator.clipboard?.writeText(text).then(() => { pushProgress(`已复制「${detail.name}」英文生图提示词`, 'done') }).catch(() => { /* ignore */ })
|
|
4982
|
-
}}>复制提示词</button>
|
|
4983
|
-
<input className={css.input} style={{ width: 130, fontSize: 12, padding: '3px 6px' }} placeholder="图集标签" value={sceneUploadLabel} onChange={e => { setSceneUploadLabel(e.target.value) }} />
|
|
4984
|
-
<button type="button" className={`${css.button} ${css.buttonSmall}`} disabled={busy} onClick={() => {
|
|
4985
|
-
setRoleImageLabel(sceneUploadLabel.trim() !== '' ? sceneUploadLabel.trim() : '全景')
|
|
4986
|
-
setSceneUploadLabel(sceneUploadLabel.trim() !== '' ? sceneUploadLabel.trim() : '全景')
|
|
4987
|
-
setRoleImageTarget(detail.name + '||scene')
|
|
4988
|
-
roleImageInputRef.current?.click()
|
|
4989
|
-
}}>📤 上传场景图</button>
|
|
4990
|
-
<button type="button" className={`${css.button} ${css.buttonSmall}`} onClick={() => { setSceneDraft(detail); setSceneDetailName(null) }}>✏️ 编辑</button>
|
|
4991
|
-
<button type="button" className={`${css.button} ${css.buttonSmall}`} style={{ color: 'var(--nf-danger, #e05)' }} onClick={() => { void (async () => {
|
|
4992
|
-
if (!window.confirm(`确定删除场景「${detail.name}」?`)) return
|
|
4993
|
-
try {
|
|
4994
|
-
await api.scenes({ op: 'remove', name: detail.name })
|
|
4995
|
-
await refresh(false)
|
|
4996
|
-
setSceneDetailName(null)
|
|
4997
|
-
pushProgress(`已删除场景「${detail.name}」`, 'done')
|
|
4998
|
-
} catch (err) { setError((err as Error).message) }
|
|
4999
|
-
})() }}>🗑 删除</button>
|
|
5000
|
-
</div>
|
|
5001
|
-
</div>
|
|
5002
|
-
</div>
|
|
5003
|
-
)}
|
|
5004
|
-
</>
|
|
5005
|
-
)
|
|
5006
|
-
})()}
|
|
5007
|
-
</div>
|
|
5008
|
-
)}
|
|
5009
|
-
|
|
5010
4380
|
</>)}
|
|
5011
4381
|
</div>
|
|
5012
4382
|
</div>
|