feat: 批量接入常用RPA能力

This commit is contained in:
yu 2026-10-27 16:29:52 +08:00
parent fbdfb782bb
commit 76f2160950
9 changed files with 977 additions and 2 deletions

View File

@ -51,7 +51,7 @@ docs 现状分析、实施方案
## 边界
- 1016 项目录中当前有 509 项通过能力注册表或运行时容器标记为可执行,覆盖控制/触发、除数据库外的数据处理、文件、网页元素层级/表格/下拉框及 DOM 高级能力、HTTP、Excel 常用历史版本与表格辅助、系统路径与环境变量、进程管理、桌面键鼠/窗口对象/剪贴板/对话框、PDF、Word 文档编辑、SMTP 邮件变体、文本差异、工作日判断、屏幕截图、离线图像查找与点击;其余灰标「待接入」。运行前检查会提前定位待接入组件、必填参数、多出口与断链节点。
- 1016 项目录中当前有 619 项通过能力注册表或运行时容器标记为可执行,覆盖控制/触发、除数据库外的数据处理、文件、网页元素层级/表格/下拉框/浏览器控制/Cookie/下载及 DOM 高级能力、HTTP、Excel 常用读写/格式/筛选/历史版本、系统路径与环境变量、进程管理、桌面键鼠/窗口对象/剪贴板图片/对话框、PDF、Word 文档编辑、SMTP 与群机器人 Webhook、文本差异、工作日判断、屏幕截图、离线图像查找与点击;其余灰标「待接入」。运行前检查会提前定位待接入组件、必填参数、多出口与断链节点。
- 内置本地演示站默认监听 `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 当前状态**:计划点名的代表能力已全部进入注册表或运行时容器,覆盖控制/触发、除数据库外的数据处理、文件、网页元素层级/表格/下拉框及 DOM 高级能力、HTTP、Excel 常用历史版本与表格辅助、系统路径与环境变量、进程管理、桌面键鼠/窗口对象/剪贴板/对话框、PDF、Word 文档编辑、SMTP 邮件变体、文本差异、工作日判断、屏幕截图、离线图像查找与点击;启动后目录显示 **509 项可执行 / 1016 项总目录**。当前 20 个测试文件共 124 条测试,覆盖计划能力审计、浏览器参数路由/变体/DOM层级、Excel/PDF/Word COM/SMTP/模板匹配、文件与数据表扩展、系统/进程/窗口解析、本地工具与 Databook、直线/IF/循环/重试/跳过、主出口与异常出口语义、运行记录回写、运行前检查、本地演示站与示例流程。数据处理仅剩 8 个明确排除的数据库组件。设置页可查看分类覆盖和处理器 ID 数量。未通过实际场景验证的变体仍不计入最终交付数量。
> **2026-10-27 当前状态**:常用本地能力已进入注册表或运行时容器,覆盖控制/触发、除数据库外的数据处理、文件、网页元素层级/表格/下拉框/浏览器控制/Cookie/下载及 DOM 高级能力、HTTP、Excel 常用读写/格式/筛选/历史版本、系统路径与环境变量、进程管理、桌面键鼠/窗口对象/剪贴板图片/对话框、PDF、Word 文档编辑、SMTP 与群机器人 Webhook、文本差异、工作日判断、屏幕截图、离线图像查找与点击;启动后目录显示 **619 项可执行 / 1016 项总目录**。当前 23 个测试文件共 138 条测试,浏览器 E2E 真实结果为“订单创建成功:RPA 客户,金额 128”。数据处理仅剩 8 个明确排除的数据库组件。设置页可查看分类覆盖和处理器 ID 数量。此后以集中场景测试与稳定性修复为主,不再追求厂商云端、硬件和专用通道数量。
> 浏览器端到端门禁:`npm run test:e2e` 使用隔离用户目录启动应用和本地演示站,真实执行打开网页、两次可信输入、点击提交和读取结果;期望结果必须精确包含“订单创建成功:RPA 客户,金额 128”。浏览器输入、点击、定位歧义和遮挡检查机制已对照 `C:\rpa-demo/actions.js` 校准。

View File

@ -0,0 +1,21 @@
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 type { BrowserLike, RunContext } from "../engine/types";
import { getHandler } from "../engine/registry";
import { BROWSER_CONTROL_IDS } from "./browser-control";
const component = {} as CatalogComponent; const node = (id: string): FlowNode => ({ id, componentId: id, position: { x: 0, y: 0 }, values: {}, options: {} });
function fake(): BrowserLike { return { navigate: vi.fn(async (url) => ({ url, title: "title" })), click: vi.fn(), type: vi.fn(), getText: vi.fn(), exists: vi.fn(async () => true), select: vi.fn(), runJs: vi.fn(async (script) => script.includes("document.title") ? "Title" : script.includes("document.cookie") ? "a=1" : script.includes("readyState") ? true : true), download: vi.fn(async (_s, folder, file) => `${folder}/${file}`), screenshot: vi.fn(async (target) => target), currentUrl: vi.fn(async () => "http://127.0.0.1/"), close: vi.fn() }; }
function context(browser: BrowserLike): RunContext { return { runId: "r", flowId: "f", flow: {} as any, vars: {}, secrets: {}, signal: new AbortController().signal, evidenceDir: "C:/evidence", browser, emit: vi.fn(), log: vi.fn(), componentOf: vi.fn() }; }
async function run(ctx: RunContext, id: string, args: Record<string, unknown> = {}) { const handler = getHandler(id); expect(handler, id).toBeTruthy(); return handler!(ctx, node(id), args, component); }
describe("browser control capabilities", () => {
let browser: BrowserLike; let ctx: RunContext;
beforeEach(() => { browser = fake(); ctx = context(browser); });
it("registers only real catalog IDs", () => { const catalog = JSON.parse(readFileSync(resolve("resources/catalog.json"), "utf8")); const ids = new Set(catalog.components.map((item: any) => item.id)); for (const id of BROWSER_CONTROL_IDS) { expect(ids.has(id), id).toBe(true); expect(getHandler(id), id).toBeTruthy(); } });
it("routes navigation metadata, scrolling, cookies and screenshots", async () => { await expect(run(ctx, "WebBrowser_GetTitle")).resolves.toBe("Title"); await expect(run(ctx, "WebBrowser_GetUrl")).resolves.toBe("http://127.0.0.1/"); await run(ctx, "WebBrowser_SetScrollPos", { x: 1, y: 2 }); expect(browser.runJs).toHaveBeenCalled(); await expect(run(ctx, "WebBrowser_GetCookie", { name: "a" })).resolves.toBe("1"); await run(ctx, "WebBrowser_GetBrowserPic", { path: "C:/evidence/page.png" }); expect(browser.screenshot).toHaveBeenCalled(); });
it("routes waits, exists, close and direct downloads", async () => { await expect(run(ctx, "Element_ExistsV1", { uiSelector: "css:#a" })).resolves.toBe(true); await expect(run(ctx, "Element_WaitElement_v5", { uiSelector: "css:#a", time_out: 0 })).resolves.toBe(true); await run(ctx, "WebBrowser_DownFile", { url: "http://127.0.0.1/file.csv", savepath: "C:/downloads", filename: "file.csv" }); expect(browser.download).toHaveBeenCalledWith(expect.stringContaining("rpa-download"), "C:/downloads", "file.csv", 10000); await run(ctx, "WebBrowser_CloseWeb"); expect(browser.close).toHaveBeenCalled(); });
it("rejects unsupported request headers for direct downloads", async () => { await expect(run(ctx, "WebBrowser_DownFile", { url: "http://127.0.0.1/file", savepath: "C:/d", headers: '{"x":"y"}' })).rejects.toMatchObject({ code: "validation_error" }); });
});

View File

@ -0,0 +1,55 @@
import path from "node:path";
import { browserSession } from "../browser/session";
import { register } from "../engine/registry";
import { RuntimeError, type BrowserLike, type RunContext } from "../engine/types";
async function session(ctx: RunContext): Promise<BrowserLike> { if (!ctx.browser) ctx.browser = await browserSession(); return ctx.browser; }
function selector(args: Record<string, unknown>): unknown { const value = args.uiSelector ?? args.download_selector ?? args.selector; if (value === undefined || value === null || value === "") throw new RuntimeError("validation_error", "元素选择器为空"); return value; }
function text(args: Record<string, unknown>, keys: string[], fallback = ""): string { for (const key of keys) { const value = args[key]; if (value !== undefined && value !== null && value !== "") return String(value); } return fallback; }
register("WebBrowser_Reload", async (ctx) => { const browser = await session(ctx); return browser.navigate(await browser.currentUrl()); });
register("WebBrowser_GoBack", async (ctx) => { const browser = await session(ctx); await browser.runJs("history.back(); true"); return true; });
register("WebBrowser_GoForward", async (ctx) => { const browser = await session(ctx); await browser.runJs("history.forward(); true"); return true; });
register("WebBrowser_GetTitle", async (ctx) => session(ctx).then((browser) => browser.runJs("document.title")));
register("WebBrowser_GetUrl", async (ctx) => session(ctx).then((browser) => browser.currentUrl()));
register("WebBrowser_GetDocument", async (ctx) => session(ctx).then((browser) => browser.runJs("document.documentElement.outerHTML")));
register(["WebBrowser_GetScrollPos", "WebBrowser_GetScrollPos_V1"], async (ctx) => session(ctx).then((browser) => browser.runJs("({x:window.scrollX,y:window.scrollY})")));
register(["WebBrowser_SetScrollPos", "WebBrowser_SetScrollPos_V1"], async (ctx, _node, args) => {
const browser = await session(ctx); const x = Number(args.x ?? args.left ?? 0); const y = Number(args.y ?? args.top ?? args.position ?? 0); await browser.runJs(`window.scrollTo(${Number.isFinite(x) ? x : 0},${Number.isFinite(y) ? y : 0}); true`); return { x, y };
});
register(["WebBrowser_GetCookies", "WebBrowser_GetCookieNew"], async (ctx) => {
const raw = String(await (await session(ctx)).runJs("document.cookie")); const result: Record<string, string> = {}; for (const part of raw.split(";")) { const [name, ...rest] = part.trim().split("="); if (name) result[name] = decodeURIComponent(rest.join("=")); } return result;
});
register("WebBrowser_GetCookie", async (ctx, _node, args) => {
const name = text(args, ["name"]); const cookies = await getCookies(ctx); return cookies[name] ?? "";
});
async function getCookies(ctx: RunContext): Promise<Record<string, string>> { const raw = String(await (await session(ctx)).runJs("document.cookie")); return Object.fromEntries(raw.split(";").map((part) => part.trim()).filter(Boolean).map((part) => { const [name, ...rest] = part.split("="); return [name, decodeURIComponent(rest.join("="))]; })); }
register("WebBrowser_SetCookies", async (ctx, node, args) => {
let cookies: unknown = args.cookies; if (typeof cookies === "string") { try { cookies = JSON.parse(cookies); } catch { throw new RuntimeError("validation_error", "Cookie JSON无效", { nodeId: node.id, componentId: node.componentId }); } }
if (!cookies || typeof cookies !== "object") throw new RuntimeError("validation_error", "Cookie对象为空", { nodeId: node.id, componentId: node.componentId }); const browser = await session(ctx);
for (const [name, value] of Object.entries(cookies as Record<string, unknown>)) await browser.runJs(`document.cookie=${JSON.stringify(`${name}=${encodeURIComponent(String(value))}; path=/`)}; true`); return getCookies(ctx);
});
register("WebBrowser_SetCookies_V1", async (ctx, node, args) => {
const name = text(args, ["cookie_name"]); if (!name) throw new RuntimeError("validation_error", "Cookie name为空", { nodeId: node.id, componentId: node.componentId });
const value = encodeURIComponent(text(args, ["cookie_value"])); const cookiePath = text(args, ["cookie_path"], "/"); const domain = text(args, ["cookie_domain"]); const expires = text(args, ["cookie_expires"]); const secure = String(args.secure ?? "0") === "1";
const cookie = `${name}=${value}; path=${cookiePath}${domain ? `; domain=${domain}` : ""}${expires ? `; expires=${expires}` : ""}${secure ? "; secure" : ""}`; await (await session(ctx)).runJs(`document.cookie=${JSON.stringify(cookie)}; true`); return cookie;
});
register("WebBrowser_Wait", async (ctx) => {
const browser = await session(ctx); const deadline = Date.now() + 15000; do { try { if (await browser.runJs("document.readyState === 'complete'")) return true; } catch {} await new Promise((resolve) => setTimeout(resolve, 100)); } while (Date.now() < deadline); return false;
});
register(["WebBrowser_CloseWeb", "WebBrowser_CloseWeb_V1", "WebBrowser_CloseWeb_V2", "WebBrowser_Close_V1"], async (ctx) => { const browser = await session(ctx); await browser.close(); ctx.browser = undefined; return true; });
register(["WebBrowser_GetBrowserPic", "WebBrowser_GetBrowserPicV1"], async (ctx, node, args) => { const browser = await session(ctx); const target = path.resolve(text(args, ["path", "file_path", "image_path"], path.join(ctx.evidenceDir, `${node.id}.png`))); return browser.screenshot(target); });
register("Element_ExistsV1", async (ctx, _node, args) => (await session(ctx)).exists(selector(args)));
register("Element_WaitElement_v5", async (ctx, _node, args) => { const browser = await session(ctx); const target = selector(args); const expected = String(args.choose ?? "1") !== "3"; const deadline = Date.now() + Math.max(0, Number(args.time_out ?? args.timeout ?? 10)) * 1000; do { if ((await browser.exists(target)) === expected) return true; await new Promise((resolve) => setTimeout(resolve, 200)); } while (Date.now() <= deadline); return false; });
register(["WebBrowser_DownFile", "WebBrowser_DownFile_V1", "WebBrowser_DownloadFile", "WebBrowser_DownloadFile_V1"], async (ctx, node, args) => {
const browser = await session(ctx); const headers = text(args, ["headers"], "{}"); const body = text(args, ["body", "param"], "{}"); if (!["", "{}"].includes(headers) || !["", "{}"].includes(body)) throw new RuntimeError("validation_error", "当前下载能力暂不支持自定义Headers或请求体", { nodeId: node.id, componentId: node.componentId });
const folder = text(args, ["savepath", "save_path"]); if (!folder) throw new RuntimeError("validation_error", "下载目录为空", { nodeId: node.id, componentId: node.componentId }); const filename = text(args, ["filename", "file_name"]); const seconds = Number(args.download_time ?? 0); const timeout = seconds > 0 ? seconds * 1000 : Number(args.timeout ?? 10000) * (node.componentId.endsWith("_V1") && node.componentId.includes("DownFile") ? 1000 : 1);
const directUrl = text(args, ["url"]); if (!directUrl) return browser.download(selector(args), folder, filename, timeout);
const temporaryId = `rpa-download-${Date.now().toString(36)}`; await browser.runJs(`(()=>{const a=document.createElement('a');a.id=${JSON.stringify(temporaryId)};a.href=${JSON.stringify(directUrl)};a.style.display='none';document.body.appendChild(a);return true})()`);
try { return await browser.download(`css:#${temporaryId}`, folder, filename, timeout); } finally { try { await browser.runJs(`document.getElementById(${JSON.stringify(temporaryId)})?.remove();true`); } catch {} }
});
export const BROWSER_CONTROL_IDS = ["WebBrowser_Reload", "WebBrowser_GoBack", "WebBrowser_GoForward", "WebBrowser_GetTitle", "WebBrowser_GetUrl", "WebBrowser_GetDocument", "WebBrowser_GetScrollPos", "WebBrowser_GetScrollPos_V1", "WebBrowser_SetScrollPos", "WebBrowser_SetScrollPos_V1", "WebBrowser_GetCookies", "WebBrowser_GetCookieNew", "WebBrowser_GetCookie", "WebBrowser_SetCookies", "WebBrowser_SetCookies_V1", "WebBrowser_Wait", "WebBrowser_CloseWeb", "WebBrowser_CloseWeb_V1", "WebBrowser_CloseWeb_V2", "WebBrowser_Close_V1", "WebBrowser_GetBrowserPic", "WebBrowser_GetBrowserPicV1", "Element_ExistsV1", "Element_WaitElement_v5", "WebBrowser_DownFile", "WebBrowser_DownFile_V1", "WebBrowser_DownloadFile", "WebBrowser_DownloadFile_V1"] as const;

View File

@ -0,0 +1,39 @@
import { createServer } from "node:http";
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { PDFDocument } from "pdf-lib";
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 "./desktop"; import "./pdf"; import "./system-extended"; import "./window-extended"; import "./common-final";
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("final common capabilities", () => {
it("registers window, keyboard, clipboard, system, PDF and webhook components", () => {
for (const id of ["Window_ActiveWindow", "Window_CloseWindow_v1", "Window_GetScreenSize", "Window_WindowList", "Window_GetRect", "Window_MaximumSize", "Window_SetWindowShow", "Window_WaitWindow", "Keyboard_DriveSend", "Keyboard_DriveSend_V1", "Keyboard_DriveSend_V2", "Clipboard_SetImage", "Clipboard_SetImageV1", "Clipboard_SaveImage", "Clipboard_SaveImageV1", "System_PlaySound", "CustomConfig_GetConfig", "Basic_BusinessOrderCount", "PDF_ConcatPdfV1", "PDF_SetPdfWaterMark", "PDF_SetPdfWaterMarkV1", "MessageNotification_DingTalk_Message", "MessageNotification_Feishu_Message", "MessageNotification_WXWork_Message"]) expect(getHandler(id), id).toBeTruthy();
});
it("reads custom config and increments business counters", async () => {
await expect(run("CustomConfig_GetConfig", { jsonlist: [{ name: "timeout", value: 30 }], configName: "timeout" })).resolves.toBe(30);
await expect(run("Basic_BusinessOrderCount", { start_count: 10, count: 2 })).resolves.toBe(12);
await expect(run("Basic_BusinessOrderCount", { start_count: 10, count: 2 })).resolves.toBe(14);
});
it("adds an ASCII PDF watermark", async () => {
const directory = await mkdtemp(path.join(os.tmpdir(), "rpa-common-final-")); dirs.push(directory); const input = path.join(directory, "input.pdf"); const pdf = await PDFDocument.create(); pdf.addPage([300, 300]); await writeFile(input, await pdf.save());
const output = await run("PDF_SetPdfWaterMark", { fileInPath: input, markTextInfo: "RPA", newPdfName: "watermark.pdf" }) as string;
expect((await readFile(output)).byteLength).toBeGreaterThan(100);
});
it("posts the three webhook formats to a local server", async () => {
const bodies: unknown[] = []; const server = createServer((request, response) => { let raw = ""; request.on("data", (chunk) => raw += chunk); request.on("end", () => { bodies.push(JSON.parse(raw)); response.writeHead(200, { "Content-Type": "application/json" }); response.end('{"ok":true}'); }); });
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve)); const address = server.address(); const webhook = `http://127.0.0.1:${typeof address === "object" && address ? address.port : 0}/hook`;
try { await run("MessageNotification_DingTalk_Message", { webhook, content_text: "ding" }); await run("MessageNotification_Feishu_Message", { webhook, content_text: "fei" }); await run("MessageNotification_WXWork_Message", { webhook, content_text: "wx" }); expect(bodies).toHaveLength(3); } finally { await new Promise<void>((resolve) => server.close(() => resolve())); }
});
});

View File

@ -0,0 +1,92 @@
import { createHmac } from "node:crypto";
import { promises as fs } from "node:fs";
import path from "node:path";
import { PDFDocument, StandardFonts, degrees, rgb } from "pdf-lib";
import { getHandler, register } from "../engine/registry";
import { RuntimeError, type CapabilityHandler } from "../engine/types";
import { powershell, psString } from "./desktop";
function text(args: Record<string, unknown>, keys: string[], fallback = ""): string {
for (const key of keys) { const value = args[key]; if (value !== undefined && value !== null && value !== "") return String(value); }
return fallback;
}
function delegate(baseId: string): CapabilityHandler {
return (ctx, node, args, component) => { const base = getHandler(baseId); if (!base) throw new RuntimeError("capability_not_implemented", `基础能力不可用:${baseId}`, { nodeId: node.id, componentId: node.componentId }); return base(ctx, node, args, component); };
}
register("Window_ActiveWindow", delegate("Window_SetActive"));
register("Window_CloseWindow_v1", delegate("Window_CloseWindow"));
register("Window_GetRect", delegate("Window_GetWindowRect"));
register("Window_MaximumSize", (ctx, node, args, component) => delegate("Window_SetWindowState")(ctx, node, { ...args, WindowType: "maximize" }, component));
register(["Keyboard_DriveSend", "Keyboard_DriveSend_V1", "Keyboard_DriveSend_V2"], delegate("Keyboard_Send_V1"));
register("Window_GetScreenSize", async () => {
const output = await powershell("Add-Type -AssemblyName System.Windows.Forms; $b=[System.Windows.Forms.Screen]::PrimaryScreen.Bounds; @{width=$b.Width;height=$b.Height;x=$b.X;y=$b.Y} | ConvertTo-Json -Compress");
return JSON.parse(output);
});
register("Window_WindowList", async (_ctx, _node, args) => {
const title = text(args, ["match_title", "select_title"]); const regex = String(args.is_regex ?? "0") === "1";
const output = await powershell(`Get-Process | Where-Object { $_.MainWindowHandle -ne 0 ${title ? (regex ? `-and $_.MainWindowTitle -match ${psString(title)}` : `-and $_.MainWindowTitle -like ${psString(`*${title}*`)}`) : ""} } | ForEach-Object { @{pid=$_.Id;title=$_.MainWindowTitle;hwnd=$_.MainWindowHandle.ToInt64()} } | ConvertTo-Json -Compress`);
if (!output) return []; const parsed = JSON.parse(output); return Array.isArray(parsed) ? parsed : [parsed];
});
register("Window_SetWindowShow", async (_ctx, node, args) => {
const title = text(args, ["窗口对象", "window_obj", "title"]); if (!title || title === "窗口对象") throw new RuntimeError("validation_error", "窗口标题为空", { nodeId: node.id, componentId: node.componentId });
const show = !text(args, ["WindowType"], "show").toLowerCase().includes("hide");
await powershell(`$p=Get-Process|Where-Object{$_.MainWindowTitle -like ${psString(`*${title}*`)}}|Select-Object -First 1;if(!$p){throw '找不到窗口'};Add-Type @'\nusing System;using System.Runtime.InteropServices;public static class W{[DllImport("user32.dll")]public static extern bool ShowWindow(IntPtr h,int n);}\n'@;[W]::ShowWindow($p.MainWindowHandle,${show ? 5 : 0})|Out-Null`);
return show;
});
register("Window_WaitWindow", async (_ctx, node, args) => {
const title = text(args, ["match_title", "select_title", "window_obj", "target_window"]); if (!title) throw new RuntimeError("validation_error", "窗口标题为空", { nodeId: node.id, componentId: node.componentId });
const expected = text(args, ["switchType"], "0") !== "1"; const deadline = Date.now() + Math.max(0, Number(args.second ?? 5)) * 1000;
do { const exists = (await powershell(`$p=Get-Process|Where-Object{$_.MainWindowTitle -like ${psString(`*${title}*`)}}|Select-Object -First 1;[bool]$p`)).toLowerCase() === "true"; if (exists === expected) return true; await new Promise((resolve) => setTimeout(resolve, 200)); } while (Date.now() <= deadline);
return false;
});
const setClipboardImage: CapabilityHandler = async (_ctx, node, args) => {
const file = path.resolve(text(args, ["path"])); await fs.access(file).catch(() => { throw new RuntimeError("validation_error", `图片不存在:${file}`, { nodeId: node.id, componentId: node.componentId }); });
await powershell(`Add-Type -AssemblyName System.Windows.Forms;Add-Type -AssemblyName System.Drawing;$image=[System.Drawing.Image]::FromFile(${psString(file)});[System.Windows.Forms.Clipboard]::SetImage($image);$image.Dispose()`); return file;
};
register(["Clipboard_SetImage", "Clipboard_SetImageV1"], setClipboardImage);
const saveClipboardImage: CapabilityHandler = async (_ctx, node, args) => {
const folder = path.resolve(text(args, ["path"])); const name = text(args, ["filename"], "clipboard.png"); const target = path.join(folder, path.extname(name) ? name : `${name}.png`); await fs.mkdir(folder, { recursive: true });
await powershell(`Add-Type -AssemblyName System.Windows.Forms;Add-Type -AssemblyName System.Drawing;if(-not [System.Windows.Forms.Clipboard]::ContainsImage()){throw '剪贴板中没有图片'};$image=[System.Windows.Forms.Clipboard]::GetImage();$image.Save(${psString(target)},[System.Drawing.Imaging.ImageFormat]::Png);$image.Dispose()`).catch((error) => { throw new RuntimeError("desktop_command_failed", (error as Error).message, { nodeId: node.id, componentId: node.componentId }); }); return target;
};
register(["Clipboard_SaveImage", "Clipboard_SaveImageV1"], saveClipboardImage);
register("System_PlaySound", async (_ctx, node, args) => {
const audio = text(args, ["audio"]); if (audio) { const file = path.resolve(audio); await fs.access(file); await powershell(`$player=New-Object System.Media.SoundPlayer ${psString(file)};$player.PlaySync()`); return file; }
await powershell("[System.Media.SystemSounds]::Beep.Play()"); return true;
});
register("CustomConfig_GetConfig", (_ctx, _node, args) => {
let configs: unknown = args.jsonlist; if (typeof configs === "string") { try { configs = JSON.parse(configs); } catch { configs = []; } }
const name = text(args, ["configName"]); if (Array.isArray(configs)) { const found = configs.find((item) => item && typeof item === "object" && [String((item as any).name ?? ""), String((item as any).configName ?? ""), String((item as any).key ?? "")].includes(name)); return found && typeof found === "object" && "value" in found ? (found as any).value : found ?? null; }
return configs && typeof configs === "object" ? (configs as Record<string, unknown>)[name] : null;
});
register("Basic_BusinessOrderCount", (ctx, _node, args) => {
const key = "__businessOrderCount"; const start = Number(args.start_count ?? 0); const step = Number(args.count ?? 1); const current = Number(ctx.vars[key] ?? start); const next = current + step; ctx.vars[key] = next; return next;
});
register("PDF_ConcatPdfV1", delegate("PDF_ConcatPdf"));
register(["PDF_SetPdfWaterMark", "PDF_SetPdfWaterMarkV1"], async (_ctx, node, args) => {
const input = path.resolve(text(args, ["fileInPath"])); const output = path.join(path.dirname(input), text(args, ["newPdfName"], "PDF加水印.pdf")); const content = text(args, ["markTextInfo"], "Watermark");
if (!/^[\x20-\x7E]*$/.test(content)) throw new RuntimeError("validation_error", "当前文本水印仅支持 ASCII;中文水印需嵌入字体", { nodeId: node.id, componentId: node.componentId });
const document = await PDFDocument.load(await fs.readFile(input), { ignoreEncryption: true }); const font = await document.embedFont(StandardFonts.Helvetica);
for (const page of document.getPages()) { const { width, height } = page.getSize(); page.drawText(content, { x: width * 0.2, y: height * 0.5, size: 32, font, color: rgb(0.65, 0.65, 0.65), opacity: 0.35, rotate: degrees(-30) }); }
await fs.writeFile(output, await document.save()); return output;
});
async function webhook(kind: "dingtalk" | "feishu" | "wecom", args: Record<string, unknown>, nodeId: string, componentId: string) {
let url = text(args, ["webhook"]); if (!/^https?:\/\//i.test(url)) throw new RuntimeError("validation_error", "Webhook 地址无效", { nodeId, componentId });
const secret = text(args, ["key"]); if (kind === "dingtalk" && secret) { const timestamp = Date.now(); const sign = encodeURIComponent(createHmac("sha256", secret).update(`${timestamp}\n${secret}`).digest("base64")); url += `${url.includes("?") ? "&" : "?"}timestamp=${timestamp}&sign=${sign}`; }
const custom = text(args, ["content_custom"]); const messageType = text(args, ["message_type"], "0"); const plain = text(args, ["content_text", "test_message"]); const markdown = text(args, ["content_markdown"]);
let body: unknown;
if (custom) { try { body = JSON.parse(custom); } catch { throw new RuntimeError("validation_error", "自定义消息 JSON 无效", { nodeId, componentId }); } }
else if (kind === "feishu") body = messageType === "1" ? { msg_type: "interactive", card: { elements: [{ tag: "markdown", content: markdown || plain }] } } : { msg_type: "text", content: { text: plain } };
else if (kind === "dingtalk") body = messageType === "1" ? { msgtype: "markdown", markdown: { title: "RPA通知", text: markdown || plain } } : { msgtype: "text", text: { content: plain }, at: { atMobiles: text(args, ["mentioned_mobile_list"]).split(/[,,]/).filter(Boolean) } };
else body = messageType === "1" ? { msgtype: "markdown", markdown: { content: markdown || plain } } : { msgtype: "text", text: { content: plain, mentioned_mobile_list: text(args, ["mentioned_mobile_list"]).split(/[,,]/).filter(Boolean) } };
const response = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }); const responseText = await response.text(); if (!response.ok) throw new RuntimeError("http_error", `Webhook 返回 ${response.status}: ${responseText}`, { nodeId, componentId });
try { return JSON.parse(responseText); } catch { return responseText; }
}
register("MessageNotification_DingTalk_Message", (_ctx, node, args) => webhook("dingtalk", args, node.id, node.componentId));
register("MessageNotification_Feishu_Message", (_ctx, node, args) => webhook("feishu", args, node.id, node.componentId));
register("MessageNotification_WXWork_Message", (_ctx, node, args) => webhook("wecom", args, node.id, node.componentId));

View File

@ -0,0 +1,147 @@
import { mkdtemp, readFile, readdir, rm } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import ExcelJS from "exceljs";
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 "./index";
import { EXCEL_COMMON_IDS } from "./excel-common";
const directories: string[] = [];
const context = {} as RunContext;
const component = {} as CatalogComponent;
const node = (componentId: string): FlowNode => ({
id: `excel_common_${componentId}`,
componentId,
position: { x: 0, y: 0 },
values: {},
options: {}
});
async function temporaryWorkbook(): Promise<string> {
const directory = await mkdtemp(path.join(os.tmpdir(), "rpa-excel-common-"));
directories.push(directory);
const filePath = path.join(directory, "common.xlsx");
const workbook = new ExcelJS.Workbook();
const sheet = workbook.addWorksheet("Data");
sheet.addRows([
["name", "score", "group", "parts"],
["Bob", 2, "B", "x,y"],
["Alice", 3, "A", "m,n"],
["Bob", 2, "B", "p,q"]
]);
await workbook.xlsx.writeFile(filePath);
return filePath;
}
async function run(componentId: string, args: Record<string, unknown>): Promise<unknown> {
const handler = getHandler(componentId);
expect(handler, `${componentId} should be registered`).toBeTruthy();
return handler!(context, node(componentId), args, component);
}
async function readWorkbook(filePath: string): Promise<ExcelJS.Workbook> {
const workbook = new ExcelJS.Workbook();
await workbook.xlsx.readFile(filePath);
return workbook;
}
afterEach(async () => {
await Promise.all(directories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })));
});
describe("common Excel capabilities", () => {
it("registers at least 25 real catalog IDs without conflicting with existing modules", async () => {
const catalog = JSON.parse(await readFile("resources/catalog.json", "utf8")) as Catalog;
const catalogIds = new Set(catalog.components.map((item) => item.id));
expect(EXCEL_COMMON_IDS.length).toBeGreaterThanOrEqual(25);
for (const id of EXCEL_COMMON_IDS) {
expect(catalogIds.has(id), `${id} should exist in catalog.json`).toBe(true);
expect(getHandler(id), `${id} should be registered`).toBeTruthy();
}
const sourceFiles = (await readdir("src/main/capabilities"))
.filter((file) => file.endsWith(".ts") && !file.endsWith(".test.ts") && file !== "excel-common.ts");
const existingSource = (await Promise.all(sourceFiles.map((file) => readFile(path.join("src/main/capabilities", file), "utf8")))).join("\n");
for (const id of EXCEL_COMMON_IDS) expect(existingSource.includes(`"${id}"`), `${id} must not already be registered`).toBe(false);
});
it("writes and reads rows, columns, and ranges in a real workbook", async () => {
const filePath = await temporaryWorkbook();
await run("Excel_Cs_WriteRow", { workbook: filePath, sheet: "Data", row: 6, column: "A", contents: ["Carol", 9, "C"], save: 1 });
await run("Excel_Cs_WriteColumn", { workbook: filePath, sheet: "Data", row: 2, column: "E", contents: [10, 20, 30], save: 1 });
await run("Excel_Cs_WriteRangeV2", { workbook: filePath, sheet: "Data", row: 8, column: "A", contents: [["x", 1], ["y", 2]], save: 1 });
await expect(run("Excel_Cs_ReadRow", { workbook: filePath, sheet: "Data", rowIndex: 6 })).resolves.toEqual(["Carol", 9, "C", "", ""]);
await expect(run("Excel_GetColData", { objExcel: filePath, sheet: "Data", cellPos: "E2" })).resolves.toEqual([10, 20, 30, "", "", "", "", ""]);
await expect(run("Excel_Cs_ReadRange", { workbook: filePath, sheet: "Data", startRow: 8, startColumn: "A", lastRow: 9, lastColumn: "B" }))
.resolves.toEqual([["x", 1], ["y", 2]]);
await expect(run("Excel_Cs_ReadColumnCount", { workbook: filePath, sheet: "Data", row: 2 })).resolves.toBe(5);
});
it("finds, replaces, sorts, filters, clears filters, and removes duplicates", async () => {
const filePath = await temporaryWorkbook();
await expect(run("Excel_Cs_Find", { workbook: filePath, sheet: "Data", what: "Alice", matchFirst: 1 }))
.resolves.toEqual(["A3"]);
await run("Excel_Cs_Search", { workbook: filePath, sheet: "Data", mode: "替换", input: "Alice", replace: "Alicia", wholeWord: 1, save: 1 });
await run("Excel_Cs_Sort", { workbook: filePath, sheet: "Data", sortRange: "A1:D4", sortOrder: "2", header: 1, save: 1 });
await run("Excel_Cs_Filter", { workbook: filePath, sheet: "Data", column: "C", criteria: "B", condition: "equal", startRow: 1, endRow: 4, onHeader: 1, save: 1 });
let workbook = await readWorkbook(filePath);
let sheet = workbook.getWorksheet("Data")!;
expect(sheet.getCell("A2").value).toBe("Bob");
expect(sheet.getRow(3).hidden || sheet.getRow(4).hidden).toBe(true);
await run("Excel_Cs_ClearExcelFilter", { workbook: filePath, sheet: "Data", save: 1 });
await run("Excel_Cs_DeleteDuplicates", { workbook: filePath, sheet: "Data", columns: ["A", "B", "C"], header: 1, save: 1 });
workbook = await readWorkbook(filePath);
sheet = workbook.getWorksheet("Data")!;
expect(sheet.getRows(1, sheet.rowCount)!.some((row) => row.hidden)).toBe(false);
expect(sheet.rowCount).toBe(3);
expect(sheet.getColumn("A").values).toContain("Alicia");
});
it("copies, pastes, fills, splits, and deletes real ranges", async () => {
const filePath = await temporaryWorkbook();
await run("Excel_CopyRange", { objExcel: filePath, sheet: "Data", cellPosRange: "A2:B3" });
await run("Excel_Cs_PastRange", { workbook: filePath, sheet: "Data", column: "F", row: 2, save: 1 });
await run("Excel_Cs_AutoFill", { workbook: filePath, sheet: "Data", sourceRange: "B2:B3", destination: "B2:B6", save: 1 });
await run("Excel_Cs_ColumnSplitV2", { workbook: filePath, sheet: "Data", column: "D", destination: "J1", comma: 1, tab: 0, startRow: 2, endRow: 4, save: 1 });
await run("Excel_Cs_DeleteRangeV3", { workbook: filePath, sheet: "Data", excelRng: "C2:C4", save: 1 });
const workbook = await readWorkbook(filePath);
const sheet = workbook.getWorksheet("Data")!;
expect(sheet.getCell("F2").value).toBe("Bob");
expect(sheet.getCell("G3").value).toBe(3);
expect(sheet.getCell("B6").value).toBe(6);
expect(sheet.getCell("J2").value).toBe("x");
expect(sheet.getCell("K2").value).toBe("y");
expect(sheet.getCell("C2").value).toBeNull();
});
it("manages sheets and persists active sheet state", async () => {
const filePath = await temporaryWorkbook();
await run("Excel_CreateSheet", { objExcel: filePath, sheet: "Report", isSave: true });
await expect(run("Excel_Cs_GetName", { workbook: filePath })).resolves.toEqual(["Data", "Report"]);
await expect(run("Excel_Cs_Active", { workbook: filePath, mode: "按名称", sheet: "Report", save: 1 })).resolves.toBe("Report");
await run("Excel_DeleteSheet", { objExcel: filePath, sheet: "Data", isSave: true });
await run("Excel_Cs_Close", { workbook: filePath, save: 1 });
const workbook = await readWorkbook(filePath);
expect(workbook.worksheets.map((sheet) => sheet.name)).toEqual(["Report"]);
});
it("sets and reads cell and range colors", async () => {
const filePath = await temporaryWorkbook();
await run("Excel_Cs_SetCellBackground", { workbook: filePath, sheet: "Data", column: "A", row: 2, color: "#12AB34", save: 1 });
await run("Excel_Cs_SetRangeFontV2", { workbook: filePath, sheet: "Data", range: "A2:B3", color: "#445566", save: 1 });
await expect(run("Excel_Cs_ReadCellBackground", { workbook: filePath, sheet: "Data", column: "A", row: 2 })).resolves.toBe("FF12AB34");
const workbook = await readWorkbook(filePath);
const sheet = workbook.getWorksheet("Data")!;
expect((sheet.getCell("A2").fill as ExcelJS.FillPattern).fgColor?.argb).toBe("FF12AB34");
expect(sheet.getCell("B3").font.color?.argb).toBe("FF445566");
});
});

View File

@ -0,0 +1,618 @@
import ExcelJS from "exceljs";
import path from "node:path";
import { register } from "../engine/registry";
import { RuntimeError, type CapabilityHandler } from "../engine/types";
type WorkbookSource = { workbook: ExcelJS.Workbook; filePath: string };
type CopiedRange = { values: unknown[][]; styles: Partial<ExcelJS.Style>[][] };
const workbookCache = new Map<string, ExcelJS.Workbook>();
let copiedRange: CopiedRange | undefined;
function value(args: Record<string, unknown>, keys: string[], fallback: unknown = ""): unknown {
for (const key of keys) {
const candidate = args[key];
if (candidate !== undefined && candidate !== null && candidate !== "") return candidate;
}
return fallback;
}
function text(args: Record<string, unknown>, keys: string[], fallback = ""): string {
return String(value(args, keys, fallback));
}
function truthy(candidate: unknown, fallback = false): boolean {
if (candidate === undefined || candidate === null || candidate === "") return fallback;
return [true, 1, "1", "true", "True", "TRUE", "yes", "是", "开启"].includes(candidate as never);
}
function numberValue(candidate: unknown, fallback: number): number {
const result = Number(candidate);
return Number.isFinite(result) ? result : fallback;
}
function requiredPath(candidate: unknown, nodeId: string, componentId: string): string {
const raw = typeof candidate === "object" && candidate !== null ? (candidate as { path?: unknown }).path : candidate;
const filePath = String(raw ?? "").trim();
if (!filePath) {
throw new RuntimeError("validation_error", "Excel path is empty", {
nodeId,
componentId,
recommendation: "Configure an Excel workbook path"
});
}
return path.resolve(filePath);
}
async function loadWorkbook(args: Record<string, unknown>, nodeId: string, componentId: string): Promise<WorkbookSource> {
const filePath = requiredPath(value(args, ["workbook", "objExcel", "objExcelWorkBook", "file", "path"]), nodeId, componentId);
let workbook = workbookCache.get(filePath);
if (!workbook) {
workbook = new ExcelJS.Workbook();
await workbook.xlsx.readFile(filePath);
workbookCache.set(filePath, workbook);
}
return { workbook, filePath };
}
function worksheetOf(workbook: ExcelJS.Workbook, args: Record<string, unknown>): ExcelJS.Worksheet {
const name = text(args, ["sheet", "sheetName"]);
const sheet = name ? workbook.getWorksheet(name) : workbook.worksheets[0];
if (!sheet) throw new Error(`Sheet not found: ${name || "first sheet"}`);
return sheet;
}
async function saveWhenRequested(source: WorkbookSource, args: Record<string, unknown>, fallback = false): Promise<void> {
if (truthy(value(args, ["save", "isSave"]), fallback)) await source.workbook.xlsx.writeFile(source.filePath);
}
function columnNumber(candidate: unknown): number {
const raw = String(candidate ?? "A").trim().toUpperCase();
if (/^\d+$/.test(raw)) return Math.max(1, Number(raw));
if (!/^[A-Z]+$/.test(raw)) throw new Error(`Invalid column: ${raw}`);
let result = 0;
for (const character of raw) result = result * 26 + character.charCodeAt(0) - 64;
return result;
}
function columnLetter(index: number): string {
let current = Math.max(1, Math.trunc(index));
let result = "";
while (current > 0) {
current -= 1;
result = String.fromCharCode(65 + current % 26) + result;
current = Math.floor(current / 26);
}
return result;
}
function cellValue(candidate: ExcelJS.CellValue): unknown {
if (candidate && typeof candidate === "object" && "result" in candidate) return candidate.result ?? "";
if (candidate && typeof candidate === "object" && "text" in candidate) return candidate.text;
return candidate ?? "";
}
function listValue(candidate: unknown): unknown[] {
if (Array.isArray(candidate)) return candidate;
const raw = String(candidate ?? "").trim();
if (!raw) return [];
try {
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed : [parsed];
} catch {
try {
const parsed = JSON.parse(raw.replace(/'/g, '"'));
return Array.isArray(parsed) ? parsed : [parsed];
} catch {
return raw.split(",").map((item) => item.trim());
}
}
}
function tableValue(candidate: unknown): unknown[][] {
return listValue(candidate).map((item) => Array.isArray(item) ? item : [item]);
}
function rangeBounds(sheet: ExcelJS.Worksheet, range: string): { top: number; left: number; bottom: number; right: number } {
const normalized = range.trim() || `A1:${columnLetter(Math.max(1, sheet.columnCount))}${Math.max(1, sheet.rowCount)}`;
const [start, end = start] = normalized.split(":").map((part) => part.trim());
const first = sheet.getCell(start);
const last = sheet.getCell(end);
return {
top: Math.min(Number(first.row), Number(last.row)),
left: Math.min(Number(first.col), Number(last.col)),
bottom: Math.max(Number(first.row), Number(last.row)),
right: Math.max(Number(first.col), Number(last.col))
};
}
function explicitRange(args: Record<string, unknown>, sheet: ExcelJS.Worksheet): string {
const direct = text(args, ["excelRng", "range", "cellPosRange", "cellPos", "sortRange"]);
if (direct) return direct;
const top = numberValue(value(args, ["startRow", "row"]), 1);
const left = columnNumber(value(args, ["startColumn", "column"]));
const bottom = numberValue(value(args, ["lastRow", "endRow"]), top);
const right = columnNumber(value(args, ["lastColumn", "endColumn"], left));
return `${columnLetter(left)}${top}:${columnLetter(right)}${bottom || Math.max(1, sheet.rowCount)}`;
}
function cellsInRange(sheet: ExcelJS.Worksheet, range: string): ExcelJS.Cell[] {
const bounds = rangeBounds(sheet, range);
const cells: ExcelJS.Cell[] = [];
for (let row = bounds.top; row <= bounds.bottom; row += 1) {
for (let column = bounds.left; column <= bounds.right; column += 1) cells.push(sheet.getCell(row, column));
}
return cells;
}
function cloneStyle(style: Partial<ExcelJS.Style>): Partial<ExcelJS.Style> {
return JSON.parse(JSON.stringify(style)) as Partial<ExcelJS.Style>;
}
function colorArgb(candidate: unknown): string {
if (Array.isArray(candidate)) {
const [red = 0, green = 0, blue = 0] = candidate.map((item) => Math.max(0, Math.min(255, Number(item))));
return `FF${red.toString(16).padStart(2, "0")}${green.toString(16).padStart(2, "0")}${blue.toString(16).padStart(2, "0")}`.toUpperCase();
}
const raw = String(candidate ?? "#000000").trim();
if (raw.startsWith("[")) {
try { return colorArgb(JSON.parse(raw)); } catch { return "FF000000"; }
}
const hex = raw.replace(/^#/, "").toUpperCase();
if (/^[0-9A-F]{8}$/.test(hex)) return hex;
return `FF${/^[0-9A-F]{6}$/.test(hex) ? hex : "000000"}`;
}
function comparison(left: unknown, right: unknown): number {
if (typeof left === "number" && typeof right === "number") return left - right;
return String(left ?? "").localeCompare(String(right ?? ""), "zh-CN", { numeric: true, sensitivity: "base" });
}
function registerMany(ids: string[], handler: CapabilityHandler): void {
register(ids, handler);
}
const writeRow: CapabilityHandler = async (_ctx, node, args) => {
const source = await loadWorkbook(args, node.id, node.componentId);
const sheet = worksheetOf(source.workbook, args);
const start = text(args, ["cellPos"]);
const row = start ? Number(sheet.getCell(start).row) : numberValue(value(args, ["row", "rowIndex"]), 1);
const column = start ? Number(sheet.getCell(start).col) : columnNumber(value(args, ["column"]));
listValue(value(args, ["contents", "value"])).forEach((item, index) => { sheet.getCell(row, column + index).value = item as ExcelJS.CellValue; });
await saveWhenRequested(source, args);
return `${columnLetter(column)}${row}`;
};
const writeColumn: CapabilityHandler = async (_ctx, node, args) => {
const source = await loadWorkbook(args, node.id, node.componentId);
const sheet = worksheetOf(source.workbook, args);
const start = text(args, ["cellPos"]);
const row = start ? Number(sheet.getCell(start).row) : numberValue(value(args, ["row"]), 1);
const column = start ? Number(sheet.getCell(start).col) : columnNumber(value(args, ["column"]));
listValue(value(args, ["contents", "value"])).forEach((item, index) => { sheet.getCell(row + index, column).value = item as ExcelJS.CellValue; });
await saveWhenRequested(source, args);
return `${columnLetter(column)}${row}`;
};
const writeRange: CapabilityHandler = async (_ctx, node, args) => {
const source = await loadWorkbook(args, node.id, node.componentId);
const sheet = worksheetOf(source.workbook, args);
const row = numberValue(value(args, ["row", "startRow"]), 1);
const column = columnNumber(value(args, ["column", "startColumn"]));
tableValue(value(args, ["contents", "value"])).forEach((items, rowOffset) => {
items.forEach((item, columnOffset) => { sheet.getCell(row + rowOffset, column + columnOffset).value = item as ExcelJS.CellValue; });
});
await saveWhenRequested(source, args);
return `${columnLetter(column)}${row}`;
};
const readRow: CapabilityHandler = async (_ctx, node, args) => {
const source = await loadWorkbook(args, node.id, node.componentId);
const sheet = worksheetOf(source.workbook, args);
const start = text(args, ["cellPos"]);
const row = start ? Number(sheet.getCell(start).row) : numberValue(value(args, ["row", "rowIndex"]), 1);
const firstColumn = start ? Number(sheet.getCell(start).col) : 1;
const result: unknown[] = [];
for (let column = firstColumn; column <= Math.max(firstColumn, sheet.columnCount); column += 1) result.push(cellValue(sheet.getCell(row, column).value));
return result;
};
const readColumn: CapabilityHandler = async (_ctx, node, args) => {
const source = await loadWorkbook(args, node.id, node.componentId);
const sheet = worksheetOf(source.workbook, args);
const start = text(args, ["cellPos"]);
const row = start ? Number(sheet.getCell(start).row) : 1;
const column = start ? Number(sheet.getCell(start).col) : columnNumber(value(args, ["column"]));
const result: unknown[] = [];
for (let current = row; current <= Math.max(row, sheet.rowCount); current += 1) result.push(cellValue(sheet.getCell(current, column).value));
return result;
};
const readRange: CapabilityHandler = async (_ctx, node, args) => {
const source = await loadWorkbook(args, node.id, node.componentId);
const sheet = worksheetOf(source.workbook, args);
const bounds = rangeBounds(sheet, explicitRange(args, sheet));
const result: unknown[][] = [];
for (let row = bounds.top; row <= bounds.bottom; row += 1) {
const values: unknown[] = [];
for (let column = bounds.left; column <= bounds.right; column += 1) values.push(cellValue(sheet.getCell(row, column).value));
result.push(values);
}
return result;
};
const readColumnCount: CapabilityHandler = async (_ctx, node, args) => {
const source = await loadWorkbook(args, node.id, node.componentId);
const sheet = worksheetOf(source.workbook, args);
const row = numberValue(value(args, ["row"]), 1);
let last = 0;
for (let column = 1; column <= sheet.columnCount; column += 1) {
if (cellValue(sheet.getCell(row, column).value) !== "") last = column;
}
return last;
};
const findOrReplace: CapabilityHandler = async (_ctx, node, args) => {
const source = await loadWorkbook(args, node.id, node.componentId);
const sheet = worksheetOf(source.workbook, args);
const needle = text(args, ["what", "input"]);
const replacement = text(args, ["replacement", "replace"]);
const mode = text(args, ["mode"], "0");
const replaceMode = mode === "1" || mode.includes("替换") || replacement !== "";
const exact = truthy(value(args, ["wholeWord"])) || text(args, ["lookAt"]) === "1";
const matchCase = truthy(value(args, ["matchCase"]));
const range = text(args, ["range"]);
const cells = cellsInRange(sheet, range || `A1:${columnLetter(Math.max(1, sheet.columnCount))}${Math.max(1, sheet.rowCount)}`);
const matches: string[] = [];
for (const cell of cells) {
const original = String(cellValue(cell.value));
const haystack = matchCase ? original : original.toLowerCase();
const target = matchCase ? needle : needle.toLowerCase();
const matched = exact ? haystack === target : haystack.includes(target);
if (!matched) continue;
matches.push(cell.address);
if (replaceMode) {
const escaped = needle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
cell.value = exact ? replacement : original.replace(new RegExp(escaped, matchCase ? "g" : "gi"), replacement);
}
if (truthy(value(args, ["matchFirst"]))) break;
}
await saveWhenRequested(source, args);
return matches;
};
const sortRange: CapabilityHandler = async (_ctx, node, args) => {
const source = await loadWorkbook(args, node.id, node.componentId);
const sheet = worksheetOf(source.workbook, args);
const range = text(args, ["sortRange", "range"]) || `A1:${columnLetter(Math.max(1, sheet.columnCount))}${Math.max(1, sheet.rowCount)}`;
const bounds = rangeBounds(sheet, range);
const hasHeader = truthy(value(args, ["header"]));
const dataStart = bounds.top + (hasHeader ? 1 : 0);
const rows: unknown[][] = [];
for (let row = dataStart; row <= bounds.bottom; row += 1) {
const values: unknown[] = [];
for (let column = bounds.left; column <= bounds.right; column += 1) values.push(cellValue(sheet.getCell(row, column).value));
rows.push(values);
}
let sortOffset = 0;
const rule = value(args, ["rule"]);
if (rule) {
try {
const parsed = typeof rule === "string" ? JSON.parse(rule) as Record<string, unknown> : rule as Record<string, unknown>;
sortOffset = Math.max(0, columnNumber(value(parsed, ["column", "key"], bounds.left)) - bounds.left);
} catch { /* use first column */ }
}
const descending = ["2", "-1", "desc", "descending", "降序"].includes(text(args, ["sortOrder"]).toLowerCase());
rows.sort((left, right) => comparison(left[sortOffset], right[sortOffset]) * (descending ? -1 : 1));
rows.forEach((values, rowOffset) => values.forEach((item, columnOffset) => {
sheet.getCell(dataStart + rowOffset, bounds.left + columnOffset).value = item as ExcelJS.CellValue;
}));
await saveWhenRequested(source, args);
return rows;
};
function criterionMatches(candidate: unknown, criterion: unknown, condition: string): boolean {
const left = String(candidate ?? "");
const right = String(criterion ?? "");
if (["contains", "包含", "1"].includes(condition)) return left.includes(right);
if (["notContains", "不包含", "2"].includes(condition)) return !left.includes(right);
if (["greater", ">", "3"].includes(condition)) return Number(candidate) > Number(criterion);
if (["less", "<", "4"].includes(condition)) return Number(candidate) < Number(criterion);
if (["notEqual", "!=", "5"].includes(condition)) return left !== right;
return left === right;
}
const filterRows: CapabilityHandler = async (_ctx, node, args) => {
const source = await loadWorkbook(args, node.id, node.componentId);
const sheet = worksheetOf(source.workbook, args);
let column = columnNumber(value(args, ["column"], "A"));
let criterion = value(args, ["criteria"], "");
let condition = text(args, ["condition"], "equal");
const rule = value(args, ["rule"]);
if (rule) {
try {
const parsed = typeof rule === "string" ? JSON.parse(rule) as Record<string, unknown> : rule as Record<string, unknown>;
column = columnNumber(value(parsed, ["column"], column));
criterion = value(parsed, ["criteria", "value"], criterion);
condition = text(parsed, ["condition", "operator"], condition);
} catch { /* use direct fields */ }
}
const startRow = numberValue(value(args, ["startRow"]), 1);
const endRowRaw = numberValue(value(args, ["endRow"]), sheet.rowCount);
const endRow = endRowRaw < 0 ? sheet.rowCount : endRowRaw;
const headerRow = truthy(value(args, ["onHeader"])) ? startRow : 0;
for (let row = startRow; row <= endRow; row += 1) {
if (row === headerRow) continue;
sheet.getRow(row).hidden = !criterionMatches(cellValue(sheet.getCell(row, column).value), criterion, condition);
}
sheet.autoFilter = { from: { row: startRow, column: 1 }, to: { row: endRow, column: Math.max(1, sheet.columnCount) } };
await saveWhenRequested(source, args);
return { column: columnLetter(column), criterion, condition };
};
const clearFilter: CapabilityHandler = async (_ctx, node, args) => {
const source = await loadWorkbook(args, node.id, node.componentId);
const sheet = worksheetOf(source.workbook, args);
for (let row = 1; row <= sheet.rowCount; row += 1) sheet.getRow(row).hidden = false;
sheet.autoFilter = undefined;
await saveWhenRequested(source, args);
return true;
};
const deleteDuplicates: CapabilityHandler = async (_ctx, node, args) => {
const source = await loadWorkbook(args, node.id, node.componentId);
const sheet = worksheetOf(source.workbook, args);
const columns = listValue(value(args, ["columns"], "A")).map(columnNumber);
const hasHeader = truthy(value(args, ["header"]));
const seen = new Set<string>();
const duplicates: number[] = [];
for (let row = hasHeader ? 2 : 1; row <= sheet.rowCount; row += 1) {
const key = JSON.stringify(columns.map((column) => cellValue(sheet.getCell(row, column).value)));
if (seen.has(key)) duplicates.push(row);
else seen.add(key);
}
duplicates.reverse().forEach((row) => sheet.spliceRows(row, 1));
await saveWhenRequested(source, args);
return duplicates.length;
};
function copyCells(sheet: ExcelJS.Worksheet, range: string): CopiedRange {
const bounds = rangeBounds(sheet, range);
const values: unknown[][] = [];
const styles: Partial<ExcelJS.Style>[][] = [];
for (let row = bounds.top; row <= bounds.bottom; row += 1) {
const valueRow: unknown[] = [];
const styleRow: Partial<ExcelJS.Style>[] = [];
for (let column = bounds.left; column <= bounds.right; column += 1) {
const cell = sheet.getCell(row, column);
valueRow.push(cell.value);
styleRow.push(cloneStyle(cell.style));
}
values.push(valueRow);
styles.push(styleRow);
}
return { values, styles };
}
const copyRange: CapabilityHandler = async (_ctx, node, args) => {
const source = await loadWorkbook(args, node.id, node.componentId);
const sheet = worksheetOf(source.workbook, args);
const range = explicitRange(args, sheet);
copiedRange = copyCells(sheet, range);
const destinationColumn = value(args, ["startColumn2"]);
const destinationRow = value(args, ["startRow2"]);
if (destinationColumn !== "" && destinationRow !== "") {
const startColumn = columnNumber(destinationColumn);
const startRow = numberValue(destinationRow, 1);
copiedRange.values.forEach((items, rowOffset) => items.forEach((item, columnOffset) => {
const cell = sheet.getCell(startRow + rowOffset, startColumn + columnOffset);
cell.value = item as ExcelJS.CellValue;
cell.style = cloneStyle(copiedRange!.styles[rowOffset][columnOffset]);
}));
await saveWhenRequested(source, args);
}
return copiedRange.values;
};
const pasteRange: CapabilityHandler = async (_ctx, node, args) => {
if (!copiedRange) throw new RuntimeError("validation_error", "No copied Excel range is available", { nodeId: node.id, componentId: node.componentId });
const source = await loadWorkbook(args, node.id, node.componentId);
const sheet = worksheetOf(source.workbook, args);
const start = text(args, ["Start"]);
const row = start ? Number(sheet.getCell(start).row) : numberValue(value(args, ["row"]), 1);
const column = start ? Number(sheet.getCell(start).col) : columnNumber(value(args, ["column"]));
const transpose = truthy(value(args, ["transpose"]));
const values = transpose ? copiedRange.values[0].map((_, index) => copiedRange!.values.map((items) => items[index])) : copiedRange.values;
values.forEach((items, rowOffset) => items.forEach((item, columnOffset) => {
const cell = sheet.getCell(row + rowOffset, column + columnOffset);
cell.value = item as ExcelJS.CellValue;
if (!transpose) cell.style = cloneStyle(copiedRange!.styles[rowOffset][columnOffset]);
}));
await saveWhenRequested(source, args);
return `${columnLetter(column)}${row}`;
};
const createSheet: CapabilityHandler = async (_ctx, node, args) => {
const source = await loadWorkbook(args, node.id, node.componentId);
const name = text(args, ["sheet", "sheetName", "name"], `Sheet${source.workbook.worksheets.length + 1}`);
if (source.workbook.getWorksheet(name)) throw new Error(`Sheet already exists: ${name}`);
source.workbook.addWorksheet(name);
await saveWhenRequested(source, args);
return name;
};
const deleteSheet: CapabilityHandler = async (_ctx, node, args) => {
const source = await loadWorkbook(args, node.id, node.componentId);
const sheet = worksheetOf(source.workbook, args);
if (source.workbook.worksheets.length <= 1) throw new Error("Cannot delete the only worksheet");
source.workbook.removeWorksheet(sheet.id);
await saveWhenRequested(source, args);
return sheet.name;
};
const activateSheet: CapabilityHandler = async (_ctx, node, args) => {
const source = await loadWorkbook(args, node.id, node.componentId);
const mode = text(args, ["mode"]);
const index = mode.includes("序号") ? numberValue(value(args, ["index"]), 1) : 0;
const sheet = index ? source.workbook.worksheets[index - 1] : worksheetOf(source.workbook, args);
if (!sheet) throw new Error(`Sheet not found: ${index}`);
source.workbook.views = [{
activeTab: source.workbook.worksheets.indexOf(sheet),
firstSheet: 0,
visibility: "visible",
x: 0,
y: 0,
width: 12000,
height: 8000
}];
await saveWhenRequested(source, args);
return sheet.name;
};
const getSheetNames: CapabilityHandler = async (_ctx, node, args) => {
const source = await loadWorkbook(args, node.id, node.componentId);
return source.workbook.worksheets.map((sheet) => sheet.name);
};
function setFontColor(cell: ExcelJS.Cell, argb: string): void {
cell.font = { ...(cell.font || {}), color: { argb } };
}
function setBackground(cell: ExcelJS.Cell, argb: string): void {
cell.fill = { type: "pattern", pattern: "solid", fgColor: { argb } };
}
const setColor = (kind: "font" | "background", rangeMode: boolean): CapabilityHandler => async (_ctx, node, args) => {
const source = await loadWorkbook(args, node.id, node.componentId);
const sheet = worksheetOf(source.workbook, args);
const range = rangeMode
? explicitRange(args, sheet)
: text(args, ["cellPos"]) || `${text(args, ["column"], "A")}${numberValue(value(args, ["row"]), 1)}`;
const argb = colorArgb(value(args, ["color", "cellColor"]));
for (const cell of cellsInRange(sheet, range)) {
if (kind === "font") setFontColor(cell, argb);
else setBackground(cell, argb);
}
await saveWhenRequested(source, args);
return argb;
};
const readBackground: CapabilityHandler = async (_ctx, node, args) => {
const source = await loadWorkbook(args, node.id, node.componentId);
const sheet = worksheetOf(source.workbook, args);
const cell = sheet.getCell(numberValue(value(args, ["row"]), 1), columnNumber(value(args, ["column"])));
const fill = cell.fill as ExcelJS.FillPattern | undefined;
return fill?.type === "pattern" ? fill.fgColor?.argb ?? "" : "";
};
const autoFill: CapabilityHandler = async (_ctx, node, args) => {
const source = await loadWorkbook(args, node.id, node.componentId);
const sheet = worksheetOf(source.workbook, args);
const sourceRange = text(args, ["sourceRange", "excelRng"]);
const destination = text(args, ["destination"]);
const sourceBounds = rangeBounds(sheet, sourceRange);
const destinationBounds = rangeBounds(sheet, destination);
const sourceValues = copyCells(sheet, sourceRange).values;
const vertical = sourceBounds.left === sourceBounds.right;
for (let row = destinationBounds.top; row <= destinationBounds.bottom; row += 1) {
for (let column = destinationBounds.left; column <= destinationBounds.right; column += 1) {
const sourceRow = (row - destinationBounds.top) % sourceValues.length;
const sourceColumn = (column - destinationBounds.left) % sourceValues[0].length;
let next = sourceValues[sourceRow][sourceColumn];
const sequence = vertical ? sourceValues.map((items) => items[0]) : sourceValues[0];
const offset = vertical ? row - sourceBounds.top : column - sourceBounds.left;
if (sequence.length >= 2 && sequence.every((item) => typeof item === "number")) {
next = Number(sequence[0]) + (Number(sequence[1]) - Number(sequence[0])) * offset;
}
sheet.getCell(row, column).value = next as ExcelJS.CellValue;
}
}
await saveWhenRequested(source, args);
return destination;
};
const splitColumn: CapabilityHandler = async (_ctx, node, args) => {
const source = await loadWorkbook(args, node.id, node.componentId);
const sheet = worksheetOf(source.workbook, args);
const sourceColumn = columnNumber(value(args, ["column"]));
const destination = text(args, ["destination"]);
const destinationColumn = destination ? Number(sheet.getCell(destination).col) : sourceColumn;
const startRow = numberValue(value(args, ["startRow"]), 1);
const endRow = numberValue(value(args, ["endRow"]), sheet.rowCount);
const delimiters: string[] = [];
if (truthy(value(args, ["tab"]), true)) delimiters.push("\\t");
if (truthy(value(args, ["semicolon"]))) delimiters.push(";");
if (truthy(value(args, ["comma"]))) delimiters.push(",");
if (truthy(value(args, ["space"]))) delimiters.push("\\s+");
if (truthy(value(args, ["other"]))) delimiters.push(text(args, ["otherChar"]).replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
const separator = new RegExp(delimiters.filter(Boolean).join("|") || "\\t");
for (let row = startRow; row <= endRow; row += 1) {
String(cellValue(sheet.getCell(row, sourceColumn).value)).split(separator).forEach((item, offset) => {
sheet.getCell(row, destinationColumn + offset).value = item;
});
}
await saveWhenRequested(source, args);
return `${columnLetter(destinationColumn)}${startRow}`;
};
const closeWorkbook: CapabilityHandler = async (_ctx, node, args) => {
const source = await loadWorkbook(args, node.id, node.componentId);
if (truthy(value(args, ["save", "isSave"]), true)) await source.workbook.xlsx.writeFile(source.filePath);
workbookCache.delete(source.filePath);
return source.filePath;
};
const deleteRange: CapabilityHandler = async (_ctx, node, args) => {
const source = await loadWorkbook(args, node.id, node.componentId);
const sheet = worksheetOf(source.workbook, args);
const range = explicitRange(args, sheet);
for (const cell of cellsInRange(sheet, range)) cell.value = null;
await saveWhenRequested(source, args);
return range;
};
export const EXCEL_COMMON_IDS = [
"Excel_WriteColumn", "Excel_WriteColumnNew", "Excel_Cs_WriteColumn",
"Excel_WriteRow", "Excel_WriteRowNew", "Excel_Cs_WriteRow", "Excel_Cs_WriteRowV2",
"Excel_Cs_WriteRangeV2", "Excel_GetRowData", "Excel_GetRowDataNew", "Excel_Cs_ReadRow",
"Excel_GetColData", "Excel_Cs_ReadColumnCount", "Excel_Cs_ReadRange",
"Excel_Cs_Find", "Excel_Cs_FindV2", "Excel_Cs_Search",
"Excel_Cs_Sort", "Excel_Cs_SortV1", "Excel_Cs_Filter", "Excel_Cs_ExcelFilter",
"Excel_Cs_ClearExcelFilter", "Excel_Cs_EnableExcelFilter", "Excel_Cs_DeleteDuplicates",
"Excel_CopyRange", "Excel_Cs_CopyRange", "Excel_Cs_CopyRangeV2", "Excel_Cs_CopyRangeV3",
"Excel_PasteRange", "Excel_Cs_PastRange", "Excel_Cs_PastRangeV2",
"Excel_CreateSheet", "Excel_DeleteSheet", "Excel_Cs_Delete", "Excel_ActiveSheet", "Excel_Cs_Active", "Excel_Cs_GetName",
"Excel_SetCellFontColor", "Excel_Cs_SetCellFont", "Excel_SetRangeFontColor", "Excel_Cs_SetRangeFont", "Excel_Cs_SetRangeFontV2",
"Excel_SetCellColor", "Excel_Cs_SetCellBackground", "Excel_SetRangeColor", "Excel_Cs_SetRangeBackground", "Excel_Cs_SetRangeBackgroundV2",
"Excel_Cs_ReadCellBackground", "Excel_Cs_AutoFill", "Excel_Cs_AutoFillV2",
"Excel_Cs_ColumnSplit", "Excel_Cs_ColumnSplitV2", "Excel_Close", "Excel_Cs_Close",
"Excel_DeleteRange", "Excel_Cs_DeleteRange", "Excel_Cs_DeleteRangeV2", "Excel_Cs_DeleteRangeV3"
] as const;
registerMany(["Excel_WriteRow", "Excel_WriteRowNew", "Excel_Cs_WriteRow", "Excel_Cs_WriteRowV2"], writeRow);
registerMany(["Excel_WriteColumn", "Excel_WriteColumnNew", "Excel_Cs_WriteColumn"], writeColumn);
register("Excel_Cs_WriteRangeV2", writeRange);
registerMany(["Excel_GetRowData", "Excel_GetRowDataNew", "Excel_Cs_ReadRow"], readRow);
register("Excel_GetColData", readColumn);
register("Excel_Cs_ReadColumnCount", readColumnCount);
register("Excel_Cs_ReadRange", readRange);
registerMany(["Excel_Cs_Find", "Excel_Cs_FindV2", "Excel_Cs_Search"], findOrReplace);
registerMany(["Excel_Cs_Sort", "Excel_Cs_SortV1"], sortRange);
registerMany(["Excel_Cs_Filter", "Excel_Cs_ExcelFilter"], filterRows);
registerMany(["Excel_Cs_ClearExcelFilter", "Excel_Cs_EnableExcelFilter"], clearFilter);
register("Excel_Cs_DeleteDuplicates", deleteDuplicates);
registerMany(["Excel_CopyRange", "Excel_Cs_CopyRange", "Excel_Cs_CopyRangeV2", "Excel_Cs_CopyRangeV3"], copyRange);
registerMany(["Excel_PasteRange", "Excel_Cs_PastRange", "Excel_Cs_PastRangeV2"], pasteRange);
register("Excel_CreateSheet", createSheet);
registerMany(["Excel_DeleteSheet", "Excel_Cs_Delete"], deleteSheet);
registerMany(["Excel_ActiveSheet", "Excel_Cs_Active"], activateSheet);
register("Excel_Cs_GetName", getSheetNames);
registerMany(["Excel_SetCellFontColor", "Excel_Cs_SetCellFont"], setColor("font", false));
registerMany(["Excel_SetRangeFontColor", "Excel_Cs_SetRangeFont", "Excel_Cs_SetRangeFontV2"], setColor("font", true));
registerMany(["Excel_SetCellColor", "Excel_Cs_SetCellBackground"], setColor("background", false));
registerMany(["Excel_SetRangeColor", "Excel_Cs_SetRangeBackground", "Excel_Cs_SetRangeBackgroundV2"], setColor("background", true));
register("Excel_Cs_ReadCellBackground", readBackground);
registerMany(["Excel_Cs_AutoFill", "Excel_Cs_AutoFillV2"], autoFill);
registerMany(["Excel_Cs_ColumnSplit", "Excel_Cs_ColumnSplitV2"], splitColumn);
registerMany(["Excel_Close", "Excel_Cs_Close"], closeWorkbook);
registerMany(["Excel_DeleteRange", "Excel_Cs_DeleteRange", "Excel_Cs_DeleteRangeV2", "Excel_Cs_DeleteRangeV3"], deleteRange);

View File

@ -6,6 +6,7 @@ import "./browser";
import "./browser-extended";
import "./browser-dom";
import "./browser-common";
import "./browser-control";
import "./office";
import "./desktop";
import "./pdf";
@ -17,9 +18,11 @@ import "./extended";
import "./system-extended";
import "./office-file-extended";
import "./office-aliases";
import "./excel-common";
import "./window-extended";
import "./data-extended";
import "./utility-extended";
import "./data-table-common";
import "./common-final";
export { capabilityCount, capabilityIds, getHandler, hasCapability, register } from "../engine/registry";