dsh-prompt-customizer 0.2.7 → 0.3.1

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.
@@ -1,201 +1,553 @@
1
- /** Prompt-sections tab: block / replace / inject / reorder prompt sections. */
2
- import { createElement as h, useState, type ReactElement, type DragEvent, type ChangeEvent } from 'react'
3
- import type { Config, Inventory, SettingsScope } from './types.ts'
1
+ /** 提示词段 Tab:四个部分 —— 引导期 / 常驻期 / 压缩受控期(三个阶段恒定
2
+ * 显示,预设没有某个阶段时该部分只是空的)+ 本系统全部提示词(只读池)。
3
+ *
4
+ * 交互对齐提示词段初版(v1),并修复三类反馈缺失:
5
+ * - 上三段每行 = 真实进入该阶段装配的段(post 视图,与预览一致 —— 预设
6
+ * 原生阶段插件裁剪掉的段不显示)∪ 被屏蔽的段 ∪ 配置里的自定义注入段,
7
+ * 顺序以 base 骨架为底再叠加草稿序;整行可拖拽,拖到某行上方/下方出现
8
+ * 插入指示线,松手即在该位置重排。
9
+ * - 被屏蔽的段与普通段一模一样:留在列表原位、一样可操作(拖拽/箭头/替换/
10
+ * 恢复/删除),随时可互相转换,唯一区别是屏蔽勾选框与「已屏蔽」徽标 ——
11
+ * 屏蔽只意味着不注入模型,不剥夺任何编辑能力(v1 语义)。屏蔽勾选直接
12
+ * 绑定当前生效名单(deniedNames = 全局 + 该阶段名单,含未保存草稿),
13
+ * 点击立即反馈。
14
+ * - 重排(拖入/箭头)走 `persistPhase`:把该阶段全部行重写为连续整数
15
+ * 虚拟 order(系统段写空文本 = 仅 order 覆盖,服务端保留原文;custom 段
16
+ * 保留文本)—— UI 行序叠加同一份 order 立即重排,拖动即时可见。
17
+ * - 文本编辑(「编辑」/「还原」)同样按阶段生效:写的就是本阶段注入条目的
18
+ * 文本,三个阶段各改各的、互不影响(服务端让本阶段注入文本优先于全局
19
+ * replace,否则一份文本会同时改坏三个阶段)。全局 replace 字典自此不再由
20
+ * 界面写入,只在「还原」时顺带清除遗留条目 —— 新界面没有别的出口能恢复它。
21
+ * - 拖放搬移保留行对象(system/custom 身份与文本原样带走),绝不重建行 ——
22
+ * 重建会把系统段误标成 custom 并清空内容。只有从其它部分/全部池拖入的
23
+ * 新段才构造新行(身份取自拖拽源)。
24
+ * - 拖放事件在行上处理并 stopPropagation,避免与阶段框二次处理对冲。
25
+ * - 全部提示词 → 上三段:插入该段到目标位置(已存在则只是移动排序;该段
26
+ * 在目标阶段被屏蔽则同步解除屏蔽 —— 行立即出现)。上三段 → 全部:从该
27
+ * 阶段移除 —— custom 段删除注入条目;系统段等价于屏蔽该阶段(系统段是
28
+ * 装配的一部分,无法真正删除)。
29
+ * - 解除屏蔽按份额归属分流写入:继承自全局名单的屏蔽直接改全局名单(立即
30
+ * 写盘通道)—— override 无法表达「比全局更宽松」(空名单回落全局,解除
31
+ * 会在保存后复现);override 自报名单与阶段名单的份额走目标草稿。
32
+ * - 每个阶段部分按自己那一阶段的装配结果给出警示:该 scope 有 complete 段
33
+ * 整段接管最终提示词,或本插件产出的段被下游装配规则丢弃时,明确写出
34
+ * 「段级定制不会(完全)进入模型看到的提示词」,避免提示词 Tab 与预览 Tab
35
+ * 各说各话。
36
+ *
37
+ * 全部阶段状态逻辑来自 lib/sectionOps.mjs(纯函数,node --test 单测直接
38
+ * 覆盖同一份代码)。
39
+ */
40
+ import { createElement as h, useRef, useState, type CSSProperties, type ReactElement, type DragEvent, type ChangeEvent } from 'react'
41
+ import type { Config, Inventory, Phase, PhaseViewKey, Preview } from './types.ts'
4
42
  import type { Translate } from './locales.ts'
5
- import { mergeSections, removeSection, type Section } from './presets.ts'
43
+ import { PART_ORDER } from './presets.ts'
44
+ import { injectPhaseOf, injectedAt, deniedNames, blockPatch, reorderInsert, phaseInjectEntries } from '../../lib/sectionOps.mjs'
6
45
  import { s } from './styles.ts'
7
46
 
8
- export function SectionsTab({ cfg, inv, scope, t }: { cfg: Config; inv: Inventory | null; scope: SettingsScope; t: Translate }): ReactElement {
9
- const replace = cfg.replace ?? {}
10
- const blockedNames = new Set(cfg.sections ?? [])
47
+ /** Panel 并行拉取的三阶段装配(与 PreviewTab 同一形状)。 */
48
+ type PhaseViews = Record<PhaseViewKey, Preview | null>
49
+
50
+ /** 屏幕上一条可交互的段行。 */
51
+ interface PartRow {
52
+ name: string
53
+ text: string
54
+ replaced: boolean
55
+ custom: boolean
56
+ /** 本阶段的用户替换文本(空串 = 本阶段没改过文本)—— 三个阶段各一份,互不影响。 */
57
+ override: string
58
+ /** 该阶段生效名单(全局 + 阶段独立)是否屏蔽了此段:仅影响展示,不剥夺操作。 */
59
+ blocked: boolean
60
+ }
61
+
62
+ /** 拖拽来源:全部池,或某个阶段部分(copy 语义,携带源的身份与文本)。 */
63
+ interface DragSource {
64
+ kind: 'all' | 'part'
65
+ key?: PhaseViewKey
66
+ text: string
67
+ custom: boolean
68
+ }
69
+
70
+ /** 编辑态的键:段名之外还要带阶段,否则三个部分里同名的行会同时展开编辑器。 */
71
+ const editKey = (key: PhaseViewKey, name: string): string => `${key}:${name}`
72
+
73
+ export function SectionsTab({ cfg, inv, phases, target, globalSections, ownedSections, t, write, writeGlobalField }: {
74
+ cfg: Config
75
+ inv: Inventory | null
76
+ phases: PhaseViews | null
77
+ /** 编辑目标:undefined = 全局默认;字符串 = agent 预设 id(字段级覆盖)。 */
78
+ target: string | undefined
79
+ /** 原始全局段屏蔽名单(未叠加 override / 草稿)——解除屏蔽时判定份额归属。 */
80
+ globalSections: string[] | undefined
81
+ /** 目标 override 自己的段屏蔽名单(未叠加草稿);undefined = 该 override 没有名单。 */
82
+ ownedSections: string[] | undefined
83
+ t: Translate
84
+ write: (field: 'sections' | 'sectionsBootstrap' | 'sectionsCompaction' | 'replace' | 'inject' | 'tools', value: unknown) => void
85
+ /** 全局字段的立即写入通道(不经目标草稿):解除继承自全局的屏蔽时使用。 */
86
+ writeGlobalField: (field: 'sections', value: unknown) => void
87
+ }): ReactElement {
11
88
  const [editing, setEditing] = useState<string | null>(null)
12
89
  const [draft, setDraft] = useState('')
13
90
  const [dragName, setDragName] = useState<string | null>(null)
14
- const [dropTarget, setDropTarget] = useState<{ name: string; pos: 'above' | 'below' } | null>(null)
15
-
16
- // Merge inventory sections with user-injected sections, sorted by order.
17
- // Injected sections that don't exist in the original prompt appear here too,
18
- // positioned by their order value.
19
- const merged: Section[] = mergeSections(inv, cfg, blockedNames)
20
-
21
- const toggleBlock = (name: string, currentlyBlocked: boolean): void => {
22
- const list = (cfg.sections ?? []).slice()
23
- if (currentlyBlocked) { const i = list.indexOf(name); if (i >= 0) list.splice(i, 1) }
24
- else list.push(name)
25
- scope.set('sections', list)
26
- }
27
- const startReplace = (name: string, original: string): void => {
28
- // Echo the ORIGINAL prompt text so the user edits from the current value.
29
- setEditing(name)
30
- setDraft(replace[name] ?? original)
31
- }
32
- const commitReplace = (name: string): void => {
33
- const next = { ...replace }
34
- if (draft.trim() === '') delete next[name]
35
- else next[name] = draft
36
- scope.set('replace', next)
37
- setEditing(null)
91
+ const [dragSource, setDragSource] = useState<DragSource | null>(null)
92
+ const [dropTarget, setDropTarget] = useState<{ part: PhaseViewKey; name: string; pos: 'above' | 'below' } | null>(null)
93
+ // 面板内短提示(例如:动态段加不进某个阶段时说明原因)。
94
+ const [notice, setNotice] = useState<string | null>(null)
95
+
96
+ // ── 拖拽边缘自动滚动 ────────────────────────────────────────────────────
97
+ // 列表远超视口时,浏览器的内建 DnD autoscroll 在宿主滚动容器上不可靠,
98
+ // 视口外的行无法成为放置目标(无法把最上方段拖到最下方)。dragover 冒泡
99
+ // 到根容器记录指针位置并启动 rAF 循环:指针接近视口上/下边缘时逐级向上
100
+ // 找最近的可滚动祖先持续滚动;drop/dragend 停止。
101
+ const scrollRef = useRef<{ raf: number; clientY: number }>({ raf: 0, clientY: 0 })
102
+ const stopAutoScroll = (): void => {
103
+ if (scrollRef.current.raf !== 0) { cancelAnimationFrame(scrollRef.current.raf); scrollRef.current.raf = 0 }
38
104
  }
39
- const clearReplace = (name: string): void => {
40
- const next = { ...replace }
41
- delete next[name]
42
- scope.set('replace', next)
43
- }
44
-
45
- // Persist the full ordered list. We treat the sorted list as an array and
46
- // re-index every section with a sequential integer order (0, 1, 2, …), so
47
- // there are never duplicate or fractional orders. System sections keep empty
48
- // text (so we never freeze their dynamically generated content); custom
49
- // (plugin-generated) sections keep their own text and the hidden `custom`
50
- // marker, so they render real content and stay identifiable/deletable.
51
- const persistOrder = (ordered: Section[]): void => {
52
- const list = ordered.map((sec, i) => {
53
- const existing = (cfg.inject ?? []).find((x) => x.name === sec.name)
54
- const isCustom = sec.source === 'custom'
55
- return { name: sec.name, order: i, text: isCustom ? (sec.text ?? '') : (existing?.text ?? ''), custom: isCustom }
105
+ const autoScrollTick = (): void => {
106
+ scrollRef.current.raf = 0
107
+ const edge = 48
108
+ const y = scrollRef.current.clientY
109
+ const inner = window.innerHeight
110
+ let node = rootRef.current?.parentElement
111
+ while (node !== null && node !== undefined) {
112
+ if (node.scrollHeight > node.clientHeight) {
113
+ if (y < edge) node.scrollTop -= Math.max(6, (edge - y) / 2)
114
+ else if (y > inner - edge) node.scrollTop += Math.max(6, (y - (inner - edge)) / 2)
115
+ else return
116
+ scrollRef.current.raf = requestAnimationFrame(autoScrollTick)
117
+ return
118
+ }
119
+ node = node.parentElement
120
+ }
121
+ // 无可滚动祖先(整页滚动):滚 window 本身。
122
+ if (y < edge) window.scrollBy({ top: -Math.max(6, (edge - y) / 2) })
123
+ else if (y > inner - edge) window.scrollBy({ top: Math.max(6, (y - (inner - edge)) / 2) })
124
+ if (y < edge || y > inner - edge) scrollRef.current.raf = requestAnimationFrame(autoScrollTick)
125
+ }
126
+ const rootRef = useRef<HTMLDivElement | null>(null)
127
+ // 只记录指针位置并启动循环;不 preventDefault —— 行/部分自身已处理放置
128
+ // 语义,空白区域维持「不可放置」的原生表现。
129
+ const rootDragOver = (e: DragEvent): void => {
130
+ if (!dragName) return
131
+ scrollRef.current.clientY = e.clientY
132
+ if (scrollRef.current.raf === 0) scrollRef.current.raf = requestAnimationFrame(autoScrollTick)
133
+ }
134
+
135
+ // 三个阶段部分:恒定全部显示(预设没有某个阶段时该部分只是空的)。
136
+ const parts: PhaseViewKey[] = PART_ORDER
137
+ // 逐阶段的「段级定制没进最终提示词」警示:每个部分按自己那一阶段的装配结果
138
+ // 判定 —— 有 complete 段整段接管就说接管,否则探测本插件产出的段是否被下游
139
+ // 丢弃(丢弃规则不止 persona complete 一种)。
140
+ const partNote = (key: PhaseViewKey): ReactElement | null => {
141
+ const view = phases?.[key]
142
+ if (view === undefined || view === null) return null
143
+ if (view.takenOverBy !== undefined) {
144
+ return h('div', { style: s.noticeWarn }, t('sectionsTakenOver', { name: view.takenOverBy }))
145
+ }
146
+ const lost = view.lostSections
147
+ if (lost !== undefined) {
148
+ return h('div', { style: s.noticeWarn }, t('sectionsLost', { emitted: lost.emitted, survived: lost.survived }))
149
+ }
150
+ return null
151
+ }
152
+
153
+ // 一个阶段部分的全部行:行集合 = 「真实进入该阶段装配的段」(post 视图,
154
+ // 与预览同源 —— 预设原生阶段插件裁剪掉的段不出现在这里)∪「被屏蔽的段」
155
+ //(屏蔽名单含未保存草稿,随时可反选)∪「配置里属于该阶段的自定义注入段」。
156
+ // 顺序以 base(预过滤)视图的骨架为底,再叠加「该阶段的注入 order」草稿序
157
+ // —— 未保存的重排立即反映到界面。文本 post 优先(替换 / 注入结果),回退
158
+ // base 原文与注入文本。身份只认注入条目的 custom 隐藏标记,绝不互相转换。
159
+ const rowsOf = (key: PhaseViewKey): PartRow[] => {
160
+ const replace = cfg.replace ?? {}
161
+ const view = phases?.[key]
162
+ const denied = new Set(deniedNames(cfg, key))
163
+ const postByName = new Map((view?.sections ?? []).map((sec) => [sec.name, sec] as const))
164
+ const baseByName = new Map((view?.baseSections ?? []).map((sec) => [sec.name, sec] as const))
165
+ // 本部分的注入身份(含未保存草稿):phase = 本阶段注入阶段,names = 本部分
166
+ // 可见的注入段名,custom = 自定义段身份,text = 本阶段生效的用户文本,
167
+ // order = 本阶段草稿序。
168
+ const { phase, names: injectedHere, custom: customNames, text, order: phaseOrder } = injectedAt(cfg, key)
169
+ const names: string[] = []
170
+ const seen = new Set<string>()
171
+ const push = (name: string): void => { if (!seen.has(name)) { seen.add(name); names.push(name) } }
172
+ // base 骨架顺序里取 post 段与被屏蔽段(保持自然位置)。被屏蔽取并集:
173
+ // 当前草稿名单(切换立即反馈)∪ 上次保存的名单(base 的 blocked 标记 ——
174
+ // 刚解除屏蔽的行不会立即消失,仍可再勾回去)。
175
+ for (const sec of view?.baseSections ?? []) {
176
+ if (postByName.has(sec.name) || denied.has(sec.name) || sec.blocked === true) push(sec.name)
177
+ }
178
+ // post 独有段 = 本插件注入进去的段(post 恒为 base 减去屏蔽再加上注入),
179
+ // 追加在尾部。但它来自「上次保存」的服务端结果,删除只改草稿,所以必须
180
+ // 有当前 inject 条目背书 —— 否则刚删掉的自定义段会以「系统段」复活(身份
181
+ // 只认 inject 的 custom 标记,条目没了就被判成系统),再经一次重排就被写成
182
+ // custom:false 空文本,用户填的内容被抹平。
183
+ for (const name of postByName.keys()) {
184
+ if (baseByName.has(name) || injectedHere.has(name)) push(name)
185
+ }
186
+ // 精确属于该阶段的注入条目:自定义段始终显示;系统段在装配输入里见过
187
+ //(base/post)**或**带着用户为该阶段填的文本(= 从全部池显式加进来的)才
188
+ // 显示 —— 既让拖入立即出现,又不让历史遗留的陈旧条目把装配里根本没有的段带回来。
189
+ for (const item of cfg.inject ?? []) {
190
+ if ((item.phase ?? 'always') !== phase) continue
191
+ if (item.custom === true || text.get(item.name) || baseByName.has(item.name) || postByName.has(item.name)) push(item.name)
192
+ }
193
+ // 跨阶段生效的自定义段(如 always)。
194
+ for (const name of customNames) push(name)
195
+ const rows = names.map((name) => {
196
+ // 本阶段的用户替换文本:只认属于本阶段的注入条目(injectedAt 已按阶段筛)。
197
+ const override = text.get(name) ?? ''
198
+ return {
199
+ name,
200
+ text: postByName.get(name)?.text ?? baseByName.get(name)?.text ?? '',
201
+ replaced: override !== '' || Object.hasOwn(replace, name),
202
+ custom: customNames.has(name),
203
+ override,
204
+ blocked: denied.has(name),
205
+ }
56
206
  })
57
- scope.set('inject', list)
207
+ // 草稿序叠加:该阶段有注入 order 就按它重排当前行(未保存的拖动/箭头
208
+ // 立即生效);无 order 的行走 base 骨架相对序(稳定排序)。
209
+ if (phaseOrder.size > 0) {
210
+ const fallback = new Map(rows.map((row, i) => [row.name, i] as const))
211
+ rows.sort((a, b) => (phaseOrder.get(a.name) ?? fallback.get(a.name) ?? 0) - (phaseOrder.get(b.name) ?? fallback.get(b.name) ?? 0))
212
+ }
213
+ return rows
214
+ }
215
+
216
+ // 屏蔽 / 恢复一个段。屏蔽永远走当前目标的草稿(override 可以表达「比全局
217
+ // 更严格」)。解除屏蔽按屏蔽份额的归属分流:阶段名单与 override 自报名单
218
+ // 走草稿;继承自全局的屏蔽必须直接改全局名单 —— override 表达不了「比全局
219
+ // 更宽松」(空名单回落全局,解除会在保存后失效并复现)。
220
+ const applyBlock = (key: PhaseViewKey, name: string, blocked: boolean): void => {
221
+ if (blocked || !target) {
222
+ const patch = blockPatch(cfg, key, name, blocked)
223
+ for (const [field, value] of Object.entries(patch)) {
224
+ // 名单未变化时不写,避免把继承值冻结进 override。
225
+ if (JSON.stringify(value) === JSON.stringify((cfg as Record<string, unknown>)[field] ?? [])) continue
226
+ write(field as 'sections' | 'sectionsBootstrap' | 'sectionsCompaction', value)
227
+ }
228
+ return
229
+ }
230
+ const patch = blockPatch(cfg, key, name, false)
231
+ const effectiveOwned = ownedSections && ownedSections.length > 0 ? ownedSections : undefined
232
+ for (const [field, value] of Object.entries(patch)) {
233
+ if (field === 'sections') {
234
+ // 全局份额:override 未接管名单(或解除后会把名单清空)时,全局名单
235
+ // 里的该段必须从全局移除,否则空名单回落全局后屏蔽复现。
236
+ if ((globalSections ?? []).includes(name) && (!effectiveOwned || ownedSections!.includes(name))) {
237
+ writeGlobalField('sections', (globalSections ?? []).filter((n) => n !== name))
238
+ }
239
+ // override 自报名单里的份额:走草稿。
240
+ if (effectiveOwned?.includes(name)) write('sections', value)
241
+ continue
242
+ }
243
+ if (JSON.stringify(value) === JSON.stringify((cfg as Record<string, unknown>)[field] ?? [])) continue
244
+ write(field as 'sectionsBootstrap' | 'sectionsCompaction', value)
245
+ }
58
246
  }
247
+ const toggleBlocked = (key: PhaseViewKey, name: string): void =>
248
+ applyBlock(key, name, !deniedNames(cfg, key).includes(name))
249
+
250
+ // 该阶段中某名字是否在该阶段的装配输入里(判断拖入是「解除屏蔽」还是
251
+ // 「凭空注入」)。
252
+ const inBaseOf = (key: PhaseViewKey, name: string): boolean =>
253
+ (phases?.[key]?.baseSections ?? []).some((sec) => sec.name === name)
59
254
 
60
- // Delete a custom (plugin-added) section: remove it from inject / sections /
61
- // replace so it no longer appears in the panel at all.
62
- const removeCustom = (name: string): void => {
63
- const patch = removeSection(name, cfg)
64
- scope.set('sections', patch.sections)
65
- scope.set('inject', patch.inject)
66
- scope.set('replace', patch.replace)
255
+ // 持久化一个阶段的有序列表(写回 inject):该阶段每行一条注入项、order 为
256
+ // 连续整数;系统行空文本(仅 order 覆盖),custom 行保留文本;其它阶段
257
+ //(含 always)的注入项原样保留。被屏蔽的行同样写入(服务端对被屏蔽段
258
+ // 跳过注入,order 留作解除屏蔽后立即生效)。
259
+ const persistPhase = (key: PhaseViewKey, rows: PartRow[]): void => {
260
+ write('inject', phaseInjectEntries(cfg, key, rows))
67
261
  }
68
262
 
69
- // Move a section up/down one slot (array-index swap), then re-index.
70
- const moveOrder = (index: number, dir: -1 | 1): void => {
263
+ // 上移/下移一格(数组交换后重排整个阶段)。
264
+ const moveRow = (key: PhaseViewKey, index: number, dir: -1 | 1): void => {
265
+ const rows = rowsOf(key)
71
266
  const target = index + dir
72
- if (target < 0 || target >= merged.length) return
73
- const next = merged.slice()
267
+ if (target < 0 || target >= rows.length) return
268
+ const next = rows.slice()
74
269
  const [item] = next.splice(index, 1)
75
270
  next.splice(target, 0, item)
76
- persistOrder(next)
271
+ persistPhase(key, next)
272
+ }
273
+
274
+ // 从该阶段移除一个段:删掉它在本阶段的注入条目,以及跨阶段生效(always)的
275
+ // 条目 —— 预设应用 / 导入产生的自定义段就是 always,它在三个阶段都显示,
276
+ // 只按当前阶段过滤的话永远删不掉。随后解除该阶段屏蔽,行才真正消失。
277
+ const removeFromPart = (key: PhaseViewKey, name: string): void => {
278
+ const phase = injectPhaseOf(key)
279
+ const inject = (cfg.inject ?? []).filter((x) => {
280
+ if (x.name !== name) return true
281
+ const itemPhase = x.phase ?? 'always'
282
+ return !(itemPhase === phase || itemPhase === 'always')
283
+ })
284
+ write('inject', inject)
285
+ applyBlock(key, name, false)
286
+ }
287
+
288
+ // 文本编辑按阶段生效:编辑态的键是「阶段 + 段名」,三个阶段部分里同名的行
289
+ // 各自独立展开。写入走 persistPhase(本阶段的注入条目带 text),绝不碰全局
290
+ // replace 字典 —— 那一份文本对三个阶段同时生效,正是「改一个阶段、三个阶段
291
+ // 都变」的根源。
292
+ const startReplace = (key: PhaseViewKey, row: PartRow): void => {
293
+ // 回显当前生效文本:本阶段替换过的从替换值开始,否则从原始文本开始(v1 语义)。
294
+ setEditing(editKey(key, row.name))
295
+ setDraft(row.override || row.text)
296
+ }
297
+ const commitReplace = (key: PhaseViewKey, row: PartRow): void => {
298
+ const next = rowsOf(key).map((r) => (r.name !== row.name ? r : { ...r, override: draft, text: draft }))
299
+ persistPhase(key, next)
300
+ setEditing(null)
301
+ }
302
+ const restoreReplace = (key: PhaseViewKey, row: PartRow): void => {
303
+ const next = rowsOf(key).map((r) =>
304
+ r.name !== row.name ? r : { ...r, override: '', text: r.custom ? r.text : '' },
305
+ )
306
+ persistPhase(key, next)
307
+ // 遗留的全局替换条目(旧版 UI 的产物,新界面再也不写它):只在本部分还原
308
+ // 时一并清除,否则这份跨阶段文本再也没有恢复原文的入口。
309
+ if (!row.custom && Object.hasOwn(cfg.replace ?? {}, row.name)) {
310
+ const rest = { ...(cfg.replace ?? {}) }
311
+ delete rest[row.name]
312
+ write('replace', rest)
313
+ }
77
314
  }
78
315
 
79
- const onDragStart = (e: DragEvent, name: string): void => {
316
+ const addSection = (name: string, text: string, phase: Phase): void => {
317
+ const inject = (cfg.inject ?? []).slice()
318
+ inject.push({ name, order: 120 + inject.length, text, phase, custom: true })
319
+ write('inject', inject)
320
+ }
321
+
322
+ // ── 拖拽(v1 语义:整行拖拽 + 上/下插入指示) ─────────────────────────────
323
+ const startDrag = (e: DragEvent, name: string, source: DragSource): void => {
80
324
  setDragName(name)
325
+ setDragSource(source)
81
326
  e.dataTransfer.effectAllowed = 'move'
82
327
  try { e.dataTransfer.setData('text/plain', name) } catch { /* ignore */ }
83
328
  }
84
- const onDragOver = (e: DragEvent, name: string): void => {
329
+ const rowDragOver = (e: DragEvent, key: PhaseViewKey, name: string): void => {
85
330
  if (!dragName || dragName === name) return
86
331
  e.preventDefault()
87
332
  e.dataTransfer.dropEffect = 'move'
88
333
  const rect = e.currentTarget.getBoundingClientRect()
89
334
  const pos = e.clientY < rect.top + rect.height / 2 ? 'above' : 'below'
90
- setDropTarget({ name, pos })
335
+ setDropTarget({ part: key, name, pos })
91
336
  }
92
- const onDrop = (e: DragEvent, target: Section): void => {
337
+ // 从「全部」池把一个该阶段原本没有的系统段加进来:带上它的原文作为本阶段
338
+ // 文本 —— 注入条目带文本,服务端才会把它真正建出来(幽灵防线只放行带文本的
339
+ // 条目)。该阶段已有的段不在此列(只是排序/解除屏蔽)。动态段没有可带的
340
+ // 原文,加不了,返回 blocked 让调用方提示。
341
+ const carryFor = (key: PhaseViewKey, source: DragSource, name: string): { text: string; blocked: boolean } => {
342
+ if (source.kind !== 'all' || source.custom) return { text: '', blocked: false }
343
+ if (inBaseOf(key, name)) return { text: '', blocked: false }
344
+ if (source.text === '') return { text: '', blocked: true }
345
+ return { text: source.text, blocked: false }
346
+ }
347
+ // 拖入/重排:把 dragName 插入到目标行上/下方(已存在则移动位置)。
348
+ // 目标部分里已有的行整体搬移(身份与文本原样保留,绝不重建 —— 重建会把
349
+ // 系统段误标成 custom 并清空内容);只有真正新拖入的段才构造新行。
350
+ const insertInto = (key: PhaseViewKey, targetName: string, pos: 'above' | 'below'): void => {
351
+ if (!dragName || !dragSource) return
352
+ const carry = carryFor(key, dragSource, dragName)
353
+ if (carry.blocked) { setNotice(t('sectionDynamicNoAdd', { name: dragName })); return }
354
+ const rows = rowsOf(key)
355
+ const existing = rows.find((row) => row.name === dragName)
356
+ const newRow: PartRow = existing ?? {
357
+ name: dragName,
358
+ text: dragSource.custom ? dragSource.text : '',
359
+ replaced: Object.hasOwn(cfg.replace ?? {}, dragName),
360
+ custom: dragSource.custom,
361
+ override: carry.text,
362
+ blocked: false,
363
+ }
364
+ const next = reorderInsert(rows, dragName, targetName, pos, newRow)
365
+ if (next === null) return
366
+ // 从全部池拖入一个被本阶段屏蔽的系统段:解除该阶段屏蔽,否则装配仍会过滤。
367
+ if (dragSource.kind === 'all' && inBaseOf(key, dragName) && deniedNames(cfg, key).includes(dragName)) {
368
+ applyBlock(key, dragName, false)
369
+ }
370
+ persistPhase(key, next)
371
+ }
372
+ const rowDrop = (e: DragEvent, key: PhaseViewKey, name: string): void => {
373
+ e.preventDefault()
374
+ e.stopPropagation() // 避免继续冒泡到阶段框的 append 逻辑(防双份)
375
+ if (dragName && dragName !== name) {
376
+ const pos = dropTarget?.part === key && dropTarget.name === name && dropTarget.pos ? dropTarget.pos : 'below'
377
+ insertInto(key, name, pos)
378
+ }
379
+ setDragName(null); setDragSource(null); setDropTarget(null)
380
+ }
381
+ const partDropEnd = (e: DragEvent, key: PhaseViewKey): void => {
382
+ // 拖到阶段部分空白处 = 追加到末尾(同样保留已有行的身份)。
93
383
  e.preventDefault()
94
- if (!dragName || dragName === target.name) { setDragName(null); setDropTarget(null); return }
95
- const dragged = merged.find((x) => x.name === dragName)
96
- if (!dragged) { setDragName(null); setDropTarget(null); return }
97
- const next = merged.filter((x) => x.name !== dragName)
98
- const idx = next.findIndex((x) => x.name === target.name)
99
- const pos = dropTarget?.name === target.name ? dropTarget.pos : 'below'
100
- next.splice(pos === 'above' ? idx : idx + 1, 0, dragged)
101
- persistOrder(next)
102
- setDragName(null)
103
- setDropTarget(null)
104
- }
105
- const onDragEnd = (): void => { setDragName(null); setDropTarget(null) }
106
-
107
- // Add a new injected section, placed by its order hint, then re-index. The
108
- // section is marked `custom` (the hidden marker) so it is identified as
109
- // plugin-generated and thus deletable, and its own text is preserved.
110
- const addSection = (name: string, order: number, text: string): void => {
111
- const next = merged.slice()
112
- const entry: Section = { name, order, text, active: true, replaced: false, source: 'custom' }
113
- const existing = next.findIndex((x) => x.name === name)
114
- if (existing >= 0) next[existing] = entry
115
- else {
116
- const idx = next.findIndex((x) => x.order > order)
117
- if (idx < 0) next.push(entry)
118
- else next.splice(idx, 0, entry)
119
- }
120
- persistOrder(next)
121
- }
122
-
123
- return h('div', { style: s.list }, [
124
- merged.length === 0 ? h('div', { style: s.muted }, t('empty')) : null,
125
- merged.map((sec, index) => {
126
- const blocked = blockedNames.has(sec.name)
127
- const isEditing = editing === sec.name
128
- const isDragging = dragName === sec.name
129
- const isDropTarget = dropTarget?.name === sec.name
130
- const rowStyle = {
131
- ...s.row,
132
- ...(isDragging ? s.rowDragging : {}),
133
- ...(isDropTarget && dropTarget.pos === 'above' ? s.rowDropAbove : {}),
134
- ...(isDropTarget && dropTarget.pos === 'below' ? s.rowDropBelow : {}),
384
+ if (dragName && dragSource) {
385
+ const carry = carryFor(key, dragSource, dragName)
386
+ if (!carry.blocked) {
387
+ const rows = rowsOf(key)
388
+ const existing = rows.find((row) => row.name === dragName)
389
+ const next = rows.filter((row) => row.name !== dragName)
390
+ next.push(existing ?? {
391
+ name: dragName,
392
+ text: dragSource.custom ? dragSource.text : '',
393
+ replaced: Object.hasOwn(cfg.replace ?? {}, dragName),
394
+ custom: dragSource.custom,
395
+ override: carry.text,
396
+ blocked: false,
397
+ })
398
+ if (dragSource.kind === 'all' && inBaseOf(key, dragName) && deniedNames(cfg, key).includes(dragName)) {
399
+ applyBlock(key, dragName, false)
400
+ }
401
+ persistPhase(key, next)
402
+ } else {
403
+ setNotice(t('sectionDynamicNoAdd', { name: dragName }))
135
404
  }
136
- return h('div', {
405
+ }
406
+ setDragName(null); setDragSource(null); setDropTarget(null)
407
+ }
408
+ const allBoxDrop = (e: DragEvent): void => {
409
+ e.preventDefault()
410
+ // 拖回「全部」= 从该阶段移除:custom 段删除注入条目;系统段无法真正
411
+ // 删除(它是装配的一部分),等价操作是屏蔽该阶段 —— 行翻转成「已屏蔽」,
412
+ // 仍留在列表里可随时拖回 / 勾回。
413
+ if (dragName && dragSource?.kind === 'part' && dragSource.key) {
414
+ const src = dragSource.key
415
+ const row = rowsOf(src).find((r) => r.name === dragName)
416
+ // 用户加进来的系统段(该阶段原本没有、靠注入文本出现):移除注入条目即还原。
417
+ const addedSystem = !!row && !row.custom && !inBaseOf(src, dragName) && row.override !== ''
418
+ if (row && !row.custom && !addedSystem) applyBlock(src, dragName, true)
419
+ else removeFromPart(src, dragName)
420
+ }
421
+ setDragName(null); setDragSource(null); setDropTarget(null)
422
+ }
423
+ const endDrag = (): void => { stopAutoScroll(); setDragName(null); setDragSource(null); setDropTarget(null) }
424
+
425
+ const partNoun = (key: PhaseViewKey): string =>
426
+ key === 'bootstrap' ? t('sectionsPartGuide') : key === 'compaction' ? t('sectionsPartControlled') : t('sectionsPartResident')
427
+
428
+ const isEditing = (key: PhaseViewKey, row: PartRow): boolean => editing === editKey(key, row.name)
429
+
430
+ const renderRow = (key: PhaseViewKey, row: PartRow, index: number, total: number): ReactElement => {
431
+ const isDragging = dragName === row.name
432
+ const isDropTarget = dropTarget?.part === key && dropTarget.name === row.name
433
+ const rowStyle: CSSProperties = {
434
+ ...s.row,
435
+ ...(isDragging ? s.rowDragging : {}),
436
+ ...(isDropTarget && dropTarget && dropTarget.pos === 'above' ? s.rowDropAbove : {}),
437
+ ...(isDropTarget && dropTarget && dropTarget.pos === 'below' ? s.rowDropBelow : {}),
438
+ cursor: 'move',
439
+ }
440
+ return h('div', {
441
+ key: row.name,
442
+ style: rowStyle,
443
+ draggable: true,
444
+ onDragStart: (e: DragEvent) => startDrag(e, row.name, { kind: 'part', key, text: row.custom ? (row.override || row.text) : '', custom: row.custom }),
445
+ onDragOver: (e: DragEvent) => rowDragOver(e, key, row.name),
446
+ onDrop: (e: DragEvent) => rowDrop(e, key, row.name),
447
+ onDragEnd: endDrag,
448
+ }, [
449
+ h('span', { style: s.dragHandle, title: t('drag') }, '⠿'),
450
+ h('label', { style: s.switchWrap }, [
451
+ h('input', { type: 'checkbox', checked: row.blocked, onChange: () => toggleBlocked(key, row.name) }),
452
+ h('span', { style: row.blocked ? s.badgeBlocked : s.badgeOk }, row.blocked ? t('blockedOn') : t('blockedOff')),
453
+ ]),
454
+ h('div', { style: s.rowBody }, [
455
+ h('div', { style: s.rowTitle }, [
456
+ h('span', { style: s.code }, row.name),
457
+ h('span', { style: s.orderTag }, '#' + index),
458
+ h('span', { style: row.custom ? s.badgeCustom : s.badgeSystem }, row.custom ? t('manual') : t('system')),
459
+ row.replaced ? h('span', { style: s.badgeReplaced }, t('replaced')) : null,
460
+ ]),
461
+ isEditing(key, row)
462
+ ? h('div', { style: s.editBox }, [
463
+ h('textarea', { style: s.editInput, value: draft, onChange: (e: ChangeEvent<HTMLTextAreaElement>) => setDraft(e.target.value), rows: 3 }),
464
+ h('div', { style: s.injectRow }, [
465
+ h('button', { style: s.mini, onClick: () => commitReplace(key, row) }, t('save')),
466
+ h('button', { style: s.mini, onClick: () => setEditing(null) }, t('clearInput')),
467
+ !row.custom && (row.override || Object.hasOwn(cfg.replace ?? {}, row.name))
468
+ ? h('button', { style: s.mini, onClick: () => restoreReplace(key, row) }, t('restore'))
469
+ : null,
470
+ ]),
471
+ ])
472
+ : h('div', { style: s.preview }, String(row.override || row.text || '').slice(0, 140) || (row.custom ? t('empty') : t('dynamic'))),
473
+ ]),
474
+ h('div', { style: s.arrowCol }, [
475
+ h('button', { style: s.arrow, disabled: index === 0, onClick: () => moveRow(key, index, -1), title: t('moveUp') }, '↑'),
476
+ h('button', { style: s.arrow, disabled: index === total - 1, onClick: () => moveRow(key, index, 1), title: t('moveDown') }, '↓'),
477
+ ]),
478
+ isEditing(key, row) ? null : h('button', { style: s.mini, onClick: () => startReplace(key, row) }, t('replace')),
479
+ !isEditing(key, row) && !row.custom && (row.override || Object.hasOwn(cfg.replace ?? {}, row.name))
480
+ ? h('button', { style: s.mini, onClick: () => restoreReplace(key, row) }, t('restore'))
481
+ : null,
482
+ row.custom ? h('button', { style: s.mini, onClick: () => removeFromPart(key, row.name), title: t('delete') }, t('delete')) : null,
483
+ ])
484
+ }
485
+
486
+ // 一个阶段部分:标题 + 全部行(含被屏蔽的段,整行拖拽/箭头/序号)+ 注入表单。
487
+ const renderPart = (key: PhaseViewKey): ReactElement => {
488
+ const rows = rowsOf(key)
489
+ return h('div', {
490
+ key,
491
+ style: s.injectBox,
492
+ onDragOver: (e: DragEvent) => { if (dragName) { e.preventDefault(); e.dataTransfer.dropEffect = 'move' } },
493
+ onDrop: (e: DragEvent) => partDropEnd(e, key),
494
+ }, [
495
+ h('div', { style: s.rowTitle }, [
496
+ h('span', null, partNoun(key)),
497
+ h('span', { style: s.orderTag }, `${t('phaseVisible')} ${rows.length}`),
498
+ ]),
499
+ partNote(key),
500
+ rows.map((row, i) => renderRow(key, row, i, rows.length)),
501
+ h(InjectForm, { onAdd: (name, text) => addSection(name, text, injectPhaseOf(key)), lockedPhase: injectPhaseOf(key), t }),
502
+ ])
503
+ }
504
+
505
+ return h('div', { style: s.list, ref: rootRef, onDragOver: rootDragOver }, [
506
+ notice ? h('div', { style: s.noticeWarn }, notice) : null,
507
+ parts.map(renderPart),
508
+ h('div', {
509
+ style: s.injectBox,
510
+ onDragOver: (e: DragEvent) => { if (dragName) { e.preventDefault(); e.dataTransfer.dropEffect = 'move' } },
511
+ onDrop: allBoxDrop,
512
+ }, [
513
+ h('div', { style: s.rowTitle }, [t('allSectionsTitle'), h('span', { style: s.orderTag }, `${(inv?.sections ?? []).length}`)]),
514
+ h('div', { style: { ...s.muted, marginBottom: 4 } }, t('sectionsFourHint')),
515
+ (inv?.sections ?? []).length === 0 ? h('div', { style: s.muted }, t('empty')) : null,
516
+ (inv?.sections ?? []).map((sec) => h('div', {
137
517
  key: sec.name,
138
- style: rowStyle,
518
+ style: { ...s.row, cursor: 'copy', opacity: 0.92 },
139
519
  draggable: true,
140
- onDragStart: (e: DragEvent) => onDragStart(e, sec.name),
141
- onDragOver: (e: DragEvent) => onDragOver(e, sec.name),
142
- onDrop: (e: DragEvent) => onDrop(e, sec),
143
- onDragEnd,
520
+ onDragStart: (e: DragEvent) => startDrag(e, sec.name, { kind: 'all', text: (sec.text ?? '').startsWith('<') || !sec.text ? '' : sec.text, custom: false }),
521
+ onDragEnd: endDrag,
144
522
  }, [
145
- h('span', { style: s.dragHandle, title: t('drag') }, '⠿'),
146
- h('label', { style: s.switchWrap }, [
147
- h('input', { type: 'checkbox', checked: blocked, onChange: () => toggleBlock(sec.name, blocked) }),
148
- h('span', { style: blocked ? s.badgeBlocked : s.badgeOk }, blocked ? t('blockedOn') : t('blockedOff')),
149
- ]),
150
523
  h('div', { style: s.rowBody }, [
151
- h('div', { style: s.rowTitle }, [
152
- h('span', { style: s.code }, sec.name),
153
- h('span', { style: s.orderTag }, '#' + index),
154
- h('span', { style: sec.source === 'custom' ? s.badgeCustom : s.badgeSystem }, sec.source === 'custom' ? t('manual') : t('system')),
155
- sec.replaced ? h('span', { style: s.badgeReplaced }, t('replaced')) : null,
156
- ]),
157
- isEditing
158
- ? h('div', { style: s.editBox }, [
159
- h('textarea', { style: s.editInput, value: draft, onChange: (e: ChangeEvent<HTMLTextAreaElement>) => setDraft(e.target.value), rows: 3 }),
160
- h('div', { style: s.injectRow }, [
161
- h('button', { style: s.mini, onClick: () => commitReplace(sec.name) }, t('add')),
162
- h('button', { style: s.mini, onClick: () => setEditing(null) }, t('clearReplace')),
163
- ]),
164
- ])
165
- : h('div', { style: s.preview }, String(sec.text ?? '').slice(0, 140) || (sec.source === 'custom' ? t('empty') : t('dynamic'))),
524
+ h('div', { style: s.rowTitle }, h('span', { style: s.code }, sec.name)),
525
+ h('div', { style: s.preview }, String(sec.text ?? '').slice(0, 140) || t('dynamic')),
166
526
  ]),
167
- h('div', { style: s.arrowCol }, [
168
- h('button', { style: s.arrow, disabled: index === 0, onClick: () => moveOrder(index, -1), title: t('moveUp') }, '↑'),
169
- h('button', { style: s.arrow, disabled: index === merged.length - 1, onClick: () => moveOrder(index, 1), title: t('moveDown') }, '↓'),
170
- ]),
171
- h('button', { style: s.mini, onClick: () => replace[sec.name] ? clearReplace(sec.name) : startReplace(sec.name, String(sec.text ?? '')) }, replace[sec.name] ? t('clearReplace') : t('replace')),
172
- sec.source === 'custom' ? h('button', { style: s.mini, onClick: () => removeCustom(sec.name), title: t('delete') }, t('delete')) : null,
173
- ])
174
- }),
175
- h('div', { style: s.injectBox }, [
176
- h('div', { style: s.rowTitle }, t('injectNew')),
177
- h(InjectForm, { onAdd: addSection, t }),
527
+ ])),
178
528
  ]),
179
529
  ])
180
530
  }
181
531
 
182
- function InjectForm({ onAdd, t }: { onAdd: (name: string, order: number, text: string) => void; t: Translate }): ReactElement {
532
+ /** 注入新段表单:阶段部分内联形态,阶段锁定为所在部分。 */
533
+ function InjectForm({ onAdd, lockedPhase, t }: {
534
+ onAdd: (name: string, text: string) => void
535
+ lockedPhase: 'bootstrap' | 'active' | 'compaction'
536
+ t: Translate
537
+ }): ReactElement {
183
538
  const [name, setName] = useState('')
184
- const [order, setOrder] = useState('120')
185
539
  const [text, setText] = useState('')
186
-
187
540
  const submit = (): void => {
188
541
  if (!name.trim()) return
189
- onAdd(name.trim(), Number(order) || 120, text)
190
- setName(''); setText(''); setOrder('120')
542
+ onAdd(name.trim(), text)
543
+ setName(''); setText('')
191
544
  }
192
-
193
- return h('div', {}, [
194
- h('div', { style: s.injectRow }, [
195
- h('input', { style: { ...s.input, width: '22%' }, placeholder: t('name'), value: name, onChange: (e) => setName(e.target.value) }),
196
- h('input', { style: { ...s.input, width: '12%' }, type: 'number', placeholder: t('order'), value: order, onChange: (e) => setOrder(e.target.value) }),
197
- h('input', { style: { ...s.input, flex: 1 }, placeholder: t('text'), value: text, onChange: (e) => setText(e.target.value) }),
198
- h('button', { style: s.mini, onClick: submit }, t('add')),
199
- ]),
545
+ const phaseLabel = lockedPhase === 'bootstrap' ? t('phaseStageGuide')
546
+ : lockedPhase === 'compaction' ? t('phaseStageControlled')
547
+ : t('phaseStageResident')
548
+ return h('div', { style: s.injectRow }, [
549
+ h('input', { style: { ...s.input, width: '22%' }, placeholder: t('name'), value: name, onChange: (e: ChangeEvent<HTMLInputElement>) => setName(e.target.value) }),
550
+ h('input', { style: { ...s.input, flex: 1 }, placeholder: `${t('text')}(${phaseLabel})`, value: text, onChange: (e: ChangeEvent<HTMLInputElement>) => setText(e.target.value) }),
551
+ h('button', { style: s.mini, onClick: submit }, t('add')),
200
552
  ])
201
553
  }