Compare commits

...

11 Commits

Author SHA1 Message Date
yu
47b7a43be2 Merge branch 'main' of http://43.140.215.244:7040/wangyukun/PRA-only-demo 2026-11-05 13:04:49 +08:00
yu
36b268f74c 运行时元素操作跟随前台标签页\n\n- 点搜索/按钮常开新标签页, 此前运行时元素操作仍附着旧标签页,\n 导致新页上的拾取选择器找不到元素、相似元素列表抓到空\n- evaluate 入口增加前台标签页检查(400ms 节流), 变化时 activateTab 切换附着 2026-11-05 13:01:27 +08:00
yu
21a400e172 运行前校验识别循环容器注入的迭代变量\n\n- 数组/字典循环运行时注入 item/index/value, 计数循环注入自定义索引变量\n- 校验器原先只认 flow.variables 与节点输出变量, 对循环体内引用误报未定义变量警告 2026-11-05 12:47:01 +08:00
yu
4af6bd0693 修复模板字面量吃掉正则反斜杠: \\d{6,} 改为 [0-9]{6,}
- 引导脚本为 TS 模板字面量, 编译时 \\d 被当作无效转义吞掉反斜杠,
  运行时正则退化为字面量 d{6,} 永不匹配, 动态id泛化从未真正生效
- 此前端到端验证直接注入源码原文绕过了模板编译, 故未暴露
- 改用 [0-9] 从根源规避转义问题; 已用 bundle 提取的真实运行时脚本验证通过
2026-11-05 12:24:56 +08:00
yu
3e4ded9f5f 动态id泛化阈值放宽为命中1个即泛化, 修复渐进渲染期拾取仍生成唯一id
- 浏览器刚拉起页面渐进渲染时仅 1 张卡, 原 >1 阈值不满足而回退唯一 id
- 动态数字 id 模式下命中 1 个即泛化(与原 id 等价, 零风险), 后续渲染出的同类卡也能命中
2026-11-05 11:53:26 +08:00
yu
6a27ede6d9 元素拾取强制接管残留拾取器, 修复强杀应用后拾取永远走旧逻辑
- 根因: 应用被强杀时页面上残留 active 拾取器(__rpaPickerActive.active=true 永久为真),
  引导脚本防重入守卫直接 return, 之后每次拾取都由旧代码残留监听器响应, 选择器无法更新
- 引导脚本改为强制接管: 发现 active 残留先 cancelHard/cancel 并清状态再启动
- pickElement 注入前同步清理残留拾取器/宿主元素/结果变量
2026-11-05 11:41:41 +08:00
yu
cf1e560802 元素拾取泛化动态记录id: 长数字结尾唯一id自动转为前缀匹配
- 淘宝等站点卡片根节点 id 为动态记录 id(如 item_id_1087512368188), 原样生成导致选择器只命中一张卡
- stable() 检测 id 以长数字(6位以上)结尾时, 若 [id^=前缀] 命中多个元素则优先采用, 使选择器覆盖所有同类卡片
- 拾取器与元素引用两处 cssPath 同步修改
2026-11-05 11:15:36 +08:00
yu
309898045c 元素拾取悬停0.3秒自动钻入子元素, 无需先锁定再调层级
- 进入新交互区域先吸附交互元素, 悬停300ms后自动钻入光标最深处
- 钻入后跟随光标在卡片内部移动; PageUp/PageDown 保留为手动上下调层(未锁定时直接锁定)
2026-11-05 11:11:03 +08:00
yu
150d7ce9c9 元素拾取支持钻入子元素: PageDown 沿锁定元素向下进入卡片内部子节点
- 悬停时记录光标下最深元素, 锁定时把锁定元素到最深元素的子级路径并入调整链
- 场景: 淘宝搜索结果整卡为链接, 标题/价格是链接子元素, 此前只能沿祖先向上调层级选不到
2026-11-05 10:57:24 +08:00
yu
91d998b2ea 元素拾取跟随前台标签页: 点搜索等开新标签页后附着目标自动切换
- pickElement 轮询前经 /json/list 检查前台标签页, 变化时 activateTab 切换附着并重新注册/注入拾取脚本
- 结束时对所有已附着标签页清理拾取残留, 并移除全部 addScriptToEvaluateOnNewDocument 注入
2026-11-05 10:12:59 +08:00
yu
5250bf1e35 元素拾取支持页面跳转后继续拾取; 恢复 ws 根依赖声明修复启动崩溃
- pickElement 重构: 拾取脚本经 Page.addScriptToEvaluateOnNewDocument 随新文档自动重注入,
  主进程轮询 __rpaPickResult, 用户拾取期间可翻页/搜索/过滑块, 页面跳转不再使拾取失效
- 恢复 package.json ws 根依赖与 electron.vite.config include:[ws]
  (合并时被覆盖导致 dev 启动报 bufferutil 解析错误)
2026-11-05 10:07:48 +08:00
4 changed files with 392 additions and 236 deletions

1
package-lock.json generated
View File

@ -34,6 +34,7 @@
"react-dom": "^18.3.1",
"sm-crypto": "^0.5.8",
"tesseract.js": "^7.0.0",
"ws": "^8.18.0",
"zustand": "^5.0.2"
},
"devDependencies": {

View File

@ -46,7 +46,7 @@
"react-dom": "^18.3.1",
"sm-crypto": "^0.5.8",
"tesseract.js": "^7.0.0",
"ws": "^8.22.0",
"ws": "^8.18.0",
"zustand": "^5.0.2"
},
"devDependencies": {

View File

@ -192,7 +192,22 @@ export class BrowserSession implements BrowserLike {
return this.transport.send<T>(method, params);
}
private lastFollowCheck = 0;
/** 运行时跟随前台标签页:点按钮/搜索常开新标签页,元素操作应落在用户眼前看到的页面(节流 400ms) */
private async followForegroundTab(): Promise<void> {
if (this.win || !this.httpBase) return;
const now = Date.now();
if (now - this.lastFollowCheck < 400) return;
this.lastFollowCheck = now;
const pages = await listPageTargets(this.httpBase).catch(() => []);
const page = pages[0];
if (!page || page.id === this.activeTargetId) return;
await this.activateTab(page.id);
}
private async evaluate<T = unknown>(expression: string): Promise<T> {
await this.followForegroundTab().catch(() => {});
const result = await this.cdp<{ result?: { value?: T }; exceptionDetails?: { exception?: { description?: string }; text?: string } }>(
"Runtime.evaluate",
{ expression, returnByValue: true, awaitPromise: true }
@ -297,8 +312,18 @@ export class BrowserSession implements BrowserLike {
const localPath = (start, root) => {
const stable = (item) => {
if (item.id) {
// 长数字结尾的唯一 id 多为动态记录 id(如淘宝 item_id_1087512368188),泛化为前缀匹配以命中所有同类卡片
const dynamic = /^(.*?[-_])[0-9]{6,}$/.exec(item.id);
if (dynamic) {
const generalized = '[id^="' + cssEscape(dynamic[1]) + '"]';
try { if (root.querySelectorAll(generalized).length >= 1) return generalized; } catch {}
}
const candidate = '#' + cssEscape(item.id);
try { if (root.querySelectorAll(candidate).length === 1) return candidate; } catch {}
if (dynamic) {
const generalized = '[id^="' + cssEscape(dynamic[1]) + '"]';
try { if (root.querySelectorAll(generalized).length >= 1) return generalized; } catch {}
}
}
const testId = item.getAttribute('data-testid');
if (testId) {
@ -1153,7 +1178,8 @@ export class BrowserSession implements BrowserLike {
}
async pickElement(timeoutMs = 90000): Promise<import("../../shared/types").ElementPickResult> {
// 拾取脚本经 Runtime.evaluate 注入并等待用户点击,内置/外部浏览器行为一致
// 拾取脚本注入当前文档,并经 Page.addScriptToEvaluateOnNewDocument 随页面跳转自动重注入;
// 主进程轮询 window.__rpaPickResult——用户在拾取期间可随意翻页/搜索/过滑块,停在哪就能拾哪。
const url = await this.currentUrl();
if (!url || url === "about:blank") {
throw new RuntimeError("no_page", "浏览器还没有打开网页", { recommendation: "先运行或配置「打开网页」,再拾取元素" });
@ -1162,241 +1188,63 @@ export class BrowserSession implements BrowserLike {
this.win.show();
this.win.focus();
}
const result = await this.evaluate<ElementPickResult>(`(() => new Promise((resolve, reject) => {
const timeout = ${JSON.stringify(timeoutMs)};
if (window.__rpaElementPicker?.cancel) window.__rpaElementPicker.cancel();
let host = null;
let root = null;
let box = null;
let badge = null;
let target = null;
let locked = false;
let chain = [];
let chainIndex = 0;
let selected = null;
let timer = 0;
let finished = false;
const cssEscape = (value) => {
if (globalThis.CSS && typeof CSS.escape === 'function') return CSS.escape(String(value));
return String(value).replace(/[^a-zA-Z0-9_-]/g, (char) => '\\\\' + char);
};
const isUnique = (candidate) => {
try { return document.querySelectorAll(candidate).length === 1; } catch { return false; }
};
const cssPath = (node) => {
const localPath = (start, root) => {
const stable = (item) => {
if (item.id) {
const candidate = '#' + cssEscape(item.id);
try { if (root.querySelectorAll(candidate).length === 1) return candidate; } catch {}
}
const testId = item.getAttribute('data-testid');
if (testId) {
const candidate = '[data-testid="' + cssEscape(testId) + '"]';
try { if (root.querySelectorAll(candidate).length === 1) return candidate; } catch {}
}
return null;
};
const parts = [];
let current = start;
while (current && current.nodeType === 1) {
const stableCandidate = stable(current);
if (stableCandidate) {
parts.unshift(stableCandidate);
return parts.join(' > ');
}
let part = current.tagName.toLowerCase();
const parent = current.parentElement;
if (parent) {
const siblings = [...parent.children].filter((item) => item.tagName === current.tagName);
if (siblings.length > 1) part += ':nth-of-type(' + (siblings.indexOf(current) + 1) + ')';
}
parts.unshift(part);
current = parent;
}
return parts.join(' > ');
};
const trees = [];
let current = node;
while (current && current.nodeType === 1) {
const root = current.getRootNode();
trees.unshift(localPath(current, root));
current = root instanceof ShadowRoot ? root.host : null;
const inject = () =>
this.evaluate(
`(() => {
try { window.__rpaElementPicker?.cancelHard?.(); window.__rpaElementPicker?.cancel?.(); } catch {}
try { window.__rpaPickerActive = undefined; } catch {}
try { document.getElementById('__rpa_pick_host')?.remove(); } catch {}
try { delete window.__rpaPickResult; ${PICKER_BOOTSTRAP_SOURCE} } catch {}
})()`
);
const injectionIds: string[] = [];
const registerInjection = async () => {
const registered = await this.cdp<{ identifier?: string }>("Page.addScriptToEvaluateOnNewDocument", {
source: PICKER_BOOTSTRAP_SOURCE
}).catch(() => null);
if (registered?.identifier) injectionIds.push(registered.identifier);
};
// 跟随前台标签页:搜索/点按钮常开新标签页,附着目标切过去并重新注入,拾取不中断
const followForegroundTab = async () => {
if (this.win || !this.httpBase) return;
const pages = await listPageTargets(this.httpBase).catch(() => []);
const page = pages[0];
if (!page || page.id === this.activeTargetId) return;
await this.activateTab(page.id);
await this.cdp("Page.enable").catch(() => {});
await registerInjection();
await inject();
};
await this.cdp("Page.enable").catch(() => {});
await registerInjection();
try {
await inject();
const deadline = Date.now() + Math.max(5000, timeoutMs);
while (Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, 400));
await followForegroundTab().catch(() => {});
const state = await this.evaluate<{ result: { ok: boolean; value?: ElementPickResult; reason?: string } | null; active: boolean }>(
"({ result: window.__rpaPickResult ?? null, active: window.__rpaPickerActive?.active === true })"
).catch(() => null);
if (!state) continue; // 页面跳转中:等待新文档自动注入
if (state.result) {
if (state.result.ok && state.result.value) return state.result.value;
throw new RuntimeError("cancelled", state.result.reason || "已取消元素拾取");
}
return trees.join(' >>> ');
};
const describe = (node) => {
const rect = node.getBoundingClientRect();
return {
selector: 'css:' + cssPath(node),
tag: node.tagName.toLowerCase(),
id: node.id || undefined,
className: node.getAttribute('class') || undefined,
name: node.getAttribute('name') || undefined,
type: node.getAttribute('type') || undefined,
placeholder: node.getAttribute('placeholder') || undefined,
label: node.getAttribute('aria-label') || node.getAttribute('placeholder') || node.getAttribute('name') || (node.innerText || '').trim().slice(0, 100) || undefined,
text: (node.innerText || node.textContent || '').trim().slice(0, 200) || undefined,
value: String(node.value ?? '') || undefined,
url: location.href
};
};
const nodeName = (node) => node.getAttribute('aria-label') || node.getAttribute('placeholder') || node.getAttribute('name') || node.id || (node.innerText || '').trim().slice(0, 60) || '';
const updateBox = () => {
const node = locked ? selected : target;
if (!node || !box || !badge) return;
const rect = node.getBoundingClientRect();
box.style.left = Math.max(0, rect.left - 3) + 'px';
box.style.top = Math.max(0, rect.top - 3) + 'px';
box.style.width = Math.max(4, rect.width + 6) + 'px';
box.style.height = Math.max(4, rect.height + 6) + 'px';
const tag = node.tagName.toLowerCase();
const name = nodeName(node);
if (locked) {
badge.textContent = '已锁定 ' + (chainIndex + 1) + '/' + chain.length + ' · ' + tag + (name ? ' · ' + name.slice(0, 42) : '') + ' · [PageUp/PageDown]调层级 [空格]确认 [Esc]取消';
} else {
badge.textContent = '拾取 · ' + tag + (name ? ' · ' + name.slice(0, 42) : '') + ' · [空格]锁定 [Esc]取消';
}
badge.style.left = Math.max(0, rect.left) + 'px';
badge.style.top = Math.max(0, rect.top - 34) + 'px';
};
const interactiveSelector = [
'button', 'a', 'input', 'select', 'textarea', 'summary', 'label', 'option',
'[role]', '[onclick]', '[contenteditable]', '[tabindex]'
].join(',');
const closestInteractive = (start) => {
let node = start;
while (node && node.nodeType === 1) {
try { if (node.matches(interactiveSelector)) return node; } catch {}
if (node.parentElement) {
node = node.parentElement;
continue;
}
const root = node.getRootNode();
node = root instanceof ShadowRoot ? root.host : null;
}
return start;
};
const firstElement = (event) => {
let deepest = null;
for (const item of event.composedPath()) {
if (item instanceof Element) {
deepest = item;
break;
}
}
if (!deepest && event.target instanceof Element) deepest = event.target;
return closestInteractive(deepest);
};
const buildChain = (node) => {
const result = [];
let current = node;
while (current && current.nodeType === 1) {
result.unshift(current);
const root = current.getRootNode();
current = root instanceof ShadowRoot ? root.host : null;
}
return result;
};
const lock = () => {
if (locked || !target) return;
locked = true;
chain = buildChain(target);
chainIndex = Math.max(0, chain.length - 1);
selected = chain[chainIndex] || target;
updateBox();
};
const adjustLevel = (delta) => {
if (!locked || !chain.length) return;
const next = Math.min(chain.length - 1, Math.max(0, chainIndex + delta));
if (next === chainIndex) return;
chainIndex = next;
selected = chain[chainIndex];
updateBox();
};
const confirm = () => {
if (!locked || !selected) return;
cleanup();
resolve(describe(selected));
};
const move = (event) => {
if (finished || locked) return;
target = firstElement(event);
updateBox();
};
const click = (event) => {
if (finished) return;
// 与桌面拾取对齐:鼠标只用于定位,空格锁定/确认,避免误触发页面按钮。
event.preventDefault();
event.stopPropagation();
event.stopImmediatePropagation();
};
const key = (event) => {
if (event.key === 'Escape') {
event.preventDefault();
event.stopPropagation();
cleanup();
reject(new Error('已取消元素拾取'));
return;
}
if (event.code === 'Space' && !event.repeat) {
event.preventDefault();
event.stopPropagation();
if (locked) confirm();
else lock();
return;
}
if (locked && event.key === 'PageUp' && !event.repeat) {
event.preventDefault();
event.stopPropagation();
adjustLevel(-1);
return;
}
if (locked && event.key === 'PageDown' && !event.repeat) {
event.preventDefault();
event.stopPropagation();
adjustLevel(1);
}
};
const cleanup = () => {
if (finished) return;
finished = true;
clearTimeout(timer);
window.removeEventListener('mousemove', move, true);
window.removeEventListener('mousedown', click, true);
window.removeEventListener('click', click, true);
window.removeEventListener('scroll', updateBox, true);
window.removeEventListener('resize', updateBox);
window.removeEventListener('keydown', key, true);
host?.remove();
delete window.__rpaElementPicker;
};
host = document.createElement('div');
host.style.cssText = 'position:fixed;inset:0;width:100vw;height:100vh;z-index:2147483647;pointer-events:none;';
root = host.attachShadow({ mode: 'closed' });
box = document.createElement('div');
badge = document.createElement('div');
box.style.cssText = 'position:fixed;border:2px solid #2563eb;background:rgba(37,99,235,.16);border-radius:3px;pointer-events:none;';
badge.style.cssText = 'position:fixed;z-index:2147483647;padding:4px 7px;border-radius:6px;background:#2563eb;color:#fff;font:600 12px/16px system-ui,sans-serif;pointer-events:none;white-space:nowrap;';
root.append(box, badge);
document.documentElement.append(host);
window.addEventListener('mousemove', move, true);
window.addEventListener('mousedown', click, true);
window.addEventListener('click', click, true);
window.addEventListener('scroll', updateBox, true);
window.addEventListener('resize', updateBox);
window.addEventListener('keyup', () => {}, true);
window.addEventListener('keydown', key, true);
window.__rpaElementPicker = { cancel: () => { cleanup(); reject(new Error('已取消元素拾取')); } };
timer = setTimeout(() => {
cleanup();
reject(new Error('拾取超时,请重新点击拾取'));
}, timeout);
}))()`);
return result;
if (!state.active) await inject().catch(() => {}); // 注入未生效时兜底重注入
}
throw new RuntimeError("timeout", "拾取超时,请重新点击拾取");
} finally {
const cleanupExpr =
"(() => { try { window.__rpaElementPicker?.cancelHard?.(); window.__rpaPickerActive = undefined; delete window.__rpaPickResult; } catch {} })()";
await this.evaluate(cleanupExpr).catch(() => {});
for (const tabTransport of this.tabTransports.values()) {
await tabTransport.send("Runtime.evaluate", { expression: cleanupExpr }).catch(() => {});
}
for (const identifier of injectionIds) {
await this.cdp("Page.removeScriptToEvaluateOnNewDocument", { identifier }).catch(() => {});
}
}
}
async close(): Promise<void> {
@ -1429,6 +1277,299 @@ declare global {
var __rpaExternalSessions: Map<string, BrowserSession> | undefined;
}
const PICKER_BOOTSTRAP_SOURCE = `(() => {
if (window.__rpaPickerActive && window.__rpaPickerActive.active) {
// 强杀应用等异常场景会留下 active 残留拾取器, 若直接 return 新拾取永远不生效, 必须强制接管
try { window.__rpaElementPicker?.cancelHard?.(); window.__rpaElementPicker?.cancel?.(); } catch {}
window.__rpaPickerActive = undefined;
document.getElementById('__rpa_pick_host')?.remove();
}
window.__rpaPickResult = null;
let host = null;
let root = null;
let box = null;
let badge = null;
let target = null;
let deepTarget = null;
let lastSnap = null;
let drilled = false;
let hoverTimer = 0;
let locked = false;
let chain = [];
let chainIndex = 0;
let selected = null;
let finished = false;
const cssEscape = (value) => {
if (globalThis.CSS && typeof CSS.escape === 'function') return CSS.escape(String(value));
return String(value).replace(/[^a-zA-Z0-9_-]/g, (char) => '\\\\' + char);
};
const isUnique = (candidate) => {
try { return document.querySelectorAll(candidate).length === 1; } catch { return false; }
};
const cssPath = (node) => {
const localPath = (start, root) => {
const stable = (item) => {
if (item.id) {
// 长数字结尾的唯一 id 多为动态记录 id(如淘宝 item_id_1087512368188),泛化为前缀匹配以命中所有同类卡片
const dynamic = /^(.*?[-_])[0-9]{6,}$/.exec(item.id);
if (dynamic) {
const generalized = '[id^="' + cssEscape(dynamic[1]) + '"]';
try { if (root.querySelectorAll(generalized).length >= 1) return generalized; } catch {}
}
const candidate = '#' + cssEscape(item.id);
try { if (root.querySelectorAll(candidate).length === 1) return candidate; } catch {}
if (dynamic) {
const generalized = '[id^="' + cssEscape(dynamic[1]) + '"]';
try { if (root.querySelectorAll(generalized).length >= 1) return generalized; } catch {}
}
}
const testId = item.getAttribute('data-testid');
if (testId) {
const candidate = '[data-testid="' + cssEscape(testId) + '"]';
try { if (root.querySelectorAll(candidate).length === 1) return candidate; } catch {}
}
return null;
};
const parts = [];
let current = start;
while (current && current.nodeType === 1) {
const stableCandidate = stable(current);
if (stableCandidate) {
parts.unshift(stableCandidate);
return parts.join(' > ');
}
let part = current.tagName.toLowerCase();
const parent = current.parentElement;
if (parent) {
const siblings = [...parent.children].filter((item) => item.tagName === current.tagName);
if (siblings.length > 1) part += ':nth-of-type(' + (siblings.indexOf(current) + 1) + ')';
}
parts.unshift(part);
current = parent;
}
return parts.join(' > ');
};
const trees = [];
let current = node;
while (current && current.nodeType === 1) {
const root = current.getRootNode();
trees.unshift(localPath(current, root));
current = root instanceof ShadowRoot ? root.host : null;
}
return trees.join(' >>> ');
};
const describe = (node) => {
const rect = node.getBoundingClientRect();
return {
selector: 'css:' + cssPath(node),
tag: node.tagName.toLowerCase(),
id: node.id || undefined,
className: node.getAttribute('class') || undefined,
name: node.getAttribute('name') || undefined,
type: node.getAttribute('type') || undefined,
placeholder: node.getAttribute('placeholder') || undefined,
label: node.getAttribute('aria-label') || node.getAttribute('placeholder') || node.getAttribute('name') || (node.innerText || '').trim().slice(0, 100) || undefined,
text: (node.innerText || node.textContent || '').trim().slice(0, 200) || undefined,
value: String(node.value ?? '') || undefined,
url: location.href
};
};
const nodeName = (node) => node.getAttribute('aria-label') || node.getAttribute('placeholder') || node.getAttribute('name') || node.id || (node.innerText || '').trim().slice(0, 60) || '';
const updateBox = () => {
const node = locked ? selected : target;
if (!node || !box || !badge) return;
const rect = node.getBoundingClientRect();
box.style.left = Math.max(0, rect.left - 3) + 'px';
box.style.top = Math.max(0, rect.top - 3) + 'px';
box.style.width = Math.max(4, rect.width + 6) + 'px';
box.style.height = Math.max(4, rect.height + 6) + 'px';
const tag = node.tagName.toLowerCase();
const name = nodeName(node);
if (locked) {
badge.textContent = '已锁定 ' + (chainIndex + 1) + '/' + chain.length + ' · ' + tag + (name ? ' · ' + name.slice(0, 42) : '') + ' · [PageUp]升 [PageDown]钻入子元素 [空格]确认 [Esc]取消';
} else {
badge.textContent = '拾取 · ' + tag + (name ? ' · ' + name.slice(0, 42) : '') + ' · 悬停0.3秒自动钻入 · [PageUp/PageDown]手动调层 [空格]确认 [Esc]取消 · 可直接翻页后继续拾取';
}
badge.style.left = Math.max(0, rect.left) + 'px';
badge.style.top = Math.max(0, rect.top - 34) + 'px';
};
const interactiveSelector = [
'button', 'a', 'input', 'select', 'textarea', 'summary', 'label', 'option',
'[role]', '[onclick]', '[contenteditable]', '[tabindex]'
].join(',');
const closestInteractive = (start) => {
let node = start;
while (node && node.nodeType === 1) {
try { if (node.matches(interactiveSelector)) return node; } catch {}
if (node.parentElement) {
node = node.parentElement;
continue;
}
const root = node.getRootNode();
node = root instanceof ShadowRoot ? root.host : null;
}
return start;
};
const deepestOf = (event) => {
let deepest = null;
for (const item of event.composedPath()) {
if (item instanceof Element) {
deepest = item;
break;
}
}
if (!deepest && event.target instanceof Element) deepest = event.target;
return deepest;
};
const firstElement = (event) => {
return closestInteractive(deepestOf(event));
};
const buildChain = (node) => {
const result = [];
let current = node;
while (current && current.nodeType === 1) {
result.unshift(current);
const root = current.getRootNode();
current = root instanceof ShadowRoot ? root.host : null;
}
return result;
};
const lockAt = (base) => {
if (locked || !base) return;
locked = true;
chain = buildChain(base);
chainIndex = Math.max(0, chain.length - 1);
// 把光标下最深处元素到锁定元素之间的子级路径并入链,PageDown/PageUp 可在卡片与其内部子元素间切换
if (deepTarget && base !== deepTarget && base.contains(deepTarget)) {
const below = [];
let current = deepTarget;
while (current && current !== base && base.contains(current)) {
below.unshift(current);
const root = current.getRootNode();
current = root instanceof ShadowRoot ? root.host : current.parentElement;
}
chain = chain.concat(below);
}
selected = chain[chainIndex] || base;
updateBox();
};
const lock = () => lockAt(target);
const adjustLevel = (delta) => {
if (!locked || !chain.length) return;
const next = Math.min(chain.length - 1, Math.max(0, chainIndex + delta));
if (next === chainIndex) return;
chainIndex = next;
selected = chain[chainIndex];
updateBox();
};
const confirm = () => {
if (!locked || !selected) return;
finish({ ok: true, value: describe(selected) });
};
const move = (event) => {
if (finished || locked) return;
deepTarget = deepestOf(event);
const snapped = firstElement(event);
if (snapped !== lastSnap) {
// 进入新的交互区域:先吸附交互元素,悬停 0.3 秒后自动钻入光标最深处
lastSnap = snapped;
drilled = false;
target = snapped;
clearTimeout(hoverTimer);
if (deepTarget && deepTarget !== snapped) {
hoverTimer = setTimeout(() => {
if (finished || locked) return;
drilled = true;
target = deepTarget;
updateBox();
}, 300);
}
updateBox();
return;
}
if (drilled && deepTarget && snapped.contains(deepTarget)) {
target = deepTarget; // 已钻入:跟随光标在卡片内部移动
}
updateBox();
};
const click = (event) => {
if (finished) return;
event.preventDefault();
event.stopPropagation();
event.stopImmediatePropagation();
};
const key = (event) => {
if (event.key === 'Escape') {
event.preventDefault();
event.stopPropagation();
finish({ ok: false, reason: '已取消元素拾取' });
return;
}
if (event.code === 'Space' && !event.repeat) {
event.preventDefault();
event.stopPropagation();
if (locked) confirm();
else lock();
return;
}
if (event.key === 'PageDown' && !event.repeat) {
event.preventDefault();
event.stopPropagation();
if (locked) adjustLevel(1);
else lockAt(deepTarget || target); // 未锁定时直接锁定光标最深处(如卡片内的标题)
return;
}
if (event.key === 'PageUp' && !event.repeat) {
event.preventDefault();
event.stopPropagation();
if (locked) adjustLevel(-1);
else if (target) lockAt(target); // 未锁定时锁定吸附到的交互元素(如整张卡片)
}
};
const cleanup = () => {
if (finished) return;
finished = true;
clearTimeout(hoverTimer);
window.removeEventListener('mousemove', move, true);
window.removeEventListener('mousedown', click, true);
window.removeEventListener('click', click, true);
window.removeEventListener('scroll', updateBox, true);
window.removeEventListener('resize', updateBox);
window.removeEventListener('keydown', key, true);
host && host.remove();
window.__rpaElementPicker = null;
window.__rpaPickerActive = { active: false };
};
const finish = (payload) => {
cleanup();
window.__rpaPickResult = payload;
};
host = document.createElement('div');
host.id = '__rpa_pick_host';
host.style.cssText = 'position:fixed;inset:0;width:100vw;height:100vh;z-index:2147483647;pointer-events:none;';
root = host.attachShadow({ mode: 'closed' });
box = document.createElement('div');
badge = document.createElement('div');
box.style.cssText = 'position:fixed;border:2px solid #2563eb;background:rgba(37,99,235,.16);border-radius:3px;pointer-events:none;';
badge.style.cssText = 'position:fixed;z-index:2147483647;padding:4px 7px;border-radius:6px;background:#2563eb;color:#fff;font:600 12px/16px system-ui,sans-serif;pointer-events:none;white-space:nowrap;';
root.append(box, badge);
document.documentElement.append(host);
window.addEventListener('mousemove', move, true);
window.addEventListener('mousedown', click, true);
window.addEventListener('click', click, true);
window.addEventListener('scroll', updateBox, true);
window.addEventListener('resize', updateBox);
window.addEventListener('keydown', key, true);
window.__rpaElementPicker = {
cancel: () => { if (!finished) finish({ ok: false, reason: '已取消元素拾取' }); },
cancelHard: () => { cleanup(); }
};
window.__rpaPickerActive = { active: true };
})();`;
function externalSessionCache(): Map<string, BrowserSession> {
if (!globalThis.__rpaExternalSessions) globalThis.__rpaExternalSessions = new Map();
return globalThis.__rpaExternalSessions;

View File

@ -122,6 +122,20 @@ export function validateFlow(flow: FlowDefinition, catalog: Catalog): FlowValida
...(flow.variables ?? []).map((item) => item.name),
...flow.nodes.map((node) => node.options?.outputVar).filter(Boolean) as string[]
]);
// 循环容器在运行时向子节点注入迭代变量,静态校验时视为已定义
const LOOP_VARS: Record<string, string[]> = {
logic_control_array_for_v3: ["item", "index", "value"],
logic_control_dictionary_for_v1: ["item", "index", "value"],
logic_control_count_for: ["index"]
};
for (const node of flow.nodes) {
const vars = LOOP_VARS[node.componentId];
if (vars) {
const custom = String(node.values?.count_index ?? "").trim();
for (const name of vars) definedVars.add(name);
if (node.componentId === "logic_control_count_for" && /^[A-Za-z_]\w*$/.test(custom)) definedVars.add(custom);
}
}
const variableRef = /\$\{\s*([A-Za-z_\u4e00-\u9fa5][\w\u4e00-\u9fa5]*)\s*(?:\.[A-Za-z_]\w*)?\s*\}/g;
for (const node of flow.nodes) {
const component = componentMap.get(node.componentId);