@szc-ft/mcp-szcd-client 0.34.1 → 0.35.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/opencode-extension/skills/local-browser-test/SKILL.md +266 -111
- package/opencode-extension/skills/local-browser-test/lib/browser-engine.js +593 -11
- package/opencode-extension/skills/local-browser-test/local-browser-executor.js +200 -2
- package/package.json +1 -1
- package/qwen-extension/qwen-extension.json +1 -1
- package/qwen-extension/skills/local-browser-test/SKILL.md +266 -111
- package/qwen-extension/skills/local-browser-test/lib/browser-engine.js +593 -11
- package/qwen-extension/skills/local-browser-test/local-browser-executor.js +200 -2
- package/scripts/lib/common.js +1 -1
- package/standard-skill/local-browser-test/SKILL.md +266 -111
- package/standard-skill/local-browser-test/lib/browser-engine.js +593 -11
- package/standard-skill/local-browser-test/local-browser-executor.js +200 -2
- package/standard-skill/local-browser-test/local-browser-executor.cjs +0 -742
|
@@ -685,7 +685,18 @@ export class BrowserEngine {
|
|
|
685
685
|
|
|
686
686
|
const ensureDrawerOpen = async () => {
|
|
687
687
|
if (!drawerTriggerSelector) return { ensured: false, reason: "no drawer" };
|
|
688
|
-
|
|
688
|
+
// 用可见性判断抽屉是否打开,而非单纯检查元素是否存在。
|
|
689
|
+
// 很多自定义抽屉(如 .page-left-menu)常驻 DOM,靠 CSS(width:0 / transform / display)
|
|
690
|
+
// 控制开关,querySelector 永远命中会误判 already open。
|
|
691
|
+
const isVisible = (sel) => {
|
|
692
|
+
const el = document.querySelector(sel);
|
|
693
|
+
if (!el) return false;
|
|
694
|
+
if (el.offsetParent === null && getComputedStyle(el).position !== "fixed") return false;
|
|
695
|
+
const rect = el.getBoundingClientRect();
|
|
696
|
+
const style = getComputedStyle(el);
|
|
697
|
+
return rect.width > 1 && rect.height > 1 && style.display !== "none" && style.visibility !== "hidden" && style.opacity !== "0";
|
|
698
|
+
};
|
|
699
|
+
const isOpen = await frame.evaluate(isVisible, drawerOpenSelector).catch(() => false);
|
|
689
700
|
if (isOpen) return { ensured: true, reason: "already open" };
|
|
690
701
|
const triggered = await frame.evaluate((sel) => {
|
|
691
702
|
const el = document.querySelector(sel);
|
|
@@ -696,7 +707,9 @@ export class BrowserEngine {
|
|
|
696
707
|
}, drawerTriggerSelector).catch(() => false);
|
|
697
708
|
if (!triggered) return { ensured: false, reason: "trigger not found" };
|
|
698
709
|
await new Promise((r) => setTimeout(r, waitAfterEach));
|
|
699
|
-
|
|
710
|
+
// 点击后再次用可见性确认抽屉真的打开了
|
|
711
|
+
const openedAfter = await frame.evaluate(isVisible, drawerOpenSelector).catch(() => false);
|
|
712
|
+
return { ensured: openedAfter, reason: openedAfter ? "clicked trigger" : "clicked but not visible" };
|
|
700
713
|
};
|
|
701
714
|
|
|
702
715
|
const scrollContainerOnce = async () => {
|
|
@@ -723,7 +736,7 @@ export class BrowserEngine {
|
|
|
723
736
|
return rect.width > 0 && rect.height > 0 && style.display !== "none" && style.visibility !== "hidden";
|
|
724
737
|
}
|
|
725
738
|
function menuCandidates() {
|
|
726
|
-
|
|
739
|
+
const tagged = Array.from(document.querySelectorAll([
|
|
727
740
|
"[role='menuitem']",
|
|
728
741
|
".ant-menu-item",
|
|
729
742
|
".ant-menu-submenu-title",
|
|
@@ -732,7 +745,18 @@ export class BrowserEngine {
|
|
|
732
745
|
"button",
|
|
733
746
|
"[class*='menu-item']",
|
|
734
747
|
"[class*='submenu']",
|
|
735
|
-
].join(",")))
|
|
748
|
+
].join(",")));
|
|
749
|
+
// 补充可见的叶子文本节点(裸 span/div,无 menu 类名)。
|
|
750
|
+
// 某些自定义抽屉/导航把菜单项渲染成无类名的 <span>,上面的选择器命不中,
|
|
751
|
+
// 导致只能退而命中带 .menu-item 类的父容器(文字是整块菜单),点错元素。
|
|
752
|
+
const leaves = Array.from(document.querySelectorAll("span, div, li"))
|
|
753
|
+
.filter((el) => {
|
|
754
|
+
if (el.childElementCount > 0) return false;
|
|
755
|
+
const t = (el.innerText || el.textContent || "").trim();
|
|
756
|
+
return t.length > 0 && t.length <= 20;
|
|
757
|
+
});
|
|
758
|
+
const merged = new Set([...tagged, ...leaves]);
|
|
759
|
+
return Array.from(merged).filter(isVisible);
|
|
736
760
|
}
|
|
737
761
|
function score(el) {
|
|
738
762
|
const actual = textOf(el);
|
|
@@ -748,6 +772,14 @@ export class BrowserEngine {
|
|
|
748
772
|
if (cls.includes("ant-menu-submenu-title")) s += lvl < totalLevels - 1 ? 20 : -5;
|
|
749
773
|
if (cls.includes("ant-menu-item")) s += lvl === totalLevels - 1 ? 20 : 0;
|
|
750
774
|
if (cls.includes("ant-menu-item-selected")) s += 5;
|
|
775
|
+
// 文本越接近目标越优先:惩罚包含大量额外文字的父容器,避免命中整块菜单包裹元素。
|
|
776
|
+
// 例如目标"数据探索",父容器文字"语料资产中心 数据集目录 工程概览 数据探索 数据智搜"
|
|
777
|
+
// 多出的字符越多,扣分越多,让最贴近目标的叶子节点胜出。
|
|
778
|
+
const extraLen = actual.length - expectedText.length;
|
|
779
|
+
if (extraLen > 0) s -= Math.min(extraLen * 2, 60);
|
|
780
|
+
// 叶子节点(无子元素或仅含文本/图标)加分,优先于包裹容器
|
|
781
|
+
const childElementCount = el.childElementCount || 0;
|
|
782
|
+
if (childElementCount <= 1) s += 15;
|
|
751
783
|
return s;
|
|
752
784
|
}
|
|
753
785
|
const ranked = menuCandidates()
|
|
@@ -786,13 +818,49 @@ export class BrowserEngine {
|
|
|
786
818
|
};
|
|
787
819
|
|
|
788
820
|
const clickStep = async (isLast) => {
|
|
789
|
-
|
|
821
|
+
// 记录点击前状态 + 滚动目标到可见区
|
|
822
|
+
const beforeState = await frame.evaluate(() => {
|
|
790
823
|
const el = document.querySelector("[data-szcd-menu-target='1']");
|
|
791
|
-
if (!el) return {
|
|
824
|
+
if (!el) return { ok: false };
|
|
792
825
|
el.scrollIntoView({ block: "center", inline: "center" });
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
826
|
+
return { ok: true, beforeUrl: location.href, beforeText: (document.body?.innerText || "").length };
|
|
827
|
+
});
|
|
828
|
+
if (!beforeState.ok) return { performed: false, error: "marker lost" };
|
|
829
|
+
|
|
830
|
+
// 优先用 puppeteer 原生鼠标点击(发出真实 mousedown/mouseup/click 序列)。
|
|
831
|
+
// 纯 el.click() 对 React 事件委托型菜单(如 .page-left-menu 抽屉里的无类名菜单项)无效,
|
|
832
|
+
// 不会触发路由切换;原生鼠标事件才能驱动 React 合成事件。
|
|
833
|
+
let nativeClicked = false;
|
|
834
|
+
try {
|
|
835
|
+
const handle = await frame.$("[data-szcd-menu-target='1']");
|
|
836
|
+
if (handle) {
|
|
837
|
+
const box = await handle.boundingBox();
|
|
838
|
+
if (box && box.width > 0 && box.height > 0) {
|
|
839
|
+
const cx = box.x + box.width / 2;
|
|
840
|
+
const cy = box.y + box.height / 2;
|
|
841
|
+
await this.page.mouse.move(cx, cy);
|
|
842
|
+
await new Promise((r) => setTimeout(r, 120));
|
|
843
|
+
await this.page.mouse.click(cx, cy);
|
|
844
|
+
nativeClicked = true;
|
|
845
|
+
}
|
|
846
|
+
await handle.dispose().catch(() => {});
|
|
847
|
+
}
|
|
848
|
+
} catch {
|
|
849
|
+
// 原生点击失败(跨 frame 坐标 / target detached),回退到 DOM click
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
// 回退:DOM el.click()
|
|
853
|
+
const result = await frame.evaluate(async (waitMs, last, didNative, beforeUrl, beforeTextLen) => {
|
|
854
|
+
const el = document.querySelector("[data-szcd-menu-target='1']");
|
|
855
|
+
if (!el) {
|
|
856
|
+
// 原生点击后元素可能已被卸载(路由切换),视为成功
|
|
857
|
+
return {
|
|
858
|
+
performed: true,
|
|
859
|
+
action: last ? "click" : "expand",
|
|
860
|
+
after: { urlChanged: location.href !== beforeUrl, textChanged: (document.body?.innerText || "").length !== beforeTextLen, markerGone: true },
|
|
861
|
+
};
|
|
862
|
+
}
|
|
863
|
+
if (!didNative) el.click();
|
|
796
864
|
el.removeAttribute("data-szcd-menu-target");
|
|
797
865
|
await new Promise((r) => setTimeout(r, waitMs));
|
|
798
866
|
const cls = typeof el.className === "string" ? el.className : "";
|
|
@@ -803,11 +871,13 @@ export class BrowserEngine {
|
|
|
803
871
|
className: cls,
|
|
804
872
|
ariaExpanded: el.getAttribute("aria-expanded"),
|
|
805
873
|
urlChanged: location.href !== beforeUrl,
|
|
806
|
-
textChanged: (document.body?.innerText || "") !==
|
|
874
|
+
textChanged: (document.body?.innerText || "").length !== beforeTextLen,
|
|
807
875
|
selected: cls.includes("selected") || cls.includes("active"),
|
|
876
|
+
nativeClicked: didNative,
|
|
808
877
|
},
|
|
809
878
|
};
|
|
810
|
-
}, waitAfterEach, isLast);
|
|
879
|
+
}, waitAfterEach, isLast, nativeClicked, beforeState.beforeUrl, beforeState.beforeText);
|
|
880
|
+
return result;
|
|
811
881
|
};
|
|
812
882
|
|
|
813
883
|
const steps = [];
|
|
@@ -2429,6 +2499,518 @@ export class BrowserEngine {
|
|
|
2429
2499
|
}
|
|
2430
2500
|
return { acted: true, passed: true, action: action, status: 'PASS' };
|
|
2431
2501
|
},
|
|
2502
|
+
// 抽屉式导航菜单完整链路:开抽屉 → 定位菜单项 → 原生点击 → 验证路由切换。
|
|
2503
|
+
// 适用于"点左上角汉堡按钮弹出抽屉,再点菜单项导航"的模式(如语料平台 .operate + .page-left-menu)。
|
|
2504
|
+
// 解决三个坑:(1) 抽屉常驻 DOM 靠 CSS 隐藏,需按可见性判断开关;
|
|
2505
|
+
// (2) 菜单项常是无类名裸 span,需叶子文本精确匹配;
|
|
2506
|
+
// (3) React 事件委托菜单必须用原生鼠标事件(mouse.click),el.click() 不触发路由。
|
|
2507
|
+
'drawer-menu-nav': async (page, frame, params) => {
|
|
2508
|
+
const triggerSelector = params.triggerSelector || params.drawerTriggerSelector || '.operate';
|
|
2509
|
+
const openSelector = params.openSelector || params.drawerOpenSelector || '.page-left-menu';
|
|
2510
|
+
const menuText = params.menuText || params.text;
|
|
2511
|
+
const waitAfter = params.waitAfter || 1500;
|
|
2512
|
+
if (!menuText) return { acted: false, passed: false, reason: 'missing menuText param', status: 'FAIL' };
|
|
2513
|
+
|
|
2514
|
+
// 可见性判断:元素存在且有尺寸、未被 CSS 隐藏
|
|
2515
|
+
const isVisible = (sel) => {
|
|
2516
|
+
const el = document.querySelector(sel);
|
|
2517
|
+
if (!el) return false;
|
|
2518
|
+
if (el.offsetParent === null && getComputedStyle(el).position !== 'fixed') return false;
|
|
2519
|
+
const r = el.getBoundingClientRect();
|
|
2520
|
+
const s = getComputedStyle(el);
|
|
2521
|
+
return r.width > 1 && r.height > 1 && s.display !== 'none' && s.visibility !== 'hidden' && s.opacity !== '0';
|
|
2522
|
+
};
|
|
2523
|
+
|
|
2524
|
+
// 1. 确保抽屉打开(用可见性判断,而非单纯存在性)
|
|
2525
|
+
let drawerOpen = await frame.evaluate(isVisible, openSelector).catch(() => false);
|
|
2526
|
+
if (!drawerOpen) {
|
|
2527
|
+
await frame.evaluate((sel) => { const el = document.querySelector(sel); if (el) { el.scrollIntoView({ block: 'center' }); el.click(); } }, triggerSelector).catch(() => {});
|
|
2528
|
+
await new Promise((r) => setTimeout(r, 600));
|
|
2529
|
+
drawerOpen = await frame.evaluate(isVisible, openSelector).catch(() => false);
|
|
2530
|
+
}
|
|
2531
|
+
if (!drawerOpen) return { acted: false, passed: false, reason: 'drawer did not open via ' + triggerSelector, status: 'FAIL' };
|
|
2532
|
+
|
|
2533
|
+
// 2. 定位菜单项叶子节点并打标记(精确文本匹配,优先无子元素的叶子)
|
|
2534
|
+
const located = await frame.evaluate((scope, txt) => {
|
|
2535
|
+
const root = document.querySelector(scope) || document;
|
|
2536
|
+
const all = Array.from(root.querySelectorAll('*')).filter((e) => {
|
|
2537
|
+
if (e.childElementCount > 0) return false;
|
|
2538
|
+
const t = (e.innerText || e.textContent || '').trim();
|
|
2539
|
+
if (t !== txt) return false;
|
|
2540
|
+
const r = e.getBoundingClientRect();
|
|
2541
|
+
return r.width > 0 && r.height > 0;
|
|
2542
|
+
});
|
|
2543
|
+
if (all.length === 0) return { found: false };
|
|
2544
|
+
// 点击目标优先取带语义类名的可点击祖先(如 .item),否则用叶子本身
|
|
2545
|
+
let target = all[0];
|
|
2546
|
+
let cur = target;
|
|
2547
|
+
for (let i = 0; i < 5 && cur; i++) {
|
|
2548
|
+
if (cur.className && typeof cur.className === 'string' && /item|menu/i.test(cur.className)) { target = cur; break; }
|
|
2549
|
+
cur = cur.parentElement;
|
|
2550
|
+
}
|
|
2551
|
+
target.setAttribute('data-szcd-drawernav', '1');
|
|
2552
|
+
return { found: true };
|
|
2553
|
+
}, openSelector, menuText).catch(() => ({ found: false }));
|
|
2554
|
+
if (!located.found) return { acted: false, passed: false, reason: 'menu item "' + menuText + '" not found in drawer', status: 'FAIL' };
|
|
2555
|
+
|
|
2556
|
+
// 3. 原生鼠标点击(真实 mousedown/mouseup/click,驱动 React 路由)
|
|
2557
|
+
const beforeUrl = page.url();
|
|
2558
|
+
const handle = await frame.$('[data-szcd-drawernav="1"]');
|
|
2559
|
+
if (!handle) return { acted: false, passed: false, reason: 'marker lost', status: 'FAIL' };
|
|
2560
|
+
const box = await handle.boundingBox();
|
|
2561
|
+
if (box && box.width > 0 && box.height > 0) {
|
|
2562
|
+
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
|
|
2563
|
+
await new Promise((r) => setTimeout(r, 120));
|
|
2564
|
+
await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2);
|
|
2565
|
+
} else {
|
|
2566
|
+
await frame.evaluate(() => { const el = document.querySelector('[data-szcd-drawernav="1"]'); el && el.click(); }).catch(() => {});
|
|
2567
|
+
}
|
|
2568
|
+
await handle.dispose().catch(() => {});
|
|
2569
|
+
await new Promise((r) => setTimeout(r, waitAfter));
|
|
2570
|
+
|
|
2571
|
+
// 4. 验证路由是否切换
|
|
2572
|
+
const afterUrl = page.url();
|
|
2573
|
+
const routeChanged = afterUrl !== beforeUrl;
|
|
2574
|
+
await frame.evaluate(() => { const el = document.querySelector('[data-szcd-drawernav="1"]'); el && el.removeAttribute('data-szcd-drawernav'); }).catch(() => {});
|
|
2575
|
+
return {
|
|
2576
|
+
acted: true,
|
|
2577
|
+
passed: routeChanged,
|
|
2578
|
+
status: routeChanged ? 'PASS' : 'FAIL',
|
|
2579
|
+
menuText: menuText,
|
|
2580
|
+
routeChanged: routeChanged,
|
|
2581
|
+
beforeUrl: beforeUrl,
|
|
2582
|
+
afterUrl: afterUrl,
|
|
2583
|
+
reason: routeChanged ? undefined : 'clicked but route did not change (可能是 hash 路由检测时机或该项无导航)',
|
|
2584
|
+
};
|
|
2585
|
+
},
|
|
2586
|
+
// 导航拓扑探测:报告当前导航控件结构,供 LLM 编排"切模块 → 读 sidebar → 逐项探索"的完整遍历。
|
|
2587
|
+
// ⚠️ 抽屉是模块切换器/快捷入口,抽屉里的项不代表模块完整菜单;
|
|
2588
|
+
// 每个模块的权威完整菜单是"切进该模块后的左侧 sidebar"。
|
|
2589
|
+
'detect-menu': async (page, frame, params) => {
|
|
2590
|
+
const triggerHints = params.triggerHints || ['.operate', '[class*=menu-toggle]', '[class*=menu-fold]', '[class*=hamburger]', '.anticon-menu', '.anticon-menu-fold', '.anticon-menu-unfold'];
|
|
2591
|
+
const drawerHints = params.drawerHints || ['.page-left-menu', '.ant-drawer', '[class*=drawer]', '[class*=side-menu]', '[class*=nav-panel]'];
|
|
2592
|
+
const detected = await frame.evaluate((triggers, drawers) => {
|
|
2593
|
+
const isVisible = (el) => {
|
|
2594
|
+
if (!el) return false;
|
|
2595
|
+
if (el.offsetParent === null && getComputedStyle(el).position !== 'fixed') return false;
|
|
2596
|
+
const r = el.getBoundingClientRect();
|
|
2597
|
+
const s = getComputedStyle(el);
|
|
2598
|
+
return r.width > 1 && r.height > 1 && s.display !== 'none' && s.visibility !== 'hidden' && s.opacity !== '0';
|
|
2599
|
+
};
|
|
2600
|
+
// 1. 当前模块的左侧 sidebar(权威完整菜单)
|
|
2601
|
+
const sidebarEls = Array.from(document.querySelectorAll('.ant-menu-item, .ant-menu-submenu-title')).filter(isVisible);
|
|
2602
|
+
const currentSidebar = sidebarEls.map((e) => (e.innerText || e.textContent || '').trim()).filter(Boolean);
|
|
2603
|
+
const hasSidebar = currentSidebar.length > 0;
|
|
2604
|
+
|
|
2605
|
+
// 2. 抽屉触发器
|
|
2606
|
+
let triggerSelector = null;
|
|
2607
|
+
for (const sel of triggers) { const el = document.querySelector(sel); if (el && isVisible(el)) { triggerSelector = sel; break; } }
|
|
2608
|
+
// 3. 抽屉容器(存在即可,可能 CSS 隐藏)
|
|
2609
|
+
let drawerSelector = null, drawerCurrentlyOpen = false;
|
|
2610
|
+
for (const sel of drawers) { const el = document.querySelector(sel); if (el) { drawerSelector = sel; drawerCurrentlyOpen = isVisible(el); break; } }
|
|
2611
|
+
|
|
2612
|
+
// 4. 抽屉里的模块入口名(仅作"切模块"用,非完整菜单)。
|
|
2613
|
+
// 抽屉常驻 DOM,即使当前关闭也能读到模块名。
|
|
2614
|
+
let drawerModules = [];
|
|
2615
|
+
if (drawerSelector) {
|
|
2616
|
+
const drawer = document.querySelector(drawerSelector);
|
|
2617
|
+
const mods = Array.from(drawer.querySelectorAll('.menu-item, [class*=menu-item]'));
|
|
2618
|
+
drawerModules = mods.map((m) => {
|
|
2619
|
+
const titleEl = m.querySelector('.title, [class*=title], h3, h4, strong');
|
|
2620
|
+
const active = /active|selected|current/i.test(m.className);
|
|
2621
|
+
// 模块下快捷项(仅供参考,非权威)
|
|
2622
|
+
const shortcuts = Array.from(m.querySelectorAll('span, .item')).map((e) => (e.innerText || e.textContent || '').trim()).filter((t) => t && t.length <= 16);
|
|
2623
|
+
return { moduleTitle: titleEl ? titleEl.textContent.trim() : (m.textContent || '').trim().slice(0, 20), active, shortcutSample: Array.from(new Set(shortcuts)).slice(0, 8) };
|
|
2624
|
+
}).filter((m) => m.moduleTitle);
|
|
2625
|
+
}
|
|
2626
|
+
return { hasSidebar, currentSidebar, triggerSelector, drawerSelector, drawerCurrentlyOpen, drawerModules };
|
|
2627
|
+
}, triggerHints, drawerHints).catch(() => null);
|
|
2628
|
+
|
|
2629
|
+
if (!detected) return { acted: true, passed: false, reason: 'detect failed', status: 'FAIL' };
|
|
2630
|
+
|
|
2631
|
+
const hasDrawer = !!(detected.triggerSelector && detected.drawerSelector);
|
|
2632
|
+
return {
|
|
2633
|
+
acted: true,
|
|
2634
|
+
passed: true,
|
|
2635
|
+
status: 'PASS',
|
|
2636
|
+
hasDrawer,
|
|
2637
|
+
hasSidebar: detected.hasSidebar,
|
|
2638
|
+
triggerSelector: detected.triggerSelector,
|
|
2639
|
+
drawerSelector: detected.drawerSelector,
|
|
2640
|
+
drawerCurrentlyOpen: detected.drawerCurrentlyOpen,
|
|
2641
|
+
currentSidebar: detected.currentSidebar, // 当前模块完整菜单(权威)
|
|
2642
|
+
drawerModules: detected.drawerModules, // 可切换的模块入口(非完整菜单)
|
|
2643
|
+
navModel: hasDrawer
|
|
2644
|
+
? '抽屉=模块切换器;遍历全部菜单需逐个 switch-module 切模块后读 currentSidebar,再 act --menu-path 点二级项。抽屉里的 shortcutSample 仅供参考,不代表模块完整菜单。'
|
|
2645
|
+
: '仅 sidebar,直接 act --menu-path 遍历 currentSidebar。',
|
|
2646
|
+
recommend: hasDrawer
|
|
2647
|
+
? { method: 'cdpScript switch-module(切模块)+ act --menu-path(点二级)', example: { type: 'cdpScript', script: 'switch-module', params: { moduleText: '<模块名或其下快捷项>', triggerSelector: detected.triggerSelector, drawerSelector: detected.drawerSelector } } }
|
|
2648
|
+
: { method: 'act --menu-path', example: { action: 'act', menuPath: '<一级/二级>' } },
|
|
2649
|
+
};
|
|
2650
|
+
},
|
|
2651
|
+
// 切模块:开抽屉 → 点目标模块(或其下快捷项)→ 等左侧 sidebar 刷新 → 返回新 sidebar 项。
|
|
2652
|
+
// 用于完整遍历:切到某模块后,用返回的 newSidebar 在该模块内 act --menu-path 逐项探索。
|
|
2653
|
+
'switch-module': async (page, frame, params) => {
|
|
2654
|
+
const triggerSelector = params.triggerSelector || '.operate';
|
|
2655
|
+
const drawerSelector = params.drawerSelector || '.page-left-menu';
|
|
2656
|
+
const moduleText = params.moduleText || params.text;
|
|
2657
|
+
const waitAfter = params.waitAfter || 1800;
|
|
2658
|
+
if (!moduleText) return { acted: false, passed: false, reason: 'missing moduleText param', status: 'FAIL' };
|
|
2659
|
+
|
|
2660
|
+
const isVisibleSel = (sel) => {
|
|
2661
|
+
const el = document.querySelector(sel);
|
|
2662
|
+
if (!el) return false;
|
|
2663
|
+
if (el.offsetParent === null && getComputedStyle(el).position !== 'fixed') return false;
|
|
2664
|
+
const r = el.getBoundingClientRect();
|
|
2665
|
+
const s = getComputedStyle(el);
|
|
2666
|
+
return r.width > 1 && r.height > 1 && s.display !== 'none' && s.visibility !== 'hidden' && s.opacity !== '0';
|
|
2667
|
+
};
|
|
2668
|
+
const readSidebar = () => frame.evaluate(() => Array.from(document.querySelectorAll('.ant-menu-item, .ant-menu-submenu-title')).filter((e) => e.offsetParent !== null).map((e) => (e.innerText || e.textContent || '').trim()).filter(Boolean));
|
|
2669
|
+
|
|
2670
|
+
const beforeSidebar = await readSidebar();
|
|
2671
|
+
const beforeUrl = page.url();
|
|
2672
|
+
|
|
2673
|
+
// 1. 确保抽屉打开(可见性判断)
|
|
2674
|
+
let open = await frame.evaluate(isVisibleSel, drawerSelector).catch(() => false);
|
|
2675
|
+
if (!open) {
|
|
2676
|
+
await frame.evaluate((sel) => { const el = document.querySelector(sel); if (el) { el.scrollIntoView({ block: 'center' }); el.click(); } }, triggerSelector).catch(() => {});
|
|
2677
|
+
await new Promise((r) => setTimeout(r, 600));
|
|
2678
|
+
open = await frame.evaluate(isVisibleSel, drawerSelector).catch(() => false);
|
|
2679
|
+
}
|
|
2680
|
+
if (!open) return { acted: false, passed: false, reason: 'drawer did not open via ' + triggerSelector, status: 'FAIL' };
|
|
2681
|
+
|
|
2682
|
+
// 2. 在抽屉内按文本定位目标(模块标题或其下快捷项叶子),标记可点击祖先
|
|
2683
|
+
const located = await frame.evaluate((scope, txt) => {
|
|
2684
|
+
const root = document.querySelector(scope) || document;
|
|
2685
|
+
const leaves = Array.from(root.querySelectorAll('*')).filter((e) => {
|
|
2686
|
+
if (e.childElementCount > 0) return false;
|
|
2687
|
+
const t = (e.innerText || e.textContent || '').trim();
|
|
2688
|
+
return t === txt && e.getBoundingClientRect().width > 0;
|
|
2689
|
+
});
|
|
2690
|
+
if (leaves.length === 0) return { found: false };
|
|
2691
|
+
let target = leaves[0], cur = target;
|
|
2692
|
+
for (let i = 0; i < 5 && cur; i++) { if (cur.className && typeof cur.className === 'string' && /item|menu/i.test(cur.className)) { target = cur; break; } cur = cur.parentElement; }
|
|
2693
|
+
target.setAttribute('data-szcd-switchmod', '1');
|
|
2694
|
+
return { found: true };
|
|
2695
|
+
}, drawerSelector, moduleText).catch(() => ({ found: false }));
|
|
2696
|
+
if (!located.found) return { acted: false, passed: false, reason: 'module/shortcut "' + moduleText + '" not found in drawer', status: 'FAIL' };
|
|
2697
|
+
|
|
2698
|
+
// 3. 原生鼠标点击
|
|
2699
|
+
const handle = await frame.$('[data-szcd-switchmod="1"]');
|
|
2700
|
+
if (!handle) return { acted: false, passed: false, reason: 'marker lost', status: 'FAIL' };
|
|
2701
|
+
const box = await handle.boundingBox();
|
|
2702
|
+
if (box && box.width > 0 && box.height > 0) {
|
|
2703
|
+
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
|
|
2704
|
+
await new Promise((r) => setTimeout(r, 120));
|
|
2705
|
+
await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2);
|
|
2706
|
+
} else {
|
|
2707
|
+
await frame.evaluate(() => { const el = document.querySelector('[data-szcd-switchmod="1"]'); el && el.click(); }).catch(() => {});
|
|
2708
|
+
}
|
|
2709
|
+
await handle.dispose().catch(() => {});
|
|
2710
|
+
await new Promise((r) => setTimeout(r, waitAfter));
|
|
2711
|
+
await frame.evaluate(() => { const el = document.querySelector('[data-szcd-switchmod="1"]'); el && el.removeAttribute('data-szcd-switchmod'); }).catch(() => {});
|
|
2712
|
+
|
|
2713
|
+
// 4. 读取刷新后的 sidebar(该模块完整菜单)
|
|
2714
|
+
const afterSidebar = await readSidebar();
|
|
2715
|
+
const afterUrl = page.url();
|
|
2716
|
+
const sidebarChanged = JSON.stringify(beforeSidebar) !== JSON.stringify(afterSidebar);
|
|
2717
|
+
const routeChanged = afterUrl !== beforeUrl;
|
|
2718
|
+
return {
|
|
2719
|
+
acted: true,
|
|
2720
|
+
passed: sidebarChanged || routeChanged,
|
|
2721
|
+
status: (sidebarChanged || routeChanged) ? 'PASS' : 'FAIL',
|
|
2722
|
+
moduleText,
|
|
2723
|
+
sidebarChanged,
|
|
2724
|
+
routeChanged,
|
|
2725
|
+
beforeSidebar,
|
|
2726
|
+
newSidebar: afterSidebar, // 切换后该模块的完整菜单,供后续 act --menu-path 遍历
|
|
2727
|
+
beforeUrl: beforeUrl.slice(-60),
|
|
2728
|
+
afterUrl: afterUrl.slice(-60),
|
|
2729
|
+
reason: (sidebarChanged || routeChanged) ? undefined : '点击后 sidebar/URL 未变(可能该项无导航或检测时机问题)',
|
|
2730
|
+
};
|
|
2731
|
+
},
|
|
2732
|
+
// 阶段1·全量菜单路由遍历:自动逐个切模块,按 URL 去重,输出完整菜单树。
|
|
2733
|
+
// 产出 modules[],每项 { entryText, url, urlKey, sidebar[] },供阶段2 explore-page 逐 URL 深度探索。
|
|
2734
|
+
// ⚠️ 只做"导航广度"覆盖,不展开页面内部(tab/详情/接口)——那是 explore-page 的职责。
|
|
2735
|
+
'traverse-all-menus': async (page, frame, params) => {
|
|
2736
|
+
const triggerSelector = params.triggerSelector || '.operate';
|
|
2737
|
+
const drawerSelector = params.drawerSelector || '.page-left-menu';
|
|
2738
|
+
const maxModules = params.maxModules || 30;
|
|
2739
|
+
const waitAfter = params.waitAfter || 1600;
|
|
2740
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
2741
|
+
const urlKey = () => { const h = (page.url().split('#/')[1] || ''); return h.split('?')[0].split('/').slice(0, 2).join('/'); };
|
|
2742
|
+
const readSidebar = () => frame.evaluate(() => Array.from(document.querySelectorAll('.ant-menu-item, .ant-menu-submenu-title')).filter((e) => e.offsetParent !== null).map((e) => (e.innerText || e.textContent || '').trim()).filter(Boolean));
|
|
2743
|
+
const isOpen = () => frame.evaluate((sel) => { const el = document.querySelector(sel); if (!el) return false; const r = el.getBoundingClientRect(); const s = getComputedStyle(el); return r.width > 1 && r.height > 1 && s.display !== 'none' && s.visibility !== 'hidden' && s.opacity !== '0'; }, drawerSelector).catch(() => false);
|
|
2744
|
+
const openDrawer = async () => { if (!(await isOpen())) { await frame.evaluate((sel) => { const el = document.querySelector(sel); el && el.click(); }, triggerSelector).catch(() => {}); await sleep(700); } };
|
|
2745
|
+
const closeDrawer = async () => { if (await isOpen()) { await frame.evaluate((sel) => { const el = document.querySelector(sel); el && el.click(); }, triggerSelector).catch(() => {}); await sleep(400); } };
|
|
2746
|
+
|
|
2747
|
+
// 1. 提取模块入口(每模块取首个二级快捷项叶子文本)
|
|
2748
|
+
await openDrawer();
|
|
2749
|
+
const entries = await frame.evaluate((scope) => {
|
|
2750
|
+
const drawer = document.querySelector(scope);
|
|
2751
|
+
if (!drawer) return [];
|
|
2752
|
+
return Array.from(drawer.querySelectorAll('.menu-item, [class*=menu-item]')).map((m) => {
|
|
2753
|
+
const titleEl = m.querySelector('.title, [class*=title]');
|
|
2754
|
+
const moduleTitle = titleEl ? titleEl.textContent.trim() : (m.textContent || '').trim().slice(0, 16);
|
|
2755
|
+
const firstLeaf = Array.from(m.querySelectorAll('span')).find((e) => e.children.length === 0 && e.textContent.trim() && e.textContent.trim().length <= 14);
|
|
2756
|
+
return { moduleTitle, entryText: firstLeaf ? firstLeaf.textContent.trim() : null };
|
|
2757
|
+
}).filter((m) => m.entryText);
|
|
2758
|
+
}, drawerSelector).catch(() => []);
|
|
2759
|
+
await closeDrawer();
|
|
2760
|
+
|
|
2761
|
+
if (entries.length === 0) return { acted: true, passed: false, reason: 'no drawer module entries found (检查 drawerSelector/triggerSelector)', status: 'FAIL' };
|
|
2762
|
+
|
|
2763
|
+
// 2. 逐个切模块 + 按 URL 去重 + 读 sidebar
|
|
2764
|
+
const visited = new Set();
|
|
2765
|
+
const modules = [];
|
|
2766
|
+
let count = 0;
|
|
2767
|
+
for (const ent of entries) {
|
|
2768
|
+
if (count >= maxModules) break;
|
|
2769
|
+
count += 1;
|
|
2770
|
+
// 切模块:开抽屉 → 原生点击入口叶子 → 等 sidebar 刷新
|
|
2771
|
+
await openDrawer();
|
|
2772
|
+
const marked = await frame.evaluate((scope, txt) => {
|
|
2773
|
+
const d = document.querySelector(scope);
|
|
2774
|
+
if (!d) return false;
|
|
2775
|
+
const s = Array.from(d.querySelectorAll('span')).find((e) => e.children.length === 0 && e.textContent.trim() === txt);
|
|
2776
|
+
if (!s) return false;
|
|
2777
|
+
let t = s;
|
|
2778
|
+
for (let i = 0; i < 4 && t; i++) { if (t.className && typeof t.className === 'string' && /item/i.test(t.className)) break; t = t.parentElement; }
|
|
2779
|
+
(t || s).setAttribute('data-szcd-trav', '1');
|
|
2780
|
+
return true;
|
|
2781
|
+
}, drawerSelector, ent.entryText).catch(() => false);
|
|
2782
|
+
if (!marked) { modules.push({ entryText: ent.entryText, moduleTitle: ent.moduleTitle, error: 'entry not found' }); continue; }
|
|
2783
|
+
const handle = await frame.$('[data-szcd-trav="1"]');
|
|
2784
|
+
if (handle) {
|
|
2785
|
+
const box = await handle.boundingBox();
|
|
2786
|
+
if (box && box.width > 0) { await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); await sleep(120); await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2); }
|
|
2787
|
+
await handle.dispose().catch(() => {});
|
|
2788
|
+
}
|
|
2789
|
+
await sleep(waitAfter);
|
|
2790
|
+
await frame.evaluate(() => { const e = document.querySelector('[data-szcd-trav="1"]'); e && e.removeAttribute('data-szcd-trav'); }).catch(() => {});
|
|
2791
|
+
|
|
2792
|
+
const key = urlKey();
|
|
2793
|
+
const url = page.url();
|
|
2794
|
+
if (visited.has(key)) {
|
|
2795
|
+
modules.push({ entryText: ent.entryText, moduleTitle: ent.moduleTitle, urlKey: key, skipped: true, reason: 'duplicate urlKey' });
|
|
2796
|
+
continue;
|
|
2797
|
+
}
|
|
2798
|
+
visited.add(key);
|
|
2799
|
+
const sidebar = await readSidebar();
|
|
2800
|
+
modules.push({ entryText: ent.entryText, moduleTitle: ent.moduleTitle, url, urlKey: key, sidebar });
|
|
2801
|
+
}
|
|
2802
|
+
|
|
2803
|
+
const explored = modules.filter((m) => m.sidebar);
|
|
2804
|
+
return {
|
|
2805
|
+
acted: true,
|
|
2806
|
+
passed: explored.length > 0,
|
|
2807
|
+
status: explored.length > 0 ? 'PASS' : 'FAIL',
|
|
2808
|
+
moduleEntriesFound: entries.length,
|
|
2809
|
+
exploredCount: explored.length,
|
|
2810
|
+
skippedCount: modules.filter((m) => m.skipped).length,
|
|
2811
|
+
modules,
|
|
2812
|
+
note: '导航广度遍历完成。每个 module.url + sidebar 可交给 explore-page 做页面内深度探索(tab/详情/接口/写操作)。',
|
|
2813
|
+
};
|
|
2814
|
+
},
|
|
2815
|
+
// 阶段2·单页深度探索(只读 + 记录写入口,绝不触发写操作)。
|
|
2816
|
+
// 捕获页面加载接口 → 遍历 tab(每个 tab 捕获接口)→ 识别详情入口(点进去捕获,只读)→ 识别写按钮(仅记录不点)。
|
|
2817
|
+
// 安全:只点只读元素(tab/查看/详情/查询);写按钮(新增/编辑/删除/提交/保存/上线/下线/禁用…)只记录文本与分类。
|
|
2818
|
+
// 写操作的真正触发是独立下一步——LLM 拿 writeEntries 给用户确认后再单独执行。
|
|
2819
|
+
'explore-page': async (page, frame, params) => {
|
|
2820
|
+
const maxDepth = Math.min(params.maxDepth || 3, 3);
|
|
2821
|
+
// apiPattern 默认空 = 捕获所有 XHR/Fetch;传了才作为收窄过滤器。
|
|
2822
|
+
// 切忌默认过滤——不同模块接口前缀不同(实测 dp-corpus / dp-label),默认丢弃会漏抓。
|
|
2823
|
+
const apiPattern = params.apiPattern || '';
|
|
2824
|
+
const settleMs = params.settleMs || 1500;
|
|
2825
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
2826
|
+
|
|
2827
|
+
// 写操作关键词(识别用,绝不点击)
|
|
2828
|
+
const WRITE_RE = /新增|新建|创建|添加|编辑|修改|删除|移除|提交|保存|确定|确认|上线|下线|发布|禁用|启用|停用|注册|导入|上传|重置|审核|通过|驳回|撤销/;
|
|
2829
|
+
// 只读动作关键词
|
|
2830
|
+
const READ_RE = /查看|详情|预览|查询|搜索|筛选|展开|刷新/;
|
|
2831
|
+
|
|
2832
|
+
// 自动选「内容最多的 frame」:wujie 微前端常把业务内容放在主 frame 的 shadow DOM,
|
|
2833
|
+
// 而 _getTargetFrame 默认可能选到空的子应用 frame。这里遍历所有 frame(含 shadow 穿透)
|
|
2834
|
+
// 按可点元素数选最丰富的,覆盖传入 frame,避免 frame 选错导致全空。
|
|
2835
|
+
if (!params.frameContentContains && !params.frameUrlContains && params.frameIndex === undefined) {
|
|
2836
|
+
try {
|
|
2837
|
+
let best = frame, bestScore = -1;
|
|
2838
|
+
for (const f of page.frames()) {
|
|
2839
|
+
const score = await f.evaluate(() => {
|
|
2840
|
+
const roots = [document];
|
|
2841
|
+
document.querySelectorAll('wujie-app, [class*=wujie]').forEach((w) => { if (w.shadowRoot) roots.push(w.shadowRoot); });
|
|
2842
|
+
let n = 0;
|
|
2843
|
+
roots.forEach((r) => { try { n += r.querySelectorAll('.ant-btn, button, .ant-tabs-tab, [class*=item], [class*=card]').length; } catch (e) {} });
|
|
2844
|
+
return n;
|
|
2845
|
+
}).catch(() => -1);
|
|
2846
|
+
if (score > bestScore) { bestScore = score; best = f; }
|
|
2847
|
+
}
|
|
2848
|
+
frame = best;
|
|
2849
|
+
} catch (e) { /* 选择失败保持原 frame */ }
|
|
2850
|
+
}
|
|
2851
|
+
|
|
2852
|
+
// 轻量接口监听:自建 CDP Network 缓冲,按需读差量
|
|
2853
|
+
let apiBuffer = [];
|
|
2854
|
+
let cdp = null;
|
|
2855
|
+
try {
|
|
2856
|
+
cdp = await page.target().createCDPSession();
|
|
2857
|
+
await cdp.send('Network.enable');
|
|
2858
|
+
cdp.on('Network.responseReceived', (e) => {
|
|
2859
|
+
const url = e.response && e.response.url || '';
|
|
2860
|
+
const type = e.type || '';
|
|
2861
|
+
if ((type === 'XHR' || type === 'Fetch') && (!apiPattern || url.includes(apiPattern))) {
|
|
2862
|
+
apiBuffer.push({ url: url.slice(0, 200), status: e.response.status, method: (e.response.requestHeaders && e.response.requestHeaders[':method']) || '' });
|
|
2863
|
+
}
|
|
2864
|
+
});
|
|
2865
|
+
} catch (e) { /* 接口捕获不可用不影响其余探索 */ }
|
|
2866
|
+
const drainApis = () => { const b = apiBuffer; apiBuffer = []; return b; };
|
|
2867
|
+
|
|
2868
|
+
const result = { url: page.url(), maxDepth, loadApis: [], tabs: [], detailEntries: [], writeEntries: [], errors: [] };
|
|
2869
|
+
|
|
2870
|
+
// 1. 页面加载接口(settle 一下收集首屏请求)
|
|
2871
|
+
await sleep(settleMs);
|
|
2872
|
+
result.loadApis = drainApis();
|
|
2873
|
+
|
|
2874
|
+
// 2. 识别交互元素(按钮/tab/链接),分类
|
|
2875
|
+
const elements = await frame.evaluate((writeSrc, readSrc) => {
|
|
2876
|
+
const WRITE = new RegExp(writeSrc), READ = new RegExp(readSrc);
|
|
2877
|
+
// 穿透 wujie-app shadowRoot:业务内容常渲染在 shadow DOM 内,普通 querySelectorAll 探不到
|
|
2878
|
+
const roots = [document];
|
|
2879
|
+
document.querySelectorAll('wujie-app, [class*=wujie]').forEach((w) => { if (w.shadowRoot) roots.push(w.shadowRoot); });
|
|
2880
|
+
const queryAll = (sel) => { const out = []; roots.forEach((r) => { try { out.push(...Array.from(r.querySelectorAll(sel))); } catch (e) {} }); return out; };
|
|
2881
|
+
const isVisible = (el) => { const r = el.getBoundingClientRect(); const s = (el.ownerDocument.defaultView || window).getComputedStyle(el); return r.width > 1 && r.height > 1 && s.display !== 'none' && s.visibility !== 'hidden'; };
|
|
2882
|
+
const out = { tabs: [], readBtns: [], writeBtns: [], rawClickables: [], otherBtns: [] };
|
|
2883
|
+
queryAll('.ant-tabs-tab, [role=tab], [class*=tab-item]').filter(isVisible).forEach((el) => {
|
|
2884
|
+
const t = (el.innerText || el.textContent || '').trim();
|
|
2885
|
+
if (t && t.length <= 20 && !out.tabs.includes(t)) out.tabs.push(t);
|
|
2886
|
+
});
|
|
2887
|
+
queryAll('.ant-btn, button, [role=button], a, .ant-table-tbody .ant-space-item').filter(isVisible).forEach((el) => {
|
|
2888
|
+
const t = (el.innerText || el.textContent || '').replace(/\s+/g, '').trim();
|
|
2889
|
+
if (!t || t.length > 12) return;
|
|
2890
|
+
if (WRITE.test(t)) { if (!out.writeBtns.includes(t)) out.writeBtns.push(t); }
|
|
2891
|
+
else if (READ.test(t)) { if (!out.readBtns.includes(t)) out.readBtns.push(t); }
|
|
2892
|
+
else if (!out.otherBtns.includes(t)) out.otherBtns.push(t); // 未匹配读/写词表的按钮,供 LLM 推理归类
|
|
2893
|
+
});
|
|
2894
|
+
// 兜底现场快照:所有可点元素(cursor:pointer / onclick / 自定义可点类)文本采样,
|
|
2895
|
+
// 供 explore-page 标准识别为空时,LLM 直接据此推理非标准入口,无需重新 observe。
|
|
2896
|
+
const seen = new Set();
|
|
2897
|
+
queryAll('[class*=item], [class*=card], [class*=cell], [class*=link], [class*=btn], [onclick], a, li').forEach((el) => {
|
|
2898
|
+
if (!isVisible(el)) return;
|
|
2899
|
+
if (el.childElementCount > 2) return; // 偏叶子,避免大容器
|
|
2900
|
+
const s = (el.ownerDocument.defaultView || window).getComputedStyle(el);
|
|
2901
|
+
if (s.cursor !== 'pointer' && !el.onclick) return;
|
|
2902
|
+
const t = (el.innerText || el.textContent || '').replace(/\s+/g, ' ').trim();
|
|
2903
|
+
if (!t || t.length > 24 || seen.has(t)) return;
|
|
2904
|
+
seen.add(t);
|
|
2905
|
+
if (out.rawClickables.length < 40) out.rawClickables.push(t);
|
|
2906
|
+
});
|
|
2907
|
+
return out;
|
|
2908
|
+
}, WRITE_RE.source, READ_RE.source).catch(() => ({ tabs: [], readBtns: [], writeBtns: [] }));
|
|
2909
|
+
|
|
2910
|
+
result.writeEntries = elements.writeBtns.map((text) => ({ text, category: 'write', note: '已记录,未点击;需用户确认后单独触发' }));
|
|
2911
|
+
|
|
2912
|
+
// 公共:穿透 shadow 按文本定位元素,返回其视口 rect,再用原生鼠标坐标点击。
|
|
2913
|
+
// 解决 frame.$ 选不到 shadow DOM 内元素的问题(wujie WebComponent 模式)。
|
|
2914
|
+
const clickByText = async (selectorCss, text, exactTrim) => {
|
|
2915
|
+
const rect = await frame.evaluate((sel, txt, exact) => {
|
|
2916
|
+
const roots = [document];
|
|
2917
|
+
document.querySelectorAll('wujie-app, [class*=wujie]').forEach((w) => { if (w.shadowRoot) roots.push(w.shadowRoot); });
|
|
2918
|
+
let target = null;
|
|
2919
|
+
for (const r of roots) {
|
|
2920
|
+
const els = Array.from(r.querySelectorAll(sel));
|
|
2921
|
+
target = els.find((e) => {
|
|
2922
|
+
const t = (e.innerText || e.textContent || '');
|
|
2923
|
+
const norm = exact ? t.replace(/\s+/g, '').trim() : t.trim();
|
|
2924
|
+
return norm === txt;
|
|
2925
|
+
});
|
|
2926
|
+
if (target) break;
|
|
2927
|
+
}
|
|
2928
|
+
if (!target) return null;
|
|
2929
|
+
target.scrollIntoView({ block: 'center', inline: 'center' });
|
|
2930
|
+
const b = target.getBoundingClientRect();
|
|
2931
|
+
return { x: b.x + b.width / 2, y: b.y + b.height / 2, w: b.width, h: b.height };
|
|
2932
|
+
}, selectorCss, text, !!exactTrim).catch(() => null);
|
|
2933
|
+
if (!rect || rect.w < 1) return false;
|
|
2934
|
+
await page.mouse.move(rect.x, rect.y);
|
|
2935
|
+
await sleep(80);
|
|
2936
|
+
await page.mouse.click(rect.x, rect.y);
|
|
2937
|
+
return true;
|
|
2938
|
+
};
|
|
2939
|
+
|
|
2940
|
+
// 3. 遍历 tab(depth>=1),每个 tab 切换后捕获接口
|
|
2941
|
+
if (maxDepth >= 1) {
|
|
2942
|
+
for (const tabText of elements.tabs.slice(0, 12)) {
|
|
2943
|
+
try {
|
|
2944
|
+
const clicked = await clickByText('.ant-tabs-tab, [role=tab], [class*=tab-item]', tabText, false);
|
|
2945
|
+
if (!clicked) continue;
|
|
2946
|
+
await sleep(settleMs);
|
|
2947
|
+
result.tabs.push({ tab: tabText, apis: drainApis() });
|
|
2948
|
+
} catch (e) { result.errors.push('tab "' + tabText + '": ' + e.message); }
|
|
2949
|
+
}
|
|
2950
|
+
}
|
|
2951
|
+
|
|
2952
|
+
// 4. 详情入口(depth>=2):行内"查看/详情/管理/明细/检测/进入"等只读管理入口,点进去捕获接口+URL。
|
|
2953
|
+
// 从 readBtns + otherBtns 一起筛("管理数据/检测"等不在读/写词表,落在 otherBtns)。
|
|
2954
|
+
if (maxDepth >= 2) {
|
|
2955
|
+
const DETAIL_RE = /查看|详情|明细|管理|检测|进入|预览/;
|
|
2956
|
+
const candidates = [...elements.readBtns, ...elements.otherBtns].filter((t) => DETAIL_RE.test(t));
|
|
2957
|
+
const detailTexts = Array.from(new Set(candidates)).slice(0, 3);
|
|
2958
|
+
for (const dt of detailTexts) {
|
|
2959
|
+
try {
|
|
2960
|
+
const beforeUrl = page.url();
|
|
2961
|
+
const clicked = await clickByText('.ant-btn, button, a, [role=button], .ant-table-tbody span', dt, true);
|
|
2962
|
+
if (!clicked) continue;
|
|
2963
|
+
await sleep(settleMs);
|
|
2964
|
+
const afterUrl = page.url();
|
|
2965
|
+
const detailApis = drainApis();
|
|
2966
|
+
// 详情内的 tab(depth 3)——穿透 shadow
|
|
2967
|
+
let innerTabs = [];
|
|
2968
|
+
if (maxDepth >= 3) {
|
|
2969
|
+
innerTabs = await frame.evaluate(() => {
|
|
2970
|
+
const roots = [document];
|
|
2971
|
+
document.querySelectorAll('wujie-app, [class*=wujie]').forEach((w) => { if (w.shadowRoot) roots.push(w.shadowRoot); });
|
|
2972
|
+
const out = [];
|
|
2973
|
+
roots.forEach((r) => Array.from(r.querySelectorAll('.ant-tabs-tab, [role=tab]')).forEach((e) => { const b = e.getBoundingClientRect(); const t = (e.innerText || e.textContent || '').trim(); if (b.width > 1 && t && !out.includes(t)) out.push(t); }));
|
|
2974
|
+
return out.slice(0, 8);
|
|
2975
|
+
}).catch(() => []);
|
|
2976
|
+
}
|
|
2977
|
+
result.detailEntries.push({ trigger: dt, urlChanged: afterUrl !== beforeUrl, afterUrl: afterUrl.slice(-60), apis: detailApis, innerTabs });
|
|
2978
|
+
// 关闭详情(抽屉/弹窗)回到列表——穿透 shadow
|
|
2979
|
+
await frame.evaluate(() => {
|
|
2980
|
+
const roots = [document];
|
|
2981
|
+
document.querySelectorAll('wujie-app, [class*=wujie]').forEach((w) => { if (w.shadowRoot) roots.push(w.shadowRoot); });
|
|
2982
|
+
for (const r of roots) {
|
|
2983
|
+
const close = r.querySelector('.ant-drawer-close, .ant-modal-close');
|
|
2984
|
+
if (close) { close.click(); return; }
|
|
2985
|
+
const cancelBtn = Array.from(r.querySelectorAll('.ant-drawer button, .ant-modal button')).find((b) => /取消|关闭/.test(b.textContent || ''));
|
|
2986
|
+
if (cancelBtn) { cancelBtn.click(); return; }
|
|
2987
|
+
}
|
|
2988
|
+
}).catch(() => {});
|
|
2989
|
+
await sleep(600);
|
|
2990
|
+
} catch (e) { result.errors.push('detail "' + dt + '": ' + e.message); }
|
|
2991
|
+
}
|
|
2992
|
+
}
|
|
2993
|
+
|
|
2994
|
+
if (cdp) { try { await cdp.detach(); } catch (e) {} }
|
|
2995
|
+
|
|
2996
|
+
return {
|
|
2997
|
+
acted: true,
|
|
2998
|
+
passed: true,
|
|
2999
|
+
status: 'PASS',
|
|
3000
|
+
...result,
|
|
3001
|
+
otherBtns: elements.otherBtns, // 未匹配读/写词表的按钮,供 LLM 推理归类
|
|
3002
|
+
rawClickables: elements.rawClickables, // 兜底现场快照:所有可点元素文本,标准识别为空时据此推理
|
|
3003
|
+
summary: {
|
|
3004
|
+
loadApiCount: result.loadApis.length,
|
|
3005
|
+
tabCount: result.tabs.length,
|
|
3006
|
+
detailCount: result.detailEntries.length,
|
|
3007
|
+
writeEntryCount: result.writeEntries.length,
|
|
3008
|
+
otherBtnCount: elements.otherBtns.length,
|
|
3009
|
+
rawClickableCount: elements.rawClickables.length,
|
|
3010
|
+
},
|
|
3011
|
+
note: 'writeEntries 仅记录未触发。要执行写操作请用户确认后,单独用 act/antInput/formFill + 原生点击触发。标准识别为空时看 rawClickables/otherBtns 推理非标准入口。',
|
|
3012
|
+
};
|
|
3013
|
+
},
|
|
2432
3014
|
'modal-action': async (page, frame, params) => {
|
|
2433
3015
|
var action = params.action;
|
|
2434
3016
|
var pattern = params.buttonText || (action === 'confirm' ? '确定|OK|确认' : '取消|Cancel');
|