diff --git a/README.md b/README.md index 0836614..6d1753e 100644 --- a/README.md +++ b/README.md @@ -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()` 伪造点击。 - 不包含厂商代码、二进制、模型或前端资产;目录是从厂商元数据派生的静态定义。 diff --git a/docs/02-实施方案.md b/docs/02-实施方案.md index 6921b7c..06ffd8c 100644 --- a/docs/02-实施方案.md +++ b/docs/02-实施方案.md @@ -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` 校准。 diff --git a/src/main/browser/session.ts b/src/main/browser/session.ts index f596f74..0e58595 100644 --- a/src/main/browser/session.ts +++ b/src/main/browser/session.ts @@ -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 { + return this.withElement(selector, ` + ${this.elementReferenceHelpers()} + return describe(el); + `); + } + + async getElementInfo(selector: unknown): Promise { + return this.withElement(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 { + const normalizedLevel = Math.max(1, Math.trunc(level)); + const result = await this.withElement(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 { + const normalizedIndex = index && index > 0 ? Math.trunc(index) : 0; + const result = await this.withElement(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 { + const result = await this.withElement(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 { + return this.withElement(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 { + return this.withElement(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 { + return this.withElement(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 { + 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 { return this.win.webContents.getURL(); } diff --git a/src/main/capabilities/browser-common.test.ts b/src/main/capabilities/browser-common.test.ts new file mode 100644 index 0000000..7363142 --- /dev/null +++ b/src/main/capabilities/browser-common.test.ts @@ -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: '', + 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): Promise { + 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" }); + }); +}); diff --git a/src/main/capabilities/browser-common.ts b/src/main/capabilities/browser-common.ts new file mode 100644 index 0000000..c77f20e --- /dev/null +++ b/src/main/capabilities/browser-common.ts @@ -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 { + 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): 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): 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(browser: BrowserLike, key: K): NonNullable { + const method = browser[key]; + if (typeof method !== "function") throw new RuntimeError("browser_error", `浏览器不支持 DOM 方法:${String(key)}`); + return method.bind(browser) as NonNullable; +} + +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 { + 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 { + 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): Promise { + 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; +}); diff --git a/src/main/capabilities/data-table-common.test.ts b/src/main/capabilities/data-table-common.test.ts new file mode 100644 index 0000000..5b97a29 --- /dev/null +++ b/src/main/capabilities/data-table-common.test.ts @@ -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 = {}) { 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")); + }); +}); diff --git a/src/main/capabilities/data-table-common.ts b/src/main/capabilities/data-table-common.ts new file mode 100644 index 0000000..2d232ce --- /dev/null +++ b/src/main/capabilities/data-table-common.ts @@ -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 }): 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) => { + 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); 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 }>(); + 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 { + 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; meta[String(args.column_name)] = args.column_description; return meta; }); +register("Databook_GetColumnInfo", (ctx, _node, args) => { const meta = (ctx.vars[META] || {}) as Record; return meta[String(args.column_name)] ?? ""; }); + +async function getHandlerResult(id: string, ctx: any, node: any, args: Record): Promise { + 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 ?? "") : []); +} diff --git a/src/main/capabilities/index.ts b/src/main/capabilities/index.ts index 11d6578..ddd15e1 100644 --- a/src/main/capabilities/index.ts +++ b/src/main/capabilities/index.ts @@ -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"; diff --git a/src/main/capabilities/office-aliases.test.ts b/src/main/capabilities/office-aliases.test.ts new file mode 100644 index 0000000..348f333 --- /dev/null +++ b/src/main/capabilities/office-aliases.test.ts @@ -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) { 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"); + }); +}); diff --git a/src/main/capabilities/office-aliases.ts b/src/main/capabilities/office-aliases.ts new file mode 100644 index 0000000..99f0d10 --- /dev/null +++ b/src/main/capabilities/office-aliases.ts @@ -0,0 +1,32 @@ +import { getHandler, register } from "../engine/registry"; +import { RuntimeError, type CapabilityHandler } from "../engine/types"; + +const mappings: Record = { + 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(); diff --git a/src/main/engine/types.ts b/src/main/engine/types.ts index e890752..3b1c7a1 100644 --- a/src/main/engine/types.ts +++ b/src/main/engine/types.ts @@ -20,10 +20,56 @@ export interface BrowserLike { getRegion?(selector: unknown): Promise; hover?(selector: unknown): Promise; screenshotElement?(selector: unknown, path: string, captureBeyondViewport?: boolean): Promise; + getElement?(selector: unknown): Promise; + getElementInfo?(selector: unknown): Promise; + getParent?(selector: unknown, level?: number, filter?: string): Promise; + getChildren?(selector: unknown, index?: number, filter?: string): Promise; + getSibling?(selector: unknown, direction?: "previous" | "next", filter?: string): Promise; + getSelectOptions?(selector: unknown): Promise; + setSelectOptions?(selector: unknown, values: string[], mode?: "replace" | "add" | "remove"): Promise; + getTableData?(selector: unknown): Promise; + getSimilarElements?(selector: unknown, options?: BrowserSimilarElementOptions): Promise; currentUrl(): Promise; close(): Promise; } +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; + 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;