@waterwx/dsh-novel-forge 1.3.1 → 1.3.2-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.
Files changed (161) hide show
  1. package/README.md +288 -279
  2. package/lib/client.js +56309 -12221
  3. package/lib/index.js +14239 -5595
  4. package/lib/types/action-guard.d.ts +61 -0
  5. package/lib/types/ai-scan.d.ts +36 -0
  6. package/lib/types/assets.d.ts +24 -9
  7. package/lib/types/assistant.d.ts +29 -0
  8. package/lib/types/author-assets.d.ts +25 -0
  9. package/lib/types/bookshelf.d.ts +11 -1
  10. package/lib/types/client/api.d.ts +186 -10
  11. package/lib/types/client/chip-prefs.d.ts +36 -0
  12. package/lib/types/client/docx.d.ts +2 -0
  13. package/lib/types/client/index.d.ts +1 -1
  14. package/lib/types/client/llmLive.d.ts +31 -0
  15. package/lib/types/client/locales.d.ts +28 -67
  16. package/lib/types/client/panel/AdaptModeView.d.ts +5 -0
  17. package/lib/types/client/panel/AiTag.d.ts +1 -0
  18. package/lib/types/client/panel/AssistantTab.d.ts +4 -1
  19. package/lib/types/client/panel/AuthorAssetsView.d.ts +4 -0
  20. package/lib/types/client/panel/AuthorHome.d.ts +34 -0
  21. package/lib/types/client/panel/BookAnalysisView.d.ts +4 -0
  22. package/lib/types/client/panel/CmdPalette.d.ts +12 -0
  23. package/lib/types/client/panel/CreateBookView.d.ts +5 -1
  24. package/lib/types/client/panel/DirectorView.d.ts +7 -0
  25. package/lib/types/client/panel/GlobalAssetLibraryView.d.ts +4 -0
  26. package/lib/types/client/panel/IdeaInspirationView.d.ts +6 -0
  27. package/lib/types/client/panel/ImportModal.d.ts +7 -0
  28. package/lib/types/client/panel/KnowledgeBaseView.d.ts +4 -0
  29. package/lib/types/client/panel/LiveFeedLog.d.ts +13 -0
  30. package/lib/types/client/panel/MarketRadarView.d.ts +5 -0
  31. package/lib/types/client/panel/ModelManager.d.ts +14 -0
  32. package/lib/types/client/panel/NovelPanel.d.ts +6 -1
  33. package/lib/types/client/panel/PageHeader.d.ts +18 -0
  34. package/lib/types/client/panel/ProgressConsole.d.ts +17 -0
  35. package/lib/types/client/panel/ProgressHomeView.d.ts +5 -0
  36. package/lib/types/client/panel/PromptSlotsView.d.ts +11 -0
  37. package/lib/types/client/panel/ReasoningSection.d.ts +10 -0
  38. package/lib/types/client/panel/SettingsView.d.ts +19 -0
  39. package/lib/types/client/panel/ShelfView.d.ts +12 -1
  40. package/lib/types/client/panel/SubPage.d.ts +27 -0
  41. package/lib/types/client/panel/TensionView.d.ts +18 -0
  42. package/lib/types/client/panel/TimelineView.d.ts +18 -0
  43. package/lib/types/client/panel/helpers.d.ts +5 -3
  44. package/lib/types/client/panel/toast.d.ts +2 -0
  45. package/lib/types/client/panel/views.d.ts +25 -7
  46. package/lib/types/client/run-status.d.ts +21 -0
  47. package/lib/types/client/text.d.ts +9 -0
  48. package/lib/types/docx-core.d.ts +16 -0
  49. package/lib/types/docx.d.ts +2 -3
  50. package/lib/types/engine.d.ts +186 -45
  51. package/lib/types/global-assets.d.ts +6 -0
  52. package/lib/types/index.d.ts +22 -8
  53. package/lib/types/llm-live.d.ts +75 -0
  54. package/lib/types/llm-retry.d.ts +38 -0
  55. package/lib/types/market-radar-scan.d.ts +19 -0
  56. package/lib/types/market-radar-sources.d.ts +31 -0
  57. package/lib/types/migrate-output.d.ts +25 -0
  58. package/lib/types/novel-context.d.ts +80 -0
  59. package/lib/types/prompt-slots.d.ts +57 -0
  60. package/lib/types/prompt-utils.d.ts +35 -0
  61. package/lib/types/prompting.d.ts +10 -0
  62. package/lib/types/protocol.d.ts +1362 -166
  63. package/lib/types/review-policy.d.ts +97 -0
  64. package/lib/types/revision.d.ts +76 -0
  65. package/lib/types/routes.d.ts +4 -0
  66. package/lib/types/run.d.ts +23 -1
  67. package/lib/types/snapshots.d.ts +46 -0
  68. package/lib/types/stage-contract.d.ts +60 -0
  69. package/lib/types/tension.d.ts +53 -0
  70. package/lib/types/timeline.d.ts +37 -0
  71. package/package.json +52 -28
  72. package/src/action-guard.ts +127 -0
  73. package/src/ai-scan.ts +179 -0
  74. package/src/assets.ts +1806 -520
  75. package/src/assistant.ts +1098 -770
  76. package/src/author-assets.ts +149 -0
  77. package/src/bookshelf.ts +187 -151
  78. package/src/client/api.ts +378 -21
  79. package/src/client/chip-prefs.ts +121 -0
  80. package/src/client/docx.ts +37 -69
  81. package/src/client/index.ts +3 -1
  82. package/src/client/llmLive.ts +111 -0
  83. package/src/client/locales.ts +28 -253
  84. package/src/client/mount.tsx +1 -1
  85. package/src/client/panel/AdaptModeView.tsx +535 -0
  86. package/src/client/panel/AiTag.tsx +7 -0
  87. package/src/client/panel/AssetsTab.tsx +448 -458
  88. package/src/client/panel/AssistantTab.tsx +355 -301
  89. package/src/client/panel/AuthorAssetsView.tsx +206 -0
  90. package/src/client/panel/AuthorHome.tsx +309 -0
  91. package/src/client/panel/BookAnalysisView.tsx +63 -0
  92. package/src/client/panel/CmdPalette.tsx +102 -0
  93. package/src/client/panel/CreateBookView.tsx +366 -348
  94. package/src/client/panel/DirectorView.tsx +200 -0
  95. package/src/client/panel/GlobalAssetLibraryView.tsx +168 -0
  96. package/src/client/panel/IdeaInspirationView.tsx +76 -0
  97. package/src/client/panel/ImportModal.tsx +303 -0
  98. package/src/client/panel/KnowledgeBaseView.tsx +125 -0
  99. package/src/client/panel/LiveFeedLog.tsx +204 -0
  100. package/src/client/panel/MarketRadarView.tsx +377 -0
  101. package/src/client/panel/ModelManager.tsx +205 -0
  102. package/src/client/panel/NovelPanel.tsx +3135 -2651
  103. package/src/client/panel/PageHeader.tsx +37 -0
  104. package/src/client/panel/ProgressConsole.tsx +63 -0
  105. package/src/client/panel/PromptSlotsView.tsx +97 -0
  106. package/src/client/panel/ReaderView.tsx +306 -306
  107. package/src/client/panel/ReasoningSection.tsx +31 -0
  108. package/src/client/panel/RunPanel.tsx +64 -32
  109. package/src/client/panel/SettingsView.tsx +354 -0
  110. package/src/client/panel/ShelfView.tsx +266 -236
  111. package/src/client/panel/SubPage.tsx +42 -0
  112. package/src/client/panel/TensionView.tsx +212 -0
  113. package/src/client/panel/TimelineView.tsx +208 -0
  114. package/src/client/panel/WorldTab.tsx +202 -202
  115. package/src/client/panel/helpers.ts +7 -5
  116. package/src/client/panel/panel.module.css +4481 -1655
  117. package/src/client/panel/toast.ts +51 -0
  118. package/src/client/panel/views.tsx +401 -314
  119. package/src/client/run-status.ts +171 -0
  120. package/src/client/sidebar-entry.ts +1 -1
  121. package/src/client/text.ts +28 -0
  122. package/src/docx-core.ts +53 -0
  123. package/src/docx.ts +44 -83
  124. package/src/engine.ts +4672 -2943
  125. package/src/global-assets.ts +59 -0
  126. package/src/index.ts +89 -33
  127. package/src/llm-live.ts +260 -0
  128. package/src/llm-retry.ts +77 -0
  129. package/src/market-radar-scan.ts +41 -0
  130. package/src/market-radar-sources.ts +173 -0
  131. package/src/migrate-output.ts +76 -0
  132. package/src/novel-context.ts +286 -0
  133. package/src/prompt-slots.ts +125 -0
  134. package/src/prompting.ts +26 -0
  135. package/src/protocol.ts +1388 -158
  136. package/src/review-policy.ts +161 -0
  137. package/src/revision.ts +283 -0
  138. package/src/routes.ts +1934 -434
  139. package/src/run.ts +118 -21
  140. package/src/snapshots.ts +174 -0
  141. package/src/stage-contract.ts +189 -0
  142. package/src/tension.ts +251 -0
  143. package/src/timeline.ts +159 -0
  144. package/lib/client.js.map +0 -1
  145. package/lib/index.js.map +0 -1
  146. package/lib/types/comic-presets.d.ts +0 -12
  147. package/scripts/assistant-frames.txt +0 -8
  148. package/scripts/diagnose-adapter.mts +0 -81
  149. package/scripts/diagnose-assistant.mjs +0 -58
  150. package/scripts/diagnose-bible.mjs +0 -72
  151. package/scripts/diagnose-harness.mts +0 -75
  152. package/scripts/novel-forge-restart.log +0 -10
  153. package/scripts/novel-forge-web.stderr.log +0 -2
  154. package/scripts/novel-forge-web.stdout.log +0 -1
  155. package/scripts/outline-sample.txt +0 -449
  156. package/scripts/probe.mjs +0 -6
  157. package/scripts/restart-web.ps1 +0 -88
  158. package/scripts/smoke.mts +0 -72
  159. package/scripts/upload-github.ps1 +0 -51
  160. package/src/client/panel/BookshelfBar.tsx +0 -170
  161. package/src/comic-presets.ts +0 -67
@@ -1,314 +1,401 @@
1
- /**
2
- * 纯展示组件(views):所有状态与事件处理器都留在 NovelPanel,
3
- * 这里只接收 props 渲染。便于单独维护与复用。
4
- */
5
- import type { ReactElement } from 'react'
6
- import type { AuditIssue, Plotline, PlotlineHealthReport, PlotlinePlan, RoleRecord, RoleStatusCard } from '../../protocol.ts'
7
- import { tt, ROLE_LABELS, roleColor, kindLabel, plotlineStatusLabel, plotlineStatusColor } from './helpers.ts'
8
- import css from './panel.module.css'
9
-
10
- /** 统计格:状态摘要条 / 资产健康通用。 */
11
- export function StatCell(props: {
12
- label: string
13
- value: string
14
- detail: string
15
- /** 值颜色(可选)。 */
16
- valueColor?: string
17
- /** 值字号覆盖(如长文本用 13)。 */
18
- valueFontSize?: number
19
- /** detail 悬浮提示(可选)。 */
20
- detailTitle?: string
21
- }): ReactElement {
22
- return (
23
- <div className={css.assetStat}>
24
- <span className={css.assetStatLabel}>{props.label}</span>
25
- <span className={css.assetStatValue} style={{ color: props.valueColor, fontSize: props.valueFontSize }}>
26
- {props.value}
27
- </span>
28
- <span className={css.assetStatDetail} title={props.detailTitle}>{props.detail}</span>
29
- </div>
30
- )
31
- }
32
-
33
- /** 待办队列行。 */
34
- export function TodoRow(props: {
35
- tone: 'danger' | 'warning' | 'info' | 'success'
36
- title: string
37
- description: string
38
- actionLabel: string
39
- disabled: boolean
40
- onAction: () => void
41
- }): ReactElement {
42
- return (
43
- <div className={`${css.todoItem} ${props.tone === 'danger' ? css.todoDanger : props.tone === 'warning' ? css.todoWarning : css.todoInfo}`}>
44
- <span className={css.todoText}>
45
- {props.title}
46
- {props.description !== '' && <span className={css.meta}> — {props.description}</span>}
47
- </span>
48
- <button type="button" className={`${css.button} ${css.buttonSmall}`} disabled={props.disabled} onClick={props.onAction}>
49
- {props.actionLabel}
50
- </button>
51
- </div>
52
- )
53
- }
54
-
55
- /** 全书质检问题行。 */
56
- export function AuditIssueRow(props: {
57
- issue: AuditIssue
58
- disabled: boolean
59
- onFix: () => void
60
- }): ReactElement {
61
- const { issue } = props
62
- return (
63
- <div className={`${css.todoItem} ${issue.severity === 'high' ? css.todoDanger : issue.severity === 'medium' ? css.todoWarning : css.todoInfo}`}>
64
- <span className={css.todoText}>
65
- <span>
66
- {issue.chapterNo > 0 ? `第 ${issue.chapterNo} 章` : '未定位章节'} · [{issue.severity}] {issue.item}
67
- </span>
68
- {issue.suggestion !== '' && <span className={css.meta}>建议:{issue.suggestion}</span>}
69
- </span>
70
- {issue.chapterNo > 0 && (
71
- <button type="button" className={`${css.button} ${css.buttonSmall}`} disabled={props.disabled} onClick={props.onFix}>
72
- 去修订
73
- </button>
74
- )}
75
- </div>
76
- )
77
- }
78
-
79
- /** 剧情线卡片(列表主体)。 */
80
- export function PlotlineCard(props: {
81
- line: Plotline
82
- disabled: boolean
83
- onRefresh: () => void
84
- onEdit: () => void
85
- onRemove: () => void
86
- }): ReactElement {
87
- const { line } = props
88
- return (
89
- <div key={line.id} style={{ border: '1px solid var(--nf-border)', borderRadius: 10, padding: '8px 12px', fontSize: 12, display: 'flex', flexDirection: 'column', gap: 4 }}>
90
- <div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}>
91
- <span style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
92
- <b>{line.name}</b>
93
- <span className={css.badge} style={{ borderColor: 'var(--nf-accent)', color: 'var(--nf-accent)' }}>{kindLabel(line.kind)}</span>
94
- <span className={css.badge} style={{ borderColor: plotlineStatusColor(line.status), color: plotlineStatusColor(line.status) }}>{plotlineStatusLabel(line.status)}</span>
95
- </span>
96
- <span style={{ display: 'flex', gap: 6 }}>
97
- <button
98
- type="button"
99
- className={`${css.button} ${css.buttonSmall}`}
100
- disabled={props.disabled}
101
- onClick={props.onRefresh}
102
- title="AI 结合编年录与章节摘要,自动更新这条线的当前进度"
103
- >
104
- ↻ AI 刷新进度
105
- </button>
106
- <button type="button" className={`${css.button} ${css.buttonSmall}`} disabled={props.disabled} onClick={props.onEdit}>
107
- {tt('plotlines.edit')}
108
- </button>
109
- <button type="button" className={`${css.button} ${css.buttonSmall}`} disabled={props.disabled} onClick={props.onRemove}>
110
- {tt('plotlines.remove')}
111
- </button>
112
- </span>
113
- </div>
114
- {line.goal !== '' && <div className={css.meta}><b>{tt('plotlines.goal')}:</b>{line.goal}</div>}
115
- {line.progress !== '' && <div className={css.meta}><b>{tt('plotlines.progress')}:</b>{line.progress}</div>}
116
- <div className={css.meta}>
117
- {tt('plotlines.chapters')}:{line.chapters.length > 0 ? line.chapters.map(n => `第${n}章`).join('、') : '—'}
118
- </div>
119
- </div>
120
- )
121
- }
122
-
123
- /** AI 候选角色行(提炼结果,可采纳/修改后采纳)。 */
124
- export function RoleCandidateRow(props: {
125
- candidate: RoleRecord
126
- disabled: boolean
127
- onAdopt: () => void
128
- onEdit: () => void
129
- }): ReactElement {
130
- const { candidate: r } = props
131
- const label = ROLE_LABELS[r.roleLabel] ?? r.roleLabel
132
- const color = roleColor(r.roleLabel)
133
- return (
134
- <div key={r.name} style={{ border: '1px solid var(--nf-border)', borderRadius: 8, padding: '6px 10px', fontSize: 12 }}>
135
- <div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}>
136
- <span style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
137
- <b>{r.name}</b>
138
- <span className={css.badge} style={{ borderColor: color, color }}>{label}</span>
139
- {r.identity !== '' && <span className={css.meta}>{r.identity}</span>}
140
- </span>
141
- <span style={{ display: 'flex', gap: 6 }}>
142
- <button type="button" className={`${css.button} ${css.buttonSmall} ${css.buttonPrimary}`} disabled={props.disabled} onClick={props.onAdopt}>
143
- + 采纳
144
- </button>
145
- <button type="button" className={`${css.button} ${css.buttonSmall}`} disabled={props.disabled} onClick={props.onEdit} title="修改后再采纳(候选列表保留)">
146
- ✏️ 修改后采纳
147
- </button>
148
- </span>
149
- </div>
150
- {r.goals !== '' && <div className={css.meta}>目标:{r.goals}</div>}
151
- {r.relations.length > 0 && <div className={css.meta}>关系:{r.relations.join('、')}</div>}
152
- </div>
153
- )
154
- }
155
-
156
- /** 已收录角色卡(含从编年录刷新的当前状态行)。 */
157
- export function RoleCard(props: {
158
- role: RoleRecord
159
- status?: RoleStatusCard
160
- disabled: boolean
161
- onEdit: () => void
162
- onRemove: () => void
163
- }): ReactElement {
164
- const { role: r, status: st } = props
165
- const label = ROLE_LABELS[r.roleLabel] ?? r.roleLabel
166
- const color = roleColor(r.roleLabel)
167
- return (
168
- <div key={r.name} style={{ border: '1px solid var(--nf-border)', borderRadius: 8, padding: '6px 10px', fontSize: 12 }}>
169
- <div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}>
170
- <span style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
171
- <b>{r.name}</b>
172
- <span className={css.badge} style={{ borderColor: color, color }}>{label}</span>
173
- {r.identity !== '' && <span className={css.meta}>{r.identity}</span>}
174
- </span>
175
- <span style={{ display: 'flex', gap: 6 }}>
176
- <button type="button" className={`${css.button} ${css.buttonSmall}`} disabled={props.disabled} onClick={props.onEdit}>编辑</button>
177
- <button type="button" className={`${css.button} ${css.buttonSmall}`} disabled={props.disabled} onClick={props.onRemove}>删除</button>
178
- </span>
179
- </div>
180
- {r.goals !== '' && <div className={css.meta}>目标:{r.goals}</div>}
181
- {st !== undefined && (
182
- <div className={css.meta} style={{ color: 'var(--nf-accent)' }}>
183
- <b>当前状态:</b>{st.status !== '' ? st.status : '(编年录暂无该角色记录)'}
184
- {' · 出场 '}{st.appearances} 次 · 最近 第 {st.lastChapter} 章
185
- </div>
186
- )}
187
- {r.relations.length > 0 && <div className={css.meta}>关系:{r.relations.join('、')}</div>}
188
- {r.arc.length > 0 && <div className={css.meta}>成长线:{r.arc.join(' → ')}</div>}
189
- {r.knowledge.length > 0 && <div className={css.meta}>知情度:{r.knowledge.join(';')}</div>}
190
- </div>
191
- )
192
- }
193
-
194
- /** 🩺 剧情健康检查报告面板。 */
195
- export function PlotlineHealthPanel(props: {
196
- report: PlotlineHealthReport
197
- disabled: boolean
198
- onPlan: () => void
199
- onClose: () => void
200
- }): ReactElement {
201
- const { report } = props
202
- return (
203
- <div style={{ display: 'flex', flexDirection: 'column', gap: 6, border: '1px solid var(--nf-info)', borderRadius: 12, padding: 10 }}>
204
- <div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}>
205
- <b>🩺 剧情健康检查</b>
206
- <span style={{ display: 'flex', gap: 8 }}>
207
- <button
208
- type="button"
209
- className={`${css.button} ${css.buttonSmall}`}
210
- disabled={props.disabled}
211
- onClick={props.onPlan}
212
- title="基于本次诊断生成下一阶段剧情方案"
213
- >
214
- ✨ 基于此设计方案
215
- </button>
216
- <button type="button" className={`${css.button} ${css.buttonSmall}`} onClick={props.onClose}>收起</button>
217
- </span>
218
- </div>
219
- <div style={{ fontSize: 12, fontWeight: 700, color: 'var(--nf-accent)' }}>判定:{report.verdict}</div>
220
- {report.timing !== '' && <div className={css.meta}><b>建议时机:</b>{report.timing}</div>}
221
- {report.reasons.length > 0 && (
222
- <div style={{ display: 'flex', flexDirection: 'column', gap: 2, fontSize: 12 }}>
223
- {report.reasons.map((r, i) => <div key={i} className={css.meta}>· {r}</div>)}
224
- </div>
225
- )}
226
- {report.lines.length > 0 && (
227
- <div style={{ display: 'flex', flexDirection: 'column', gap: 4, fontSize: 12 }}>
228
- {report.lines.map((l, i) => {
229
- const color = l.health === 'ok' ? 'var(--nf-success)' : l.health === 'warning' ? 'var(--nf-warn)' : 'var(--nf-error)'
230
- const label = l.health === 'ok' ? '健康' : l.health === 'warning' ? '预警' : '搁置过久'
231
- return (
232
- <div key={i} style={{ display: 'flex', gap: 8, alignItems: 'flex-start' }}>
233
- <span className={css.badge} style={{ borderColor: color, color, flex: 'none', marginTop: 1 }}>{label}</span>
234
- <span className={css.meta}><b>{l.name}</b>:{l.note}</span>
235
- </div>
236
- )
237
- })}
238
- </div>
239
- )}
240
- </div>
241
- )
242
- }
243
-
244
- /** ✨ AI 剧情方案面板。 */
245
- export function PlotlinePlanPanel(props: {
246
- plan: PlotlinePlan
247
- disabled: boolean
248
- onAdopt: (suggestion: Plotline) => void
249
- onClose: () => void
250
- }): ReactElement {
251
- const { plan } = props
252
- return (
253
- <div style={{ display: 'flex', flexDirection: 'column', gap: 6, border: '1px solid var(--nf-accent)', borderRadius: 12, padding: 10 }}>
254
- <div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}>
255
- <b>✨ AI 剧情方案</b>
256
- <button type="button" className={`${css.button} ${css.buttonSmall}`} onClick={props.onClose}>收起</button>
257
- </div>
258
- {plan.direction !== '' && (
259
- <div className={css.meta} style={{ fontSize: 12 }}><b>下一阶段方向:</b>{plan.direction}</div>
260
- )}
261
- {plan.suggestions.length > 0 && (
262
- <div style={{ display: 'flex', flexDirection: 'column', gap: 4, fontSize: 12 }}>
263
- {plan.suggestions.map((s, i) => (
264
- <div key={i} style={{ border: '1px solid var(--nf-border)', borderRadius: 8, padding: '6px 10px' }}>
265
- <div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}>
266
- <span style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
267
- <b>{s.name}</b>
268
- <span className={css.badge} style={{ borderColor: 'var(--nf-accent)', color: 'var(--nf-accent)' }}>{kindLabel(s.kind)}</span>
269
- </span>
270
- <button type="button" className={`${css.button} ${css.buttonSmall} ${css.buttonPrimary}`} disabled={props.disabled} onClick={() => { props.onAdopt(s) }}>
271
- + 采纳
272
- </button>
273
- </div>
274
- {s.goal !== '' && <div className={css.meta}>{s.goal}</div>}
275
- {s.progress !== '' && <div className={css.meta}>初始进度:{s.progress}</div>}
276
- </div>
277
- ))}
278
- </div>
279
- )}
280
- </div>
281
- )
282
- }
283
-
284
- /** ✨ AI 建议剧情线面板。 */
285
- export function PlotlineSuggestionPanel(props: {
286
- suggestions: Plotline[]
287
- disabled: boolean
288
- onAdopt: (suggestion: Plotline) => void
289
- onClose: () => void
290
- }): ReactElement {
291
- return (
292
- <div style={{ display: 'flex', flexDirection: 'column', gap: 6, border: '1px solid var(--nf-info)', borderRadius: 12, padding: 10 }}>
293
- <div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}>
294
- <b>✨ AI 建议({props.suggestions.length} 条)</b>
295
- <button type="button" className={`${css.button} ${css.buttonSmall}`} onClick={props.onClose}>收起</button>
296
- </div>
297
- {props.suggestions.length === 0 && <span className={css.meta}>没有候选线。</span>}
298
- {props.suggestions.map((s, i) => (
299
- <div key={i} style={{ border: '1px solid var(--nf-border)', borderRadius: 8, padding: '6px 10px', fontSize: 12 }}>
300
- <div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}>
301
- <span style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
302
- <b>{s.name}</b>
303
- <span className={css.badge} style={{ borderColor: 'var(--nf-accent)', color: 'var(--nf-accent)' }}>{kindLabel(s.kind)}</span>
304
- </span>
305
- <button type="button" className={`${css.button} ${css.buttonSmall} ${css.buttonPrimary}`} disabled={props.disabled} onClick={() => { props.onAdopt(s) }}>
306
- + 采纳
307
- </button>
308
- </div>
309
- {s.goal !== '' && <div className={css.meta}>{s.goal}</div>}
310
- </div>
311
- ))}
312
- </div>
313
- )
314
- }
1
+ /**
2
+ * 纯展示组件(views):所有状态与事件处理器都留在 NovelPanel,
3
+ * 这里只接收 props 渲染。便于单独维护与复用。
4
+ */
5
+ import { useCallback, useEffect, useRef, useState } from 'react'
6
+ import type { ReactElement } from 'react'
7
+ import type { AuditIssue, Plotline, PlotlineHealthReport, PlotlinePlan, RoleRecord, RoleStatusCard } from '../../protocol.ts'
8
+ import { tt, ROLE_LABELS, roleColor, kindLabel, plotlineStatusLabel, plotlineStatusColor } from './helpers.ts'
9
+ import css from './panel.module.css'
10
+
11
+ /** 滑轨胶囊导航(B5-5):active 高亮为滑动指示块,随选中项平移。 */
12
+ export function SlideNav<T extends string>(props: {
13
+ ariaLabel: string
14
+ items: ReadonlyArray<{ id: T; label: ReactElement | string }>
15
+ active: T
16
+ onSelect: (id: T) => void
17
+ }): JSX.Element {
18
+ const { ariaLabel, items, active, onSelect } = props
19
+ const ref = useRef<HTMLDivElement | null>(null)
20
+ const [slider, setSlider] = useState<{ left: number; width: number } | null>(null)
21
+ const update = useCallback(() => {
22
+ const el = ref.current
23
+ if (el === null) return
24
+ const btn = el.querySelector<HTMLButtonElement>('button[data-active=""]')
25
+ if (btn === null) { setSlider(null); return }
26
+ const left = btn.offsetLeft
27
+ const width = btn.offsetWidth
28
+ setSlider(prev => (prev !== null && prev.left === left && prev.width === width ? prev : { left, width }))
29
+ }, [])
30
+ // 每次渲染后测量(set 前做等值比较,不会引发循环)。
31
+ useEffect(() => { update() })
32
+ useEffect(() => {
33
+ const el = ref.current
34
+ if (el === null) return
35
+ const ro = new ResizeObserver(() => { update() })
36
+ ro.observe(el)
37
+ return () => { ro.disconnect() }
38
+ }, [update])
39
+ return (
40
+ <div ref={ref} className={`${css.pillNav} ${css.pillNavSliding}`} role="tablist" aria-label={ariaLabel}>
41
+ {slider !== null && (
42
+ <span
43
+ className={css.pillNavSlider}
44
+ style={{ transform: `translateX(${slider.left}px)`, width: `${slider.width}px` }}
45
+ aria-hidden
46
+ />
47
+ )}
48
+ {items.map(it => (
49
+ <button
50
+ key={it.id}
51
+ type="button"
52
+ role="tab"
53
+ aria-selected={active === it.id}
54
+ data-active={active === it.id ? '' : undefined}
55
+ className={css.pillNavPill}
56
+ onClick={() => { onSelect(it.id) }}
57
+ >
58
+ {it.label}
59
+ </button>
60
+ ))}
61
+ </div>
62
+ )
63
+ }
64
+
65
+ /** 统计格:状态摘要条 / 资产健康通用。 */
66
+ export function StatCell(props: {
67
+ label: string
68
+ value: string
69
+ detail: string
70
+ /** 值颜色(可选)。 */
71
+ valueColor?: string
72
+ /** 值字号覆盖(如长文本用 13)。 */
73
+ valueFontSize?: number
74
+ /** detail 悬浮提示(可选)。 */
75
+ detailTitle?: string
76
+ }): ReactElement {
77
+ return (
78
+ <div className={css.assetStat}>
79
+ <span className={css.assetStatLabel}>{props.label}</span>
80
+ <span className={css.assetStatValue} style={{ color: props.valueColor, fontSize: props.valueFontSize }}>
81
+ {props.value}
82
+ </span>
83
+ <span className={css.assetStatDetail} title={props.detailTitle}>{props.detail}</span>
84
+ </div>
85
+ )
86
+ }
87
+
88
+ /** 待办队列行。 */
89
+ export function TodoRow(props: {
90
+ tone: 'danger' | 'warning' | 'info' | 'success'
91
+ title: string
92
+ description: string
93
+ actionLabel: string
94
+ disabled: boolean
95
+ onAction: () => void
96
+ }): ReactElement {
97
+ return (
98
+ <div className={`${css.todoItem} ${props.tone === 'danger' ? css.todoDanger : props.tone === 'warning' ? css.todoWarning : css.todoInfo}`}>
99
+ <span className={css.todoText}>
100
+ {props.title}
101
+ {props.description !== '' && <span className={css.meta}> — {props.description}</span>}
102
+ </span>
103
+ <button type="button" className={`${css.button} ${css.buttonSmall}`} disabled={props.disabled} onClick={props.onAction}>
104
+ {props.actionLabel}
105
+ </button>
106
+ </div>
107
+ )
108
+ }
109
+
110
+ /** 全书质检问题行。 */
111
+ export function AuditIssueRow(props: {
112
+ issue: AuditIssue
113
+ disabled: boolean
114
+ onFix: () => void
115
+ }): ReactElement {
116
+ const { issue } = props
117
+ return (
118
+ <div className={`${css.todoItem} ${issue.severity === 'high' ? css.todoDanger : issue.severity === 'medium' ? css.todoWarning : css.todoInfo}`}>
119
+ <span className={css.todoText}>
120
+ <span>
121
+ {issue.chapterNo > 0 ? `第 ${issue.chapterNo} 章` : '未定位章节'} · [{issue.severity}] {issue.item}
122
+ </span>
123
+ {issue.suggestion !== '' && <span className={css.meta}>建议:{issue.suggestion}</span>}
124
+ </span>
125
+ {issue.chapterNo > 0 && (
126
+ <button type="button" className={`${css.button} ${css.buttonSmall}`} disabled={props.disabled} onClick={props.onFix}>
127
+ 去修订
128
+ </button>
129
+ )}
130
+ </div>
131
+ )
132
+ }
133
+
134
+ /** 剧情线卡片(列表主体)。 */
135
+ export function PlotlineCard(props: {
136
+ line: Plotline
137
+ disabled: boolean
138
+ onRefresh: () => void
139
+ onEdit: () => void
140
+ onRemove: () => void
141
+ }): ReactElement {
142
+ const { line } = props
143
+ return (
144
+ <div key={line.id} style={{ border: '1px solid var(--nf-border)', borderRadius: 'var(--nf-radius-10)', padding: 'var(--nf-space-8) var(--nf-space-12)', fontSize: 'var(--nf-fs-12)', display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-4)' }}>
145
+ <div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}>
146
+ <span style={{ display: 'flex', alignItems: 'center', gap: 'var(--nf-space-6)', flexWrap: 'wrap' }}>
147
+ <b>{line.name}</b>
148
+ <span className={css.badge} style={{ borderColor: 'var(--nf-accent)', color: 'var(--nf-accent)' }}>{kindLabel(line.kind)}</span>
149
+ <span className={css.badge} style={{ borderColor: plotlineStatusColor(line.status), color: plotlineStatusColor(line.status) }}>{plotlineStatusLabel(line.status)}</span>
150
+ </span>
151
+ <span style={{ display: 'flex', gap: 'var(--nf-space-6)' }}>
152
+ <button
153
+ type="button"
154
+ className={`${css.button} ${css.buttonSmall}`}
155
+ disabled={props.disabled}
156
+ onClick={props.onRefresh}
157
+ title="AI 结合编年录与章节摘要,自动更新这条线的当前进度"
158
+ >
159
+ ↻ 刷新进度
160
+ </button>
161
+ <button type="button" className={`${css.button} ${css.buttonSmall}`} disabled={props.disabled} onClick={props.onEdit}>
162
+ {tt('plotlines.edit')}
163
+ </button>
164
+ <button type="button" className={`${css.button} ${css.buttonSmall}`} disabled={props.disabled} onClick={props.onRemove}>
165
+ {tt('plotlines.remove')}
166
+ </button>
167
+ </span>
168
+ </div>
169
+ {line.goal !== '' && <div className={css.meta}><b>{tt('plotlines.goal')}:</b>{line.goal}</div>}
170
+ {line.progress !== '' && <div className={css.meta}><b>{tt('plotlines.progress')}:</b>{line.progress}</div>}
171
+ <div className={css.meta}>
172
+ {tt('plotlines.chapters')}:{line.chapters.length > 0 ? line.chapters.map(n => `第${n}章`).join('、') : '—'}
173
+ </div>
174
+ </div>
175
+ )
176
+ }
177
+
178
+ /** AI 候选角色行(提炼结果,可采纳/修改后采纳)。 */
179
+ export function RoleCandidateRow(props: {
180
+ candidate: RoleRecord
181
+ disabled: boolean
182
+ onAdopt: () => void
183
+ onEdit: () => void
184
+ }): ReactElement {
185
+ const { candidate: r } = props
186
+ const label = ROLE_LABELS[r.roleLabel] ?? r.roleLabel
187
+ const color = roleColor(r.roleLabel)
188
+ return (
189
+ <div key={r.name} style={{ border: '1px solid var(--nf-border)', borderRadius: 'var(--nf-radius-8)', padding: 'var(--nf-space-6) var(--nf-space-10)', fontSize: 'var(--nf-fs-12)' }}>
190
+ <div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}>
191
+ <span style={{ display: 'flex', alignItems: 'center', gap: 'var(--nf-space-6)', flexWrap: 'wrap' }}>
192
+ <b>{r.name}</b>
193
+ <span className={css.badge} style={{ borderColor: color, color }}>{label}</span>
194
+ {r.identity !== '' && <span className={css.meta}>{r.identity}</span>}
195
+ </span>
196
+ <span style={{ display: 'flex', gap: 'var(--nf-space-6)' }}>
197
+ <button type="button" className={`${css.button} ${css.buttonSmall} ${css.buttonPrimary}`} disabled={props.disabled} onClick={props.onAdopt}>
198
+ + 采纳
199
+ </button>
200
+ <button type="button" className={`${css.button} ${css.buttonSmall}`} disabled={props.disabled} onClick={props.onEdit} title="修改后再采纳(候选列表保留)">
201
+ 修改后采纳
202
+ </button>
203
+ </span>
204
+ </div>
205
+ {r.goals !== '' && <div className={css.meta}>目标:{r.goals}</div>}
206
+ {r.relations.length > 0 && <div className={css.meta}>关系:{r.relations.join('、')}</div>}
207
+ </div>
208
+ )
209
+ }
210
+
211
+ /** 已收录角色卡(含从编年录刷新的当前状态行)。 */
212
+ export function RoleCard(props: {
213
+ role: RoleRecord
214
+ status?: RoleStatusCard
215
+ disabled: boolean
216
+ onEdit: () => void
217
+ onRemove: () => void
218
+ }): ReactElement {
219
+ const { role: r, status: st } = props
220
+ const label = ROLE_LABELS[r.roleLabel] ?? r.roleLabel
221
+ const color = roleColor(r.roleLabel)
222
+ return (
223
+ <div key={r.name} style={{ border: '1px solid var(--nf-border)', borderRadius: 'var(--nf-radius-8)', padding: 'var(--nf-space-6) var(--nf-space-10)', fontSize: 'var(--nf-fs-12)' }}>
224
+ <div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}>
225
+ <span style={{ display: 'flex', alignItems: 'center', gap: 'var(--nf-space-6)', flexWrap: 'wrap' }}>
226
+ <b>{r.name}</b>
227
+ <span className={css.badge} style={{ borderColor: color, color }}>{label}</span>
228
+ {r.identity !== '' && <span className={css.meta}>{r.identity}</span>}
229
+ </span>
230
+ <span style={{ display: 'flex', gap: 'var(--nf-space-6)' }}>
231
+ <button type="button" className={`${css.button} ${css.buttonSmall}`} disabled={props.disabled} onClick={props.onEdit}>编辑</button>
232
+ <button type="button" className={`${css.button} ${css.buttonSmall}`} disabled={props.disabled} onClick={props.onRemove}>删除</button>
233
+ </span>
234
+ </div>
235
+ {r.goals !== '' && <div className={css.meta}>目标:{r.goals}</div>}
236
+ {st !== undefined && (
237
+ <div className={css.meta} style={{ color: 'var(--nf-accent)' }}>
238
+ <b>当前状态:</b>{st.status !== '' ? st.status : '(编年录暂无该角色记录)'}
239
+ {' · 出场 '}{st.appearances} 次 · 最近 第 {st.lastChapter} 章
240
+ </div>
241
+ )}
242
+ {r.relations.length > 0 && <div className={css.meta}>关系:{r.relations.join('、')}</div>}
243
+ {r.arc.length > 0 && <div className={css.meta}>成长线:{r.arc.join(' → ')}</div>}
244
+ {r.knowledge.length > 0 && <div className={css.meta}>知情度:{r.knowledge.join(';')}</div>}
245
+ </div>
246
+ )
247
+ }
248
+
249
+ /** 剧情健康检查报告面板。 */
250
+ export function PlotlineHealthPanel(props: {
251
+ report: PlotlineHealthReport
252
+ disabled: boolean
253
+ onPlan: () => void
254
+ onClose: () => void
255
+ }): ReactElement {
256
+ const { report } = props
257
+ return (
258
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-6)', border: '1px solid var(--nf-info)', borderRadius: 'var(--nf-radius-12)', padding: 'var(--nf-space-10)' }}>
259
+ <div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}>
260
+ <b> 剧情健康检查</b>
261
+ <span style={{ display: 'flex', gap: 'var(--nf-space-8)' }}>
262
+ <button
263
+ type="button"
264
+ className={`${css.button} ${css.buttonSmall}`}
265
+ disabled={props.disabled}
266
+ onClick={props.onPlan}
267
+ title="基于本次诊断生成下一阶段剧情方案"
268
+ >
269
+ 基于此设计方案
270
+ </button>
271
+ <button type="button" className={`${css.button} ${css.buttonSmall}`} onClick={props.onClose}>收起</button>
272
+ </span>
273
+ </div>
274
+ <div style={{ fontSize: 'var(--nf-fs-12)', fontWeight: 700, color: 'var(--nf-accent)' }}>判定:{report.verdict}</div>
275
+ {report.timing !== '' && <div className={css.meta}><b>建议时机:</b>{report.timing}</div>}
276
+ {report.reasons.length > 0 && (
277
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-2)', fontSize: 'var(--nf-fs-12)' }}>
278
+ {report.reasons.map((r, i) => <div key={i} className={css.meta}>· {r}</div>)}
279
+ </div>
280
+ )}
281
+ {report.lines.length > 0 && (
282
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-4)', fontSize: 'var(--nf-fs-12)' }}>
283
+ {report.lines.map((l, i) => {
284
+ const color = l.health === 'ok' ? 'var(--nf-success)' : l.health === 'warning' ? 'var(--nf-warn)' : 'var(--nf-error)'
285
+ const label = l.health === 'ok' ? '健康' : l.health === 'warning' ? '预警' : '搁置过久'
286
+ return (
287
+ <div key={i} style={{ display: 'flex', gap: 'var(--nf-space-8)', alignItems: 'flex-start' }}>
288
+ <span className={css.badge} style={{ borderColor: color, color, flex: 'none', marginTop: 'var(--nf-space-2)' }}>{label}</span>
289
+ <span className={css.meta}><b>{l.name}</b>:{l.note}</span>
290
+ </div>
291
+ )
292
+ })}
293
+ </div>
294
+ )}
295
+ </div>
296
+ )
297
+ }
298
+
299
+ /** 剧情方案面板。 */
300
+ export function PlotlinePlanPanel(props: {
301
+ plan: PlotlinePlan
302
+ disabled: boolean
303
+ onAdopt: (suggestion: Plotline) => void
304
+ onClose: () => void
305
+ }): ReactElement {
306
+ const { plan } = props
307
+ return (
308
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-6)', border: '1px solid var(--nf-accent)', borderRadius: 'var(--nf-radius-12)', padding: 'var(--nf-space-10)' }}>
309
+ <div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}>
310
+ <b> 剧情方案</b>
311
+ <button type="button" className={`${css.button} ${css.buttonSmall}`} onClick={props.onClose}>收起</button>
312
+ </div>
313
+ {plan.direction !== '' && (
314
+ <div className={css.meta} style={{ fontSize: 'var(--nf-fs-12)' }}><b>下一阶段方向:</b>{plan.direction}</div>
315
+ )}
316
+ {plan.suggestions.length > 0 && (
317
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-4)', fontSize: 'var(--nf-fs-12)' }}>
318
+ {plan.suggestions.map((s, i) => (
319
+ <div key={i} style={{ border: '1px solid var(--nf-border)', borderRadius: 'var(--nf-radius-8)', padding: 'var(--nf-space-6) var(--nf-space-10)' }}>
320
+ <div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}>
321
+ <span style={{ display: 'flex', alignItems: 'center', gap: 'var(--nf-space-6)', flexWrap: 'wrap' }}>
322
+ <b>{s.name}</b>
323
+ <span className={css.badge} style={{ borderColor: 'var(--nf-accent)', color: 'var(--nf-accent)' }}>{kindLabel(s.kind)}</span>
324
+ </span>
325
+ <button type="button" className={`${css.button} ${css.buttonSmall} ${css.buttonPrimary}`} disabled={props.disabled} onClick={() => { props.onAdopt(s) }}>
326
+ + 采纳
327
+ </button>
328
+ </div>
329
+ {s.goal !== '' && <div className={css.meta}>{s.goal}</div>}
330
+ {s.progress !== '' && <div className={css.meta}>初始进度:{s.progress}</div>}
331
+ </div>
332
+ ))}
333
+ </div>
334
+ )}
335
+ </div>
336
+ )
337
+ }
338
+
339
+ /** 建议剧情线面板。 */
340
+ export function PlotlineSuggestionPanel(props: {
341
+ suggestions: Plotline[]
342
+ disabled: boolean
343
+ onAdopt: (suggestion: Plotline) => void
344
+ onClose: () => void
345
+ }): ReactElement {
346
+ return (
347
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-6)', border: '1px solid var(--nf-info)', borderRadius: 'var(--nf-radius-12)', padding: 'var(--nf-space-10)' }}>
348
+ <div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}>
349
+ <b> 建议({props.suggestions.length} 条)</b>
350
+ <button type="button" className={`${css.button} ${css.buttonSmall}`} onClick={props.onClose}>收起</button>
351
+ </div>
352
+ {props.suggestions.length === 0 && <span className={css.meta}>没有候选线。</span>}
353
+ {props.suggestions.map((s, i) => (
354
+ <div key={i} style={{ border: '1px solid var(--nf-border)', borderRadius: 'var(--nf-radius-8)', padding: 'var(--nf-space-6) var(--nf-space-10)', fontSize: 'var(--nf-fs-12)' }}>
355
+ <div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}>
356
+ <span style={{ display: 'flex', alignItems: 'center', gap: 'var(--nf-space-6)', flexWrap: 'wrap' }}>
357
+ <b>{s.name}</b>
358
+ <span className={css.badge} style={{ borderColor: 'var(--nf-accent)', color: 'var(--nf-accent)' }}>{kindLabel(s.kind)}</span>
359
+ </span>
360
+ <button type="button" className={`${css.button} ${css.buttonSmall} ${css.buttonPrimary}`} disabled={props.disabled} onClick={() => { props.onAdopt(s) }}>
361
+ + 采纳
362
+ </button>
363
+ </div>
364
+ {s.goal !== '' && <div className={css.meta}>{s.goal}</div>}
365
+ </div>
366
+ ))}
367
+ </div>
368
+ )
369
+ }
370
+
371
+ /** 空状态基元:图标 + 标题 + 可选提示。compact 变体用于列表/卡片内嵌场景。 */
372
+ export function EmptyState(props: {
373
+ icon?: string
374
+ title: string
375
+ hint?: string
376
+ compact?: boolean
377
+ }): ReactElement {
378
+ return (
379
+ <div className={css.emptyState} data-compact={props.compact === true ? 'true' : undefined}>
380
+ {props.icon !== undefined && <span className={css.emptyStateIcon}>{props.icon}</span>}
381
+ <span className={css.emptyStateTitle}>{props.title}</span>
382
+ {props.hint !== undefined && <span className={css.emptyStateHint}>{props.hint}</span>}
383
+ </div>
384
+ )
385
+ }
386
+
387
+ /** 骨架屏基元:rows 条微光占位行;widths 指定各行宽度档位,循环取用。 */
388
+ export function SkeletonLines(props: {
389
+ rows?: number
390
+ widths?: Array<'full' | 'wide' | 'half'>
391
+ }): ReactElement {
392
+ const rows = props.rows ?? 3
393
+ const widths = props.widths ?? ['full', 'wide', 'half']
394
+ return (
395
+ <div className={css.skeletonLines} aria-hidden="true">
396
+ {Array.from({ length: rows }, (_, i) => (
397
+ <span key={i} className={css.skeletonLine} data-w={widths[i % widths.length]} />
398
+ ))}
399
+ </div>
400
+ )
401
+ }