@szc-ft/mcp-szcd-client 0.27.4 → 0.28.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.
@@ -0,0 +1,716 @@
1
+ /**
2
+ * Ant Design 组件适配层
3
+ *
4
+ * 设计目标:把"触发器定位 → 浮层等待 → 选项交互 → VERIFY → 全局清理"四步固化,
5
+ * 解决 6/22 反馈中用户绕开 act 自己手写 pickSelect 的根因。
6
+ *
7
+ * 关键约束(来自反馈):
8
+ * - Ant Design Select 必须用 mousedown 触发(click 不工作)
9
+ * - 浮层渲染在 document.body 末尾,不在 form-item 子树
10
+ * - 选项异步渲染需 15×200ms 轮询
11
+ * - 每次操作前后必须 closeAllDropdowns 防串选
12
+ * - 通过 .ant-select-selection-item[title] 验证回写
13
+ *
14
+ * 与 szcd 私有组件库集成:
15
+ * 构造函数接收 componentMeta(来自 MCP get_architecture_overview("full")),
16
+ * szcd 私有组件可注入自定义 selector 规则。例:szcd 的 Query 组件包了一层 .szc-query
17
+ * 外壳,通过 meta 自定义匹配规则。
18
+ */
19
+
20
+ const POLL_TIMEOUT = 3000;
21
+ const POLL_INTERVAL = 200;
22
+
23
+ /**
24
+ * 在 frame 上执行带超时的轮询 evaluate
25
+ */
26
+ async function pollEval(frame, fn, args, options = {}) {
27
+ const timeout = options.timeout || POLL_TIMEOUT;
28
+ const interval = options.interval || POLL_INTERVAL;
29
+ const start = Date.now();
30
+ let last;
31
+ while (Date.now() - start < timeout) {
32
+ try {
33
+ last = await frame.evaluate(fn, args);
34
+ if (last) return { ok: true, value: last, duration: Date.now() - start };
35
+ } catch (err) {
36
+ last = { error: err.message };
37
+ }
38
+ await new Promise((r) => setTimeout(r, interval));
39
+ }
40
+ return { ok: false, value: last, duration: Date.now() - start, timedOut: true };
41
+ }
42
+
43
+ export class AntAdapter {
44
+ /**
45
+ * @param {object} options - { componentMeta }
46
+ * componentMeta - 可选,来自 szcd MCP 的组件元信息,用于私有组件 selector 覆盖
47
+ */
48
+ constructor(options = {}) {
49
+ this.componentMeta = options.componentMeta || {};
50
+ }
51
+
52
+ /**
53
+ * 关闭所有打开的下拉浮层 / 抽屉 / 弹窗
54
+ * 反馈第 2 条:每次 Select 操作后必须调用,否则下次操作会命中残留选项
55
+ */
56
+ async closeAllDropdowns(frame) {
57
+ return frame.evaluate(() => {
58
+ // 1) 移除 ant-select-open 状态 + 触发 ESC
59
+ document.querySelectorAll(".ant-select-open").forEach((el) => {
60
+ el.classList.remove("ant-select-open");
61
+ });
62
+ // 2) 隐藏所有未隐藏的下拉浮层
63
+ const dropdowns = document.querySelectorAll(
64
+ "body > .ant-select-dropdown:not(.ant-select-dropdown-hidden), " +
65
+ "body > .ant-cascader-dropdown:not(.ant-cascader-dropdown-hidden), " +
66
+ "body > .ant-picker-dropdown:not(.ant-picker-dropdown-hidden)"
67
+ );
68
+ const count = dropdowns.length;
69
+ dropdowns.forEach((el) => {
70
+ el.classList.add("ant-select-dropdown-hidden");
71
+ el.classList.add("ant-cascader-dropdown-hidden");
72
+ el.classList.add("ant-picker-dropdown-hidden");
73
+ });
74
+ // 3) 派发 ESC keydown 让 antd 自己处理
75
+ document.body.dispatchEvent(new KeyboardEvent("keydown", {
76
+ key: "Escape", code: "Escape", keyCode: 27, which: 27, bubbles: true,
77
+ }));
78
+ return { closed: count };
79
+ }).catch(() => ({ closed: 0 }));
80
+ }
81
+
82
+ /**
83
+ * 定位包含指定 label 的 .ant-form-item,返回其内 control 元素的 selector 信息
84
+ * 不直接返回 ElementHandle(跨 evaluate 边界丢失),而是返回一个临时标记 id
85
+ */
86
+ async _markFormItemControl(frame, labelText, controlHint) {
87
+ return frame.evaluate(({ label, hint }) => {
88
+ const items = Array.from(document.querySelectorAll(".ant-form-item"));
89
+ const item = items.find((el) => {
90
+ const lbl = el.querySelector(".ant-form-item-label label, .ant-form-item-label");
91
+ if (!lbl) return false;
92
+ return (lbl.innerText || lbl.textContent || "").trim().includes(label);
93
+ });
94
+ if (!item) return { found: false };
95
+
96
+ // 找控件:根据 hint 决定优先匹配何种节点
97
+ let control;
98
+ if (hint === "select") {
99
+ control = item.querySelector(".ant-select:not(.ant-select-disabled)");
100
+ } else if (hint === "treeSelect") {
101
+ control = item.querySelector(".ant-select.ant-tree-select, .ant-tree-select");
102
+ } else if (hint === "cascader") {
103
+ control = item.querySelector(".ant-cascader, .ant-cascader-picker");
104
+ } else if (hint === "datePicker") {
105
+ control = item.querySelector(".ant-picker");
106
+ } else if (hint === "input") {
107
+ control = item.querySelector("input.ant-input, textarea.ant-input, .ant-input-affix-wrapper input");
108
+ } else if (hint === "radio") {
109
+ control = item.querySelector(".ant-radio-group");
110
+ } else if (hint === "checkbox") {
111
+ control = item.querySelector(".ant-checkbox-group, .ant-checkbox-wrapper");
112
+ } else if (hint === "switch") {
113
+ control = item.querySelector(".ant-switch");
114
+ } else if (hint === "upload") {
115
+ control = item.querySelector(".ant-upload input[type='file'], input[type='file']");
116
+ } else {
117
+ control = item.querySelector("input, .ant-select, .ant-picker, .ant-cascader");
118
+ }
119
+
120
+ if (!control) return { found: true, hasControl: false };
121
+
122
+ // 用临时 data 属性标记,避免重复 traversal
123
+ const marker = `__sz_marker_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
124
+ control.setAttribute("data-sz-marker", marker);
125
+ item.setAttribute("data-sz-form-item-marker", marker);
126
+ const rect = control.getBoundingClientRect();
127
+ return {
128
+ found: true,
129
+ hasControl: true,
130
+ marker,
131
+ tagName: control.tagName,
132
+ visible: rect.width > 0 && rect.height > 0,
133
+ };
134
+ }, { label: labelText, hint: controlHint });
135
+ }
136
+
137
+ async _clearMarkers(frame) {
138
+ return frame.evaluate(() => {
139
+ document.querySelectorAll("[data-sz-marker]").forEach((el) => el.removeAttribute("data-sz-marker"));
140
+ document.querySelectorAll("[data-sz-form-item-marker]").forEach((el) => el.removeAttribute("data-sz-form-item-marker"));
141
+ }).catch(() => {});
142
+ }
143
+
144
+ /**
145
+ * Ant Select 操作
146
+ * @param {Frame} frame
147
+ * @param {string} fieldLabel - 表单字段 label 文本
148
+ * @param {string|string[]} option - 要选择的选项文本(多选模式可传数组)
149
+ * @param {object} opts - { mode, timeout, allowCreate, popupFrame }
150
+ * popupFrame - 可选,浮层渲染所在的 frame(用于微前端 getPopupContainer 跨 frame 场景)
151
+ */
152
+ async select(frame, fieldLabel, option, opts = {}) {
153
+ const options = Array.isArray(option) ? option : [option];
154
+ const mode = opts.mode || "single";
155
+ const timeout = opts.timeout || POLL_TIMEOUT;
156
+ // 微前端:浮层可能渲染到父文档(通过 getPopupContainer({ getContainer: () => parent.document.body }))
157
+ const popupFrame = opts.popupFrame || frame;
158
+
159
+ // 0) 预清理:同时清理触发器 frame 和浮层 frame
160
+ await this.closeAllDropdowns(frame);
161
+ if (popupFrame !== frame) await this.closeAllDropdowns(popupFrame);
162
+
163
+ // 1) 定位 form-item 的 .ant-select 控件(在 trigger frame)
164
+ const mark = await this._markFormItemControl(frame, fieldLabel, "select");
165
+ if (!mark.found) {
166
+ await this._clearMarkers(frame);
167
+ return { acted: false, reason: `form item with label "${fieldLabel}" not found` };
168
+ }
169
+ if (!mark.hasControl) {
170
+ await this._clearMarkers(frame);
171
+ return { acted: false, reason: `no .ant-select control inside form item "${fieldLabel}"` };
172
+ }
173
+
174
+ // 2) 触发器 mousedown 打开下拉
175
+ const triggerOk = await frame.evaluate(({ marker }) => {
176
+ const el = document.querySelector(`[data-sz-marker="${marker}"]`);
177
+ if (!el) return false;
178
+ el.scrollIntoView({ block: "center" });
179
+ const selector = el.querySelector(".ant-select-selector") || el;
180
+ // Ant Select 必须 mousedown,click 不工作(反馈第 1 条)
181
+ selector.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true, button: 0 }));
182
+ selector.dispatchEvent(new MouseEvent("mouseup", { bubbles: true, cancelable: true, button: 0 }));
183
+ return true;
184
+ }, { marker: mark.marker });
185
+
186
+ if (!triggerOk) {
187
+ await this._clearMarkers(frame);
188
+ return { acted: false, reason: "trigger element disappeared after marking" };
189
+ }
190
+
191
+ const selectedSummary = [];
192
+ for (const opt of options) {
193
+ // 3) 浮层探测:先在触发器 frame 找,没找到再在 popupFrame 找(兜底)
194
+ let popupResolved = popupFrame;
195
+ let optionFound = await pollEval(popupResolved, (text) => {
196
+ const items = Array.from(document.querySelectorAll(
197
+ "body > .ant-select-dropdown:not(.ant-select-dropdown-hidden) .ant-select-item-option"
198
+ ));
199
+ const visibleItems = items.filter((el) => {
200
+ const rect = el.getBoundingClientRect();
201
+ return rect.width > 0 && rect.height > 0;
202
+ });
203
+ if (visibleItems.length === 0) return null;
204
+ const matched = visibleItems.find((el) =>
205
+ (el.innerText || el.textContent || "").trim() === text
206
+ ) || visibleItems.find((el) =>
207
+ (el.innerText || el.textContent || "").trim().includes(text)
208
+ );
209
+ if (!matched) return { renderedButNoMatch: true, options: visibleItems.slice(0, 10).map((el) => (el.innerText || "").trim()) };
210
+ const tag = `__sz_opt_${Date.now()}`;
211
+ matched.setAttribute("data-sz-option-marker", tag);
212
+ return { tag, text: (matched.innerText || "").trim() };
213
+ }, opt, { timeout: opts.popupFrame ? timeout : Math.min(1500, timeout) });
214
+
215
+ // 微前端跨 frame 兜底:触发器 frame 没找到浮层 → 尝试父 frame
216
+ if ((!optionFound.ok || !optionFound.value?.tag) && popupResolved === frame) {
217
+ // frame.parentFrame() 是 puppeteer 标准 API
218
+ let parentFrame = null;
219
+ try { parentFrame = frame.parentFrame(); } catch {}
220
+ if (parentFrame && parentFrame !== frame) {
221
+ const retryFound = await pollEval(parentFrame, (text) => {
222
+ const items = Array.from(document.querySelectorAll(
223
+ "body > .ant-select-dropdown:not(.ant-select-dropdown-hidden) .ant-select-item-option"
224
+ ));
225
+ const visibleItems = items.filter((el) => {
226
+ const rect = el.getBoundingClientRect();
227
+ return rect.width > 0 && rect.height > 0;
228
+ });
229
+ if (visibleItems.length === 0) return null;
230
+ const matched = visibleItems.find((el) =>
231
+ (el.innerText || el.textContent || "").trim() === text
232
+ ) || visibleItems.find((el) =>
233
+ (el.innerText || el.textContent || "").trim().includes(text)
234
+ );
235
+ if (!matched) return null;
236
+ const tag = `__sz_opt_${Date.now()}`;
237
+ matched.setAttribute("data-sz-option-marker", tag);
238
+ return { tag, text: (matched.innerText || "").trim() };
239
+ }, opt, { timeout: 1500 });
240
+ if (retryFound.ok && retryFound.value?.tag) {
241
+ optionFound = retryFound;
242
+ popupResolved = parentFrame;
243
+ }
244
+ }
245
+ }
246
+
247
+ if (!optionFound.ok || !optionFound.value?.tag) {
248
+ await this.closeAllDropdowns(frame);
249
+ if (popupResolved !== frame) await this.closeAllDropdowns(popupResolved);
250
+ await this._clearMarkers(frame);
251
+ return {
252
+ acted: false,
253
+ reason: `option "${opt}" not found in dropdown`,
254
+ availableOptions: optionFound.value?.options || [],
255
+ triggerFrameUrl: frame.url(),
256
+ searchedFrames: [frame.url(), popupResolved.url()].filter((u, i, a) => a.indexOf(u) === i),
257
+ };
258
+ }
259
+
260
+ // 4) mousedown 选项(在 popupResolved frame 内)
261
+ await popupResolved.evaluate((tag) => {
262
+ const el = document.querySelector(`[data-sz-option-marker="${tag}"]`);
263
+ if (!el) return false;
264
+ el.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true, button: 0 }));
265
+ el.dispatchEvent(new MouseEvent("mouseup", { bubbles: true, cancelable: true, button: 0 }));
266
+ el.removeAttribute("data-sz-option-marker");
267
+ return true;
268
+ }, optionFound.value.tag);
269
+
270
+ selectedSummary.push(opt);
271
+
272
+ if (mode === "single") break;
273
+ }
274
+
275
+ // 5) 关闭浮层(双 frame 清理)
276
+ await this.closeAllDropdowns(frame);
277
+ if (popupResolved !== frame) await this.closeAllDropdowns(popupResolved);
278
+
279
+ // 6) VERIFY:在 trigger frame 检查 selection-item
280
+ const verify = await pollEval(frame, ({ marker, expected, mode }) => {
281
+ const el = document.querySelector(`[data-sz-marker="${marker}"]`);
282
+ if (!el) return null;
283
+ const items = Array.from(el.querySelectorAll(".ant-select-selection-item"));
284
+ if (items.length === 0) return null;
285
+ const texts = items.map((i) => (i.getAttribute("title") || i.innerText || "").trim()).filter(Boolean);
286
+ if (mode === "single") {
287
+ return texts[0] && expected.some((e) => texts[0].includes(e)) ? texts : null;
288
+ }
289
+ const allFound = expected.every((e) => texts.some((t) => t.includes(e)));
290
+ return allFound ? texts : null;
291
+ }, { marker: mark.marker, expected: options, mode }, { timeout: 1500 });
292
+
293
+ await this._clearMarkers(frame);
294
+
295
+ return {
296
+ acted: true,
297
+ field: fieldLabel,
298
+ selected: selectedSummary,
299
+ verified: verify.ok,
300
+ currentValue: verify.value,
301
+ triggerFrameUrl: frame.url(),
302
+ popupFrameUrl: typeof popupResolved.url === "function" ? popupResolved.url() : null,
303
+ reason: verify.ok ? null : "selection-item did not reflect chosen option",
304
+ };
305
+ }
306
+
307
+ /**
308
+ * Ant TreeSelect 操作
309
+ * @param {Frame} frame
310
+ * @param {string} fieldLabel
311
+ * @param {string|string[]} path - 树节点路径(按顺序展开),传字符串则只选叶子
312
+ */
313
+ async treeSelect(frame, fieldLabel, path, opts = {}) {
314
+ const segments = Array.isArray(path) ? path : [path];
315
+ const timeout = opts.timeout || POLL_TIMEOUT;
316
+
317
+ await this.closeAllDropdowns(frame);
318
+ const mark = await this._markFormItemControl(frame, fieldLabel, "treeSelect");
319
+ if (!mark.found || !mark.hasControl) {
320
+ await this._clearMarkers(frame);
321
+ return { acted: false, reason: !mark.found ? `field "${fieldLabel}" not found` : "no tree-select control" };
322
+ }
323
+
324
+ // 触发下拉
325
+ await frame.evaluate(({ marker }) => {
326
+ const el = document.querySelector(`[data-sz-marker="${marker}"]`);
327
+ if (!el) return;
328
+ el.scrollIntoView({ block: "center" });
329
+ const selector = el.querySelector(".ant-select-selector") || el;
330
+ selector.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true, button: 0 }));
331
+ selector.dispatchEvent(new MouseEvent("mouseup", { bubbles: true, cancelable: true, button: 0 }));
332
+ }, { marker: mark.marker });
333
+
334
+ // 逐级展开 + 最后一级点击
335
+ for (let i = 0; i < segments.length; i++) {
336
+ const seg = segments[i];
337
+ const isLeaf = i === segments.length - 1;
338
+
339
+ const found = await pollEval(frame, ({ text, isLeaf }) => {
340
+ const titles = Array.from(document.querySelectorAll(
341
+ "body > .ant-select-dropdown:not(.ant-select-dropdown-hidden) .ant-select-tree-title, " +
342
+ "body > .ant-tree-select-dropdown:not(.ant-select-dropdown-hidden) .ant-select-tree-title"
343
+ ));
344
+ const visible = titles.filter((el) => {
345
+ const rect = el.getBoundingClientRect();
346
+ return rect.width > 0 && rect.height > 0;
347
+ });
348
+ if (visible.length === 0) return null;
349
+ const matched = visible.find((el) => (el.innerText || "").trim() === text)
350
+ || visible.find((el) => (el.innerText || "").trim().includes(text));
351
+ if (!matched) return { rendered: true, options: visible.slice(0, 10).map((el) => (el.innerText || "").trim()) };
352
+
353
+ // 找到节点 → 找其 switcher 来展开(非叶子),或直接点击(叶子)
354
+ const treeNode = matched.closest(".ant-select-tree-treenode");
355
+ if (!treeNode) return null;
356
+
357
+ if (isLeaf) {
358
+ matched.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true, button: 0 }));
359
+ matched.dispatchEvent(new MouseEvent("mouseup", { bubbles: true, cancelable: true, button: 0 }));
360
+ matched.click();
361
+ return { acted: "click", text: (matched.innerText || "").trim() };
362
+ }
363
+ const switcher = treeNode.querySelector(".ant-select-tree-switcher");
364
+ if (switcher && !switcher.classList.contains("ant-select-tree-switcher_open")) {
365
+ switcher.click();
366
+ }
367
+ return { acted: "expand", text };
368
+ }, { text: seg, isLeaf }, { timeout });
369
+
370
+ if (!found.ok) {
371
+ await this.closeAllDropdowns(frame);
372
+ await this._clearMarkers(frame);
373
+ return {
374
+ acted: false,
375
+ reason: `tree node "${seg}" not found at level ${i + 1}/${segments.length}`,
376
+ availableNodes: found.value?.options || [],
377
+ };
378
+ }
379
+ }
380
+
381
+ await this.closeAllDropdowns(frame);
382
+
383
+ const verify = await pollEval(frame, ({ marker, leaf }) => {
384
+ const el = document.querySelector(`[data-sz-marker="${marker}"]`);
385
+ if (!el) return null;
386
+ const items = Array.from(el.querySelectorAll(".ant-select-selection-item"));
387
+ if (items.length === 0) return null;
388
+ const text = items.map((i) => (i.getAttribute("title") || i.innerText || "").trim()).join(" ");
389
+ return text.includes(leaf) ? text : null;
390
+ }, { marker: mark.marker, leaf: segments[segments.length - 1] }, { timeout: 1500 });
391
+
392
+ await this._clearMarkers(frame);
393
+ return {
394
+ acted: true,
395
+ field: fieldLabel,
396
+ path: segments,
397
+ verified: verify.ok,
398
+ currentValue: verify.value,
399
+ };
400
+ }
401
+
402
+ /**
403
+ * Ant Input / TextArea 文本输入
404
+ * @param {Frame} frame
405
+ * @param {object} target - { label }(按字段 label)或 { placeholder } 或 { selector }
406
+ * @param {string} value
407
+ * @param {object} opts - { clear: true, delay: 0 }
408
+ */
409
+ async input(frame, target, value, opts = {}) {
410
+ const clear = opts.clear !== false;
411
+ const result = await frame.evaluate(({ target, value, clear }) => {
412
+ let el = null;
413
+
414
+ if (target.label) {
415
+ const items = Array.from(document.querySelectorAll(".ant-form-item"));
416
+ const item = items.find((x) => {
417
+ const lbl = x.querySelector(".ant-form-item-label");
418
+ return lbl && (lbl.innerText || lbl.textContent || "").trim().includes(target.label);
419
+ });
420
+ el = item?.querySelector("input.ant-input, textarea.ant-input, .ant-input-affix-wrapper input, input[type='text'], input[type='number'], input[type='password'], input:not([type])");
421
+ } else if (target.placeholder) {
422
+ el = document.querySelector(`input[placeholder*="${target.placeholder}"], textarea[placeholder*="${target.placeholder}"]`);
423
+ } else if (target.selector) {
424
+ try { el = document.querySelector(target.selector); } catch { el = null; }
425
+ }
426
+
427
+ if (!el) return { found: false };
428
+ el.scrollIntoView({ block: "center" });
429
+ const proto = Object.getPrototypeOf(el);
430
+ const valueSetter = Object.getOwnPropertyDescriptor(proto, "value")?.set;
431
+
432
+ el.focus();
433
+ const newValue = clear ? value : (el.value || "") + value;
434
+ if (valueSetter) {
435
+ valueSetter.call(el, newValue);
436
+ } else {
437
+ el.value = newValue;
438
+ }
439
+ el.dispatchEvent(new Event("input", { bubbles: true }));
440
+ el.dispatchEvent(new Event("change", { bubbles: true }));
441
+ return {
442
+ found: true,
443
+ actualValue: el.value,
444
+ placeholder: el.getAttribute("placeholder"),
445
+ tagName: el.tagName,
446
+ };
447
+ }, { target, value, clear });
448
+
449
+ return {
450
+ acted: result.found,
451
+ target,
452
+ value,
453
+ actualValue: result.actualValue,
454
+ verified: result.actualValue === value,
455
+ reason: result.found ? null : `input not found by ${JSON.stringify(target)}`,
456
+ };
457
+ }
458
+
459
+ /**
460
+ * Ant Radio 选择
461
+ */
462
+ async radio(frame, fieldLabel, optionLabel) {
463
+ const result = await frame.evaluate(({ field, opt }) => {
464
+ const items = Array.from(document.querySelectorAll(".ant-form-item"));
465
+ const item = items.find((x) => {
466
+ const lbl = x.querySelector(".ant-form-item-label");
467
+ return lbl && (lbl.innerText || lbl.textContent || "").trim().includes(field);
468
+ });
469
+ if (!item) return { found: false, reason: "form item not found" };
470
+
471
+ const wrappers = Array.from(item.querySelectorAll(".ant-radio-wrapper"));
472
+ const target = wrappers.find((w) => (w.innerText || "").trim().includes(opt));
473
+ if (!target) return { found: false, reason: `radio option "${opt}" not found`, available: wrappers.map((w) => (w.innerText || "").trim()) };
474
+
475
+ target.scrollIntoView({ block: "center" });
476
+ target.click();
477
+ const input = target.querySelector("input[type='radio']");
478
+ return {
479
+ found: true,
480
+ checked: input?.checked || false,
481
+ value: input?.value || "",
482
+ };
483
+ }, { field: fieldLabel, opt: optionLabel });
484
+ return {
485
+ acted: result.found,
486
+ field: fieldLabel,
487
+ option: optionLabel,
488
+ verified: result.checked,
489
+ availableOptions: result.available,
490
+ reason: result.reason,
491
+ };
492
+ }
493
+
494
+ /**
495
+ * Ant Checkbox 切换(按 label 文本)
496
+ */
497
+ async checkbox(frame, fieldLabel, optionLabel, checked = true) {
498
+ const result = await frame.evaluate(({ field, opt, target }) => {
499
+ const items = Array.from(document.querySelectorAll(".ant-form-item"));
500
+ const item = items.find((x) => {
501
+ const lbl = x.querySelector(".ant-form-item-label");
502
+ return lbl && (lbl.innerText || lbl.textContent || "").trim().includes(field);
503
+ });
504
+ if (!item) return { found: false };
505
+ // 支持 group 和单独 checkbox
506
+ const wrappers = Array.from(item.querySelectorAll(".ant-checkbox-wrapper"));
507
+ const wrapper = opt
508
+ ? wrappers.find((w) => (w.innerText || "").includes(opt))
509
+ : wrappers[0];
510
+ if (!wrapper) return { found: false, reason: "checkbox not found" };
511
+ const input = wrapper.querySelector("input[type='checkbox']");
512
+ if (!input) return { found: false };
513
+ if (input.checked !== target) wrapper.click();
514
+ return { found: true, checked: input.checked };
515
+ }, { field: fieldLabel, opt: optionLabel, target: checked });
516
+ return { acted: result.found, field: fieldLabel, option: optionLabel, verified: result.checked === checked };
517
+ }
518
+
519
+ /**
520
+ * Ant Switch 切换
521
+ */
522
+ async switch(frame, fieldLabel, on = true) {
523
+ const result = await frame.evaluate(({ field, target }) => {
524
+ const items = Array.from(document.querySelectorAll(".ant-form-item"));
525
+ const item = items.find((x) => {
526
+ const lbl = x.querySelector(".ant-form-item-label");
527
+ return lbl && (lbl.innerText || lbl.textContent || "").trim().includes(field);
528
+ });
529
+ if (!item) return { found: false };
530
+ const sw = item.querySelector(".ant-switch");
531
+ if (!sw) return { found: false };
532
+ const isOn = sw.getAttribute("aria-checked") === "true" || sw.classList.contains("ant-switch-checked");
533
+ if (isOn !== target) sw.click();
534
+ return { found: true, state: target };
535
+ }, { field: fieldLabel, target: on });
536
+ return { acted: result.found, field: fieldLabel, state: result.state, verified: result.state === on };
537
+ }
538
+
539
+ /**
540
+ * Ant DatePicker 设值(通过 input + 回车确认)
541
+ */
542
+ async datePicker(frame, fieldLabel, dateString) {
543
+ await this.closeAllDropdowns(frame);
544
+ const result = await frame.evaluate(({ field, value }) => {
545
+ const items = Array.from(document.querySelectorAll(".ant-form-item"));
546
+ const item = items.find((x) => {
547
+ const lbl = x.querySelector(".ant-form-item-label");
548
+ return lbl && (lbl.innerText || lbl.textContent || "").trim().includes(field);
549
+ });
550
+ if (!item) return { found: false };
551
+ const picker = item.querySelector(".ant-picker");
552
+ const input = picker?.querySelector("input");
553
+ if (!input) return { found: false };
554
+
555
+ input.scrollIntoView({ block: "center" });
556
+ input.focus();
557
+ const setter = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(input), "value")?.set;
558
+ if (setter) setter.call(input, value);
559
+ else input.value = value;
560
+ input.dispatchEvent(new Event("input", { bubbles: true }));
561
+ input.dispatchEvent(new Event("change", { bubbles: true }));
562
+ // 回车确认
563
+ input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", code: "Enter", keyCode: 13, which: 13, bubbles: true }));
564
+ input.blur();
565
+ return { found: true, value: input.value };
566
+ }, { field: fieldLabel, value: dateString });
567
+ await this.closeAllDropdowns(frame);
568
+ return { acted: result.found, field: fieldLabel, value: result.value, verified: result.value === dateString };
569
+ }
570
+
571
+ /**
572
+ * Cascader 选择
573
+ */
574
+ async cascader(frame, fieldLabel, path, opts = {}) {
575
+ const segments = Array.isArray(path) ? path : [path];
576
+ const timeout = opts.timeout || POLL_TIMEOUT;
577
+ await this.closeAllDropdowns(frame);
578
+
579
+ const mark = await this._markFormItemControl(frame, fieldLabel, "cascader");
580
+ if (!mark.found || !mark.hasControl) {
581
+ await this._clearMarkers(frame);
582
+ return { acted: false, reason: "cascader not found" };
583
+ }
584
+
585
+ await frame.evaluate(({ marker }) => {
586
+ const el = document.querySelector(`[data-sz-marker="${marker}"]`);
587
+ const trigger = el?.querySelector(".ant-cascader-picker, .ant-select-selector, input") || el;
588
+ if (trigger) {
589
+ trigger.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, button: 0 }));
590
+ trigger.dispatchEvent(new MouseEvent("mouseup", { bubbles: true, button: 0 }));
591
+ trigger.click?.();
592
+ }
593
+ }, { marker: mark.marker });
594
+
595
+ for (let i = 0; i < segments.length; i++) {
596
+ const seg = segments[i];
597
+ const result = await pollEval(frame, (text) => {
598
+ const all = Array.from(document.querySelectorAll(
599
+ "body > .ant-cascader-dropdown:not(.ant-cascader-dropdown-hidden) .ant-cascader-menu-item"
600
+ ));
601
+ const visible = all.filter((el) => {
602
+ const r = el.getBoundingClientRect();
603
+ return r.width > 0 && r.height > 0;
604
+ });
605
+ if (visible.length === 0) return null;
606
+ const matched = visible.find((el) => (el.innerText || "").trim() === text)
607
+ || visible.find((el) => (el.innerText || "").trim().includes(text));
608
+ if (!matched) return { options: visible.map((el) => (el.innerText || "").trim()) };
609
+ matched.click();
610
+ return { clicked: text };
611
+ }, seg, { timeout });
612
+ if (!result.ok || !result.value?.clicked) {
613
+ await this.closeAllDropdowns(frame);
614
+ await this._clearMarkers(frame);
615
+ return { acted: false, reason: `cascader level "${seg}" not found`, available: result.value?.options };
616
+ }
617
+ }
618
+
619
+ await this.closeAllDropdowns(frame);
620
+ await this._clearMarkers(frame);
621
+ return { acted: true, field: fieldLabel, path: segments };
622
+ }
623
+
624
+ /**
625
+ * Upload 文件上传
626
+ */
627
+ async upload(frame, fieldLabel, filePath) {
628
+ // 注意:puppeteer 上传文件需要通过 ElementHandle.uploadFile,evaluate 内做不到
629
+ // 这里只能返回需要上层处理的标志
630
+ const result = await frame.evaluate((field) => {
631
+ const items = Array.from(document.querySelectorAll(".ant-form-item"));
632
+ const item = items.find((x) => {
633
+ const lbl = x.querySelector(".ant-form-item-label");
634
+ return lbl && (lbl.innerText || lbl.textContent || "").trim().includes(field);
635
+ });
636
+ if (!item) return { found: false };
637
+ const input = item.querySelector("input[type='file']");
638
+ if (!input) return { found: false };
639
+ const marker = `__sz_upload_${Date.now()}`;
640
+ input.setAttribute("data-sz-marker", marker);
641
+ return { found: true, marker };
642
+ }, fieldLabel);
643
+
644
+ if (!result.found) return { acted: false, reason: "upload input not found" };
645
+
646
+ try {
647
+ const handles = await frame.$$(`input[data-sz-marker="${result.marker}"]`);
648
+ if (handles.length === 0) return { acted: false, reason: "upload input handle lost" };
649
+ await handles[0].uploadFile(filePath);
650
+ await this._clearMarkers(frame);
651
+ return { acted: true, field: fieldLabel, filePath };
652
+ } catch (err) {
653
+ await this._clearMarkers(frame);
654
+ return { acted: false, reason: `upload failed: ${err.message}` };
655
+ }
656
+ }
657
+
658
+ /**
659
+ * 高层 API:按字段定义批量填充表单
660
+ * fields: [{ label, type: "input"|"select"|"treeSelect"|"radio"|"checkbox"|"switch"|"datePicker"|"cascader"|"upload",
661
+ * value, path, matchBy: "label"|"placeholder" }]
662
+ */
663
+ async formFill(frame, fields, opts = {}) {
664
+ const stepDelay = opts.stepDelay ?? 200;
665
+ const results = [];
666
+ for (const f of fields) {
667
+ let r;
668
+ switch (f.type) {
669
+ case "input":
670
+ r = await this.input(frame,
671
+ f.matchBy === "placeholder" ? { placeholder: f.label } : { label: f.label },
672
+ f.value, { clear: f.clear !== false });
673
+ break;
674
+ case "select":
675
+ r = await this.select(frame, f.label, f.value, { mode: f.mode });
676
+ break;
677
+ case "treeSelect":
678
+ r = await this.treeSelect(frame, f.label, f.path || f.value);
679
+ break;
680
+ case "radio":
681
+ r = await this.radio(frame, f.label, f.value);
682
+ break;
683
+ case "checkbox":
684
+ r = await this.checkbox(frame, f.label, f.value, f.checked !== false);
685
+ break;
686
+ case "switch":
687
+ r = await this.switch(frame, f.label, f.on !== false);
688
+ break;
689
+ case "datePicker":
690
+ r = await this.datePicker(frame, f.label, f.value);
691
+ break;
692
+ case "cascader":
693
+ r = await this.cascader(frame, f.label, f.path || f.value);
694
+ break;
695
+ case "upload":
696
+ r = await this.upload(frame, f.label, f.value);
697
+ break;
698
+ default:
699
+ r = { acted: false, reason: `unknown field type: ${f.type}` };
700
+ }
701
+ results.push({ field: f.label, type: f.type, ...r });
702
+ if (stepDelay > 0) await new Promise((res) => setTimeout(res, stepDelay));
703
+ }
704
+ return {
705
+ fields: results,
706
+ summary: {
707
+ total: results.length,
708
+ acted: results.filter((r) => r.acted).length,
709
+ verified: results.filter((r) => r.verified).length,
710
+ failed: results.filter((r) => !r.acted).length,
711
+ },
712
+ };
713
+ }
714
+ }
715
+
716
+ export default AntAdapter;