@waterwx/dsh-novel-forge 1.1.2 → 1.1.4
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 +10 -0
- package/lib/client.js +4413 -2428
- package/lib/client.js.map +1 -1
- package/lib/index.js +926 -37
- package/lib/index.js.map +1 -1
- package/lib/types/client/api.d.ts +11 -0
- package/lib/types/client/locales.d.ts +46 -2
- package/lib/types/client/panel/NovelPanel.d.ts +1 -1
- package/lib/types/engine.d.ts +19 -1
- package/lib/types/index.d.ts +2 -0
- package/lib/types/protocol.d.ts +131 -0
- package/package.json +1 -1
- package/src/assistant.ts +59 -36
- package/src/client/api.ts +20 -0
- package/src/client/locales.ts +92 -4
- package/src/client/panel/AssetsTab.tsx +7 -7
- package/src/client/panel/NovelPanel.tsx +1139 -152
- package/src/client/panel/panel.module.css +398 -57
- package/src/engine.ts +378 -0
- package/src/index.ts +6 -0
- package/src/protocol.ts +135 -0
- package/src/routes.ts +295 -0
|
@@ -21,8 +21,12 @@ import type {
|
|
|
21
21
|
Foreshadow,
|
|
22
22
|
JobFrame,
|
|
23
23
|
NovelConfig,
|
|
24
|
+
Plotline,
|
|
25
|
+
PlotlineHealthReport,
|
|
26
|
+
PlotlinePlan,
|
|
24
27
|
ProjectState,
|
|
25
28
|
ReviewReport,
|
|
29
|
+
SensitiveHit,
|
|
26
30
|
StoryBible,
|
|
27
31
|
Volume,
|
|
28
32
|
} from '../../protocol.ts'
|
|
@@ -31,7 +35,7 @@ import css from './panel.module.css'
|
|
|
31
35
|
/** The panel's tab identifiers. */
|
|
32
36
|
export type NovelTab =
|
|
33
37
|
| 'workflow' | 'overview' | 'blurb' | 'plan' | 'bible' | 'world' | 'foreshadow' | 'assistant' | 'settings'
|
|
34
|
-
| 'characters'
|
|
38
|
+
| 'characters' | 'facts' | 'plotlines' | 'reviews' | 'progress'
|
|
35
39
|
| 'assetsGenre' | 'assetsProgression' | 'assetsTemplates' | 'assetsRules' | 'assetsStyle'
|
|
36
40
|
|
|
37
41
|
/** Panel shell props. */
|
|
@@ -63,6 +67,7 @@ const NAV_GROUPS: ReadonlyArray<{ id: string; label: string; items: ReadonlyArra
|
|
|
63
67
|
{ id: 'overview', label: tt('tab.overview'), icon: '📄' },
|
|
64
68
|
{ id: 'blurb', label: '卷首语', icon: '📖' },
|
|
65
69
|
{ id: 'plan', label: tt('tab.plan'), icon: '📚' },
|
|
70
|
+
{ id: 'plotlines', label: tt('tab.plotlines'), icon: '🧵' },
|
|
66
71
|
],
|
|
67
72
|
},
|
|
68
73
|
{
|
|
@@ -70,6 +75,7 @@ const NAV_GROUPS: ReadonlyArray<{ id: string; label: string; items: ReadonlyArra
|
|
|
70
75
|
label: '工具',
|
|
71
76
|
items: [
|
|
72
77
|
{ id: 'assistant', label: tt('tab.assistant'), icon: '💬' },
|
|
78
|
+
{ id: 'progress', label: '工作进度', icon: '📊' },
|
|
73
79
|
],
|
|
74
80
|
},
|
|
75
81
|
{
|
|
@@ -80,6 +86,8 @@ const NAV_GROUPS: ReadonlyArray<{ id: string; label: string; items: ReadonlyArra
|
|
|
80
86
|
{ id: 'world', label: '大世界', icon: '🌍' },
|
|
81
87
|
{ id: 'characters', label: '人物志', icon: '👥' },
|
|
82
88
|
{ id: 'foreshadow', label: tt('tab.foreshadow'), icon: '🔮' },
|
|
89
|
+
{ id: 'facts', label: tt('tab.facts'), icon: '📜' },
|
|
90
|
+
{ id: 'reviews', label: '复盘记录', icon: '📋' },
|
|
83
91
|
{ id: 'assetsGenre', label: '题材基底', icon: '🏷️' },
|
|
84
92
|
{ id: 'assetsProgression', label: '推进模式', icon: '📈' },
|
|
85
93
|
{ id: 'assetsTemplates', label: '笔法帖', icon: '🖋️' },
|
|
@@ -305,6 +313,8 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
305
313
|
const [progress, setProgress] = useState<ProgressLine[]>([])
|
|
306
314
|
const [configDraft, setConfigDraft] = useState<NovelConfig | null>(null)
|
|
307
315
|
const [expandedChapter, setExpandedChapter] = useState<number | null>(null)
|
|
316
|
+
/** 复盘记录页:当前展开的章节号。 */
|
|
317
|
+
const [expandedReviewChapter, setExpandedReviewChapter] = useState<number | null>(null)
|
|
308
318
|
const [chapterText, setChapterText] = useState('')
|
|
309
319
|
const progressId = useRef(0)
|
|
310
320
|
/** id of the single live progress row (generation counter), if any. */
|
|
@@ -347,6 +357,17 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
347
357
|
setEditorFontSize(v)
|
|
348
358
|
try { window.localStorage.setItem('dsh-novel-forge.editor.fontSize', String(v)) } catch { /* ignore */ }
|
|
349
359
|
}
|
|
360
|
+
/** 面板主题(localStorage 记忆):'liquid'=iOS 液态玻璃(绿) / 'classic'=经典毛玻璃(蓝) / 'neumorph'=新拟物(浅色)。 */
|
|
361
|
+
const [panelTheme, setPanelTheme] = useState<'liquid' | 'classic' | 'neumorph'>(() => {
|
|
362
|
+
try {
|
|
363
|
+
const v = window.localStorage.getItem('dsh-novel-forge.theme')
|
|
364
|
+
return v === 'classic' || v === 'neumorph' ? v : 'liquid'
|
|
365
|
+
} catch { return 'liquid' }
|
|
366
|
+
})
|
|
367
|
+
const changePanelTheme = (next: 'liquid' | 'classic' | 'neumorph'): void => {
|
|
368
|
+
setPanelTheme(next)
|
|
369
|
+
try { window.localStorage.setItem('dsh-novel-forge.theme', next) } catch { /* ignore */ }
|
|
370
|
+
}
|
|
350
371
|
/** 有未采纳草稿的章节号(refresh 后检测到遗留草稿时提示)。 */
|
|
351
372
|
const [draftNo, setDraftNo] = useState<number | null>(null)
|
|
352
373
|
/** 大纲页「更新大纲」编辑区是否展开。 */
|
|
@@ -367,8 +388,35 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
367
388
|
const coverFileRef = useRef<HTMLInputElement | null>(null)
|
|
368
389
|
/** 章节列表按卷折叠(存已折叠的卷号)。 */
|
|
369
390
|
const [collapsedVolumes, setCollapsedVolumes] = useState<number[]>([])
|
|
391
|
+
/** 章节页当前选中的卷('all' = 全部卷显示在一起)。 */
|
|
392
|
+
const [selectedVolume, setSelectedVolume] = useState<number | 'all'>('all')
|
|
393
|
+
/** 剧情线编辑草稿(null = 未在编辑)。 */
|
|
394
|
+
const [plotlineDraft, setPlotlineDraft] = useState<{
|
|
395
|
+
id: string
|
|
396
|
+
name: string
|
|
397
|
+
kind: Plotline['kind']
|
|
398
|
+
goal: string
|
|
399
|
+
progress: string
|
|
400
|
+
status: Plotline['status']
|
|
401
|
+
} | null>(null)
|
|
402
|
+
/** 角色知情度编辑草稿(角色名 → 文本,每行一条)。 */
|
|
403
|
+
const [knowledgeDraft, setKnowledgeDraft] = useState<Record<string, string>>({})
|
|
404
|
+
/** 全书敏感词检查结果(null = 未运行)。 */
|
|
405
|
+
const [sensHits, setSensHits] = useState<SensitiveHit[] | null>(null)
|
|
406
|
+
const [sensScanned, setSensScanned] = useState(0)
|
|
407
|
+
/** AI 建议的剧情线候选(null = 未运行)。 */
|
|
408
|
+
const [plotlineSuggestions, setPlotlineSuggestions] = useState<Plotline[] | null>(null)
|
|
409
|
+
/** 剧情健康检查报告(null = 未运行)。 */
|
|
410
|
+
const [plotlineHealth, setPlotlineHealth] = useState<PlotlineHealthReport | null>(null)
|
|
411
|
+
/** AI 剧情方案(null = 未运行)。 */
|
|
412
|
+
const [plotlinePlan, setPlotlinePlan] = useState<PlotlinePlan | null>(null)
|
|
370
413
|
/** npm 最新版本(更新检测;null = 未检测/检测失败)。 */
|
|
371
414
|
const [npmLatest, setNpmLatest] = useState<string | null>(null)
|
|
415
|
+
/** 活动输出容器:自动滚动锚点(有新活动时跟随到底部)。 */
|
|
416
|
+
const progressEndRef = useRef<HTMLDivElement | null>(null)
|
|
417
|
+
useEffect(() => {
|
|
418
|
+
progressEndRef.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
|
|
419
|
+
}, [progress])
|
|
372
420
|
|
|
373
421
|
/** 后台检测 npm 最新版本(失败静默,不打扰)。 */
|
|
374
422
|
useEffect(() => {
|
|
@@ -383,6 +431,30 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
383
431
|
}, [])
|
|
384
432
|
/** AI 助手悬浮窗:是否打开。 */
|
|
385
433
|
const [assistantOpen, setAssistantOpen] = useState(false)
|
|
434
|
+
/** 工作进度悬浮窗:是否打开。 */
|
|
435
|
+
const [progressOpen, setProgressOpen] = useState(false)
|
|
436
|
+
/** 工作进度悬浮窗位置(localStorage 记忆)。 */
|
|
437
|
+
const [progressPos, setProgressPos] = useState(() => {
|
|
438
|
+
try {
|
|
439
|
+
const raw = window.localStorage.getItem('dsh-novel-forge.progress.float')
|
|
440
|
+
if (raw !== null) {
|
|
441
|
+
const parsed = JSON.parse(raw) as { x?: unknown; y?: unknown }
|
|
442
|
+
return { x: typeof parsed.x === 'number' ? parsed.x : 60, y: typeof parsed.y === 'number' ? parsed.y : 120 }
|
|
443
|
+
}
|
|
444
|
+
} catch { /* ignore */ }
|
|
445
|
+
return { x: 60, y: 120 }
|
|
446
|
+
})
|
|
447
|
+
/** 工作进度悬浮窗尺寸(localStorage 记忆)。 */
|
|
448
|
+
const [progressSize, setProgressSize] = useState(() => {
|
|
449
|
+
try {
|
|
450
|
+
const raw = window.localStorage.getItem('dsh-novel-forge.progress.size')
|
|
451
|
+
if (raw !== null) {
|
|
452
|
+
const parsed = JSON.parse(raw) as { w?: unknown; h?: unknown }
|
|
453
|
+
return { w: typeof parsed.w === 'number' ? parsed.w : 460, h: typeof parsed.h === 'number' ? parsed.h : 420 }
|
|
454
|
+
}
|
|
455
|
+
} catch { /* ignore */ }
|
|
456
|
+
return { w: 460, h: 420 }
|
|
457
|
+
})
|
|
386
458
|
/** 悬浮窗位置(相对面板,localStorage 记忆)。 */
|
|
387
459
|
const [assistantPos, setAssistantPos] = useState(() => {
|
|
388
460
|
try {
|
|
@@ -405,16 +477,18 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
405
477
|
} catch { /* ignore */ }
|
|
406
478
|
return { w: 420, h: 460 }
|
|
407
479
|
})
|
|
408
|
-
/**
|
|
409
|
-
const dragState = useRef<{ type: 'move' | 'resize'; startX: number; startY: number; origX: number; origY: number; origW: number; origH: number } | null>(null)
|
|
480
|
+
/** 拖拽/缩放状态(target 区分 AI 助手 / 工作进度两个悬浮窗)。 */
|
|
481
|
+
const dragState = useRef<{ type: 'move' | 'resize'; target: 'assistant' | 'progress'; startX: number; startY: number; origX: number; origY: number; origW: number; origH: number } | null>(null)
|
|
410
482
|
|
|
411
483
|
/** 悬浮窗位置/尺寸持久化。 */
|
|
412
484
|
useEffect(() => {
|
|
413
485
|
try {
|
|
414
486
|
window.localStorage.setItem('dsh-novel-forge.assistant.float', JSON.stringify(assistantPos))
|
|
415
487
|
window.localStorage.setItem('dsh-novel-forge.assistant.size', JSON.stringify(assistantSize))
|
|
488
|
+
window.localStorage.setItem('dsh-novel-forge.progress.float', JSON.stringify(progressPos))
|
|
489
|
+
window.localStorage.setItem('dsh-novel-forge.progress.size', JSON.stringify(progressSize))
|
|
416
490
|
} catch { /* ignore */ }
|
|
417
|
-
}, [assistantPos, assistantSize])
|
|
491
|
+
}, [assistantPos, assistantSize, progressPos, progressSize])
|
|
418
492
|
|
|
419
493
|
/** 全局拖拽/缩放监听(挂一次,靠 dragState 判断)。 */
|
|
420
494
|
useEffect(() => {
|
|
@@ -422,15 +496,19 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
422
496
|
const s = dragState.current
|
|
423
497
|
if (s === null) return
|
|
424
498
|
if (s.type === 'move') {
|
|
425
|
-
|
|
499
|
+
const next = {
|
|
426
500
|
x: Math.max(-340, Math.min(s.origX + e.clientX - s.startX, 3000)),
|
|
427
501
|
y: Math.max(0, Math.min(s.origY + e.clientY - s.startY, 3000)),
|
|
428
|
-
}
|
|
502
|
+
}
|
|
503
|
+
if (s.target === 'assistant') setAssistantPos(next)
|
|
504
|
+
else setProgressPos(next)
|
|
429
505
|
} else {
|
|
430
|
-
|
|
506
|
+
const next = {
|
|
431
507
|
w: Math.max(320, Math.min(s.origW + e.clientX - s.startX, 1400)),
|
|
432
508
|
h: Math.max(220, Math.min(s.origH + e.clientY - s.startY, 1200)),
|
|
433
|
-
}
|
|
509
|
+
}
|
|
510
|
+
if (s.target === 'assistant') setAssistantSize(next)
|
|
511
|
+
else setProgressSize(next)
|
|
434
512
|
}
|
|
435
513
|
}
|
|
436
514
|
const onUp = (): void => { dragState.current = null }
|
|
@@ -668,6 +746,266 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
668
746
|
}
|
|
669
747
|
}
|
|
670
748
|
|
|
749
|
+
/** 剧情线:保存草稿(新增或更新)。 */
|
|
750
|
+
const handlePlotlineSave = async (): Promise<void> => {
|
|
751
|
+
if (plotlineDraft === null) return
|
|
752
|
+
const line: Plotline = {
|
|
753
|
+
id: plotlineDraft.id,
|
|
754
|
+
name: plotlineDraft.name.trim(),
|
|
755
|
+
kind: plotlineDraft.kind,
|
|
756
|
+
goal: plotlineDraft.goal.trim(),
|
|
757
|
+
progress: plotlineDraft.progress.trim(),
|
|
758
|
+
status: plotlineDraft.status,
|
|
759
|
+
chapters: plotlineDraft.id !== ''
|
|
760
|
+
? (project?.plotlines?.find(l => l.id === plotlineDraft.id)?.chapters ?? [])
|
|
761
|
+
: [],
|
|
762
|
+
createdAt: plotlineDraft.id !== ''
|
|
763
|
+
? (project?.plotlines?.find(l => l.id === plotlineDraft.id)?.createdAt ?? new Date().toISOString())
|
|
764
|
+
: new Date().toISOString(),
|
|
765
|
+
}
|
|
766
|
+
if (line.name === '') {
|
|
767
|
+
setError('剧情线名称不能为空')
|
|
768
|
+
return
|
|
769
|
+
}
|
|
770
|
+
setBusy(true)
|
|
771
|
+
setError('')
|
|
772
|
+
try {
|
|
773
|
+
const result = await api.plotlines({ op: plotlineDraft.id !== '' ? 'update' : 'add', line })
|
|
774
|
+
setProject(prev => prev === null ? prev : { ...prev, plotlines: result.plotlines, updatedAt: new Date().toISOString() })
|
|
775
|
+
setPlotlineDraft(null)
|
|
776
|
+
pushProgress(plotlineDraft.id !== '' ? `剧情线已更新:${line.name}` : `剧情线已创建:${line.name}`, 'done')
|
|
777
|
+
} catch (err) {
|
|
778
|
+
setError((err as Error).message)
|
|
779
|
+
pushProgress(`保存剧情线失败:${(err as Error).message}`, 'error')
|
|
780
|
+
} finally {
|
|
781
|
+
setBusy(false)
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
/** 剧情线:删除。 */
|
|
786
|
+
const handlePlotlineRemove = async (id: string): Promise<void> => {
|
|
787
|
+
if (!window.confirm('确定删除这条剧情线?关联章节记录会一并移除。')) return
|
|
788
|
+
setBusy(true)
|
|
789
|
+
setError('')
|
|
790
|
+
try {
|
|
791
|
+
const result = await api.plotlines({ op: 'remove', id })
|
|
792
|
+
setProject(prev => prev === null ? prev : { ...prev, plotlines: result.plotlines, updatedAt: new Date().toISOString() })
|
|
793
|
+
pushProgress('剧情线已删除', 'done')
|
|
794
|
+
} catch (err) {
|
|
795
|
+
setError((err as Error).message)
|
|
796
|
+
} finally {
|
|
797
|
+
setBusy(false)
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
/** 剧情线:把本章关联到某条线(推进节点)。 */
|
|
802
|
+
const handlePlotlineLink = async (id: string, chapterNo: number): Promise<void> => {
|
|
803
|
+
setBusy(true)
|
|
804
|
+
setError('')
|
|
805
|
+
try {
|
|
806
|
+
const result = await api.plotlines({ op: 'link', id, chapterNo })
|
|
807
|
+
setProject(prev => prev === null ? prev : { ...prev, plotlines: result.plotlines, updatedAt: new Date().toISOString() })
|
|
808
|
+
pushProgress(`已把第 ${chapterNo} 章关联到剧情线`, 'done')
|
|
809
|
+
} catch (err) {
|
|
810
|
+
setError((err as Error).message)
|
|
811
|
+
} finally {
|
|
812
|
+
setBusy(false)
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
/** 剧情线:AI 建议候选线。 */
|
|
817
|
+
const handlePlotlineSuggest = async (): Promise<void> => {
|
|
818
|
+
setBusy(true)
|
|
819
|
+
setBusyLabel('AI 分析剧情线中…')
|
|
820
|
+
setError('')
|
|
821
|
+
try {
|
|
822
|
+
const result = await api.plotlines({ op: 'suggest' })
|
|
823
|
+
setPlotlineSuggestions(result.suggestions ?? [])
|
|
824
|
+
pushProgress(result.suggestions !== undefined && result.suggestions.length > 0
|
|
825
|
+
? `AI 建议了 ${result.suggestions.length} 条剧情线,可逐条采纳`
|
|
826
|
+
: 'AI 没有给出剧情线建议,请检查大纲是否已加载', 'done')
|
|
827
|
+
} catch (err) {
|
|
828
|
+
setError((err as Error).message)
|
|
829
|
+
pushProgress(`AI 建议剧情线失败:${(err as Error).message}`, 'error')
|
|
830
|
+
} finally {
|
|
831
|
+
setBusy(false)
|
|
832
|
+
setBusyLabel('')
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
/** 剧情线:采纳一条 AI 建议。 */
|
|
837
|
+
const handlePlotlineAdopt = async (suggestion: Plotline): Promise<void> => {
|
|
838
|
+
setBusy(true)
|
|
839
|
+
setError('')
|
|
840
|
+
try {
|
|
841
|
+
const result = await api.plotlines({ op: 'add', line: { ...suggestion, id: '' } })
|
|
842
|
+
setProject(prev => prev === null ? prev : { ...prev, plotlines: result.plotlines, updatedAt: new Date().toISOString() })
|
|
843
|
+
setPlotlineSuggestions(prev => prev === null ? prev : prev.filter(s => s !== suggestion))
|
|
844
|
+
pushProgress(`已采纳剧情线:${suggestion.name}`, 'done')
|
|
845
|
+
} catch (err) {
|
|
846
|
+
setError((err as Error).message)
|
|
847
|
+
} finally {
|
|
848
|
+
setBusy(false)
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
/** 剧情线:AI 刷新单条线进度。 */
|
|
853
|
+
const handlePlotlineRefresh = async (id: string): Promise<void> => {
|
|
854
|
+
setBusy(true)
|
|
855
|
+
setBusyLabel('AI 刷新剧情线进度中…')
|
|
856
|
+
setError('')
|
|
857
|
+
try {
|
|
858
|
+
const result = await api.plotlines({ op: 'refresh', id })
|
|
859
|
+
setProject(prev => prev === null ? prev : { ...prev, plotlines: result.plotlines, updatedAt: new Date().toISOString() })
|
|
860
|
+
const line = result.plotlines.find(l => l.id === id)
|
|
861
|
+
pushProgress(`剧情线进度已刷新:${line?.progress ?? ''}`, 'done')
|
|
862
|
+
} catch (err) {
|
|
863
|
+
setError((err as Error).message)
|
|
864
|
+
pushProgress(`刷新剧情线进度失败:${(err as Error).message}`, 'error')
|
|
865
|
+
} finally {
|
|
866
|
+
setBusy(false)
|
|
867
|
+
setBusyLabel('')
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
/** 🩺 剧情健康检查:判断是否需要新线、多少章后添加。 */
|
|
872
|
+
const handlePlotlineHealth = async (): Promise<void> => {
|
|
873
|
+
setBusy(true)
|
|
874
|
+
setBusyLabel('🩺 剧情健康检查中…')
|
|
875
|
+
setError('')
|
|
876
|
+
try {
|
|
877
|
+
const result = await api.plotlines({ op: 'health' })
|
|
878
|
+
setPlotlineHealth(result.health ?? null)
|
|
879
|
+
pushProgress(`剧情健康检查完成:${result.health?.verdict ?? '无结论'}`, 'done')
|
|
880
|
+
} catch (err) {
|
|
881
|
+
setError((err as Error).message)
|
|
882
|
+
pushProgress(`剧情健康检查失败:${(err as Error).message}`, 'error')
|
|
883
|
+
} finally {
|
|
884
|
+
setBusy(false)
|
|
885
|
+
setBusyLabel('')
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
/** ✨ AI 设计剧情方案:下一阶段方向 + 建议新线(含健康检查)。 */
|
|
890
|
+
const handlePlotlinePlan = async (): Promise<void> => {
|
|
891
|
+
setBusy(true)
|
|
892
|
+
setBusyLabel('✨ 设计剧情方案中…')
|
|
893
|
+
setError('')
|
|
894
|
+
try {
|
|
895
|
+
const result = await api.plotlines({ op: 'plan' })
|
|
896
|
+
setPlotlineHealth(result.health ?? null)
|
|
897
|
+
setPlotlinePlan(result.plan ?? null)
|
|
898
|
+
pushProgress(`剧情方案已生成:${result.plan?.suggestions.length ?? 0} 条建议新线`, 'done')
|
|
899
|
+
} catch (err) {
|
|
900
|
+
setError((err as Error).message)
|
|
901
|
+
pushProgress(`剧情方案生成失败:${(err as Error).message}`, 'error')
|
|
902
|
+
} finally {
|
|
903
|
+
setBusy(false)
|
|
904
|
+
setBusyLabel('')
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
/** 采纳方案里的一条建议线。 */
|
|
909
|
+
const handlePlanAdopt = async (suggestion: Plotline): Promise<void> => {
|
|
910
|
+
setBusy(true)
|
|
911
|
+
setError('')
|
|
912
|
+
try {
|
|
913
|
+
const result = await api.plotlines({ op: 'add', line: { ...suggestion, id: '' } })
|
|
914
|
+
setProject(prev => prev === null ? prev : { ...prev, plotlines: result.plotlines, updatedAt: new Date().toISOString() })
|
|
915
|
+
setPlotlinePlan(prev => prev === null ? prev : { ...prev, suggestions: prev.suggestions.filter(s => s !== suggestion) })
|
|
916
|
+
pushProgress(`已采纳剧情线:${suggestion.name}`, 'done')
|
|
917
|
+
} catch (err) {
|
|
918
|
+
setError((err as Error).message)
|
|
919
|
+
} finally {
|
|
920
|
+
setBusy(false)
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
/** 全书敏感词检查(硬匹配内置词库)。 */
|
|
925
|
+
const handleSensitiveScan = async (): Promise<void> => {
|
|
926
|
+
setBusy(true)
|
|
927
|
+
setBusyLabel('敏感词检查中…')
|
|
928
|
+
setError('')
|
|
929
|
+
try {
|
|
930
|
+
const result = await api.sensitiveCheck({ all: true })
|
|
931
|
+
setSensHits(result.hits)
|
|
932
|
+
setSensScanned(result.scannedChapters)
|
|
933
|
+
pushProgress(result.hits.length > 0
|
|
934
|
+
? `敏感词检查:${result.hits.length} 处命中(${new Set(result.hits.map(h => h.chapterNo)).size} 章受影响)`
|
|
935
|
+
: `敏感词检查完成:扫描 ${result.scannedChapters} 章,未命中违禁词`, result.hits.length > 0 ? 'error' : 'done')
|
|
936
|
+
} catch (err) {
|
|
937
|
+
setError((err as Error).message)
|
|
938
|
+
pushProgress(`敏感词检查失败:${(err as Error).message}`, 'error')
|
|
939
|
+
} finally {
|
|
940
|
+
setBusy(false)
|
|
941
|
+
setBusyLabel('')
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
/** 作者复盘补跑:全书缺失章节(流式)。 */
|
|
946
|
+
const handleAuthorBackfillAll = async (): Promise<void> => {
|
|
947
|
+
const missing = chapters.filter(c => c.status !== 'pending' && c.status !== 'generating' && c.authorReview === undefined).length
|
|
948
|
+
setBusy(true)
|
|
949
|
+
setBusyLabel(`补齐历史章节作者复盘(${missing} 章)…`)
|
|
950
|
+
setError('')
|
|
951
|
+
try {
|
|
952
|
+
await api.reviewBackfillAll(frame => { applyJobFrame(frame, () => '') })
|
|
953
|
+
} catch (err) {
|
|
954
|
+
setError((err as Error).message)
|
|
955
|
+
pushProgress(`补齐作者复盘失败:${(err as Error).message}`, 'error')
|
|
956
|
+
} finally {
|
|
957
|
+
setBusy(false)
|
|
958
|
+
setBusyLabel('')
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
/** 作者复盘补跑:单章。 */
|
|
963
|
+
const handleAuthorBackfillChapter = async (no: number): Promise<void> => {
|
|
964
|
+
setBusy(true)
|
|
965
|
+
setBusyLabel(`生成第 ${no} 章作者复盘…`)
|
|
966
|
+
setError('')
|
|
967
|
+
try {
|
|
968
|
+
const result = await api.reviewBackfillChapter(no)
|
|
969
|
+
setProject(prev => prev === null ? prev : {
|
|
970
|
+
...prev,
|
|
971
|
+
chapters: prev.chapters.map(c => c.no === result.no ? { ...c, authorReview: result.review } : c),
|
|
972
|
+
})
|
|
973
|
+
const r = result.review
|
|
974
|
+
pushProgress(
|
|
975
|
+
`📋 第${no}章作者复盘:钩子${r.hookHonored ? '已兑现 ✓' : '未兑现 ✗'} · 结尾钩子 ${r.endingHook}/10`,
|
|
976
|
+
r.hookHonored && r.endingHook >= 6 ? 'done' : 'error',
|
|
977
|
+
)
|
|
978
|
+
} catch (err) {
|
|
979
|
+
setError((err as Error).message)
|
|
980
|
+
pushProgress(`生成第 ${no} 章作者复盘失败:${(err as Error).message}`, 'error')
|
|
981
|
+
} finally {
|
|
982
|
+
setBusy(false)
|
|
983
|
+
setBusyLabel('')
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
/** 保存角色知情度(bible.characters 整体更新)。 */
|
|
988
|
+
const handleKnowledgeSave = async (): Promise<void> => {
|
|
989
|
+
if (bible === undefined) return
|
|
990
|
+
const characters = bible.characters.map(card => ({
|
|
991
|
+
...card,
|
|
992
|
+
knowledge: (knowledgeDraft[card.name] ?? (card.knowledge ?? []).join('\n'))
|
|
993
|
+
.split('\n').map(l => l.trim()).filter(l => l !== ''),
|
|
994
|
+
}))
|
|
995
|
+
setBusy(true)
|
|
996
|
+
setError('')
|
|
997
|
+
try {
|
|
998
|
+
const result = await api.biblePatch({ characters })
|
|
999
|
+
setProject(prev => prev === null || prev.bible === undefined ? prev : { ...prev, bible: result.bible, updatedAt: new Date().toISOString() })
|
|
1000
|
+
pushProgress('角色知情度已保存(生成/审稿都会严格遵守信息差)', 'done')
|
|
1001
|
+
} catch (err) {
|
|
1002
|
+
setError((err as Error).message)
|
|
1003
|
+
pushProgress(`保存知情度失败:${(err as Error).message}`, 'error')
|
|
1004
|
+
} finally {
|
|
1005
|
+
setBusy(false)
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
|
|
671
1009
|
/** 保存世界观规则编辑(bible tab,每行一条)。 */
|
|
672
1010
|
const handleSaveWorldRules = async (): Promise<void> => {
|
|
673
1011
|
if (bible === undefined) return
|
|
@@ -1079,6 +1417,8 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
1079
1417
|
lastDeltaChars.current = 0
|
|
1080
1418
|
liveChars.current = 0
|
|
1081
1419
|
currentJobNo.current = frame.no
|
|
1420
|
+
// 任务开始时自动弹出工作进度悬浮窗(顶部进度条已移除,进度只在这里可见)。
|
|
1421
|
+
setProgressOpen(true)
|
|
1082
1422
|
setProject(prev => prev === null ? prev : {
|
|
1083
1423
|
...prev,
|
|
1084
1424
|
chapters: prev.chapters.map(c => c.no === frame.no ? { ...c, status: 'generating', error: undefined } : c),
|
|
@@ -1117,6 +1457,21 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
1117
1457
|
score: frame.report.score,
|
|
1118
1458
|
verdict: frame.report.verdict,
|
|
1119
1459
|
}), frame.report.passed ? 'done' : 'error')
|
|
1460
|
+
} else if (frame.type === 'author-review') {
|
|
1461
|
+
setProject(prev => prev === null ? prev : {
|
|
1462
|
+
...prev,
|
|
1463
|
+
chapters: prev.chapters.map(c => c.no === frame.no ? { ...c, authorReview: frame.review } : c),
|
|
1464
|
+
})
|
|
1465
|
+
const r = frame.review
|
|
1466
|
+
pushProgress(
|
|
1467
|
+
`📋 第${frame.no}章作者复盘:钩子${r.hookHonored ? '已兑现 ✓' : '未兑现 ✗'} · 结尾钩子 ${r.endingHook}/10 · ${r.plotlineProgress !== '' ? r.plotlineProgress : '无实质推进'}`,
|
|
1468
|
+
r.hookHonored && r.endingHook >= 6 ? 'done' : r.endingHook < 6 || !r.hookHonored ? 'error' : 'info',
|
|
1469
|
+
)
|
|
1470
|
+
} else if (frame.type === 'author-backfill-done') {
|
|
1471
|
+
clearLiveProgress()
|
|
1472
|
+
setLiveBar(null)
|
|
1473
|
+
pushProgress(`✅ 历史章节作者复盘补齐完成(共 ${frame.count} 章)`, 'done')
|
|
1474
|
+
void refresh(false)
|
|
1120
1475
|
} else if (frame.type === 'drafted') {
|
|
1121
1476
|
// 润色/重写完成:产物作为待确认草稿,展示在工作区预览,由用户决定。
|
|
1122
1477
|
clearLiveProgress()
|
|
@@ -1172,7 +1527,8 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
1172
1527
|
// 失败自动重试:最多尝试 2 次,间隔 3 秒(网络抖动/LLM 偶发失败自愈)。
|
|
1173
1528
|
for (let attempt = 1; attempt <= 2; attempt++) {
|
|
1174
1529
|
try {
|
|
1175
|
-
|
|
1530
|
+
// 批量连写也走完整质量门:生成 → 自动审稿 → 作者复盘(false = 不跳过审稿)。
|
|
1531
|
+
await api.generate(chapter.no, false, frame => { applyJobFrame(frame, n => tt('progress.generating', { no: n, title: (project?.chapters.find(c => c.no === n)?.title ?? '') })) })
|
|
1176
1532
|
lastError = null
|
|
1177
1533
|
break
|
|
1178
1534
|
} catch (err) {
|
|
@@ -1489,7 +1845,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
1489
1845
|
})()
|
|
1490
1846
|
|
|
1491
1847
|
return (
|
|
1492
|
-
<div className={css.panel}>
|
|
1848
|
+
<div className={css.panel} data-nf-theme={panelTheme}>
|
|
1493
1849
|
{viewMode === 'shelf' ? (
|
|
1494
1850
|
/* 书架首页:默认视图,选择一本书进入工作台 */
|
|
1495
1851
|
<ShelfView
|
|
@@ -1548,22 +1904,26 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
1548
1904
|
) : (
|
|
1549
1905
|
<>
|
|
1550
1906
|
<div className={css.panelHeader}>
|
|
1551
|
-
<
|
|
1552
|
-
<
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
)
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1907
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 2, minWidth: 0 }}>
|
|
1908
|
+
<h2 className={css.panelTitle}>
|
|
1909
|
+
<button type="button" className={css.iconButton} title="返回书架" aria-label="返回书架" onClick={() => { setViewMode('shelf') }}>
|
|
1910
|
+
←
|
|
1911
|
+
</button>
|
|
1912
|
+
{tt('panel.title')}
|
|
1913
|
+
</h2>
|
|
1914
|
+
{(project?.bookName !== '' && project?.bookName !== undefined) && (
|
|
1915
|
+
<div className={css.panelSubtitle}>
|
|
1916
|
+
<span>📖 {project.bookName}</span>
|
|
1917
|
+
{chapters.length > 0 && (
|
|
1918
|
+
<span className={css.headerProgress}>
|
|
1919
|
+
已完成 <b>{doneCount}</b>/{chapters.length} 章
|
|
1920
|
+
<span className={css.headerProgressDot} />
|
|
1921
|
+
通过 <b>{chapters.filter(c => c.status === 'approved').length}</b>
|
|
1922
|
+
</span>
|
|
1923
|
+
)}
|
|
1924
|
+
</div>
|
|
1565
1925
|
)}
|
|
1566
|
-
</
|
|
1926
|
+
</div>
|
|
1567
1927
|
<div style={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
|
1568
1928
|
<button
|
|
1569
1929
|
type="button"
|
|
@@ -1594,13 +1954,15 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
1594
1954
|
key={tab.id}
|
|
1595
1955
|
type="button"
|
|
1596
1956
|
role="tab"
|
|
1597
|
-
aria-selected={activeTab === tab.id || (tab.id === 'assistant' && assistantOpen)}
|
|
1598
|
-
data-active={activeTab === tab.id || (tab.id === 'assistant' && assistantOpen) ? '' : undefined}
|
|
1957
|
+
aria-selected={activeTab === tab.id || (tab.id === 'assistant' && assistantOpen) || (tab.id === 'progress' && progressOpen)}
|
|
1958
|
+
data-active={activeTab === tab.id || (tab.id === 'assistant' && assistantOpen) || (tab.id === 'progress' && progressOpen) ? '' : undefined}
|
|
1599
1959
|
className={css.navTab}
|
|
1600
1960
|
title={tab.label}
|
|
1601
1961
|
onClick={() => {
|
|
1602
1962
|
if (tab.id === 'assistant') {
|
|
1603
1963
|
setAssistantOpen(true)
|
|
1964
|
+
} else if (tab.id === 'progress') {
|
|
1965
|
+
setProgressOpen(v => !v)
|
|
1604
1966
|
} else {
|
|
1605
1967
|
setActiveTab(tab.id)
|
|
1606
1968
|
}
|
|
@@ -1669,19 +2031,6 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
1669
2031
|
<div className={css.panelContent}>
|
|
1670
2032
|
{error !== '' && <div className={css.card} style={{ borderColor: 'var(--nf-error)' }}><span style={{ color: 'var(--nf-error)' }}>{tt('common.error')}: {error}</span></div>}
|
|
1671
2033
|
{notice !== '' && <div className={css.card}><span style={{ color: 'var(--nf-success)' }}>{notice}</span></div>}
|
|
1672
|
-
{busy && busyLabel !== '' && (
|
|
1673
|
-
<div className={css.card}>
|
|
1674
|
-
<div className={css.busyRow}>
|
|
1675
|
-
<span style={{ color: 'var(--nf-accent)' }}>{busyLabel}…</span>
|
|
1676
|
-
{liveBar !== null && <span className={css.liveText}>{liveBar.text}</span>}
|
|
1677
|
-
</div>
|
|
1678
|
-
{liveBar?.ratio !== undefined && (
|
|
1679
|
-
<div className={css.bigProgressBar}>
|
|
1680
|
-
<div className={css.bigProgressBarFill} style={{ width: `${Math.round(liveBar.ratio * 100)}%` }} />
|
|
1681
|
-
</div>
|
|
1682
|
-
)}
|
|
1683
|
-
</div>
|
|
1684
|
-
)}
|
|
1685
2034
|
|
|
1686
2035
|
{/* 遗留草稿提示:刷新页面后仍有未采纳的润色/修订草稿 */}
|
|
1687
2036
|
{workspace === null && draftNo !== null && (
|
|
@@ -1700,10 +2049,10 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
1700
2049
|
</div>
|
|
1701
2050
|
)}
|
|
1702
2051
|
|
|
1703
|
-
{/*
|
|
2052
|
+
{/* 章节编辑页(独占整页:点「编辑」进入,返回后回到原页面;无卡片盒子,撑满内容区) */}
|
|
1704
2053
|
{workspace !== null && (
|
|
1705
|
-
<div className={css.
|
|
1706
|
-
<div className={css.
|
|
2054
|
+
<div className={css.wsPage}>
|
|
2055
|
+
<div className={css.wsPageHeader}>
|
|
1707
2056
|
<button type="button" className={`${css.button} ${css.buttonSmall}`} onClick={() => { setWorkspace(null) }} title="返回章节列表(草稿不丢失)">
|
|
1708
2057
|
← 返回
|
|
1709
2058
|
</button>
|
|
@@ -1820,7 +2169,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
1820
2169
|
</div>
|
|
1821
2170
|
)}
|
|
1822
2171
|
{workspace.draft !== null && (
|
|
1823
|
-
<div className={css.wsPreview}>
|
|
2172
|
+
<div className={css.wsPreview} style={{ flex: 1, minHeight: 0 }}>
|
|
1824
2173
|
<div className={css.busyRow}>
|
|
1825
2174
|
<span className={css.meta}>优化预览({workspace.draft.length} 字)</span>
|
|
1826
2175
|
<span style={{ display: 'flex', gap: 8 }}>
|
|
@@ -1894,17 +2243,15 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
1894
2243
|
))}
|
|
1895
2244
|
</div>
|
|
1896
2245
|
</div>
|
|
1897
|
-
{/* HeroFact 4 格 */}
|
|
1898
|
-
<div className={css.dashFacts}>
|
|
1899
|
-
<div className={css.dashFact}><span className={css.meta}>已沉淀章节</span><b>{doneCount} 章</b></div>
|
|
1900
|
-
<div className={css.dashFact}><span className={css.meta}>累计字数</span><b>{totalChars} 字</b></div>
|
|
1901
|
-
<div className={css.dashFact}><span className={css.meta}>当前卷</span><b>{currentVolumeName}</b></div>
|
|
1902
|
-
<div className={css.dashFact}><span className={css.meta}>最近创作</span><b>{lastUpdated}</b></div>
|
|
1903
|
-
</div>
|
|
1904
2246
|
</div>
|
|
1905
2247
|
|
|
1906
|
-
{/*
|
|
2248
|
+
{/* 状态摘要条:全书进度 + 六个统计格,同样式并排一行 */}
|
|
1907
2249
|
<div className={css.assetGrid}>
|
|
2250
|
+
<div className={css.assetStat}>
|
|
2251
|
+
<span className={css.assetStatLabel}>全书进度</span>
|
|
2252
|
+
<span className={css.assetStatValue} style={{ color: 'var(--nf-accent)' }}>{doneCount}/{chapters.length} 章</span>
|
|
2253
|
+
<span className={css.assetStatDetail}>{chapters.length > 0 ? Math.round((doneCount / chapters.length) * 100) : 0}%</span>
|
|
2254
|
+
</div>
|
|
1908
2255
|
<div className={css.assetStat}>
|
|
1909
2256
|
<span className={css.assetStatLabel}>已通过审稿</span>
|
|
1910
2257
|
<span className={css.assetStatValue} style={{ color: approvedCount > 0 ? 'var(--nf-success)' : undefined }}>{approvedCount}</span>
|
|
@@ -1925,69 +2272,127 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
1925
2272
|
<span className={css.assetStatValue}>{totalChars}</span>
|
|
1926
2273
|
<span className={css.assetStatDetail}>已生成正文累计</span>
|
|
1927
2274
|
</div>
|
|
2275
|
+
<div className={css.assetStat}>
|
|
2276
|
+
<span className={css.assetStatLabel}>当前卷</span>
|
|
2277
|
+
<span className={css.assetStatValue} style={{ fontSize: 13 }}>{currentVolumeName}</span>
|
|
2278
|
+
<span className={css.assetStatDetail}>正在推进的卷</span>
|
|
2279
|
+
</div>
|
|
2280
|
+
<div className={css.assetStat}>
|
|
2281
|
+
<span className={css.assetStatLabel}>最近创作</span>
|
|
2282
|
+
<span className={css.assetStatValue} style={{ fontSize: 13 }}>{lastUpdated}</span>
|
|
2283
|
+
<span className={css.assetStatDetail}>最近生成/编辑时间</span>
|
|
2284
|
+
</div>
|
|
1928
2285
|
</div>
|
|
1929
2286
|
|
|
1930
|
-
{/*
|
|
1931
|
-
<div className={css.
|
|
1932
|
-
<div className={css.
|
|
1933
|
-
<span className={css.cardTitle}
|
|
1934
|
-
{
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
2287
|
+
{/* 资产健康:独占一行(道藏/卷计划/写作资产/伏笔 + 全书质检) */}
|
|
2288
|
+
<div className={css.card}>
|
|
2289
|
+
<div className={css.row} style={{ justifyContent: 'space-between' }}>
|
|
2290
|
+
<span className={css.cardTitle}>资产健康</span>
|
|
2291
|
+
<button type="button" className={`${css.button} ${css.buttonSmall}`} disabled={busy || doneCount === 0} onClick={() => { void handleAudit() }} title="LLM 扫描全本已生成章节,检查人名/境界/资源/时间线矛盾">
|
|
2292
|
+
🔍 全书质检
|
|
2293
|
+
</button>
|
|
2294
|
+
</div>
|
|
2295
|
+
<div className={css.assetGrid}>
|
|
2296
|
+
<div className={css.assetStat}>
|
|
2297
|
+
<span className={css.assetStatLabel}>道藏</span>
|
|
2298
|
+
<span className={css.assetStatValue} style={{ color: bible !== undefined ? 'var(--nf-success)' : 'var(--nf-text-3)' }}>
|
|
2299
|
+
{bible !== undefined ? `✓ ${bible.worldRules.length} 条规则` : '未生成'}
|
|
2300
|
+
</span>
|
|
2301
|
+
<span className={css.assetStatDetail}>
|
|
2302
|
+
{bible !== undefined ? `${bible.characters.length} 人物 · ${bible.redLines.length} 红线` : '提炼人设 / 世界观 / 金手指'}
|
|
2303
|
+
</span>
|
|
2304
|
+
</div>
|
|
2305
|
+
<div className={css.assetStat}>
|
|
2306
|
+
<span className={css.assetStatLabel}>卷计划</span>
|
|
2307
|
+
<span className={css.assetStatValue} style={{ color: volumes !== undefined ? 'var(--nf-success)' : 'var(--nf-text-3)' }}>
|
|
2308
|
+
{volumes !== undefined ? `${volumes.length} 卷` : '未生成'}
|
|
2309
|
+
</span>
|
|
2310
|
+
<span className={css.assetStatDetail} title={volumes?.map(v => v.title).join(' / ')}>
|
|
2311
|
+
{volumes !== undefined ? volumes.map(v => v.title).join(' / ') : '按剧情弧线划分全书'}
|
|
2312
|
+
</span>
|
|
2313
|
+
</div>
|
|
2314
|
+
<div className={css.assetStat}>
|
|
2315
|
+
<span className={css.assetStatLabel}>写作资产</span>
|
|
2316
|
+
<span className={css.assetStatValue} style={{ color: assetCount > 0 ? 'var(--nf-success)' : 'var(--nf-text-3)' }}>
|
|
2317
|
+
{assetCount} 项
|
|
2318
|
+
</span>
|
|
2319
|
+
<span className={css.assetStatDetail} title={assetSummary}>{assetSummary}</span>
|
|
2320
|
+
</div>
|
|
2321
|
+
<div className={css.assetStat}>
|
|
2322
|
+
<span className={css.assetStatLabel}>伏笔</span>
|
|
2323
|
+
<span className={css.assetStatValue}>{foreshadows.length} 条</span>
|
|
2324
|
+
<span className={css.assetStatDetail}>
|
|
2325
|
+
{foreshadows.filter(f => f.status === 'planned').length} 待埋 · {foreshadows.filter(f => f.status === 'resolved').length} 已回收
|
|
2326
|
+
</span>
|
|
2327
|
+
</div>
|
|
1949
2328
|
</div>
|
|
2329
|
+
</div>
|
|
2330
|
+
|
|
2331
|
+
{/* 剧情线进度 | 作者复盘趋势:精简摘要卡,并排(点击跳转详情页) */}
|
|
2332
|
+
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(300px, 1fr))', gap: 14, alignItems: 'stretch' }}>
|
|
1950
2333
|
<div className={css.card}>
|
|
1951
|
-
<div className={css.row} style={{ justifyContent: 'space-between' }}>
|
|
1952
|
-
<span className={css.cardTitle}
|
|
1953
|
-
<button type="button" className={`${css.button} ${css.buttonSmall}`}
|
|
1954
|
-
|
|
2334
|
+
<div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}>
|
|
2335
|
+
<span className={css.cardTitle}>🧵 {tt('plotlines.workflowTitle')}({(project?.plotlines ?? []).length} 条)</span>
|
|
2336
|
+
<button type="button" className={`${css.button} ${css.buttonSmall}`} onClick={() => { setActiveTab('plotlines') }} title="查看完整剧情线管理页">
|
|
2337
|
+
查看全部 →
|
|
1955
2338
|
</button>
|
|
1956
2339
|
</div>
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
{
|
|
1971
|
-
</span>
|
|
1972
|
-
<span className={css.assetStatDetail} title={volumes?.map(v => v.title).join(' / ')}>
|
|
1973
|
-
{volumes !== undefined ? volumes.map(v => v.title).join(' / ') : '按剧情弧线划分全书'}
|
|
1974
|
-
</span>
|
|
1975
|
-
</div>
|
|
1976
|
-
<div className={css.assetStat}>
|
|
1977
|
-
<span className={css.assetStatLabel}>写作资产</span>
|
|
1978
|
-
<span className={css.assetStatValue} style={{ color: assetCount > 0 ? 'var(--nf-success)' : 'var(--nf-text-3)' }}>
|
|
1979
|
-
{assetCount} 项
|
|
1980
|
-
</span>
|
|
1981
|
-
<span className={css.assetStatDetail} title={assetSummary}>{assetSummary}</span>
|
|
1982
|
-
</div>
|
|
1983
|
-
<div className={css.assetStat}>
|
|
1984
|
-
<span className={css.assetStatLabel}>伏笔</span>
|
|
1985
|
-
<span className={css.assetStatValue}>{foreshadows.length} 条</span>
|
|
1986
|
-
<span className={css.assetStatDetail}>
|
|
1987
|
-
{foreshadows.filter(f => f.status === 'planned').length} 待埋 · {foreshadows.filter(f => f.status === 'resolved').length} 已回收
|
|
2340
|
+
{(() => {
|
|
2341
|
+
const all = project?.plotlines ?? []
|
|
2342
|
+
const active = all.filter(l => l.status === 'active').length
|
|
2343
|
+
const paused = all.filter(l => l.status === 'paused').length
|
|
2344
|
+
const resolved = all.filter(l => l.status === 'resolved').length
|
|
2345
|
+
const main = all.find(l => l.kind === 'main' && l.status === 'active')
|
|
2346
|
+
const withLinks = all.filter(l => l.chapters.length > 0)
|
|
2347
|
+
const latest = withLinks.sort((a, b) => Math.max(...b.chapters) - Math.max(...a.chapters))[0]
|
|
2348
|
+
if (all.length === 0) return <span className={css.meta}>{tt('plotlines.workflowEmpty')}</span>
|
|
2349
|
+
return (
|
|
2350
|
+
<span className={css.meta}>
|
|
2351
|
+
{active} 推进中 · {paused} 暂停 · {resolved} 已完结
|
|
2352
|
+
{main !== undefined && <> · 主线:{main.name}</>}
|
|
2353
|
+
{latest !== undefined && <> · 最近推进:{latest.name}(第 {Math.max(...latest.chapters)} 章)</>}
|
|
1988
2354
|
</span>
|
|
2355
|
+
)
|
|
2356
|
+
})()}
|
|
2357
|
+
</div>
|
|
2358
|
+
|
|
2359
|
+
<div className={css.card}>
|
|
2360
|
+
<div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}>
|
|
2361
|
+
<span className={css.cardTitle}>📋 作者复盘(最近 {Math.min(6, chapters.filter(c => c.authorReview !== undefined).length)} 章)</span>
|
|
2362
|
+
<div className={css.row}>
|
|
2363
|
+
{(() => {
|
|
2364
|
+
const missing = chapters.filter(c => c.status !== 'pending' && c.status !== 'generating' && c.authorReview === undefined).length
|
|
2365
|
+
return missing > 0 && (
|
|
2366
|
+
<button
|
|
2367
|
+
type="button"
|
|
2368
|
+
className={`${css.button} ${css.buttonSmall}`}
|
|
2369
|
+
disabled={busy}
|
|
2370
|
+
onClick={() => { void handleAuthorBackfillAll() }}
|
|
2371
|
+
title="对历史已写章节逐章补跑作者复盘(不重新生成正文,每章约 2000 token)"
|
|
2372
|
+
>
|
|
2373
|
+
↻ 补齐({missing})
|
|
2374
|
+
</button>
|
|
2375
|
+
)
|
|
2376
|
+
})()}
|
|
2377
|
+
<button type="button" className={`${css.button} ${css.buttonSmall}`} onClick={() => { setActiveTab('reviews') }} title="查看按卷分组的全部复盘记录">
|
|
2378
|
+
查看全部 →
|
|
2379
|
+
</button>
|
|
1989
2380
|
</div>
|
|
1990
2381
|
</div>
|
|
2382
|
+
{(() => {
|
|
2383
|
+
const reviewed = chapters.filter(c => c.authorReview !== undefined)
|
|
2384
|
+
if (reviewed.length === 0) {
|
|
2385
|
+
return <span className={css.meta}>尚无作者复盘——生成/审稿后自动生成,或点「补齐」为已写章节补跑。</span>
|
|
2386
|
+
}
|
|
2387
|
+
const honored = reviewed.filter(c => c.authorReview!.hookHonored).length
|
|
2388
|
+
const avg = Math.round(reviewed.reduce((s, c) => s + c.authorReview!.endingHook, 0) / reviewed.length * 10) / 10
|
|
2389
|
+
const last = reviewed[reviewed.length - 1]!
|
|
2390
|
+
return (
|
|
2391
|
+
<span className={css.meta}>
|
|
2392
|
+
钩子兑现 {honored}/{reviewed.length} · 结尾钩子均分 {avg} · 最近:第 {last.no} 章(钩子{last.authorReview!.hookHonored ? '✓' : '✗'} {last.authorReview!.endingHook}/10)
|
|
2393
|
+
</span>
|
|
2394
|
+
)
|
|
2395
|
+
})()}
|
|
1991
2396
|
</div>
|
|
1992
2397
|
</div>
|
|
1993
2398
|
|
|
@@ -2028,6 +2433,26 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
2028
2433
|
)}
|
|
2029
2434
|
</div>
|
|
2030
2435
|
)}
|
|
2436
|
+
|
|
2437
|
+
{/* 待办队列(活动输出已移入「工具 → 工作进度」悬浮窗) */}
|
|
2438
|
+
<div className={css.card}>
|
|
2439
|
+
<span className={css.cardTitle}>待办队列</span>
|
|
2440
|
+
{todos.length === 0 ? (
|
|
2441
|
+
<span className={css.meta}>🎉 暂无待办,一切顺畅</span>
|
|
2442
|
+
) : (
|
|
2443
|
+
todos.map((todo, i) => (
|
|
2444
|
+
<div key={i} className={`${css.todoItem} ${todo.tone === 'danger' ? css.todoDanger : todo.tone === 'warning' ? css.todoWarning : css.todoInfo}`}>
|
|
2445
|
+
<span className={css.todoText}>
|
|
2446
|
+
{todo.title}
|
|
2447
|
+
{todo.description !== '' && <span className={css.meta}> — {todo.description}</span>}
|
|
2448
|
+
</span>
|
|
2449
|
+
<button type="button" className={`${css.button} ${css.buttonSmall}`} disabled={busy} onClick={() => { todo.onClick() }}>
|
|
2450
|
+
{todo.actionLabel}
|
|
2451
|
+
</button>
|
|
2452
|
+
</div>
|
|
2453
|
+
))
|
|
2454
|
+
)}
|
|
2455
|
+
</div>
|
|
2031
2456
|
</>
|
|
2032
2457
|
)}
|
|
2033
2458
|
|
|
@@ -2239,9 +2664,9 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
2239
2664
|
{activeTab === 'plan' && (
|
|
2240
2665
|
<>
|
|
2241
2666
|
<div className={css.card}>
|
|
2242
|
-
<div className={css.row} style={{ justifyContent: 'space-between' }}>
|
|
2667
|
+
<div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}>
|
|
2243
2668
|
<span className={css.cardTitle}>{tt('tab.plan')}</span>
|
|
2244
|
-
<div className={css.row}>
|
|
2669
|
+
<div className={css.row} style={{ flexWrap: 'wrap' }}>
|
|
2245
2670
|
<span className={css.meta}>{tt('plan.generateHint')}</span>
|
|
2246
2671
|
<input
|
|
2247
2672
|
className={css.input}
|
|
@@ -2256,23 +2681,78 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
2256
2681
|
<button type="button" className={`${css.button} ${css.buttonPrimary}`} disabled={busy || outlineText.length < 50} onClick={() => { void handlePlan() }}>
|
|
2257
2682
|
{tt('plan.generate')}
|
|
2258
2683
|
</button>
|
|
2684
|
+
<button type="button" className={`${css.button} ${css.buttonSmall}`} disabled={busy || doneCount === 0} onClick={() => { void handleSensitiveScan() }} title={tt('sensitive.hint')}>
|
|
2685
|
+
🔞 {tt('sensitive.scanAll')}
|
|
2686
|
+
</button>
|
|
2259
2687
|
</div>
|
|
2260
2688
|
</div>
|
|
2261
|
-
{volumes !== undefined && volumes.length > 0 && (
|
|
2262
|
-
<div className={css.row}>
|
|
2263
|
-
{volumes.map(v => (
|
|
2264
|
-
<span key={v.no} className={css.badge} style={{ borderColor: 'var(--nf-accent)', color: 'var(--nf-accent)' }}>
|
|
2265
|
-
{v.no}. {v.title}({v.chapterStart}-{v.chapterEnd})
|
|
2266
|
-
</span>
|
|
2267
|
-
))}
|
|
2268
|
-
</div>
|
|
2269
|
-
)}
|
|
2270
2689
|
</div>
|
|
2271
2690
|
|
|
2691
|
+
{sensHits !== null && (
|
|
2692
|
+
<div className={css.card} style={{ borderColor: sensHits.length > 0 ? 'var(--nf-warn)' : 'var(--nf-success)' }}>
|
|
2693
|
+
<div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}>
|
|
2694
|
+
<span className={css.cardTitle}>
|
|
2695
|
+
{sensHits.length === 0
|
|
2696
|
+
? tt('sensitive.clean', { n: sensScanned })
|
|
2697
|
+
: tt('sensitive.hits', { n: sensHits.length, chapters: new Set(sensHits.map(h => h.chapterNo)).size })}
|
|
2698
|
+
</span>
|
|
2699
|
+
<button type="button" className={`${css.button} ${css.buttonSmall}`} onClick={() => { setSensHits(null) }}>收起</button>
|
|
2700
|
+
</div>
|
|
2701
|
+
<span className={css.meta}>{tt('sensitive.hint')}</span>
|
|
2702
|
+
{sensHits.length > 0 && (
|
|
2703
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, maxHeight: 300, overflowY: 'auto', fontSize: 12 }}>
|
|
2704
|
+
{sensHits.map((hit, i) => (
|
|
2705
|
+
<div key={i} style={{ display: 'flex', gap: 8, alignItems: 'flex-start', border: '1px solid var(--nf-border)', borderRadius: 6, padding: '4px 8px' }}>
|
|
2706
|
+
<span className={css.badge} style={{ borderColor: 'var(--nf-warn)', color: 'var(--nf-warn)', flex: 'none' }}>
|
|
2707
|
+
{hit.chapterNo > 0 ? `第${hit.chapterNo}章` : '文本'}
|
|
2708
|
+
</span>
|
|
2709
|
+
<span className={css.meta} style={{ flex: 1 }}>
|
|
2710
|
+
<b style={{ color: 'var(--nf-error)' }}>{hit.word}</b> ×{hit.count} · [{hit.category}]
|
|
2711
|
+
</span>
|
|
2712
|
+
{hit.chapterNo > 0 && (
|
|
2713
|
+
<button
|
|
2714
|
+
type="button"
|
|
2715
|
+
className={`${css.button} ${css.buttonSmall}`}
|
|
2716
|
+
disabled={busy}
|
|
2717
|
+
onClick={() => { void openWorkspace(hit.chapterNo, tt('sensitive.fixPrefill', { word: hit.word, category: hit.category })) }}
|
|
2718
|
+
>
|
|
2719
|
+
{tt('sensitive.goFix')}
|
|
2720
|
+
</button>
|
|
2721
|
+
)}
|
|
2722
|
+
</div>
|
|
2723
|
+
))}
|
|
2724
|
+
</div>
|
|
2725
|
+
)}
|
|
2726
|
+
</div>
|
|
2727
|
+
)}
|
|
2728
|
+
|
|
2272
2729
|
{chapters.length > 0 && (
|
|
2273
2730
|
<div className={css.card}>
|
|
2274
|
-
<div className={css.row} style={{ justifyContent: 'space-between' }}>
|
|
2275
|
-
<
|
|
2731
|
+
<div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}>
|
|
2732
|
+
<div className={css.row} style={{ flexWrap: 'wrap', gap: 6 }}>
|
|
2733
|
+
{(volumes !== undefined && volumes.length > 0) && (
|
|
2734
|
+
<>
|
|
2735
|
+
<button
|
|
2736
|
+
type="button"
|
|
2737
|
+
className={`${css.button} ${css.buttonSmall} ${selectedVolume === 'all' ? css.buttonPrimary : ''}`}
|
|
2738
|
+
onClick={() => { setSelectedVolume('all') }}
|
|
2739
|
+
>
|
|
2740
|
+
全部卷
|
|
2741
|
+
</button>
|
|
2742
|
+
{volumes.map(v => (
|
|
2743
|
+
<button
|
|
2744
|
+
key={v.no}
|
|
2745
|
+
type="button"
|
|
2746
|
+
className={`${css.button} ${css.buttonSmall} ${selectedVolume === v.no ? css.buttonPrimary : ''}`}
|
|
2747
|
+
onClick={() => { setSelectedVolume(v.no) }}
|
|
2748
|
+
title={`第${v.no}卷 · ${v.chapterStart}-${v.chapterEnd} 章`}
|
|
2749
|
+
>
|
|
2750
|
+
{v.no}. {v.title}({v.chapterStart}-{v.chapterEnd})
|
|
2751
|
+
</button>
|
|
2752
|
+
))}
|
|
2753
|
+
</>
|
|
2754
|
+
)}
|
|
2755
|
+
</div>
|
|
2276
2756
|
{pendingCount > 0 && (
|
|
2277
2757
|
<button type="button" className={`${css.button} ${css.buttonPrimary}`} disabled={busy} onClick={() => { void handleWriteAll() }}>
|
|
2278
2758
|
{tt('plan.writeAllPending')}({pendingCount})
|
|
@@ -2280,7 +2760,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
2280
2760
|
)}
|
|
2281
2761
|
</div>
|
|
2282
2762
|
<div className={css.chapterList}>
|
|
2283
|
-
{chapterGroups.map(group => {
|
|
2763
|
+
{chapterGroups.filter(g => selectedVolume === 'all' || g.no === selectedVolume).map(group => {
|
|
2284
2764
|
const collapsed = group.no !== 0 && collapsedVolumes.includes(group.no)
|
|
2285
2765
|
const groupDone = group.chapters.filter(c => c.status === 'approved' || c.status === 'written' || c.status === 'rejected').length
|
|
2286
2766
|
return (
|
|
@@ -2344,6 +2824,41 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
2344
2824
|
)}
|
|
2345
2825
|
</div>
|
|
2346
2826
|
)}
|
|
2827
|
+
{chapter.authorReview === undefined && chapter.status !== 'pending' && chapter.status !== 'generating' && (
|
|
2828
|
+
<div className={css.row}>
|
|
2829
|
+
<button
|
|
2830
|
+
type="button"
|
|
2831
|
+
className={`${css.button} ${css.buttonSmall}`}
|
|
2832
|
+
disabled={busy}
|
|
2833
|
+
onClick={() => { void handleAuthorBackfillChapter(chapter.no) }}
|
|
2834
|
+
title="对该章补跑一次作者复盘(读取已落盘正文,不重新生成)"
|
|
2835
|
+
>
|
|
2836
|
+
📋 生成作者复盘
|
|
2837
|
+
</button>
|
|
2838
|
+
</div>
|
|
2839
|
+
)}
|
|
2840
|
+
{chapter.authorReview !== undefined && (
|
|
2841
|
+
<div className={css.reviewBox} style={{ borderColor: 'color-mix(in srgb, var(--nf-info) 45%, transparent)' }}>
|
|
2842
|
+
<div className={css.row} style={{ justifyContent: 'space-between' }}>
|
|
2843
|
+
<b>📋 作者复盘</b>
|
|
2844
|
+
<span style={{ color: chapter.authorReview.hookHonored ? 'var(--nf-success)' : 'var(--nf-warn)' }}>
|
|
2845
|
+
钩子{chapter.authorReview.hookHonored ? '已兑现 ✓' : '未兑现 ✗'} · 结尾钩子 {chapter.authorReview.endingHook}/10
|
|
2846
|
+
</span>
|
|
2847
|
+
</div>
|
|
2848
|
+
{chapter.authorReview.hookNote !== '' && (
|
|
2849
|
+
<div className={css.meta}><b>钩子:</b>{chapter.authorReview.hookNote}</div>
|
|
2850
|
+
)}
|
|
2851
|
+
{chapter.authorReview.plotlineProgress !== '' && (
|
|
2852
|
+
<div className={css.meta}><b>推进:</b>{chapter.authorReview.plotlineProgress}</div>
|
|
2853
|
+
)}
|
|
2854
|
+
{chapter.authorReview.continuity !== '' && (
|
|
2855
|
+
<div className={css.meta}><b>衔接:</b>{chapter.authorReview.continuity}</div>
|
|
2856
|
+
)}
|
|
2857
|
+
{chapter.authorReview.trend !== '' && (
|
|
2858
|
+
<div className={css.meta}><b>趋势:</b>{chapter.authorReview.trend}</div>
|
|
2859
|
+
)}
|
|
2860
|
+
</div>
|
|
2861
|
+
)}
|
|
2347
2862
|
{(chapter.status === 'rejected' || chapter.status === 'written' || chapter.status === 'approved') && (
|
|
2348
2863
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
|
2349
2864
|
<div className={css.meta} style={{ fontWeight: 600 }}>
|
|
@@ -2411,24 +2926,85 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
2411
2926
|
</div>
|
|
2412
2927
|
</div>
|
|
2413
2928
|
)}
|
|
2929
|
+
</>
|
|
2930
|
+
)}
|
|
2414
2931
|
|
|
2415
|
-
|
|
2416
|
-
|
|
2417
|
-
|
|
2418
|
-
|
|
2419
|
-
|
|
2420
|
-
|
|
2421
|
-
|
|
2422
|
-
|
|
2423
|
-
|
|
2424
|
-
|
|
2425
|
-
|
|
2426
|
-
|
|
2427
|
-
|
|
2428
|
-
|
|
2932
|
+
{activeTab === 'reviews' && (
|
|
2933
|
+
<div className={css.card} style={{ flex: 1, minHeight: 0 }}>
|
|
2934
|
+
<div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}>
|
|
2935
|
+
<span className={css.cardTitle}>📋 复盘记录({chapters.filter(c => c.authorReview !== undefined).length} 章已复盘)</span>
|
|
2936
|
+
<div className={css.row}>
|
|
2937
|
+
{(() => {
|
|
2938
|
+
const missing = chapters.filter(c => c.status !== 'pending' && c.status !== 'generating' && c.authorReview === undefined).length
|
|
2939
|
+
return missing > 0 && (
|
|
2940
|
+
<button
|
|
2941
|
+
type="button"
|
|
2942
|
+
className={`${css.button} ${css.buttonSmall} ${css.buttonPrimary}`}
|
|
2943
|
+
disabled={busy}
|
|
2944
|
+
onClick={() => { void handleAuthorBackfillAll() }}
|
|
2945
|
+
title="对历史已写章节逐章补跑作者复盘"
|
|
2946
|
+
>
|
|
2947
|
+
↻ 补齐缺失复盘({missing})
|
|
2948
|
+
</button>
|
|
2949
|
+
)
|
|
2950
|
+
})()}
|
|
2429
2951
|
</div>
|
|
2430
2952
|
</div>
|
|
2431
|
-
|
|
2953
|
+
{(() => {
|
|
2954
|
+
const reviewed = chapters.filter(c => c.authorReview !== undefined)
|
|
2955
|
+
if (reviewed.length === 0) {
|
|
2956
|
+
return <span className={css.meta}>尚无作者复盘——生成/审稿后自动生成,或点「补齐缺失复盘」为已写章节补跑。</span>
|
|
2957
|
+
}
|
|
2958
|
+
const honored = reviewed.filter(c => c.authorReview!.hookHonored).length
|
|
2959
|
+
const avg = Math.round(reviewed.reduce((s, c) => s + c.authorReview!.endingHook, 0) / reviewed.length * 10) / 10
|
|
2960
|
+
return (
|
|
2961
|
+
<span className={css.meta} style={{ fontWeight: 600 }}>
|
|
2962
|
+
钩子兑现 {honored}/{reviewed.length} · 结尾钩子均分 {avg}/10
|
|
2963
|
+
</span>
|
|
2964
|
+
)
|
|
2965
|
+
})()}
|
|
2966
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, overflowY: 'auto', flex: 1, minHeight: 0 }}>
|
|
2967
|
+
{chapterGroups.map(group => {
|
|
2968
|
+
const groupReviewed = group.chapters.filter(c => c.authorReview !== undefined)
|
|
2969
|
+
if (groupReviewed.length === 0) return null
|
|
2970
|
+
return (
|
|
2971
|
+
<div key={group.no} style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
|
2972
|
+
<div className={css.row} style={{ alignItems: 'center', gap: 8 }}>
|
|
2973
|
+
<b style={{ fontSize: 13 }}>{group.title}</b>
|
|
2974
|
+
<span className={css.meta}>已复盘 {groupReviewed.length}/{group.chapters.length} 章</span>
|
|
2975
|
+
</div>
|
|
2976
|
+
{[...groupReviewed].reverse().map(c => {
|
|
2977
|
+
const ar = c.authorReview!
|
|
2978
|
+
const expanded = expandedReviewChapter === c.no
|
|
2979
|
+
return (
|
|
2980
|
+
<div key={c.no} style={{ border: '1px solid var(--nf-border)', borderRadius: 8, overflow: 'hidden' }}>
|
|
2981
|
+
<button
|
|
2982
|
+
type="button"
|
|
2983
|
+
style={{ width: '100%', textAlign: 'left', border: 'none', background: 'transparent', cursor: 'pointer', padding: '6px 10px', fontSize: 12, display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}
|
|
2984
|
+
onClick={() => { setExpandedReviewChapter(expanded ? null : c.no) }}
|
|
2985
|
+
>
|
|
2986
|
+
<span style={{ color: 'var(--nf-text-3)' }}>{expanded ? '▾' : '▸'}</span>
|
|
2987
|
+
<b>第{c.no}章《{c.title}》</b>
|
|
2988
|
+
<span style={{ color: ar.hookHonored ? 'var(--nf-success)' : 'var(--nf-warn)' }}>钩子{ar.hookHonored ? '✓' : '✗'}</span>
|
|
2989
|
+
<span style={{ color: ar.endingHook >= 6 ? 'var(--nf-success)' : 'var(--nf-error)' }}>结尾钩子 {ar.endingHook}/10</span>
|
|
2990
|
+
{ar.plotlineProgress !== '' && <span className={css.meta} style={{ marginLeft: 4 }}>{ar.plotlineProgress.slice(0, 40)}{ar.plotlineProgress.length > 40 ? '…' : ''}</span>}
|
|
2991
|
+
</button>
|
|
2992
|
+
{expanded && (
|
|
2993
|
+
<div style={{ padding: '4px 10px 8px', fontSize: 12, display: 'flex', flexDirection: 'column', gap: 3, borderTop: '1px solid var(--nf-border)' }}>
|
|
2994
|
+
{ar.hookNote !== '' && <span className={css.meta}><b>钩子:</b>{ar.hookNote}</span>}
|
|
2995
|
+
{ar.plotlineProgress !== '' && <span className={css.meta}><b>推进:</b>{ar.plotlineProgress}</span>}
|
|
2996
|
+
{ar.continuity !== '' && <span className={css.meta}><b>衔接:</b>{ar.continuity}</span>}
|
|
2997
|
+
{ar.trend !== '' && <span className={css.meta}><b>趋势:</b>{ar.trend}</span>}
|
|
2998
|
+
</div>
|
|
2999
|
+
)}
|
|
3000
|
+
</div>
|
|
3001
|
+
)
|
|
3002
|
+
})}
|
|
3003
|
+
</div>
|
|
3004
|
+
)
|
|
3005
|
+
})}
|
|
3006
|
+
</div>
|
|
3007
|
+
</div>
|
|
2432
3008
|
)}
|
|
2433
3009
|
|
|
2434
3010
|
{activeTab === 'bible' && (
|
|
@@ -2564,6 +3140,44 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
2564
3140
|
</div>
|
|
2565
3141
|
)}
|
|
2566
3142
|
|
|
3143
|
+
{/* 角色知情度:信息差管理 */}
|
|
3144
|
+
{activeTab === 'characters' && (
|
|
3145
|
+
<div className={css.card}>
|
|
3146
|
+
<div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}>
|
|
3147
|
+
<span className={css.cardTitle}>角色知情度(信息差管理)</span>
|
|
3148
|
+
<button type="button" className={`${css.button} ${css.buttonSmall} ${css.buttonPrimary}`} disabled={busy || (bible?.characters ?? []).length === 0} onClick={() => { void handleKnowledgeSave() }}>
|
|
3149
|
+
💾 保存知情度
|
|
3150
|
+
</button>
|
|
3151
|
+
</div>
|
|
3152
|
+
<span className={css.meta}>
|
|
3153
|
+
填写每个角色「已经知道」的事实/秘密(每行一条)。生成与审稿会严格遵守:未列出的信息该角色一律不知道——避免"不该知道的人知道了"。
|
|
3154
|
+
</span>
|
|
3155
|
+
{(bible?.characters ?? []).length === 0 ? (
|
|
3156
|
+
<span className={css.meta}>暂无角色卡——先在「道藏」提炼设定。</span>
|
|
3157
|
+
) : (
|
|
3158
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
|
3159
|
+
{bible!.characters.map(card => (
|
|
3160
|
+
<div key={card.name} style={{ border: '1px solid var(--nf-border)', borderRadius: 10, padding: '8px 10px', fontSize: 12 }}>
|
|
3161
|
+
<div className={css.row} style={{ justifyContent: 'space-between' }}>
|
|
3162
|
+
<b>{card.name}</b>
|
|
3163
|
+
<span className={css.meta}>
|
|
3164
|
+
{card.role === 'protagonist' ? '主角' : card.role === 'supporting' ? '配角' : card.role === 'antagonist' ? '反派' : '其他'}
|
|
3165
|
+
</span>
|
|
3166
|
+
</div>
|
|
3167
|
+
<textarea
|
|
3168
|
+
className={css.textarea}
|
|
3169
|
+
style={{ minHeight: 52, fontSize: 12 }}
|
|
3170
|
+
placeholder="每行一条该角色知道的信息,例如:林越的真实身份 / 古玉残片的秘密…"
|
|
3171
|
+
value={knowledgeDraft[card.name] ?? (card.knowledge ?? []).join('\n')}
|
|
3172
|
+
onChange={e => { setKnowledgeDraft(prev => ({ ...prev, [card.name]: e.target.value })) }}
|
|
3173
|
+
/>
|
|
3174
|
+
</div>
|
|
3175
|
+
))}
|
|
3176
|
+
</div>
|
|
3177
|
+
)}
|
|
3178
|
+
</div>
|
|
3179
|
+
)}
|
|
3180
|
+
|
|
2567
3181
|
{/* 写作资产 5 个子分类(左侧导航直达) */}
|
|
2568
3182
|
{activeTab === 'assetsGenre' && <AssetsTab api={api} initialTab="genre" />}
|
|
2569
3183
|
{activeTab === 'assetsProgression' && <AssetsTab api={api} initialTab="progression" />}
|
|
@@ -2683,6 +3297,18 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
2683
3297
|
<option value="0">✗ 否</option>
|
|
2684
3298
|
</select>
|
|
2685
3299
|
</div>
|
|
3300
|
+
<div className={css.field} style={{ flex: 1 }}>
|
|
3301
|
+
<label className={css.fieldLabel}>{tt('settings.autoAuthorReview')}</label>
|
|
3302
|
+
<select
|
|
3303
|
+
className={css.input}
|
|
3304
|
+
value={configDraft.autoAuthorReview ? '1' : '0'}
|
|
3305
|
+
onChange={e => { setConfigDraft({ ...configDraft, autoAuthorReview: e.target.value === '1' }) }}
|
|
3306
|
+
>
|
|
3307
|
+
<option value="1">✓ 是</option>
|
|
3308
|
+
<option value="0">✗ 否</option>
|
|
3309
|
+
</select>
|
|
3310
|
+
<span className={css.meta}>{tt('settings.autoAuthorReviewHint')}</span>
|
|
3311
|
+
</div>
|
|
2686
3312
|
</div>
|
|
2687
3313
|
<div className={css.row}>
|
|
2688
3314
|
<button type="button" className={`${css.button} ${css.buttonPrimary}`} disabled={busy} onClick={() => { void handleSaveConfig() }}>
|
|
@@ -2702,25 +3328,310 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
2702
3328
|
{tt('settings.exportMd')}
|
|
2703
3329
|
</button>
|
|
2704
3330
|
</div>
|
|
2705
|
-
|
|
2706
|
-
|
|
2707
|
-
|
|
2708
|
-
|
|
2709
|
-
|
|
2710
|
-
|
|
2711
|
-
|
|
2712
|
-
|
|
2713
|
-
|
|
2714
|
-
|
|
2715
|
-
|
|
2716
|
-
|
|
2717
|
-
|
|
2718
|
-
|
|
3331
|
+
</div>
|
|
3332
|
+
)}
|
|
3333
|
+
|
|
3334
|
+
{activeTab === 'settings' && (
|
|
3335
|
+
<div className={css.card}>
|
|
3336
|
+
<span className={css.cardTitle}>{tt('settings.theme')}</span>
|
|
3337
|
+
<div className={css.row} style={{ gap: 8, flexWrap: 'wrap' }}>
|
|
3338
|
+
<button
|
|
3339
|
+
type="button"
|
|
3340
|
+
className={`${css.button} ${css.buttonSmall} ${panelTheme === 'liquid' ? css.buttonPrimary : ''}`}
|
|
3341
|
+
onClick={() => { changePanelTheme('liquid') }}
|
|
3342
|
+
title="iOS 液态玻璃质感 · 绿色强调(当前默认)"
|
|
3343
|
+
>
|
|
3344
|
+
🧊 {tt('settings.themeLiquid')}
|
|
3345
|
+
</button>
|
|
3346
|
+
<button
|
|
3347
|
+
type="button"
|
|
3348
|
+
className={`${css.button} ${css.buttonSmall} ${panelTheme === 'classic' ? css.buttonPrimary : ''}`}
|
|
3349
|
+
onClick={() => { changePanelTheme('classic') }}
|
|
3350
|
+
title="经典 iOS 毛玻璃 · 蓝色强调"
|
|
3351
|
+
>
|
|
3352
|
+
💠 {tt('settings.themeClassic')}
|
|
3353
|
+
</button>
|
|
3354
|
+
<button
|
|
3355
|
+
type="button"
|
|
3356
|
+
className={`${css.button} ${css.buttonSmall} ${panelTheme === 'neumorph' ? css.buttonPrimary : ''}`}
|
|
3357
|
+
onClick={() => { changePanelTheme('neumorph') }}
|
|
3358
|
+
title="新拟物派 · 双阴影立体(仅浅色;深色下自动回退液态)"
|
|
3359
|
+
>
|
|
3360
|
+
🔘 {tt('settings.themeNeumorph')}
|
|
3361
|
+
</button>
|
|
3362
|
+
<span className={css.meta}>{tt('settings.themeHint')}</span>
|
|
3363
|
+
</div>
|
|
3364
|
+
</div>
|
|
3365
|
+
)}
|
|
3366
|
+
|
|
3367
|
+
{activeTab === 'facts' && (
|
|
3368
|
+
<div className={css.card}>
|
|
3369
|
+
<div className={css.busyRow} style={{ flexWrap: 'wrap' }}>
|
|
3370
|
+
<span className={css.cardTitle}>{tt('facts.title', { n: (project?.facts ?? []).length })}</span>
|
|
3371
|
+
<button
|
|
3372
|
+
type="button"
|
|
3373
|
+
className={`${css.button} ${css.buttonSmall}`}
|
|
3374
|
+
disabled={busy || chapters.length === 0}
|
|
3375
|
+
onClick={() => { void handleFactsBackfill() }}
|
|
3376
|
+
title="用 LLM 从历史章节正文重新抽取事实,补齐缺失的编年录条目"
|
|
3377
|
+
>
|
|
3378
|
+
📥 {tt('facts.backfill')}
|
|
3379
|
+
</button>
|
|
3380
|
+
</div>
|
|
3381
|
+
<span className={css.meta}>{tt('facts.hint')}</span>
|
|
3382
|
+
{(project?.facts ?? []).length > 0 ? (
|
|
3383
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, maxHeight: '60vh', overflowY: 'auto', fontSize: 12 }}>
|
|
3384
|
+
{[...(project?.facts ?? [])].reverse().map((fact, i) => (
|
|
3385
|
+
<div key={i} style={{ display: 'flex', gap: 8, alignItems: 'flex-start' }}>
|
|
3386
|
+
<span className={css.badge} style={{ borderColor: 'var(--nf-text-3)', color: 'var(--nf-text-3)', flex: 'none', marginTop: 1 }}>
|
|
3387
|
+
第 {fact.chapterNo} 章
|
|
3388
|
+
</span>
|
|
3389
|
+
<span className={css.meta}>{fact.text}</span>
|
|
3390
|
+
</div>
|
|
3391
|
+
))}
|
|
3392
|
+
</div>
|
|
3393
|
+
) : (
|
|
3394
|
+
<span className={css.meta}>暂无事实条目——写一章后会自动生成,或点击上方「回填」。</span>
|
|
3395
|
+
)}
|
|
3396
|
+
</div>
|
|
3397
|
+
)}
|
|
3398
|
+
|
|
3399
|
+
{activeTab === 'plotlines' && (
|
|
3400
|
+
<div className={css.card} style={{ flex: 1, minHeight: 0 }}>
|
|
3401
|
+
<div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}>
|
|
3402
|
+
<span className={css.cardTitle}>{tt('tab.plotlines')}({project?.plotlines?.length ?? 0})</span>
|
|
3403
|
+
<div className={css.row} style={{ flexWrap: 'wrap' }}>
|
|
3404
|
+
<button
|
|
3405
|
+
type="button"
|
|
3406
|
+
className={`${css.button} ${css.buttonSmall}`}
|
|
3407
|
+
disabled={busy || outlineText.length < 50}
|
|
3408
|
+
onClick={() => { void handlePlotlineHealth() }}
|
|
3409
|
+
title="根据已写章节数与各线推进情况,判断是否需要新增剧情线、建议多少章后添加"
|
|
3410
|
+
>
|
|
3411
|
+
🩺 剧情健康检查
|
|
3412
|
+
</button>
|
|
3413
|
+
<button
|
|
3414
|
+
type="button"
|
|
3415
|
+
className={`${css.button} ${css.buttonSmall}`}
|
|
3416
|
+
disabled={busy || outlineText.length < 50}
|
|
3417
|
+
onClick={() => { void handlePlotlinePlan() }}
|
|
3418
|
+
title="AI 设计下一阶段剧情方案:未来 5-10 章方向 + 2-3 条建议新线"
|
|
3419
|
+
>
|
|
3420
|
+
✨ 设计剧情方案
|
|
3421
|
+
</button>
|
|
3422
|
+
<button
|
|
3423
|
+
type="button"
|
|
3424
|
+
className={`${css.button} ${css.buttonSmall}`}
|
|
3425
|
+
disabled={busy || outlineText.length < 50}
|
|
3426
|
+
onClick={() => { void handlePlotlineSuggest() }}
|
|
3427
|
+
title="AI 根据大纲/卷计划/已写章节/编年录,提炼候选剧情线"
|
|
3428
|
+
>
|
|
3429
|
+
✨ AI 建议剧情线
|
|
3430
|
+
</button>
|
|
3431
|
+
{plotlineDraft === null && (
|
|
3432
|
+
<button
|
|
3433
|
+
type="button"
|
|
3434
|
+
className={`${css.button} ${css.buttonSmall} ${css.buttonPrimary}`}
|
|
3435
|
+
onClick={() => { setPlotlineDraft({ id: '', name: '', kind: 'main', goal: '', progress: '', status: 'active' }) }}
|
|
3436
|
+
>
|
|
3437
|
+
{tt('plotlines.new')}
|
|
3438
|
+
</button>
|
|
3439
|
+
)}
|
|
3440
|
+
</div>
|
|
3441
|
+
</div>
|
|
3442
|
+
<span className={css.meta}>{tt('plotlines.hint')}</span>
|
|
3443
|
+
|
|
3444
|
+
{plotlineHealth !== null && (
|
|
3445
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, border: '1px solid var(--nf-info)', borderRadius: 12, padding: 10 }}>
|
|
3446
|
+
<div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}>
|
|
3447
|
+
<b>🩺 剧情健康检查</b>
|
|
3448
|
+
<span style={{ display: 'flex', gap: 8 }}>
|
|
3449
|
+
<button
|
|
3450
|
+
type="button"
|
|
3451
|
+
className={`${css.button} ${css.buttonSmall}`}
|
|
3452
|
+
disabled={busy}
|
|
3453
|
+
onClick={() => { void handlePlotlinePlan() }}
|
|
3454
|
+
title="基于本次诊断生成下一阶段剧情方案"
|
|
3455
|
+
>
|
|
3456
|
+
✨ 基于此设计方案
|
|
3457
|
+
</button>
|
|
3458
|
+
<button type="button" className={`${css.button} ${css.buttonSmall}`} onClick={() => { setPlotlineHealth(null) }}>收起</button>
|
|
3459
|
+
</span>
|
|
3460
|
+
</div>
|
|
3461
|
+
<div style={{ fontSize: 12, fontWeight: 700, color: 'var(--nf-accent)' }}>判定:{plotlineHealth.verdict}</div>
|
|
3462
|
+
{plotlineHealth.timing !== '' && <div className={css.meta}><b>建议时机:</b>{plotlineHealth.timing}</div>}
|
|
3463
|
+
{plotlineHealth.reasons.length > 0 && (
|
|
3464
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 2, fontSize: 12 }}>
|
|
3465
|
+
{plotlineHealth.reasons.map((r, i) => <div key={i} className={css.meta}>· {r}</div>)}
|
|
3466
|
+
</div>
|
|
3467
|
+
)}
|
|
3468
|
+
{plotlineHealth.lines.length > 0 && (
|
|
3469
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, fontSize: 12 }}>
|
|
3470
|
+
{plotlineHealth.lines.map((l, i) => {
|
|
3471
|
+
const color = l.health === 'ok' ? 'var(--nf-success)' : l.health === 'warning' ? 'var(--nf-warn)' : 'var(--nf-error)'
|
|
3472
|
+
const label = l.health === 'ok' ? '健康' : l.health === 'warning' ? '预警' : '搁置过久'
|
|
3473
|
+
return (
|
|
3474
|
+
<div key={i} style={{ display: 'flex', gap: 8, alignItems: 'flex-start' }}>
|
|
3475
|
+
<span className={css.badge} style={{ borderColor: color, color, flex: 'none', marginTop: 1 }}>{label}</span>
|
|
3476
|
+
<span className={css.meta}><b>{l.name}</b>:{l.note}</span>
|
|
3477
|
+
</div>
|
|
3478
|
+
)
|
|
3479
|
+
})}
|
|
3480
|
+
</div>
|
|
3481
|
+
)}
|
|
3482
|
+
</div>
|
|
3483
|
+
)}
|
|
3484
|
+
|
|
3485
|
+
{plotlinePlan !== null && (
|
|
3486
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, border: '1px solid var(--nf-accent)', borderRadius: 12, padding: 10 }}>
|
|
3487
|
+
<div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}>
|
|
3488
|
+
<b>✨ AI 剧情方案</b>
|
|
3489
|
+
<button type="button" className={`${css.button} ${css.buttonSmall}`} onClick={() => { setPlotlinePlan(null) }}>收起</button>
|
|
3490
|
+
</div>
|
|
3491
|
+
{plotlinePlan.direction !== '' && (
|
|
3492
|
+
<div className={css.meta} style={{ fontSize: 12 }}><b>下一阶段方向:</b>{plotlinePlan.direction}</div>
|
|
3493
|
+
)}
|
|
3494
|
+
{plotlinePlan.suggestions.length > 0 && (
|
|
3495
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, fontSize: 12 }}>
|
|
3496
|
+
{plotlinePlan.suggestions.map((s, i) => {
|
|
3497
|
+
const kindLabel = { main: tt('plotlines.kindMain'), branch: tt('plotlines.kindBranch'), character: tt('plotlines.kindCharacter'), mystery: tt('plotlines.kindMystery') }[s.kind]
|
|
3498
|
+
return (
|
|
3499
|
+
<div key={i} style={{ border: '1px solid var(--nf-border)', borderRadius: 8, padding: '6px 10px' }}>
|
|
3500
|
+
<div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}>
|
|
3501
|
+
<span style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
|
|
3502
|
+
<b>{s.name}</b>
|
|
3503
|
+
<span className={css.badge} style={{ borderColor: 'var(--nf-accent)', color: 'var(--nf-accent)' }}>{kindLabel}</span>
|
|
3504
|
+
</span>
|
|
3505
|
+
<button type="button" className={`${css.button} ${css.buttonSmall} ${css.buttonPrimary}`} disabled={busy} onClick={() => { void handlePlanAdopt(s) }}>
|
|
3506
|
+
+ 采纳
|
|
3507
|
+
</button>
|
|
3508
|
+
</div>
|
|
3509
|
+
{s.goal !== '' && <div className={css.meta}>{s.goal}</div>}
|
|
3510
|
+
{s.progress !== '' && <div className={css.meta}>初始进度:{s.progress}</div>}
|
|
3511
|
+
</div>
|
|
3512
|
+
)
|
|
3513
|
+
})}
|
|
3514
|
+
</div>
|
|
3515
|
+
)}
|
|
3516
|
+
</div>
|
|
3517
|
+
)}
|
|
3518
|
+
|
|
3519
|
+
{plotlineSuggestions !== null && (
|
|
3520
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, border: '1px solid var(--nf-info)', borderRadius: 12, padding: 10 }}>
|
|
3521
|
+
<div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}>
|
|
3522
|
+
<b>✨ AI 建议({plotlineSuggestions.length} 条)</b>
|
|
3523
|
+
<button type="button" className={`${css.button} ${css.buttonSmall}`} onClick={() => { setPlotlineSuggestions(null) }}>收起</button>
|
|
3524
|
+
</div>
|
|
3525
|
+
{plotlineSuggestions.length === 0 && <span className={css.meta}>没有候选线。</span>}
|
|
3526
|
+
{plotlineSuggestions.map((s, i) => {
|
|
3527
|
+
const kindLabel = { main: tt('plotlines.kindMain'), branch: tt('plotlines.kindBranch'), character: tt('plotlines.kindCharacter'), mystery: tt('plotlines.kindMystery') }[s.kind]
|
|
3528
|
+
return (
|
|
3529
|
+
<div key={i} style={{ border: '1px solid var(--nf-border)', borderRadius: 8, padding: '6px 10px', fontSize: 12 }}>
|
|
3530
|
+
<div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}>
|
|
3531
|
+
<span style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
|
|
3532
|
+
<b>{s.name}</b>
|
|
3533
|
+
<span className={css.badge} style={{ borderColor: 'var(--nf-accent)', color: 'var(--nf-accent)' }}>{kindLabel}</span>
|
|
3534
|
+
</span>
|
|
3535
|
+
<button type="button" className={`${css.button} ${css.buttonSmall} ${css.buttonPrimary}`} disabled={busy} onClick={() => { void handlePlotlineAdopt(s) }}>
|
|
3536
|
+
+ 采纳
|
|
3537
|
+
</button>
|
|
3538
|
+
</div>
|
|
3539
|
+
{s.goal !== '' && <div className={css.meta}>{s.goal}</div>}
|
|
2719
3540
|
</div>
|
|
2720
|
-
)
|
|
3541
|
+
)
|
|
3542
|
+
})}
|
|
3543
|
+
</div>
|
|
3544
|
+
)}
|
|
3545
|
+
|
|
3546
|
+
{plotlineDraft !== null && (
|
|
3547
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, border: '1px solid var(--nf-accent)', borderRadius: 12, padding: 10 }}>
|
|
3548
|
+
<div className={css.row} style={{ flexWrap: 'wrap' }}>
|
|
3549
|
+
<div className={css.field} style={{ flex: 2, minWidth: 160 }}>
|
|
3550
|
+
<label className={css.fieldLabel}>{tt('plotlines.name')}</label>
|
|
3551
|
+
<input className={css.input} value={plotlineDraft.name} onChange={e => { setPlotlineDraft({ ...plotlineDraft, name: e.target.value }) }} placeholder="如:集齐古玉残片" />
|
|
3552
|
+
</div>
|
|
3553
|
+
<div className={css.field} style={{ flex: 1 }}>
|
|
3554
|
+
<label className={css.fieldLabel}>{tt('plotlines.kind')}</label>
|
|
3555
|
+
<select className={css.input} value={plotlineDraft.kind} onChange={e => { setPlotlineDraft({ ...plotlineDraft, kind: e.target.value as Plotline['kind'] }) }}>
|
|
3556
|
+
<option value="main">{tt('plotlines.kindMain')}</option>
|
|
3557
|
+
<option value="branch">{tt('plotlines.kindBranch')}</option>
|
|
3558
|
+
<option value="character">{tt('plotlines.kindCharacter')}</option>
|
|
3559
|
+
<option value="mystery">{tt('plotlines.kindMystery')}</option>
|
|
3560
|
+
</select>
|
|
3561
|
+
</div>
|
|
3562
|
+
<div className={css.field} style={{ flex: 1 }}>
|
|
3563
|
+
<label className={css.fieldLabel}>{tt('plotlines.status')}</label>
|
|
3564
|
+
<select className={css.input} value={plotlineDraft.status} onChange={e => { setPlotlineDraft({ ...plotlineDraft, status: e.target.value as Plotline['status'] }) }}>
|
|
3565
|
+
<option value="active">{tt('plotlines.statusActive')}</option>
|
|
3566
|
+
<option value="paused">{tt('plotlines.statusPaused')}</option>
|
|
3567
|
+
<option value="resolved">{tt('plotlines.statusResolved')}</option>
|
|
3568
|
+
<option value="abandoned">{tt('plotlines.statusAbandoned')}</option>
|
|
3569
|
+
</select>
|
|
3570
|
+
</div>
|
|
3571
|
+
</div>
|
|
3572
|
+
<div className={css.field}>
|
|
3573
|
+
<label className={css.fieldLabel}>{tt('plotlines.goal')}</label>
|
|
3574
|
+
<textarea className={css.textarea} style={{ minHeight: 48 }} value={plotlineDraft.goal} onChange={e => { setPlotlineDraft({ ...plotlineDraft, goal: e.target.value }) }} />
|
|
3575
|
+
</div>
|
|
3576
|
+
<div className={css.field}>
|
|
3577
|
+
<label className={css.fieldLabel}>{tt('plotlines.progress')}</label>
|
|
3578
|
+
<textarea className={css.textarea} style={{ minHeight: 40 }} value={plotlineDraft.progress} onChange={e => { setPlotlineDraft({ ...plotlineDraft, progress: e.target.value }) }} placeholder="如:已取得第二枚残片,正追踪第三枚线索" />
|
|
3579
|
+
</div>
|
|
3580
|
+
<div className={css.row}>
|
|
3581
|
+
<button type="button" className={`${css.button} ${css.buttonSmall} ${css.buttonPrimary}`} disabled={busy} onClick={() => { void handlePlotlineSave() }}>
|
|
3582
|
+
{tt('plotlines.save')}
|
|
3583
|
+
</button>
|
|
3584
|
+
<button type="button" className={`${css.button} ${css.buttonSmall}`} onClick={() => { setPlotlineDraft(null) }}>
|
|
3585
|
+
{tt('plotlines.cancel')}
|
|
3586
|
+
</button>
|
|
2721
3587
|
</div>
|
|
2722
3588
|
</div>
|
|
2723
3589
|
)}
|
|
3590
|
+
|
|
3591
|
+
{(project?.plotlines ?? []).length === 0 ? (
|
|
3592
|
+
<span className={css.meta}>{tt('plotlines.empty')}</span>
|
|
3593
|
+
) : (
|
|
3594
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, overflowY: 'auto', flex: 1, minHeight: 0 }}>
|
|
3595
|
+
{(project?.plotlines ?? []).map(line => {
|
|
3596
|
+
const kindLabel = { main: tt('plotlines.kindMain'), branch: tt('plotlines.kindBranch'), character: tt('plotlines.kindCharacter'), mystery: tt('plotlines.kindMystery') }[line.kind]
|
|
3597
|
+
const statusLabel = { active: tt('plotlines.statusActive'), paused: tt('plotlines.statusPaused'), resolved: tt('plotlines.statusResolved'), abandoned: tt('plotlines.statusAbandoned') }[line.status]
|
|
3598
|
+
const statusColor = line.status === 'resolved' ? 'var(--nf-success)' : line.status === 'abandoned' ? 'var(--nf-text-3)' : line.status === 'paused' ? 'var(--nf-warn)' : 'var(--nf-accent)'
|
|
3599
|
+
return (
|
|
3600
|
+
<div key={line.id} style={{ border: '1px solid var(--nf-border)', borderRadius: 10, padding: '8px 12px', fontSize: 12, display: 'flex', flexDirection: 'column', gap: 4 }}>
|
|
3601
|
+
<div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}>
|
|
3602
|
+
<span style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
|
|
3603
|
+
<b>{line.name}</b>
|
|
3604
|
+
<span className={css.badge} style={{ borderColor: 'var(--nf-accent)', color: 'var(--nf-accent)' }}>{kindLabel}</span>
|
|
3605
|
+
<span className={css.badge} style={{ borderColor: statusColor, color: statusColor }}>{statusLabel}</span>
|
|
3606
|
+
</span>
|
|
3607
|
+
<span style={{ display: 'flex', gap: 6 }}>
|
|
3608
|
+
<button
|
|
3609
|
+
type="button"
|
|
3610
|
+
className={`${css.button} ${css.buttonSmall}`}
|
|
3611
|
+
disabled={busy}
|
|
3612
|
+
onClick={() => { void handlePlotlineRefresh(line.id) }}
|
|
3613
|
+
title="AI 结合编年录与章节摘要,自动更新这条线的当前进度"
|
|
3614
|
+
>
|
|
3615
|
+
↻ AI 刷新进度
|
|
3616
|
+
</button>
|
|
3617
|
+
<button type="button" className={`${css.button} ${css.buttonSmall}`} disabled={busy} onClick={() => { setPlotlineDraft({ id: line.id, name: line.name, kind: line.kind, goal: line.goal, progress: line.progress, status: line.status }) }}>
|
|
3618
|
+
{tt('plotlines.edit')}
|
|
3619
|
+
</button>
|
|
3620
|
+
<button type="button" className={`${css.button} ${css.buttonSmall}`} disabled={busy} onClick={() => { void handlePlotlineRemove(line.id) }}>
|
|
3621
|
+
{tt('plotlines.remove')}
|
|
3622
|
+
</button>
|
|
3623
|
+
</span>
|
|
3624
|
+
</div>
|
|
3625
|
+
{line.goal !== '' && <div className={css.meta}><b>{tt('plotlines.goal')}:</b>{line.goal}</div>}
|
|
3626
|
+
{line.progress !== '' && <div className={css.meta}><b>{tt('plotlines.progress')}:</b>{line.progress}</div>}
|
|
3627
|
+
<div className={css.meta}>
|
|
3628
|
+
{tt('plotlines.chapters')}:{line.chapters.length > 0 ? line.chapters.map(n => `第${n}章`).join('、') : '—'}
|
|
3629
|
+
</div>
|
|
3630
|
+
</div>
|
|
3631
|
+
)
|
|
3632
|
+
})}
|
|
3633
|
+
</div>
|
|
3634
|
+
)}
|
|
2724
3635
|
</div>
|
|
2725
3636
|
)}
|
|
2726
3637
|
</>)}
|
|
@@ -2739,7 +3650,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
2739
3650
|
className={css.assistantFloatHeader}
|
|
2740
3651
|
onMouseDown={e => {
|
|
2741
3652
|
e.preventDefault()
|
|
2742
|
-
dragState.current = { type: 'move', startX: e.clientX, startY: e.clientY, origX: assistantPos.x, origY: assistantPos.y, origW: assistantSize.w, origH: assistantSize.h }
|
|
3653
|
+
dragState.current = { type: 'move', target: 'assistant', startX: e.clientX, startY: e.clientY, origX: assistantPos.x, origY: assistantPos.y, origW: assistantSize.w, origH: assistantSize.h }
|
|
2743
3654
|
}}
|
|
2744
3655
|
>
|
|
2745
3656
|
<span>💬 AI 助手 <span className={css.meta}>(拖动标题栏移动 · 右下角拉大小)</span></span>
|
|
@@ -2753,7 +3664,83 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
2753
3664
|
onMouseDown={e => {
|
|
2754
3665
|
e.preventDefault()
|
|
2755
3666
|
e.stopPropagation()
|
|
2756
|
-
dragState.current = { type: 'resize', startX: e.clientX, startY: e.clientY, origX: assistantPos.x, origY: assistantPos.y, origW: assistantSize.w, origH: assistantSize.h }
|
|
3667
|
+
dragState.current = { type: 'resize', target: 'assistant', startX: e.clientX, startY: e.clientY, origX: assistantPos.x, origY: assistantPos.y, origW: assistantSize.w, origH: assistantSize.h }
|
|
3668
|
+
}}
|
|
3669
|
+
/>
|
|
3670
|
+
</div>
|
|
3671
|
+
)}
|
|
3672
|
+
|
|
3673
|
+
{/* 工作进度悬浮窗:可拖动、可拉大小(同 AI 助手),承接活动输出 + 当前任务进度 */}
|
|
3674
|
+
{progressOpen && (
|
|
3675
|
+
<div
|
|
3676
|
+
className={css.assistantFloat}
|
|
3677
|
+
style={{ left: progressPos.x, top: progressPos.y, width: progressSize.w, height: progressSize.h }}
|
|
3678
|
+
>
|
|
3679
|
+
<div
|
|
3680
|
+
className={css.assistantFloatHeader}
|
|
3681
|
+
onMouseDown={e => {
|
|
3682
|
+
e.preventDefault()
|
|
3683
|
+
dragState.current = { type: 'move', target: 'progress', startX: e.clientX, startY: e.clientY, origX: progressPos.x, origY: progressPos.y, origW: progressSize.w, origH: progressSize.h }
|
|
3684
|
+
}}
|
|
3685
|
+
>
|
|
3686
|
+
<span>
|
|
3687
|
+
📊 工作进度
|
|
3688
|
+
{busy && <span style={{ color: 'var(--nf-accent)' }}> 🟢 任务进行中</span>}
|
|
3689
|
+
<span className={css.meta}>(拖动标题栏移动 · 右下角拉大小)</span>
|
|
3690
|
+
</span>
|
|
3691
|
+
<span style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
|
3692
|
+
{progress.length > 0 && (
|
|
3693
|
+
<button type="button" className={`${css.button} ${css.buttonSmall}`} onClick={() => { setProgress([]) }} title="清空活动记录">
|
|
3694
|
+
清空
|
|
3695
|
+
</button>
|
|
3696
|
+
)}
|
|
3697
|
+
<button type="button" className={css.iconButton} title="关闭" aria-label="关闭工作进度" onClick={() => { setProgressOpen(false) }}>×</button>
|
|
3698
|
+
</span>
|
|
3699
|
+
</div>
|
|
3700
|
+
<div className={css.assistantFloatBody} style={{ padding: 10, display: 'flex', flexDirection: 'column', gap: 8, overflow: 'hidden' }}>
|
|
3701
|
+
{/* 当前任务大进度条:进行中显示 */}
|
|
3702
|
+
{(busy && (busyLabel !== '' || liveBar !== null)) && (
|
|
3703
|
+
<div style={{ border: '1px solid var(--nf-accent)', borderRadius: 10, padding: '8px 12px', display: 'flex', flexDirection: 'column', gap: 5, background: 'color-mix(in srgb, var(--nf-accent) 6%, transparent)' }}>
|
|
3704
|
+
<span style={{ fontSize: 12, fontWeight: 600, color: 'var(--nf-accent)' }}>
|
|
3705
|
+
✍ {busyLabel !== '' ? busyLabel : (liveBar?.text ?? '任务进行中')}…
|
|
3706
|
+
</span>
|
|
3707
|
+
{liveBar?.ratio !== undefined && (
|
|
3708
|
+
<div className={css.bigProgressBar}>
|
|
3709
|
+
<div className={css.bigProgressBarFill} style={{ width: `${Math.round(liveBar.ratio * 100)}%` }} />
|
|
3710
|
+
</div>
|
|
3711
|
+
)}
|
|
3712
|
+
{liveBar?.text !== undefined && <span className={css.liveText}>{liveBar.text}</span>}
|
|
3713
|
+
</div>
|
|
3714
|
+
)}
|
|
3715
|
+
{/* 活动记录列表(标题行已并入窗口标题栏,清空按钮在右上角) */}
|
|
3716
|
+
<div className={css.progress} style={{ flex: 1, minHeight: 0, overflowY: 'auto', border: '1px solid var(--nf-border)', borderRadius: 10, background: 'var(--nf-bg-inset)', padding: 8, display: 'flex', flexDirection: 'column' }}>
|
|
3717
|
+
{progress.length === 0 ? (
|
|
3718
|
+
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 4, color: 'var(--nf-text-3)', fontSize: 12, minHeight: 120 }}>
|
|
3719
|
+
<span style={{ fontSize: 22 }}>📭</span>
|
|
3720
|
+
<span>暂无活动记录</span>
|
|
3721
|
+
<span>生成、审稿等操作会显示在这里</span>
|
|
3722
|
+
</div>
|
|
3723
|
+
) : (
|
|
3724
|
+
progress.map(line => (
|
|
3725
|
+
<div key={line.id} className={line.kind === 'done' ? css.progressLineDone : line.kind === 'error' ? css.progressLineError : line.live === true ? css.progressLineLive : css.progressLine}>
|
|
3726
|
+
{line.live === true && (
|
|
3727
|
+
<span className={css.progressBar}>
|
|
3728
|
+
<span className={css.progressBarFill} style={{ width: `${Math.round((line.ratio ?? 0) * 100)}%` }} />
|
|
3729
|
+
</span>
|
|
3730
|
+
)}
|
|
3731
|
+
{line.text}
|
|
3732
|
+
</div>
|
|
3733
|
+
))
|
|
3734
|
+
)}
|
|
3735
|
+
<div ref={progressEndRef} />
|
|
3736
|
+
</div>
|
|
3737
|
+
</div>
|
|
3738
|
+
<div
|
|
3739
|
+
className={css.assistantResize}
|
|
3740
|
+
onMouseDown={e => {
|
|
3741
|
+
e.preventDefault()
|
|
3742
|
+
e.stopPropagation()
|
|
3743
|
+
dragState.current = { type: 'resize', target: 'progress', startX: e.clientX, startY: e.clientY, origX: progressPos.x, origY: progressPos.y, origW: progressSize.w, origH: progressSize.h }
|
|
2757
3744
|
}}
|
|
2758
3745
|
/>
|
|
2759
3746
|
</div>
|