dsh-vscode-mode 0.22.0 → 0.23.0
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 +5 -3
- package/lib/client.js +3370 -85
- package/lib/client.js.map +2 -2
- package/lib/index.js +1167 -266
- package/lib/index.js.map +1 -1
- package/package.json +1 -1
- package/src/client/index.ts +166 -0
- package/src/client/quickMenuHost.ts +46 -0
- package/src/client/quickMenuStore.ts +260 -0
- package/src/client/state/diffSummary.ts +9 -4
- package/src/client/styles/quickmenu.css +1049 -0
- package/src/client/ui/QuickMenuDialog.tsx +835 -0
- package/src/client/ui/QuickMenuLauncher.tsx +792 -0
- package/src/client/ui/QuickMenuPickers.ts +577 -0
- package/src/index.ts +2 -2
- package/src/paths.ts +10 -0
- package/src/pending/capture.ts +104 -75
- package/src/pending/persist.ts +6 -1
- package/src/pending/store.ts +10 -5
- package/src/pending/vcs.ts +2 -2
- package/src/pendingCompat.ts +3 -8
- package/src/pendingRpc.ts +140 -58
- package/src/quickMenu.ts +269 -0
- package/src/rpc.ts +37 -0
- package/src/shared/pathKey.ts +54 -0
- package/src/shared/quickMenu.ts +1009 -0
- package/src/shared/quickPick.ts +67 -0
- package/src/shared/rpc.ts +10 -0
- package/src/shared/skillCatalog.ts +60 -0
- package/src/skillCatalog.ts +138 -0
|
@@ -0,0 +1,835 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-vscode-mode client — 快捷菜单的配置弹窗(新增/编辑项 + 全局设置)。
|
|
3
|
+
*
|
|
4
|
+
* 设计要点:配置不再有独立设置页,全部从菜单就地入口打开;
|
|
5
|
+
* 表单按「类型」动态取舍字段(组只要名称/归属/优先级,按钮额外要提示词与绑定);
|
|
6
|
+
* 工作区限定沿祖先链继承——父组已限定时本项只读展示继承值,避免出现互相矛盾的配置。
|
|
7
|
+
* 技能与规则都走多选下拉(候选来自目录扫描,不再手输名字);分段与开关统一由 QuickMenuPickers 提供。
|
|
8
|
+
* 作者 ddj 2026年10月30号
|
|
9
|
+
*/
|
|
10
|
+
import React from 'react'
|
|
11
|
+
import { ModalShell } from './ModalShell.js'
|
|
12
|
+
import { PickMulti, PickSeg, PickSwitch, PickWorkspace } from './QuickMenuPickers.js'
|
|
13
|
+
import type { PickGroup, PickOption, PickSegOption } from './QuickMenuPickers.js'
|
|
14
|
+
import type { QuickMenuHost } from '../quickMenuHost.js'
|
|
15
|
+
import {
|
|
16
|
+
QUICK_MENU_DEFAULT_DEPTH,
|
|
17
|
+
QUICK_MENU_MAX_DEPTH,
|
|
18
|
+
QUICK_MENU_MIN_DEPTH,
|
|
19
|
+
QUICK_MENU_NAME_CAP,
|
|
20
|
+
QUICK_MENU_ORDER_CAP,
|
|
21
|
+
QUICK_MENU_PROMPT_CAP,
|
|
22
|
+
bindingKey,
|
|
23
|
+
depthOf,
|
|
24
|
+
descendantIds,
|
|
25
|
+
inheritWorkspaces,
|
|
26
|
+
itemStatus,
|
|
27
|
+
normalizePath,
|
|
28
|
+
parseDoc,
|
|
29
|
+
serializeDoc,
|
|
30
|
+
validateItem,
|
|
31
|
+
validateRule,
|
|
32
|
+
validateSkill,
|
|
33
|
+
} from '../../shared/quickMenu.js'
|
|
34
|
+
import type {
|
|
35
|
+
ClickAction,
|
|
36
|
+
QuickButton,
|
|
37
|
+
QuickGroup,
|
|
38
|
+
QuickItemType,
|
|
39
|
+
QuickMenuItem,
|
|
40
|
+
QuickMenuDoc,
|
|
41
|
+
RuleBinding,
|
|
42
|
+
RuleInjectMode,
|
|
43
|
+
RuleListSnapshot,
|
|
44
|
+
ValidateContext,
|
|
45
|
+
} from '../../shared/quickMenu.js'
|
|
46
|
+
import { groupSkills } from '../../shared/skillCatalog.js'
|
|
47
|
+
import type { SkillCatalog, SkillInfo } from '../../shared/skillCatalog.js'
|
|
48
|
+
|
|
49
|
+
/** 表单项候选(规则下拉的展示与状态)。 */
|
|
50
|
+
interface RuleChoice {
|
|
51
|
+
binding: RuleBinding
|
|
52
|
+
label: string
|
|
53
|
+
hint: string
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** 类型分段的可选值。 */
|
|
57
|
+
const TYPE_OPTIONS: PickSegOption[] = [
|
|
58
|
+
{ value: 'button', label: '按钮' },
|
|
59
|
+
{ value: 'group', label: '组' },
|
|
60
|
+
]
|
|
61
|
+
|
|
62
|
+
/** 规则注入方式分段的可选值。 */
|
|
63
|
+
const MODE_OPTIONS: PickSegOption[] = [
|
|
64
|
+
{ value: 'reference', label: '引用' },
|
|
65
|
+
{ value: 'inline', label: '内联正文' },
|
|
66
|
+
]
|
|
67
|
+
|
|
68
|
+
/** 最大嵌套层数分段的可选值(1–5)。 */
|
|
69
|
+
const DEPTH_OPTIONS: PickSegOption[] = Array.from({ length: QUICK_MENU_MAX_DEPTH }, (_, index) => {
|
|
70
|
+
const depth = QUICK_MENU_MIN_DEPTH + index
|
|
71
|
+
return { value: String(depth), label: String(depth) }
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
/** 技能描述在候选行里的最大长度。 */
|
|
75
|
+
const SKILL_HINT_CAP = 28
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* 截断技能描述,作为候选行的次要说明。
|
|
79
|
+
* @author ddj 2026年10月30号
|
|
80
|
+
* @param description 技能描述
|
|
81
|
+
* @returns 单行摘要
|
|
82
|
+
*/
|
|
83
|
+
function brief(description: string): string | undefined {
|
|
84
|
+
const text = description.trim().replace(/\s+/g, ' ')
|
|
85
|
+
if (text === '') return undefined
|
|
86
|
+
return text.length > SKILL_HINT_CAP ? text.slice(0, SKILL_HINT_CAP) + '…' : text
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* 单个技能候选(状态点来自会话可见性校验)。
|
|
91
|
+
* @author ddj 2026年10月30号
|
|
92
|
+
* @param name 技能名
|
|
93
|
+
* @param ctx 校验上下文
|
|
94
|
+
* @param hint 次要说明
|
|
95
|
+
* @returns 候选项
|
|
96
|
+
*/
|
|
97
|
+
function skillOption(name: string, ctx: ValidateContext, hint?: string): PickOption {
|
|
98
|
+
const status = validateSkill(name, ctx)
|
|
99
|
+
return {
|
|
100
|
+
key: name,
|
|
101
|
+
label: name,
|
|
102
|
+
...(hint ? { hint } : {}),
|
|
103
|
+
...(status.state === 'ok' ? {} : { state: status.state === 'warn' ? 'warn' : undefined }),
|
|
104
|
+
...(status.reason ? { reason: status.reason } : {}),
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* 技能候选分组:全局技能 / 当前工作区技能 / 已绑定但不在候选内。
|
|
110
|
+
* @author ddj 2026年10月30号
|
|
111
|
+
* @param catalog 目录扫描结果(读取失败为 null)
|
|
112
|
+
* @param names 当前会话可见技能名(候选为空时的兜底来源)
|
|
113
|
+
* @param selected 已绑定技能名
|
|
114
|
+
* @param ctx 校验上下文
|
|
115
|
+
* @returns 分组候选
|
|
116
|
+
*/
|
|
117
|
+
function skillGroups(
|
|
118
|
+
catalog: SkillCatalog | null,
|
|
119
|
+
names: string[] | null,
|
|
120
|
+
selected: string[],
|
|
121
|
+
ctx: ValidateContext,
|
|
122
|
+
): PickGroup[] {
|
|
123
|
+
const groups: PickGroup[] = []
|
|
124
|
+
const known = new Set<string>()
|
|
125
|
+
|
|
126
|
+
for (const group of groupSkills(catalog)) {
|
|
127
|
+
const options = group.skills.map((info: SkillInfo) => skillOption(info.name, ctx, brief(info.description)))
|
|
128
|
+
for (const option of options) known.add(option.key)
|
|
129
|
+
if (options.length > 0) groups.push({ key: group.key, label: group.label, options })
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// 目录读不到(旧 host 或权限问题)时退回会话可见技能名,避免下拉变空
|
|
133
|
+
if (groups.length === 0 && names && names.length > 0) {
|
|
134
|
+
const options = names.map((name) => skillOption(name, ctx))
|
|
135
|
+
for (const option of options) known.add(option.key)
|
|
136
|
+
groups.push({ key: 'session', label: '当前会话技能', options })
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const extra = selected.filter((name) => !known.has(name))
|
|
140
|
+
if (extra.length > 0) {
|
|
141
|
+
groups.push({
|
|
142
|
+
key: 'bound',
|
|
143
|
+
label: '已绑定(不在候选内)',
|
|
144
|
+
options: extra.map((name) => skillOption(name, ctx, '候选目录里没有该技能,可取消绑定')),
|
|
145
|
+
})
|
|
146
|
+
}
|
|
147
|
+
return groups
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* 单个规则候选(状态点来自 validateRule)。
|
|
152
|
+
* @author ddj 2026年10月30号
|
|
153
|
+
* @param choice 规则候选
|
|
154
|
+
* @param ctx 校验上下文
|
|
155
|
+
* @returns 候选项
|
|
156
|
+
*/
|
|
157
|
+
function ruleOption(choice: RuleChoice, ctx: ValidateContext): PickOption {
|
|
158
|
+
const status = validateRule(choice.binding, ctx)
|
|
159
|
+
const state = status.state === 'warn' || status.state === 'error' ? status.state : undefined
|
|
160
|
+
return {
|
|
161
|
+
key: bindingKey(choice.binding),
|
|
162
|
+
label: choice.label,
|
|
163
|
+
hint: choice.hint,
|
|
164
|
+
...(state ? { state } : {}),
|
|
165
|
+
...(status.reason ? { reason: status.reason } : {}),
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* 规则候选分组:用户规则 / 当前工作区规则 / 已绑定但已失效。
|
|
171
|
+
* @author ddj 2026年10月30号
|
|
172
|
+
* @param choices 规则候选
|
|
173
|
+
* @param selected 已绑定规则
|
|
174
|
+
* @param ctx 校验上下文
|
|
175
|
+
* @returns 分组候选
|
|
176
|
+
*/
|
|
177
|
+
function ruleGroups(choices: RuleChoice[], selected: RuleBinding[], ctx: ValidateContext): PickGroup[] {
|
|
178
|
+
const known = new Set(choices.map((choice) => bindingKey(choice.binding)))
|
|
179
|
+
const user = choices.filter((choice) => choice.binding.scope === 'user')
|
|
180
|
+
const project = choices.filter((choice) => choice.binding.scope === 'project')
|
|
181
|
+
const groups: PickGroup[] = []
|
|
182
|
+
|
|
183
|
+
if (user.length > 0) {
|
|
184
|
+
groups.push({ key: 'user', label: '用户规则', options: user.map((choice) => ruleOption(choice, ctx)) })
|
|
185
|
+
}
|
|
186
|
+
if (project.length > 0) {
|
|
187
|
+
groups.push({ key: 'project', label: '当前工作区规则', options: project.map((choice) => ruleOption(choice, ctx)) })
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const extra = selected.filter((binding) => !known.has(bindingKey(binding)))
|
|
191
|
+
if (extra.length > 0) {
|
|
192
|
+
groups.push({
|
|
193
|
+
key: 'bound',
|
|
194
|
+
label: '已绑定(规则已不存在)',
|
|
195
|
+
options: extra.map((binding) => {
|
|
196
|
+
const status = validateRule(binding, ctx)
|
|
197
|
+
return {
|
|
198
|
+
key: bindingKey(binding),
|
|
199
|
+
label: binding.file,
|
|
200
|
+
hint: binding.scope === 'user' ? '用户规则' : '项目规则',
|
|
201
|
+
state: status.state === 'warn' ? 'warn' : 'error',
|
|
202
|
+
reason: status.reason ?? '候选里没有该规则,可取消绑定',
|
|
203
|
+
}
|
|
204
|
+
}),
|
|
205
|
+
})
|
|
206
|
+
}
|
|
207
|
+
return groups
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* 构造待保存草稿(编辑时沿用原 id,校验需要它判断自身子树)。
|
|
212
|
+
* @author ddj 2026年10月30号
|
|
213
|
+
* @param id 项 id
|
|
214
|
+
* @param fields 表单字段
|
|
215
|
+
* @returns 菜单项
|
|
216
|
+
*/
|
|
217
|
+
function buildDraft(
|
|
218
|
+
id: string,
|
|
219
|
+
fields: {
|
|
220
|
+
type: QuickItemType
|
|
221
|
+
name: string
|
|
222
|
+
order: number
|
|
223
|
+
parentId: string | null
|
|
224
|
+
workspaces: string[] | null
|
|
225
|
+
prompt: string
|
|
226
|
+
skills: string[]
|
|
227
|
+
rules: RuleBinding[]
|
|
228
|
+
ruleMode: RuleInjectMode
|
|
229
|
+
clickAction: ClickAction | 'inherit'
|
|
230
|
+
},
|
|
231
|
+
): QuickMenuItem {
|
|
232
|
+
const base = {
|
|
233
|
+
id,
|
|
234
|
+
name: fields.name,
|
|
235
|
+
order: fields.order,
|
|
236
|
+
parentId: fields.parentId,
|
|
237
|
+
workspaces: fields.workspaces,
|
|
238
|
+
}
|
|
239
|
+
if (fields.type === 'group') return { ...base, type: 'group' } as QuickGroup
|
|
240
|
+
const button: QuickButton = {
|
|
241
|
+
...base,
|
|
242
|
+
type: 'button',
|
|
243
|
+
prompt: fields.prompt,
|
|
244
|
+
skills: fields.skills,
|
|
245
|
+
rules: fields.rules,
|
|
246
|
+
ruleMode: fields.ruleMode,
|
|
247
|
+
}
|
|
248
|
+
if (fields.clickAction !== 'inherit') button.clickAction = fields.clickAction
|
|
249
|
+
return button
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* 归属组候选(组自身与自身子树不能当父级,否则会成环)。
|
|
254
|
+
* @author ddj 2026年10月30号
|
|
255
|
+
* @param doc 文档
|
|
256
|
+
* @param selfId 正在编辑的项 id
|
|
257
|
+
* @returns 候选(顶层 + 可选组)
|
|
258
|
+
*/
|
|
259
|
+
function groupChoices(doc: QuickMenuDoc, selfId: string): Array<{ id: string; label: string }> {
|
|
260
|
+
const banned = selfId === '' ? [] : descendantIds(doc, selfId)
|
|
261
|
+
const out: Array<{ id: string; label: string }> = [{ id: '', label: '顶层(root 组)' }]
|
|
262
|
+
for (const item of doc.items) {
|
|
263
|
+
if (item.type !== 'group' || banned.includes(item.id)) continue
|
|
264
|
+
out.push({ id: item.id, label: item.name + '(第 ' + depthOf(doc, item.id) + ' 层)' })
|
|
265
|
+
}
|
|
266
|
+
return out
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* 规则候选(用户规则 + 当前工作区的项目规则)。
|
|
271
|
+
* @author ddj 2026年10月30号
|
|
272
|
+
* @param snapshot 规则快照
|
|
273
|
+
* @param cwd 当前工作区
|
|
274
|
+
* @returns 候选列表
|
|
275
|
+
*/
|
|
276
|
+
function ruleChoices(snapshot: RuleListSnapshot | null, cwd?: string): RuleChoice[] {
|
|
277
|
+
if (!snapshot) return []
|
|
278
|
+
const out: RuleChoice[] = []
|
|
279
|
+
for (const info of snapshot.user) {
|
|
280
|
+
const binding: RuleBinding = { scope: 'user', file: info.file }
|
|
281
|
+
if (info.origin) binding.origin = info.origin
|
|
282
|
+
out.push({ binding, label: info.file, hint: '用户规则 · ' + (info.type === 'always' ? '总是生效' : info.type === 'auto' ? '按 globs' : '手动') })
|
|
283
|
+
}
|
|
284
|
+
const wanted = normalizePath(cwd ?? '')
|
|
285
|
+
for (const project of snapshot.projects) {
|
|
286
|
+
if (wanted !== '' && normalizePath(project.workspacePath) !== wanted) continue
|
|
287
|
+
for (const info of project.rules) {
|
|
288
|
+
out.push({
|
|
289
|
+
binding: { scope: 'project', workspacePath: project.workspacePath, file: info.file },
|
|
290
|
+
label: info.file,
|
|
291
|
+
hint: '项目规则 · ' + project.title,
|
|
292
|
+
})
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
return out
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** 分组区块的属性。 */
|
|
299
|
+
interface SectionProps {
|
|
300
|
+
title: string
|
|
301
|
+
hint?: string
|
|
302
|
+
children?: React.ReactNode
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* 表单里的分组区块(标题 + 说明 + 内容),把长表单切成可扫读的几段。
|
|
307
|
+
* @author ddj 2026年10月30号
|
|
308
|
+
* @param props 区块属性
|
|
309
|
+
* @returns 区块元素
|
|
310
|
+
*/
|
|
311
|
+
function Section(props: SectionProps) {
|
|
312
|
+
return React.createElement(
|
|
313
|
+
'div',
|
|
314
|
+
{ className: 'edrv-qm-section' },
|
|
315
|
+
React.createElement(
|
|
316
|
+
'div',
|
|
317
|
+
{ className: 'edrv-qm-section-head' },
|
|
318
|
+
React.createElement('span', { className: 'edrv-qm-section-title' }, props.title),
|
|
319
|
+
props.hint ? React.createElement('span', { className: 'edrv-qm-section-hint' }, props.hint) : null,
|
|
320
|
+
),
|
|
321
|
+
React.createElement('div', { className: 'edrv-qm-section-body' }, props.children),
|
|
322
|
+
)
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** 新建项弹窗的 props。 */
|
|
326
|
+
export interface QuickItemDialogProps {
|
|
327
|
+
host: QuickMenuHost
|
|
328
|
+
/** 编辑已有项时传入(新建省略)。 */
|
|
329
|
+
item?: QuickMenuItem
|
|
330
|
+
/** 新建时的归属组(null = 顶层)。 */
|
|
331
|
+
parentId: string | null
|
|
332
|
+
onClose(): void
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* 新增/编辑菜单项弹窗。
|
|
337
|
+
* @author ddj 2026年10月30号
|
|
338
|
+
* @param props host、待编辑项、父组、关闭回调
|
|
339
|
+
* @returns 弹窗元素
|
|
340
|
+
*/
|
|
341
|
+
export function QuickItemDialog(props: QuickItemDialogProps) {
|
|
342
|
+
const { host, item, parentId } = props
|
|
343
|
+
const doc = host.store.getState().doc
|
|
344
|
+
const editing = item
|
|
345
|
+
const [type, setType] = React.useState<QuickItemType>(editing?.type ?? 'button')
|
|
346
|
+
const [name, setName] = React.useState(editing?.name ?? '')
|
|
347
|
+
const [order, setOrder] = React.useState(editing?.order ?? 100)
|
|
348
|
+
const [parent, setParent] = React.useState<string | null>(editing?.parentId ?? parentId)
|
|
349
|
+
const [workspaces, setWorkspaces] = React.useState<string[] | null>(editing?.workspaces ?? null)
|
|
350
|
+
const [prompt, setPrompt] = React.useState(editing?.type === 'button' ? editing.prompt : '')
|
|
351
|
+
const [skills, setSkills] = React.useState<string[]>(editing?.type === 'button' ? editing.skills.slice() : [])
|
|
352
|
+
const [rules, setRules] = React.useState<RuleBinding[]>(editing?.type === 'button' ? editing.rules.slice() : [])
|
|
353
|
+
const [ruleMode, setRuleMode] = React.useState<RuleInjectMode>(editing?.type === 'button' ? editing.ruleMode : 'reference')
|
|
354
|
+
const [clickAction, setClickAction] = React.useState<ClickAction | 'inherit'>(editing?.type === 'button' ? editing.clickAction ?? 'inherit' : 'inherit')
|
|
355
|
+
const [catalog, setCatalog] = React.useState<SkillCatalog | null>(null)
|
|
356
|
+
const [snapshot, setSnapshot] = React.useState<RuleListSnapshot | null>(null)
|
|
357
|
+
const [skillNames, setSkillNames] = React.useState<string[] | null>(null)
|
|
358
|
+
const [busy, setBusy] = React.useState(false)
|
|
359
|
+
const [error, setError] = React.useState('')
|
|
360
|
+
|
|
361
|
+
React.useEffect(() => {
|
|
362
|
+
let alive = true
|
|
363
|
+
const cwd = host.store.getContext().cwd
|
|
364
|
+
void (async () => {
|
|
365
|
+
const [loadedRules, loadedSkills, loadedCatalog] = await Promise.all([
|
|
366
|
+
host.rules().catch(() => null),
|
|
367
|
+
host.skills().catch(() => null),
|
|
368
|
+
host.skillCatalog(cwd).catch(() => null),
|
|
369
|
+
])
|
|
370
|
+
if (!alive) return
|
|
371
|
+
setSnapshot(loadedRules)
|
|
372
|
+
setSkillNames(loadedSkills)
|
|
373
|
+
setCatalog(loadedCatalog)
|
|
374
|
+
})()
|
|
375
|
+
return () => { alive = false }
|
|
376
|
+
}, [host])
|
|
377
|
+
|
|
378
|
+
const before = host.store.getContext()
|
|
379
|
+
const ctx: ValidateContext = { cwd: before.cwd, rules: snapshot ?? undefined, skillNames }
|
|
380
|
+
const draftId = editing?.id ?? ''
|
|
381
|
+
const draft = buildDraft(draftId === '' ? 'draft' : draftId, {
|
|
382
|
+
type, name, order, parentId: parent, workspaces, prompt, skills, rules, ruleMode, clickAction,
|
|
383
|
+
})
|
|
384
|
+
const status = validateItem(draft, doc)
|
|
385
|
+
const choices = ruleChoices(snapshot, before.cwd)
|
|
386
|
+
const parentItem = parent === null ? null : doc.items.find((entry) => entry.id === parent)
|
|
387
|
+
const inherited = parentItem ? inheritWorkspaces(doc, parentItem) : null
|
|
388
|
+
const workspaceList = host.workspaces()
|
|
389
|
+
const skillPick = skillGroups(catalog, skillNames, skills, ctx)
|
|
390
|
+
const rulePick = ruleGroups(choices, rules, ctx)
|
|
391
|
+
const ruleByKey = new Map(choices.map((choice) => [bindingKey(choice.binding), choice.binding]))
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* 切换技能绑定。
|
|
395
|
+
* @author ddj 2026年10月30号
|
|
396
|
+
* @param target 技能名
|
|
397
|
+
*/
|
|
398
|
+
function toggleSkill(target: string): void {
|
|
399
|
+
setSkills((current) => current.includes(target) ? current.filter((entry) => entry !== target) : current.concat(target))
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* 切换规则绑定(键来自下拉,失效规则也能取消绑定)。
|
|
404
|
+
* @author ddj 2026年10月30号
|
|
405
|
+
* @param key bindingKey 结果
|
|
406
|
+
*/
|
|
407
|
+
function toggleRuleKey(key: string): void {
|
|
408
|
+
const binding = ruleByKey.get(key)
|
|
409
|
+
setRules((current) => {
|
|
410
|
+
if (current.some((entry) => bindingKey(entry) === key)) {
|
|
411
|
+
return current.filter((entry) => bindingKey(entry) !== key)
|
|
412
|
+
}
|
|
413
|
+
return binding ? current.concat(binding) : current
|
|
414
|
+
})
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* 保存(新建或更新);冲突/校验失败时留在弹窗内提示。
|
|
419
|
+
* @author ddj 2026年10月30号
|
|
420
|
+
*/
|
|
421
|
+
async function onSave(): Promise<void> {
|
|
422
|
+
if (status.errors.length > 0) return
|
|
423
|
+
setBusy(true)
|
|
424
|
+
const finalItem = buildDraft(editing?.id ?? host.store.nextId(type), {
|
|
425
|
+
type, name, order, parentId: parent, workspaces, prompt, skills, rules, ruleMode, clickAction,
|
|
426
|
+
})
|
|
427
|
+
const result = editing ? await host.store.updateItem(finalItem) : await host.store.createItem(finalItem)
|
|
428
|
+
setBusy(false)
|
|
429
|
+
if (!result.ok) {
|
|
430
|
+
setError(result.error ?? '保存失败')
|
|
431
|
+
return
|
|
432
|
+
}
|
|
433
|
+
props.onClose()
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* 删除该项及其子项。
|
|
438
|
+
* @author ddj 2026年10月30号
|
|
439
|
+
*/
|
|
440
|
+
async function onDelete(): Promise<void> {
|
|
441
|
+
if (!editing) return
|
|
442
|
+
const suffix = editing.type === 'group' ? '及其全部子项' : ''
|
|
443
|
+
if (!window.confirm('确认删除「' + editing.name + '」' + suffix + '?')) return
|
|
444
|
+
setBusy(true)
|
|
445
|
+
const result = await host.store.removeItem(editing.id)
|
|
446
|
+
setBusy(false)
|
|
447
|
+
if (!result.ok) {
|
|
448
|
+
setError(result.error ?? '删除失败')
|
|
449
|
+
return
|
|
450
|
+
}
|
|
451
|
+
props.onClose()
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* 类型与名称。
|
|
456
|
+
* @author ddj 2026年10月30号
|
|
457
|
+
* @returns 表单行
|
|
458
|
+
*/
|
|
459
|
+
function basicRow(): React.ReactNode {
|
|
460
|
+
return React.createElement('div', { className: 'edrv-qm-row2' },
|
|
461
|
+
React.createElement('div', { className: 'edrv-qm-field edrv-qm-field--narrow' },
|
|
462
|
+
React.createElement('span', { className: 'edrv-qm-field-label' }, '类型'),
|
|
463
|
+
React.createElement(PickSeg, {
|
|
464
|
+
options: TYPE_OPTIONS,
|
|
465
|
+
value: type,
|
|
466
|
+
label: '菜单项类型',
|
|
467
|
+
onChange: (next) => setType(next as QuickItemType),
|
|
468
|
+
})),
|
|
469
|
+
React.createElement('label', { className: 'edrv-qm-field' },
|
|
470
|
+
React.createElement('span', { className: 'edrv-qm-field-label' }, '名称'),
|
|
471
|
+
React.createElement('input', {
|
|
472
|
+
className: 'edrv-qm-input', value: name, maxLength: QUICK_MENU_NAME_CAP,
|
|
473
|
+
placeholder: type === 'group' ? '例如:代码审查' : '例如:审查当前改动',
|
|
474
|
+
onChange: (event) => setName(event.target.value),
|
|
475
|
+
})))
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* 归属组与同级优先级。
|
|
480
|
+
* @author ddj 2026年10月30号
|
|
481
|
+
* @returns 表单行
|
|
482
|
+
*/
|
|
483
|
+
function placeRow(): React.ReactNode {
|
|
484
|
+
return React.createElement('div', { className: 'edrv-qm-row2' },
|
|
485
|
+
React.createElement('label', { className: 'edrv-qm-field' },
|
|
486
|
+
React.createElement('span', { className: 'edrv-qm-field-label' }, '归属组'),
|
|
487
|
+
React.createElement('select', {
|
|
488
|
+
className: 'edrv-qm-select', value: parent ?? '',
|
|
489
|
+
onChange: (event: React.ChangeEvent<HTMLSelectElement>) => setParent(event.target.value === '' ? null : event.target.value),
|
|
490
|
+
}, groupChoices(doc, draftId).map((choice) => React.createElement('option', { key: choice.id, value: choice.id }, choice.label)))),
|
|
491
|
+
React.createElement('div', { className: 'edrv-qm-field' },
|
|
492
|
+
React.createElement('span', { className: 'edrv-qm-field-label' }, '同级优先级(越小越靠前)'),
|
|
493
|
+
React.createElement('div', { className: 'edrv-qm-inline' },
|
|
494
|
+
React.createElement('input', {
|
|
495
|
+
className: 'edrv-qm-input edrv-qm-input--order', type: 'number', min: 0, max: QUICK_MENU_ORDER_CAP, value: order,
|
|
496
|
+
onChange: (event) => setOrder(Number(event.target.value) || 0),
|
|
497
|
+
}),
|
|
498
|
+
React.createElement('button', { type: 'button', className: 'edrv-qm-btn edrv-qm-btn--sm', onClick: () => setOrder((value) => Math.max(0, value - 10)) }, '上移'),
|
|
499
|
+
React.createElement('button', { type: 'button', className: 'edrv-qm-btn edrv-qm-btn--sm', onClick: () => setOrder((value) => Math.min(QUICK_MENU_ORDER_CAP, value + 10)) }, '下移'))))
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/**
|
|
503
|
+
* 限定工作区(继承上级组时只读)。
|
|
504
|
+
* @author ddj 2026年10月30号
|
|
505
|
+
* @returns 表单行
|
|
506
|
+
*/
|
|
507
|
+
function workspaceField(): React.ReactNode {
|
|
508
|
+
return React.createElement('div', { className: 'edrv-qm-field' },
|
|
509
|
+
React.createElement('div', { className: 'edrv-qm-field-head' },
|
|
510
|
+
React.createElement('span', { className: 'edrv-qm-field-label' }, '限定工作区'),
|
|
511
|
+
React.createElement('span', { className: 'edrv-qm-field-hint' }, inherited
|
|
512
|
+
? '遵循上级组:' + inherited.join('、') + '(此项不可编辑)'
|
|
513
|
+
: '默认全部工作区可见,可切到当前工作区或展开更多')),
|
|
514
|
+
React.createElement(PickWorkspace, {
|
|
515
|
+
buttonId: 'edrv-qm-pick-workspaces',
|
|
516
|
+
workspaces: workspaceList,
|
|
517
|
+
selected: inherited ?? workspaces,
|
|
518
|
+
cwd: before.cwd,
|
|
519
|
+
inherited: inherited !== null,
|
|
520
|
+
inheritedHint: '遵循上级组,此项不可编辑',
|
|
521
|
+
onChange: (next) => setWorkspaces(next),
|
|
522
|
+
}))
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/**
|
|
526
|
+
* 提示词输入。
|
|
527
|
+
* @author ddj 2026年10月30号
|
|
528
|
+
* @returns 表单行
|
|
529
|
+
*/
|
|
530
|
+
function promptField(): React.ReactNode {
|
|
531
|
+
return React.createElement('div', { className: 'edrv-qm-field' },
|
|
532
|
+
React.createElement('div', { className: 'edrv-qm-field-head' },
|
|
533
|
+
React.createElement('span', { className: 'edrv-qm-field-label' }, '预填提示词'),
|
|
534
|
+
React.createElement('span', { className: 'edrv-qm-field-hint' }, '占位符:{{workspace}} {{selection}} {{date}} {{rules}} {{skills}}')),
|
|
535
|
+
React.createElement('textarea', {
|
|
536
|
+
className: 'edrv-qm-textarea', value: prompt, maxLength: QUICK_MENU_PROMPT_CAP,
|
|
537
|
+
placeholder: '点击后发送给 AI 的内容…',
|
|
538
|
+
onChange: (event: React.ChangeEvent<HTMLTextAreaElement>) => setPrompt(event.target.value),
|
|
539
|
+
}))
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/**
|
|
543
|
+
* 绑定技能(多选下拉,候选来自全局与工作区技能目录)。
|
|
544
|
+
* @author ddj 2026年10月30号
|
|
545
|
+
* @returns 表单行
|
|
546
|
+
*/
|
|
547
|
+
function skillField(): React.ReactNode {
|
|
548
|
+
return React.createElement('div', { className: 'edrv-qm-field' },
|
|
549
|
+
React.createElement('div', { className: 'edrv-qm-field-head' },
|
|
550
|
+
React.createElement('span', { className: 'edrv-qm-field-label' }, '绑定技能'),
|
|
551
|
+
React.createElement('span', { className: 'edrv-qm-field-hint' }, '发送时写成 /技能名;黄点=当前会话不可见')),
|
|
552
|
+
React.createElement(PickMulti, {
|
|
553
|
+
buttonId: 'edrv-qm-pick-skills',
|
|
554
|
+
groups: skillPick,
|
|
555
|
+
selected: skills,
|
|
556
|
+
unit: '个技能',
|
|
557
|
+
emptyText: '没有可选技能(全局技能目录与当前工作区 .dsh/skills 均为空)',
|
|
558
|
+
onToggle: toggleSkill,
|
|
559
|
+
}))
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
/**
|
|
563
|
+
* 绑定规则(多选下拉 + 引用/内联切换)。
|
|
564
|
+
* @author ddj 2026年10月30号
|
|
565
|
+
* @returns 表单行
|
|
566
|
+
*/
|
|
567
|
+
function ruleField(): React.ReactNode {
|
|
568
|
+
return React.createElement('div', { className: 'edrv-qm-field' },
|
|
569
|
+
React.createElement('div', { className: 'edrv-qm-field-head' },
|
|
570
|
+
React.createElement('span', { className: 'edrv-qm-field-label' }, '绑定规则'),
|
|
571
|
+
React.createElement(PickSeg, {
|
|
572
|
+
options: MODE_OPTIONS,
|
|
573
|
+
value: ruleMode,
|
|
574
|
+
label: '规则注入方式',
|
|
575
|
+
onChange: (next) => setRuleMode(next as RuleInjectMode),
|
|
576
|
+
})),
|
|
577
|
+
React.createElement(PickMulti, {
|
|
578
|
+
buttonId: 'edrv-qm-pick-rules',
|
|
579
|
+
groups: rulePick,
|
|
580
|
+
selected: rules.map((binding) => bindingKey(binding)),
|
|
581
|
+
unit: '条规则',
|
|
582
|
+
emptyText: '没有可选规则(可在规则面板新建 .mdc 规则)',
|
|
583
|
+
onToggle: toggleRuleKey,
|
|
584
|
+
}))
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
/**
|
|
588
|
+
* 点击动作(跟随全局 / 直接发送 / 只填入)。
|
|
589
|
+
* @author ddj 2026年10月30号
|
|
590
|
+
* @returns 表单行
|
|
591
|
+
*/
|
|
592
|
+
function clickField(): React.ReactNode {
|
|
593
|
+
return React.createElement('label', { className: 'edrv-qm-field' },
|
|
594
|
+
React.createElement('span', { className: 'edrv-qm-field-label' }, '点击动作'),
|
|
595
|
+
React.createElement('select', {
|
|
596
|
+
className: 'edrv-qm-select', value: clickAction,
|
|
597
|
+
onChange: (event: React.ChangeEvent<HTMLSelectElement>) => setClickAction(event.target.value as ClickAction | 'inherit'),
|
|
598
|
+
},
|
|
599
|
+
React.createElement('option', { value: 'inherit' }, '跟随全局'),
|
|
600
|
+
React.createElement('option', { value: 'send' }, '直接发送'),
|
|
601
|
+
React.createElement('option', { value: 'fill' }, '只填入输入框')))
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
// 标题从滚动区里提出来做成固定头:长表单往下滚时仍知道自己在编辑什么
|
|
605
|
+
const parentName = parent === null ? null : (doc.items.find((entry) => entry.id === parent)?.name ?? null)
|
|
606
|
+
const typeLabel = type === 'group' ? '组' : '按钮'
|
|
607
|
+
const headTitle = editing ? '编辑「' + editing.name + '」' : parent === null ? '新建顶层项' : '新建子项'
|
|
608
|
+
const headSub = (editing ? (editing.type === 'group' ? '组' : '按钮') : typeLabel)
|
|
609
|
+
+ (parentName ? ' · 位于「' + parentName + '」' : ' · 位于顶层')
|
|
610
|
+
|
|
611
|
+
const header = React.createElement('div', { className: 'edrv-qm-head' },
|
|
612
|
+
React.createElement('div', { className: 'edrv-qm-head-title' }, headTitle),
|
|
613
|
+
React.createElement('div', { className: 'edrv-qm-head-sub' }, headSub))
|
|
614
|
+
|
|
615
|
+
const body = React.createElement('div', { className: 'edrv-qm-dialog-body' },
|
|
616
|
+
React.createElement(Section, { title: '基本信息' }, basicRow(), placeRow(), workspaceField()),
|
|
617
|
+
type === 'button' ? React.createElement(Section, { title: '提示词', hint: '点击后发给 AI 的内容' }, promptField()) : null,
|
|
618
|
+
type === 'button' ? React.createElement(Section, { title: '绑定' }, skillField(), ruleField()) : null,
|
|
619
|
+
type === 'button' ? React.createElement(Section, { title: '行为' }, clickField()) : null,
|
|
620
|
+
error !== '' ? React.createElement('div', { className: 'edrv-qm-error-list' }, error) : null,
|
|
621
|
+
status.errors.length > 0 ? React.createElement('ul', { className: 'edrv-qm-error-list' }, status.errors.map((text) => React.createElement('li', { key: text }, text))) : null,
|
|
622
|
+
status.warnings.length > 0 ? React.createElement('ul', { className: 'edrv-qm-warn-list' }, status.warnings.map((text) => React.createElement('li', { key: text }, text))) : null,
|
|
623
|
+
)
|
|
624
|
+
|
|
625
|
+
const actions = React.createElement('div', { className: 'edrv-qm-actions' },
|
|
626
|
+
editing ? React.createElement('button', { type: 'button', className: 'edrv-qm-btn edrv-qm-btn--danger', disabled: busy, onClick: () => { void onDelete() } }, '删除') : null,
|
|
627
|
+
React.createElement('span', { className: 'edrv-qm-actions-grow' }),
|
|
628
|
+
React.createElement('button', { type: 'button', className: 'edrv-qm-btn', onClick: props.onClose }, '取消'),
|
|
629
|
+
React.createElement('button', {
|
|
630
|
+
type: 'button', className: 'edrv-qm-btn edrv-qm-btn--primary', disabled: busy || status.errors.length > 0,
|
|
631
|
+
onClick: () => { void onSave() },
|
|
632
|
+
}, busy ? '保存中…' : editing ? '保存' : '创建'))
|
|
633
|
+
|
|
634
|
+
return React.createElement(ModalShell, {
|
|
635
|
+
dialogClass: 'edrv-qm-dialog', zIndex: 220,
|
|
636
|
+
onClose: props.onClose, closeOnMask: false,
|
|
637
|
+
}, header, body, actions)
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
/** 全局设置弹窗 props。 */
|
|
641
|
+
export interface MenuGlobalDialogProps {
|
|
642
|
+
host: QuickMenuHost
|
|
643
|
+
onClose(): void
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
/**
|
|
647
|
+
* 菜单全局设置(显隐、点击动作、最大嵌套层数、位置重置、导入导出、失效绑定清理)。
|
|
648
|
+
* @author ddj 2026年10月30号
|
|
649
|
+
* @param props host 与关闭回调
|
|
650
|
+
* @returns 弹窗元素
|
|
651
|
+
*/
|
|
652
|
+
export function MenuGlobalDialog(props: MenuGlobalDialogProps) {
|
|
653
|
+
const { host } = props
|
|
654
|
+
const doc = host.store.getState().doc
|
|
655
|
+
const [enabled, setEnabled] = React.useState(doc.enabled)
|
|
656
|
+
const [clickAction, setClickAction] = React.useState<ClickAction>(doc.clickAction)
|
|
657
|
+
const [maxDepth, setMaxDepth] = React.useState(doc.maxDepth || QUICK_MENU_DEFAULT_DEPTH)
|
|
658
|
+
const [busy, setBusy] = React.useState(false)
|
|
659
|
+
const [error, setError] = React.useState('')
|
|
660
|
+
const ctx = host.store.getContext()
|
|
661
|
+
const broken = collectBroken(doc, ctx)
|
|
662
|
+
const fileRef = React.useRef<HTMLInputElement>(null)
|
|
663
|
+
|
|
664
|
+
/**
|
|
665
|
+
* 保存全局设置。
|
|
666
|
+
* @author ddj 2026年10月30号
|
|
667
|
+
*/
|
|
668
|
+
async function onSave(): Promise<void> {
|
|
669
|
+
setBusy(true)
|
|
670
|
+
const result = await host.store.save({ ...doc, enabled, clickAction, maxDepth })
|
|
671
|
+
setBusy(false)
|
|
672
|
+
if (!result.ok) {
|
|
673
|
+
setError(result.error ?? '保存失败')
|
|
674
|
+
return
|
|
675
|
+
}
|
|
676
|
+
props.onClose()
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
/**
|
|
680
|
+
* 清理全部失效绑定(移除失效规则/技能,保留提示词)。
|
|
681
|
+
* @author ddj 2026年10月30号
|
|
682
|
+
*/
|
|
683
|
+
async function onClean(): Promise<void> {
|
|
684
|
+
setBusy(true)
|
|
685
|
+
const items = doc.items.map((item) => {
|
|
686
|
+
if (item.type !== 'button') return item
|
|
687
|
+
const rules = item.rules.filter((binding) => validateRule(binding, ctx).state === 'ok')
|
|
688
|
+
const skills = item.skills.filter((name) => validateSkill(name, ctx).state === 'ok')
|
|
689
|
+
return { ...item, rules, skills }
|
|
690
|
+
})
|
|
691
|
+
const result = await host.store.save({ ...doc, enabled, clickAction, maxDepth, items })
|
|
692
|
+
setBusy(false)
|
|
693
|
+
if (!result.ok) setError(result.error ?? '清理失败')
|
|
694
|
+
else host.notify('已清理失效绑定')
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
/**
|
|
698
|
+
* 配置备份与迁移(导出 / 导入 / 重置按钮位置)。
|
|
699
|
+
* @author ddj 2026年10月30号
|
|
700
|
+
* @returns 按钮行
|
|
701
|
+
*/
|
|
702
|
+
function backupRow(): React.ReactNode {
|
|
703
|
+
// 导入用真按钮触发隐藏的 file input:label 会被弹窗样式(display:flex + margin)改盒模型,导致与相邻按钮错位
|
|
704
|
+
return React.createElement('div', { className: 'edrv-qm-btn-row' },
|
|
705
|
+
React.createElement('input', {
|
|
706
|
+
ref: fileRef, type: 'file', accept: 'application/json', style: { display: 'none' },
|
|
707
|
+
onChange: (event) => { void onImport(event, host, setError) },
|
|
708
|
+
}),
|
|
709
|
+
React.createElement('button', { type: 'button', className: 'edrv-qm-btn', onClick: () => downloadDoc(doc) }, '导出 JSON'),
|
|
710
|
+
React.createElement('button', {
|
|
711
|
+
type: 'button', className: 'edrv-qm-btn',
|
|
712
|
+
onClick: () => fileRef.current?.click(),
|
|
713
|
+
}, '导入 JSON'),
|
|
714
|
+
React.createElement('button', {
|
|
715
|
+
type: 'button', className: 'edrv-qm-btn',
|
|
716
|
+
onClick: () => { host.store.patchLauncher(null, null); void host.store.flushLauncher(); host.notify('已重置悬浮按钮位置') },
|
|
717
|
+
}, '重置按钮位置'))
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
const header = React.createElement('div', { className: 'edrv-qm-head' },
|
|
721
|
+
React.createElement('div', { className: 'edrv-qm-head-title' }, '菜单设置'),
|
|
722
|
+
React.createElement('div', { className: 'edrv-qm-head-sub' }, '悬浮按钮、交互行为与配置迁移'))
|
|
723
|
+
|
|
724
|
+
const body = React.createElement('div', { className: 'edrv-qm-dialog-body' },
|
|
725
|
+
React.createElement(Section, { title: '外观' },
|
|
726
|
+
React.createElement(PickSwitch, {
|
|
727
|
+
buttonId: 'edrv-qm-switch-enabled',
|
|
728
|
+
label: '显示悬浮按钮',
|
|
729
|
+
hint: '关闭后左侧不再显示快捷菜单按钮,配置会保留',
|
|
730
|
+
checked: enabled,
|
|
731
|
+
onChange: setEnabled,
|
|
732
|
+
})),
|
|
733
|
+
React.createElement(Section, { title: '交互' },
|
|
734
|
+
React.createElement('label', { className: 'edrv-qm-field' },
|
|
735
|
+
React.createElement('span', { className: 'edrv-qm-field-label' }, '点击默认动作'),
|
|
736
|
+
React.createElement('select', {
|
|
737
|
+
className: 'edrv-qm-select', value: clickAction,
|
|
738
|
+
onChange: (event: React.ChangeEvent<HTMLSelectElement>) => setClickAction(event.target.value as ClickAction),
|
|
739
|
+
},
|
|
740
|
+
React.createElement('option', { value: 'send' }, '直接发送给 AI'),
|
|
741
|
+
React.createElement('option', { value: 'fill' }, '只填入输入框'))),
|
|
742
|
+
React.createElement('div', { className: 'edrv-qm-field' },
|
|
743
|
+
React.createElement('div', { className: 'edrv-qm-field-head' },
|
|
744
|
+
React.createElement('span', { className: 'edrv-qm-field-label' }, '最大嵌套层数'),
|
|
745
|
+
React.createElement('span', { className: 'edrv-qm-field-hint' }, 'root 列里的项是第 1 层;调小不删除已有深层项,只禁止继续新增')),
|
|
746
|
+
React.createElement(PickSeg, {
|
|
747
|
+
options: DEPTH_OPTIONS,
|
|
748
|
+
value: String(maxDepth),
|
|
749
|
+
label: '最大嵌套层数',
|
|
750
|
+
onChange: (next) => setMaxDepth(Number(next) || QUICK_MENU_DEFAULT_DEPTH),
|
|
751
|
+
}))),
|
|
752
|
+
React.createElement(Section, { title: '配置备份与迁移' }, backupRow()),
|
|
753
|
+
React.createElement(Section, {
|
|
754
|
+
title: '绑定状态',
|
|
755
|
+
hint: broken.length === 0 ? '全部可用' : String(broken.length) + ' 项失效',
|
|
756
|
+
},
|
|
757
|
+
broken.length > 0 ? React.createElement('ul', { className: 'edrv-qm-error-list' },
|
|
758
|
+
broken.map((entry) => React.createElement('li', { key: entry.key }, entry.label))) : null,
|
|
759
|
+
broken.length > 0 ? React.createElement('div', { className: 'edrv-qm-btn-row' },
|
|
760
|
+
React.createElement('button', { type: 'button', className: 'edrv-qm-btn', disabled: busy, onClick: () => { void onClean() } }, '清理失效绑定')) : null),
|
|
761
|
+
error !== '' ? React.createElement('div', { className: 'edrv-qm-error-list' }, error) : null,
|
|
762
|
+
React.createElement('div', { className: 'edrv-qm-field-hint' }, '左侧按钮 / 菜单项'
|
|
763
|
+
+ (host.store.isMounted() ? '(当前界面已挂载)' : '(当前 DSH 未提供浮层槽,按钮不显示)')),
|
|
764
|
+
)
|
|
765
|
+
|
|
766
|
+
return React.createElement(ModalShell, {
|
|
767
|
+
dialogClass: 'edrv-qm-dialog', zIndex: 220, onClose: props.onClose, closeOnMask: false,
|
|
768
|
+
}, header, body,
|
|
769
|
+
React.createElement('div', { className: 'edrv-qm-actions' },
|
|
770
|
+
React.createElement('span', { className: 'edrv-qm-actions-grow' }),
|
|
771
|
+
React.createElement('button', { type: 'button', className: 'edrv-qm-btn', onClick: props.onClose }, '取消'),
|
|
772
|
+
React.createElement('button', { type: 'button', className: 'edrv-qm-btn edrv-qm-btn--primary', disabled: busy, onClick: () => { void onSave() } }, busy ? '保存中…' : '保存')))
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
/**
|
|
776
|
+
* 收集全菜单的失效绑定(设置弹窗顶部汇总)。
|
|
777
|
+
* @author ddj 2026年10月30号
|
|
778
|
+
* @param doc 文档
|
|
779
|
+
* @param ctx 校验上下文
|
|
780
|
+
* @returns 失效条目文案
|
|
781
|
+
*/
|
|
782
|
+
function collectBroken(doc: QuickMenuDoc, ctx: ValidateContext): Array<{ key: string; label: string }> {
|
|
783
|
+
const out: Array<{ key: string; label: string }> = []
|
|
784
|
+
for (const item of doc.items) {
|
|
785
|
+
if (item.type !== 'button') continue
|
|
786
|
+
const status = itemStatus(item, ctx)
|
|
787
|
+
status.errors.concat(status.warnings).forEach((reason, index) => {
|
|
788
|
+
out.push({ key: item.id + ':' + index, label: item.name + ' → ' + reason })
|
|
789
|
+
})
|
|
790
|
+
}
|
|
791
|
+
return out
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
/**
|
|
795
|
+
* 下载当前文档为 JSON(浏览器侧 Blob,不经 host)。
|
|
796
|
+
* @author ddj 2026年10月30号
|
|
797
|
+
* @param doc 文档
|
|
798
|
+
*/
|
|
799
|
+
function downloadDoc(doc: QuickMenuDoc): void {
|
|
800
|
+
const blob = new Blob([serializeDoc(doc)], { type: 'application/json' })
|
|
801
|
+
const url = URL.createObjectURL(blob)
|
|
802
|
+
const anchor = document.createElement('a')
|
|
803
|
+
anchor.href = url
|
|
804
|
+
anchor.download = 'quick-menu.json'
|
|
805
|
+
anchor.click()
|
|
806
|
+
setTimeout(() => URL.revokeObjectURL(url), 2000)
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
/**
|
|
810
|
+
* 导入 JSON(解析失败/校验不通过时给错误文案)。
|
|
811
|
+
* @author ddj 2026年10月30号
|
|
812
|
+
* @param event 文件输入事件
|
|
813
|
+
* @param host 宿主
|
|
814
|
+
* @param setError 错误回写
|
|
815
|
+
*/
|
|
816
|
+
async function onImport(event: React.ChangeEvent<HTMLInputElement>, host: QuickMenuHost, setError: (text: string) => void): Promise<void> {
|
|
817
|
+
const file = event.target.files?.[0]
|
|
818
|
+
event.target.value = ''
|
|
819
|
+
if (!file) return
|
|
820
|
+
let text = ''
|
|
821
|
+
try {
|
|
822
|
+
text = await file.text()
|
|
823
|
+
} catch (error) {
|
|
824
|
+
setError('读取文件失败:' + String(error))
|
|
825
|
+
return
|
|
826
|
+
}
|
|
827
|
+
const parsed = parseDoc(text)
|
|
828
|
+
if (parsed.warning) {
|
|
829
|
+
setError(parsed.warning)
|
|
830
|
+
return
|
|
831
|
+
}
|
|
832
|
+
const result = await host.store.save(parsed.doc)
|
|
833
|
+
if (!result.ok) setError(result.error ?? '导入失败')
|
|
834
|
+
else host.notify('已导入快捷菜单配置')
|
|
835
|
+
}
|