@waterwx/dsh-novel-forge 1.2.1-alpha → 1.3.0-alpha
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 -5
- package/lib/client.js +2470 -858
- package/lib/index.js +2155 -388
- package/lib/types/action-guard.d.ts +1 -1
- package/lib/types/assets.d.ts +7 -7
- package/lib/types/client/api.d.ts +31 -1
- package/lib/types/client/llmLive.d.ts +12 -14
- package/lib/types/client/panel/LiveFeedLog.d.ts +5 -0
- package/lib/types/client/panel/ModelManager.d.ts +2 -2
- package/lib/types/client/panel/PromptSlotsView.d.ts +11 -0
- package/lib/types/client/panel/TensionView.d.ts +14 -0
- package/lib/types/client/panel/TimelineView.d.ts +14 -0
- package/lib/types/engine.d.ts +40 -2
- package/lib/types/index.d.ts +4 -0
- package/lib/types/llm-live.d.ts +65 -34
- package/lib/types/llm-retry.d.ts +38 -0
- package/lib/types/market-radar-sources.d.ts +1 -1
- package/lib/types/prompt-slots.d.ts +57 -0
- package/lib/types/prompting.d.ts +2 -2
- package/lib/types/protocol.d.ts +283 -3
- package/lib/types/snapshots.d.ts +46 -0
- package/lib/types/stage-contract.d.ts +1 -1
- package/lib/types/tension.d.ts +65 -0
- package/lib/types/timeline.d.ts +37 -0
- package/package.json +3 -2
- package/src/action-guard.ts +1 -1
- package/src/assets.ts +300 -268
- package/src/assistant.ts +45 -29
- package/src/client/api.ts +76 -2
- package/src/client/llmLive.ts +26 -16
- package/src/client/panel/AssetsTab.tsx +2 -2
- package/src/client/panel/LiveFeedLog.tsx +204 -67
- package/src/client/panel/ModelManager.tsx +2 -2
- package/src/client/panel/NovelPanel.tsx +127 -13
- package/src/client/panel/PromptSlotsView.tsx +97 -0
- package/src/client/panel/SettingsView.tsx +35 -0
- package/src/client/panel/TensionView.tsx +196 -0
- package/src/client/panel/TimelineView.tsx +192 -0
- package/src/engine.ts +549 -108
- package/src/index.ts +12 -0
- package/src/llm-live.ts +260 -68
- package/src/llm-retry.ts +77 -0
- package/src/market-radar-sources.ts +173 -173
- package/src/prompt-slots.ts +125 -0
- package/src/prompting.ts +8 -8
- package/src/protocol.ts +314 -3
- package/src/routes.ts +325 -6
- package/src/run.ts +4 -0
- package/src/snapshots.ts +174 -0
- package/src/stage-contract.ts +189 -189
- package/src/tension.ts +240 -0
- package/src/timeline.ts +159 -0
|
@@ -28,8 +28,12 @@ import { ImportModal } from './ImportModal.tsx'
|
|
|
28
28
|
import { WorldTab } from './WorldTab.tsx'
|
|
29
29
|
import DirectorView from './DirectorView.tsx'
|
|
30
30
|
import KnowledgeBaseView from './KnowledgeBaseView.tsx'
|
|
31
|
+
import TimelineView from './TimelineView.tsx'
|
|
32
|
+
import TensionView from './TensionView.tsx'
|
|
33
|
+
import PromptSlotsView from './PromptSlotsView.tsx'
|
|
31
34
|
import { EmptyState, PlotlineCard, PlotlineHealthPanel, PlotlinePlanPanel, PlotlineSuggestionPanel, RoleCandidateRow, RoleCard, SlideNav } from './views.tsx'
|
|
32
35
|
import { extractDocxTextFromBuffer } from '../docx.ts'
|
|
36
|
+
import { EXPORT_SCOPE_LABELS, EXPORT_SCOPE_VALUES } from '../../protocol.ts'
|
|
33
37
|
import type {
|
|
34
38
|
AuditStatus,
|
|
35
39
|
BookSettings,
|
|
@@ -44,6 +48,8 @@ import type {
|
|
|
44
48
|
ProjectState,
|
|
45
49
|
ReviewReport,
|
|
46
50
|
RoleRecord,
|
|
51
|
+
ExportScope,
|
|
52
|
+
ChapterSnapshot,
|
|
47
53
|
SensitiveHit,
|
|
48
54
|
StageInfo,
|
|
49
55
|
StoryBible,
|
|
@@ -97,7 +103,7 @@ const CMD_DRAWER_OF: Partial<Record<NovelTab, 'write' | 'setup' | 'assets' | 'pa
|
|
|
97
103
|
breakdown: 'breakdown',
|
|
98
104
|
}
|
|
99
105
|
|
|
100
|
-
/**
|
|
106
|
+
/** 侧栏导航分组(按创作阶段归类)。 */
|
|
101
107
|
const NAV_GROUPS: ReadonlyArray<{ id: string; label: string; collapsible?: boolean; items: ReadonlyArray<{ id: NovelTab; label: string; icon: ReactElement }> }> = [ {
|
|
102
108
|
id: 'dashboard',
|
|
103
109
|
label: '概览',
|
|
@@ -350,7 +356,7 @@ function DiffList({ original, draft, fontSize }: { original: string; draft: stri
|
|
|
350
356
|
}
|
|
351
357
|
|
|
352
358
|
/** 章节工作台右侧页签:对比(草稿 diff)/ 审稿(AI 审稿报告)/ 复盘(作者复盘)。 */
|
|
353
|
-
type ChapterSideTab = 'diff' | 'review' | 'author'
|
|
359
|
+
type ChapterSideTab = 'diff' | 'review' | 'author' | 'versions'
|
|
354
360
|
|
|
355
361
|
/** 章节工作台(独立全页)需要的全部输入;所有数据来自真实 project.chapters。 */
|
|
356
362
|
interface ChapterWorkbenchProps {
|
|
@@ -380,6 +386,10 @@ interface ChapterWorkbenchProps {
|
|
|
380
386
|
onApplyDraft: () => void
|
|
381
387
|
onDiscardDraft: () => void
|
|
382
388
|
onCheckDraft: () => void
|
|
389
|
+
/** 本章历史版本(快照)与回滚/存档动作。 */
|
|
390
|
+
snapshots: ChapterSnapshot[]
|
|
391
|
+
onRestoreSnapshot: (id: string) => void
|
|
392
|
+
onCreateSnapshot: () => void
|
|
383
393
|
}
|
|
384
394
|
|
|
385
395
|
/**
|
|
@@ -390,6 +400,7 @@ function ChapterWorkbench({
|
|
|
390
400
|
chapter, text, draft, fontSize, busy, busyAny,
|
|
391
401
|
onBack, onWrite, onReview, onApprove, onEdit, onRevisePicked, onReset, onRegenerate, onBackfillAuthor,
|
|
392
402
|
tab, onTabChange, draftReport, onApplyDraft, onDiscardDraft, onCheckDraft,
|
|
403
|
+
snapshots, onRestoreSnapshot, onCreateSnapshot,
|
|
393
404
|
}: ChapterWorkbenchProps): ReactElement {
|
|
394
405
|
const badge = statusBadge(chapter)
|
|
395
406
|
const review: ReviewReport | undefined = chapter.review
|
|
@@ -572,12 +583,39 @@ function ChapterWorkbench({
|
|
|
572
583
|
{ id: 'review', label: '审稿' },
|
|
573
584
|
{ id: 'diff', label: hasDraft ? '对比 · 草稿' : '对比' },
|
|
574
585
|
{ id: 'author', label: '复盘' },
|
|
586
|
+
{ id: 'versions', label: snapshots.length > 0 ? `历史版本 · ${snapshots.length}` : '历史版本' },
|
|
575
587
|
]}
|
|
576
588
|
active={tab}
|
|
577
589
|
onSelect={onTabChange}
|
|
578
590
|
/>
|
|
579
591
|
</div>
|
|
580
592
|
<div className={css.chWorkSideBody}>
|
|
593
|
+
{tab === 'versions' && (
|
|
594
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-8)' }}>
|
|
595
|
+
<div className={css.row} style={{ justifyContent: 'space-between', alignItems: 'center' }}>
|
|
596
|
+
<span className={css.meta}>覆盖写入前会自动留存;回滚也可再撤销。</span>
|
|
597
|
+
<button type="button" className={css.button} onClick={onCreateSnapshot}>存档当前正文</button>
|
|
598
|
+
</div>
|
|
599
|
+
{snapshots.length === 0 && <span className={css.meta}>这一章还没有历史版本。</span>}
|
|
600
|
+
{snapshots.slice().sort((a, b) => b.at.localeCompare(a.at)).map(snapshot => (
|
|
601
|
+
<div key={snapshot.id} style={{ border: '1px solid var(--nf-border)', borderRadius: 8, padding: '6px 8px', fontSize: 'var(--nf-fs-12)' }}>
|
|
602
|
+
<div style={{ display: 'flex', gap: 6, alignItems: 'baseline', flexWrap: 'wrap' }}>
|
|
603
|
+
<b>{new Date(snapshot.at).toLocaleString()}</b>
|
|
604
|
+
<span className={css.meta}>{snapshot.reason}</span>
|
|
605
|
+
<span className={css.meta}>{snapshot.chars} 字</span>
|
|
606
|
+
<button
|
|
607
|
+
type="button"
|
|
608
|
+
className={css.button}
|
|
609
|
+
style={{ marginLeft: 'auto' }}
|
|
610
|
+
disabled={busyAny}
|
|
611
|
+
onClick={() => onRestoreSnapshot(snapshot.id)}
|
|
612
|
+
title="用这一版覆盖当前正文(回滚前会先留存当前正文)"
|
|
613
|
+
>回滚到这一版</button>
|
|
614
|
+
</div>
|
|
615
|
+
</div>
|
|
616
|
+
))}
|
|
617
|
+
</div>
|
|
618
|
+
)}
|
|
581
619
|
{tab === 'diff' && (hasDraft ? (
|
|
582
620
|
<>
|
|
583
621
|
<div className={css.chWorkDraftBar}>
|
|
@@ -737,6 +775,10 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
737
775
|
* 的聊天记录,而新消息已经发到另一本书里。
|
|
738
776
|
*/
|
|
739
777
|
const [bookKey, setBookKey] = useState('')
|
|
778
|
+
/** 导出范围(设置页导出卡片):整本正文 / 设定 / 规划 / 角色 / 质检记录 / 项目备份。 */
|
|
779
|
+
const [exportScope, setExportScope] = useState<ExportScope>('book')
|
|
780
|
+
/** 章节历史版本(快照):打开工作台时拉取,回滚/存档后刷新。 */
|
|
781
|
+
const [snapshots, setSnapshots] = useState<ChapterSnapshot[]>([])
|
|
740
782
|
const [dragActive, setDragActive] = useState(false)
|
|
741
783
|
const fileInputRef = useRef<HTMLInputElement | null>(null)
|
|
742
784
|
const [planCount, setPlanCount] = useState(30)
|
|
@@ -753,7 +795,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
753
795
|
/** 章节工作台(独立全页)当前打开的章号;null = 关闭。 */
|
|
754
796
|
const [workbenchNo, setWorkbenchNo] = useState<number | null>(null)
|
|
755
797
|
/** 工作台右栏页签(提升到面板层:修订完成后需要自动切到「对比」)。 */
|
|
756
|
-
const [wbTab, setWbTab] = useState<'review' | 'diff' | 'author'>('review')
|
|
798
|
+
const [wbTab, setWbTab] = useState<'review' | 'diff' | 'author' | 'versions'>('review')
|
|
757
799
|
/** 工作台「草稿审查」报告(验证模式产物)—— 与 chapter.review(原稿审稿)是两回事,独立存放。 */
|
|
758
800
|
const [wbDraftReport, setWbDraftReport] = useState<ReviewReport | null>(null)
|
|
759
801
|
/** 复盘记录页:当前展开的章节号。 */
|
|
@@ -972,7 +1014,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
972
1014
|
/** AI 助手悬浮窗:是否打开。 */
|
|
973
1015
|
const [assistantOpen, setAssistantOpen] = useState(false)
|
|
974
1016
|
/** 顶部导航右滑抽屉:写作/设定/资产/参数/工具页 = 内容抽屉。null = 常驻总编台。资料侧柜走总编台内嵌格,不再有独立滑出层。 */
|
|
975
|
-
const [rightDrawer, setRightDrawer] = useState<'write' | 'setup' | 'assets' | 'params' | 'overview' | 'blurb' | 'plotlines' | 'director' | 'knowledge' | 'run' | 'breakdown' | null>(null)
|
|
1017
|
+
const [rightDrawer, setRightDrawer] = useState<'write' | 'setup' | 'assets' | 'params' | 'overview' | 'blurb' | 'plotlines' | 'director' | 'knowledge' | 'run' | 'breakdown' | 'timeline' | 'tension' | 'promptSlots' | null>(null)
|
|
976
1018
|
/** 打开参数抽屉或切换激活书时,重新加载本书参数。 */
|
|
977
1019
|
useEffect(() => {
|
|
978
1020
|
if (rightDrawer !== 'params') return
|
|
@@ -1052,7 +1094,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
1052
1094
|
const [drawerMax, setDrawerMax] = useState(false)
|
|
1053
1095
|
|
|
1054
1096
|
/** 打开内容抽屉:只滑出抽屉层,不切 activeTab —— 底座总编台保持常驻可见(对齐 demo)。 */
|
|
1055
|
-
const openDrawer = (which: 'write' | 'setup' | 'assets' | 'params' | 'overview' | 'blurb' | 'plotlines' | 'director' | 'knowledge' | 'run' | 'breakdown'): void => {
|
|
1097
|
+
const openDrawer = (which: 'write' | 'setup' | 'assets' | 'params' | 'overview' | 'blurb' | 'plotlines' | 'director' | 'knowledge' | 'run' | 'breakdown' | 'timeline' | 'tension' | 'promptSlots'): void => {
|
|
1056
1098
|
setRightDrawer(which)
|
|
1057
1099
|
setDrawerMax(false)
|
|
1058
1100
|
}
|
|
@@ -1310,6 +1352,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
1310
1352
|
setCharCards(null)
|
|
1311
1353
|
}
|
|
1312
1354
|
setGeneratedFiles(status.generatedFiles)
|
|
1355
|
+
try { setSnapshots((await api.snapshots()).snapshots) } catch { /* 快照是附加能力,失败不影响主流程 */ }
|
|
1313
1356
|
const withDraft = status.project?.chapters.find(c => c.pendingDraft !== undefined && c.pendingDraft !== '')
|
|
1314
1357
|
setDraftNo(withDraft?.no ?? null)
|
|
1315
1358
|
const nextOutline = status.project?.outline
|
|
@@ -2626,11 +2669,46 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
2626
2669
|
}
|
|
2627
2670
|
|
|
2628
2671
|
/** Export the book. */
|
|
2629
|
-
|
|
2672
|
+
/** 刷新章节历史版本列表。 */
|
|
2673
|
+
const refreshSnapshots = async (): Promise<void> => {
|
|
2674
|
+
try { setSnapshots((await api.snapshots()).snapshots) } catch { /* ignore */ }
|
|
2675
|
+
}
|
|
2676
|
+
|
|
2677
|
+
/** 回滚到某个历史版本(回滚前宿主会先留存当前正文,所以可撤销)。 */
|
|
2678
|
+
const handleRestoreSnapshot = async (id: string): Promise<void> => {
|
|
2679
|
+
if (!window.confirm('用这一版覆盖当前正文?回滚前会自动留存当前正文,可以再换回来。')) return
|
|
2680
|
+
setBusy(true); setError('')
|
|
2681
|
+
try {
|
|
2682
|
+
await api.snapshotRestore(id)
|
|
2683
|
+
await refresh(false, true)
|
|
2684
|
+
await refreshSnapshots()
|
|
2685
|
+
setNotice('已回滚到所选版本')
|
|
2686
|
+
} catch (err) {
|
|
2687
|
+
setError((err as Error).message)
|
|
2688
|
+
} finally {
|
|
2689
|
+
setBusy(false)
|
|
2690
|
+
}
|
|
2691
|
+
}
|
|
2692
|
+
|
|
2693
|
+
/** 手工给当前章节存档一个历史版本。 */
|
|
2694
|
+
const handleCreateSnapshot = async (no: number): Promise<void> => {
|
|
2695
|
+
setBusy(true); setError('')
|
|
2696
|
+
try {
|
|
2697
|
+
await api.snapshotCreate(no)
|
|
2698
|
+
await refreshSnapshots()
|
|
2699
|
+
setNotice('已存档当前正文')
|
|
2700
|
+
} catch (err) {
|
|
2701
|
+
setError((err as Error).message)
|
|
2702
|
+
} finally {
|
|
2703
|
+
setBusy(false)
|
|
2704
|
+
}
|
|
2705
|
+
}
|
|
2706
|
+
|
|
2707
|
+
const handleExport = async (format: 'txt' | 'md' | 'json', scope: ExportScope = 'book'): Promise<void> => {
|
|
2630
2708
|
setBusy(true)
|
|
2631
2709
|
setError('')
|
|
2632
2710
|
try {
|
|
2633
|
-
const result = await api.exportBook(format)
|
|
2711
|
+
const result = await api.exportBook(format, scope)
|
|
2634
2712
|
setNotice(tt('settings.exported', { file: result.file, chars: result.chars, chapters: result.chapters }))
|
|
2635
2713
|
pushProgress(tt('settings.exported', { file: result.file, chars: result.chars, chapters: result.chapters }), 'done')
|
|
2636
2714
|
} catch (err) {
|
|
@@ -2840,7 +2918,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
2840
2918
|
/** 侧栏当前书卡(demo 风格):书架激活书 → 封面首字 / 书名 / 进度。 */
|
|
2841
2919
|
const activeBook = shelf?.books.find(b => b.id === shelf?.activeBookId) ?? null
|
|
2842
2920
|
|
|
2843
|
-
/**
|
|
2921
|
+
/** 主行动卡片:同一刻只给作者一个「推荐下一步」。 */
|
|
2844
2922
|
const nextAction = useMemo((): { eyebrow: string; title: string; reason: string; actionLabel: string; onClick: () => void } | null => {
|
|
2845
2923
|
if (project === null) {
|
|
2846
2924
|
return {
|
|
@@ -2949,6 +3027,9 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
2949
3027
|
{ key: 'blurb', group: 'book', name: '简介 / 封面', di: project !== null && project.blurb !== '' ? '● 已填写' : '○ 未填写', dim: project === null, go: () => { openDrawer('blurb') } },
|
|
2950
3028
|
// —— 工具与设置 ——
|
|
2951
3029
|
{ key: 'run', group: 'tools', name: '生产单', di: '批次生产 · 流水线', go: () => { openDrawer('run') } },
|
|
3030
|
+
{ key: 'tension', group: 'tools', name: '张力曲线', di: chapters.length > 0 ? `● ${chapters.length} 章曲线` : '○ 需先排章节', dim: project === null, go: () => { openDrawer('tension') } },
|
|
3031
|
+
{ key: 'promptSlots', group: 'tools', name: '提示词槽位', di: Object.keys(project?.promptSlots ?? {}).length > 0 ? `● 已设 ${Object.keys(project?.promptSlots ?? {}).length} 项` : '○ 未设置', dim: project === null, go: () => { openDrawer('promptSlots') } },
|
|
3032
|
+
{ key: 'timeline', group: 'tools', name: '故事时间线', di: (project?.timeline ?? []).length > 0 ? `● ${(project?.timeline ?? []).length} 条事件` : '○ 未抽取', dim: project === null, go: () => { openDrawer('timeline') } },
|
|
2952
3033
|
{ key: 'knowledge', group: 'tools', name: '知识库', di: '跨书沉淀', go: () => { openDrawer('knowledge') } },
|
|
2953
3034
|
{ key: 'breakdown', group: 'tools', name: '拆书分析', di: '对标拆解', go: () => { openDrawer('breakdown') } },
|
|
2954
3035
|
]
|
|
@@ -3142,6 +3223,9 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
3142
3223
|
onApplyDraft={() => { void handleWbApplyDraft(workbench.no) }}
|
|
3143
3224
|
onDiscardDraft={() => { void handleWbDiscardDraft(workbench.no) }}
|
|
3144
3225
|
onCheckDraft={() => { void handleWbCheckDraft(workbench.no) }}
|
|
3226
|
+
snapshots={snapshots.filter(s => s.chapterNo === workbench.no)}
|
|
3227
|
+
onRestoreSnapshot={id => { void handleRestoreSnapshot(id) }}
|
|
3228
|
+
onCreateSnapshot={() => { void handleCreateSnapshot(workbench.no) }}
|
|
3145
3229
|
/>
|
|
3146
3230
|
)}
|
|
3147
3231
|
{/* 右滑抽屉遮罩:覆盖总编台底座,点按关闭并回落 */}
|
|
@@ -3699,14 +3783,41 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
3699
3783
|
onChange={patch => { setBookCfg({ ...bookCfg, ...patch }) }}
|
|
3700
3784
|
/>
|
|
3701
3785
|
|
|
3702
|
-
{/*
|
|
3786
|
+
{/* 导出(本书):按范围导出——整本正文 / 设定 / 规划 / 角色 / 质检记录 / 项目备份 JSON */}
|
|
3703
3787
|
<div className={`${css.card} ${css.settingsCard}`} style={{ gap: 'var(--nf-space-12)' }}>
|
|
3704
3788
|
<span className={css.cardTitle}>导出(本书)</span>
|
|
3705
|
-
<div className={css.row}>
|
|
3706
|
-
<
|
|
3707
|
-
|
|
3789
|
+
<div className={css.row} style={{ flexWrap: 'wrap', gap: 'var(--nf-space-8)' }}>
|
|
3790
|
+
<select
|
|
3791
|
+
className={css.input}
|
|
3792
|
+
value={exportScope}
|
|
3793
|
+
onChange={e => setExportScope(e.target.value as ExportScope)}
|
|
3794
|
+
title="选择导出范围"
|
|
3795
|
+
>
|
|
3796
|
+
{EXPORT_SCOPE_VALUES.map(scope => (
|
|
3797
|
+
<option key={scope} value={scope}>{EXPORT_SCOPE_LABELS[scope]}</option>
|
|
3798
|
+
))}
|
|
3799
|
+
</select>
|
|
3800
|
+
{exportScope === 'project' ? (
|
|
3801
|
+
<button type="button" className={css.button} disabled={busy} onClick={() => { void handleExport('json', 'project') }}>导出项目备份 JSON</button>
|
|
3802
|
+
) : exportScope === 'book' ? (
|
|
3803
|
+
<>
|
|
3804
|
+
<button type="button" className={css.button} disabled={busy || chapters.length === 0} onClick={() => { void handleExport('txt', 'book') }}>{tt('settings.exportTxt')}</button>
|
|
3805
|
+
<button type="button" className={css.button} disabled={busy || chapters.length === 0} onClick={() => { void handleExport('md', 'book') }}>{tt('settings.exportMd')}</button>
|
|
3806
|
+
</>
|
|
3807
|
+
) : (
|
|
3808
|
+
<>
|
|
3809
|
+
<button type="button" className={css.button} disabled={busy} onClick={() => { void handleExport('txt', exportScope) }}>导出 TXT</button>
|
|
3810
|
+
<button type="button" className={css.button} disabled={busy} onClick={() => { void handleExport('md', exportScope) }}>导出 Markdown</button>
|
|
3811
|
+
</>
|
|
3812
|
+
)}
|
|
3708
3813
|
</div>
|
|
3709
|
-
<span className={css.meta}
|
|
3814
|
+
<span className={css.meta}>
|
|
3815
|
+
{exportScope === 'project'
|
|
3816
|
+
? '导出完整项目 JSON(含设定/规划/角色/事实库),用于备份或迁移到其它机器。'
|
|
3817
|
+
: exportScope === 'book'
|
|
3818
|
+
? '导出当前书的全部已写章节。'
|
|
3819
|
+
: `导出「${EXPORT_SCOPE_LABELS[exportScope]}」,不含正文,便于单独存档或交给他人。`}
|
|
3820
|
+
</span>
|
|
3710
3821
|
</div>
|
|
3711
3822
|
</>
|
|
3712
3823
|
)}
|
|
@@ -4133,6 +4244,9 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
4133
4244
|
)}
|
|
4134
4245
|
{rightDrawer === 'director' && <DirectorView api={api} todos={project?.todos ?? []} onTodosChange={(todos) => setProject(prev => prev === null ? prev : { ...prev, todos, updatedAt: new Date().toISOString() })} />}
|
|
4135
4246
|
{rightDrawer === 'knowledge' && <KnowledgeBaseView api={api} />}
|
|
4247
|
+
{rightDrawer === 'timeline' && <TimelineView api={api} chapters={chapters.map(c => c.no)} />}
|
|
4248
|
+
{rightDrawer === 'tension' && <TensionView api={api} />}
|
|
4249
|
+
{rightDrawer === 'promptSlots' && <PromptSlotsView api={api} />}
|
|
4136
4250
|
{rightDrawer === 'run' && (
|
|
4137
4251
|
<RunPanel api={api} totalChapters={chapters.length} />
|
|
4138
4252
|
)}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 提示词槽位:给作者留几个**安全可改**的位置来微调本书表达偏好。
|
|
3
|
+
*
|
|
4
|
+
* 边界写在界面上:槽位只**追加偏好**,不能覆盖道藏、写作红线、九条内容合规红线、
|
|
5
|
+
* 反 AI 规则与阶段契约 —— 这些由宿主锁定。清空即恢复默认。
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { useCallback, useEffect, useState } from 'react'
|
|
9
|
+
import type { NovelApi } from '../api.ts'
|
|
10
|
+
import type { PromptSlotId, PromptSlotState } from '../../protocol.ts'
|
|
11
|
+
import css from './panel.module.css'
|
|
12
|
+
|
|
13
|
+
export interface PromptSlotsViewProps {
|
|
14
|
+
api: NovelApi
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export default function PromptSlotsView({ api }: PromptSlotsViewProps): JSX.Element {
|
|
18
|
+
const [slots, setSlots] = useState<PromptSlotState[]>([])
|
|
19
|
+
const [drafts, setDrafts] = useState<Record<string, string>>({})
|
|
20
|
+
const [busy, setBusy] = useState(false)
|
|
21
|
+
const [error, setError] = useState('')
|
|
22
|
+
const [notice, setNotice] = useState('')
|
|
23
|
+
|
|
24
|
+
const refresh = useCallback(async (showError = true): Promise<void> => {
|
|
25
|
+
try {
|
|
26
|
+
const r = await api.promptSlots()
|
|
27
|
+
setSlots(r.slots)
|
|
28
|
+
setDrafts(Object.fromEntries(r.slots.map(s => [s.id, s.value])))
|
|
29
|
+
} catch (err) {
|
|
30
|
+
if (showError) setError((err as Error).message)
|
|
31
|
+
}
|
|
32
|
+
}, [api])
|
|
33
|
+
|
|
34
|
+
useEffect(() => { void refresh(false) }, [refresh])
|
|
35
|
+
|
|
36
|
+
const save = useCallback(async (id: PromptSlotId): Promise<void> => {
|
|
37
|
+
setBusy(true); setError(''); setNotice('')
|
|
38
|
+
try {
|
|
39
|
+
await api.promptSlotSet(id, drafts[id] ?? '')
|
|
40
|
+
await refresh()
|
|
41
|
+
setNotice((drafts[id] ?? '').trim() === '' ? '已清空该槽位(恢复默认)' : '已保存,下一次生成/审稿生效')
|
|
42
|
+
} catch (err) {
|
|
43
|
+
setError((err as Error).message)
|
|
44
|
+
} finally {
|
|
45
|
+
setBusy(false)
|
|
46
|
+
}
|
|
47
|
+
}, [api, drafts, refresh])
|
|
48
|
+
|
|
49
|
+
const dirty = (slot: PromptSlotState): boolean => (drafts[slot.id] ?? '') !== slot.value
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<div className={css.card} style={{ gap: 'var(--nf-space-12)' }}>
|
|
53
|
+
<div className={css.row} style={{ justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 'var(--nf-space-8)' }}>
|
|
54
|
+
<span className={css.cardTitle}>提示词槽位(本书)</span>
|
|
55
|
+
<span className={css.meta}>只追加偏好,不覆盖道藏 / 红线 / 合规</span>
|
|
56
|
+
</div>
|
|
57
|
+
<span className={css.meta}>
|
|
58
|
+
这里的文字会追加到写作提示词末尾,用来微调表达方式。**规则类内容不可改**:
|
|
59
|
+
道藏、写作红线、内容合规红线、反 AI 规则、阶段契约由宿主锁定,槽位碰不到。
|
|
60
|
+
</span>
|
|
61
|
+
|
|
62
|
+
{notice !== '' && <span style={{ color: 'var(--nf-success)', fontSize: 'var(--nf-text-12)' }}>{notice}</span>}
|
|
63
|
+
{error !== '' && <span style={{ color: 'var(--nf-error)', fontSize: 'var(--nf-text-12)' }}>{error}</span>}
|
|
64
|
+
|
|
65
|
+
{slots.map(slot => {
|
|
66
|
+
const value = drafts[slot.id] ?? ''
|
|
67
|
+
const over = value.length > slot.maxChars
|
|
68
|
+
return (
|
|
69
|
+
<div key={slot.id} style={{ border: '1px solid var(--nf-border)', borderRadius: 8, padding: 8, display: 'flex', flexDirection: 'column', gap: 6 }}>
|
|
70
|
+
<div className={css.row} style={{ justifyContent: 'space-between', alignItems: 'baseline', gap: 'var(--nf-space-8)' }}>
|
|
71
|
+
<b style={{ fontSize: 'var(--nf-fs-12)' }}>{slot.label}</b>
|
|
72
|
+
<span className={css.meta} style={{ color: over ? 'var(--nf-error)' : undefined }}>
|
|
73
|
+
{value.length} / {slot.maxChars}
|
|
74
|
+
</span>
|
|
75
|
+
</div>
|
|
76
|
+
<span className={css.meta}>{slot.hint}</span>
|
|
77
|
+
<textarea
|
|
78
|
+
className={css.textarea}
|
|
79
|
+
rows={3}
|
|
80
|
+
value={value}
|
|
81
|
+
placeholder={slot.placeholder}
|
|
82
|
+
onChange={e => setDrafts(prev => ({ ...prev, [slot.id]: e.target.value }))}
|
|
83
|
+
/>
|
|
84
|
+
<div className={css.row} style={{ justifyContent: 'flex-end', gap: 6 }}>
|
|
85
|
+
<button type="button" className={css.button} disabled={busy || value === ''} onClick={() => { void save(slot.id) }}>
|
|
86
|
+
清空
|
|
87
|
+
</button>
|
|
88
|
+
<button type="button" className={css.button + ' ' + css.buttonPrimary} disabled={busy || !dirty(slot)} onClick={() => { void save(slot.id) }}>
|
|
89
|
+
保存
|
|
90
|
+
</button>
|
|
91
|
+
</div>
|
|
92
|
+
</div>
|
|
93
|
+
)
|
|
94
|
+
})}
|
|
95
|
+
</div>
|
|
96
|
+
)
|
|
97
|
+
}
|
|
@@ -106,6 +106,8 @@ export function SettingsView({ api, variant = 'page', onSettingsTab, onEditorFon
|
|
|
106
106
|
generateModel: configDraft.generateModel,
|
|
107
107
|
reviewModel: configDraft.reviewModel,
|
|
108
108
|
auditModel: configDraft.auditModel,
|
|
109
|
+
fallbackModel: configDraft.fallbackModel ?? '',
|
|
110
|
+
autoTimeline: configDraft.autoTimeline ?? true,
|
|
109
111
|
reasoningEffort: configDraft.reasoningEffort ?? 'off',
|
|
110
112
|
chapterChars: configDraft.chapterChars,
|
|
111
113
|
maxTokens: configDraft.maxTokens,
|
|
@@ -199,6 +201,39 @@ export function SettingsView({ api, variant = 'page', onSettingsTab, onEditorFon
|
|
|
199
201
|
onSavedModels={models => setConfigDraft({ ...configDraft, savedModels: models })}
|
|
200
202
|
/>
|
|
201
203
|
</div>
|
|
204
|
+
<div className={css.card + ' ' + css.settingsCard} style={{ gap: 'var(--nf-space-12)' }}>
|
|
205
|
+
<span className={css.cardTitle}>备用模型(故障自动切换)</span>
|
|
206
|
+
<div className={css.field}>
|
|
207
|
+
<label className={css.fieldLabel}>主模型调用失败(限流 / 超时 / 额度 / 服务不可用)时,自动换它重试一次</label>
|
|
208
|
+
<select
|
|
209
|
+
className={css.input}
|
|
210
|
+
value={configDraft.fallbackModel ?? ''}
|
|
211
|
+
onChange={e => setConfigDraft({ ...configDraft, fallbackModel: e.target.value })}
|
|
212
|
+
>
|
|
213
|
+
<option value="">不切换(关闭)</option>
|
|
214
|
+
{[...new Set([configDraft.model, ...(configDraft.savedModels ?? []).map(m => m.model)])]
|
|
215
|
+
.filter(m => m !== '')
|
|
216
|
+
.map(m => <option key={m} value={m}>{m}</option>)}
|
|
217
|
+
</select>
|
|
218
|
+
</div>
|
|
219
|
+
<span className={css.meta}>
|
|
220
|
+
只对可重试的失败生效:用户取消、参数错误不会切换;正文生成只在「一个字都没产出」时才换模型重试,避免重复生成。
|
|
221
|
+
</span>
|
|
222
|
+
</div>
|
|
223
|
+
<div className={css.card + ' ' + css.settingsCard} style={{ gap: 'var(--nf-space-12)' }}>
|
|
224
|
+
<span className={css.cardTitle}>故事时间线</span>
|
|
225
|
+
<label className={css.row} style={{ gap: 'var(--nf-space-8)', alignItems: 'center' }}>
|
|
226
|
+
<input
|
|
227
|
+
type="checkbox"
|
|
228
|
+
checked={configDraft.autoTimeline ?? true}
|
|
229
|
+
onChange={e => setConfigDraft({ ...configDraft, autoTimeline: e.target.checked })}
|
|
230
|
+
/>
|
|
231
|
+
<span>出章后自动抽取时间线事件(时间 / 地点 / 在场角色 / 事件)</span>
|
|
232
|
+
</label>
|
|
233
|
+
<span className={css.meta}>
|
|
234
|
+
抽取失败不会阻断出章;可在侧柜「故事时间线」里手工抽取、修正,并一键检查时间倒流 / 地点瞬移等矛盾。
|
|
235
|
+
</span>
|
|
236
|
+
</div>
|
|
202
237
|
<ReasoningSection
|
|
203
238
|
reasoningEffort={configDraft.reasoningEffort ?? 'off'}
|
|
204
239
|
analysisReasoning={configDraft.analysisReasoning ?? 'low'}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 张力曲线:把「每章该有多紧」画出来并可逐章调整。
|
|
3
|
+
*
|
|
4
|
+
* 图上是三条线:
|
|
5
|
+
* 参考(虚线,形状预设) / 目标(实线,作者或规划设定) / 实际(散点,审稿打分)。
|
|
6
|
+
* 下方列出规则核对发现的曲线级问题(连续同值、高位不回落、长期低位、实际偏离目标)。
|
|
7
|
+
*
|
|
8
|
+
* 张力只影响写作提示词里的"松紧"指引,不改变任何校验规则。
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { useCallback, useEffect, useMemo, useState } from 'react'
|
|
12
|
+
import type { NovelApi } from '../api.ts'
|
|
13
|
+
import type { TensionCurvePoint, TensionCurvePreset, TensionIssue, TensionResponse } from '../../protocol.ts'
|
|
14
|
+
import { TENSION_PRESET_LABELS } from '../../tension.ts'
|
|
15
|
+
import css from './panel.module.css'
|
|
16
|
+
|
|
17
|
+
const PRESETS: TensionCurvePreset[] = ['escalation', 'suspense', 'wave', 'frontLoad', 'flat', 'custom']
|
|
18
|
+
|
|
19
|
+
/** 简易折线图(SVG,无依赖):宽高固定,按章节数均分横轴。 */
|
|
20
|
+
function CurveChart({ points, onPick }: { points: TensionCurvePoint[]; onPick: (no: number) => void }): JSX.Element {
|
|
21
|
+
const W = 720
|
|
22
|
+
const H = 200
|
|
23
|
+
const padX = 28
|
|
24
|
+
const padY = 16
|
|
25
|
+
const n = points.length
|
|
26
|
+
const x = (i: number): number => (n <= 1 ? W / 2 : padX + (i / (n - 1)) * (W - padX * 2))
|
|
27
|
+
const y = (v: number): number => padY + (1 - v / 100) * (H - padY * 2)
|
|
28
|
+
const line = (pick: (p: TensionCurvePoint) => number | undefined): string => points
|
|
29
|
+
.map((p, i) => {
|
|
30
|
+
const v = pick(p)
|
|
31
|
+
return v === undefined ? null : `${x(i).toFixed(1)},${y(v).toFixed(1)}`
|
|
32
|
+
})
|
|
33
|
+
.filter((s): s is string => s !== null)
|
|
34
|
+
.join(' ')
|
|
35
|
+
const refLine = line(p => p.reference)
|
|
36
|
+
const targetLine = line(p => p.target)
|
|
37
|
+
return (
|
|
38
|
+
<svg viewBox={`0 0 ${W} ${H}`} width="100%" height={H} role="img" aria-label="张力曲线">
|
|
39
|
+
{/* 网格:0 / 40 / 80 三条参考线 */}
|
|
40
|
+
{[0, 40, 80, 100].map(v => (
|
|
41
|
+
<g key={v}>
|
|
42
|
+
<line x1={padX} x2={W - padX} y1={y(v)} y2={y(v)} stroke="var(--nf-border)" strokeWidth={v === 0 || v === 100 ? 1 : 0.5} strokeDasharray={v === 0 || v === 100 ? '' : '3 4'} />
|
|
43
|
+
<text x={4} y={y(v) + 3} fontSize={9} fill="var(--nf-text-2)">{v}</text>
|
|
44
|
+
</g>
|
|
45
|
+
))}
|
|
46
|
+
{refLine !== '' && <polyline points={refLine} fill="none" stroke="var(--nf-text-2)" strokeWidth={1} strokeDasharray="4 4" opacity={0.7} />}
|
|
47
|
+
{targetLine !== '' && <polyline points={targetLine} fill="none" stroke="var(--nf-accent)" strokeWidth={2} />}
|
|
48
|
+
{points.map((p, i) => (
|
|
49
|
+
<g key={p.no}>
|
|
50
|
+
{p.actual !== undefined && <circle cx={x(i)} cy={y(p.actual)} r={3} fill="var(--nf-success)" />}
|
|
51
|
+
{p.target !== undefined && <circle cx={x(i)} cy={y(p.target)} r={2.5} fill="var(--nf-accent)" />}
|
|
52
|
+
<rect
|
|
53
|
+
x={x(i) - 6}
|
|
54
|
+
y={padY}
|
|
55
|
+
width={12}
|
|
56
|
+
height={H - padY * 2}
|
|
57
|
+
fill="transparent"
|
|
58
|
+
style={{ cursor: 'pointer' }}
|
|
59
|
+
onClick={() => onPick(p.no)}
|
|
60
|
+
>
|
|
61
|
+
<title>{`第${p.no}章 ${p.title}|目标 ${p.target ?? '—'}|实际 ${p.actual ?? '—'}|参考 ${p.reference ?? '—'}`}</title>
|
|
62
|
+
</rect>
|
|
63
|
+
</g>
|
|
64
|
+
))}
|
|
65
|
+
</svg>
|
|
66
|
+
)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface TensionViewProps {
|
|
70
|
+
api: NovelApi
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export default function TensionView({ api }: TensionViewProps): JSX.Element {
|
|
74
|
+
const [data, setData] = useState<TensionResponse | null>(null)
|
|
75
|
+
const [busy, setBusy] = useState(false)
|
|
76
|
+
const [error, setError] = useState('')
|
|
77
|
+
const [notice, setNotice] = useState('')
|
|
78
|
+
const [editing, setEditing] = useState<number | null>(null)
|
|
79
|
+
const [draft, setDraft] = useState(50)
|
|
80
|
+
|
|
81
|
+
const refresh = useCallback(async (showError = true): Promise<void> => {
|
|
82
|
+
try {
|
|
83
|
+
setData(await api.tension())
|
|
84
|
+
} catch (err) {
|
|
85
|
+
if (showError) setError((err as Error).message)
|
|
86
|
+
}
|
|
87
|
+
}, [api])
|
|
88
|
+
|
|
89
|
+
useEffect(() => { void refresh(false) }, [refresh])
|
|
90
|
+
|
|
91
|
+
const run = useCallback(async (label: string, fn: () => Promise<void>): Promise<void> => {
|
|
92
|
+
setBusy(true); setError(''); setNotice('')
|
|
93
|
+
try {
|
|
94
|
+
await fn()
|
|
95
|
+
setNotice(label)
|
|
96
|
+
} catch (err) {
|
|
97
|
+
setError((err as Error).message)
|
|
98
|
+
} finally {
|
|
99
|
+
setBusy(false)
|
|
100
|
+
}
|
|
101
|
+
}, [])
|
|
102
|
+
|
|
103
|
+
const changePreset = (preset: TensionCurvePreset): void => {
|
|
104
|
+
void run(`已切换参考曲线:${TENSION_PRESET_LABELS[preset]}`, async () => {
|
|
105
|
+
await api.tensionPreset(preset)
|
|
106
|
+
await refresh()
|
|
107
|
+
})
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const saveEdit = (): void => {
|
|
111
|
+
if (editing === null) return
|
|
112
|
+
void run(`第 ${editing} 章目标张力已设为 ${draft}`, async () => {
|
|
113
|
+
await api.tensionSet(editing, draft)
|
|
114
|
+
setEditing(null)
|
|
115
|
+
await refresh()
|
|
116
|
+
})
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const clearEdit = (): void => {
|
|
120
|
+
if (editing === null) return
|
|
121
|
+
void run(`已清除第 ${editing} 章的目标张力`, async () => {
|
|
122
|
+
await api.tensionSet(editing)
|
|
123
|
+
setEditing(null)
|
|
124
|
+
await refresh()
|
|
125
|
+
})
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const issues: TensionIssue[] = data?.issues ?? []
|
|
129
|
+
const points = data?.points ?? []
|
|
130
|
+
const stat = useMemo(() => {
|
|
131
|
+
const targets = points.map(p => p.target).filter((v): v is number => v !== undefined)
|
|
132
|
+
const actuals = points.map(p => p.actual).filter((v): v is number => v !== undefined)
|
|
133
|
+
const avg = (xs: number[]): string => (xs.length === 0 ? '—' : String(Math.round(xs.reduce((a, b) => a + b, 0) / xs.length)))
|
|
134
|
+
return { targetAvg: avg(targets), actualAvg: avg(actuals), rated: actuals.length, total: points.length }
|
|
135
|
+
}, [points])
|
|
136
|
+
|
|
137
|
+
return (
|
|
138
|
+
<div className={css.card} style={{ gap: 'var(--nf-space-12)' }}>
|
|
139
|
+
<div className={css.row} style={{ justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 'var(--nf-space-8)' }}>
|
|
140
|
+
<span className={css.cardTitle}>张力曲线</span>
|
|
141
|
+
<span className={css.meta}>{points.length} 章 · 已评 {stat.rated} 章 · 目标均 {stat.targetAvg} / 实际均 {stat.actualAvg}</span>
|
|
142
|
+
</div>
|
|
143
|
+
|
|
144
|
+
<div className={css.row} style={{ gap: 'var(--nf-space-8)', alignItems: 'center', flexWrap: 'wrap' }}>
|
|
145
|
+
<select className={css.input} value={data?.preset ?? 'escalation'} disabled={busy} onChange={e => changePreset(e.target.value as TensionCurvePreset)}>
|
|
146
|
+
{PRESETS.map(p => <option key={p} value={p}>{TENSION_PRESET_LABELS[p]}</option>)}
|
|
147
|
+
</select>
|
|
148
|
+
<span className={css.meta}>虚线=参考曲线形状;实线=目标;绿点=审稿实际。点图中任意一章可改目标张力。</span>
|
|
149
|
+
</div>
|
|
150
|
+
|
|
151
|
+
{notice !== '' && <span style={{ color: 'var(--nf-success)', fontSize: 'var(--nf-text-12)' }}>{notice}</span>}
|
|
152
|
+
{error !== '' && <span style={{ color: 'var(--nf-error)', fontSize: 'var(--nf-text-12)' }}>{error}</span>}
|
|
153
|
+
|
|
154
|
+
{points.length === 0
|
|
155
|
+
? <span className={css.meta}>还没有章节计划。先在「总编台 → 卷/章节计划」排出章节,这里才有曲线可看。</span>
|
|
156
|
+
: <CurveChart points={points} onPick={no => {
|
|
157
|
+
const p = points.find(x => x.no === no)
|
|
158
|
+
setEditing(no)
|
|
159
|
+
setDraft(p?.target ?? p?.actual ?? 50)
|
|
160
|
+
}} />}
|
|
161
|
+
|
|
162
|
+
{issues.length > 0 && (
|
|
163
|
+
<div style={{ border: '1px solid var(--nf-border)', borderRadius: 8, padding: 8, background: 'var(--nf-bg-inset)' }}>
|
|
164
|
+
<b style={{ fontSize: 'var(--nf-fs-12)' }}>曲线级问题 {issues.length} 条</b>
|
|
165
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, marginTop: 6 }}>
|
|
166
|
+
{issues.map((issue, i) => (
|
|
167
|
+
<div key={i} style={{ fontSize: 'var(--nf-fs-12)' }}>
|
|
168
|
+
<span className={css.lfPill} data-phase={issue.severity === 'high' ? 'failed' : 'requesting'}>{issue.severity === 'high' ? '高' : issue.severity === 'medium' ? '中' : '低'}</span>
|
|
169
|
+
<span style={{ marginLeft: 6 }}>{issue.chapters.length > 0 ? `第 ${issue.chapters.join('、')} 章:` : ''}{issue.item}</span>
|
|
170
|
+
{issue.suggestion !== '' && <div className={css.meta} style={{ marginLeft: 6 }}>建议:{issue.suggestion}</div>}
|
|
171
|
+
</div>
|
|
172
|
+
))}
|
|
173
|
+
</div>
|
|
174
|
+
</div>
|
|
175
|
+
)}
|
|
176
|
+
|
|
177
|
+
{editing !== null && (
|
|
178
|
+
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,.45)', zIndex: 60, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 20 }} onClick={() => setEditing(null)}>
|
|
179
|
+
<div onClick={e => e.stopPropagation()} style={{ background: 'var(--nf-bg)', border: '1px solid var(--nf-border)', borderRadius: 10, width: 'min(420px, 92vw)', padding: 12, display: 'flex', flexDirection: 'column', gap: 8 }}>
|
|
180
|
+
<b style={{ fontSize: 'var(--nf-fs-13)' }}>第 {editing} 章 · 目标张力</b>
|
|
181
|
+
<input type="range" min={0} max={100} value={draft} onChange={e => setDraft(Number(e.target.value))} />
|
|
182
|
+
<div className={css.row} style={{ justifyContent: 'space-between' }}>
|
|
183
|
+
<span className={css.meta}>{draft} / 100({draft < 40 ? '偏松:铺垫、日常、消化信息' : draft > 80 ? '偏紧:对抗、揭示、抉择、代价' : '中等:推进为主' })</span>
|
|
184
|
+
<input className={css.input} style={{ width: 72 }} type="number" min={0} max={100} value={draft} onChange={e => setDraft(Math.max(0, Math.min(100, Number(e.target.value))))} />
|
|
185
|
+
</div>
|
|
186
|
+
<div className={css.row} style={{ justifyContent: 'flex-end', gap: 6 }}>
|
|
187
|
+
<button type="button" className={css.button} onClick={clearEdit} disabled={busy}>清除</button>
|
|
188
|
+
<button type="button" className={css.button} onClick={() => setEditing(null)}>取消</button>
|
|
189
|
+
<button type="button" className={css.button + ' ' + css.buttonPrimary} onClick={saveEdit} disabled={busy}>保存</button>
|
|
190
|
+
</div>
|
|
191
|
+
</div>
|
|
192
|
+
</div>
|
|
193
|
+
)}
|
|
194
|
+
</div>
|
|
195
|
+
)
|
|
196
|
+
}
|