@waterwx/dsh-novel-forge 1.1.3 → 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 +4 -1
- package/lib/client.js +3980 -2957
- package/lib/client.js.map +1 -1
- package/lib/index.js +440 -35
- package/lib/index.js.map +1 -1
- package/lib/types/client/api.d.ts +7 -0
- package/lib/types/client/locales.d.ts +4 -2
- package/lib/types/client/panel/NovelPanel.d.ts +1 -1
- package/lib/types/engine.d.ts +13 -1
- package/lib/types/index.d.ts +2 -0
- package/lib/types/protocol.d.ts +65 -3
- package/package.json +1 -1
- package/src/assistant.ts +59 -36
- package/src/client/api.ts +10 -0
- package/src/client/locales.ts +8 -4
- package/src/client/panel/NovelPanel.tsx +704 -154
- package/src/engine.ts +290 -0
- package/src/index.ts +6 -0
- package/src/protocol.ts +63 -3
- package/src/routes.ts +167 -0
|
@@ -22,6 +22,8 @@ import type {
|
|
|
22
22
|
JobFrame,
|
|
23
23
|
NovelConfig,
|
|
24
24
|
Plotline,
|
|
25
|
+
PlotlineHealthReport,
|
|
26
|
+
PlotlinePlan,
|
|
25
27
|
ProjectState,
|
|
26
28
|
ReviewReport,
|
|
27
29
|
SensitiveHit,
|
|
@@ -33,7 +35,7 @@ import css from './panel.module.css'
|
|
|
33
35
|
/** The panel's tab identifiers. */
|
|
34
36
|
export type NovelTab =
|
|
35
37
|
| 'workflow' | 'overview' | 'blurb' | 'plan' | 'bible' | 'world' | 'foreshadow' | 'assistant' | 'settings'
|
|
36
|
-
| 'characters' | 'facts' | 'plotlines'
|
|
38
|
+
| 'characters' | 'facts' | 'plotlines' | 'reviews' | 'progress'
|
|
37
39
|
| 'assetsGenre' | 'assetsProgression' | 'assetsTemplates' | 'assetsRules' | 'assetsStyle'
|
|
38
40
|
|
|
39
41
|
/** Panel shell props. */
|
|
@@ -73,6 +75,7 @@ const NAV_GROUPS: ReadonlyArray<{ id: string; label: string; items: ReadonlyArra
|
|
|
73
75
|
label: '工具',
|
|
74
76
|
items: [
|
|
75
77
|
{ id: 'assistant', label: tt('tab.assistant'), icon: '💬' },
|
|
78
|
+
{ id: 'progress', label: '工作进度', icon: '📊' },
|
|
76
79
|
],
|
|
77
80
|
},
|
|
78
81
|
{
|
|
@@ -84,6 +87,7 @@ const NAV_GROUPS: ReadonlyArray<{ id: string; label: string; items: ReadonlyArra
|
|
|
84
87
|
{ id: 'characters', label: '人物志', icon: '👥' },
|
|
85
88
|
{ id: 'foreshadow', label: tt('tab.foreshadow'), icon: '🔮' },
|
|
86
89
|
{ id: 'facts', label: tt('tab.facts'), icon: '📜' },
|
|
90
|
+
{ id: 'reviews', label: '复盘记录', icon: '📋' },
|
|
87
91
|
{ id: 'assetsGenre', label: '题材基底', icon: '🏷️' },
|
|
88
92
|
{ id: 'assetsProgression', label: '推进模式', icon: '📈' },
|
|
89
93
|
{ id: 'assetsTemplates', label: '笔法帖', icon: '🖋️' },
|
|
@@ -309,6 +313,8 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
309
313
|
const [progress, setProgress] = useState<ProgressLine[]>([])
|
|
310
314
|
const [configDraft, setConfigDraft] = useState<NovelConfig | null>(null)
|
|
311
315
|
const [expandedChapter, setExpandedChapter] = useState<number | null>(null)
|
|
316
|
+
/** 复盘记录页:当前展开的章节号。 */
|
|
317
|
+
const [expandedReviewChapter, setExpandedReviewChapter] = useState<number | null>(null)
|
|
312
318
|
const [chapterText, setChapterText] = useState('')
|
|
313
319
|
const progressId = useRef(0)
|
|
314
320
|
/** id of the single live progress row (generation counter), if any. */
|
|
@@ -398,6 +404,12 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
398
404
|
/** 全书敏感词检查结果(null = 未运行)。 */
|
|
399
405
|
const [sensHits, setSensHits] = useState<SensitiveHit[] | null>(null)
|
|
400
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)
|
|
401
413
|
/** npm 最新版本(更新检测;null = 未检测/检测失败)。 */
|
|
402
414
|
const [npmLatest, setNpmLatest] = useState<string | null>(null)
|
|
403
415
|
/** 活动输出容器:自动滚动锚点(有新活动时跟随到底部)。 */
|
|
@@ -419,6 +431,30 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
419
431
|
}, [])
|
|
420
432
|
/** AI 助手悬浮窗:是否打开。 */
|
|
421
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
|
+
})
|
|
422
458
|
/** 悬浮窗位置(相对面板,localStorage 记忆)。 */
|
|
423
459
|
const [assistantPos, setAssistantPos] = useState(() => {
|
|
424
460
|
try {
|
|
@@ -441,16 +477,18 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
441
477
|
} catch { /* ignore */ }
|
|
442
478
|
return { w: 420, h: 460 }
|
|
443
479
|
})
|
|
444
|
-
/**
|
|
445
|
-
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)
|
|
446
482
|
|
|
447
483
|
/** 悬浮窗位置/尺寸持久化。 */
|
|
448
484
|
useEffect(() => {
|
|
449
485
|
try {
|
|
450
486
|
window.localStorage.setItem('dsh-novel-forge.assistant.float', JSON.stringify(assistantPos))
|
|
451
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))
|
|
452
490
|
} catch { /* ignore */ }
|
|
453
|
-
}, [assistantPos, assistantSize])
|
|
491
|
+
}, [assistantPos, assistantSize, progressPos, progressSize])
|
|
454
492
|
|
|
455
493
|
/** 全局拖拽/缩放监听(挂一次,靠 dragState 判断)。 */
|
|
456
494
|
useEffect(() => {
|
|
@@ -458,15 +496,19 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
458
496
|
const s = dragState.current
|
|
459
497
|
if (s === null) return
|
|
460
498
|
if (s.type === 'move') {
|
|
461
|
-
|
|
499
|
+
const next = {
|
|
462
500
|
x: Math.max(-340, Math.min(s.origX + e.clientX - s.startX, 3000)),
|
|
463
501
|
y: Math.max(0, Math.min(s.origY + e.clientY - s.startY, 3000)),
|
|
464
|
-
}
|
|
502
|
+
}
|
|
503
|
+
if (s.target === 'assistant') setAssistantPos(next)
|
|
504
|
+
else setProgressPos(next)
|
|
465
505
|
} else {
|
|
466
|
-
|
|
506
|
+
const next = {
|
|
467
507
|
w: Math.max(320, Math.min(s.origW + e.clientX - s.startX, 1400)),
|
|
468
508
|
h: Math.max(220, Math.min(s.origH + e.clientY - s.startY, 1200)),
|
|
469
|
-
}
|
|
509
|
+
}
|
|
510
|
+
if (s.target === 'assistant') setAssistantSize(next)
|
|
511
|
+
else setProgressSize(next)
|
|
470
512
|
}
|
|
471
513
|
}
|
|
472
514
|
const onUp = (): void => { dragState.current = null }
|
|
@@ -771,6 +813,114 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
771
813
|
}
|
|
772
814
|
}
|
|
773
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
|
+
|
|
774
924
|
/** 全书敏感词检查(硬匹配内置词库)。 */
|
|
775
925
|
const handleSensitiveScan = async (): Promise<void> => {
|
|
776
926
|
setBusy(true)
|
|
@@ -792,6 +942,48 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
792
942
|
}
|
|
793
943
|
}
|
|
794
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
|
+
|
|
795
987
|
/** 保存角色知情度(bible.characters 整体更新)。 */
|
|
796
988
|
const handleKnowledgeSave = async (): Promise<void> => {
|
|
797
989
|
if (bible === undefined) return
|
|
@@ -1225,6 +1417,8 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
1225
1417
|
lastDeltaChars.current = 0
|
|
1226
1418
|
liveChars.current = 0
|
|
1227
1419
|
currentJobNo.current = frame.no
|
|
1420
|
+
// 任务开始时自动弹出工作进度悬浮窗(顶部进度条已移除,进度只在这里可见)。
|
|
1421
|
+
setProgressOpen(true)
|
|
1228
1422
|
setProject(prev => prev === null ? prev : {
|
|
1229
1423
|
...prev,
|
|
1230
1424
|
chapters: prev.chapters.map(c => c.no === frame.no ? { ...c, status: 'generating', error: undefined } : c),
|
|
@@ -1263,6 +1457,21 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
1263
1457
|
score: frame.report.score,
|
|
1264
1458
|
verdict: frame.report.verdict,
|
|
1265
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)
|
|
1266
1475
|
} else if (frame.type === 'drafted') {
|
|
1267
1476
|
// 润色/重写完成:产物作为待确认草稿,展示在工作区预览,由用户决定。
|
|
1268
1477
|
clearLiveProgress()
|
|
@@ -1318,7 +1527,8 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
1318
1527
|
// 失败自动重试:最多尝试 2 次,间隔 3 秒(网络抖动/LLM 偶发失败自愈)。
|
|
1319
1528
|
for (let attempt = 1; attempt <= 2; attempt++) {
|
|
1320
1529
|
try {
|
|
1321
|
-
|
|
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 ?? '') })) })
|
|
1322
1532
|
lastError = null
|
|
1323
1533
|
break
|
|
1324
1534
|
} catch (err) {
|
|
@@ -1744,13 +1954,15 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
1744
1954
|
key={tab.id}
|
|
1745
1955
|
type="button"
|
|
1746
1956
|
role="tab"
|
|
1747
|
-
aria-selected={activeTab === tab.id || (tab.id === 'assistant' && assistantOpen)}
|
|
1748
|
-
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}
|
|
1749
1959
|
className={css.navTab}
|
|
1750
1960
|
title={tab.label}
|
|
1751
1961
|
onClick={() => {
|
|
1752
1962
|
if (tab.id === 'assistant') {
|
|
1753
1963
|
setAssistantOpen(true)
|
|
1964
|
+
} else if (tab.id === 'progress') {
|
|
1965
|
+
setProgressOpen(v => !v)
|
|
1754
1966
|
} else {
|
|
1755
1967
|
setActiveTab(tab.id)
|
|
1756
1968
|
}
|
|
@@ -1819,19 +2031,6 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
1819
2031
|
<div className={css.panelContent}>
|
|
1820
2032
|
{error !== '' && <div className={css.card} style={{ borderColor: 'var(--nf-error)' }}><span style={{ color: 'var(--nf-error)' }}>{tt('common.error')}: {error}</span></div>}
|
|
1821
2033
|
{notice !== '' && <div className={css.card}><span style={{ color: 'var(--nf-success)' }}>{notice}</span></div>}
|
|
1822
|
-
{busy && busyLabel !== '' && (
|
|
1823
|
-
<div className={css.card}>
|
|
1824
|
-
<div className={css.busyRow}>
|
|
1825
|
-
<span style={{ color: 'var(--nf-accent)' }}>{busyLabel}…</span>
|
|
1826
|
-
{liveBar !== null && <span className={css.liveText}>{liveBar.text}</span>}
|
|
1827
|
-
</div>
|
|
1828
|
-
{liveBar?.ratio !== undefined && (
|
|
1829
|
-
<div className={css.bigProgressBar}>
|
|
1830
|
-
<div className={css.bigProgressBarFill} style={{ width: `${Math.round(liveBar.ratio * 100)}%` }} />
|
|
1831
|
-
</div>
|
|
1832
|
-
)}
|
|
1833
|
-
</div>
|
|
1834
|
-
)}
|
|
1835
2034
|
|
|
1836
2035
|
{/* 遗留草稿提示:刷新页面后仍有未采纳的润色/修订草稿 */}
|
|
1837
2036
|
{workspace === null && draftNo !== null && (
|
|
@@ -2044,54 +2243,15 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
2044
2243
|
))}
|
|
2045
2244
|
</div>
|
|
2046
2245
|
</div>
|
|
2047
|
-
{/* HeroFact 4 格 */}
|
|
2048
|
-
<div className={css.dashFacts}>
|
|
2049
|
-
<div className={css.dashFact}><span className={css.meta}>已沉淀章节</span><b>{doneCount} 章</b></div>
|
|
2050
|
-
<div className={css.dashFact}><span className={css.meta}>累计字数</span><b>{totalChars} 字</b></div>
|
|
2051
|
-
<div className={css.dashFact}><span className={css.meta}>当前卷</span><b>{currentVolumeName}</b></div>
|
|
2052
|
-
<div className={css.dashFact}><span className={css.meta}>最近创作</span><b>{lastUpdated}</b></div>
|
|
2053
|
-
</div>
|
|
2054
|
-
</div>
|
|
2055
|
-
|
|
2056
|
-
{/* 全书进度条(原章节页进度移入工作流) */}
|
|
2057
|
-
<div className={css.card} style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: 8 }}>
|
|
2058
|
-
<span className={css.meta} style={{ fontWeight: 600 }}>
|
|
2059
|
-
📊 全书进度:已完成 <b style={{ color: 'var(--nf-accent)' }}>{doneCount}</b>/{chapters.length} 章({chapters.length > 0 ? Math.round((doneCount / chapters.length) * 100) : 0}%)
|
|
2060
|
-
</span>
|
|
2061
|
-
<span className={css.meta}>
|
|
2062
|
-
待生成 {pendingCount} · 待审稿 {reviewPendingCount} · 通过 {approvedCount}
|
|
2063
|
-
</span>
|
|
2064
2246
|
</div>
|
|
2065
2247
|
|
|
2066
|
-
{/*
|
|
2067
|
-
<div className={css.card}>
|
|
2068
|
-
<span className={css.cardTitle}>🧵 {tt('plotlines.workflowTitle')}</span>
|
|
2069
|
-
{(project?.plotlines ?? []).filter(l => l.status === 'active' || l.status === 'paused').length === 0 ? (
|
|
2070
|
-
<span className={css.meta}>{tt('plotlines.workflowEmpty')}</span>
|
|
2071
|
-
) : (
|
|
2072
|
-
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
|
2073
|
-
{(project?.plotlines ?? []).filter(l => l.status === 'active' || l.status === 'paused').map(line => {
|
|
2074
|
-
const kindLabel = { main: tt('plotlines.kindMain'), branch: tt('plotlines.kindBranch'), character: tt('plotlines.kindCharacter'), mystery: tt('plotlines.kindMystery') }[line.kind]
|
|
2075
|
-
return (
|
|
2076
|
-
<div key={line.id} style={{ border: '1px solid var(--nf-border)', borderRadius: 8, padding: '6px 10px', fontSize: 12 }}>
|
|
2077
|
-
<div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}>
|
|
2078
|
-
<span style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
|
2079
|
-
<b>{line.name}</b>
|
|
2080
|
-
<span className={css.badge} style={{ borderColor: 'var(--nf-accent)', color: 'var(--nf-accent)' }}>{kindLabel}</span>
|
|
2081
|
-
{line.status === 'paused' && <span className={css.badge} style={{ borderColor: 'var(--nf-warn)', color: 'var(--nf-warn)' }}>{tt('plotlines.statusPaused')}</span>}
|
|
2082
|
-
</span>
|
|
2083
|
-
<span className={css.meta}>{tt('plotlines.chapters')} {line.chapters.length} 章</span>
|
|
2084
|
-
</div>
|
|
2085
|
-
{line.progress !== '' && <div className={css.meta}>{line.progress}</div>}
|
|
2086
|
-
</div>
|
|
2087
|
-
)
|
|
2088
|
-
})}
|
|
2089
|
-
</div>
|
|
2090
|
-
)}
|
|
2091
|
-
</div>
|
|
2092
|
-
|
|
2093
|
-
{/* 状态摘要条 */}
|
|
2248
|
+
{/* 状态摘要条:全书进度 + 六个统计格,同样式并排一行 */}
|
|
2094
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>
|
|
2095
2255
|
<div className={css.assetStat}>
|
|
2096
2256
|
<span className={css.assetStatLabel}>已通过审稿</span>
|
|
2097
2257
|
<span className={css.assetStatValue} style={{ color: approvedCount > 0 ? 'var(--nf-success)' : undefined }}>{approvedCount}</span>
|
|
@@ -2112,69 +2272,127 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
2112
2272
|
<span className={css.assetStatValue}>{totalChars}</span>
|
|
2113
2273
|
<span className={css.assetStatDetail}>已生成正文累计</span>
|
|
2114
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>
|
|
2115
2285
|
</div>
|
|
2116
2286
|
|
|
2117
|
-
{/*
|
|
2118
|
-
<div className={css.
|
|
2119
|
-
<div className={css.
|
|
2120
|
-
<span className={css.cardTitle}
|
|
2121
|
-
{
|
|
2122
|
-
|
|
2123
|
-
|
|
2124
|
-
|
|
2125
|
-
|
|
2126
|
-
|
|
2127
|
-
|
|
2128
|
-
|
|
2129
|
-
|
|
2130
|
-
|
|
2131
|
-
|
|
2132
|
-
|
|
2133
|
-
|
|
2134
|
-
|
|
2135
|
-
|
|
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>
|
|
2136
2328
|
</div>
|
|
2329
|
+
</div>
|
|
2330
|
+
|
|
2331
|
+
{/* 剧情线进度 | 作者复盘趋势:精简摘要卡,并排(点击跳转详情页) */}
|
|
2332
|
+
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(300px, 1fr))', gap: 14, alignItems: 'stretch' }}>
|
|
2137
2333
|
<div className={css.card}>
|
|
2138
|
-
<div className={css.row} style={{ justifyContent: 'space-between' }}>
|
|
2139
|
-
<span className={css.cardTitle}
|
|
2140
|
-
<button type="button" className={`${css.button} ${css.buttonSmall}`}
|
|
2141
|
-
|
|
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
|
+
查看全部 →
|
|
2142
2338
|
</button>
|
|
2143
2339
|
</div>
|
|
2144
|
-
|
|
2145
|
-
|
|
2146
|
-
|
|
2147
|
-
|
|
2148
|
-
|
|
2149
|
-
|
|
2150
|
-
|
|
2151
|
-
|
|
2152
|
-
|
|
2153
|
-
|
|
2154
|
-
|
|
2155
|
-
|
|
2156
|
-
|
|
2157
|
-
{
|
|
2158
|
-
</span>
|
|
2159
|
-
<span className={css.assetStatDetail} title={volumes?.map(v => v.title).join(' / ')}>
|
|
2160
|
-
{volumes !== undefined ? volumes.map(v => v.title).join(' / ') : '按剧情弧线划分全书'}
|
|
2161
|
-
</span>
|
|
2162
|
-
</div>
|
|
2163
|
-
<div className={css.assetStat}>
|
|
2164
|
-
<span className={css.assetStatLabel}>写作资产</span>
|
|
2165
|
-
<span className={css.assetStatValue} style={{ color: assetCount > 0 ? 'var(--nf-success)' : 'var(--nf-text-3)' }}>
|
|
2166
|
-
{assetCount} 项
|
|
2167
|
-
</span>
|
|
2168
|
-
<span className={css.assetStatDetail} title={assetSummary}>{assetSummary}</span>
|
|
2169
|
-
</div>
|
|
2170
|
-
<div className={css.assetStat}>
|
|
2171
|
-
<span className={css.assetStatLabel}>伏笔</span>
|
|
2172
|
-
<span className={css.assetStatValue}>{foreshadows.length} 条</span>
|
|
2173
|
-
<span className={css.assetStatDetail}>
|
|
2174
|
-
{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)} 章)</>}
|
|
2175
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>
|
|
2176
2380
|
</div>
|
|
2177
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
|
+
})()}
|
|
2178
2396
|
</div>
|
|
2179
2397
|
</div>
|
|
2180
2398
|
|
|
@@ -2216,33 +2434,24 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
2216
2434
|
</div>
|
|
2217
2435
|
)}
|
|
2218
2436
|
|
|
2219
|
-
{/*
|
|
2437
|
+
{/* 待办队列(活动输出已移入「工具 → 工作进度」悬浮窗) */}
|
|
2220
2438
|
<div className={css.card}>
|
|
2221
|
-
<
|
|
2222
|
-
|
|
2223
|
-
<
|
|
2224
|
-
|
|
2225
|
-
|
|
2226
|
-
|
|
2227
|
-
|
|
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}
|
|
2228
2451
|
</button>
|
|
2229
|
-
)}
|
|
2230
|
-
</div>
|
|
2231
|
-
</div>
|
|
2232
|
-
<div className={css.progress} style={{ maxHeight: 260, overflowY: 'auto' }}>
|
|
2233
|
-
{progress.length === 0 && <span className={css.meta}>{tt('progress.empty')}</span>}
|
|
2234
|
-
{progress.map(line => (
|
|
2235
|
-
<div key={line.id} className={line.kind === 'done' ? css.progressLineDone : line.kind === 'error' ? css.progressLineError : line.live === true ? css.progressLineLive : css.progressLine}>
|
|
2236
|
-
{line.live === true && (
|
|
2237
|
-
<span className={css.progressBar}>
|
|
2238
|
-
<span className={css.progressBarFill} style={{ width: `${Math.round((line.ratio ?? 0) * 100)}%` }} />
|
|
2239
|
-
</span>
|
|
2240
|
-
)}
|
|
2241
|
-
{line.text}
|
|
2242
2452
|
</div>
|
|
2243
|
-
))
|
|
2244
|
-
|
|
2245
|
-
</div>
|
|
2453
|
+
))
|
|
2454
|
+
)}
|
|
2246
2455
|
</div>
|
|
2247
2456
|
</>
|
|
2248
2457
|
)}
|
|
@@ -2615,6 +2824,41 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
2615
2824
|
)}
|
|
2616
2825
|
</div>
|
|
2617
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
|
+
)}
|
|
2618
2862
|
{(chapter.status === 'rejected' || chapter.status === 'written' || chapter.status === 'approved') && (
|
|
2619
2863
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
|
2620
2864
|
<div className={css.meta} style={{ fontWeight: 600 }}>
|
|
@@ -2685,6 +2929,84 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
2685
2929
|
</>
|
|
2686
2930
|
)}
|
|
2687
2931
|
|
|
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
|
+
})()}
|
|
2951
|
+
</div>
|
|
2952
|
+
</div>
|
|
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>
|
|
3008
|
+
)}
|
|
3009
|
+
|
|
2688
3010
|
{activeTab === 'bible' && (
|
|
2689
3011
|
<>
|
|
2690
3012
|
<div className={css.card}>
|
|
@@ -2975,6 +3297,18 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
2975
3297
|
<option value="0">✗ 否</option>
|
|
2976
3298
|
</select>
|
|
2977
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>
|
|
2978
3312
|
</div>
|
|
2979
3313
|
<div className={css.row}>
|
|
2980
3314
|
<button type="button" className={`${css.button} ${css.buttonPrimary}`} disabled={busy} onClick={() => { void handleSaveConfig() }}>
|
|
@@ -3066,18 +3400,149 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
3066
3400
|
<div className={css.card} style={{ flex: 1, minHeight: 0 }}>
|
|
3067
3401
|
<div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}>
|
|
3068
3402
|
<span className={css.cardTitle}>{tt('tab.plotlines')}({project?.plotlines?.length ?? 0})</span>
|
|
3069
|
-
{
|
|
3403
|
+
<div className={css.row} style={{ flexWrap: 'wrap' }}>
|
|
3070
3404
|
<button
|
|
3071
3405
|
type="button"
|
|
3072
|
-
className={`${css.button} ${css.buttonSmall}
|
|
3073
|
-
|
|
3406
|
+
className={`${css.button} ${css.buttonSmall}`}
|
|
3407
|
+
disabled={busy || outlineText.length < 50}
|
|
3408
|
+
onClick={() => { void handlePlotlineHealth() }}
|
|
3409
|
+
title="根据已写章节数与各线推进情况,判断是否需要新增剧情线、建议多少章后添加"
|
|
3074
3410
|
>
|
|
3075
|
-
|
|
3411
|
+
🩺 剧情健康检查
|
|
3076
3412
|
</button>
|
|
3077
|
-
|
|
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>
|
|
3078
3441
|
</div>
|
|
3079
3442
|
<span className={css.meta}>{tt('plotlines.hint')}</span>
|
|
3080
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>}
|
|
3540
|
+
</div>
|
|
3541
|
+
)
|
|
3542
|
+
})}
|
|
3543
|
+
</div>
|
|
3544
|
+
)}
|
|
3545
|
+
|
|
3081
3546
|
{plotlineDraft !== null && (
|
|
3082
3547
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, border: '1px solid var(--nf-accent)', borderRadius: 12, padding: 10 }}>
|
|
3083
3548
|
<div className={css.row} style={{ flexWrap: 'wrap' }}>
|
|
@@ -3140,6 +3605,15 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
3140
3605
|
<span className={css.badge} style={{ borderColor: statusColor, color: statusColor }}>{statusLabel}</span>
|
|
3141
3606
|
</span>
|
|
3142
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>
|
|
3143
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 }) }}>
|
|
3144
3618
|
{tt('plotlines.edit')}
|
|
3145
3619
|
</button>
|
|
@@ -3176,7 +3650,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
3176
3650
|
className={css.assistantFloatHeader}
|
|
3177
3651
|
onMouseDown={e => {
|
|
3178
3652
|
e.preventDefault()
|
|
3179
|
-
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 }
|
|
3180
3654
|
}}
|
|
3181
3655
|
>
|
|
3182
3656
|
<span>💬 AI 助手 <span className={css.meta}>(拖动标题栏移动 · 右下角拉大小)</span></span>
|
|
@@ -3190,7 +3664,83 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
3190
3664
|
onMouseDown={e => {
|
|
3191
3665
|
e.preventDefault()
|
|
3192
3666
|
e.stopPropagation()
|
|
3193
|
-
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 }
|
|
3194
3744
|
}}
|
|
3195
3745
|
/>
|
|
3196
3746
|
</div>
|