feat: 接入常用网页层级与数据表能力

This commit is contained in:
yu 2026-10-27 15:52:30 +08:00
parent 741dc773c1
commit fbdfb782bb
11 changed files with 958 additions and 3 deletions

View File

@ -51,7 +51,7 @@ docs 现状分析、实施方案
## 边界
- 1016 项目录中当前有 428 项通过能力注册表或运行时容器标记为可执行,覆盖控制/触发、字符串/集合/INI/二维表格与 Databook、数组重塑、时间转换、文件、浏览器元素常用及 DOM 高级能力、HTTP、Excel/CSV与表格辅助、系统路径与环境变量、进程管理、桌面键鼠/窗口对象/剪贴板/对话框、PDF、Word 文档编辑、SMTP 邮件变体、文本差异、工作日判断、屏幕截图、离线图像查找与点击;其余灰标「待接入」。运行前检查会提前定位待接入组件、必填参数、多出口与断链节点。
- 1016 项目录中当前有 509 项通过能力注册表或运行时容器标记为可执行,覆盖控制/触发、除数据库外的数据处理、文件、网页元素层级/表格/下拉框及 DOM 高级能力、HTTP、Excel 常用历史版本与表格辅助、系统路径与环境变量、进程管理、桌面键鼠/窗口对象/剪贴板/对话框、PDF、Word 文档编辑、SMTP 邮件变体、文本差异、工作日判断、屏幕截图、离线图像查找与点击;其余灰标「待接入」。运行前检查会提前定位待接入组件、必填参数、多出口与断链节点。
- 内置本地演示站默认监听 `127.0.0.1:17863`;新用户首次启动会获得“本地网页订单演示”任务,可离线验证网页输入、点击和文本读取。
- 浏览器可信输入与定位机制参考 `C:\rpa-demo`:CDP Input、唯一定位、遮挡检查和输入回读校验;不使用 `el.click()` 伪造点击。
- 不包含厂商代码、二进制、模型或前端资产;目录是从厂商元数据派生的静态定义。

View File

@ -4,7 +4,7 @@
> 依据:`D:\code\Agent-Employee`(只读)、`C:\rpa-demo`(只读)、2026-10-26 需求确认
> 本方案是执行基线:范围、契约、排期、验收、降级顺序都在这里;实施中变更必须回写本文件。
> **2026-10-27 当前状态**:计划点名的代表能力已全部进入注册表或运行时容器,覆盖控制/触发、字符串/集合/INI/二维表格与 Databook、数组重塑、时间转换、文件、浏览器元素常用及 DOM 高级能力、HTTP、Excel/CSV与表格辅助、系统路径与环境变量、进程管理、桌面键鼠/窗口对象/剪贴板/对话框、PDF、Word 文档编辑、SMTP 邮件变体、文本差异、工作日判断、屏幕截图、离线图像查找与点击;启动后目录显示 **428 项可执行 / 1016 项总目录**。当前 17 个测试文件共 110 条测试,覆盖计划能力审计、浏览器参数路由/变体/DOM、Excel/PDF/Word COM/SMTP/模板匹配、文件与数据扩展、系统/进程/窗口解析、本地工具与 Databook、直线/IF/循环/重试/跳过、主出口与异常出口语义、运行记录回写、运行前检查、本地演示站与示例流程。设置页可查看分类覆盖和处理器 ID 数量。未通过实际场景验证的变体仍不计入最终交付数量。
> **2026-10-27 当前状态**:计划点名的代表能力已全部进入注册表或运行时容器,覆盖控制/触发、除数据库外的数据处理、文件、网页元素层级/表格/下拉框及 DOM 高级能力、HTTP、Excel 常用历史版本与表格辅助、系统路径与环境变量、进程管理、桌面键鼠/窗口对象/剪贴板/对话框、PDF、Word 文档编辑、SMTP 邮件变体、文本差异、工作日判断、屏幕截图、离线图像查找与点击;启动后目录显示 **509 项可执行 / 1016 项总目录**。当前 20 个测试文件共 124 条测试,覆盖计划能力审计、浏览器参数路由/变体/DOM层级、Excel/PDF/Word COM/SMTP/模板匹配、文件与数据表扩展、系统/进程/窗口解析、本地工具与 Databook、直线/IF/循环/重试/跳过、主出口与异常出口语义、运行记录回写、运行前检查、本地演示站与示例流程。数据处理仅剩 8 个明确排除的数据库组件。设置页可查看分类覆盖和处理器 ID 数量。未通过实际场景验证的变体仍不计入最终交付数量。
> 浏览器端到端门禁:`npm run test:e2e` 使用隔离用户目录启动应用和本地演示站,真实执行打开网页、两次可信输入、点击提交和读取结果;期望结果必须精确包含“订单创建成功:RPA 客户,金额 128”。浏览器输入、点击、定位歧义和遮挡检查机制已对照 `C:\rpa-demo/actions.js` 校准。

View File

@ -1,7 +1,15 @@
import { BrowserWindow } from "electron";
import { promises as fs } from "node:fs";
import path from "node:path";
import { RuntimeError, type BrowserElementRegion, type BrowserLike } from "../engine/types";
import {
RuntimeError,
type BrowserElementInfo,
type BrowserElementReference,
type BrowserElementRegion,
type BrowserLike,
type BrowserSelectState,
type BrowserSimilarElementOptions
} from "../engine/types";
const DEFAULT_DOMAINS = ["127.0.0.1", "localhost", "example.com"];
@ -70,6 +78,7 @@ function selectorSpec(selector: unknown): { kind: string; value: string; role?:
const text = String(selector ?? "").trim();
if (!text) throw new RuntimeError("validation_error", "元素选择器为空", { recommendation: "使用 css:#id、text:提交 或 aria:button=提交" });
if (text.startsWith("css:")) return { kind: "css", value: text.slice(4) };
if (text.startsWith("xpath:")) return { kind: "xpath", value: text.slice(6) };
if (text.startsWith("testid:")) return { kind: "css", value: `[data-testid="${text.slice(7).replace(/"/g, '\\"')}"]` };
if (text.startsWith("text:")) return { kind: "text", value: text.slice(5) };
if (text.startsWith("aria:")) {
@ -148,6 +157,9 @@ export class BrowserSession implements BrowserLike {
const result = await this.evaluate<{ found: boolean; value?: T }>(`(() => {
const spec = ${JSON.stringify(spec)};
const find = () => {
if (spec.kind === 'xpath') {
return document.evaluate(spec.value, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue;
}
if (spec.kind === 'text') {
const target = spec.value;
const matches = [...document.querySelectorAll('*')].filter((node) => {
@ -190,6 +202,54 @@ export class BrowserSession implements BrowserLike {
`;
}
private elementReferenceHelpers(): string {
return `
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) => {
if (node.id) {
const candidate = '#' + cssEscape(node.id);
if (isUnique(candidate)) return candidate;
}
const testId = node.getAttribute('data-testid');
if (testId) {
const candidate = '[data-testid="' + cssEscape(testId) + '"]';
if (isUnique(candidate)) return candidate;
}
const parts = [];
let current = node;
while (current && current.nodeType === 1) {
let part = current.tagName.toLowerCase();
if (current.id) {
part += '#' + cssEscape(current.id);
parts.unshift(part);
break;
}
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 describe = (node) => ({
kind: 'css',
value: cssPath(node),
tag: node.tagName.toLowerCase(),
text: (node.innerText || node.textContent || '').trim().slice(0, 500),
elementValue: String(node.value ?? '')
});
`;
}
async navigate(url: string): Promise<{ url: string; title: string }> {
const target = assertUrl(url);
await this.cdp("Page.enable");
@ -475,6 +535,178 @@ export class BrowserSession implements BrowserLike {
return target;
}
async getElement(selector: unknown): Promise<BrowserElementReference> {
return this.withElement<BrowserElementReference>(selector, `
${this.elementReferenceHelpers()}
return describe(el);
`);
}
async getElementInfo(selector: unknown): Promise<BrowserElementInfo> {
return this.withElement<BrowserElementInfo>(selector, `
${this.elementReferenceHelpers()}
const reference = describe(el);
const rect = el.getBoundingClientRect();
return {
...reference,
html: el.outerHTML,
href: String(el.href ?? el.getAttribute('href') ?? ''),
checked: 'checked' in el ? Boolean(el.checked) : null,
disabled: Boolean(el.disabled) || el.getAttribute('aria-disabled') === 'true',
attributes: Object.fromEntries([...el.attributes].map((item) => [item.name, item.value])),
region: {
x: rect.x, y: rect.y, width: rect.width, height: rect.height,
left: rect.left, top: rect.top, right: rect.right, bottom: rect.bottom,
centerX: rect.x + rect.width / 2, centerY: rect.y + rect.height / 2,
pageX: rect.x + window.scrollX, pageY: rect.y + window.scrollY
}
};
`);
}
async getParent(selector: unknown, level = 1, filter = ""): Promise<BrowserElementReference> {
const normalizedLevel = Math.max(1, Math.trunc(level));
const result = await this.withElement<BrowserElementReference | null>(selector, `
${this.elementReferenceHelpers()}
let target = el;
for (let index = 0; index < ${normalizedLevel}; index += 1) target = target?.parentElement || null;
if (!target) return null;
const filter = ${JSON.stringify(filter.trim())};
if (filter && !target.matches(filter)) return null;
return describe(target);
`);
if (!result) throw new RuntimeError("not_found", `未找到第 ${normalizedLevel} 层父元素`);
return result;
}
async getChildren(selector: unknown, index?: number, filter = ""): Promise<BrowserElementReference[]> {
const normalizedIndex = index && index > 0 ? Math.trunc(index) : 0;
const result = await this.withElement<BrowserElementReference[]>(selector, `
${this.elementReferenceHelpers()}
const filter = ${JSON.stringify(filter.trim())};
let children = [...el.children];
if (filter) children = children.filter((item) => item.matches(filter));
const index = ${normalizedIndex};
if (index > 0) children = children.slice(index - 1, index);
return children.map(describe);
`);
if (normalizedIndex > 0 && result.length === 0) {
throw new RuntimeError("not_found", `未找到第 ${normalizedIndex} 个子元素`);
}
return result;
}
async getSibling(
selector: unknown,
direction: "previous" | "next" = "next",
filter = ""
): Promise<BrowserElementReference> {
const result = await this.withElement<BrowserElementReference | null>(selector, `
${this.elementReferenceHelpers()}
const filter = ${JSON.stringify(filter.trim())};
let target = el.${direction === "previous" ? "previousElementSibling" : "nextElementSibling"};
while (target && filter && !target.matches(filter)) {
target = target.${direction === "previous" ? "previousElementSibling" : "nextElementSibling"};
}
return target ? describe(target) : null;
`);
if (!result) throw new RuntimeError("not_found", `未找到${direction === "previous" ? "上一个" : "下一个"}兄弟元素`);
return result;
}
async getSelectOptions(selector: unknown): Promise<BrowserSelectState> {
return this.withElement<BrowserSelectState>(selector, `
if (el.tagName.toLowerCase() !== 'select') throw new Error('目标不是标准 HTML select 元素');
const options = [...el.options].map((item) => ({
text: item.text, value: item.value, selected: item.selected, disabled: item.disabled
}));
const selected = options.filter((item) => item.selected);
return { values: selected.map((item) => item.value), texts: selected.map((item) => item.text), options };
`);
}
async setSelectOptions(
selector: unknown,
values: string[],
mode: "replace" | "add" | "remove" = "replace"
): Promise<BrowserSelectState> {
return this.withElement<BrowserSelectState>(selector, `
if (el.tagName.toLowerCase() !== 'select') throw new Error('目标不是标准 HTML select 元素');
const requested = ${JSON.stringify(values)}.map(String);
const mode = ${JSON.stringify(mode)};
const matched = new Set();
for (const option of [...el.options]) {
const hit = requested.includes(option.value) || requested.includes(option.text);
if (hit) matched.add(option.value);
if (mode === 'replace') option.selected = hit;
else if (mode === 'add' && hit) option.selected = true;
else if (mode === 'remove' && hit) option.selected = false;
}
if (matched.size === 0) throw new Error('未找到指定下拉选项');
el.dispatchEvent(new Event('input', { bubbles: true }));
el.dispatchEvent(new Event('change', { bubbles: true }));
const options = [...el.options].map((item) => ({
text: item.text, value: item.value, selected: item.selected, disabled: item.disabled
}));
const selected = options.filter((item) => item.selected);
return { values: selected.map((item) => item.value), texts: selected.map((item) => item.text), options };
`);
}
async getTableData(selector: unknown): Promise<string[][]> {
return this.withElement<string[][]>(selector, `
const table = el.matches('table') ? el : el.querySelector('table');
if (!table) throw new Error('目标不是标准 HTML table 元素');
return [...table.querySelectorAll('tr')].map((row) =>
[...row.querySelectorAll(':scope > th, :scope > td')].map((cell) =>
(cell.innerText || cell.textContent || '').trim()
)
);
`);
}
async getSimilarElements(
selector: unknown,
options: BrowserSimilarElementOptions = {}
): Promise<BrowserElementReference[]> {
const spec = selectorSpec(selector);
const parentSpec = options.parent === undefined ? null : selectorSpec(options.parent);
const start = Math.max(1, Math.trunc(options.start ?? 1));
const end = Math.trunc(options.end ?? -1);
const result = await this.evaluate<{ parentFound: boolean; items: BrowserElementReference[] }>(`(() => {
const spec = ${JSON.stringify(spec)};
const parentSpec = ${JSON.stringify(parentSpec)};
${this.elementReferenceHelpers()}
const resolveOne = (targetSpec) => {
if (!targetSpec) return document;
if (targetSpec.kind === 'xpath') {
return document.evaluate(targetSpec.value, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue;
}
if (targetSpec.kind === 'css') return document.querySelector(targetSpec.value);
return null;
};
const root = resolveOne(parentSpec);
if (!root) return { parentFound: false, items: [] };
let nodes = [];
if (spec.kind === 'xpath') {
const snapshot = document.evaluate(spec.value, document, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null);
for (let index = 0; index < snapshot.snapshotLength; index += 1) {
const node = snapshot.snapshotItem(index);
if (node?.nodeType === 1 && (root === document || root.contains(node))) nodes.push(node);
}
} else if (spec.kind === 'css') {
nodes = [...root.querySelectorAll(spec.value)];
}
if (${options.reverse ? "true" : "false"}) nodes.reverse();
const startIndex = ${start} - 1;
const endValue = ${end};
const endIndex = endValue < 0 ? nodes.length + endValue + 1 : endValue;
return { parentFound: true, items: nodes.slice(startIndex, Math.max(startIndex, endIndex)).map(describe) };
})()`);
if (!result.parentFound) throw new RuntimeError("not_found", "未找到相似元素的父元素范围");
return result.items;
}
async currentUrl(): Promise<string> {
return this.win.webContents.getURL();
}

View File

@ -0,0 +1,172 @@
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { CatalogComponent, FlowNode } from "../../shared/types";
import { getHandler } from "../engine/registry";
import {
RuntimeError,
type BrowserElementInfo,
type BrowserElementReference,
type BrowserLike,
type BrowserSelectState,
type RunContext
} from "../engine/types";
import { BROWSER_COMMON_IDS } from "./browser-common";
const component = {} as CatalogComponent;
const selector = { kind: "css", value: "#target" };
const reference: BrowserElementReference = {
kind: "css", value: "#target", tag: "button", text: "提交", elementValue: ""
};
const info: BrowserElementInfo = {
...reference,
html: '<button id="target" data-id="7">提交</button>',
href: "https://example.com/orders/7",
checked: null,
disabled: false,
attributes: { id: "target", "data-id": "7", href: "/orders/7" },
region: {
x: 10, y: 20, width: 100, height: 30,
left: 10, top: 20, right: 110, bottom: 50,
centerX: 60, centerY: 35, pageX: 10, pageY: 120
}
};
const selectState: BrowserSelectState = {
values: ["sh"], texts: ["上海"],
options: [
{ text: "北京", value: "bj", selected: false, disabled: false },
{ text: "上海", value: "sh", selected: true, disabled: false }
]
};
function node(componentId: string): FlowNode {
return { id: `common_${componentId}`, componentId, position: { x: 0, y: 0 }, values: {}, options: {} };
}
function fakeBrowser(): BrowserLike {
return {
navigate: vi.fn(async (url: string) => ({ url, title: "Fake page" })),
click: vi.fn(), type: vi.fn(), getText: vi.fn(), exists: vi.fn(), select: vi.fn(), runJs: vi.fn(),
download: vi.fn(), screenshot: vi.fn(), currentUrl: vi.fn(async () => "https://example.com/base/"), close: vi.fn(),
getElement: vi.fn(async () => reference),
getElementInfo: vi.fn(async () => info),
getParent: vi.fn(async () => ({ ...reference, value: "#parent", tag: "section" })),
getChildren: vi.fn(async () => [
{ ...reference, value: "#child-1", tag: "span", text: "A" },
{ ...reference, value: "#child-2", tag: "span", text: "B" }
]),
getSibling: vi.fn(async () => ({ ...reference, value: "#sibling", tag: "span" })),
getSelectOptions: vi.fn(async () => selectState),
setSelectOptions: vi.fn(async () => selectState),
getTableData: vi.fn(async () => [["姓名", "金额"], ["张三", "100"]]),
getSimilarElements: vi.fn(async () => [
{ ...reference, value: ".item:nth-of-type(1)", text: "A" },
{ ...reference, value: ".item:nth-of-type(2)", text: "B" }
])
};
}
function context(browser: BrowserLike): RunContext {
return {
runId: "common-run", flowId: "common-flow", flow: {} as RunContext["flow"], vars: {}, secrets: {},
signal: new AbortController().signal, evidenceDir: "C:/temp/evidence", browser,
emit: vi.fn(), log: vi.fn(), componentOf: vi.fn()
};
}
async function invoke(ctx: RunContext, componentId: string, args: Record<string, unknown>): Promise<unknown> {
const handler = getHandler(componentId);
expect(handler, `${componentId} should be registered`).toBeTypeOf("function");
return handler!(ctx, node(componentId), args, component);
}
describe("common browser component registration", () => {
it("registers real catalog IDs and excludes dedicated channels", () => {
const catalog = JSON.parse(readFileSync(resolve("resources/catalog.json"), "utf8")) as {
components: Array<{ id: string }>;
};
const catalogIds = new Set(catalog.components.map((item) => item.id));
expect(BROWSER_COMMON_IDS.length).toBeGreaterThanOrEqual(12);
expect(BROWSER_COMMON_IDS.filter((id) => !catalogIds.has(id))).toEqual([]);
expect(BROWSER_COMMON_IDS.some((id) => /Ground|IE|Chart/i.test(id))).toBe(false);
expect(BROWSER_COMMON_IDS.every((id) => typeof getHandler(id) === "function")).toBe(true);
});
});
describe("common browser capability routing", () => {
let browser: BrowserLike;
let ctx: RunContext;
beforeEach(() => {
browser = fakeBrowser();
ctx = context(browser);
});
it("gets reusable element references from picked, CSS and XPath selectors", async () => {
await expect(invoke(ctx, "Element_GetElement", { default: selector })).resolves.toEqual(reference);
expect(browser.getElement).toHaveBeenLastCalledWith(selector);
await invoke(ctx, "Element_GetElementV1", { element_position: "css", cssSelector: ".order" });
expect(browser.getElement).toHaveBeenLastCalledWith("css:.order");
await invoke(ctx, "Element_GetElementV2", { element_position: "xpath", xpath: "//button" });
expect(browser.getElement).toHaveBeenLastCalledWith("xpath://button");
});
it("returns text, attributes, URL and full element information", async () => {
await expect(invoke(ctx, "Element_GetElementInfo", { uiSelector: selector, action: "gettext" })).resolves.toBe("提交");
await expect(invoke(ctx, "Element_GetElementInfo_V1", {
uiSelector: selector, action: "getattribute", attr_name: "href", attr_complate: "1"
})).resolves.toBe("https://example.com/orders/7");
await expect(invoke(ctx, "Element_GetElementInfo", { uiSelector: selector, action: "all" })).resolves.toEqual(info);
});
it("routes parent, child and sibling relationships", async () => {
await invoke(ctx, "Element_Parent_V2", { uiSelector: selector, level: "2", use: "1", filter: "section" });
expect(browser.getParent).toHaveBeenCalledWith(selector, 2, "section");
await expect(invoke(ctx, "Element_Children", { uiSelector: selector })).resolves.toHaveLength(2);
await expect(invoke(ctx, "Element_Children_V1", { uiSelector: selector, num: "2" })).resolves.toMatchObject({ text: "A" });
expect(browser.getChildren).toHaveBeenLastCalledWith(selector, 2, "");
await invoke(ctx, "Element_Sibling_V2", { uiSelector: selector, position: "1" });
expect(browser.getSibling).toHaveBeenCalledWith(selector, "previous", "");
});
it("reads and writes standard select elements", async () => {
await expect(invoke(ctx, "Element_GetSelect", { uiSelector: selector, choose: "1" })).resolves.toBe("sh");
await expect(invoke(ctx, "Element_GetSelect", { uiSelector: selector, choose: "3" })).resolves.toEqual(selectState.options);
await invoke(ctx, "Element_SetSelect", { uiSelector: selector, itemsToSelect: '["bj","sh"]', choose: "2" });
expect(browser.setSelectOptions).toHaveBeenCalledWith(selector, ["bj", "sh"], "add");
});
it("reads full, row, column and cell ranges from standard HTML tables", async () => {
await expect(invoke(ctx, "Element_GetTableData", { uiSelector: selector, tabelRange: "0" })).resolves.toEqual([
["姓名", "金额"], ["张三", "100"]
]);
await expect(invoke(ctx, "Element_GetTableData", {
uiSelector: selector, tabelRange: "3", rowNo: 2, columnNo: 2
})).resolves.toBe("100");
await expect(invoke(ctx, "Element_GetTableData", { uiSelector: selector, sap_mode: true })).rejects.toMatchObject({
code: "validation_error"
});
});
it("gets similar element references and projected text values", async () => {
await expect(invoke(ctx, "Element_SimilarElementList", {
element_position: "css", cssSelector: ".item", start_index: 1, end_index: -1
})).resolves.toHaveLength(2);
expect(browser.getSimilarElements).toHaveBeenCalledWith("css:.item", {
parent: undefined, start: 1, end: -1, reverse: false
});
await expect(invoke(ctx, "Element_SimilarElementList_V2", {
uiSelector: selector, action: "gettext"
})).resolves.toEqual(["提交", "提交"]);
});
it("rejects missing selectors and unsupported browser channels", async () => {
await expect(invoke(ctx, "Element_GetElementNew", {})).rejects.toBeInstanceOf(RuntimeError);
await expect(invoke(ctx, "Element_Parent_V2", {
uiSelector: selector, designated_info: "1", designated_obj: "other"
})).rejects.toMatchObject({ code: "validation_error" });
await expect(invoke(ctx, "Element_Children_V1", {
uiSelector: selector, browser_type: "IE"
})).rejects.toMatchObject({ code: "validation_error" });
});
});

View File

@ -0,0 +1,231 @@
import { browserSession } from "../browser/session";
import { register } from "../engine/registry";
import { RuntimeError, type BrowserElementInfo, type BrowserLike, type RunContext } from "../engine/types";
const GET_ELEMENT_IDS = ["Element_GetElement", "Element_GetElementV1", "Element_GetElementV2", "Element_GetElementNew"];
const GET_INFO_IDS = ["Element_GetElementInfo", "Element_GetElementInfo_V1"];
const PARENT_IDS = ["Element_Parent", "Element_Parent_V1", "Element_Parent_V2"];
const CHILDREN_IDS = ["Element_Children", "Element_ChildrenV1", "Element_Children_V1", "Element_Children_V2"];
const SIBLING_IDS = ["Element_Sibling", "Element_Sibling_V1", "Element_Sibling_V2"];
const SIMILAR_IDS = ["Element_SimilarElementList", "Element_SimilarElementList_V1", "Element_SimilarElementList_V2"];
export const BROWSER_COMMON_IDS = [
...GET_ELEMENT_IDS, ...GET_INFO_IDS, ...PARENT_IDS, ...CHILDREN_IDS, ...SIBLING_IDS,
"Element_GetSelect", "Element_SetSelect", "Element_GetTableData", ...SIMILAR_IDS
] as const;
async function session(ctx: RunContext): Promise<BrowserLike> {
if (!ctx.browser) ctx.browser = await browserSession();
return ctx.browser;
}
function enabled(value: unknown): boolean {
return value === true || value === 1 || String(value).toLowerCase() === "true" || String(value) === "1";
}
function assertUnifiedBrowser(args: Record<string, unknown>): void {
if (enabled(args.designated_info)) {
throw new RuntimeError("validation_error", "当前能力不支持指定其他网页或窗口对象", {
recommendation: "关闭指定网页/窗口对象,使用当前运行浏览器"
});
}
const browserType = String(args.browser_type ?? "").toLowerCase();
if (browserType.includes("ie") || browserType.includes("sap") || browserType.includes("ground")) {
throw new RuntimeError("validation_error", "当前能力仅支持标准浏览器 DOM");
}
}
function hasSelector(value: unknown): boolean {
if (typeof value === "string") return value.trim().length > 0;
return Boolean(value && typeof value === "object" && Object.keys(value as object).length > 0);
}
function selectorOf(args: Record<string, unknown>): unknown {
const position = String(args.element_position ?? "default").toLowerCase();
if (position.includes("xpath") && hasSelector(args.xpath)) return `xpath:${String(args.xpath)}`;
if (position.includes("css") && hasSelector(args.cssSelector)) return `css:${String(args.cssSelector)}`;
for (const key of ["uiSelector", "default", "selector", "target"]) {
if (hasSelector(args[key])) return args[key];
}
if (hasSelector(args.cssSelector)) return `css:${String(args.cssSelector)}`;
if (hasSelector(args.xpath)) return `xpath:${String(args.xpath)}`;
throw new RuntimeError("validation_error", "元素选择器为空", {
recommendation: "填写标准 CSS/XPath 选择器或使用已有元素引用"
});
}
function requireMethod<K extends keyof BrowserLike>(browser: BrowserLike, key: K): NonNullable<BrowserLike[K]> {
const method = browser[key];
if (typeof method !== "function") throw new RuntimeError("browser_error", `浏览器不支持 DOM 方法:${String(key)}`);
return method.bind(browser) as NonNullable<BrowserLike[K]>;
}
function positiveInteger(value: unknown, fallback: number): number {
const parsed = Math.trunc(Number(value));
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
}
function optionalIndex(value: unknown): number | undefined {
if (value === undefined || value === null || String(value).trim() === "") return undefined;
return positiveInteger(value, 1);
}
function filterOf(args: Record<string, unknown>): string {
return enabled(args.use) ? String(args.filter ?? "").trim() : "";
}
function listValue(value: unknown): string[] {
if (Array.isArray(value)) return value.map(String).filter(Boolean);
const text = String(value ?? "").trim();
if (!text) return [];
if (text.startsWith("[")) {
try {
const parsed = JSON.parse(text) as unknown;
if (Array.isArray(parsed)) return parsed.map(String).filter(Boolean);
} catch {
// Fall back to comma-separated input.
}
}
return text.split(",").map((item) => item.trim()).filter(Boolean);
}
async function completeUrl(browser: BrowserLike, value: string, shouldComplete: boolean): Promise<string> {
if (!shouldComplete || !value) return value;
try {
return new URL(value, await browser.currentUrl()).toString();
} catch {
return value;
}
}
async function infoResult(browser: BrowserLike, info: BrowserElementInfo, args: Record<string, unknown>): Promise<unknown> {
const action = String(args.action ?? "gettext").toLowerCase();
if (["gettext", "text", "1"].includes(action)) return info.text;
if (["getvalue", "value", "2"].includes(action)) return info.elementValue;
if (["getposition", "position", "region", "3"].includes(action)) return info.region;
if (["gethref", "href", "link", "4"].includes(action)) return completeUrl(browser, info.href, enabled(args.attr_complate));
if (["gethtml", "html", "code", "5"].includes(action)) return info.html;
if (["getattribute", "attribute", "attr", "6"].includes(action)) {
const name = String(args.attr_name ?? "").trim();
if (!name) throw new RuntimeError("validation_error", "属性名为空");
return completeUrl(browser, info.attributes[name] ?? "", enabled(args.attr_complate));
}
if (["all", "info", "0"].includes(action)) return info;
throw new RuntimeError("validation_error", `不支持的元素信息类型:${action}`);
}
register(GET_ELEMENT_IDS, async (ctx, node, args) => {
assertUnifiedBrowser(args);
const result = await requireMethod(await session(ctx), "getElement")(selectorOf(args));
ctx.log(`已获取元素对象:${result.tag}`, "info", node.id);
return result;
});
register(GET_INFO_IDS, async (ctx, node, args) => {
assertUnifiedBrowser(args);
const browser = await session(ctx);
const info = await requireMethod(browser, "getElementInfo")(selectorOf(args));
const result = await infoResult(browser, info, args);
ctx.log("已获取元素信息", "info", node.id);
return result;
});
register(PARENT_IDS, async (ctx, node, args) => {
assertUnifiedBrowser(args);
const result = await requireMethod(await session(ctx), "getParent")(
selectorOf(args), positiveInteger(args.level, 1), filterOf(args)
);
ctx.log("已获取父元素", "info", node.id);
return result;
});
register(CHILDREN_IDS, async (ctx, node, args) => {
assertUnifiedBrowser(args);
const index = optionalIndex(args.num);
const result = await requireMethod(await session(ctx), "getChildren")(selectorOf(args), index, filterOf(args));
ctx.log(`已获取 ${result.length} 个子元素`, "info", node.id);
return index === undefined ? result : result[0];
});
register(SIBLING_IDS, async (ctx, node, args) => {
assertUnifiedBrowser(args);
const position = String(args.position ?? "2").toLowerCase();
const direction = ["1", "previous", "prev", "up"].includes(position) ? "previous" : "next";
const result = await requireMethod(await session(ctx), "getSibling")(selectorOf(args), direction, filterOf(args));
ctx.log(`已获取${direction === "previous" ? "上一个" : "下一个"}兄弟元素`, "info", node.id);
return result;
});
register("Element_GetSelect", async (ctx, node, args) => {
assertUnifiedBrowser(args);
const state = await requireMethod(await session(ctx), "getSelectOptions")(selectorOf(args));
const choose = String(args.choose ?? "1").toLowerCase();
let result: unknown = state.values.length === 1 ? state.values[0] : state.values;
if (["2", "text", "texts"].includes(choose)) result = state.texts.length === 1 ? state.texts[0] : state.texts;
if (["3", "all", "options"].includes(choose)) result = state.options;
ctx.log(`已读取下拉框,共 ${state.options.length} 个选项`, "info", node.id);
return result;
});
register("Element_SetSelect", async (ctx, node, args) => {
assertUnifiedBrowser(args);
const values = listValue(args.itemsToSelect ?? args.value);
if (values.length === 0) throw new RuntimeError("validation_error", "下拉框选项为空");
const choose = String(args.choose ?? "1");
const mode = choose === "2" ? "add" : choose === "3" ? "remove" : "replace";
const result = await requireMethod(await session(ctx), "setSelectOptions")(selectorOf(args), values, mode);
ctx.log(`已设置下拉框:${result.texts.join(", ")}`, "info", node.id);
return result;
});
register("Element_GetTableData", async (ctx, node, args) => {
assertUnifiedBrowser(args);
if (enabled(args.sap_mode) || String(args.mode ?? "").toLowerCase().includes("sap")) {
throw new RuntimeError("validation_error", "当前组件仅支持标准 HTML table,不支持 SAP 表格");
}
let rows = await requireMethod(await session(ctx), "getTableData")(selectorOf(args));
const includeHeader = !["0", "false"].includes(String(args.operation ?? "1").toLowerCase());
if (!includeHeader) rows = rows.slice(1);
const range = String(args.tabelRange ?? "0");
const row = positiveInteger(args.rowNo, 1) - 1;
const column = positiveInteger(args.columnNo, 1) - 1;
let result: unknown = rows;
if (range === "1") result = rows[row] ?? [];
else if (range === "2") result = rows.map((item) => item[column] ?? "");
else if (range === "3") result = rows[row]?.[column] ?? "";
ctx.log(`已读取 HTML 表格 ${rows.length} 行`, "info", node.id);
return result;
});
register(SIMILAR_IDS, async (ctx, node, args) => {
assertUnifiedBrowser(args);
const browser = await session(ctx);
const parent = enabled(args.relate_parent) ? args.parent_selector : undefined;
if (enabled(args.relate_parent) && !hasSelector(parent)) {
throw new RuntimeError("validation_error", "已启用关联父元素,但父元素选择器为空");
}
const references = await requireMethod(browser, "getSimilarElements")(selectorOf(args), {
parent,
start: positiveInteger(args.start_index, 1),
end: Number.isFinite(Number(args.end_index)) ? Math.trunc(Number(args.end_index)) : -1,
reverse: enabled(args.reverse)
});
const action = String(args.action ?? "selector").toLowerCase();
let result: unknown = references;
if (!["selector", "element", "elements"].includes(action)) {
const infos = await Promise.all(references.map((reference) => requireMethod(browser, "getElementInfo")(reference)));
if (["gettext", "text"].includes(action)) result = infos.map((info) => info.text);
else if (["getvalue", "value"].includes(action)) result = infos.map((info) => info.elementValue);
else if (["getattribute", "attribute", "attr"].includes(action)) {
const name = String(args.attr_name ?? "").trim();
if (!name) throw new RuntimeError("validation_error", "属性名为空");
result = await Promise.all(
infos.map((info) => completeUrl(browser, info.attributes[name] ?? "", enabled(args.attr_complate)))
);
} else {
throw new RuntimeError("validation_error", `不支持的相似元素操作:${action}`);
}
}
ctx.log(`已获取 ${references.length} 个相似元素`, "info", node.id);
return result;
});

View File

@ -0,0 +1,45 @@
import { mkdtemp, rm } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import type { CatalogComponent, FlowNode } from "../../shared/types";
import type { RunContext } from "../engine/types";
import { getHandler } from "../engine/registry";
import "./data-table-common";
const dirs: string[] = [];
const context = { vars: {} } as RunContext;
const component = {} as CatalogComponent;
const node = (id: string): FlowNode => ({ id, componentId: id, position: { x: 0, y: 0 }, values: {}, options: {} });
async function run(id: string, args: Record<string, unknown> = {}) { const handler = getHandler(id); expect(handler, id).toBeTruthy(); return handler!(context, node(id), args, component); }
afterEach(async () => { context.vars = {}; await Promise.all(dirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))); });
describe("common data table capabilities", () => {
it("reads, writes, calculates and joins columns", async () => {
const source = [["A", "B"], [1, 2], [3, 4]];
await expect(run("Bivartable_ReadDfData", { df: source, readRange: "cell", row_num: 2, col_name: "B" })).resolves.toBe(2);
await expect(run("Bivartable_ColumnOperationDf", { df: source, name1: "A", name2: "B", operator: "+", name: "Total" })).resolves.toEqual([["A", "B", "Total"], [1, 2, 3], [3, 4, 7]]);
await expect(run("Bivartable_DoJoint", { df: source, cola: "A", colb: "B", colnew: "AB" })).resolves.toEqual([["A", "B", "AB"], [1, 2, "12"], [3, 4, "34"]]);
});
it("fills missing values and modifies column names", async () => {
await expect(run("Bivartable_HandleDfMissValue", { df: [["A"], [null], [2]], handleType: "1", value: 0 })).resolves.toEqual([["A"], [0], [2]]);
await expect(run("Bivartable_ModifyColName", { df: [["A"], [1]], srcname: "A", newname: "Value" })).resolves.toEqual([["Value"], [1]]);
});
it("converts column types and builds a pivot table", async () => {
await expect(run("Bivartable_Dtype", { df: [["id", "amount"], ["1", "2.5"]], change_type: "name", col_name: ["id", "amount"], dtype: "float" })).resolves.toEqual([["id", "amount"], [1, 2.5]]);
const source = [["team", "month", "amount"], ["A", "Jan", 2], ["A", "Feb", 3], ["B", "Jan", 4]];
await expect(run("Bivartable_PivotDf", { df: source, row_list: ["team"], col_list: ["month"], operator: { amount: "sum" }, value: 0 })).resolves.toEqual([["team", "Jan", "Feb"], ["A", 2, 3], ["B", 4, 0]]);
});
it("exports and imports Excel and databook metadata", async () => {
const directory = await mkdtemp(path.join(os.tmpdir(), "rpa-table-common-")); dirs.push(directory); const file = path.join(directory, "table.xlsx");
await run("Bivartable_DoSave", { df: [["A", "B"], [1, 2]], filePath: file });
await expect(run("Bivartable_DoOpenExcel", { filePath: file, sheetName: "Sheet1" })).resolves.toEqual([["A", "B"], [1, 2]]);
await run("Databook_Import", { excel_path: file, sheet_name: "Sheet1" });
await run("Databook_SetColumnInfo", { column_name: "A", column_description: "编号" });
await expect(run("Databook_GetColumnInfo", { column_name: "A" })).resolves.toBe("编号");
await expect(run("Databook_Export", { export_path: directory, export_name: "book.xlsx" })).resolves.toBe(path.join(directory, "book.xlsx"));
});
});

View File

@ -0,0 +1,160 @@
import ExcelJS from "exceljs";
import { promises as fs } from "node:fs";
import path from "node:path";
import { register } from "../engine/registry";
import { RuntimeError } from "../engine/types";
type Table = unknown[][];
const BOOK = "__rpaDatabook";
const META = "__rpaDatabookColumns";
function parse(value: unknown): unknown {
if (typeof value !== "string") return value;
try { return JSON.parse(value); } catch { return value; }
}
function table(value: unknown): Table {
const source = parse(value);
if (!Array.isArray(source)) return source == null || source === "" ? [] : [[source]];
return source.map((row) => Array.isArray(row) ? [...row] : [row]);
}
function list(value: unknown): unknown[] {
const source = parse(value);
if (Array.isArray(source)) return source;
if (source == null || source === "") return [];
return String(source).split(/[,,;;\n]/).map((item) => item.trim()).filter(Boolean);
}
function index(headers: unknown[], value: unknown): number {
const source = String(value ?? "A").trim();
const named = headers.findIndex((header) => String(header) === source);
if (named >= 0) return named;
if (/^\d+$/.test(source)) return Math.max(0, Number(source) - 1);
if (/^[A-Za-z]+$/.test(source)) {
let result = 0; for (const char of source.toUpperCase()) result = result * 26 + char.charCodeAt(0) - 64;
return Math.max(0, result - 1);
}
return -1;
}
function number(value: unknown): number { const result = Number(value); return Number.isFinite(result) ? result : 0; }
function required(value: unknown, label: string, nodeId: string, componentId: string): string {
const result = String(value ?? "").trim(); if (!result) throw new RuntimeError("validation_error", `${label}为空`, { nodeId, componentId }); return result;
}
function book(ctx: { vars: Record<string, unknown> }): Table {
const current = table(ctx.vars[BOOK]); ctx.vars[BOOK] = current; return current;
}
function calculate(left: unknown, right: unknown, operator: string): unknown {
if (operator === "+" && (typeof left === "string" || typeof right === "string")) return `${left ?? ""}${right ?? ""}`;
const a = number(left); const b = number(right);
if (operator === "-") return a - b; if (operator === "*") return a * b; if (operator === "/") return b === 0 ? null : a / b; if (operator === "%") return b === 0 ? null : a % b;
return a + b;
}
const readData = (_ctx: unknown, _node: unknown, args: Record<string, unknown>) => {
const source = table(args.df); if (!source.length) return null;
const headers = source[0]; const mode = String(args.readRange ?? "cell").toLowerCase();
const row = Math.max(1, Number(args.row_num ?? args.row_name ?? 1)); const column = index(headers, args.col_name ?? args.col_num ?? "A");
if (mode.includes("row")) return source[row - 1] ?? [];
if (mode.includes("col")) return source.map((item) => item[column]);
if (mode.includes("range")) {
const endRow = Math.max(row, Number(args.appoint_row_num ?? row)); const endColumn = index(headers, args.appoint_col_name ?? args.appoint_col_num ?? column + 1);
return source.slice(row - 1, endRow).map((item) => item.slice(column, endColumn + 1));
}
return source[row - 1]?.[column];
};
register(["Bivartable_ReadDfData", "Bivartable_ReadDfData_V1"], readData);
register(["Bivartable_WriteDfData", "Bivartable_WriteTable"], (_ctx, _node, args) => {
const source = table(args.df); if (!source.length) source.push([]);
const headers = source[0]; const row = Math.max(1, Number(args.row_num ?? args.row ?? 1)); const column = index(headers, args.col_name ?? args.column ?? "A");
while (source.length < row) source.push([]); while (source[row - 1].length <= column) source[row - 1].push(null);
source[row - 1][column] = parse(args.data);
return source;
});
register("Bivartable_InsertDfData", (_ctx, _node, args) => {
const source = table(args.df); const direction = String(args.writeRange ?? "row").toLowerCase(); const data = list(args.data);
if (direction.includes("col")) {
const at = Math.max(0, Number(args.col_num ?? 1) - 1); for (let row = 0; row < Math.max(source.length, data.length); row += 1) { if (!source[row]) source[row] = []; source[row].splice(at, 0, data[row] ?? null); }
} else source.splice(Math.max(0, Number(args.row_num ?? 1) - 1), 0, data);
return source;
});
register("Bivartable_HandleDfMissValue", (_ctx, _node, args) => {
const source = table(args.df); const fill = args.value ?? ""; const remove = String(args.handleType ?? "1") === "2";
if (remove) return source.filter((row, rowIndex) => rowIndex === 0 || !row.some((cell) => cell === null || cell === undefined || cell === ""));
return source.map((row, rowIndex) => rowIndex === 0 ? row : row.map((cell) => cell === null || cell === undefined || cell === "" ? fill : cell));
});
register(["Bivartable_ColumnOperationDf", "Bivartable_DoCal"], (_ctx, _node, args) => {
const source = table(args.df); if (!source.length) return source; const headers = source[0];
const left = index(headers, args.name1 ?? args.cola); const right = index(headers, args.name2 ?? args.colb); const name = String(args.name ?? args.colnew ?? "结果"); const operator = String(args.operator ?? args.cal ?? "+");
const result: Table = [[...headers, name]]; for (const row of source.slice(1)) result.push([...row, calculate(row[left], row[right], operator)]); return result;
});
register(["Bivartable_SpliceColData", "Bivartable_DoJoint"], (_ctx, _node, args) => {
const source = table(args.df); if (!source.length) return source; const headers = source[0];
const left = index(headers, args.col1 ?? args.cola); const right = index(headers, args.col2 ?? args.colb); const name = String(args.name ?? args.colnew ?? "拼接结果"); const separator = String(args.separator ?? "");
return [[...headers, name], ...source.slice(1).map((row) => [...row, `${row[left] ?? ""}${separator}${row[right] ?? ""}`])];
});
register("Bivartable_ModifyColName", (_ctx, _node, args) => {
const source = table(args.df); if (!source.length) return source; const at = index(source[0], args.srcname); if (at < 0) return source; source[0][at] = args.newname; return source;
});
register("Bivartable_ModifyDfName", (_ctx, _node, args) => {
const source = table(args.df); const names = list(args.new_col_name_list); if (source.length && names.length) source[0] = names; return source;
});
register("Bivartable_ReadDfIndexColumns", (_ctx, _node, args) => ({ columns: table(args.df)[0] || [], index: table(args.df).slice(1).map((_row, indexValue) => indexValue + 1) }));
register("Bivartable_Dtype", (_ctx, _node, args) => {
const source = table(args.df); if (!source.length) return source;
const columns = (String(args.change_type ?? "num") === "name" ? list(args.col_name) : list(args.col_num)).map((item) => index(source[0], item)).filter((item) => item >= 0);
const dtype = String(args.dtype ?? "str").toLowerCase();
const convert = (current: unknown) => {
if (dtype.includes("int")) return Math.trunc(number(current));
if (dtype.includes("float") || dtype.includes("number")) return number(current);
if (dtype.includes("bool")) return [true, 1, "1", "true", "True", "是"].includes(current as never);
return current == null ? "" : String(current);
};
return [source[0], ...source.slice(1).map((row) => row.map((cell, column) => columns.includes(column) ? convert(cell) : cell))];
});
register("Bivartable_PivotDf", (_ctx, _node, args) => {
const source = table(args.df); if (source.length < 2) return source;
const headers = source[0]; const rowNames = list(args.row_list).map(String); const columnNames = list(args.col_list).map(String);
let operator = parse(args.operator); let valueName = ""; let aggregation = "sum";
if (operator && typeof operator === "object" && !Array.isArray(operator)) {
const entries = Object.entries(operator as Record<string, unknown>); valueName = entries[0]?.[0] || ""; aggregation = String(entries[0]?.[1] || "sum");
} else valueName = String(operator || "");
if (!valueName) valueName = String(headers.find((header) => !rowNames.includes(String(header)) && !columnNames.includes(String(header))) || headers.at(-1));
const rowIndexes = rowNames.map((name) => index(headers, name)); const columnIndexes = columnNames.map((name) => index(headers, name)); const valueIndex = index(headers, valueName);
const columnKeys = [...new Set(source.slice(1).map((row) => columnIndexes.map((column) => String(row[column] ?? "")).join(" / ")))].filter(Boolean);
const groups = new Map<string, { rows: unknown[]; values: Map<string, number[]> }>();
for (const row of source.slice(1)) {
const rowValues = rowIndexes.map((column) => row[column]); const rowKey = JSON.stringify(rowValues); const columnKey = columnIndexes.map((column) => String(row[column] ?? "")).join(" / ");
const group = groups.get(rowKey) || { rows: rowValues, values: new Map() }; const values = group.values.get(columnKey) || []; values.push(number(row[valueIndex])); group.values.set(columnKey, values); groups.set(rowKey, group);
}
const aggregate = (values: number[]) => aggregation.includes("avg") || aggregation.includes("mean") ? values.reduce((sum, item) => sum + item, 0) / Math.max(1, values.length) : aggregation.includes("count") ? values.length : aggregation.includes("max") ? Math.max(...values) : aggregation.includes("min") ? Math.min(...values) : values.reduce((sum, item) => sum + item, 0);
if (!columnIndexes.length) return [[...rowNames, `${valueName}_${aggregation}`], ...[...groups.values()].map((group) => [...group.rows, aggregate([...group.values.values()].flat())])];
return [[...rowNames, ...columnKeys], ...[...groups.values()].map((group) => [...group.rows, ...columnKeys.map((key) => group.values.has(key) ? aggregate(group.values.get(key)!) : args.value ?? "")])];
});
async function exportExcel(source: Table, filePath: string): Promise<string> {
const workbook = new ExcelJS.Workbook(); const sheet = workbook.addWorksheet("Sheet1"); sheet.addRows(source as ExcelJS.CellValue[][]); await fs.mkdir(path.dirname(filePath), { recursive: true }); await workbook.xlsx.writeFile(filePath); return filePath;
}
register(["Bivartable_DfWriteToFile", "Bivartable_DoSave"], async (_ctx, node, args) => {
const filePath = path.resolve(required(args.filePath ?? args.path ?? args.savePath, "Excel路径", node.id, node.componentId)); return exportExcel(table(args.df), filePath);
});
register("Bivartable_DoOpenExcel", async (_ctx, node, args) => {
const filePath = path.resolve(required(args.filePath, "Excel路径", node.id, node.componentId)); const workbook = new ExcelJS.Workbook(); await workbook.xlsx.readFile(filePath); const sheet = workbook.getWorksheet(String(args.sheetName || "Sheet1")) || workbook.worksheets[0]; return sheet.getSheetValues().slice(1).map((row) => Array.isArray(row) ? row.slice(1).map((cell) => cell ?? "") : []);
});
register("Databook_Import", async (ctx, node, args) => { const result = await getHandlerResult("Bivartable_DoOpenExcel", ctx, node, { filePath: args.excel_path, sheetName: args.sheet_name }); ctx.vars[BOOK] = result; return result; });
register("Databook_Export", async (ctx, node, args) => {
const folder = path.resolve(required(args.export_path, "导出目录", node.id, node.componentId)); const name = String(args.export_name || "数据表格.xlsx"); return exportExcel(book(ctx), path.join(folder, path.extname(name) ? name : `${name}.xlsx`));
});
register("Databook_SetColumnInfo", (ctx, _node, args) => { const meta = (ctx.vars[META] ||= {}) as Record<string, unknown>; meta[String(args.column_name)] = args.column_description; return meta; });
register("Databook_GetColumnInfo", (ctx, _node, args) => { const meta = (ctx.vars[META] || {}) as Record<string, unknown>; return meta[String(args.column_name)] ?? ""; });
async function getHandlerResult(id: string, ctx: any, node: any, args: Record<string, unknown>): Promise<unknown> {
if (id !== "Bivartable_DoOpenExcel") return null;
const filePath = path.resolve(required(args.filePath, "Excel路径", node.id, node.componentId)); const workbook = new ExcelJS.Workbook(); await workbook.xlsx.readFile(filePath); const sheet = workbook.getWorksheet(String(args.sheetName || "Sheet1")) || workbook.worksheets[0]; return sheet.getSheetValues().slice(1).map((row) => Array.isArray(row) ? row.slice(1).map((cell) => cell ?? "") : []);
}

View File

@ -5,6 +5,7 @@ import "./data";
import "./browser";
import "./browser-extended";
import "./browser-dom";
import "./browser-common";
import "./office";
import "./desktop";
import "./pdf";
@ -15,8 +16,10 @@ import "./vision";
import "./extended";
import "./system-extended";
import "./office-file-extended";
import "./office-aliases";
import "./window-extended";
import "./data-extended";
import "./utility-extended";
import "./data-table-common";
export { capabilityCount, capabilityIds, getHandler, hasCapability, register } from "../engine/registry";

View File

@ -0,0 +1,34 @@
import { readFile, mkdtemp, rm } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import type { Catalog, CatalogComponent, FlowNode } from "../../shared/types";
import type { RunContext } from "../engine/types";
import { getHandler } from "../engine/registry";
import "./office";
import "./extended";
import "./office-file-extended";
import { EXCEL_COMMON_ALIASES } from "./office-aliases";
const dirs: string[] = [];
const context = {} as RunContext;
const component = {} as CatalogComponent;
const node = (id: string): FlowNode => ({ id, componentId: id, position: { x: 0, y: 0 }, values: {}, options: {} });
async function run(id: string, args: Record<string, unknown>) { const handler = getHandler(id); expect(handler, id).toBeTruthy(); return handler!(context, node(id), args, component); }
afterEach(async () => Promise.all(dirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))));
describe("common Excel aliases", () => {
it("registers only real catalog aliases", async () => {
const catalog = JSON.parse(await readFile("resources/catalog.json", "utf8")) as Catalog;
const ids = new Set(catalog.components.map((item) => item.id));
expect(EXCEL_COMMON_ALIASES.length).toBeGreaterThanOrEqual(25);
for (const id of EXCEL_COMMON_ALIASES) { expect(ids.has(id), id).toBe(true); expect(getHandler(id), id).toBeTruthy(); }
});
it("runs a real create, write and read pipeline through alias IDs", async () => {
const directory = await mkdtemp(path.join(os.tmpdir(), "rpa-excel-alias-")); dirs.push(directory);
const handle = await run("Excel_NewExcel", { path: directory, name: "aliases.xlsx", override: "1" });
await run("Excel_WriteCellNew", { workbook: handle, sheet: "Sheet1", row: 1, column: "A", content: "alias", save: "1" });
await expect(run("Excel_ReadCellNew", { workbook: handle, sheet: "Sheet1", row: 1, column: "A" })).resolves.toBe("alias");
});
});

View File

@ -0,0 +1,32 @@
import { getHandler, register } from "../engine/registry";
import { RuntimeError, type CapabilityHandler } from "../engine/types";
const mappings: Record<string, string[]> = {
Excel_Cs_NewV5: ["Excel_NewExcel", "Excel_Cs_NewV2", "Excel_Cs_NewV3", "Excel_Cs_NewV4"],
Excel_Cs_OpenV4: ["Excel_Cs_OpenV2", "Excel_Cs_OpenV3", "Excel_OpenExcel", "Excel_OpenExcelNew"],
Excel_Cs_ReadContentV2: ["Excel_Cs_ReadContent", "Excel_Cs_ReadContentV3", "Excel_Cs_ReadContentV4", "Excel_ReadRangeNew"],
Excel_Cs_WriteCell: ["Excel_Cs_WriteCellV2", "Excel_WriteCellNew"],
Excel_Cs_WriteRange: ["Excel_WriteRangeNew", "Excel_Cs_WriteContent"],
Excel_Cs_ReadCell: ["Excel_ReadCell", "Excel_ReadCellNew"],
Excel_Cs_ClearRangeV4: ["Excel_ClearRange", "Excel_Cs_ClearRange", "Excel_Cs_ClearRangeV2", "Excel_Cs_ClearRangeV3"],
Excel_Cs_MergeCells: ["Excel_Cs_MergeCellsV2"],
Excel_Cs_SetWidth: ["Excel_Cs_SetWidthV1", "Excel_SetColumnWidth"],
Excel_Cs_SetHeight: ["Excel_Cs_SetHeightV1", "Excel_SetRowHeight"],
Excel_Cs_DeleteRow: ["Excel_DeleteRow"],
Excel_Cs_DeleteColumn: ["Excel_DeleteColumn"],
Excel_Cs_InsertRow: ["Excel_InsertRow", "Excel_Cs_InsertRowV2", "Excel_Cs_InsertRowV3"],
Excel_Cs_InsertColumn: ["Excel_InsertColumn", "Excel_Cs_InsertColumnV2", "Excel_Cs_InsertColumnV3"],
Excel_Cs_CopyV2: ["Excel_Cs_Copy", "Excel_CopySheet"]
};
function delegate(baseId: string): CapabilityHandler {
return (ctx, node, args, component) => {
const base = getHandler(baseId);
if (!base) throw new RuntimeError("capability_not_implemented", `基础 Excel 能力不可用:${baseId}`, { nodeId: node.id, componentId: node.componentId });
return base(ctx, node, args, component);
};
}
for (const [baseId, aliases] of Object.entries(mappings)) register(aliases, delegate(baseId));
export const EXCEL_COMMON_ALIASES = Object.values(mappings).flat();

View File

@ -20,10 +20,56 @@ export interface BrowserLike {
getRegion?(selector: unknown): Promise<BrowserElementRegion>;
hover?(selector: unknown): Promise<BrowserElementRegion>;
screenshotElement?(selector: unknown, path: string, captureBeyondViewport?: boolean): Promise<string>;
getElement?(selector: unknown): Promise<BrowserElementReference>;
getElementInfo?(selector: unknown): Promise<BrowserElementInfo>;
getParent?(selector: unknown, level?: number, filter?: string): Promise<BrowserElementReference>;
getChildren?(selector: unknown, index?: number, filter?: string): Promise<BrowserElementReference[]>;
getSibling?(selector: unknown, direction?: "previous" | "next", filter?: string): Promise<BrowserElementReference>;
getSelectOptions?(selector: unknown): Promise<BrowserSelectState>;
setSelectOptions?(selector: unknown, values: string[], mode?: "replace" | "add" | "remove"): Promise<BrowserSelectState>;
getTableData?(selector: unknown): Promise<string[][]>;
getSimilarElements?(selector: unknown, options?: BrowserSimilarElementOptions): Promise<BrowserElementReference[]>;
currentUrl(): Promise<string>;
close(): Promise<void>;
}
export interface BrowserElementReference {
kind: "css" | "xpath";
value: string;
tag: string;
text: string;
elementValue: string;
}
export interface BrowserElementInfo extends BrowserElementReference {
html: string;
href: string;
checked: boolean | null;
disabled: boolean;
attributes: Record<string, string>;
region: BrowserElementRegion;
}
export interface BrowserSelectOption {
text: string;
value: string;
selected: boolean;
disabled: boolean;
}
export interface BrowserSelectState {
values: string[];
texts: string[];
options: BrowserSelectOption[];
}
export interface BrowserSimilarElementOptions {
parent?: unknown;
start?: number;
end?: number;
reverse?: boolean;
}
export interface BrowserElementRegion {
x: number;
y: number;