@waterwx/dsh-novel-forge 1.1.1 → 1.1.3
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 +17 -0
- package/lib/client.js +2953 -1814
- package/lib/client.js.map +1 -1
- package/lib/index.js +486 -2
- package/lib/index.js.map +1 -1
- package/lib/types/client/api.d.ts +4 -0
- package/lib/types/client/locales.d.ts +44 -0
- package/lib/types/client/panel/NovelPanel.d.ts +1 -1
- package/lib/types/engine.d.ts +6 -0
- package/lib/types/protocol.d.ts +69 -0
- package/package.json +1 -1
- package/src/client/api.ts +10 -0
- package/src/client/locales.ts +88 -0
- package/src/client/panel/AssetsTab.tsx +7 -7
- package/src/client/panel/NovelPanel.tsx +605 -83
- package/src/client/panel/panel.module.css +399 -58
- package/src/engine.ts +88 -0
- package/src/protocol.ts +75 -0
- package/src/routes.ts +128 -0
|
@@ -21,8 +21,10 @@ import type {
|
|
|
21
21
|
Foreshadow,
|
|
22
22
|
JobFrame,
|
|
23
23
|
NovelConfig,
|
|
24
|
+
Plotline,
|
|
24
25
|
ProjectState,
|
|
25
26
|
ReviewReport,
|
|
27
|
+
SensitiveHit,
|
|
26
28
|
StoryBible,
|
|
27
29
|
Volume,
|
|
28
30
|
} from '../../protocol.ts'
|
|
@@ -31,7 +33,7 @@ import css from './panel.module.css'
|
|
|
31
33
|
/** The panel's tab identifiers. */
|
|
32
34
|
export type NovelTab =
|
|
33
35
|
| 'workflow' | 'overview' | 'blurb' | 'plan' | 'bible' | 'world' | 'foreshadow' | 'assistant' | 'settings'
|
|
34
|
-
| 'characters'
|
|
36
|
+
| 'characters' | 'facts' | 'plotlines'
|
|
35
37
|
| 'assetsGenre' | 'assetsProgression' | 'assetsTemplates' | 'assetsRules' | 'assetsStyle'
|
|
36
38
|
|
|
37
39
|
/** Panel shell props. */
|
|
@@ -63,6 +65,7 @@ const NAV_GROUPS: ReadonlyArray<{ id: string; label: string; items: ReadonlyArra
|
|
|
63
65
|
{ id: 'overview', label: tt('tab.overview'), icon: '📄' },
|
|
64
66
|
{ id: 'blurb', label: '卷首语', icon: '📖' },
|
|
65
67
|
{ id: 'plan', label: tt('tab.plan'), icon: '📚' },
|
|
68
|
+
{ id: 'plotlines', label: tt('tab.plotlines'), icon: '🧵' },
|
|
66
69
|
],
|
|
67
70
|
},
|
|
68
71
|
{
|
|
@@ -80,6 +83,7 @@ const NAV_GROUPS: ReadonlyArray<{ id: string; label: string; items: ReadonlyArra
|
|
|
80
83
|
{ id: 'world', label: '大世界', icon: '🌍' },
|
|
81
84
|
{ id: 'characters', label: '人物志', icon: '👥' },
|
|
82
85
|
{ id: 'foreshadow', label: tt('tab.foreshadow'), icon: '🔮' },
|
|
86
|
+
{ id: 'facts', label: tt('tab.facts'), icon: '📜' },
|
|
83
87
|
{ id: 'assetsGenre', label: '题材基底', icon: '🏷️' },
|
|
84
88
|
{ id: 'assetsProgression', label: '推进模式', icon: '📈' },
|
|
85
89
|
{ id: 'assetsTemplates', label: '笔法帖', icon: '🖋️' },
|
|
@@ -219,7 +223,7 @@ function renderBeats(beats: string): ReactElement {
|
|
|
219
223
|
}
|
|
220
224
|
|
|
221
225
|
/** The diff list of a draft-vs-original comparison (scrollable). */
|
|
222
|
-
function DiffList({ original, draft }: { original: string; draft: string }): ReactElement { const rows = useMemo(() => paragraphDiff(original, draft), [original, draft])
|
|
226
|
+
function DiffList({ original, draft, fontSize }: { original: string; draft: string; fontSize?: number }): ReactElement { const rows = useMemo(() => paragraphDiff(original, draft), [original, draft])
|
|
223
227
|
const changed = rows.filter(r => r.kind === 'change').length
|
|
224
228
|
const added = rows.filter(r => r.kind === 'add').length
|
|
225
229
|
const removed = rows.filter(r => r.kind === 'del').length
|
|
@@ -242,7 +246,7 @@ function DiffList({ original, draft }: { original: string; draft: string }): Rea
|
|
|
242
246
|
只看改动
|
|
243
247
|
</label>
|
|
244
248
|
</div>
|
|
245
|
-
<div className={css.diffList}>
|
|
249
|
+
<div className={css.diffList} style={fontSize !== undefined ? { fontSize } : undefined}>
|
|
246
250
|
{shown.map((row, idx) => {
|
|
247
251
|
if (row.kind === 'same') {
|
|
248
252
|
return (
|
|
@@ -333,6 +337,31 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
333
337
|
const wsEditorRef = useRef<HTMLTextAreaElement | null>(null)
|
|
334
338
|
/** 工作区:手动编辑后的 AI 审查结果(不落盘)。 */
|
|
335
339
|
const [wsCheckReport, setWsCheckReport] = useState<ReviewReport | null>(null)
|
|
340
|
+
/** 手动审查结果中作者勾选要修复的问题(issue 下标)。 */
|
|
341
|
+
const [wsChecked, setWsChecked] = useState<number[]>([])
|
|
342
|
+
/** 编辑页字号(localStorage 记忆,仅影响显示)。 */
|
|
343
|
+
const [editorFontSize, setEditorFontSize] = useState<number>(() => {
|
|
344
|
+
try {
|
|
345
|
+
const v = Number(window.localStorage.getItem('dsh-novel-forge.editor.fontSize'))
|
|
346
|
+
return v >= 12 && v <= 24 ? v : 14
|
|
347
|
+
} catch { return 14 }
|
|
348
|
+
})
|
|
349
|
+
const changeEditorFontSize = (next: number): void => {
|
|
350
|
+
const v = Math.min(24, Math.max(12, next))
|
|
351
|
+
setEditorFontSize(v)
|
|
352
|
+
try { window.localStorage.setItem('dsh-novel-forge.editor.fontSize', String(v)) } catch { /* ignore */ }
|
|
353
|
+
}
|
|
354
|
+
/** 面板主题(localStorage 记忆):'liquid'=iOS 液态玻璃(绿) / 'classic'=经典毛玻璃(蓝) / 'neumorph'=新拟物(浅色)。 */
|
|
355
|
+
const [panelTheme, setPanelTheme] = useState<'liquid' | 'classic' | 'neumorph'>(() => {
|
|
356
|
+
try {
|
|
357
|
+
const v = window.localStorage.getItem('dsh-novel-forge.theme')
|
|
358
|
+
return v === 'classic' || v === 'neumorph' ? v : 'liquid'
|
|
359
|
+
} catch { return 'liquid' }
|
|
360
|
+
})
|
|
361
|
+
const changePanelTheme = (next: 'liquid' | 'classic' | 'neumorph'): void => {
|
|
362
|
+
setPanelTheme(next)
|
|
363
|
+
try { window.localStorage.setItem('dsh-novel-forge.theme', next) } catch { /* ignore */ }
|
|
364
|
+
}
|
|
336
365
|
/** 有未采纳草稿的章节号(refresh 后检测到遗留草稿时提示)。 */
|
|
337
366
|
const [draftNo, setDraftNo] = useState<number | null>(null)
|
|
338
367
|
/** 大纲页「更新大纲」编辑区是否展开。 */
|
|
@@ -353,8 +382,29 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
353
382
|
const coverFileRef = useRef<HTMLInputElement | null>(null)
|
|
354
383
|
/** 章节列表按卷折叠(存已折叠的卷号)。 */
|
|
355
384
|
const [collapsedVolumes, setCollapsedVolumes] = useState<number[]>([])
|
|
385
|
+
/** 章节页当前选中的卷('all' = 全部卷显示在一起)。 */
|
|
386
|
+
const [selectedVolume, setSelectedVolume] = useState<number | 'all'>('all')
|
|
387
|
+
/** 剧情线编辑草稿(null = 未在编辑)。 */
|
|
388
|
+
const [plotlineDraft, setPlotlineDraft] = useState<{
|
|
389
|
+
id: string
|
|
390
|
+
name: string
|
|
391
|
+
kind: Plotline['kind']
|
|
392
|
+
goal: string
|
|
393
|
+
progress: string
|
|
394
|
+
status: Plotline['status']
|
|
395
|
+
} | null>(null)
|
|
396
|
+
/** 角色知情度编辑草稿(角色名 → 文本,每行一条)。 */
|
|
397
|
+
const [knowledgeDraft, setKnowledgeDraft] = useState<Record<string, string>>({})
|
|
398
|
+
/** 全书敏感词检查结果(null = 未运行)。 */
|
|
399
|
+
const [sensHits, setSensHits] = useState<SensitiveHit[] | null>(null)
|
|
400
|
+
const [sensScanned, setSensScanned] = useState(0)
|
|
356
401
|
/** npm 最新版本(更新检测;null = 未检测/检测失败)。 */
|
|
357
402
|
const [npmLatest, setNpmLatest] = useState<string | null>(null)
|
|
403
|
+
/** 活动输出容器:自动滚动锚点(有新活动时跟随到底部)。 */
|
|
404
|
+
const progressEndRef = useRef<HTMLDivElement | null>(null)
|
|
405
|
+
useEffect(() => {
|
|
406
|
+
progressEndRef.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
|
|
407
|
+
}, [progress])
|
|
358
408
|
|
|
359
409
|
/** 后台检测 npm 最新版本(失败静默,不打扰)。 */
|
|
360
410
|
useEffect(() => {
|
|
@@ -654,6 +704,116 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
654
704
|
}
|
|
655
705
|
}
|
|
656
706
|
|
|
707
|
+
/** 剧情线:保存草稿(新增或更新)。 */
|
|
708
|
+
const handlePlotlineSave = async (): Promise<void> => {
|
|
709
|
+
if (plotlineDraft === null) return
|
|
710
|
+
const line: Plotline = {
|
|
711
|
+
id: plotlineDraft.id,
|
|
712
|
+
name: plotlineDraft.name.trim(),
|
|
713
|
+
kind: plotlineDraft.kind,
|
|
714
|
+
goal: plotlineDraft.goal.trim(),
|
|
715
|
+
progress: plotlineDraft.progress.trim(),
|
|
716
|
+
status: plotlineDraft.status,
|
|
717
|
+
chapters: plotlineDraft.id !== ''
|
|
718
|
+
? (project?.plotlines?.find(l => l.id === plotlineDraft.id)?.chapters ?? [])
|
|
719
|
+
: [],
|
|
720
|
+
createdAt: plotlineDraft.id !== ''
|
|
721
|
+
? (project?.plotlines?.find(l => l.id === plotlineDraft.id)?.createdAt ?? new Date().toISOString())
|
|
722
|
+
: new Date().toISOString(),
|
|
723
|
+
}
|
|
724
|
+
if (line.name === '') {
|
|
725
|
+
setError('剧情线名称不能为空')
|
|
726
|
+
return
|
|
727
|
+
}
|
|
728
|
+
setBusy(true)
|
|
729
|
+
setError('')
|
|
730
|
+
try {
|
|
731
|
+
const result = await api.plotlines({ op: plotlineDraft.id !== '' ? 'update' : 'add', line })
|
|
732
|
+
setProject(prev => prev === null ? prev : { ...prev, plotlines: result.plotlines, updatedAt: new Date().toISOString() })
|
|
733
|
+
setPlotlineDraft(null)
|
|
734
|
+
pushProgress(plotlineDraft.id !== '' ? `剧情线已更新:${line.name}` : `剧情线已创建:${line.name}`, 'done')
|
|
735
|
+
} catch (err) {
|
|
736
|
+
setError((err as Error).message)
|
|
737
|
+
pushProgress(`保存剧情线失败:${(err as Error).message}`, 'error')
|
|
738
|
+
} finally {
|
|
739
|
+
setBusy(false)
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
/** 剧情线:删除。 */
|
|
744
|
+
const handlePlotlineRemove = async (id: string): Promise<void> => {
|
|
745
|
+
if (!window.confirm('确定删除这条剧情线?关联章节记录会一并移除。')) return
|
|
746
|
+
setBusy(true)
|
|
747
|
+
setError('')
|
|
748
|
+
try {
|
|
749
|
+
const result = await api.plotlines({ op: 'remove', id })
|
|
750
|
+
setProject(prev => prev === null ? prev : { ...prev, plotlines: result.plotlines, updatedAt: new Date().toISOString() })
|
|
751
|
+
pushProgress('剧情线已删除', 'done')
|
|
752
|
+
} catch (err) {
|
|
753
|
+
setError((err as Error).message)
|
|
754
|
+
} finally {
|
|
755
|
+
setBusy(false)
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
/** 剧情线:把本章关联到某条线(推进节点)。 */
|
|
760
|
+
const handlePlotlineLink = async (id: string, chapterNo: number): Promise<void> => {
|
|
761
|
+
setBusy(true)
|
|
762
|
+
setError('')
|
|
763
|
+
try {
|
|
764
|
+
const result = await api.plotlines({ op: 'link', id, chapterNo })
|
|
765
|
+
setProject(prev => prev === null ? prev : { ...prev, plotlines: result.plotlines, updatedAt: new Date().toISOString() })
|
|
766
|
+
pushProgress(`已把第 ${chapterNo} 章关联到剧情线`, 'done')
|
|
767
|
+
} catch (err) {
|
|
768
|
+
setError((err as Error).message)
|
|
769
|
+
} finally {
|
|
770
|
+
setBusy(false)
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
/** 全书敏感词检查(硬匹配内置词库)。 */
|
|
775
|
+
const handleSensitiveScan = async (): Promise<void> => {
|
|
776
|
+
setBusy(true)
|
|
777
|
+
setBusyLabel('敏感词检查中…')
|
|
778
|
+
setError('')
|
|
779
|
+
try {
|
|
780
|
+
const result = await api.sensitiveCheck({ all: true })
|
|
781
|
+
setSensHits(result.hits)
|
|
782
|
+
setSensScanned(result.scannedChapters)
|
|
783
|
+
pushProgress(result.hits.length > 0
|
|
784
|
+
? `敏感词检查:${result.hits.length} 处命中(${new Set(result.hits.map(h => h.chapterNo)).size} 章受影响)`
|
|
785
|
+
: `敏感词检查完成:扫描 ${result.scannedChapters} 章,未命中违禁词`, result.hits.length > 0 ? 'error' : 'done')
|
|
786
|
+
} catch (err) {
|
|
787
|
+
setError((err as Error).message)
|
|
788
|
+
pushProgress(`敏感词检查失败:${(err as Error).message}`, 'error')
|
|
789
|
+
} finally {
|
|
790
|
+
setBusy(false)
|
|
791
|
+
setBusyLabel('')
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
/** 保存角色知情度(bible.characters 整体更新)。 */
|
|
796
|
+
const handleKnowledgeSave = async (): Promise<void> => {
|
|
797
|
+
if (bible === undefined) return
|
|
798
|
+
const characters = bible.characters.map(card => ({
|
|
799
|
+
...card,
|
|
800
|
+
knowledge: (knowledgeDraft[card.name] ?? (card.knowledge ?? []).join('\n'))
|
|
801
|
+
.split('\n').map(l => l.trim()).filter(l => l !== ''),
|
|
802
|
+
}))
|
|
803
|
+
setBusy(true)
|
|
804
|
+
setError('')
|
|
805
|
+
try {
|
|
806
|
+
const result = await api.biblePatch({ characters })
|
|
807
|
+
setProject(prev => prev === null || prev.bible === undefined ? prev : { ...prev, bible: result.bible, updatedAt: new Date().toISOString() })
|
|
808
|
+
pushProgress('角色知情度已保存(生成/审稿都会严格遵守信息差)', 'done')
|
|
809
|
+
} catch (err) {
|
|
810
|
+
setError((err as Error).message)
|
|
811
|
+
pushProgress(`保存知情度失败:${(err as Error).message}`, 'error')
|
|
812
|
+
} finally {
|
|
813
|
+
setBusy(false)
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
|
|
657
817
|
/** 保存世界观规则编辑(bible tab,每行一条)。 */
|
|
658
818
|
const handleSaveWorldRules = async (): Promise<void> => {
|
|
659
819
|
if (bible === undefined) return
|
|
@@ -776,6 +936,10 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
776
936
|
try {
|
|
777
937
|
const result = await api.chapterCheck(workspace.no, workspace.original)
|
|
778
938
|
setWsCheckReport(result.report)
|
|
939
|
+
// 默认勾选 high 问题(无 high 则勾选全部 medium),作者可自行增删。
|
|
940
|
+
const highIdx = result.report.issues.map((it, i) => ({ it, i })).filter(x => x.it.severity === 'high').map(x => x.i)
|
|
941
|
+
const mediumIdx = result.report.issues.map((it, i) => ({ it, i })).filter(x => x.it.severity === 'medium').map(x => x.i)
|
|
942
|
+
setWsChecked(highIdx.length > 0 ? highIdx : mediumIdx)
|
|
779
943
|
pushProgress(`审查完成:${result.report.score} 分 — ${result.report.verdict}`, result.report.passed ? 'done' : 'error')
|
|
780
944
|
} catch (err) {
|
|
781
945
|
setError((err as Error).message)
|
|
@@ -801,6 +965,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
801
965
|
setWorkspace(null)
|
|
802
966
|
setDraftNo(null)
|
|
803
967
|
setWsCheckReport(null)
|
|
968
|
+
setWsChecked([])
|
|
804
969
|
if (report !== undefined) {
|
|
805
970
|
pushProgress(`已保存并审稿:${report.score} 分 — ${report.verdict}(${report.passed ? '通过' : '未通过'})`, report.passed ? 'done' : 'error')
|
|
806
971
|
} else {
|
|
@@ -947,7 +1112,9 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
947
1112
|
draft: chapter.pendingDraft !== undefined && chapter.pendingDraft !== '' ? chapter.pendingDraft : null,
|
|
948
1113
|
})
|
|
949
1114
|
setWsSelected('')
|
|
950
|
-
setWsShowDiff(
|
|
1115
|
+
setWsShowDiff(true)
|
|
1116
|
+
setWsCheckReport(null)
|
|
1117
|
+
setWsChecked([])
|
|
951
1118
|
} catch { /* best-effort */ }
|
|
952
1119
|
}, [api])
|
|
953
1120
|
|
|
@@ -980,14 +1147,15 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
980
1147
|
}
|
|
981
1148
|
|
|
982
1149
|
/** 工作区:按指令修订(whole=true 整章,false 仅修订选中片段)。 */
|
|
983
|
-
const handleWsRewrite = async (whole: boolean): Promise<void> => {
|
|
1150
|
+
const handleWsRewrite = async (whole: boolean, overrideInstruction?: string): Promise<void> => {
|
|
984
1151
|
if (workspace === null) return
|
|
985
1152
|
const target = whole ? '' : wsSelected
|
|
1153
|
+
const instruction = overrideInstruction ?? workspace.instruction
|
|
986
1154
|
setBusy(true)
|
|
987
1155
|
setBusyLabel(`${tt('plan.rewrite')} 第${workspace.no}章`)
|
|
988
1156
|
setError('')
|
|
989
1157
|
try {
|
|
990
|
-
await api.rewrite(workspace.no,
|
|
1158
|
+
await api.rewrite(workspace.no, instruction, target, frame => { applyJobFrame(frame, n => tt('progress.rewriting', { no: n })) })
|
|
991
1159
|
} catch (err) {
|
|
992
1160
|
setError((err as Error).message)
|
|
993
1161
|
pushProgress(`第 ${workspace.no} 章修订失败:${(err as Error).message}`, 'error')
|
|
@@ -998,6 +1166,19 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
998
1166
|
}
|
|
999
1167
|
}
|
|
1000
1168
|
|
|
1169
|
+
/** 工作区:按审查报告中勾选的问题一键修订(预填指令 + 立即整章修订到草稿)。 */
|
|
1170
|
+
const handleWsReviseByReport = async (): Promise<void> => {
|
|
1171
|
+
if (workspace === null || wsCheckReport === null) return
|
|
1172
|
+
const picked = wsChecked
|
|
1173
|
+
.map(i => wsCheckReport.issues[i])
|
|
1174
|
+
.filter((it): it is ReviewReport['issues'][number] => it !== undefined)
|
|
1175
|
+
.slice(0, 5)
|
|
1176
|
+
if (picked.length === 0) return
|
|
1177
|
+
const instruction = '按审稿意见修订(优先处理):\n' + picked.map(i => `[${i.severity}] ${i.item} → ${i.suggestion}`).join('\n')
|
|
1178
|
+
setWorkspace({ ...workspace, instruction })
|
|
1179
|
+
await handleWsRewrite(true, instruction)
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1001
1182
|
/** 采纳草稿:覆盖正文文件并刷新。 */
|
|
1002
1183
|
const handleDraftApply = async (no: number): Promise<void> => {
|
|
1003
1184
|
setBusy(true)
|
|
@@ -1454,7 +1635,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
1454
1635
|
})()
|
|
1455
1636
|
|
|
1456
1637
|
return (
|
|
1457
|
-
<div className={css.panel}>
|
|
1638
|
+
<div className={css.panel} data-nf-theme={panelTheme}>
|
|
1458
1639
|
{viewMode === 'shelf' ? (
|
|
1459
1640
|
/* 书架首页:默认视图,选择一本书进入工作台 */
|
|
1460
1641
|
<ShelfView
|
|
@@ -1513,22 +1694,26 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
1513
1694
|
) : (
|
|
1514
1695
|
<>
|
|
1515
1696
|
<div className={css.panelHeader}>
|
|
1516
|
-
<
|
|
1517
|
-
<
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
)
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1697
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 2, minWidth: 0 }}>
|
|
1698
|
+
<h2 className={css.panelTitle}>
|
|
1699
|
+
<button type="button" className={css.iconButton} title="返回书架" aria-label="返回书架" onClick={() => { setViewMode('shelf') }}>
|
|
1700
|
+
←
|
|
1701
|
+
</button>
|
|
1702
|
+
{tt('panel.title')}
|
|
1703
|
+
</h2>
|
|
1704
|
+
{(project?.bookName !== '' && project?.bookName !== undefined) && (
|
|
1705
|
+
<div className={css.panelSubtitle}>
|
|
1706
|
+
<span>📖 {project.bookName}</span>
|
|
1707
|
+
{chapters.length > 0 && (
|
|
1708
|
+
<span className={css.headerProgress}>
|
|
1709
|
+
已完成 <b>{doneCount}</b>/{chapters.length} 章
|
|
1710
|
+
<span className={css.headerProgressDot} />
|
|
1711
|
+
通过 <b>{chapters.filter(c => c.status === 'approved').length}</b>
|
|
1712
|
+
</span>
|
|
1713
|
+
)}
|
|
1714
|
+
</div>
|
|
1530
1715
|
)}
|
|
1531
|
-
</
|
|
1716
|
+
</div>
|
|
1532
1717
|
<div style={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
|
1533
1718
|
<button
|
|
1534
1719
|
type="button"
|
|
@@ -1665,12 +1850,21 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
1665
1850
|
</div>
|
|
1666
1851
|
)}
|
|
1667
1852
|
|
|
1668
|
-
{/*
|
|
1853
|
+
{/* 章节编辑页(独占整页:点「编辑」进入,返回后回到原页面;无卡片盒子,撑满内容区) */}
|
|
1669
1854
|
{workspace !== null && (
|
|
1670
|
-
<div className={css.
|
|
1671
|
-
<div className={css.
|
|
1672
|
-
<
|
|
1673
|
-
|
|
1855
|
+
<div className={css.wsPage}>
|
|
1856
|
+
<div className={css.wsPageHeader}>
|
|
1857
|
+
<button type="button" className={`${css.button} ${css.buttonSmall}`} onClick={() => { setWorkspace(null) }} title="返回章节列表(草稿不丢失)">
|
|
1858
|
+
← 返回
|
|
1859
|
+
</button>
|
|
1860
|
+
<span className={css.cardTitle}>第 {workspace.no} 章《{workspace.title}》</span>
|
|
1861
|
+
<span className={css.meta}>{workspace.original.length} 字</span>
|
|
1862
|
+
<span style={{ display: 'flex', gap: 4, alignItems: 'center', marginLeft: 'auto' }}>
|
|
1863
|
+
<button type="button" className={css.iconButton} title="减小字号" aria-label="减小字号" onClick={() => { changeEditorFontSize(editorFontSize - 1) }}>A−</button>
|
|
1864
|
+
<span className={css.meta}>{editorFontSize}px</span>
|
|
1865
|
+
<button type="button" className={css.iconButton} title="增大字号" aria-label="增大字号" onClick={() => { changeEditorFontSize(editorFontSize + 1) }}>A+</button>
|
|
1866
|
+
<button type="button" className={css.iconButton} title="关闭工作区" aria-label="关闭工作区" onClick={() => { setWorkspace(null) }}>×</button>
|
|
1867
|
+
</span>
|
|
1674
1868
|
</div>
|
|
1675
1869
|
<div className={css.wsColumns}>
|
|
1676
1870
|
<div className={css.wsColumn}>
|
|
@@ -1680,6 +1874,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
1680
1874
|
<textarea
|
|
1681
1875
|
ref={wsEditorRef}
|
|
1682
1876
|
className={`${css.textarea} ${css.wsEditor}`}
|
|
1877
|
+
style={{ fontSize: editorFontSize }}
|
|
1683
1878
|
value={workspace.original}
|
|
1684
1879
|
onChange={e => { setWorkspace({ ...workspace, original: e.target.value }) }}
|
|
1685
1880
|
onMouseUp={captureWsSelection}
|
|
@@ -1706,7 +1901,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
1706
1901
|
<div className={css.meta}>未选中内容时仅支持整章润色/修订。</div>
|
|
1707
1902
|
)}
|
|
1708
1903
|
<div className={css.row} style={{ flexWrap: 'wrap' }}>
|
|
1709
|
-
{workspace.instruction.
|
|
1904
|
+
{workspace.instruction.includes('按审稿意见修订') && (
|
|
1710
1905
|
<button type="button" className={`${css.button} ${css.buttonSmall} ${css.buttonPrimary}`} disabled={busy} onClick={() => { void handleWsRewrite(true) }} title="AI 按已预填的审稿意见自动修订整章(无需自己找问题)">
|
|
1711
1906
|
🔧 按意见修订
|
|
1712
1907
|
</button>
|
|
@@ -1738,20 +1933,44 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
1738
1933
|
</div>
|
|
1739
1934
|
<div className={css.meta}><b>总评:</b>{wsCheckReport.verdict}</div>
|
|
1740
1935
|
{wsCheckReport.issues.length > 0 && (
|
|
1741
|
-
<ul style={{ margin: 0, paddingLeft: 16,
|
|
1936
|
+
<ul style={{ margin: 0, paddingLeft: 16, display: 'flex', flexDirection: 'column', gap: 3, fontSize: editorFontSize - 1, maxHeight: '45vh', overflowY: 'auto' }}>
|
|
1742
1937
|
{wsCheckReport.issues.map((issue, i) => (
|
|
1743
|
-
<li key={i} style={{ color: severityColor(issue.severity) }}>
|
|
1744
|
-
|
|
1745
|
-
|
|
1938
|
+
<li key={i} style={{ color: severityColor(issue.severity), display: 'flex', gap: 6, alignItems: 'flex-start' }}>
|
|
1939
|
+
<input
|
|
1940
|
+
type="checkbox"
|
|
1941
|
+
style={{ marginTop: 1 }}
|
|
1942
|
+
checked={wsChecked.includes(i)}
|
|
1943
|
+
onChange={e => {
|
|
1944
|
+
setWsChecked(prev => e.target.checked ? [...prev, i] : prev.filter(x => x !== i))
|
|
1945
|
+
}}
|
|
1946
|
+
title="勾选后可由「修复所选问题」一起修订"
|
|
1947
|
+
/>
|
|
1948
|
+
<span>
|
|
1949
|
+
[{issue.severity}] {issue.item}
|
|
1950
|
+
{issue.suggestion !== '' && <span style={{ color: 'var(--nf-text-2)' }}> → {issue.suggestion}</span>}
|
|
1951
|
+
</span>
|
|
1746
1952
|
</li>
|
|
1747
1953
|
))}
|
|
1748
1954
|
</ul>
|
|
1749
1955
|
)}
|
|
1750
|
-
<span className={css.meta}
|
|
1956
|
+
<span className={css.meta}>审查只读不落盘;勾选要修的问题,点下方按钮一键修订;改完点「💾 保存为正文」才写入文件(原稿自动备份 .bak)。</span>
|
|
1957
|
+
{wsCheckReport.issues.length > 0 && wsChecked.length > 0 && (
|
|
1958
|
+
<div style={{ marginTop: 8 }}>
|
|
1959
|
+
<button
|
|
1960
|
+
type="button"
|
|
1961
|
+
className={`${css.button} ${css.buttonSmall} ${css.buttonPrimary}`}
|
|
1962
|
+
disabled={busy}
|
|
1963
|
+
onClick={() => { void handleWsReviseByReport() }}
|
|
1964
|
+
title="自动按勾选的问题修订整章,产出草稿预览(不落盘),对比后应用或放弃"
|
|
1965
|
+
>
|
|
1966
|
+
🔧 修复所选问题({wsChecked.length})
|
|
1967
|
+
</button>
|
|
1968
|
+
</div>
|
|
1969
|
+
)}
|
|
1751
1970
|
</div>
|
|
1752
1971
|
)}
|
|
1753
1972
|
{workspace.draft !== null && (
|
|
1754
|
-
<div className={css.wsPreview}>
|
|
1973
|
+
<div className={css.wsPreview} style={{ flex: 1, minHeight: 0 }}>
|
|
1755
1974
|
<div className={css.busyRow}>
|
|
1756
1975
|
<span className={css.meta}>优化预览({workspace.draft.length} 字)</span>
|
|
1757
1976
|
<span style={{ display: 'flex', gap: 8 }}>
|
|
@@ -1767,8 +1986,8 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
1767
1986
|
</span>
|
|
1768
1987
|
</div>
|
|
1769
1988
|
{wsShowDiff
|
|
1770
|
-
? <DiffList original={workspace.original} draft={workspace.draft} />
|
|
1771
|
-
: <pre className={css.wsPreviewText}>{workspace.draft}</pre>}
|
|
1989
|
+
? <DiffList original={workspace.original} draft={workspace.draft} fontSize={editorFontSize} />
|
|
1990
|
+
: <pre className={css.wsPreviewText} style={{ fontSize: editorFontSize }}>{workspace.draft}</pre>}
|
|
1772
1991
|
</div>
|
|
1773
1992
|
)}
|
|
1774
1993
|
</div>
|
|
@@ -1776,6 +1995,8 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
1776
1995
|
</div>
|
|
1777
1996
|
)}
|
|
1778
1997
|
|
|
1998
|
+
{/* 编辑页打开时独占整页:隐藏其余所有页面内容 */}
|
|
1999
|
+
{workspace === null && (<>
|
|
1779
2000
|
{activeTab === 'workflow' && (
|
|
1780
2001
|
<>
|
|
1781
2002
|
{/* ⭐ 主行动大卡片 */}
|
|
@@ -1832,6 +2053,43 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
1832
2053
|
</div>
|
|
1833
2054
|
</div>
|
|
1834
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
|
+
</div>
|
|
2065
|
+
|
|
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
|
+
|
|
1835
2093
|
{/* 状态摘要条 */}
|
|
1836
2094
|
<div className={css.assetGrid}>
|
|
1837
2095
|
<div className={css.assetStat}>
|
|
@@ -1957,6 +2215,35 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
1957
2215
|
)}
|
|
1958
2216
|
</div>
|
|
1959
2217
|
)}
|
|
2218
|
+
|
|
2219
|
+
{/* 📟 活动输出:无论生成 / 审稿 / 润色 / 质检 / 助手操作,全部活动实时显示于此 */}
|
|
2220
|
+
<div className={css.card}>
|
|
2221
|
+
<div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}>
|
|
2222
|
+
<span className={css.cardTitle}>📟 活动输出({progress.length})</span>
|
|
2223
|
+
<div className={css.row}>
|
|
2224
|
+
<span className={css.meta}>生成/审稿/润色/质检等所有操作都会记录在这里</span>
|
|
2225
|
+
{progress.length > 0 && (
|
|
2226
|
+
<button type="button" className={`${css.button} ${css.buttonSmall}`} onClick={() => { setProgress([]) }}>
|
|
2227
|
+
清空
|
|
2228
|
+
</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
|
+
</div>
|
|
2243
|
+
))}
|
|
2244
|
+
<div ref={progressEndRef} />
|
|
2245
|
+
</div>
|
|
2246
|
+
</div>
|
|
1960
2247
|
</>
|
|
1961
2248
|
)}
|
|
1962
2249
|
|
|
@@ -2168,9 +2455,9 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
2168
2455
|
{activeTab === 'plan' && (
|
|
2169
2456
|
<>
|
|
2170
2457
|
<div className={css.card}>
|
|
2171
|
-
<div className={css.row} style={{ justifyContent: 'space-between' }}>
|
|
2458
|
+
<div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}>
|
|
2172
2459
|
<span className={css.cardTitle}>{tt('tab.plan')}</span>
|
|
2173
|
-
<div className={css.row}>
|
|
2460
|
+
<div className={css.row} style={{ flexWrap: 'wrap' }}>
|
|
2174
2461
|
<span className={css.meta}>{tt('plan.generateHint')}</span>
|
|
2175
2462
|
<input
|
|
2176
2463
|
className={css.input}
|
|
@@ -2185,23 +2472,78 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
2185
2472
|
<button type="button" className={`${css.button} ${css.buttonPrimary}`} disabled={busy || outlineText.length < 50} onClick={() => { void handlePlan() }}>
|
|
2186
2473
|
{tt('plan.generate')}
|
|
2187
2474
|
</button>
|
|
2475
|
+
<button type="button" className={`${css.button} ${css.buttonSmall}`} disabled={busy || doneCount === 0} onClick={() => { void handleSensitiveScan() }} title={tt('sensitive.hint')}>
|
|
2476
|
+
🔞 {tt('sensitive.scanAll')}
|
|
2477
|
+
</button>
|
|
2188
2478
|
</div>
|
|
2189
2479
|
</div>
|
|
2190
|
-
{volumes !== undefined && volumes.length > 0 && (
|
|
2191
|
-
<div className={css.row}>
|
|
2192
|
-
{volumes.map(v => (
|
|
2193
|
-
<span key={v.no} className={css.badge} style={{ borderColor: 'var(--nf-accent)', color: 'var(--nf-accent)' }}>
|
|
2194
|
-
{v.no}. {v.title}({v.chapterStart}-{v.chapterEnd})
|
|
2195
|
-
</span>
|
|
2196
|
-
))}
|
|
2197
|
-
</div>
|
|
2198
|
-
)}
|
|
2199
2480
|
</div>
|
|
2200
2481
|
|
|
2482
|
+
{sensHits !== null && (
|
|
2483
|
+
<div className={css.card} style={{ borderColor: sensHits.length > 0 ? 'var(--nf-warn)' : 'var(--nf-success)' }}>
|
|
2484
|
+
<div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}>
|
|
2485
|
+
<span className={css.cardTitle}>
|
|
2486
|
+
{sensHits.length === 0
|
|
2487
|
+
? tt('sensitive.clean', { n: sensScanned })
|
|
2488
|
+
: tt('sensitive.hits', { n: sensHits.length, chapters: new Set(sensHits.map(h => h.chapterNo)).size })}
|
|
2489
|
+
</span>
|
|
2490
|
+
<button type="button" className={`${css.button} ${css.buttonSmall}`} onClick={() => { setSensHits(null) }}>收起</button>
|
|
2491
|
+
</div>
|
|
2492
|
+
<span className={css.meta}>{tt('sensitive.hint')}</span>
|
|
2493
|
+
{sensHits.length > 0 && (
|
|
2494
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, maxHeight: 300, overflowY: 'auto', fontSize: 12 }}>
|
|
2495
|
+
{sensHits.map((hit, i) => (
|
|
2496
|
+
<div key={i} style={{ display: 'flex', gap: 8, alignItems: 'flex-start', border: '1px solid var(--nf-border)', borderRadius: 6, padding: '4px 8px' }}>
|
|
2497
|
+
<span className={css.badge} style={{ borderColor: 'var(--nf-warn)', color: 'var(--nf-warn)', flex: 'none' }}>
|
|
2498
|
+
{hit.chapterNo > 0 ? `第${hit.chapterNo}章` : '文本'}
|
|
2499
|
+
</span>
|
|
2500
|
+
<span className={css.meta} style={{ flex: 1 }}>
|
|
2501
|
+
<b style={{ color: 'var(--nf-error)' }}>{hit.word}</b> ×{hit.count} · [{hit.category}]
|
|
2502
|
+
</span>
|
|
2503
|
+
{hit.chapterNo > 0 && (
|
|
2504
|
+
<button
|
|
2505
|
+
type="button"
|
|
2506
|
+
className={`${css.button} ${css.buttonSmall}`}
|
|
2507
|
+
disabled={busy}
|
|
2508
|
+
onClick={() => { void openWorkspace(hit.chapterNo, tt('sensitive.fixPrefill', { word: hit.word, category: hit.category })) }}
|
|
2509
|
+
>
|
|
2510
|
+
{tt('sensitive.goFix')}
|
|
2511
|
+
</button>
|
|
2512
|
+
)}
|
|
2513
|
+
</div>
|
|
2514
|
+
))}
|
|
2515
|
+
</div>
|
|
2516
|
+
)}
|
|
2517
|
+
</div>
|
|
2518
|
+
)}
|
|
2519
|
+
|
|
2201
2520
|
{chapters.length > 0 && (
|
|
2202
2521
|
<div className={css.card}>
|
|
2203
|
-
<div className={css.row} style={{ justifyContent: 'space-between' }}>
|
|
2204
|
-
<
|
|
2522
|
+
<div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}>
|
|
2523
|
+
<div className={css.row} style={{ flexWrap: 'wrap', gap: 6 }}>
|
|
2524
|
+
{(volumes !== undefined && volumes.length > 0) && (
|
|
2525
|
+
<>
|
|
2526
|
+
<button
|
|
2527
|
+
type="button"
|
|
2528
|
+
className={`${css.button} ${css.buttonSmall} ${selectedVolume === 'all' ? css.buttonPrimary : ''}`}
|
|
2529
|
+
onClick={() => { setSelectedVolume('all') }}
|
|
2530
|
+
>
|
|
2531
|
+
全部卷
|
|
2532
|
+
</button>
|
|
2533
|
+
{volumes.map(v => (
|
|
2534
|
+
<button
|
|
2535
|
+
key={v.no}
|
|
2536
|
+
type="button"
|
|
2537
|
+
className={`${css.button} ${css.buttonSmall} ${selectedVolume === v.no ? css.buttonPrimary : ''}`}
|
|
2538
|
+
onClick={() => { setSelectedVolume(v.no) }}
|
|
2539
|
+
title={`第${v.no}卷 · ${v.chapterStart}-${v.chapterEnd} 章`}
|
|
2540
|
+
>
|
|
2541
|
+
{v.no}. {v.title}({v.chapterStart}-{v.chapterEnd})
|
|
2542
|
+
</button>
|
|
2543
|
+
))}
|
|
2544
|
+
</>
|
|
2545
|
+
)}
|
|
2546
|
+
</div>
|
|
2205
2547
|
{pendingCount > 0 && (
|
|
2206
2548
|
<button type="button" className={`${css.button} ${css.buttonPrimary}`} disabled={busy} onClick={() => { void handleWriteAll() }}>
|
|
2207
2549
|
{tt('plan.writeAllPending')}({pendingCount})
|
|
@@ -2209,7 +2551,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
2209
2551
|
)}
|
|
2210
2552
|
</div>
|
|
2211
2553
|
<div className={css.chapterList}>
|
|
2212
|
-
{chapterGroups.map(group => {
|
|
2554
|
+
{chapterGroups.filter(g => selectedVolume === 'all' || g.no === selectedVolume).map(group => {
|
|
2213
2555
|
const collapsed = group.no !== 0 && collapsedVolumes.includes(group.no)
|
|
2214
2556
|
const groupDone = group.chapters.filter(c => c.status === 'approved' || c.status === 'written' || c.status === 'rejected').length
|
|
2215
2557
|
return (
|
|
@@ -2340,23 +2682,6 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
2340
2682
|
</div>
|
|
2341
2683
|
</div>
|
|
2342
2684
|
)}
|
|
2343
|
-
|
|
2344
|
-
<div className={css.card}>
|
|
2345
|
-
<span className={css.cardTitle}>{tt('plan.progress')}</span>
|
|
2346
|
-
<div className={css.progress}>
|
|
2347
|
-
{progress.length === 0 && <span className={css.meta}>{tt('progress.empty')}</span>}
|
|
2348
|
-
{progress.map(line => (
|
|
2349
|
-
<div key={line.id} className={line.kind === 'done' ? css.progressLineDone : line.kind === 'error' ? css.progressLineError : line.live === true ? css.progressLineLive : css.progressLine}>
|
|
2350
|
-
{line.live === true && (
|
|
2351
|
-
<span className={css.progressBar}>
|
|
2352
|
-
<span className={css.progressBarFill} style={{ width: `${Math.round((line.ratio ?? 0) * 100)}%` }} />
|
|
2353
|
-
</span>
|
|
2354
|
-
)}
|
|
2355
|
-
{line.text}
|
|
2356
|
-
</div>
|
|
2357
|
-
))}
|
|
2358
|
-
</div>
|
|
2359
|
-
</div>
|
|
2360
2685
|
</>
|
|
2361
2686
|
)}
|
|
2362
2687
|
|
|
@@ -2493,6 +2818,44 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
2493
2818
|
</div>
|
|
2494
2819
|
)}
|
|
2495
2820
|
|
|
2821
|
+
{/* 角色知情度:信息差管理 */}
|
|
2822
|
+
{activeTab === 'characters' && (
|
|
2823
|
+
<div className={css.card}>
|
|
2824
|
+
<div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}>
|
|
2825
|
+
<span className={css.cardTitle}>角色知情度(信息差管理)</span>
|
|
2826
|
+
<button type="button" className={`${css.button} ${css.buttonSmall} ${css.buttonPrimary}`} disabled={busy || (bible?.characters ?? []).length === 0} onClick={() => { void handleKnowledgeSave() }}>
|
|
2827
|
+
💾 保存知情度
|
|
2828
|
+
</button>
|
|
2829
|
+
</div>
|
|
2830
|
+
<span className={css.meta}>
|
|
2831
|
+
填写每个角色「已经知道」的事实/秘密(每行一条)。生成与审稿会严格遵守:未列出的信息该角色一律不知道——避免"不该知道的人知道了"。
|
|
2832
|
+
</span>
|
|
2833
|
+
{(bible?.characters ?? []).length === 0 ? (
|
|
2834
|
+
<span className={css.meta}>暂无角色卡——先在「道藏」提炼设定。</span>
|
|
2835
|
+
) : (
|
|
2836
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
|
2837
|
+
{bible!.characters.map(card => (
|
|
2838
|
+
<div key={card.name} style={{ border: '1px solid var(--nf-border)', borderRadius: 10, padding: '8px 10px', fontSize: 12 }}>
|
|
2839
|
+
<div className={css.row} style={{ justifyContent: 'space-between' }}>
|
|
2840
|
+
<b>{card.name}</b>
|
|
2841
|
+
<span className={css.meta}>
|
|
2842
|
+
{card.role === 'protagonist' ? '主角' : card.role === 'supporting' ? '配角' : card.role === 'antagonist' ? '反派' : '其他'}
|
|
2843
|
+
</span>
|
|
2844
|
+
</div>
|
|
2845
|
+
<textarea
|
|
2846
|
+
className={css.textarea}
|
|
2847
|
+
style={{ minHeight: 52, fontSize: 12 }}
|
|
2848
|
+
placeholder="每行一条该角色知道的信息,例如:林越的真实身份 / 古玉残片的秘密…"
|
|
2849
|
+
value={knowledgeDraft[card.name] ?? (card.knowledge ?? []).join('\n')}
|
|
2850
|
+
onChange={e => { setKnowledgeDraft(prev => ({ ...prev, [card.name]: e.target.value })) }}
|
|
2851
|
+
/>
|
|
2852
|
+
</div>
|
|
2853
|
+
))}
|
|
2854
|
+
</div>
|
|
2855
|
+
)}
|
|
2856
|
+
</div>
|
|
2857
|
+
)}
|
|
2858
|
+
|
|
2496
2859
|
{/* 写作资产 5 个子分类(左侧导航直达) */}
|
|
2497
2860
|
{activeTab === 'assetsGenre' && <AssetsTab api={api} initialTab="genre" />}
|
|
2498
2861
|
{activeTab === 'assetsProgression' && <AssetsTab api={api} initialTab="progression" />}
|
|
@@ -2588,6 +2951,19 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
2588
2951
|
<label className={css.fieldLabel}>{tt('settings.reviewPassScore')}</label>
|
|
2589
2952
|
<input className={css.input} type="number" min={0} max={100} value={configDraft.reviewPassScore} onChange={e => { setConfigDraft({ ...configDraft, reviewPassScore: Number(e.target.value) }) }} />
|
|
2590
2953
|
</div>
|
|
2954
|
+
<div className={css.field} style={{ flex: 1 }}>
|
|
2955
|
+
<label className={css.fieldLabel}>{tt('settings.editorFontSize')}</label>
|
|
2956
|
+
<select
|
|
2957
|
+
className={css.input}
|
|
2958
|
+
value={editorFontSize}
|
|
2959
|
+
onChange={e => { changeEditorFontSize(Number(e.target.value)) }}
|
|
2960
|
+
>
|
|
2961
|
+
{[12, 13, 14, 15, 16, 18, 20, 22, 24].map(v => (
|
|
2962
|
+
<option key={v} value={v}>{v}px</option>
|
|
2963
|
+
))}
|
|
2964
|
+
</select>
|
|
2965
|
+
<span className={css.meta}>{tt('settings.editorFontSizeHint')}</span>
|
|
2966
|
+
</div>
|
|
2591
2967
|
<div className={css.field} style={{ flex: 1 }}>
|
|
2592
2968
|
<label className={css.fieldLabel}>{tt('settings.autoReview')}</label>
|
|
2593
2969
|
<select
|
|
@@ -2618,27 +2994,173 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
2618
2994
|
{tt('settings.exportMd')}
|
|
2619
2995
|
</button>
|
|
2620
2996
|
</div>
|
|
2621
|
-
|
|
2622
|
-
|
|
2623
|
-
|
|
2624
|
-
|
|
2625
|
-
|
|
2626
|
-
|
|
2627
|
-
|
|
2628
|
-
|
|
2629
|
-
|
|
2630
|
-
|
|
2631
|
-
|
|
2632
|
-
|
|
2633
|
-
|
|
2634
|
-
|
|
2635
|
-
|
|
2636
|
-
|
|
2997
|
+
</div>
|
|
2998
|
+
)}
|
|
2999
|
+
|
|
3000
|
+
{activeTab === 'settings' && (
|
|
3001
|
+
<div className={css.card}>
|
|
3002
|
+
<span className={css.cardTitle}>{tt('settings.theme')}</span>
|
|
3003
|
+
<div className={css.row} style={{ gap: 8, flexWrap: 'wrap' }}>
|
|
3004
|
+
<button
|
|
3005
|
+
type="button"
|
|
3006
|
+
className={`${css.button} ${css.buttonSmall} ${panelTheme === 'liquid' ? css.buttonPrimary : ''}`}
|
|
3007
|
+
onClick={() => { changePanelTheme('liquid') }}
|
|
3008
|
+
title="iOS 液态玻璃质感 · 绿色强调(当前默认)"
|
|
3009
|
+
>
|
|
3010
|
+
🧊 {tt('settings.themeLiquid')}
|
|
3011
|
+
</button>
|
|
3012
|
+
<button
|
|
3013
|
+
type="button"
|
|
3014
|
+
className={`${css.button} ${css.buttonSmall} ${panelTheme === 'classic' ? css.buttonPrimary : ''}`}
|
|
3015
|
+
onClick={() => { changePanelTheme('classic') }}
|
|
3016
|
+
title="经典 iOS 毛玻璃 · 蓝色强调"
|
|
3017
|
+
>
|
|
3018
|
+
💠 {tt('settings.themeClassic')}
|
|
3019
|
+
</button>
|
|
3020
|
+
<button
|
|
3021
|
+
type="button"
|
|
3022
|
+
className={`${css.button} ${css.buttonSmall} ${panelTheme === 'neumorph' ? css.buttonPrimary : ''}`}
|
|
3023
|
+
onClick={() => { changePanelTheme('neumorph') }}
|
|
3024
|
+
title="新拟物派 · 双阴影立体(仅浅色;深色下自动回退液态)"
|
|
3025
|
+
>
|
|
3026
|
+
🔘 {tt('settings.themeNeumorph')}
|
|
3027
|
+
</button>
|
|
3028
|
+
<span className={css.meta}>{tt('settings.themeHint')}</span>
|
|
3029
|
+
</div>
|
|
3030
|
+
</div>
|
|
3031
|
+
)}
|
|
3032
|
+
|
|
3033
|
+
{activeTab === 'facts' && (
|
|
3034
|
+
<div className={css.card}>
|
|
3035
|
+
<div className={css.busyRow} style={{ flexWrap: 'wrap' }}>
|
|
3036
|
+
<span className={css.cardTitle}>{tt('facts.title', { n: (project?.facts ?? []).length })}</span>
|
|
3037
|
+
<button
|
|
3038
|
+
type="button"
|
|
3039
|
+
className={`${css.button} ${css.buttonSmall}`}
|
|
3040
|
+
disabled={busy || chapters.length === 0}
|
|
3041
|
+
onClick={() => { void handleFactsBackfill() }}
|
|
3042
|
+
title="用 LLM 从历史章节正文重新抽取事实,补齐缺失的编年录条目"
|
|
3043
|
+
>
|
|
3044
|
+
📥 {tt('facts.backfill')}
|
|
3045
|
+
</button>
|
|
3046
|
+
</div>
|
|
3047
|
+
<span className={css.meta}>{tt('facts.hint')}</span>
|
|
3048
|
+
{(project?.facts ?? []).length > 0 ? (
|
|
3049
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, maxHeight: '60vh', overflowY: 'auto', fontSize: 12 }}>
|
|
3050
|
+
{[...(project?.facts ?? [])].reverse().map((fact, i) => (
|
|
3051
|
+
<div key={i} style={{ display: 'flex', gap: 8, alignItems: 'flex-start' }}>
|
|
3052
|
+
<span className={css.badge} style={{ borderColor: 'var(--nf-text-3)', color: 'var(--nf-text-3)', flex: 'none', marginTop: 1 }}>
|
|
3053
|
+
第 {fact.chapterNo} 章
|
|
3054
|
+
</span>
|
|
3055
|
+
<span className={css.meta}>{fact.text}</span>
|
|
3056
|
+
</div>
|
|
3057
|
+
))}
|
|
3058
|
+
</div>
|
|
3059
|
+
) : (
|
|
3060
|
+
<span className={css.meta}>暂无事实条目——写一章后会自动生成,或点击上方「回填」。</span>
|
|
3061
|
+
)}
|
|
3062
|
+
</div>
|
|
3063
|
+
)}
|
|
3064
|
+
|
|
3065
|
+
{activeTab === 'plotlines' && (
|
|
3066
|
+
<div className={css.card} style={{ flex: 1, minHeight: 0 }}>
|
|
3067
|
+
<div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}>
|
|
3068
|
+
<span className={css.cardTitle}>{tt('tab.plotlines')}({project?.plotlines?.length ?? 0})</span>
|
|
3069
|
+
{plotlineDraft === null && (
|
|
3070
|
+
<button
|
|
3071
|
+
type="button"
|
|
3072
|
+
className={`${css.button} ${css.buttonSmall} ${css.buttonPrimary}`}
|
|
3073
|
+
onClick={() => { setPlotlineDraft({ id: '', name: '', kind: 'main', goal: '', progress: '', status: 'active' }) }}
|
|
3074
|
+
>
|
|
3075
|
+
{tt('plotlines.new')}
|
|
3076
|
+
</button>
|
|
3077
|
+
)}
|
|
3078
|
+
</div>
|
|
3079
|
+
<span className={css.meta}>{tt('plotlines.hint')}</span>
|
|
3080
|
+
|
|
3081
|
+
{plotlineDraft !== null && (
|
|
3082
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, border: '1px solid var(--nf-accent)', borderRadius: 12, padding: 10 }}>
|
|
3083
|
+
<div className={css.row} style={{ flexWrap: 'wrap' }}>
|
|
3084
|
+
<div className={css.field} style={{ flex: 2, minWidth: 160 }}>
|
|
3085
|
+
<label className={css.fieldLabel}>{tt('plotlines.name')}</label>
|
|
3086
|
+
<input className={css.input} value={plotlineDraft.name} onChange={e => { setPlotlineDraft({ ...plotlineDraft, name: e.target.value }) }} placeholder="如:集齐古玉残片" />
|
|
3087
|
+
</div>
|
|
3088
|
+
<div className={css.field} style={{ flex: 1 }}>
|
|
3089
|
+
<label className={css.fieldLabel}>{tt('plotlines.kind')}</label>
|
|
3090
|
+
<select className={css.input} value={plotlineDraft.kind} onChange={e => { setPlotlineDraft({ ...plotlineDraft, kind: e.target.value as Plotline['kind'] }) }}>
|
|
3091
|
+
<option value="main">{tt('plotlines.kindMain')}</option>
|
|
3092
|
+
<option value="branch">{tt('plotlines.kindBranch')}</option>
|
|
3093
|
+
<option value="character">{tt('plotlines.kindCharacter')}</option>
|
|
3094
|
+
<option value="mystery">{tt('plotlines.kindMystery')}</option>
|
|
3095
|
+
</select>
|
|
3096
|
+
</div>
|
|
3097
|
+
<div className={css.field} style={{ flex: 1 }}>
|
|
3098
|
+
<label className={css.fieldLabel}>{tt('plotlines.status')}</label>
|
|
3099
|
+
<select className={css.input} value={plotlineDraft.status} onChange={e => { setPlotlineDraft({ ...plotlineDraft, status: e.target.value as Plotline['status'] }) }}>
|
|
3100
|
+
<option value="active">{tt('plotlines.statusActive')}</option>
|
|
3101
|
+
<option value="paused">{tt('plotlines.statusPaused')}</option>
|
|
3102
|
+
<option value="resolved">{tt('plotlines.statusResolved')}</option>
|
|
3103
|
+
<option value="abandoned">{tt('plotlines.statusAbandoned')}</option>
|
|
3104
|
+
</select>
|
|
3105
|
+
</div>
|
|
3106
|
+
</div>
|
|
3107
|
+
<div className={css.field}>
|
|
3108
|
+
<label className={css.fieldLabel}>{tt('plotlines.goal')}</label>
|
|
3109
|
+
<textarea className={css.textarea} style={{ minHeight: 48 }} value={plotlineDraft.goal} onChange={e => { setPlotlineDraft({ ...plotlineDraft, goal: e.target.value }) }} />
|
|
3110
|
+
</div>
|
|
3111
|
+
<div className={css.field}>
|
|
3112
|
+
<label className={css.fieldLabel}>{tt('plotlines.progress')}</label>
|
|
3113
|
+
<textarea className={css.textarea} style={{ minHeight: 40 }} value={plotlineDraft.progress} onChange={e => { setPlotlineDraft({ ...plotlineDraft, progress: e.target.value }) }} placeholder="如:已取得第二枚残片,正追踪第三枚线索" />
|
|
3114
|
+
</div>
|
|
3115
|
+
<div className={css.row}>
|
|
3116
|
+
<button type="button" className={`${css.button} ${css.buttonSmall} ${css.buttonPrimary}`} disabled={busy} onClick={() => { void handlePlotlineSave() }}>
|
|
3117
|
+
{tt('plotlines.save')}
|
|
3118
|
+
</button>
|
|
3119
|
+
<button type="button" className={`${css.button} ${css.buttonSmall}`} onClick={() => { setPlotlineDraft(null) }}>
|
|
3120
|
+
{tt('plotlines.cancel')}
|
|
3121
|
+
</button>
|
|
2637
3122
|
</div>
|
|
2638
3123
|
</div>
|
|
2639
3124
|
)}
|
|
3125
|
+
|
|
3126
|
+
{(project?.plotlines ?? []).length === 0 ? (
|
|
3127
|
+
<span className={css.meta}>{tt('plotlines.empty')}</span>
|
|
3128
|
+
) : (
|
|
3129
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, overflowY: 'auto', flex: 1, minHeight: 0 }}>
|
|
3130
|
+
{(project?.plotlines ?? []).map(line => {
|
|
3131
|
+
const kindLabel = { main: tt('plotlines.kindMain'), branch: tt('plotlines.kindBranch'), character: tt('plotlines.kindCharacter'), mystery: tt('plotlines.kindMystery') }[line.kind]
|
|
3132
|
+
const statusLabel = { active: tt('plotlines.statusActive'), paused: tt('plotlines.statusPaused'), resolved: tt('plotlines.statusResolved'), abandoned: tt('plotlines.statusAbandoned') }[line.status]
|
|
3133
|
+
const statusColor = line.status === 'resolved' ? 'var(--nf-success)' : line.status === 'abandoned' ? 'var(--nf-text-3)' : line.status === 'paused' ? 'var(--nf-warn)' : 'var(--nf-accent)'
|
|
3134
|
+
return (
|
|
3135
|
+
<div key={line.id} style={{ border: '1px solid var(--nf-border)', borderRadius: 10, padding: '8px 12px', fontSize: 12, display: 'flex', flexDirection: 'column', gap: 4 }}>
|
|
3136
|
+
<div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}>
|
|
3137
|
+
<span style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
|
|
3138
|
+
<b>{line.name}</b>
|
|
3139
|
+
<span className={css.badge} style={{ borderColor: 'var(--nf-accent)', color: 'var(--nf-accent)' }}>{kindLabel}</span>
|
|
3140
|
+
<span className={css.badge} style={{ borderColor: statusColor, color: statusColor }}>{statusLabel}</span>
|
|
3141
|
+
</span>
|
|
3142
|
+
<span style={{ display: 'flex', gap: 6 }}>
|
|
3143
|
+
<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
|
+
{tt('plotlines.edit')}
|
|
3145
|
+
</button>
|
|
3146
|
+
<button type="button" className={`${css.button} ${css.buttonSmall}`} disabled={busy} onClick={() => { void handlePlotlineRemove(line.id) }}>
|
|
3147
|
+
{tt('plotlines.remove')}
|
|
3148
|
+
</button>
|
|
3149
|
+
</span>
|
|
3150
|
+
</div>
|
|
3151
|
+
{line.goal !== '' && <div className={css.meta}><b>{tt('plotlines.goal')}:</b>{line.goal}</div>}
|
|
3152
|
+
{line.progress !== '' && <div className={css.meta}><b>{tt('plotlines.progress')}:</b>{line.progress}</div>}
|
|
3153
|
+
<div className={css.meta}>
|
|
3154
|
+
{tt('plotlines.chapters')}:{line.chapters.length > 0 ? line.chapters.map(n => `第${n}章`).join('、') : '—'}
|
|
3155
|
+
</div>
|
|
3156
|
+
</div>
|
|
3157
|
+
)
|
|
3158
|
+
})}
|
|
3159
|
+
</div>
|
|
3160
|
+
)}
|
|
2640
3161
|
</div>
|
|
2641
3162
|
)}
|
|
3163
|
+
</>)}
|
|
2642
3164
|
</div>
|
|
2643
3165
|
</div>
|
|
2644
3166
|
</>
|