阶段1-rpa-core建包: engine+31个纯Node能力迁入packages/rpa-core, 领域类型单一源化, createEngine工厂收拢模块级状态
- engine/(registry/types/runtime/expression)物理迁入包, 解耦shared/types(域类型归包, 宿主type-only re-export) - 31个纯Node能力+27个测试迁入包; 宿主留守Electron/浏览器/AI依赖能力, index.ts经@rpa/core/capabilities拉起 - 新增createEngine()/defaultEngine: excel缓存/Excel句柄/db连接/FTP客户端/OCR实例/拟真设置挂引擎实例 - remaining-core子流程依赖改为host-bridge注入, 包内不再import宿主flows - 宿主main/index.ts启动时注入子流程源; electron-vite/vitest/tsconfig配@rpa/core源码别名 - 验证: typecheck绿, 快照909个id diff为零, 332测试过(2个桌面冒烟因会话无交互SetForegroundWindow被拒, 环境阻塞)
This commit is contained in:
1 parent
4139400dbd
commit
caa8e19332
111 files changed
+581
-337
No files matched your search
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"name": "@rpa/core",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "RPA 能力层:零 Electron 依赖的流程引擎、能力注册表与纯 Node 能力",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./capabilities": "./src/capabilities/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "vitest run",
|
||||
"typecheck": "tsc --noEmit -p tsconfig.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@gutenye/ocr-node": "^1.4.9",
|
||||
"adm-zip": "^0.5.16",
|
||||
"basic-ftp": "^6.2.3",
|
||||
"exceljs": "^4.4.0",
|
||||
"imapflow": "^2.10.0",
|
||||
"mailparser": "^3.14.1",
|
||||
"mssql": "^12.7.13",
|
||||
"mysql2": "^3.24.6",
|
||||
"nodemailer": "^6.9.16",
|
||||
"pdf-lib": "^1.17.1",
|
||||
"pg": "^8.23.1",
|
||||
"pngjs": "^7.0.0",
|
||||
"sm-crypto": "^0.5.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/adm-zip": "^0.5.7",
|
||||
"@types/mssql": "^12.3.0",
|
||||
"@types/nodemailer": "^6.4.17",
|
||||
"@types/pg": "^8.23.1",
|
||||
"@types/pngjs": "^6.0.5",
|
||||
"typescript": "^5.7.2",
|
||||
"vitest": "^2.1.8"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { CatalogComponent, FlowNode } from "../types";
|
||||
import type { RunContext } from "../engine/types";
|
||||
import { getHandler } from "../engine/registry";
|
||||
|
||||
const mocks = vi.hoisted(() => ({ powershell: vi.fn(), execFile: vi.fn() }));
|
||||
vi.mock("./desktop", () => ({ powershell: mocks.powershell, psString: (value: unknown) => `'${String(value ?? "").replace(/'/g, "''")}'` }));
|
||||
vi.mock("node:child_process", () => ({ execFile: mocks.execFile }));
|
||||
import "./data";
|
||||
import "./control";
|
||||
import "./system-extended";
|
||||
import "./windows-system";
|
||||
|
||||
function run(id: string, args: Record<string, unknown> = {}, signal = new AbortController().signal) {
|
||||
const node: FlowNode = { id: "regression", componentId: id, values: args, options: {}, position: { x: 0, y: 0 } };
|
||||
return getHandler(id)!({ signal, log: vi.fn(), vars: {} } as unknown as RunContext, node, args, {} as CatalogComponent);
|
||||
}
|
||||
|
||||
afterEach(() => { vi.useRealTimers(); vi.resetAllMocks(); });
|
||||
|
||||
describe("应用与系统组件缺陷回归", () => {
|
||||
it("没有匹配进程时返回 [] / null,忽略空行和非法 PID", async () => {
|
||||
mocks.powershell.mockResolvedValue("");
|
||||
await expect(run("App_GetPids", { pattern: "missing.exe" })).resolves.toEqual([]);
|
||||
await expect(run("App_GetPid", { pattern: "missing.exe" })).resolves.toBeNull();
|
||||
mocks.powershell.mockResolvedValue("\r\n0\r\n123\r\nabc\r\n456\r\n");
|
||||
await expect(run("App_GetPids", { pattern: "fixture.exe" })).resolves.toEqual([123, 456]);
|
||||
});
|
||||
|
||||
it("毫秒等待读取 mileSecond,计时和取消均正确", async () => {
|
||||
vi.useFakeTimers();
|
||||
const waiting = Promise.resolve(run("Basic_Delay", { mileSecond: "250" }));
|
||||
let done = false;
|
||||
void waiting.then(() => { done = true; });
|
||||
await vi.advanceTimersByTimeAsync(249);
|
||||
expect(done).toBe(false);
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(done).toBe(true);
|
||||
const controller = new AbortController();
|
||||
const cancelled = Promise.resolve(run("Basic_Delay", { mileSecond: "3000" }, controller.signal));
|
||||
const rejection = expect(cancelled).rejects.toMatchObject({ code: "cancelled" });
|
||||
controller.abort();
|
||||
await rejection;
|
||||
});
|
||||
|
||||
it.each([
|
||||
[42, "number", true], ["42", "number", false], [42, "1", true],
|
||||
[[], "list", true], [{ key: 1 }, "dict", true], [null, "null", true],
|
||||
[false, "bool", true], ["中文", "string", true], [3.5, "int", false]
|
||||
])("按目标类型判断 %j → %s", (val, into_type, expected) => {
|
||||
expect(run("Basic_JudgeDataType", { val, into_type })).toBe(expected);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["42.5", "number", 42.5], ["42", "1", 42], ["-3.9", "int", -3],
|
||||
["false", "bool", false], ["true", "bool", true], [42, "string", "42"]
|
||||
])("执行类型转换 %j → %s", (val, into_type, expected) => {
|
||||
expect(run("Basic_DataTypeConversion", { val, into_type })).toBe(expected);
|
||||
});
|
||||
|
||||
it("非法数值或未知目标类型给出定位错误,避免静默返回原值", () => {
|
||||
expect(() => run("Basic_DataTypeConversion", { val: "abc", into_type: "int" })).toThrow();
|
||||
expect(() => run("Basic_DataTypeConversion", { val: 1, into_type: "unknown" })).toThrow();
|
||||
expect(() => run("Basic_JudgeDataType", { val: 1, into_type: "unknown" })).toThrow();
|
||||
});
|
||||
|
||||
it("关闭组件按所选模式执行,非法 PID 不降级为按名称批量关闭", async () => {
|
||||
mocks.powershell.mockResolvedValue("");
|
||||
await expect(run("App_KillV1", { close_type: "id", pid: "invalid", p_name: "fixture.exe" })).rejects.toMatchObject({ code: "validation_error" });
|
||||
expect(mocks.powershell).not.toHaveBeenCalled();
|
||||
await run("App_KillV1", { close_type: "name", pid: 123, p_name: "fixture.exe", kill_all: "0" });
|
||||
expect(mocks.powershell.mock.calls[0][0]).toContain("SessionId");
|
||||
expect(mocks.powershell.mock.calls[0][0]).not.toContain("Stop-Process -Id 123");
|
||||
});
|
||||
|
||||
it("唤醒不声称已通过 Windows 登录解锁", async () => {
|
||||
mocks.execFile.mockImplementation((_file, _args, _opts, callback) => callback(null, { stdout: "", stderr: "" }));
|
||||
await expect(run("ScreenControl_ScreenUnlock")).resolves.toMatchObject({ awakened: true, loginUnlockSupported: false });
|
||||
});
|
||||
|
||||
it("屏保自定义密码不受支持时,在触发桌面操作前明确报错", async () => {
|
||||
await expect(run("System_ScreenSaverShowV1", { set_password: "1", password: "" })).rejects.toMatchObject({ code: "validation_error" });
|
||||
expect(mocks.execFile).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,157 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getHandler } from "../engine/registry";
|
||||
import type { CatalogComponent, FlowNode } from "../types";
|
||||
import type { RunContext } from "../engine/types";
|
||||
import "./data";
|
||||
import "./desktop";
|
||||
import "./system-extended";
|
||||
import "./utility-extended";
|
||||
import "./common-final";
|
||||
import "./control";
|
||||
import "./windows-system";
|
||||
import "./basic-encryption";
|
||||
|
||||
const ctx = {
|
||||
signal: new AbortController().signal,
|
||||
log: () => {},
|
||||
vars: {},
|
||||
evidenceDir: "D:/office-test2",
|
||||
runId: "smoke-run",
|
||||
flow: {
|
||||
id: "smoke-flow", name: "冒烟测试流程", description: "", enabled: true,
|
||||
trigger: null, meta: { appVersion: "dev", createdAt: "", updatedAt: "" },
|
||||
nodes: []
|
||||
}
|
||||
} as unknown as RunContext;
|
||||
|
||||
const node = { id: "smoke-app", componentId: "smoke", position: { x: 0, y: 0 }, values: {}, options: {} } as unknown as FlowNode;
|
||||
|
||||
async function run(componentId: string, args: Record<string, unknown>, ctxOverride?: RunContext): Promise<unknown> {
|
||||
const handler = getHandler(componentId);
|
||||
if (!handler) throw new Error(`组件 ${componentId} 未注册运行时`);
|
||||
const n = { ...node, componentId } as unknown as FlowNode;
|
||||
return handler(ctxOverride ?? ctx, n, args, {} as CatalogComponent);
|
||||
}
|
||||
|
||||
describe("应用与系统组件冒烟", () => {
|
||||
it("数据类型判断族", async () => {
|
||||
expect(await run("Basic_IsNumber", { val: "12.5" })).toBe(true);
|
||||
expect(await run("Basic_IsNumber", { val: "abc" })).toBe(false);
|
||||
expect(await run("Basic_IsNull", { val: "" })).toBe(true);
|
||||
expect(await run("Basic_IsNull", { val: "x" })).toBe(false);
|
||||
expect(await run("Basic_IsArray", { val: [1, 2] })).toBe(true);
|
||||
expect(await run("Basic_IsDictionary", { val: { a: 1 } })).toBe(true);
|
||||
expect(await run("Basic_Type", { val: [1] })).toBe("list");
|
||||
expect(await run("Basic_Type", { val: "s" })).toBe("string");
|
||||
expect(await run("Basic_JudgeDataType", { val: 5, into_type: "int" })).toBe(true);
|
||||
expect(await run("Basic_JudgeDataType", { val: 5.5, into_type: "int" })).toBe(false);
|
||||
console.log("[数据判断族] 通过");
|
||||
});
|
||||
|
||||
it("数据转换族", async () => {
|
||||
expect(await run("Basic_ConvertString", { val: 123 })).toBe("123");
|
||||
expect(await run("Basic_ConvertInt", { val: "12.9" })).toBe(12);
|
||||
expect(await run("Basic_ConvertBool", { val: 1 })).toBe(true);
|
||||
expect(await run("Basic_ConvertNumber", { val: "3.14" })).toBe(3.14);
|
||||
expect(await run("Basic_DataTypeConversion", { val: "是", into_type: "bool" })).toBe(true);
|
||||
expect(await run("Basic_DataTypeConversion", { val: 8, into_type: "string" })).toBe("8");
|
||||
console.log("[数据转换族] 通过");
|
||||
});
|
||||
|
||||
it("复制/随机/密文", async () => {
|
||||
const src = { a: [1, { b: 2 }] };
|
||||
const cloned = (await run("Basic_Clone", { val: src })) as typeof src;
|
||||
expect(cloned).toEqual(src);
|
||||
expect(cloned).not.toBe(src);
|
||||
const ri = (await run("Basic_RandomInt", { min: 5, max: 5 })) as number;
|
||||
expect(ri).toBe(5);
|
||||
const rnd = (await run("Basic_Rnd", {})) as number;
|
||||
expect(rnd).toBeGreaterThanOrEqual(0);
|
||||
expect(rnd).toBeLessThan(1);
|
||||
expect(await run("Basic_Dodecryption", { words: Buffer.from("hello-世界", "utf8").toString("base64") })).toBe("hello-世界");
|
||||
console.log("[复制/随机/密文] 通过");
|
||||
});
|
||||
|
||||
it("延迟/调试输出/设置变量", async () => {
|
||||
const logs: string[] = [];
|
||||
const ctxLog = { ...ctx, log: (m: string) => logs.push(m), vars: {} } as unknown as RunContext;
|
||||
const t0 = Date.now();
|
||||
expect(await run("Basic_Delay", { mileSecond: 100 })).toBe(0.1);
|
||||
expect(Date.now() - t0).toBeGreaterThanOrEqual(95);
|
||||
await run("Basic_DebugOutput", { msg: "冒烟输出" }, ctxLog);
|
||||
expect(logs.join()).toContain("冒烟输出");
|
||||
const setNode = { ...node, options: { outputVar: "v_test" } } as unknown as FlowNode;
|
||||
const handler = getHandler("Basic_SetVariable_V1")!;
|
||||
expect(await handler(ctxLog, setNode, { value: "42", variableType: "rpa_int" }, {} as CatalogComponent)).toBe(42);
|
||||
expect((ctxLog as unknown as { vars: Record<string, unknown> }).vars.v_test).toBe(42);
|
||||
console.log("[延迟/输出/变量] 通过");
|
||||
});
|
||||
|
||||
it("自定义配置与业务计数", async () => {
|
||||
const cfg = (await run("CustomConfig_GetConfig", {
|
||||
jsonlist: JSON.stringify([{ name: "host", value: "127.0.0.1" }]),
|
||||
configName: "host"
|
||||
})) as string;
|
||||
expect(cfg).toBe("127.0.0.1");
|
||||
const ctxCount = { ...ctx, vars: {} } as unknown as RunContext;
|
||||
expect(await run("Basic_BusinessOrderCount", { start_count: 100, count: 3 }, ctxCount)).toBe(103);
|
||||
expect(await run("Basic_BusinessOrderCount", { start_count: 100, count: 3 }, ctxCount)).toBe(106);
|
||||
console.log("[配置/计数] 通过");
|
||||
});
|
||||
|
||||
it("系统路径与环境变量", async () => {
|
||||
expect(String(await run("System_GetTempPath", {}))).toMatch(/Temp|tmp/i);
|
||||
expect(String(await run("System_GetHomePath", {}))).toMatch(/Users|home/i);
|
||||
expect(String(await run("System_GetSystemPath", { path_name: "desktop" }))).toMatch(/Desktop/);
|
||||
expect(await run("System_GetEnv", { name: "PATH" })).toBeTruthy();
|
||||
expect(await run("System_SetEnv", { name: "RPA_SMOKE", value: "ok" })).toBe("ok");
|
||||
expect(await run("System_GetEnv", { name: "RPA_SMOKE" })).toBe("ok");
|
||||
expect(await run("Basic_GetProcessInfo", {})).toBeTruthy();
|
||||
console.log("[系统路径/环境变量] 通过");
|
||||
});
|
||||
|
||||
it("输入法状态获取", async () => {
|
||||
const st = (await run("System_GetInputMethodState", {})) as { culture: string; layout: string };
|
||||
console.log("[输入法]", JSON.stringify(st));
|
||||
expect(st.culture).toBeTruthy();
|
||||
}, 60000);
|
||||
|
||||
it("执行命令行两种组件", async () => {
|
||||
const out1 = String(await run("System_ExecuteCommand", { command: "echo rpa-smoke-123" }));
|
||||
expect(out1).toContain("rpa-smoke-123");
|
||||
const out2 = String(await run("System_ExecuteCommandV1", { command_type: "1", dos_command: "echo dos-ok-456" }));
|
||||
expect(out2).toContain("dos-ok-456");
|
||||
const out3 = String(await run("System_ExecuteCommandV1", { command_type: "3", ps_command: "Write-Output 'ps-ok-789'" }));
|
||||
expect(out3).toContain("ps-ok-789");
|
||||
console.log("[执行命令] 通过");
|
||||
}, 120000);
|
||||
|
||||
it("应用生命周期: 启动→查PID→关闭", async () => {
|
||||
const pid = (await run("App_Run", { executeable: "notepad.exe" })) as number;
|
||||
expect(Number.isInteger(pid)).toBe(true);
|
||||
await new Promise((r) => setTimeout(r, 1200));
|
||||
// Win11 notepad.exe 为应用别名:Start-Process 返回的启动器 PID 会立即退出,真实进程另获新 PID,
|
||||
// 因此不比较具体 PID,只验证「能查到运行中的 notepad 进程」
|
||||
const single = (await run("App_GetPid", { pattern: "notepad" })) as number | null;
|
||||
expect(single).toBeTruthy();
|
||||
const all = (await run("App_GetPids", { p_name: "notepad" })) as number[];
|
||||
expect(all.length).toBeGreaterThan(0);
|
||||
const killed = await run("App_KillV1", { close_type: "name", p_name: "notepad" });
|
||||
console.log("[应用生命周期] 启动PID:", pid, "查询到:", all, "关闭:", JSON.stringify(killed));
|
||||
await new Promise((r) => setTimeout(r, 800));
|
||||
const after = (await run("App_GetPids", { pattern: "notepad" })) as number[];
|
||||
expect(after.length).toBe(0);
|
||||
}, 120000);
|
||||
|
||||
it("Windows凭据: 添加→读取→删除", async () => {
|
||||
const target = "RPA_SMOKETEST_GENERIC";
|
||||
expect((await run("System_AddCredential", { application: target, name: "smoke-user", password: "p@ss" })) as { added: boolean }).toEqual({ added: true, target });
|
||||
const read = (await run("System_ReadCredential", { application: target })) as { exists: boolean; detail: string };
|
||||
expect(read.exists).toBe(true);
|
||||
expect(read.detail).toContain("smoke-user");
|
||||
expect((await run("System_DeleteCredential", { application: target })) as { deleted: boolean }).toEqual({ deleted: true, target });
|
||||
const readAfter = (await run("System_ReadCredential", { application: target })) as { exists: boolean };
|
||||
expect(readAfter.exists).toBe(false);
|
||||
console.log("[凭据闭环] 通过");
|
||||
}, 120000);
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import { Buffer } from "node:buffer";
|
||||
import { register } from "../engine/registry";
|
||||
import { RuntimeError, type CapabilityHandler } from "../engine/types";
|
||||
|
||||
const SM4_KEY = "INPUT_PARAM_KEY4";
|
||||
const SM4_KEY_HEX = Buffer.from(SM4_KEY, "utf8").toString("hex");
|
||||
|
||||
function looksLikeBase64(value: string): boolean {
|
||||
const text = value.trim().replace(/\s+/g, "");
|
||||
if (!text || text.length % 4 !== 0 || !/^[A-Za-z0-9+/]+={0,2}$/.test(text)) return false;
|
||||
return Buffer.from(text, "base64").toString("base64").replace(/=+$/, "") === text.replace(/=+$/, "");
|
||||
}
|
||||
|
||||
function readableUtf8(value: string): boolean {
|
||||
if (!value) return false;
|
||||
let controls = 0;
|
||||
for (const char of value) {
|
||||
const code = char.codePointAt(0)!;
|
||||
if (code < 32 && !["\n", "\r", "\t"].includes(char)) controls += 1;
|
||||
if (code === 0xfffd) controls += 10;
|
||||
}
|
||||
return controls === 0;
|
||||
}
|
||||
|
||||
async function sm4Decrypt(value: string): Promise<string> {
|
||||
const { sm4 } = await import("sm-crypto");
|
||||
const errors: string[] = [];
|
||||
for (const padding of ["pkcs#7", "none"] as const) {
|
||||
try {
|
||||
const output = sm4.decrypt(value.toLowerCase(), SM4_KEY_HEX, { padding });
|
||||
if (output && readableUtf8(output)) return output;
|
||||
errors.push(`${padding}: empty or non-text output`);
|
||||
} catch (error) {
|
||||
errors.push(`${padding}: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
throw new RuntimeError("decryption_failed", "SM4 密文解密失败", { detail: errors.join("; ") });
|
||||
}
|
||||
|
||||
const dodecryption: CapabilityHandler = async (_ctx, node, args) => {
|
||||
const words = String(args.words ?? args.value ?? args.text ?? "");
|
||||
if (!words.trim()) throw new RuntimeError("validation_error", "密文内容为空", { nodeId: node.id, componentId: node.componentId });
|
||||
if (looksLikeBase64(words)) {
|
||||
const decoded = Buffer.from(words, "base64").toString("utf8");
|
||||
if (readableUtf8(decoded)) return decoded;
|
||||
}
|
||||
return sm4Decrypt(words.trim());
|
||||
};
|
||||
|
||||
register("Basic_Dodecryption", dodecryption);
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getHandler } from "../engine/registry";
|
||||
import type { CatalogComponent, FlowNode } from "../types";
|
||||
import type { RunContext } from "../engine/types";
|
||||
import "./desktop";
|
||||
import "./basic-encryption";
|
||||
|
||||
const context = {} as RunContext;
|
||||
const component = {} as CatalogComponent;
|
||||
const node = (id: string): FlowNode => ({ id, componentId: id, position: { x: 0, y: 0 }, values: {}, options: {} });
|
||||
async function run(id: string, args: Record<string, unknown>) {
|
||||
const handler = getHandler(id);
|
||||
expect(handler, id).toBeTruthy();
|
||||
return handler!(context, node(id), args, component);
|
||||
}
|
||||
|
||||
describe("manual keyboard and encrypted variables", () => {
|
||||
it("registers manual keyboard switches", async () => {
|
||||
await expect(run("Keyboard_EnableManualOperation", { random_move: true, random_click: true, random_sleep: false, min_time: 0, max_time: 0 })).resolves.toMatchObject({ enabled: true });
|
||||
await expect(run("Keyboard_DisableManualOperation", {})).resolves.toEqual({ enabled: false });
|
||||
});
|
||||
|
||||
it("decodes base64 and SM4 ciphertext", async () => {
|
||||
await expect(run("Basic_Dodecryption", { words: Buffer.from("hello", "utf8").toString("base64") })).resolves.toBe("hello");
|
||||
const { sm4 } = await import("sm-crypto");
|
||||
const key = Buffer.from("INPUT_PARAM_KEY4", "utf8").toString("hex");
|
||||
const cipher = sm4.encrypt("secret", key, { padding: "pkcs#7" });
|
||||
await expect(run("Basic_Dodecryption", { words: cipher })).resolves.toBe("secret");
|
||||
await expect(run("Basic_Dodecryption", {})).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,171 @@
|
||||
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import net from "node:net";
|
||||
import { describe, expect, it, afterEach } from "vitest";
|
||||
import { PDFDocument } from "pdf-lib";
|
||||
import { PNG } from "pngjs";
|
||||
import { getHandler } from "../engine/registry";
|
||||
import type { RunContext } from "../engine/types";
|
||||
import type { CatalogComponent, FlowNode } from "../types";
|
||||
import "./desktop";
|
||||
import "./office";
|
||||
import "./pdf";
|
||||
import "./message";
|
||||
import "./word";
|
||||
import { findImage } from "./vision";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
const node = (componentId: string): FlowNode => ({
|
||||
id: `smoke_${componentId}`,
|
||||
componentId,
|
||||
position: { x: 0, y: 0 },
|
||||
values: {},
|
||||
options: {}
|
||||
});
|
||||
const component = {} as CatalogComponent;
|
||||
const context = {} as RunContext;
|
||||
|
||||
async function tempDir(): Promise<string> {
|
||||
const directory = await mkdtemp(path.join(os.tmpdir(), "rpa-capability-smoke-"));
|
||||
tempDirs.push(directory);
|
||||
return directory;
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(tempDirs.splice(0).map((directory) => rm(directory, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
describe("capability registration smoke", () => {
|
||||
it("creates, writes, reads, and saves an Excel workbook", async () => {
|
||||
const directory = await tempDir();
|
||||
const create = getHandler("Excel_Cs_NewV5");
|
||||
const write = getHandler("Excel_Cs_WriteCell");
|
||||
const read = getHandler("Excel_Cs_ReadRangeV2");
|
||||
const insert = getHandler("Excel_Cs_Insert");
|
||||
const sheets = getHandler("Excel_GetSheetsName");
|
||||
expect(create && write && read && insert && sheets).toBeTruthy();
|
||||
|
||||
const handle = await create!(context, node("Excel_Cs_NewV5"), { path: directory, name: "smoke.xlsx", override: "1" }, component);
|
||||
await write!(context, node("Excel_Cs_WriteCell"), { workbook: handle, sheet: "Sheet1", row: "1", column: "A", content: "RPA", save: "1" }, component);
|
||||
const table = await read!(context, node("Excel_Cs_ReadRangeV2"), { workbook: handle, sheet: "Sheet1", range: "A1:A1" }, component);
|
||||
await insert!(context, node("Excel_Cs_Insert"), { workbook: handle, name: "结果", save: "1" }, component);
|
||||
|
||||
expect(table).toEqual([["RPA"]]);
|
||||
await expect(sheets!(context, node("Excel_GetSheetsName"), { workbook: handle }, component)).resolves.toEqual(["Sheet1", "结果"]);
|
||||
await expect(readFile(path.join(directory, "smoke.xlsx"))).resolves.toBeTruthy();
|
||||
});
|
||||
|
||||
it("counts and concatenates PDF files", async () => {
|
||||
const directory = await tempDir();
|
||||
const first = await PDFDocument.create();
|
||||
first.addPage([120, 120]);
|
||||
const second = await PDFDocument.create();
|
||||
second.addPage([120, 120]);
|
||||
const firstPath = path.join(directory, "one.pdf");
|
||||
const secondPath = path.join(directory, "two.pdf");
|
||||
await writeFile(firstPath, await first.save());
|
||||
await writeFile(secondPath, await second.save());
|
||||
|
||||
const count = getHandler("PDF_GetPdfPageNum");
|
||||
const concat = getHandler("PDF_ConcatPdf");
|
||||
expect(count && concat).toBeTruthy();
|
||||
await expect(count!(context, node("PDF_GetPdfPageNum"), { fileInPath: firstPath }, component)).resolves.toBe(1);
|
||||
const output = await concat!(context, node("PDF_ConcatPdf"), { newPdfPath: directory, newPdfName: "merged.pdf", pdfList: JSON.stringify([firstPath, secondPath]) }, component);
|
||||
expect(output).toBe(path.join(directory, "merged.pdf"));
|
||||
const merged = await PDFDocument.load(await readFile(output as string));
|
||||
expect(merged.getPageCount()).toBe(2);
|
||||
});
|
||||
|
||||
it("executes a local desktop command through the registered bridge", async () => {
|
||||
const execute = getHandler("System_ExecuteCommand");
|
||||
expect(execute).toBeTruthy();
|
||||
await expect(execute!(context, node("System_ExecuteCommand"), { action: "Write-Output smoke" }, component)).resolves.toContain("smoke");
|
||||
await expect(execute!(context, node("System_ExecuteCommand"), { action: "Write-Output '中文输出测试'" }, component)).resolves.toBe("中文输出测试");
|
||||
});
|
||||
|
||||
it("finds an exact PNG template inside a larger screenshot", async () => {
|
||||
const directory = await tempDir();
|
||||
const screen = new PNG({ width: 48, height: 36 });
|
||||
const template = new PNG({ width: 8, height: 8 });
|
||||
screen.data.fill(255);
|
||||
for (let y = 0; y < template.height; y += 1) {
|
||||
for (let x = 0; x < template.width; x += 1) {
|
||||
const templateIndex = (y * template.width + x) * 4;
|
||||
template.data[templateIndex] = 220;
|
||||
template.data[templateIndex + 1] = 20;
|
||||
template.data[templateIndex + 2] = 30;
|
||||
template.data[templateIndex + 3] = 255;
|
||||
const screenIndex = ((12 + y) * screen.width + 17 + x) * 4;
|
||||
template.data.copy(screen.data, screenIndex, templateIndex, templateIndex + 4);
|
||||
}
|
||||
}
|
||||
const screenPath = path.join(directory, "screen.png");
|
||||
const templatePath = path.join(directory, "template.png");
|
||||
await writeFile(screenPath, PNG.sync.write(screen));
|
||||
await writeFile(templatePath, PNG.sync.write(template));
|
||||
const match = await findImage(templatePath, screenPath, 0.02);
|
||||
expect(match).toMatchObject({ x: 17, y: 12, width: 8, height: 8, score: 1 });
|
||||
});
|
||||
|
||||
it("sends mail to a local SMTP server", async () => {
|
||||
let received = "";
|
||||
const server = net.createServer((socket) => {
|
||||
socket.setEncoding("utf8");
|
||||
socket.write("220 localhost ESMTP\r\n");
|
||||
let buffer = "";
|
||||
let dataMode = false;
|
||||
socket.on("data", (chunk) => {
|
||||
buffer += chunk;
|
||||
const lines = buffer.split("\r\n");
|
||||
buffer = lines.pop() || "";
|
||||
for (const line of lines) {
|
||||
if (dataMode) {
|
||||
if (line === ".") {
|
||||
dataMode = false;
|
||||
socket.write("250 queued\r\n");
|
||||
} else received += `${line}\n`;
|
||||
} else if (/^EHLO/i.test(line)) socket.write("250-localhost\r\n250 OK\r\n");
|
||||
else if (/^DATA/i.test(line)) { dataMode = true; socket.write("354 end with dot\r\n"); }
|
||||
else if (/^QUIT/i.test(line)) { socket.write("221 bye\r\n"); socket.end(); }
|
||||
else socket.write("250 OK\r\n");
|
||||
}
|
||||
});
|
||||
});
|
||||
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
const address = server.address();
|
||||
const port = typeof address === "object" && address ? address.port : 0;
|
||||
try {
|
||||
const send = getHandler("Mail_Send");
|
||||
expect(send).toBeTruthy();
|
||||
await send!(context, node("Mail_Send"), {
|
||||
host: "127.0.0.1", port: String(port), account: "robot@example.com",
|
||||
to: "owner@example.com", subject: "RPA smoke", content: "mail delivered", is_ssl: "False"
|
||||
}, component);
|
||||
expect(received).toContain("RPA smoke");
|
||||
expect(received).toContain("mail delivered");
|
||||
const union = getHandler("Mail_UnionSend_v1");
|
||||
expect(union).toBeTruthy();
|
||||
await union!(context, node("Mail_UnionSend_v1"), {
|
||||
account_obj: { host: "127.0.0.1", port, account: "robot@example.com" },
|
||||
receiver: "owner@example.com", subject: "Union smoke", content: "union delivered"
|
||||
}, component);
|
||||
expect(received).toContain("Union smoke");
|
||||
expect(received).toContain("union delivered");
|
||||
} finally {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
}
|
||||
});
|
||||
|
||||
it("creates and writes a Word document through COM", async () => {
|
||||
if (process.platform !== "win32") return;
|
||||
const directory = await tempDir();
|
||||
const create = getHandler("Word_New");
|
||||
const write = getHandler("Word_Write");
|
||||
expect(create && write).toBeTruthy();
|
||||
const handle = await create!(context, node("Word_New"), { filePath: directory, name: "smoke.docx" }, component);
|
||||
await write!(context, node("Word_Write"), { objWord: handle, text: "RPA Word smoke" }, component);
|
||||
const file = await readFile(path.join(directory, "smoke.docx"));
|
||||
expect(file.byteLength).toBeGreaterThan(1000);
|
||||
}, 30000);
|
||||
});
|
||||
@@ -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));
|
||||
@@ -0,0 +1,93 @@
|
||||
import { register } from "../engine/registry";
|
||||
import { BreakSignal, ContinueSignal, RuntimeError, StopSignal } from "../engine/types";
|
||||
import { interpolate, toNumber } from "../engine/expression";
|
||||
|
||||
function sleep(ms: number, signal: AbortSignal): Promise<void> {
|
||||
if (ms <= 0) return Promise.resolve();
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
resolve();
|
||||
}, ms);
|
||||
const onAbort = () => {
|
||||
clearTimeout(timer);
|
||||
reject(new RuntimeError("cancelled", "运行已取消"));
|
||||
};
|
||||
if (signal.aborted) return onAbort();
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
register(["Trigger.Manual", "Trigger.Schedule", "Trigger.FileArrival"], () => null);
|
||||
|
||||
register("Basic_Delay_V2", async (ctx, node, args) => {
|
||||
const random = String(args.is_random ?? "0") === "1" || String(args.is_random) === "true";
|
||||
const seconds = random
|
||||
? toNumber(args.min_second, 1) + Math.random() * Math.max(0, toNumber(args.max_second, 5) - toNumber(args.min_second, 1))
|
||||
: toNumber(args.second, 1);
|
||||
ctx.log(`等待 ${seconds.toFixed(2)} 秒`, "info", node.id);
|
||||
await sleep(Math.max(0, seconds) * 1000, ctx.signal);
|
||||
return seconds;
|
||||
});
|
||||
|
||||
register("Basic_Delay_V1", async (ctx, node, args) => {
|
||||
const seconds = toNumber(args.second ?? args.time ?? 1, 1);
|
||||
await sleep(Math.max(0, seconds) * 1000, ctx.signal);
|
||||
return seconds;
|
||||
});
|
||||
|
||||
register("Basic_Delay", async (ctx, node, args) => {
|
||||
const milliseconds = Number(args.mileSecond ?? 1000);
|
||||
if (!Number.isFinite(milliseconds) || milliseconds < 0 || milliseconds > 2_147_483_647) {
|
||||
throw new RuntimeError("validation_error", "延时时间必须是 0~2147483647 范围内的毫秒数", { nodeId: node.id, componentId: node.componentId });
|
||||
}
|
||||
await sleep(milliseconds, ctx.signal);
|
||||
return milliseconds / 1000;
|
||||
});
|
||||
|
||||
register("Basic_DebugOutput", (ctx, node, args) => {
|
||||
const message = String(args.msg ?? args.message ?? "");
|
||||
ctx.log(message || "(空)", "info", node.id);
|
||||
return message;
|
||||
});
|
||||
|
||||
register("Basic_SetVariable", (ctx, node, args) => {
|
||||
const name = String(node.options?.outputVar || "").trim();
|
||||
const value = args.value;
|
||||
if (!name) {
|
||||
throw new RuntimeError("validation_error", "「设置变量」未配置输出变量", {
|
||||
nodeId: node.id,
|
||||
componentId: node.componentId,
|
||||
recommendation: "在属性面板的输出变量里填写变量名"
|
||||
});
|
||||
}
|
||||
ctx.vars[name] = value;
|
||||
ctx.log(`${name} = ${typeof value === "object" ? JSON.stringify(value) : String(value)}`, "info", node.id);
|
||||
return value;
|
||||
});
|
||||
|
||||
register("logic_control_var", (ctx, node, args) => {
|
||||
const name = String(interpolate(args.var_name ?? node.options?.outputVar ?? "", ctx.vars) || "").trim();
|
||||
if (!name) {
|
||||
throw new RuntimeError("validation_error", "「设置变量」未填写变量名称", {
|
||||
nodeId: node.id,
|
||||
componentId: node.componentId
|
||||
});
|
||||
}
|
||||
const value = interpolate(args.var_value ?? args.value, ctx.vars);
|
||||
ctx.vars[name] = value;
|
||||
ctx.log(`${name} = ${typeof value === "object" ? JSON.stringify(value) : String(value)}`, "info", node.id);
|
||||
return value;
|
||||
});
|
||||
|
||||
register("logic_control_break_for", () => {
|
||||
throw new BreakSignal();
|
||||
});
|
||||
|
||||
register("logic_control_next_for", () => {
|
||||
throw new ContinueSignal();
|
||||
});
|
||||
|
||||
register(["logic_control_stop", "logic_control_stop_return"], () => {
|
||||
throw new StopSignal();
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
const powershellMock = vi.hoisted(() => vi.fn(async () => '{"ok":true}'));
|
||||
const detectMock = vi.hoisted(() => vi.fn(async () => ({
|
||||
texts: [
|
||||
{ text: "确认保存", box: [[0, 0], [20, 0], [20, 10], [0, 10]] },
|
||||
{ text: "确认", box: [[100, 200], [130, 200], [130, 210], [100, 210]] }
|
||||
]
|
||||
})));
|
||||
// desktopCapturer 模拟: 默认无画面(走 GDI 回退), 多屏用例按请求宽度提供对应显示器画面
|
||||
const electronState = vi.hoisted(() => ({ sources: [] as Array<{ display_id: string; thumbnail: any }> }));
|
||||
|
||||
vi.mock("./desktop", () => ({ powershell: powershellMock }));
|
||||
vi.mock("@gutenye/ocr-node", () => ({
|
||||
default: { create: vi.fn(async () => ({ detect: detectMock })) }
|
||||
}));
|
||||
vi.mock("electron", () => ({
|
||||
desktopCapturer: {
|
||||
getSources: vi.fn(async (opts: { thumbnailSize: { width: number } }) =>
|
||||
electronState.sources.filter(s => s.thumbnail.getSize().width === opts.thumbnailSize.width))
|
||||
},
|
||||
screen: {
|
||||
getAllDisplays: vi.fn(() => [
|
||||
{ id: 1, bounds: { x: 0, y: 0, width: 1920, height: 1080 }, scaleFactor: 1 },
|
||||
{ id: 2, bounds: { x: 1920, y: 0, width: 1280, height: 720 }, scaleFactor: 2 }
|
||||
]),
|
||||
dipToScreenPoint: vi.fn((p: { x: number; y: number }) => ({ x: p.x, y: p.y }))
|
||||
}
|
||||
}));
|
||||
|
||||
import { getHandler } from "../engine/registry";
|
||||
import "./cv-vision";
|
||||
|
||||
describe("CV vision capabilities", () => {
|
||||
beforeEach(() => {
|
||||
powershellMock.mockClear();
|
||||
detectMock.mockClear();
|
||||
electronState.sources = [];
|
||||
});
|
||||
|
||||
it.each(["CV_GetText", "CV_SetText", "CV_MouseHover", "CV_MouseClick"])("registers %s", (id) => {
|
||||
expect(getHandler(id), id).toBeTruthy();
|
||||
});
|
||||
|
||||
it("rejects an empty CV selector", async () => {
|
||||
await expect(getHandler("CV_GetText")!({ log: vi.fn() } as any, { id: "n1", componentId: "CV_GetText" } as any, {}, {} as any))
|
||||
.rejects.toThrow("CV 目标为空");
|
||||
});
|
||||
|
||||
it("locates OCR text and clicks its center with offset", async () => {
|
||||
const handler = getHandler("CV_MouseClick")!;
|
||||
await expect(handler({ log: vi.fn() } as any, { id: "n1", componentId: "CV_MouseClick" } as any, {
|
||||
cvSelector: "确认",
|
||||
offsetX: 5,
|
||||
offsetY: 2
|
||||
}, {} as any)).resolves.toBe(true);
|
||||
expect(detectMock).toHaveBeenCalledTimes(1);
|
||||
const script = String((powershellMock.mock.calls as unknown as Array<[string]>)[1]?.[0] ?? "");
|
||||
expect(script).toContain("SetCursorPos(15, 7)");
|
||||
expect(script).toContain("mouse_event(0x0002");
|
||||
});
|
||||
|
||||
it("returns matched OCR text", async () => {
|
||||
await expect(getHandler("CV_GetText")!({ log: vi.fn() } as any, { id: "n2", componentId: "CV_GetText" } as any, {
|
||||
cvSelector: "保存"
|
||||
}, {} as any)).resolves.toBe("确认保存");
|
||||
expect(powershellMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("restricts matching to the search region when provided", async () => {
|
||||
// 屏幕上有两处「确认」: 区域(0,0,50,50)内只应命中 (5,5) 那处, 而不是第一个全屏命中 (115,205)
|
||||
await expect(getHandler("CV_MouseClick")!({ log: vi.fn() } as any, { id: "n3", componentId: "CV_MouseClick" } as any, {
|
||||
cvSelector: "确认",
|
||||
searchRegion: JSON.stringify({ x: 0, y: 0, width: 50, height: 50 })
|
||||
}, {} as any)).resolves.toBe(true);
|
||||
const script = String((powershellMock.mock.calls as unknown as Array<[string]>)[1]?.[0] ?? "");
|
||||
expect(script).toContain("SetCursorPos(10, 5)");
|
||||
});
|
||||
|
||||
it("rejects when target is outside the search region", async () => {
|
||||
await expect(getHandler("CV_MouseClick")!({ log: vi.fn() } as any, { id: "n4", componentId: "CV_MouseClick" } as any, {
|
||||
cvSelector: "确认",
|
||||
searchRegion: JSON.stringify({ x: 500, y: 500, width: 50, height: 50 })
|
||||
}, {} as any)).rejects.toThrow("未找到目标文本");
|
||||
});
|
||||
|
||||
it("locates targets on a secondary display and maps DIP coordinates", async () => {
|
||||
// 主屏无画面, 副屏(bounds 1920,0 1280x720 缩放2)物理中心(450,210) → DIP(2145,105)
|
||||
const fakeThumb = (w: number, h: number): any => ({
|
||||
isEmpty: () => false,
|
||||
getSize: () => ({ width: w, height: h }),
|
||||
toPNG: () => Buffer.from([137, 80, 78, 71])
|
||||
});
|
||||
electronState.sources = [{ display_id: "2", thumbnail: fakeThumb(2560, 1440) }];
|
||||
detectMock.mockImplementationOnce(async () => ({
|
||||
texts: [{ text: "副屏按钮", box: [[400, 200], [500, 200], [500, 220], [400, 220]] }]
|
||||
}));
|
||||
await expect(getHandler("CV_MouseClick")!({ log: vi.fn() } as any, { id: "n5", componentId: "CV_MouseClick" } as any, {
|
||||
cvSelector: "副屏按钮"
|
||||
}, {} as any)).resolves.toBe(true);
|
||||
const script = String((powershellMock.mock.calls as unknown as Array<[string]>).at(-1)?.[0] ?? "");
|
||||
expect(script).toContain("SetCursorPos(2145, 105)");
|
||||
});
|
||||
|
||||
it("maps region-image hits through the display scale", async () => {
|
||||
// 框选图来自缩放2的副屏: 图内物理中心(120,90) + 框选DIP原点(1920,0) → DIP(1980,45)
|
||||
const regionShot = path.join(os.tmpdir(), "rpa-cv-region-test-" + Date.now() + ".png");
|
||||
fs.writeFileSync(regionShot, Buffer.from([137, 80, 78, 71]));
|
||||
try {
|
||||
detectMock.mockImplementationOnce(async () => ({
|
||||
texts: [{ text: "确认", box: [[100, 80], [140, 80], [140, 100], [100, 100]] }]
|
||||
}));
|
||||
await expect(getHandler("CV_MouseHover")!({ log: vi.fn() } as any, { id: "n6", componentId: "CV_MouseHover" } as any, {
|
||||
cvSelector: "确认",
|
||||
searchRegion: JSON.stringify({ x: 1920, y: 0, width: 640, height: 360, scale: 2, imagePath: regionShot })
|
||||
}, {} as any)).resolves.toBe(true);
|
||||
const script = String((powershellMock.mock.calls as unknown as Array<[string]>).at(-1)?.[0] ?? "");
|
||||
expect(script).toContain("SetCursorPos(1980, 45)");
|
||||
} finally {
|
||||
try { fs.unlinkSync(regionShot); } catch {}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,270 @@
|
||||
import { register } from "../engine/registry";
|
||||
import { RuntimeError, type CapabilityHandler } from "../engine/types";
|
||||
import { powershell } from "./desktop";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
interface CvTarget { text: string; cx: number; cy: number }
|
||||
let paddleOcr: { detect: (image: string) => Promise<{ texts: Array<{ text: string; box?: number[][]; mean?: number }> }> } | null = null;
|
||||
|
||||
async function getOcr() {
|
||||
if (paddleOcr) return paddleOcr;
|
||||
const mod = await import("@gutenye/ocr-node");
|
||||
paddleOcr = await mod.default.create();
|
||||
return paddleOcr as { detect: (image: string) => Promise<{ texts: Array<{ text: string; box?: number[][]; mean?: number }> }> };
|
||||
}
|
||||
|
||||
function cvTarget(args: Record<string, unknown>, nodeId: string): string {
|
||||
for (const key of ["cvSelector", "target", "text"]) {
|
||||
const v = String(args[key] ?? "").trim();
|
||||
if (v) return v.replace(/^text:/, "").replace(/^图片:/, "");
|
||||
}
|
||||
throw new RuntimeError("validation_error", "CV 目标为空", { nodeId, componentId: nodeId, recommendation: "填写要在屏幕上查找的目标文本" });
|
||||
}
|
||||
|
||||
async function gdiGrabScreen(imagePath: string): Promise<void> {
|
||||
const psScript = [
|
||||
// 与点击脚本一致: 先声明 DPI 感知, 否则缩放非 100% 时 Bounds 是逻辑像素, 截屏只覆盖屏幕左上角
|
||||
"Add-Type -MemberDefinition '[DllImport(\"user32.dll\")] public static extern bool SetProcessDpiAwarenessContext(IntPtr value);' -Name RpaCvDpi -Namespace RpaCv | Out-Null;",
|
||||
"try { [RpaCv.RpaCvDpi]::SetProcessDpiAwarenessContext([IntPtr](-4)) | Out-Null } catch {}",
|
||||
"Add-Type -AssemblyName System.Drawing | Out-Null;",
|
||||
"Add-Type -AssemblyName System.Windows.Forms | Out-Null;",
|
||||
"$b = [System.Windows.Forms.SystemInformation]::VirtualScreen;",
|
||||
"$bmp = New-Object System.Drawing.Bitmap($b.Width, $b.Height);",
|
||||
"$g = [System.Drawing.Graphics]::FromImage($bmp);",
|
||||
"$g.CopyFromScreen($b.X, $b.Y, 0, 0, $b.Size);",
|
||||
"$g.Dispose();",
|
||||
"$bmp.Save(" + JSON.stringify(imagePath) + ");",
|
||||
"$bmp.Dispose();"
|
||||
].join(" ");
|
||||
await powershell(psScript, 30000);
|
||||
}
|
||||
|
||||
/** 一次屏幕截图: 图片左上角在虚拟桌面上的绝对 DIP 坐标 + 物理/DIP 缩放比 */
|
||||
interface ScreenShot { imagePath: string; dipX: number; dipY: number; scale: number }
|
||||
|
||||
/** desktopCapturer 逐显示器截屏(每屏原生分辨率), 不可用时回退 GDI 全虚拟桌面单图 */
|
||||
async function grabScreenAll(baseDir: string): Promise<ScreenShot[]> {
|
||||
fs.mkdirSync(baseDir, { recursive: true });
|
||||
try {
|
||||
const { desktopCapturer, screen } = await import("electron");
|
||||
if (desktopCapturer && screen?.getAllDisplays) {
|
||||
const shots: ScreenShot[] = [];
|
||||
const displays = screen.getAllDisplays();
|
||||
for (const display of displays) {
|
||||
const scale = display.scaleFactor || 1;
|
||||
const sources = await desktopCapturer.getSources({
|
||||
types: ["screen"],
|
||||
thumbnailSize: { width: Math.round(display.bounds.width * scale), height: Math.round(display.bounds.height * scale) }
|
||||
});
|
||||
const source = sources.find(item => item.display_id === String(display.id))
|
||||
|| sources.find(item => item.thumbnail.getSize().width === Math.round(display.bounds.width * scale))
|
||||
|| sources[0];
|
||||
if (!source || source.thumbnail.isEmpty()) continue;
|
||||
const imagePath = path.join(baseDir, `rpa-cv-screen-${display.id}-${Date.now()}.png`);
|
||||
fs.writeFileSync(imagePath, source.thumbnail.toPNG());
|
||||
shots.push({ imagePath, dipX: display.bounds.x, dipY: display.bounds.y, scale });
|
||||
}
|
||||
if (shots.length) return shots;
|
||||
}
|
||||
} catch {}
|
||||
const imagePath = path.join(baseDir, `rpa-cv-screen-${Date.now()}.png`);
|
||||
await gdiGrabScreen(imagePath);
|
||||
return [{ imagePath, dipX: 0, dipY: 0, scale: 1 }];
|
||||
}
|
||||
|
||||
interface OcrLine { text: string; box?: number[][]; mean?: number }
|
||||
|
||||
/** 搜索区域: 框选生成时含 imagePath(区域截图)+x/y(屏幕绝对 DIP 坐标)+scale(该屏缩放); 也可只给坐标范围 */
|
||||
interface CvRegion { imagePath?: string; x?: number; y?: number; width?: number; height?: number; scale?: number }
|
||||
|
||||
function parseRegionArg(value: unknown): CvRegion | null {
|
||||
if (!value) return null;
|
||||
if (typeof value === "object") return value as CvRegion;
|
||||
const s = String(value).trim();
|
||||
if (!s) return null;
|
||||
if (s.startsWith("{")) {
|
||||
try { return JSON.parse(s) as CvRegion; } catch { return null; }
|
||||
}
|
||||
if (/\.(png|jpe?g|bmp)$/i.test(s) || fs.existsSync(s)) return { imagePath: s };
|
||||
return null;
|
||||
}
|
||||
|
||||
function boxCenter(box: number[][]): { cx: number; cy: number } {
|
||||
const xs = box.map(p => p[0]);
|
||||
const ys = box.map(p => p[1]);
|
||||
return { cx: (Math.min(...xs) + Math.max(...xs)) / 2, cy: (Math.min(...ys) + Math.max(...ys)) / 2 };
|
||||
}
|
||||
|
||||
export async function locateTextOnScreen(
|
||||
target: string,
|
||||
nodeId: string,
|
||||
region?: CvRegion | null
|
||||
): Promise<{ target: string; text: string; cx: number; cy: number }> {
|
||||
const ocr = await getOcr();
|
||||
const keyword = target.trim();
|
||||
// 命中中心换算为屏幕绝对 DIP 坐标
|
||||
let found: { text: string; cx: number; cy: number } | null = null;
|
||||
const cleanups: string[] = [];
|
||||
|
||||
if (region?.imagePath && fs.existsSync(region.imagePath)) {
|
||||
// 框选截图: OCR 只识别该图; 图内物理坐标 ÷ 该屏缩放 + 框选绝对 DIP 原点
|
||||
const scale = Number(region.scale ?? 1) || 1;
|
||||
const result = await ocr.detect(region.imagePath);
|
||||
const hit = (result.texts ?? []).find(t => t.text && t.text.includes(keyword) && t.box && t.box.length >= 4);
|
||||
if (hit?.box) {
|
||||
const c = boxCenter(hit.box);
|
||||
found = {
|
||||
text: hit.text,
|
||||
cx: (Number(region.x ?? 0) || 0) + c.cx / scale,
|
||||
cy: (Number(region.y ?? 0) || 0) + c.cy / scale
|
||||
};
|
||||
}
|
||||
} else {
|
||||
// 全屏查找: 逐显示器截屏识别, 覆盖副屏(含负坐标屏); 命中即停
|
||||
const shots = await grabScreenAll(path.join(os.tmpdir(), "rpa-cv-shots"));
|
||||
for (const shot of shots) {
|
||||
const result = await ocr.detect(shot.imagePath);
|
||||
const candidates = (result.texts ?? []).filter(t => t.text && t.text.includes(keyword) && t.box && t.box.length >= 4);
|
||||
let hit: OcrLine | undefined = candidates[0];
|
||||
// 给了坐标范围(绝对 DIP)时, 过滤中心落在区域内的命中
|
||||
if (hit && region?.width && region?.height) {
|
||||
const rx = Number(region.x ?? 0) || 0;
|
||||
const ry = Number(region.y ?? 0) || 0;
|
||||
hit = candidates.find(t => {
|
||||
const c = boxCenter(t.box!);
|
||||
const dx = shot.dipX + c.cx / shot.scale;
|
||||
const dy = shot.dipY + c.cy / shot.scale;
|
||||
return dx >= rx && dx <= rx + region.width! && dy >= ry && dy <= ry + region.height!;
|
||||
});
|
||||
}
|
||||
if (hit?.box) {
|
||||
const c = boxCenter(hit.box);
|
||||
found = { text: hit.text, cx: shot.dipX + c.cx / shot.scale, cy: shot.dipY + c.cy / shot.scale };
|
||||
break;
|
||||
}
|
||||
cleanups.push(shot.imagePath);
|
||||
}
|
||||
// 成功命中时清理全部截图, 未命中保留全部以便排查
|
||||
if (found) for (const p of shots.map(s => s.imagePath)) { try { fs.unlinkSync(p); } catch {} }
|
||||
}
|
||||
|
||||
if (!found) {
|
||||
throw new RuntimeError("cv_not_found", "屏幕上未找到目标文本:" + keyword + (region?.imagePath ? "(搜索范围: 框选区域)" : ""), {
|
||||
nodeId,
|
||||
componentId: nodeId,
|
||||
recommendation: "确认目标文本在" + (region?.imagePath ? "框选区域内" : "屏幕上(含副屏)") + "可见且清晰"
|
||||
});
|
||||
}
|
||||
for (const p of cleanups) { try { fs.unlinkSync(p); } catch {} }
|
||||
return { target: keyword, text: found.text, cx: found.cx, cy: found.cy };
|
||||
}
|
||||
function screenUser32(): string {
|
||||
return [
|
||||
"Add-Type -MemberDefinition '[DllImport(\"user32.dll\")] public static extern bool SetProcessDpiAwarenessContext(IntPtr value); [DllImport(\"user32.dll\")] public static extern bool SetCursorPos(int X, int Y); [DllImport(\"user32.dll\")] public static extern void mouse_event(uint flags, uint dx, uint dy, uint data, UIntPtr extra);' -Name RpaCvInput -Namespace RpaCv | Out-Null;",
|
||||
"try { [RpaCv.RpaCvInput]::SetProcessDpiAwarenessContext([IntPtr](-4)) | Out-Null } catch {}"
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function coordinateScript(x: number, y: number, action: "hover" | "click" | "doubleClick" | "paste", value = ""): string {
|
||||
const lines = [
|
||||
screenUser32(),
|
||||
"if (-not [RpaCv.RpaCvInput]::SetCursorPos(" + x + ", " + y + ")) { throw 'cursor_move_failed' }",
|
||||
"Start-Sleep -Milliseconds 100"
|
||||
];
|
||||
if (action !== "hover") {
|
||||
lines.push(
|
||||
"[RpaCv.RpaCvInput]::mouse_event(0x0002,0,0,0,[UIntPtr]::Zero)",
|
||||
"Start-Sleep -Milliseconds 40",
|
||||
"[RpaCv.RpaCvInput]::mouse_event(0x0004,0,0,0,[UIntPtr]::Zero)"
|
||||
);
|
||||
}
|
||||
if (action === "doubleClick") {
|
||||
lines.push(
|
||||
"Start-Sleep -Milliseconds 70",
|
||||
"[RpaCv.RpaCvInput]::mouse_event(0x0002,0,0,0,[UIntPtr]::Zero)",
|
||||
"Start-Sleep -Milliseconds 40",
|
||||
"[RpaCv.RpaCvInput]::mouse_event(0x0004,0,0,0,[UIntPtr]::Zero)"
|
||||
);
|
||||
}
|
||||
if (action === "paste") {
|
||||
lines.push(
|
||||
"Add-Type -AssemblyName System.Windows.Forms",
|
||||
"Start-Sleep -Milliseconds 220",
|
||||
"$saved = $null",
|
||||
"try { $saved = [System.Windows.Forms.Clipboard]::GetText() } catch {}",
|
||||
"try { [System.Windows.Forms.Clipboard]::SetText(" + JSON.stringify(value) + ") } catch { throw 'clipboard_failed' }",
|
||||
"Start-Sleep -Milliseconds 80",
|
||||
"[System.Windows.Forms.SendKeys]::SendWait('^a')",
|
||||
"Start-Sleep -Milliseconds 60",
|
||||
"[System.Windows.Forms.SendKeys]::SendWait('^v')",
|
||||
"Start-Sleep -Milliseconds 280",
|
||||
"try { if ($saved) { [System.Windows.Forms.Clipboard]::SetText($saved) } else { [System.Windows.Forms.Clipboard]::Clear() } } catch {}"
|
||||
);
|
||||
}
|
||||
lines.push("Write-Output (@{ ok = $true; x = " + x + "; y = " + y + " } | ConvertTo-Json -Compress)");
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
/** DIP → 物理像素(SetCursorPos 在 DPI 感知进程里用物理坐标); 无 Electron API 时按原值(缩放 100% 等价) */
|
||||
async function toPhysical(x: number, y: number): Promise<{ x: number; y: number }> {
|
||||
try {
|
||||
const { screen } = await import("electron");
|
||||
if (screen && typeof screen.dipToScreenPoint === "function") {
|
||||
const p = screen.dipToScreenPoint({ x: Math.round(x), y: Math.round(y) });
|
||||
return { x: p.x, y: p.y };
|
||||
}
|
||||
} catch {}
|
||||
return { x: Math.round(x), y: Math.round(y) };
|
||||
}
|
||||
|
||||
async function locateFor(args: Record<string, unknown>, node: { id: string; componentId: string }) {
|
||||
const target = cvTarget(args, node.id);
|
||||
// 可选搜索区域: 框选生成 {x,y,width,height,imagePath,scale}, 限定 OCR 查找范围
|
||||
const region = parseRegionArg(args.searchRegion ?? args.region);
|
||||
const located = await locateTextOnScreen(target, node.id, region);
|
||||
const offsetX = Number(args.offsetX ?? args.offset_x ?? 0) || 0;
|
||||
const offsetY = Number(args.offsetY ?? args.offset_y ?? 0) || 0;
|
||||
return { located, x: Math.round(located.cx + offsetX), y: Math.round(located.cy + offsetY) };
|
||||
}
|
||||
|
||||
const getTextGround: CapabilityHandler = async (ctx, node, args) => {
|
||||
const target = cvTarget(args, node.id);
|
||||
const result = await locateTextOnScreen(target, node.id);
|
||||
ctx.log("CV_GetText found: " + result.text, "info", node.id);
|
||||
return result.text;
|
||||
};
|
||||
|
||||
const setTextGround: CapabilityHandler = async (ctx, node, args) => {
|
||||
const value = String(args.value ?? args.text ?? "");
|
||||
if (!value) throw new RuntimeError("validation_error", "CV 文本输入内容为空", { nodeId: node.id, componentId: node.componentId });
|
||||
const { x, y } = await locateFor(args, node);
|
||||
const phys = await toPhysical(x, y);
|
||||
await powershell(coordinateScript(phys.x, phys.y, "paste", value), 30000);
|
||||
ctx.log("CV_SetText input at " + phys.x + "," + phys.y, "info", node.id);
|
||||
return true;
|
||||
};
|
||||
|
||||
const mouseHoverGround: CapabilityHandler = async (ctx, node, args) => {
|
||||
const { x, y } = await locateFor(args, node);
|
||||
const phys = await toPhysical(x, y);
|
||||
await powershell(coordinateScript(phys.x, phys.y, "hover"), 15000);
|
||||
ctx.log("CV_MouseHover hover at " + phys.x + "," + phys.y, "info", node.id);
|
||||
return true;
|
||||
};
|
||||
|
||||
const mouseClickGround: CapabilityHandler = async (ctx, node, args) => {
|
||||
const { x, y } = await locateFor(args, node);
|
||||
const phys = await toPhysical(x, y);
|
||||
const clickway = String(args.clickway ?? "0");
|
||||
const action = clickway === "1" || /double/i.test(clickway) ? "doubleClick" as const : "click" as const;
|
||||
await powershell(coordinateScript(phys.x, phys.y, action), 15000);
|
||||
ctx.log("CV_MouseClick " + action + " at " + phys.x + "," + phys.y, "info", node.id);
|
||||
return true;
|
||||
};
|
||||
|
||||
register("CV_GetText", getTextGround);
|
||||
register("CV_SetText", setTextGround);
|
||||
register("CV_MouseHover", mouseHoverGround);
|
||||
register("CV_MouseClick", mouseClickGround);
|
||||
@@ -0,0 +1,132 @@
|
||||
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Catalog, CatalogComponent, FlowNode } from "../types";
|
||||
import type { RunContext } from "../engine/types";
|
||||
import { getHandler } from "../engine/registry";
|
||||
import "./data-extended";
|
||||
|
||||
const ids = [
|
||||
"Collection_Create", "Collection_HasItem", "Collection_Count", "Collection_Remove", "Collection_Add",
|
||||
"Ini_Read", "Ini_Write", "Ini_ListSection", "Ini_ListKey", "Ini_DeleteKey", "Ini_DeleteSection",
|
||||
"Log_Info", "Log_Error", "ExceptionUtil_Throws",
|
||||
"Bivartable_CreateDf", "Bivartable_CreateDfV1", "Bivartable_DoCreate",
|
||||
"Bivartable_GetMaxValue", "Bivartable_GetMinValue", "Bivartable_GetSumValue", "Bivartable_GetAvgValue",
|
||||
"Bivartable_SummaryCalculationDf", "Bivartable_DeleteDuplicateRow", "Bivartable_MergeDf",
|
||||
"Bivartable_DoMerge", "Bivartable_FilterDf", "Bivartable_DoFilter"
|
||||
] as const;
|
||||
|
||||
const temporaryDirectories: string[] = [];
|
||||
const log = vi.fn();
|
||||
const context = { log } as unknown as RunContext;
|
||||
const component = {} as CatalogComponent;
|
||||
const node = (componentId: string): FlowNode => ({
|
||||
id: `data_${componentId}`,
|
||||
componentId,
|
||||
position: { x: 0, y: 0 },
|
||||
values: {},
|
||||
options: {}
|
||||
});
|
||||
|
||||
async function temporaryDirectory(): Promise<string> {
|
||||
const directory = await mkdtemp(path.join(os.tmpdir(), "rpa-data-extended-"));
|
||||
temporaryDirectories.push(directory);
|
||||
return directory;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
log.mockReset();
|
||||
await Promise.all(temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
describe("extended local data capabilities", () => {
|
||||
it("registers only real catalog component ids", async () => {
|
||||
const catalog = JSON.parse(await readFile("resources/catalog.json", "utf8")) as Catalog;
|
||||
const catalogIds = new Set(catalog.components.map((item) => item.id));
|
||||
expect(ids.length).toBeGreaterThanOrEqual(15);
|
||||
for (const id of ids) {
|
||||
expect(catalogIds.has(id), `${id} should exist in catalog`).toBe(true);
|
||||
expect(getHandler(id), `${id} should be registered`).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
it("creates and mutates unique local collections", async () => {
|
||||
const collection = await run("Collection_Create") as unknown[];
|
||||
await run("Collection_Add", { collection, value: "A" });
|
||||
await run("Collection_Add", { collection, value: "A" });
|
||||
await run("Collection_Add", { collection, value: "B" });
|
||||
await expect(run("Collection_HasItem", { collection, item: "A" })).resolves.toBe(true);
|
||||
await expect(run("Collection_Count", { collection })).resolves.toBe(2);
|
||||
await run("Collection_Remove", { collection, value: "A" });
|
||||
expect(collection).toEqual(["B"]);
|
||||
});
|
||||
|
||||
it("persists, lists, reads, and deletes INI values", async () => {
|
||||
const directory = await temporaryDirectory();
|
||||
const file = path.join(directory, "nested", "settings.ini");
|
||||
await run("Ini_Write", { file, section: "App", key: "name", value: "RPA Studio" });
|
||||
await run("Ini_Write", { file, section: "App", key: "workers", value: 3 });
|
||||
await run("Ini_Write", { file, section: "Network", key: "timeout", value: 30 });
|
||||
await expect(run("Ini_Read", { file, section: "App", key: "name" })).resolves.toBe("RPA Studio");
|
||||
await expect(run("Ini_Read", { file, section: "Missing", key: "key", defaultValue: "fallback" })).resolves.toBe("fallback");
|
||||
await expect(run("Ini_ListSection", { file })).resolves.toEqual(["App", "Network"]);
|
||||
await expect(run("Ini_ListKey", { file, section: "App" })).resolves.toEqual(["name", "workers"]);
|
||||
await run("Ini_DeleteKey", { file, section: "App", key: "workers" });
|
||||
await run("Ini_DeleteSection", { file, section: "Network" });
|
||||
await expect(run("Ini_ListKey", { file, section: "App" })).resolves.toEqual(["name"]);
|
||||
await expect(run("Ini_ListSection", { file })).resolves.toEqual(["App"]);
|
||||
await expect(readFile(file, "utf8")).resolves.toContain("name=RPA Studio");
|
||||
});
|
||||
|
||||
it("writes runtime logs and raises a located user exception", async () => {
|
||||
await run("Log_Info", { msg: "started" });
|
||||
await run("Log_Error", { msg: "failed" });
|
||||
expect(log).toHaveBeenNthCalledWith(1, "started", "info", "data_Log_Info");
|
||||
expect(log).toHaveBeenNthCalledWith(2, "failed", "error", "data_Log_Error");
|
||||
await expect(run("ExceptionUtil_Throws", { msg: "stop now" })).rejects.toMatchObject({
|
||||
name: "RuntimeError", code: "user_exception", message: "stop now", nodeId: "data_ExceptionUtil_Throws"
|
||||
});
|
||||
});
|
||||
|
||||
it("creates tables from arrays, records, and column dictionaries", async () => {
|
||||
await expect(run("Bivartable_CreateDf", { data: [[1, 2], [3, 4]], columnList: ["A", "B"] }))
|
||||
.resolves.toEqual([["A", "B"], [1, 2], [3, 4]]);
|
||||
await expect(run("Bivartable_CreateDfV1", { data: [{ name: "A", score: 2 }, { name: "B", score: 4 }] }))
|
||||
.resolves.toEqual([["name", "score"], ["A", 2], ["B", 4]]);
|
||||
await expect(run("Bivartable_DoCreate", { data: { name: ["A", "B"], score: [2, 4] } }))
|
||||
.resolves.toEqual([["name", "score"], ["A", 2], ["B", 4]]);
|
||||
});
|
||||
|
||||
it("calculates scalar and grouped table aggregates", async () => {
|
||||
const input = [["team", "score"], ["A", 2], ["A", 4], ["B", 3]];
|
||||
await expect(run("Bivartable_GetMaxValue", { df: input, maxvaluecols: "score" })).resolves.toBe(4);
|
||||
await expect(run("Bivartable_GetMinValue", { df: input, minvaluecols: "score" })).resolves.toBe(2);
|
||||
await expect(run("Bivartable_GetSumValue", { df: input, sumvaluecols: "score" })).resolves.toBe(9);
|
||||
await expect(run("Bivartable_GetAvgValue", { df: input, avgvaluecols: "score", groupby: "team" }))
|
||||
.resolves.toEqual([["team", "score"], ["A", 3], ["B", 3]]);
|
||||
await expect(run("Bivartable_SummaryCalculationDf", { df: input, name: "score", method: "sum" })).resolves.toBe(9);
|
||||
});
|
||||
|
||||
it("deduplicates, merges, and filters local tables", async () => {
|
||||
const duplicated = [["id", "name"], [1, "A"], [1, "A2"], [2, "B"]];
|
||||
await expect(run("Bivartable_DeleteDuplicateRow", { df: duplicated, change_type: "name", col_name: ["id"], mode: "first" }))
|
||||
.resolves.toEqual([["id", "name"], [1, "A"], [2, "B"]]);
|
||||
const left = [["id", "name"], [1, "A"], [2, "B"]];
|
||||
const right = [["id", "score"], [1, 90], [3, 70]];
|
||||
await expect(run("Bivartable_MergeDf", { df1: left, df2: right, on_name: "id", merge_type: "left" }))
|
||||
.resolves.toEqual([["id", "name", "score"], [1, "A", 90], [2, "B", null]]);
|
||||
await expect(run("Bivartable_DoMerge", { df1: left, df2: right, onx: "id", howx: "inner" }))
|
||||
.resolves.toEqual([["id", "name", "score"], [1, "A", 90]]);
|
||||
await expect(run("Bivartable_FilterDf", { df: left, col_name: "id", choice: ">", criteria: 1 }))
|
||||
.resolves.toEqual([["id", "name"], [2, "B"]]);
|
||||
await expect(run("Bivartable_DoFilter", { df: [["id", "score"], [1, 70], [2, 90], [3, 95]], filter: "(df['id'] > 1) & (df['score'] >= 90)" }))
|
||||
.resolves.toEqual([["id", "score"], [2, 90], [3, 95]]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,392 @@
|
||||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { register } from "../engine/registry";
|
||||
import { RuntimeError, type CapabilityHandler } from "../engine/types";
|
||||
|
||||
type Table = unknown[][];
|
||||
type IniDocument = Map<string, Map<string, string>>;
|
||||
|
||||
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(candidate: unknown): string {
|
||||
return candidate == null ? "" : String(candidate);
|
||||
}
|
||||
|
||||
function parse(candidate: unknown): unknown {
|
||||
if (typeof candidate !== "string") return candidate;
|
||||
const source = candidate.trim();
|
||||
if (!source) return candidate;
|
||||
try { return JSON.parse(source); } catch { return candidate; }
|
||||
}
|
||||
|
||||
function list(candidate: unknown): unknown[] {
|
||||
const parsed = parse(candidate);
|
||||
if (Array.isArray(parsed)) return parsed;
|
||||
if (parsed === undefined || parsed === null || parsed === "") return [];
|
||||
if (typeof parsed === "string") return parsed.split(/[,\uFF0C\n]/).map((item) => item.trim()).filter(Boolean);
|
||||
return [parsed];
|
||||
}
|
||||
|
||||
function table(candidate: unknown): Table {
|
||||
const parsed = parse(candidate);
|
||||
if (Array.isArray(parsed)) {
|
||||
if (!parsed.length) return [];
|
||||
if (parsed.every((row) => Array.isArray(row))) return parsed.map((row) => [...row as unknown[]]);
|
||||
if (parsed.every((row) => row && typeof row === "object" && !Array.isArray(row))) {
|
||||
const headers = [...new Set(parsed.flatMap((row) => Object.keys(row as Record<string, unknown>)))];
|
||||
return [headers, ...parsed.map((row) => headers.map((header) => (row as Record<string, unknown>)[header]))];
|
||||
}
|
||||
return parsed.map((item) => [item]);
|
||||
}
|
||||
if (parsed && typeof parsed === "object") {
|
||||
const source = parsed as Record<string, unknown>;
|
||||
const headers = Object.keys(source);
|
||||
const columns = headers.map((header) => list(source[header]));
|
||||
const rowCount = Math.max(0, ...columns.map((column) => column.length));
|
||||
return [headers, ...Array.from({ length: rowCount }, (_, row) => columns.map((column) => column[row]))];
|
||||
}
|
||||
return parsed === undefined || parsed === null || parsed === "" ? [] : [[parsed]];
|
||||
}
|
||||
|
||||
function columnNames(candidate: unknown): string[] {
|
||||
return list(candidate).map((item) => text(item)).filter(Boolean);
|
||||
}
|
||||
|
||||
function columnIndex(headers: unknown[], name: unknown): number {
|
||||
const source = text(name).trim();
|
||||
const byName = headers.findIndex((header) => text(header) === source);
|
||||
if (byName >= 0) return byName;
|
||||
if (/^\d+$/.test(source)) return Math.max(0, Number(source) - 1);
|
||||
if (/^[A-Za-z]+$/.test(source)) {
|
||||
let result = 0;
|
||||
for (const character of source.toUpperCase()) result = result * 26 + character.charCodeAt(0) - 64;
|
||||
return Math.max(0, result - 1);
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function selectedIndexes(rows: Table, candidate: unknown): number[] {
|
||||
const headers = rows[0] ?? [];
|
||||
const requested = columnNames(candidate);
|
||||
return requested.map((name) => columnIndex(headers, name)).filter((index) => index >= 0);
|
||||
}
|
||||
|
||||
function comparable(candidate: unknown): string | number | boolean | null {
|
||||
if (candidate === null || candidate === undefined) return null;
|
||||
if (typeof candidate === "number" || typeof candidate === "boolean") return candidate;
|
||||
const source = text(candidate).trim().replace(/^\(+|\)+$/g, "").trim();
|
||||
if (/^-?\d+(\.\d+)?$/.test(source)) return Number(source);
|
||||
if (source === "true" || source === "false") return source === "true";
|
||||
return source.replace(/^['"]|['"]$/g, "");
|
||||
}
|
||||
|
||||
function equal(left: unknown, right: unknown): boolean {
|
||||
return comparable(left) === comparable(right);
|
||||
}
|
||||
|
||||
function collection(candidate: unknown): Set<unknown> | unknown[] {
|
||||
if (candidate instanceof Set || Array.isArray(candidate)) return candidate;
|
||||
return list(candidate);
|
||||
}
|
||||
|
||||
register("Collection_Create", () => []);
|
||||
register("Collection_HasItem", (_ctx, _node, args) => {
|
||||
const source = collection(args.collection);
|
||||
return source instanceof Set ? source.has(args.item) : source.some((item) => equal(item, args.item));
|
||||
});
|
||||
register("Collection_Count", (_ctx, _node, args) => {
|
||||
const source = collection(args.collection);
|
||||
return source instanceof Set ? source.size : source.length;
|
||||
});
|
||||
register("Collection_Add", (_ctx, _node, args) => {
|
||||
const source = collection(args.collection);
|
||||
if (source instanceof Set) source.add(args.value);
|
||||
else if (!source.some((item) => equal(item, args.value))) source.push(args.value);
|
||||
return source;
|
||||
});
|
||||
register("Collection_Remove", (_ctx, _node, args) => {
|
||||
const source = collection(args.collection);
|
||||
if (source instanceof Set) source.delete(args.value);
|
||||
else {
|
||||
const index = source.findIndex((item) => equal(item, args.value));
|
||||
if (index >= 0) source.splice(index, 1);
|
||||
}
|
||||
return source;
|
||||
});
|
||||
|
||||
function requiredFile(args: Record<string, unknown>, nodeId: string, componentId: string): string {
|
||||
const candidate = text(args.file).trim();
|
||||
if (!candidate) {
|
||||
throw new RuntimeError("validation_error", "INI file path is empty", {
|
||||
nodeId, componentId, arg: "file", recommendation: "Select an INI file"
|
||||
});
|
||||
}
|
||||
return path.resolve(candidate);
|
||||
}
|
||||
|
||||
function parseIni(source: string): IniDocument {
|
||||
const document: IniDocument = new Map([["", new Map()]]);
|
||||
let section = "";
|
||||
for (const rawLine of source.replace(/^\uFEFF/, "").split(/\r?\n/)) {
|
||||
const line = rawLine.trim();
|
||||
if (!line || line.startsWith(";") || line.startsWith("#")) continue;
|
||||
const sectionMatch = line.match(/^\[([^\]]+)]$/);
|
||||
if (sectionMatch) {
|
||||
section = sectionMatch[1].trim();
|
||||
if (!document.has(section)) document.set(section, new Map());
|
||||
continue;
|
||||
}
|
||||
const separator = rawLine.indexOf("=");
|
||||
if (separator < 0) continue;
|
||||
document.get(section)!.set(rawLine.slice(0, separator).trim(), rawLine.slice(separator + 1).trim());
|
||||
}
|
||||
return document;
|
||||
}
|
||||
|
||||
function serializeIni(document: IniDocument): string {
|
||||
const lines: string[] = [];
|
||||
for (const [section, entries] of document) {
|
||||
if (section) {
|
||||
if (lines.length) lines.push("");
|
||||
lines.push(`[${section}]`);
|
||||
}
|
||||
for (const [key, entryValue] of entries) lines.push(`${key}=${entryValue}`);
|
||||
}
|
||||
return `${lines.join("\r\n")}\r\n`;
|
||||
}
|
||||
|
||||
async function loadIni(filePath: string): Promise<IniDocument> {
|
||||
try { return parseIni(await fs.readFile(filePath, "utf8")); }
|
||||
catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") return parseIni("");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveIni(filePath: string, document: IniDocument): Promise<void> {
|
||||
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
||||
await fs.writeFile(filePath, serializeIni(document), "utf8");
|
||||
}
|
||||
|
||||
register("Ini_Read", async (_ctx, node, args) => {
|
||||
const document = await loadIni(requiredFile(args, node.id, node.componentId));
|
||||
return document.get(text(args.section))?.get(text(args.key)) ?? text(args.defaultValue);
|
||||
});
|
||||
register("Ini_Write", async (_ctx, node, args) => {
|
||||
const filePath = requiredFile(args, node.id, node.componentId);
|
||||
const document = await loadIni(filePath);
|
||||
const section = text(args.section);
|
||||
if (!document.has(section)) document.set(section, new Map());
|
||||
document.get(section)!.set(text(args.key), text(args.value));
|
||||
await saveIni(filePath, document);
|
||||
return filePath;
|
||||
});
|
||||
register("Ini_ListSection", async (_ctx, node, args) => {
|
||||
const document = await loadIni(requiredFile(args, node.id, node.componentId));
|
||||
return [...document.keys()].filter(Boolean);
|
||||
});
|
||||
register("Ini_ListKey", async (_ctx, node, args) => {
|
||||
const document = await loadIni(requiredFile(args, node.id, node.componentId));
|
||||
return [...(document.get(text(args.section))?.keys() ?? [])];
|
||||
});
|
||||
register("Ini_DeleteKey", async (_ctx, node, args) => {
|
||||
const filePath = requiredFile(args, node.id, node.componentId);
|
||||
const document = await loadIni(filePath);
|
||||
const removed = document.get(text(args.section))?.delete(text(args.key)) ?? false;
|
||||
await saveIni(filePath, document);
|
||||
return removed;
|
||||
});
|
||||
register("Ini_DeleteSection", async (_ctx, node, args) => {
|
||||
const filePath = requiredFile(args, node.id, node.componentId);
|
||||
const document = await loadIni(filePath);
|
||||
const removed = document.delete(text(args.section));
|
||||
await saveIni(filePath, document);
|
||||
return removed;
|
||||
});
|
||||
|
||||
register("Log_Info", (ctx, node, args) => {
|
||||
const message = text(args.msg);
|
||||
ctx.log(message, "info", node.id);
|
||||
return message;
|
||||
});
|
||||
register("Log_Error", (ctx, node, args) => {
|
||||
const message = text(args.msg);
|
||||
ctx.log(message, "error", node.id);
|
||||
return message;
|
||||
});
|
||||
register("ExceptionUtil_Throws", (_ctx, node, args) => {
|
||||
throw new RuntimeError("user_exception", text(args.msg) || "User exception", {
|
||||
nodeId: node.id, componentId: node.componentId
|
||||
});
|
||||
});
|
||||
|
||||
const createTable: CapabilityHandler = (_ctx, _node, args) => {
|
||||
const rows = table(value(args, ["data"]));
|
||||
const columns = columnNames(args.columnList);
|
||||
if (!columns.length) return rows;
|
||||
if (!rows.length) return [columns];
|
||||
if (rows[0].length !== columns.length) throw new RuntimeError("validation_error", "Column count does not match data");
|
||||
return [columns, ...rows];
|
||||
};
|
||||
register(["Bivartable_CreateDf", "Bivartable_CreateDfV1", "Bivartable_DoCreate"], createTable);
|
||||
|
||||
function aggregate(args: Record<string, unknown>, operation: "max" | "min" | "sum" | "avg"): unknown {
|
||||
const rows = table(args.df);
|
||||
if (rows.length < 2) return 0;
|
||||
const field = operation === "max" ? "maxvaluecols" : operation === "min" ? "minvaluecols" : operation === "sum" ? "sumvaluecols" : "avgvaluecols";
|
||||
const indexes = selectedIndexes(rows, args[field]);
|
||||
if (!indexes.length) throw new RuntimeError("validation_error", "No aggregate column was found");
|
||||
const groups = selectedIndexes(rows, args.groupby);
|
||||
const calculate = (body: unknown[][], index: number): number => {
|
||||
const numbers = body.map((row) => Number(row[index])).filter(Number.isFinite);
|
||||
if (!numbers.length) return 0;
|
||||
if (operation === "max") return Math.max(...numbers);
|
||||
if (operation === "min") return Math.min(...numbers);
|
||||
const sum = numbers.reduce((total, current) => total + current, 0);
|
||||
return operation === "avg" ? sum / numbers.length : sum;
|
||||
};
|
||||
if (!groups.length) {
|
||||
const results = indexes.map((index) => calculate(rows.slice(1), index));
|
||||
return results.length === 1 ? results[0] : Object.fromEntries(indexes.map((index, position) => [text(rows[0][index]), results[position]]));
|
||||
}
|
||||
const buckets = new Map<string, unknown[][]>();
|
||||
for (const row of rows.slice(1)) {
|
||||
const key = JSON.stringify(groups.map((index) => row[index]));
|
||||
if (!buckets.has(key)) buckets.set(key, []);
|
||||
buckets.get(key)!.push(row);
|
||||
}
|
||||
return [
|
||||
[...groups.map((index) => rows[0][index]), ...indexes.map((index) => rows[0][index])],
|
||||
...[...buckets.entries()].map(([key, body]) => [...JSON.parse(key), ...indexes.map((index) => calculate(body, index))])
|
||||
];
|
||||
}
|
||||
|
||||
register("Bivartable_GetMaxValue", (_ctx, _node, args) => aggregate(args, "max"));
|
||||
register("Bivartable_GetMinValue", (_ctx, _node, args) => aggregate(args, "min"));
|
||||
register("Bivartable_GetSumValue", (_ctx, _node, args) => aggregate(args, "sum"));
|
||||
register("Bivartable_GetAvgValue", (_ctx, _node, args) => aggregate(args, "avg"));
|
||||
register("Bivartable_SummaryCalculationDf", (_ctx, _node, args) => {
|
||||
const method = text(args.method).toLowerCase();
|
||||
const operation = method.includes("max") ? "max" : method.includes("min") ? "min" : method.includes("avg") || method.includes("mean") ? "avg" : "sum";
|
||||
const field = operation === "max" ? "maxvaluecols" : operation === "min" ? "minvaluecols" : operation === "avg" ? "avgvaluecols" : "sumvaluecols";
|
||||
return aggregate({ ...args, [field]: args.name }, operation);
|
||||
});
|
||||
|
||||
register("Bivartable_DeleteDuplicateRow", (_ctx, _node, args) => {
|
||||
const rows = table(args.df);
|
||||
if (rows.length < 2) return rows;
|
||||
const requested = text(args.change_type) === "name" ? args.col_name : args.col_num;
|
||||
const indexes = selectedIndexes(rows, requested);
|
||||
const compared = indexes.length ? indexes : rows[0].map((_item, index) => index);
|
||||
const keys = rows.slice(1).map((row) => JSON.stringify(compared.map((index) => row[index])));
|
||||
const counts = new Map<string, number>();
|
||||
for (const key of keys) counts.set(key, (counts.get(key) ?? 0) + 1);
|
||||
const mode = text(args.mode).toLowerCase();
|
||||
const seen = new Set<string>();
|
||||
const body = rows.slice(1).filter((_row, index) => {
|
||||
const key = keys[index];
|
||||
if (mode === "false" || mode === "none" || mode.includes("all")) return counts.get(key) === 1;
|
||||
if (mode === "last") return keys.lastIndexOf(key) === index;
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
return [rows[0], ...body];
|
||||
});
|
||||
|
||||
function mergeTables(args: Record<string, unknown>): Table {
|
||||
const left = table(args.df1);
|
||||
const right = table(args.df2);
|
||||
if (!left.length) return right;
|
||||
if (!right.length) return left;
|
||||
const mode = text(value(args, ["merge_type", "howx"], "inner")).toLowerCase();
|
||||
const common = left[0].filter((header) => right[0].some((candidate) => text(candidate) === text(header))).map(text);
|
||||
const sameKeys = columnNames(value(args, ["on_name", "onx"]));
|
||||
const leftKeys = columnNames(value(args, ["on_left", "left_onx"], sameKeys.length ? sameKeys : common));
|
||||
const rightKeys = columnNames(value(args, ["on_right", "right_onx"], sameKeys.length ? sameKeys : common));
|
||||
if (!leftKeys.length || leftKeys.length !== rightKeys.length) throw new RuntimeError("validation_error", "Invalid table join keys");
|
||||
const leftIndexes = leftKeys.map((key) => columnIndex(left[0], key));
|
||||
const rightIndexes = rightKeys.map((key) => columnIndex(right[0], key));
|
||||
if ([...leftIndexes, ...rightIndexes].some((index) => index < 0)) throw new RuntimeError("validation_error", "Table join key does not exist");
|
||||
const rightDataIndexes = right[0].map((_header, index) => index).filter((index) => !rightIndexes.includes(index));
|
||||
const outputHeaders = [...left[0], ...rightDataIndexes.map((index) => {
|
||||
const header = text(right[0][index]);
|
||||
return left[0].some((candidate) => text(candidate) === header) ? `${header}_right` : right[0][index];
|
||||
})];
|
||||
const rightBuckets = new Map<string, unknown[][]>();
|
||||
for (const row of right.slice(1)) {
|
||||
const key = JSON.stringify(rightIndexes.map((index) => row[index]));
|
||||
if (!rightBuckets.has(key)) rightBuckets.set(key, []);
|
||||
rightBuckets.get(key)!.push(row);
|
||||
}
|
||||
const matchedRight = new Set<unknown[]>();
|
||||
const output: Table = [outputHeaders];
|
||||
for (const row of left.slice(1)) {
|
||||
const matches = rightBuckets.get(JSON.stringify(leftIndexes.map((index) => row[index]))) ?? [];
|
||||
if (matches.length) {
|
||||
for (const match of matches) {
|
||||
matchedRight.add(match);
|
||||
output.push([...row, ...rightDataIndexes.map((index) => match[index])]);
|
||||
}
|
||||
} else if (mode === "left" || mode === "outer") {
|
||||
output.push([...row, ...rightDataIndexes.map(() => null)]);
|
||||
}
|
||||
}
|
||||
if (mode === "right" || mode === "outer") {
|
||||
for (const row of right.slice(1)) {
|
||||
if (matchedRight.has(row)) continue;
|
||||
const leftPart = left[0].map((header) => {
|
||||
const keyPosition = leftKeys.indexOf(text(header));
|
||||
return keyPosition >= 0 ? row[rightIndexes[keyPosition]] : null;
|
||||
});
|
||||
output.push([...leftPart, ...rightDataIndexes.map((index) => row[index])]);
|
||||
}
|
||||
}
|
||||
return output;
|
||||
}
|
||||
register(["Bivartable_MergeDf", "Bivartable_DoMerge"], (_ctx, _node, args) => mergeTables(args));
|
||||
|
||||
function predicate(operator: string, expected: unknown): (actual: unknown) => boolean {
|
||||
const normalized = operator.trim().toLowerCase();
|
||||
if (["2", "!=", "<>", "ne"].includes(normalized)) return (actual) => !equal(actual, expected);
|
||||
if (["3", ">", "gt"].includes(normalized)) return (actual) => Number(actual) > Number(expected);
|
||||
if (["4", ">=", "gte"].includes(normalized)) return (actual) => Number(actual) >= Number(expected);
|
||||
if (["5", "<", "lt"].includes(normalized)) return (actual) => Number(actual) < Number(expected);
|
||||
if (["6", "<=", "lte"].includes(normalized)) return (actual) => Number(actual) <= Number(expected);
|
||||
if (["7", "contains"].includes(normalized)) return (actual) => text(actual).includes(text(expected));
|
||||
if (["8", "not contains"].includes(normalized)) return (actual) => !text(actual).includes(text(expected));
|
||||
if (["9", "starts", "startswith"].includes(normalized)) return (actual) => text(actual).startsWith(text(expected));
|
||||
if (["10", "ends", "endswith"].includes(normalized)) return (actual) => text(actual).endsWith(text(expected));
|
||||
return (actual) => equal(actual, expected);
|
||||
}
|
||||
|
||||
function expressionFilter(rows: Table, expression: string): unknown[][] {
|
||||
const conditions = [...expression.matchAll(/(?:\w+\s*)?\[\s*['"]([^'"]+)['"]\s*]\s*(==|!=|>=|<=|>|<)\s*([^&|]+)/g)];
|
||||
if (!conditions.length) throw new RuntimeError("validation_error", "Unsupported filter expression");
|
||||
const operators = [...expression.matchAll(/\s*([&|])\s*/g)].map((match) => match[1]);
|
||||
return rows.slice(1).filter((row) => {
|
||||
const results = conditions.map((match) => {
|
||||
const index = columnIndex(rows[0], match[1]);
|
||||
return index >= 0 && predicate(match[2], comparable(match[3]))(row[index]);
|
||||
});
|
||||
return results.slice(1).reduce((result, current, index) => operators[index] === "|" ? result || current : result && current, results[0]);
|
||||
});
|
||||
}
|
||||
|
||||
function filterTable(args: Record<string, unknown>): Table {
|
||||
const rows = table(args.df);
|
||||
if (rows.length < 2) return rows;
|
||||
const expression = text(value(args, ["rule", "filter"])).trim();
|
||||
if (expression || text(args.filter_mode).toLowerCase().includes("advanced")) return [rows[0], ...expressionFilter(rows, expression)];
|
||||
const index = columnIndex(rows[0], args.col_name);
|
||||
if (index < 0) throw new RuntimeError("validation_error", `Filter column does not exist: ${text(args.col_name)}`);
|
||||
const matches = predicate(text(args.choice || "1"), parse(args.criteria));
|
||||
return [rows[0], ...rows.slice(1).filter((row) => matches(row[index]))];
|
||||
}
|
||||
register(["Bivartable_FilterDf", "Bivartable_DoFilter"], (_ctx, _node, args) => filterTable(args));
|
||||
@@ -0,0 +1,283 @@
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterAll, describe, expect, it } from "vitest";
|
||||
import { capabilityIds, getHandler } from "../engine/registry";
|
||||
import { RuntimeError } from "../engine/types";
|
||||
import type { CatalogComponent, FlowNode } from "../types";
|
||||
import type { RunContext } from "../engine/types";
|
||||
import "./data";
|
||||
import "./data-extended";
|
||||
import "./files";
|
||||
import "./control";
|
||||
|
||||
const ctx = {
|
||||
signal: new AbortController().signal,
|
||||
log: () => {},
|
||||
vars: {} as Record<string, unknown>,
|
||||
evidenceDir: "D:/office-test2",
|
||||
runId: "smoke-run",
|
||||
flow: { id: "smoke-flow", name: "冒烟", description: "", enabled: true, trigger: null, meta: { appVersion: "dev", createdAt: "", updatedAt: "" }, nodes: [] }
|
||||
} as unknown as RunContext;
|
||||
|
||||
const node = { id: "smoke-data", componentId: "smoke", position: { x: 0, y: 0 }, values: {}, options: {} } as unknown as FlowNode;
|
||||
|
||||
async function run(componentId: string, args: Record<string, unknown>, context?: RunContext): Promise<unknown> {
|
||||
const handler = getHandler(componentId);
|
||||
if (!handler) throw new Error(`组件 ${componentId} 未注册运行时`);
|
||||
return handler(context ?? ctx, { ...node, componentId } as unknown as FlowNode, args, {} as CatalogComponent);
|
||||
}
|
||||
|
||||
const sandbox = mkdtempSync(path.join(tmpdir(), "rpa-data-smoke-"));
|
||||
afterAll(() => { rmSync(sandbox, { recursive: true, force: true }); });
|
||||
|
||||
describe("数据处理/文件/编排 冒烟", () => {
|
||||
it("字符串族 StrUtil", async () => {
|
||||
expect(await run("StrUtil_Len", { string: "你好world" })).toBe(7);
|
||||
expect(await run("StrUtil_InStr", { string: "hello", search: "ll" })).toBe(2);
|
||||
expect(await run("StrUtil_Replace", { string: "a-b-c", search: "-", replace: "+" })).toBe("a+b+c");
|
||||
expect(await run("StrUtil_LowerCase", { string: "AbC" })).toBe("abc");
|
||||
expect(await run("StrUtil_UpperCase", { string: "AbC" })).toBe("ABC");
|
||||
expect(await run("StrUtil_CaseChange", { string: "abc", case: "首字母大写" })).toBe("Abc");
|
||||
expect(await run("StrUtil_CaseChange", { string: "abc", case: "1" })).toBe("ABC");
|
||||
expect(await run("StrUtil_CaseChange", { string: "ABC", case: "2" })).toBe("abc");
|
||||
expect(await run("StrUtil_Trim", { string: " x " })).toBe("x");
|
||||
expect(await run("StrUtil_Left", { string: "hello", length: 2 })).toBe("he");
|
||||
expect(await run("StrUtil_Right", { string: "hello", length: 2 })).toBe("lo");
|
||||
expect(await run("StrUtil_Mid", { string: "hello", start: 1, length: 3 })).toBe("ell");
|
||||
expect(await run("StrUtil_CutOutStrV1", { string: "a[内容]b", start_cut_pos: "a[", end: "]" })).toContain("内容");
|
||||
expect(await run("StrUtil_Split", { string: "a,b,c", spliter: "," })).toEqual(["a", "b", "c"]);
|
||||
expect(await run("StrUtil_SplitStr", { string: "a; b ;c", diy_symbol: ";" })).toEqual(["a", "b", "c"]);
|
||||
expect(await run("StrUtil_AddToStr", { string: "尾", add_str: "头", add_type: "添加到前面", symbol: "-" })).toBe("头-尾");
|
||||
expect(await run("StrUtil_ReverseStr", { string: "ab cd", letter_reverse: true })).toBe("dc ba");
|
||||
expect(await run("StrUtil_ListToString", { str_list: ["a", "b"], connectors_custom: "|" })).toBe("a|b");
|
||||
expect(await run("StrUtil_CompleteStr", { string: "7", sum_length: 3, com_string: "0", com_type: "左边补" })).toBe("007");
|
||||
expect(await run("StrUtil_CharCode", { code: 65 })).toBe("A");
|
||||
expect(await run("StrUtil_CharAsc", { char: "A" })).toBe(65);
|
||||
expect(await run("StrUtil_CreateRandomStr", { string_length: 12 })).toHaveLength(12);
|
||||
console.log("[字符串族] 20 项通过");
|
||||
});
|
||||
|
||||
it("列表与集合族 List/Collection", async () => {
|
||||
expect(await run("List_CreateList", { data: JSON.stringify([1, 2]) })).toEqual([1, 2]);
|
||||
expect(await run("List_ListLength", { arrayRet: [1, 2, 3] })).toBe(3);
|
||||
expect(await run("List_InsertList", { arrayRet: ["a", "c"], data: "b", insertType: "指定位置", position: 1 })).toEqual(["a", "b", "c"]);
|
||||
expect(await run("List_DeleteList", { arrayRet: ["a", "b", "c"], data: "b" })).toEqual(["a", "c"]);
|
||||
expect(await run("List_DeleteList", { arrayRet: ["a", "b", "c"], deleteType: "按位置", position: 0 })).toEqual(["b", "c"]);
|
||||
expect(await run("List_GetListItem", { arrayRet: [10, 20], position: 1 })).toBe(20);
|
||||
expect(await run("List_SetListItem", { arrayRet: [1, 2], data: 9, position: 0 })).toEqual([9, 2]);
|
||||
expect(await run("List_GetIndex", { arrayRet: ["x", "y"], data: "y" })).toBe(1);
|
||||
expect(await run("List_ListMerge", { arrayRet1: [1], arrayRet2: [2, 3] })).toEqual([1, 2, 3]);
|
||||
expect(await run("List_ListDuplication", { arrayRet: [1, 1, 2] })).toEqual([1, 2]);
|
||||
expect(await run("List_ListSorted", { arrayRet: [3, 1, 2] })).toEqual([1, 2, 3]);
|
||||
expect(await run("List_ListIntersection", { arrayRet1: [1, 2, 3], arrayRet2: [2, 3, 4] })).toEqual([2, 3]);
|
||||
expect(await run("List_ClearList", {})).toEqual([]);
|
||||
expect(await run("Collection_Create", {})).toEqual([]);
|
||||
const withItem = await run("Collection_Add", { collection: [], value: "a" }) as unknown[];
|
||||
expect(withItem).toEqual(["a"]);
|
||||
expect(await run("Collection_HasItem", { collection: ["a"], item: "a" })).toBe(true);
|
||||
expect(await run("Collection_HasItem", { collection: ["a"], item: "z" })).toBe(false);
|
||||
expect(await run("Collection_Count", { collection: ["a", "b"] })).toBe(2);
|
||||
expect(await run("Collection_Remove", { collection: ["a", "b"], value: "a" })).toEqual(["b"]);
|
||||
console.log("[列表/集合族] 18 项通过");
|
||||
});
|
||||
|
||||
it("字典族 Dict", async () => {
|
||||
expect(await run("Dict_Create", { value: JSON.stringify({ a: 1 }) })).toEqual({ a: 1 });
|
||||
expect(await run("Dict_SetItem", { dictObj: { a: 1 }, key: "b", value: 2 })).toEqual({ a: 1, b: 2 });
|
||||
expect(await run("Dict_GetItem", { dictObj: { a: 1 }, key: "a" })).toBe(1);
|
||||
expect(await run("Dict_GetItem", { dictObj: {}, key: "x", default_value: "dft" })).toBe("dft");
|
||||
expect(await run("Dict_Keys", { dictObj: { a: 1, b: 2 } })).toEqual(["a", "b"]);
|
||||
expect(await run("Dict_Values", { dictObj: { a: 1, b: 2 } })).toEqual([1, 2]);
|
||||
expect(await run("Dict_Items", { dictObj: { a: 1 } })).toEqual([["a", 1]]);
|
||||
expect(await run("Dict_Delete", { dictObj: { a: 1, b: 2 }, key: "a" })).toEqual({ b: 2 });
|
||||
expect(await run("Dict_Update", { dictObj: { a: 1 }, targetDict: { b: 2 } })).toEqual({ a: 1, b: 2 });
|
||||
expect(await run("Dict_Clear", {})).toEqual({});
|
||||
console.log("[字典族] 10 项通过");
|
||||
});
|
||||
|
||||
it("数学族 Math 与正则族 Regex", async () => {
|
||||
expect(await run("Math_Abs", { data: -3.5 })).toBe(3.5);
|
||||
expect(await run("Math_Round", { data: 3.14159, digit: 2 })).toBe(3.14);
|
||||
expect(await run("Math_Int", { data: 3.2, position: "向上取整" })).toBe(4);
|
||||
expect(await run("Math_Sqrt", { data: 16 })).toBe(4);
|
||||
expect(await run("Math_Pow", { x: 2, y: 10 })).toBe(1024);
|
||||
expect(await run("Math_Sgn", { data: -9 })).toBe(-1);
|
||||
expect(await run("Math_Exp", { data: 0 })).toBe(1);
|
||||
expect(await run("Math_Hex", { num: 255 })).toBe("FF");
|
||||
expect(await run("Math_Oct", { num: 8 })).toBe("10");
|
||||
expect(await run("Math_Scale", { sourceNum: "1010", sourceNumScale: 2, targetNumScale: 10 })).toBe("10");
|
||||
expect(await run("Math_Sin", { data: 90, unit: "角度" })).toBeCloseTo(1, 5);
|
||||
expect(await run("Regex_Test", { text: "abc123", pattern: "\\d+" })).toBe(true);
|
||||
expect(await run("Regex_TestNew", { text: "abc", pattern: "\\d" })).toBe(false);
|
||||
expect(await run("Regex_FindAll", { text: "a1b22c333", pattern: "\\d+" })).toEqual(["1", "22", "333"]);
|
||||
expect(await run("Regex_FindStr", { text: "订单A-100,订单B-200", pattern: "订单[AB]-(\\d+)", index: 1 })).toBe("100");
|
||||
expect(await run("Regex_FindAllNew", { text: "2026-11-03", pattern: "\\d+" })).toEqual(["2026", "11", "03"]);
|
||||
console.log("[数学/正则族] 16 项通过");
|
||||
});
|
||||
|
||||
it("时间族 Time", async () => {
|
||||
expect(String(await run("Time_Now", {}))).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/);
|
||||
expect(String(await run("Time_Date", {}))).toMatch(/^\d{4}-\d{2}-\d{2}$/);
|
||||
expect(String(await run("Time_Time", {}))).toMatch(/^\d{2}:\d{2}:\d{2}$/);
|
||||
const ts = await run("Time_TimeStamp", {}) as number;
|
||||
expect(ts).toBeGreaterThan(1_700_000_000);
|
||||
expect(await run("Time_Format", { timestamp: 1767000000, format: "YYYY-MM-DD" })).toMatch(/^\d{4}-\d{2}-\d{2}$/);
|
||||
expect(await run("Time_DatetimeToTimestamp", { datetime: "2026-01-01 00:00:00" })).toBe(typeof 1_767_225_600 === "number" ? Math.floor(new Date("2026-01-01 00:00:00").getTime() / 1000) : 0);
|
||||
expect(await run("Time_Year", { datetime: "2026-11-03 10:20:30" })).toBe(2026);
|
||||
expect(await run("Time_Month", { datetime: "2026-11-03 10:20:30" })).toBe(11);
|
||||
expect(await run("Time_Day", { datetime: "2026-11-03 10:20:30" })).toBe(3);
|
||||
expect(await run("Time_Hour", { datetime: "2026-11-03 10:20:30" })).toBe(10);
|
||||
expect(await run("Time_Minute", { datetime: "2026-11-03 10:20:30" })).toBe(20);
|
||||
expect(await run("Time_Second", { datetime: "2026-11-03 10:20:30" })).toBe(30);
|
||||
expect(typeof (await run("Time_WeekDay", { datetime: "2026-11-03" }))).toBe("number");
|
||||
expect(await run("Time_DateOffset_V1", { date_input: "2026-11-01", offset_unit_num: 2, offset_status: "加", get_type: "YYYY-MM-DD" })).toBe("2026-11-03");
|
||||
expect(await run("Time_CalDate", { date_start: "2026-11-01", date_end: "2026-11-03", time_type: "天" })).toBeCloseTo(2, 5);
|
||||
expect(await run("Time_TimeTypeConversion", { datetime: "2026-11-03 08:00:00", format: "YYYY/MM/DD" })).toBe("2026/11/03");
|
||||
console.log("[时间族] 15 项通过");
|
||||
});
|
||||
|
||||
it("JSON 与 CSV 读写回环", async () => {
|
||||
expect(await run("JSON_Stringify", { string: { a: 1 } })).toBe('{"a":1}');
|
||||
expect(await run("JSON_Parse", { string: '{"a":1}' })).toEqual({ a: 1 });
|
||||
expect(await run("JSON_Parse", { string: "bad" })).toBeNull();
|
||||
const csvPath = path.join(sandbox, "t.csv");
|
||||
await run("CSV_WriteNew", { path: csvPath, table: [["名", "值"], ["a,b", "含\"引号\""], ["c", "d"]] });
|
||||
const back = await run("CSV_ReadNew", { path: csvPath }) as string[][];
|
||||
expect(back[0]).toEqual(["名", "值"]);
|
||||
expect(back[1]).toEqual(["a,b", '含"引号"']);
|
||||
console.log("[JSON/CSV] 通过(含引号逗号转义回环)");
|
||||
});
|
||||
|
||||
it("Ini 族完整生命周期", async () => {
|
||||
const iniPath = path.join(sandbox, "config.ini");
|
||||
await run("Ini_Write", { file: iniPath, section: "server", key: "host", value: "127.0.0.1" });
|
||||
await run("Ini_Write", { file: iniPath, section: "server", key: "port", value: "8080" });
|
||||
await run("Ini_Write", { file: iniPath, section: "log", key: "level", value: "info" });
|
||||
expect(await run("Ini_Read", { file: iniPath, section: "server", key: "host" })).toBe("127.0.0.1");
|
||||
expect(await run("Ini_ListSection", { file: iniPath })).toEqual(["server", "log"]);
|
||||
expect(await run("Ini_ListKey", { file: iniPath, section: "server" })).toEqual(["host", "port"]);
|
||||
expect(await run("Ini_DeleteKey", { file: iniPath, section: "server", key: "port" })).toBe(true);
|
||||
expect(await run("Ini_Read", { file: iniPath, section: "server", key: "port" })).toBe("");
|
||||
expect(await run("Ini_DeleteSection", { file: iniPath, section: "log" })).toBe(true);
|
||||
expect(await run("Ini_ListSection", { file: iniPath })).toEqual(["server"]);
|
||||
console.log("[Ini族] 写读列举删除全链通过");
|
||||
});
|
||||
|
||||
it("文件族全生命周期(沙盒目录)", async () => {
|
||||
const dir = path.join(sandbox, "files");
|
||||
const created = await run("File_CreateFile", { folder_path: dir, name: "a.txt" }) as string;
|
||||
expect(created).toContain("a.txt");
|
||||
await run("File_Write", { path: created, content: "第一行" });
|
||||
await run("File_Write", { path: created, content: "第二行", append: true });
|
||||
expect(await run("File_Read", { path: created })).toBe("第一行第二行");
|
||||
expect(await run("File_FileExists", { path: created })).toBe(true);
|
||||
expect(await run("File_PathExists", { path: path.join(dir, "nope.txt") })).toBe(false);
|
||||
expect(await run("File_PathSize", { path: created })).toBeGreaterThan(0);
|
||||
const info = await run("File_GetFileInfo", { file_path: created }) as { name: string; size: number };
|
||||
expect(info.name).toBe("a.txt");
|
||||
expect(info.size).toBeGreaterThan(0);
|
||||
const copy = path.join(dir, "b.txt");
|
||||
await run("File_Copy", { pathSrc: created, pathDest: copy });
|
||||
expect(await run("File_Read", { path: copy })).toBe("第一行第二行");
|
||||
const sub = path.join(dir, "sub");
|
||||
await run("File_CreateFolder", { path: sub });
|
||||
await run("File_Copy", { pathSrc: copy, pathDest: path.join(sub, "c.txt") });
|
||||
const flat = await run("File_GetFileList", { folder_path: dir }) as string[];
|
||||
expect(flat.length).toBe(2);
|
||||
const all = await run("File_GetFileList", { folder_path: dir, recursive: true }) as string[];
|
||||
expect(all.length).toBe(3);
|
||||
const kw = await run("File_GetFileList", { folder_path: dir, key_word: "a." }) as string[];
|
||||
expect(kw.length).toBe(1);
|
||||
const moved = await run("File_Move", { pathSrc: copy, pathDest: path.join(dir, "moved.txt") });
|
||||
expect(String(moved)).toContain("moved.txt");
|
||||
expect(await run("File_FileExists", { path: copy })).toBe(false);
|
||||
const renamed = await run("File_Rename", { pathSrc: moved, name: "renamed.txt" });
|
||||
expect(String(renamed)).toContain("renamed.txt");
|
||||
const zip = await run("File_CompressFile", { pathSrc: created, pathDest: path.join(sandbox, "pack.zip"), direct_type: "0" });
|
||||
expect(String(zip)).toContain("pack.zip");
|
||||
const unzipDir = path.join(sandbox, "unpacked");
|
||||
await run("File_DecompressFile", { path: path.join(sandbox, "pack.zip"), target: unzipDir, direct_type: "0" });
|
||||
expect(await run("File_FileExists", { path: path.join(unzipDir, "a.txt") })).toBe(true);
|
||||
expect(await run("File_WaitFile", { file_path: created, time_out: 1 })).toBe(true);
|
||||
await expect(run("File_WaitFile", { file_path: path.join(dir, "ghost.txt"), time_out: 1 })).rejects.toThrow(/超时/);
|
||||
await run("File_Delete", { file_path: renamed });
|
||||
expect(await run("File_FileExists", { path: renamed })).toBe(false);
|
||||
console.log("[文件族] 建/写/读/拷/列举/移/改名/压缩解压/等待/删除 全链通过");
|
||||
}, 60000);
|
||||
|
||||
it("数据表族 Bivartable", async () => {
|
||||
const table = [["部门", "姓名", "工资"], ["技术", "张三", 100], ["销售", "李四", 80], ["技术", "王五", 90], ["销售", "李四", 80]];
|
||||
const bodyOnly = [["技术", "张三", 100], ["销售", "李四", 80], ["技术", "王五", 90], ["销售", "李四", 80]];
|
||||
const withHeader = await run("Bivartable_DoCreate", { data: JSON.stringify(bodyOnly), columnList: JSON.stringify(["部门", "姓名", "工资"]) }) as unknown[][];
|
||||
expect(withHeader[0]).toEqual(["部门", "姓名", "工资"]);
|
||||
expect(withHeader.length).toBe(5);
|
||||
expect(await run("Bivartable_GetSumValue", { df: table, sumvaluecols: JSON.stringify(["工资"]) })).toBe(350);
|
||||
expect(await run("Bivartable_GetMaxValue", { df: table, maxvaluecols: JSON.stringify(["工资"]) })).toBe(100);
|
||||
expect(await run("Bivartable_GetMinValue", { df: table, minvaluecols: JSON.stringify(["工资"]) })).toBe(80);
|
||||
expect(await run("Bivartable_GetAvgValue", { df: table, avgvaluecols: JSON.stringify(["工资"]) })).toBe(87.5);
|
||||
const grouped = await run("Bivartable_GetSumValue", { df: table, sumvaluecols: JSON.stringify(["工资"]), groupby: JSON.stringify(["部门"]) }) as unknown[][];
|
||||
expect(grouped[0]).toEqual(["部门", "工资"]);
|
||||
expect(grouped).toContainEqual(["技术", 190]);
|
||||
expect(grouped).toContainEqual(["销售", 160]);
|
||||
const filtered = await run("Bivartable_DoFilter", { df: table, col_name: "部门", choice: "1", criteria: "技术" }) as unknown[][];
|
||||
expect(filtered.length).toBe(3);
|
||||
const expr = await run("Bivartable_DoFilter", { df: table, rule: '["工资"] > 85' }) as unknown[][];
|
||||
expect(expr.length).toBe(3);
|
||||
const dedup = await run("Bivartable_DeleteDuplicateRow", { df: table }) as unknown[][];
|
||||
expect(dedup.length).toBe(4);
|
||||
console.log("[数据表族] 创建/聚合(含分组)/过滤(简单+表达式)/去重 通过");
|
||||
});
|
||||
|
||||
it("编排控制与日志", async () => {
|
||||
const varsCtx = { ...ctx, vars: {} } as unknown as RunContext;
|
||||
expect(await run("logic_control_var", { var_name: "k1", var_value: "v1" }, varsCtx)).toBe("v1");
|
||||
expect((varsCtx as unknown as { vars: Record<string, unknown> }).vars.k1).toBe("v1");
|
||||
await expect(run("logic_control_var", {})).rejects.toThrow(/变量名称/);
|
||||
await expect(run("logic_control_break_for", {})).rejects.toBeTruthy();
|
||||
await expect(run("logic_control_next_for", {})).rejects.toBeTruthy();
|
||||
await expect(run("logic_control_stop", {})).rejects.toBeTruthy();
|
||||
await expect(run("logic_control_stop_return", {})).rejects.toBeTruthy();
|
||||
expect(await run("Trigger.Manual", {})).toBeNull();
|
||||
expect(await run("Trigger.Schedule", {})).toBeNull();
|
||||
expect(await run("Trigger.FileArrival", {})).toBeNull();
|
||||
const logs: Array<{ m: string; lv: string }> = [];
|
||||
const logCtx = { ...ctx, log: (m: string, lv: string) => logs.push({ m, lv }) } as unknown as RunContext;
|
||||
expect(await run("Log_Info", { msg: "提示信息" }, logCtx)).toBe("提示信息");
|
||||
expect(await run("Log_Error", { msg: "错误信息" }, logCtx)).toBe("错误信息");
|
||||
expect(logs.some((l) => l.lv === "error")).toBe(true);
|
||||
await expect(run("ExceptionUtil_Throws", { msg: "自定义异常" })).rejects.toThrow(/自定义异常/);
|
||||
console.log("[编排/日志] 变量/循环控制信号/触发器/日志/自定义异常 通过");
|
||||
});
|
||||
|
||||
it("全量健康检查: 已注册组件空参调用不产生非结构化崩溃", async () => {
|
||||
const ids: string[] = capabilityIds();
|
||||
const skip = new Set([
|
||||
"FTP_Connect", "FTP_Upload", "FTP_Download", "FTP_Delete", "FTP_Rename", "FTP_Cwd", "FTP_GetFileList", "FTP_CreateDir", "FTP_Exist", "FTP_Disconnect",
|
||||
"Database_DBConnect", "Database_SQLExecute", "Database_SingleSQLQuery", "Database_AllSQLQuery_V1", "Database_BatchSQLExecute", "Database_BatchInsertExecute", "Database_CloseDBConnect",
|
||||
"File_OpenFile", "File_StartBusiness", "File_UploadBusinessResults", "File_UploadBusinessResults_v1", "File_Collectupload",
|
||||
"File_Delete", "File_DeleteFile", "File_EmptyFolder", "File_WaitFile",
|
||||
"logic_control_break_for", "logic_control_next_for", "logic_control_stop", "logic_control_stop_return",
|
||||
"child_modules_v1", "concurrent_child_process_v1", "Element_exception_monitor_v2", "logic_control_if_ai_vision"
|
||||
]);
|
||||
const crashes: Array<{ id: string; error: string }> = [];
|
||||
let checked = 0;
|
||||
for (const id of ids) {
|
||||
const handler = getHandler(id);
|
||||
if (!handler || skip.has(id)) continue;
|
||||
checked += 1;
|
||||
try {
|
||||
await handler(ctx, { ...node, componentId: id } as unknown as FlowNode, {}, {} as CatalogComponent);
|
||||
} catch (error) {
|
||||
if (!(error instanceof RuntimeError)) {
|
||||
crashes.push({ id, error: `${(error as Error).name}: ${(error as Error).message}` });
|
||||
}
|
||||
}
|
||||
}
|
||||
console.log(`[全量健康检查] 空参调用 ${checked} 个已注册组件, 结构化报错全部正常, 非结构化崩溃 ${crashes.length} 个`);
|
||||
if (crashes.length) console.log(crashes.map((c) => `${c.id}: ${c.error}`).join("\n"));
|
||||
expect(crashes).toEqual([]);
|
||||
}, 120000);
|
||||
});
|
||||
|
||||
|
||||
@@ -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 "../types";
|
||||
import type { RunContext } from "../engine/types";
|
||||
import { getHandler } from "../engine/registry";
|
||||
import "./data-table-common";
|
||||
|
||||
const dirs: string[] = [];
|
||||
const context = { vars: {} } as RunContext;
|
||||
const component = {} as CatalogComponent;
|
||||
const node = (id: string): FlowNode => ({ id, componentId: id, position: { x: 0, y: 0 }, values: {}, options: {} });
|
||||
async function run(id: string, args: Record<string, unknown> = {}) { const handler = getHandler(id); expect(handler, id).toBeTruthy(); return handler!(context, node(id), args, component); }
|
||||
afterEach(async () => { context.vars = {}; await Promise.all(dirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))); });
|
||||
|
||||
describe("common data table capabilities", () => {
|
||||
it("reads, writes, calculates and joins columns", async () => {
|
||||
const source = [["A", "B"], [1, 2], [3, 4]];
|
||||
await expect(run("Bivartable_ReadDfData", { df: source, readRange: "cell", row_num: 2, col_name: "B" })).resolves.toBe(2);
|
||||
await expect(run("Bivartable_ColumnOperationDf", { df: source, name1: "A", name2: "B", operator: "+", name: "Total" })).resolves.toEqual([["A", "B", "Total"], [1, 2, 3], [3, 4, 7]]);
|
||||
await expect(run("Bivartable_DoJoint", { df: source, cola: "A", colb: "B", colnew: "AB" })).resolves.toEqual([["A", "B", "AB"], [1, 2, "12"], [3, 4, "34"]]);
|
||||
});
|
||||
|
||||
it("fills missing values and modifies column names", async () => {
|
||||
await expect(run("Bivartable_HandleDfMissValue", { df: [["A"], [null], [2]], handleType: "1", value: 0 })).resolves.toEqual([["A"], [0], [2]]);
|
||||
await expect(run("Bivartable_ModifyColName", { df: [["A"], [1]], srcname: "A", newname: "Value" })).resolves.toEqual([["Value"], [1]]);
|
||||
});
|
||||
|
||||
it("converts column types and builds a pivot table", async () => {
|
||||
await expect(run("Bivartable_Dtype", { df: [["id", "amount"], ["1", "2.5"]], change_type: "name", col_name: ["id", "amount"], dtype: "float" })).resolves.toEqual([["id", "amount"], [1, 2.5]]);
|
||||
const source = [["team", "month", "amount"], ["A", "Jan", 2], ["A", "Feb", 3], ["B", "Jan", 4]];
|
||||
await expect(run("Bivartable_PivotDf", { df: source, row_list: ["team"], col_list: ["month"], operator: { amount: "sum" }, value: 0 })).resolves.toEqual([["team", "Jan", "Feb"], ["A", 2, 3], ["B", 4, 0]]);
|
||||
});
|
||||
|
||||
it("exports and imports Excel and databook metadata", async () => {
|
||||
const directory = await mkdtemp(path.join(os.tmpdir(), "rpa-table-common-")); dirs.push(directory); const file = path.join(directory, "table.xlsx");
|
||||
await run("Bivartable_DoSave", { df: [["A", "B"], [1, 2]], filePath: file });
|
||||
await expect(run("Bivartable_DoOpenExcel", { filePath: file, sheetName: "Sheet1" })).resolves.toEqual([["A", "B"], [1, 2]]);
|
||||
await run("Databook_Import", { excel_path: file, sheet_name: "Sheet1" });
|
||||
await run("Databook_SetColumnInfo", { column_name: "A", column_description: "编号" });
|
||||
await expect(run("Databook_GetColumnInfo", { column_name: "A" })).resolves.toBe("编号");
|
||||
await expect(run("Databook_Export", { export_path: directory, export_name: "book.xlsx" })).resolves.toBe(path.join(directory, "book.xlsx"));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,172 @@
|
||||
import ExcelJS from "exceljs";
|
||||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { register } from "../engine/registry";
|
||||
import { RuntimeError } from "../engine/types";
|
||||
|
||||
type Table = unknown[][];
|
||||
const BOOK = "__rpaDatabook";
|
||||
const META = "__rpaDatabookColumns";
|
||||
|
||||
function parse(value: unknown): unknown {
|
||||
if (typeof value !== "string") return value;
|
||||
try { return JSON.parse(value); } catch { return value; }
|
||||
}
|
||||
function table(value: unknown): Table {
|
||||
const source = parse(value);
|
||||
if (!Array.isArray(source)) return source == null || source === "" ? [] : [[source]];
|
||||
return source.map((row) => Array.isArray(row) ? [...row] : [row]);
|
||||
}
|
||||
function list(value: unknown): unknown[] {
|
||||
const source = parse(value);
|
||||
if (Array.isArray(source)) return source;
|
||||
if (source == null || source === "") return [];
|
||||
return String(source).split(/[,,;;\n]/).map((item) => item.trim()).filter(Boolean);
|
||||
}
|
||||
function index(headers: unknown[], value: unknown): number {
|
||||
const source = String(value ?? "A").trim();
|
||||
const named = headers.findIndex((header) => String(header) === source);
|
||||
if (named >= 0) return named;
|
||||
if (/^\d+$/.test(source)) return Math.max(0, Number(source) - 1);
|
||||
if (/^[A-Za-z]+$/.test(source)) {
|
||||
let result = 0; for (const char of source.toUpperCase()) result = result * 26 + char.charCodeAt(0) - 64;
|
||||
return Math.max(0, result - 1);
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
function number(value: unknown): number { const result = Number(value); return Number.isFinite(result) ? result : 0; }
|
||||
function required(value: unknown, label: string, nodeId: string, componentId: string): string {
|
||||
const result = String(value ?? "").trim(); if (!result) throw new RuntimeError("validation_error", `${label}为空`, { nodeId, componentId }); return result;
|
||||
}
|
||||
function book(ctx: { vars: Record<string, unknown> }): Table {
|
||||
const current = table(ctx.vars[BOOK]); ctx.vars[BOOK] = current; return current;
|
||||
}
|
||||
function calculate(left: unknown, right: unknown, operator: string): unknown {
|
||||
if (operator === "+" && (typeof left === "string" || typeof right === "string")) return `${left ?? ""}${right ?? ""}`;
|
||||
const a = number(left); const b = number(right);
|
||||
if (operator === "-") return a - b; if (operator === "*") return a * b; if (operator === "/") return b === 0 ? null : a / b; if (operator === "%") return b === 0 ? null : a % b;
|
||||
return a + b;
|
||||
}
|
||||
|
||||
const readData = (_ctx: unknown, _node: unknown, args: Record<string, unknown>) => {
|
||||
const source = table(args.df); if (!source.length) return null;
|
||||
const headers = source[0]; const mode = String(args.readRange ?? "cell").toLowerCase();
|
||||
const row = Math.max(1, Number(args.row_num ?? args.row_name ?? 1)); const column = index(headers, args.col_name ?? args.col_num ?? "A");
|
||||
if (mode.includes("row")) return source[row - 1] ?? [];
|
||||
if (mode.includes("col")) return source.map((item) => item[column]);
|
||||
if (mode.includes("range")) {
|
||||
const endRow = Math.max(row, Number(args.appoint_row_num ?? row)); const endColumn = index(headers, args.appoint_col_name ?? args.appoint_col_num ?? column + 1);
|
||||
return source.slice(row - 1, endRow).map((item) => item.slice(column, endColumn + 1));
|
||||
}
|
||||
return source[row - 1]?.[column];
|
||||
};
|
||||
register(["Bivartable_ReadDfData", "Bivartable_ReadDfData_V1"], readData);
|
||||
|
||||
register(["Bivartable_WriteDfData", "Bivartable_WriteTable"], (_ctx, _node, args) => {
|
||||
const source = table(args.df); if (!source.length) source.push([]);
|
||||
const headers = source[0]; const row = Math.max(1, Number(args.row_num ?? args.row ?? 1)); const column = index(headers, args.col_name ?? args.column ?? "A");
|
||||
while (source.length < row) source.push([]); while (source[row - 1].length <= column) source[row - 1].push(null);
|
||||
source[row - 1][column] = parse(args.data);
|
||||
return source;
|
||||
});
|
||||
|
||||
register("Bivartable_InsertDfData", (_ctx, _node, args) => {
|
||||
const source = table(args.df); const direction = String(args.writeRange ?? "row").toLowerCase(); const data = list(args.data);
|
||||
if (direction.includes("col")) {
|
||||
const at = Math.max(0, Number(args.col_num ?? 1) - 1); for (let row = 0; row < Math.max(source.length, data.length); row += 1) { if (!source[row]) source[row] = []; source[row].splice(at, 0, data[row] ?? null); }
|
||||
} else source.splice(Math.max(0, Number(args.row_num ?? 1) - 1), 0, data);
|
||||
return source;
|
||||
});
|
||||
|
||||
register("Bivartable_HandleDfMissValue", (_ctx, _node, args) => {
|
||||
const source = table(args.df); const fill = args.value ?? ""; const remove = String(args.handleType ?? "1") === "2";
|
||||
if (remove) return source.filter((row, rowIndex) => rowIndex === 0 || !row.some((cell) => cell === null || cell === undefined || cell === ""));
|
||||
return source.map((row, rowIndex) => rowIndex === 0 ? row : row.map((cell) => cell === null || cell === undefined || cell === "" ? fill : cell));
|
||||
});
|
||||
|
||||
register(["Bivartable_ColumnOperationDf", "Bivartable_DoCal"], (_ctx, _node, args) => {
|
||||
const source = table(args.df); if (!source.length) return source; const headers = source[0];
|
||||
const left = index(headers, args.name1 ?? args.cola); const right = index(headers, args.name2 ?? args.colb); const name = String(args.name ?? args.colnew ?? "结果"); const operator = String(args.operator ?? args.cal ?? "+");
|
||||
const result: Table = [[...headers, name]]; for (const row of source.slice(1)) result.push([...row, calculate(row[left], row[right], operator)]); return result;
|
||||
});
|
||||
|
||||
function columnIndexOf(candidate: unknown): number {
|
||||
const text = String(candidate ?? "").trim();
|
||||
if (/^\d+$/.test(text)) return Number(text) - 1;
|
||||
let result = 0;
|
||||
for (const char of text.toUpperCase()) result = result * 26 + (char.charCodeAt(0) - 64);
|
||||
return result - 1;
|
||||
}
|
||||
|
||||
register(["Bivartable_SpliceColData", "Bivartable_DoJoint"], (_ctx, _node, args) => {
|
||||
const source = table(args.df); if (!source.length) return source; const headers = source[0];
|
||||
// catalog 字段:change_type(name/num) + col_name_1/2 或 col_num_1/2;兼容旧键 col1/col2
|
||||
const byName = String(args.change_type ?? "name") !== "num";
|
||||
const left = byName ? index(headers, args.col_name_1 ?? args.col1 ?? args.cola) : columnIndexOf(args.col_num_1);
|
||||
const right = byName ? index(headers, args.col_name_2 ?? args.col2 ?? args.colb) : columnIndexOf(args.col_num_2);
|
||||
const name = String(args.new_col_name ?? args.name ?? args.colnew ?? "拼接结果"); const separator = String(args.connector ?? args.separator ?? "");
|
||||
return [[...headers, name], ...source.slice(1).map((row) => [...row, `${row[left] ?? ""}${separator}${row[right] ?? ""}`])];
|
||||
});
|
||||
|
||||
register("Bivartable_ModifyColName", (_ctx, _node, args) => {
|
||||
const source = table(args.df); if (!source.length) return source; const at = index(source[0], args.srcname); if (at < 0) return source; source[0][at] = args.newname; return source;
|
||||
});
|
||||
register("Bivartable_ModifyDfName", (_ctx, _node, args) => {
|
||||
const source = table(args.df); const names = list(args.new_col_name_list); if (source.length && names.length) source[0] = names; return source;
|
||||
});
|
||||
register("Bivartable_ReadDfIndexColumns", (_ctx, _node, args) => ({ columns: table(args.df)[0] || [], index: table(args.df).slice(1).map((_row, indexValue) => indexValue + 1) }));
|
||||
|
||||
register("Bivartable_Dtype", (_ctx, _node, args) => {
|
||||
const source = table(args.df); if (!source.length) return source;
|
||||
const columns = (String(args.change_type ?? "num") === "name" ? list(args.col_name) : list(args.col_num)).map((item) => index(source[0], item)).filter((item) => item >= 0);
|
||||
const dtype = String(args.dtype ?? "str").toLowerCase();
|
||||
const convert = (current: unknown) => {
|
||||
if (dtype.includes("int")) return Math.trunc(number(current));
|
||||
if (dtype.includes("float") || dtype.includes("number")) return number(current);
|
||||
if (dtype.includes("bool")) return [true, 1, "1", "true", "True", "是"].includes(current as never);
|
||||
return current == null ? "" : String(current);
|
||||
};
|
||||
return [source[0], ...source.slice(1).map((row) => row.map((cell, column) => columns.includes(column) ? convert(cell) : cell))];
|
||||
});
|
||||
|
||||
register("Bivartable_PivotDf", (_ctx, _node, args) => {
|
||||
const source = table(args.df); if (source.length < 2) return source;
|
||||
const headers = source[0]; const rowNames = list(args.row_list).map(String); const columnNames = list(args.col_list).map(String);
|
||||
let operator = parse(args.operator); let valueName = ""; let aggregation = "sum";
|
||||
if (operator && typeof operator === "object" && !Array.isArray(operator)) {
|
||||
const entries = Object.entries(operator as Record<string, unknown>); valueName = entries[0]?.[0] || ""; aggregation = String(entries[0]?.[1] || "sum");
|
||||
} else valueName = String(operator || "");
|
||||
if (!valueName) valueName = String(headers.find((header) => !rowNames.includes(String(header)) && !columnNames.includes(String(header))) || headers.at(-1));
|
||||
const rowIndexes = rowNames.map((name) => index(headers, name)); const columnIndexes = columnNames.map((name) => index(headers, name)); const valueIndex = index(headers, valueName);
|
||||
const columnKeys = [...new Set(source.slice(1).map((row) => columnIndexes.map((column) => String(row[column] ?? "")).join(" / ")))].filter(Boolean);
|
||||
const groups = new Map<string, { rows: unknown[]; values: Map<string, number[]> }>();
|
||||
for (const row of source.slice(1)) {
|
||||
const rowValues = rowIndexes.map((column) => row[column]); const rowKey = JSON.stringify(rowValues); const columnKey = columnIndexes.map((column) => String(row[column] ?? "")).join(" / ");
|
||||
const group = groups.get(rowKey) || { rows: rowValues, values: new Map() }; const values = group.values.get(columnKey) || []; values.push(number(row[valueIndex])); group.values.set(columnKey, values); groups.set(rowKey, group);
|
||||
}
|
||||
const aggregate = (values: number[]) => aggregation.includes("avg") || aggregation.includes("mean") ? values.reduce((sum, item) => sum + item, 0) / Math.max(1, values.length) : aggregation.includes("count") ? values.length : aggregation.includes("max") ? Math.max(...values) : aggregation.includes("min") ? Math.min(...values) : values.reduce((sum, item) => sum + item, 0);
|
||||
if (!columnIndexes.length) return [[...rowNames, `${valueName}_${aggregation}`], ...[...groups.values()].map((group) => [...group.rows, aggregate([...group.values.values()].flat())])];
|
||||
return [[...rowNames, ...columnKeys], ...[...groups.values()].map((group) => [...group.rows, ...columnKeys.map((key) => group.values.has(key) ? aggregate(group.values.get(key)!) : args.value ?? "")])];
|
||||
});
|
||||
|
||||
async function exportExcel(source: Table, filePath: string): Promise<string> {
|
||||
const workbook = new ExcelJS.Workbook(); const sheet = workbook.addWorksheet("Sheet1"); sheet.addRows(source as ExcelJS.CellValue[][]); await fs.mkdir(path.dirname(filePath), { recursive: true }); await workbook.xlsx.writeFile(filePath); return filePath;
|
||||
}
|
||||
register(["Bivartable_DfWriteToFile", "Bivartable_DoSave"], async (_ctx, node, args) => {
|
||||
const filePath = path.resolve(required(args.filePath ?? args.path ?? args.savePath, "Excel路径", node.id, node.componentId)); return exportExcel(table(args.df), filePath);
|
||||
});
|
||||
register("Bivartable_DoOpenExcel", async (_ctx, node, args) => {
|
||||
const filePath = path.resolve(required(args.filePath, "Excel路径", node.id, node.componentId)); const workbook = new ExcelJS.Workbook(); await workbook.xlsx.readFile(filePath); const sheet = workbook.getWorksheet(String(args.sheetName || "Sheet1")) || workbook.worksheets[0]; return sheet.getSheetValues().slice(1).map((row) => Array.isArray(row) ? row.slice(1).map((cell) => cell ?? "") : []);
|
||||
});
|
||||
|
||||
register("Databook_Import", async (ctx, node, args) => { const result = await getHandlerResult("Bivartable_DoOpenExcel", ctx, node, { filePath: args.excel_path, sheetName: args.sheet_name }); ctx.vars[BOOK] = result; return result; });
|
||||
register("Databook_Export", async (ctx, node, args) => {
|
||||
const folder = path.resolve(required(args.export_path, "导出目录", node.id, node.componentId)); const name = String(args.export_name || "数据表格.xlsx"); return exportExcel(book(ctx), path.join(folder, path.extname(name) ? name : `${name}.xlsx`));
|
||||
});
|
||||
register("Databook_SetColumnInfo", (ctx, _node, args) => { const meta = (ctx.vars[META] ||= {}) as Record<string, unknown>; meta[String(args.column_name)] = args.column_description; return meta; });
|
||||
register("Databook_GetColumnInfo", (ctx, _node, args) => { const meta = (ctx.vars[META] || {}) as Record<string, unknown>; return meta[String(args.column_name)] ?? ""; });
|
||||
|
||||
async function getHandlerResult(id: string, ctx: any, node: any, args: Record<string, unknown>): Promise<unknown> {
|
||||
if (id !== "Bivartable_DoOpenExcel") return null;
|
||||
const filePath = path.resolve(required(args.filePath, "Excel路径", node.id, node.componentId)); const workbook = new ExcelJS.Workbook(); await workbook.xlsx.readFile(filePath); const sheet = workbook.getWorksheet(String(args.sheetName || "Sheet1")) || workbook.worksheets[0]; return sheet.getSheetValues().slice(1).map((row) => Array.isArray(row) ? row.slice(1).map((cell) => cell ?? "") : []);
|
||||
}
|
||||
@@ -0,0 +1,558 @@
|
||||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { register } from "../engine/registry";
|
||||
import { RuntimeError } from "../engine/types";
|
||||
|
||||
const str = (value: unknown): string => (value == null ? "" : String(value));
|
||||
const num = (value: unknown, fallback = 0): number => {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : fallback;
|
||||
};
|
||||
const bool = (value: unknown): boolean =>
|
||||
value === true || value === "true" || value === "1" || value === "True" || value === "是" || value === "开启";
|
||||
|
||||
function first(args: Record<string, unknown>, keys: string[]): unknown {
|
||||
for (const key of keys) {
|
||||
if (Object.prototype.hasOwnProperty.call(args, key)) return args[key];
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function asArray(value: unknown): unknown[] {
|
||||
if (Array.isArray(value)) return value;
|
||||
if (value == null || value === "") return [];
|
||||
if (typeof value === "string") {
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
return Array.isArray(parsed) ? parsed : [parsed];
|
||||
} catch {
|
||||
return value.split(/[,,\n]/).map((item) => item.trim()).filter(Boolean);
|
||||
}
|
||||
}
|
||||
return [value];
|
||||
}
|
||||
|
||||
function asObject(value: unknown): Record<string, unknown> {
|
||||
if (value && typeof value === "object" && !Array.isArray(value)) return value as Record<string, unknown>;
|
||||
if (typeof value === "string" && value.trim()) {
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return parsed as Record<string, unknown>;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
function toDate(value: unknown): Date {
|
||||
if (value instanceof Date) return value;
|
||||
if (typeof value === "number") return new Date(value < 1e12 ? value * 1000 : value);
|
||||
const text = str(value).trim();
|
||||
if (!text) return new Date();
|
||||
if (/^\d+$/.test(text)) {
|
||||
const numeric = Number(text);
|
||||
return new Date(numeric < 1e12 ? numeric * 1000 : numeric);
|
||||
}
|
||||
const normalized = text.replace(/\//g, "-").replace(" ", "T");
|
||||
const date = new Date(normalized);
|
||||
return Number.isNaN(date.getTime()) ? new Date(text) : date;
|
||||
}
|
||||
|
||||
function formatDate(value: unknown, pattern = "YYYY-MM-DD HH:mm:ss"): string {
|
||||
const date = toDate(value);
|
||||
if (Number.isNaN(date.getTime())) return "";
|
||||
const pad = (input: number) => String(input).padStart(2, "0");
|
||||
const fmt = pattern
|
||||
.replace(/yyyy/g, "YYYY")
|
||||
.replace(/yy/g, "YY")
|
||||
.replace(/dd/g, "DD");
|
||||
return fmt
|
||||
.replace(/YYYY/g, String(date.getFullYear()))
|
||||
.replace(/YY/g, String(date.getFullYear()).slice(-2))
|
||||
.replace(/MM/g, pad(date.getMonth() + 1))
|
||||
.replace(/DD/g, pad(date.getDate()))
|
||||
.replace(/HH/g, pad(date.getHours()))
|
||||
.replace(/mm/g, pad(date.getMinutes()))
|
||||
.replace(/ss/g, pad(date.getSeconds()));
|
||||
}
|
||||
|
||||
function cutText(text: string, args: Record<string, unknown>): string {
|
||||
const startType = str(first(args, ["start_cut_type"]));
|
||||
const endType = str(first(args, ["end_cut_type"]));
|
||||
let start = 0;
|
||||
if (startType.includes("文本")) {
|
||||
const token = str(first(args, ["start_cut_str", "start_cut_pos"]));
|
||||
const index = text.indexOf(token);
|
||||
start = index >= 0 ? index + token.length : 0;
|
||||
} else {
|
||||
start = Math.max(0, num(first(args, ["start_cut_pos", "start"]), 0));
|
||||
}
|
||||
if (endType.includes("长度")) {
|
||||
const length = Math.max(0, num(first(args, ["end_cut_length", "length"]), text.length));
|
||||
return text.slice(start, start + length);
|
||||
}
|
||||
if (endType.includes("文本")) {
|
||||
const token = str(first(args, ["end_cut_txt", "end_cut_str"]));
|
||||
const index = text.indexOf(token, start);
|
||||
return text.slice(start, index >= 0 ? index : text.length);
|
||||
}
|
||||
const end = num(first(args, ["end_cut_pos", "end"]), text.length);
|
||||
return text.slice(start, end);
|
||||
}
|
||||
|
||||
/* ---------------- StrUtil ---------------- */
|
||||
register("StrUtil_Len", (_ctx, _node, args) => str(first(args, ["string"])).length);
|
||||
register("StrUtil_InStr", (_ctx, _node, args) => {
|
||||
const text = str(first(args, ["string"]));
|
||||
const search = str(first(args, ["search"]));
|
||||
const start = Math.max(0, num(first(args, ["startIndex"]), 0));
|
||||
return text.indexOf(search, start);
|
||||
});
|
||||
register("StrUtil_Replace", (_ctx, _node, args) => {
|
||||
const text = str(first(args, ["string"]));
|
||||
const search = str(first(args, ["search", "Config"]));
|
||||
const replacement = str(first(args, ["replace"]));
|
||||
return text.split(search).join(replacement);
|
||||
});
|
||||
register(["StrUtil_LowerCase"], (_ctx, _node, args) => str(first(args, ["string"])).toLowerCase());
|
||||
register(["StrUtil_UpperCase"], (_ctx, _node, args) => str(first(args, ["string"])).toUpperCase());
|
||||
register("StrUtil_CaseChange", (_ctx, _node, args) => {
|
||||
const text = str(first(args, ["string"]));
|
||||
const mode = str(first(args, ["case"]));
|
||||
// 目录选项值为 "1"(大写)/"2"(小写),文字模式兼容 大写/小写/首字母大写
|
||||
if (mode.includes("首")) return text ? text[0].toUpperCase() + text.slice(1) : "";
|
||||
if (mode === "1" || mode.includes("大写")) return text.toUpperCase();
|
||||
if (mode === "2" || mode.includes("小写")) return text.toLowerCase();
|
||||
return text;
|
||||
});
|
||||
register("StrUtil_Trim", (_ctx, _node, args) => str(first(args, ["string"])).trim());
|
||||
register("StrUtil_Ltrim", (_ctx, _node, args) => str(first(args, ["string"])).replace(/^\s+/, ""));
|
||||
register("StrUtil_Rtrim", (_ctx, _node, args) => str(first(args, ["string"])).replace(/\s+$/, ""));
|
||||
register("StrUtil_DelTrim", (_ctx, _node, args) => str(first(args, ["string"])).trim());
|
||||
register("StrUtil_Left", (_ctx, _node, args) => str(first(args, ["string"])).slice(0, Math.max(0, num(first(args, ["length"]), 0))));
|
||||
register("StrUtil_Right", (_ctx, _node, args) => {
|
||||
const text = str(first(args, ["string"]));
|
||||
const length = Math.max(0, num(first(args, ["length"]), 0));
|
||||
return length ? text.slice(-length) : "";
|
||||
});
|
||||
register("StrUtil_Mid", (_ctx, _node, args) => {
|
||||
const text = str(first(args, ["string"]));
|
||||
const start = Math.max(0, num(first(args, ["start"]), 0));
|
||||
const length = Math.max(0, num(first(args, ["length"]), text.length));
|
||||
return text.slice(start, start + length);
|
||||
});
|
||||
register(["StrUtil_CutOutStr", "StrUtil_CutOutStrV1"], (_ctx, _node, args) => cutText(str(first(args, ["string"])), args));
|
||||
register("StrUtil_Split", (_ctx, _node, args) =>
|
||||
str(first(args, ["string"])).split(str(first(args, ["spliter"])))
|
||||
);
|
||||
register(["StrUtil_SplitStr"], (_ctx, _node, args) => {
|
||||
const text = str(first(args, ["string"]));
|
||||
const custom = str(first(args, ["diy_symbol"]));
|
||||
const symbol = custom || str(first(args, ["split_symbol"])) || ",";
|
||||
const regex = bool(first(args, ["is_re"]));
|
||||
let parts: string[];
|
||||
if (regex) {
|
||||
try {
|
||||
parts = text.split(new RegExp(symbol));
|
||||
} catch {
|
||||
parts = text.split(symbol);
|
||||
}
|
||||
} else {
|
||||
parts = text.split(symbol);
|
||||
}
|
||||
const trim = args.del_trim === undefined ? true : bool(args.del_trim);
|
||||
if (trim) parts = parts.map((item) => item.trim());
|
||||
return parts.filter((item) => item !== "" || !trim);
|
||||
});
|
||||
register("StrUtil_AddToStr", (_ctx, _node, args) => {
|
||||
const text = str(first(args, ["string"]));
|
||||
const addition = str(first(args, ["add_str"]));
|
||||
const type = str(first(args, ["add_type"]));
|
||||
const symbol = str(first(args, ["symbol"]));
|
||||
if (type.includes("前")) return addition + symbol + text;
|
||||
return text + symbol + addition;
|
||||
});
|
||||
register("StrUtil_ReverseStr", (_ctx, _node, args) => {
|
||||
const text = str(first(args, ["string"]));
|
||||
return bool(first(args, ["letter_reverse"])) ? [...text].reverse().join("") : text.split(/\s+/).reverse().join(" ");
|
||||
});
|
||||
register("StrUtil_ListToString", (_ctx, _node, args) => {
|
||||
const list = asArray(first(args, ["str_list"]));
|
||||
const connector = str(first(args, ["connectors_custom", "standard"])) || ",";
|
||||
return list.map(str).join(connector);
|
||||
});
|
||||
register("StrUtil_CompleteStr", (_ctx, _node, args) => {
|
||||
const text = str(first(args, ["string"]));
|
||||
const total = Math.max(0, num(first(args, ["sum_length"]), text.length));
|
||||
const pad = str(first(args, ["com_string"])) || " ";
|
||||
const mode = str(first(args, ["com_type"]));
|
||||
const fill = pad.repeat(Math.max(0, total - text.length)).slice(0, Math.max(0, total - text.length));
|
||||
return mode.includes("右") ? text + fill : fill + text;
|
||||
});
|
||||
register("StrUtil_CharCode", (_ctx, _node, args) => String.fromCharCode(num(first(args, ["code"]), 0)));
|
||||
register("StrUtil_CharAsc", (_ctx, _node, args) => str(first(args, ["char"])).charCodeAt(0));
|
||||
register("StrUtil_CreateRandomStr", (_ctx, _node, args) => {
|
||||
const length = Math.max(0, num(first(args, ["string_length"]), 8));
|
||||
const alphabet = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
|
||||
return Array.from({ length }, () => alphabet[Math.floor(Math.random() * alphabet.length)]).join("");
|
||||
});
|
||||
register("StrUtil_LookUpStr", (_ctx, _node, args) => {
|
||||
const text = str(first(args, ["string"]));
|
||||
const search = str(first(args, ["search"]));
|
||||
const start = Math.max(0, num(first(args, ["startIndex"]), 0));
|
||||
return text.indexOf(search, start);
|
||||
});
|
||||
|
||||
/* ---------------- Time ---------------- */
|
||||
register("Time_Now", () => formatDate(new Date()));
|
||||
register(["Time_GetDate", "Time_GetDateV1", "Time_GetDateV2"], (_ctx, _node, args) => {
|
||||
let date = toDate(first(args, ["date_start", "time_data"]));
|
||||
const offset = num(first(args, ["offset_unit_num"]), 0);
|
||||
const mode = str(first(args, ["offset_status"]));
|
||||
if (offset) date = new Date(date.getTime() + (mode.includes("减") ? -offset : offset) * 86400000);
|
||||
const type = str(first(args, ["get_type"]));
|
||||
if (type.includes("时间戳")) return Math.floor(date.getTime() / 1000);
|
||||
if (type.includes("日期") && !type.includes("时间")) return formatDate(date, "YYYY-MM-DD");
|
||||
return formatDate(date, type || "YYYY-MM-DD HH:mm:ss");
|
||||
});
|
||||
register("Time_Date", () => formatDate(new Date(), "YYYY-MM-DD"));
|
||||
register("Time_Time", () => formatDate(new Date(), "HH:mm:ss"));
|
||||
register("Time_TimeStamp", () => Math.floor(Date.now() / 1000));
|
||||
register(["Time_Format", "Time_FormatNew"], (_ctx, _node, args) =>
|
||||
formatDate(first(args, ["timestamp", "date_input"]), str(first(args, ["format"])) || "YYYY-MM-DD HH:mm:ss")
|
||||
);
|
||||
register("Time_DatetimeToTimestamp", (_ctx, _node, args) => Math.floor(toDate(first(args, ["datetime"])).getTime() / 1000));
|
||||
register("Time_DateToTimestamp", (_ctx, _node, args) => Math.floor(toDate(first(args, ["datetime"])).getTime() / 1000));
|
||||
register(["Time_DateOffset", "Time_DateOffset_V1"], (_ctx, _node, args) => {
|
||||
const date = toDate(first(args, ["date_input"]));
|
||||
const amount = num(first(args, ["offset_unit_num"]), 0);
|
||||
const mode = str(first(args, ["offset_status"]));
|
||||
const delta = mode.includes("减") ? -amount : amount;
|
||||
const pattern = str(first(args, ["get_type"])) || str(first(args, ["is_fmt"])) || "YYYY-MM-DD HH:mm:ss";
|
||||
return formatDate(new Date(date.getTime() + delta * 86400000), pattern.includes("YYYY") ? pattern : "YYYY-MM-DD HH:mm:ss");
|
||||
});
|
||||
for (const [id, getter] of [
|
||||
["Time_Year", (date: Date) => date.getFullYear()],
|
||||
["Time_Month", (date: Date) => date.getMonth() + 1],
|
||||
["Time_Day", (date: Date) => date.getDate()],
|
||||
["Time_Hour", (date: Date) => date.getHours()],
|
||||
["Time_Minute", (date: Date) => date.getMinutes()],
|
||||
["Time_Second", (date: Date) => date.getSeconds()],
|
||||
["Time_WeekDay", (date: Date) => date.getDay()]
|
||||
] as Array<[string, (date: Date) => number]>) {
|
||||
register(id, (_ctx, _node, args) => getter(toDate(first(args, ["timestamp", "datetime"]))));
|
||||
}
|
||||
register("Time_CalDate", (_ctx, _node, args) => {
|
||||
const start = toDate(first(args, ["date_start"]));
|
||||
const end = toDate(first(args, ["date_end"]));
|
||||
const unit = str(first(args, ["time_type"]));
|
||||
const ms = end.getTime() - start.getTime();
|
||||
if (unit.includes("时")) return ms / 3600000;
|
||||
if (unit.includes("分")) return ms / 60000;
|
||||
if (unit.includes("秒")) return ms / 1000;
|
||||
return ms / 86400000;
|
||||
});
|
||||
register("Time_TimeTypeConversion", (_ctx, _node, args) =>
|
||||
formatDate(first(args, ["datetime"]), str(first(args, ["format"])) || "YYYY-MM-DD HH:mm:ss")
|
||||
);
|
||||
|
||||
/* ---------------- List ---------------- */
|
||||
register("List_CreateList", (_ctx, _node, args) => asArray(first(args, ["data"])));
|
||||
register("List_ListLength", (_ctx, _node, args) => asArray(first(args, ["arrayRet"])).length);
|
||||
register("List_InsertList", (_ctx, _node, args) => {
|
||||
const list = asArray(first(args, ["arrayRet"]));
|
||||
const item = first(args, ["data"]);
|
||||
const type = str(first(args, ["insertType"]));
|
||||
if (type.includes("位置") || type.includes("指定")) {
|
||||
const position = Math.max(0, Math.min(list.length, num(first(args, ["position"]), 0)));
|
||||
return [...list.slice(0, position), item, ...list.slice(position)];
|
||||
}
|
||||
return [...list, item];
|
||||
});
|
||||
register("List_DeleteList", (_ctx, _node, args) => {
|
||||
const list = asArray(first(args, ["arrayRet"]));
|
||||
const type = str(first(args, ["deleteType"]));
|
||||
if (type.includes("位置")) {
|
||||
const position = Math.max(0, num(first(args, ["position"]), 0));
|
||||
return list.filter((_, index) => index !== position);
|
||||
}
|
||||
const target = first(args, ["data"]);
|
||||
return list.filter((item) => item !== target);
|
||||
});
|
||||
register("List_GetListItem", (_ctx, _node, args) => asArray(first(args, ["arrayRet"]))[num(first(args, ["position"]), 0)]);
|
||||
register("List_SetListItem", (_ctx, _node, args) => {
|
||||
const list = [...asArray(first(args, ["arrayRet"]))];
|
||||
const position = Math.max(0, num(first(args, ["position"]), 0));
|
||||
list[position] = first(args, ["data"]);
|
||||
return list;
|
||||
});
|
||||
register("List_GetIndex", (_ctx, _node, args) => asArray(first(args, ["arrayRet"])).indexOf(first(args, ["data"])));
|
||||
register("List_ListMerge", (_ctx, _node, args) => [
|
||||
...asArray(first(args, ["arrayRet1"])),
|
||||
...asArray(first(args, ["arrayRet2"]))
|
||||
]);
|
||||
register("List_ListDuplication", (_ctx, _node, args) => [...new Set(asArray(first(args, ["arrayRet"])))]);
|
||||
register("List_ListSorted", (_ctx, _node, args) => {
|
||||
const list = [...asArray(first(args, ["arrayRet"]))];
|
||||
const mode = str(first(args, ["sortedType"]));
|
||||
return list.sort((a, b) => {
|
||||
const result = String(a).localeCompare(String(b), "zh-CN", { numeric: true });
|
||||
return mode.includes("降") ? -result : result;
|
||||
});
|
||||
});
|
||||
register("List_ListIntersection", (_ctx, _node, args) => {
|
||||
const second = new Set(asArray(first(args, ["arrayRet2"])));
|
||||
return asArray(first(args, ["arrayRet1"])).filter((item) => second.has(item));
|
||||
});
|
||||
register("List_ClearList", () => []);
|
||||
|
||||
/* ---------------- Dict ---------------- */
|
||||
register("Dict_Create", (_ctx, _node, args) => asObject(first(args, ["value"])));
|
||||
register("Dict_GetItem", (_ctx, _node, args) => {
|
||||
const object = asObject(first(args, ["dictObj"]));
|
||||
const key = str(first(args, ["key"]));
|
||||
if (key in object) return object[key];
|
||||
return first(args, ["default_value"]);
|
||||
});
|
||||
register("Dict_SetItem", (_ctx, _node, args) => {
|
||||
const object = asObject(first(args, ["dictObj"]));
|
||||
const value = first(args, ["value"]);
|
||||
if (value && typeof value === "object") {
|
||||
const record = value as Record<string, unknown>;
|
||||
if ("key" in record) return { ...object, [str(record.key)]: record.value };
|
||||
return { ...object, ...record };
|
||||
}
|
||||
return { ...object, [str(first(args, ["key"]))]: value };
|
||||
});
|
||||
register("Dict_Keys", (_ctx, _node, args) => Object.keys(asObject(first(args, ["dictObj"]))));
|
||||
register("Dict_Values", (_ctx, _node, args) => Object.values(asObject(first(args, ["dictObj"]))));
|
||||
register("Dict_Items", (_ctx, _node, args) => Object.entries(asObject(first(args, ["dictObj"]))));
|
||||
register("Dict_Delete", (_ctx, _node, args) => {
|
||||
const object = { ...asObject(first(args, ["dictObj"])) };
|
||||
delete object[str(first(args, ["key"]))];
|
||||
return object;
|
||||
});
|
||||
register("Dict_Clear", () => ({}));
|
||||
register("Dict_Update", (_ctx, _node, args) => ({
|
||||
...asObject(first(args, ["dictObj"])),
|
||||
...asObject(first(args, ["targetDict"]))
|
||||
}));
|
||||
|
||||
/* ---------------- Regex ---------------- */
|
||||
register(["Regex_Test", "Regex_TestNew"], (_ctx, _node, args) => {
|
||||
try {
|
||||
return new RegExp(str(first(args, ["pattern", "Config"]))).test(str(first(args, ["text"])));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
register(["Regex_FindAll", "Regex_FindAllNew"], (_ctx, _node, args) => {
|
||||
try {
|
||||
const regex = new RegExp(str(first(args, ["pattern", "Config"])), "g");
|
||||
return [...str(first(args, ["text"])).matchAll(regex)].map((match) => match[0]);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
});
|
||||
register(["Regex_Find", "Regex_FindNew", "Regex_FindStr", "Regex_FindStrV1", "Regex_FindNew_1"], (_ctx, _node, args) => {
|
||||
try {
|
||||
const pattern = str(first(args, ["pattern", "Config", "expression"]));
|
||||
const regex = new RegExp(pattern);
|
||||
const match = regex.exec(str(first(args, ["text", "string"])));
|
||||
if (!match) return "";
|
||||
const index = Math.max(0, num(first(args, ["index", "extract_pos"]), 0));
|
||||
return match[index] ?? match[0];
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
});
|
||||
|
||||
/* ---------------- Math ---------------- */
|
||||
register("Math_Round", (_ctx, _node, args) => {
|
||||
const digit = num(first(args, ["digit"]), 0);
|
||||
const factor = 10 ** digit;
|
||||
return Math.round(num(first(args, ["data"])) * factor) / factor;
|
||||
});
|
||||
register("Math_Abs", (_ctx, _node, args) => Math.abs(num(first(args, ["data"]))));
|
||||
register("Math_Int", (_ctx, _node, args) => {
|
||||
const value = num(first(args, ["data"]));
|
||||
const position = str(first(args, ["position"]));
|
||||
return position.includes("上") ? Math.ceil(value) : Math.floor(value);
|
||||
});
|
||||
register("Math_Sqrt", (_ctx, _node, args) => Math.sqrt(num(first(args, ["data"]))));
|
||||
register("Math_Pow", (_ctx, _node, args) => Math.pow(num(first(args, ["x"])), num(first(args, ["y"]))));
|
||||
register("Math_Exp", (_ctx, _node, args) => Math.exp(num(first(args, ["data"]))));
|
||||
register("Math_Sgn", (_ctx, _node, args) => Math.sign(num(first(args, ["data"]))));
|
||||
register("Math_Hex", (_ctx, _node, args) => num(first(args, ["num"])).toString(16).toUpperCase());
|
||||
register("Math_Oct", (_ctx, _node, args) => num(first(args, ["num"])).toString(8));
|
||||
register("Math_Scale", (_ctx, _node, args) => {
|
||||
const source = str(first(args, ["sourceNum"]));
|
||||
const from = num(first(args, ["sourceNumScale"]), 10);
|
||||
const to = num(first(args, ["targetNumScale"]), 10);
|
||||
return parseInt(source, from).toString(to).toUpperCase();
|
||||
});
|
||||
for (const [id, fn] of [
|
||||
["Math_Sin", (value: number, angle: boolean) => Math.sin(angle ? (value * Math.PI) / 180 : value)],
|
||||
["Math_Cos", (value: number, angle: boolean) => Math.cos(angle ? (value * Math.PI) / 180 : value)],
|
||||
["Math_Tan", (value: number, angle: boolean) => Math.tan(angle ? (value * Math.PI) / 180 : value)]
|
||||
] as Array<[string, (value: number, angle: boolean) => number]>) {
|
||||
register(id, (_ctx, _node, args) => {
|
||||
const unit = str(first(args, ["unit"]));
|
||||
return fn(num(first(args, ["data"])), unit.includes("角度"));
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------------- JSON / CSV ---------------- */
|
||||
register("JSON_Stringify", (_ctx, _node, args) => JSON.stringify(first(args, ["string"])));
|
||||
register("JSON_Parse", (_ctx, _node, args) => {
|
||||
try {
|
||||
return JSON.parse(str(first(args, ["string"])));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
function parseCsv(text: string): string[][] {
|
||||
const rows: string[][] = [];
|
||||
let row: string[] = [];
|
||||
let cell = "";
|
||||
let quoted = false;
|
||||
for (let i = 0; i < text.length; i += 1) {
|
||||
const char = text[i];
|
||||
if (quoted) {
|
||||
if (char === '"' && text[i + 1] === '"') {
|
||||
cell += '"';
|
||||
i += 1;
|
||||
} else if (char === '"') {
|
||||
quoted = false;
|
||||
} else {
|
||||
cell += char;
|
||||
}
|
||||
} else if (char === '"') {
|
||||
quoted = true;
|
||||
} else if (char === ",") {
|
||||
row.push(cell);
|
||||
cell = "";
|
||||
} else if (char === "\n") {
|
||||
row.push(cell);
|
||||
rows.push(row);
|
||||
row = [];
|
||||
cell = "";
|
||||
} else if (char !== "\r") {
|
||||
cell += char;
|
||||
}
|
||||
}
|
||||
if (cell || row.length) {
|
||||
row.push(cell);
|
||||
rows.push(row);
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
function toCsv(rows: unknown[][]): string {
|
||||
return rows
|
||||
.map((row) =>
|
||||
row
|
||||
.map((cell) => {
|
||||
const text = str(cell);
|
||||
return /[",\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text;
|
||||
})
|
||||
.join(",")
|
||||
)
|
||||
.join("\r\n");
|
||||
}
|
||||
|
||||
register(["CSV_Read", "CSV_ReadNew"], async (_ctx, node, args) => {
|
||||
const filePath = str(first(args, ["path"])).trim();
|
||||
if (!filePath) throw new RuntimeError("validation_error", "CSV 路径为空", { nodeId: node.id, componentId: node.componentId });
|
||||
const text = await fs.readFile(path.resolve(filePath), "utf8");
|
||||
return parseCsv(text);
|
||||
});
|
||||
register(["CSV_Write", "CSV_WriteNew"], async (_ctx, node, args) => {
|
||||
const filePath = str(first(args, ["path"])).trim();
|
||||
if (!filePath) throw new RuntimeError("validation_error", "CSV 路径为空", { nodeId: node.id, componentId: node.componentId });
|
||||
const table = asArray(first(args, ["table"])).map((row) => asArray(row));
|
||||
const resolved = path.resolve(filePath);
|
||||
await fs.mkdir(path.dirname(resolved), { recursive: true });
|
||||
await fs.writeFile(resolved, toCsv(table), "utf8");
|
||||
return resolved;
|
||||
});
|
||||
|
||||
/* ---------------- Basic ---------------- */
|
||||
register(["Basic_ConvertNumber", "Basic_StringToNum"], (_ctx, _node, args) => num(first(args, ["val"])));
|
||||
register("Basic_ConvertString", (_ctx, _node, args) => str(first(args, ["val"])));
|
||||
register("Basic_ConvertInt", (_ctx, _node, args) => Math.trunc(num(first(args, ["val"]))));
|
||||
register("Basic_ConvertBool", (_ctx, _node, args) => {
|
||||
const value = first(args, ["val"]);
|
||||
if (typeof value === "number") return value !== 0;
|
||||
return bool(value);
|
||||
});
|
||||
register("Basic_IsNull", (_ctx, _node, args) => {
|
||||
const value = first(args, ["val"]);
|
||||
return value === null || value === undefined || value === "";
|
||||
});
|
||||
register("Basic_IsArray", (_ctx, _node, args) => Array.isArray(first(args, ["val"])));
|
||||
register("Basic_IsDictionary", (_ctx, _node, args) => {
|
||||
const value = first(args, ["val"]);
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
});
|
||||
register("Basic_IsNumber", (_ctx, _node, args) => Number.isFinite(Number(first(args, ["val"]))));
|
||||
register("Basic_Type", (_ctx, _node, args) => {
|
||||
const value = first(args, ["val"]);
|
||||
if (value === null) return "null";
|
||||
if (Array.isArray(value)) return "list";
|
||||
return typeof value;
|
||||
});
|
||||
// 原始目录缺少选项映射。新界面使用语义值;数字别名兼容此前默认的 "1"。
|
||||
const JUDGE_TYPE_ALIASES: Record<string, string> = { "1": "number", "2": "null", "3": "dict", "4": "string", "5": "list", "6": "bool", "7": "int" };
|
||||
const CONVERT_TYPE_ALIASES: Record<string, string> = { "1": "number", "2": "int", "3": "bool", "4": "string" };
|
||||
|
||||
function targetType(raw: unknown, aliases: Record<string, string>, supported: string[], nodeId: string, componentId: string): string {
|
||||
const text = String(raw ?? "number").trim().toLowerCase();
|
||||
const synonyms: Record<string, string> = { float: "number", boolean: "bool", str: "string", text: "string", array: "list", dictionary: "dict", object: "dict", empty: "null" };
|
||||
const type = aliases[text] || synonyms[text] || text;
|
||||
if (!supported.includes(type)) throw new RuntimeError("validation_error", "目标数据类型无效,请重新选择类型", { nodeId, componentId });
|
||||
return type;
|
||||
}
|
||||
|
||||
register("Basic_JudgeDataType", (_ctx, node, args) => {
|
||||
const value = first(args, ["val"]);
|
||||
const type = targetType(args.into_type, JUDGE_TYPE_ALIASES, ["number", "null", "dict", "string", "list", "bool", "int"], node.id, node.componentId);
|
||||
switch (type) {
|
||||
case "number": return typeof value === "number" && Number.isFinite(value);
|
||||
case "int": return typeof value === "number" && Number.isInteger(value);
|
||||
case "null": return value === null || value === undefined || value === "";
|
||||
case "dict": return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
case "string": return typeof value === "string";
|
||||
case "list": return Array.isArray(value);
|
||||
case "bool": return typeof value === "boolean";
|
||||
}
|
||||
});
|
||||
register("Basic_Clone", (_ctx, _node, args) => structuredClone(first(args, ["val"])));
|
||||
register("Basic_RandomInt", (_ctx, _node, args) => {
|
||||
const min = num(first(args, ["min", "start"]), 0);
|
||||
const max = num(first(args, ["max", "end"]), 100);
|
||||
return Math.floor(min + Math.random() * (max - min + 1));
|
||||
});
|
||||
register("Basic_Rnd", () => Math.random());
|
||||
register("Basic_DataTypeConversion", (_ctx, node, args) => {
|
||||
const value = first(args, ["val"]);
|
||||
const type = targetType(args.into_type, CONVERT_TYPE_ALIASES, ["number", "int", "bool", "string"], node.id, node.componentId);
|
||||
if (type === "string") return str(value);
|
||||
if (type === "bool") {
|
||||
if (value == null || value === "") return false;
|
||||
if (typeof value === "boolean") return value;
|
||||
if (typeof value === "number" && Number.isFinite(value)) return value !== 0;
|
||||
const text = String(value).trim().toLowerCase();
|
||||
if (["true", "1", "是", "开启"].includes(text)) return true;
|
||||
if (["false", "0", "否", "关闭"].includes(text)) return false;
|
||||
throw new RuntimeError("validation_error", "输入值无法转换为布尔类型", { nodeId: node.id, componentId: node.componentId });
|
||||
}
|
||||
if ((typeof value !== "number" && typeof value !== "string") || (typeof value === "string" && !value.trim()) || !Number.isFinite(Number(value))) {
|
||||
throw new RuntimeError("validation_error", "输入值不是有效数字", { nodeId: node.id, componentId: node.componentId });
|
||||
}
|
||||
const result = type === "int" ? Math.trunc(Number(value)) : Number(value);
|
||||
if (type === "int" && !Number.isSafeInteger(result)) throw new RuntimeError("validation_error", "转换后的整数超出安全范围", { nodeId: node.id, componentId: node.componentId });
|
||||
return result;
|
||||
});
|
||||
@@ -0,0 +1,147 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const powershellMock = vi.hoisted(() => vi.fn(async () => '{"clicked":true,"value":"cached"}'));
|
||||
|
||||
vi.mock("./desktop", () => ({
|
||||
powershell: powershellMock,
|
||||
psString: (value: unknown) => JSON.stringify(String(value ?? ""))
|
||||
}));
|
||||
|
||||
import { getHandler } from "../engine/registry";
|
||||
import { parseUiaSelector } from "./desktop-uia";
|
||||
|
||||
|
||||
describe("desktop UIA ground capabilities", () => {
|
||||
it.each([
|
||||
"Element_ClickGround",
|
||||
"Element_SetValueGround",
|
||||
"Element_ExistsGround",
|
||||
"Element_GetValueGround",
|
||||
"Element_WaitElementGround",
|
||||
"Element_GetCheckGround",
|
||||
"Element_SetCheckGround",
|
||||
"Element_RegionGround",
|
||||
"Element_GetSelectGround",
|
||||
"Element_SetSelectGround",
|
||||
"Element_GetAttributeGround",
|
||||
"Element_SetAttributeGround",
|
||||
"Element_GetButtonEnableGround",
|
||||
"Element_ShotGround",
|
||||
"Element_MouseToGround",
|
||||
"Element_ScrollIntoViewGround",
|
||||
"Element_ChildrenGround",
|
||||
"Element_DesktopChildren",
|
||||
"Element_RestoreWindowGround",
|
||||
"Element_ActivateWindowGround",
|
||||
"Element_MoveWindowGround"
|
||||
])("registers %s", (id) => {
|
||||
expect(getHandler(id), id).toBeTruthy();
|
||||
});
|
||||
|
||||
it("parses name selector", () => {
|
||||
const s = parseUiaSelector("name=添加供应商");
|
||||
expect(s.name).toBe("添加供应商");
|
||||
expect(s.index).toBe(1);
|
||||
});
|
||||
|
||||
it("parses window, id, type, class and index", () => {
|
||||
const s = parseUiaSelector("window=CC Switch;id=btn-save;type=button;class=btn;index=2");
|
||||
expect(s.window).toBe("CC Switch");
|
||||
expect(s.id).toBe("btn-save");
|
||||
expect(s.type).toBe("Button");
|
||||
expect(s.className).toBe("btn");
|
||||
expect(s.index).toBe(2);
|
||||
});
|
||||
|
||||
it("strips uia: prefix and accepts object form", () => {
|
||||
const s = parseUiaSelector({ value: "uia:name=确定" });
|
||||
expect(s.name).toBe("确定");
|
||||
});
|
||||
|
||||
it("parses AI screen coordinate selector", () => {
|
||||
const s = parseUiaSelector("screen=100,200,320,80;window= MyApp;ai=1");
|
||||
expect(s.screen).toEqual({ x: 100, y: 200, w: 320, h: 80 });
|
||||
expect(s.window).toBe("MyApp");
|
||||
});
|
||||
|
||||
it("parses enriched AI relocation metadata", () => {
|
||||
const selector = parseUiaSelector(
|
||||
"screen=100,200,320,80;window=App;winClass=Chrome_WidgetWin_1;process=chrome;rel=20,30,320,80;name=login;id=loginButton;type=Button;class=Button;aiType=button;aiText=login;enriched=1;ai=1"
|
||||
);
|
||||
expect(selector.screen).toEqual({ x: 100, y: 200, w: 320, h: 80 });
|
||||
expect(selector.rel).toEqual({ x: 20, y: 30, w: 320, h: 80 });
|
||||
expect(selector.window).toBe("App");
|
||||
expect(selector.winClass).toBe("Chrome_WidgetWin_1");
|
||||
expect(selector.process).toBe("chrome");
|
||||
expect(selector).toMatchObject({ name: "login", id: "loginButton", type: "Button", className: "Button", aiType: "button", aiText: "login", enriched: true, ai: true });
|
||||
});
|
||||
|
||||
it("rejects invalid selector", () => {
|
||||
expect(() => parseUiaSelector("")).toThrow();
|
||||
expect(() => parseUiaSelector("screen=1,2,3")).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("desktop UIA selector compatibility", () => {
|
||||
beforeEach(() => {
|
||||
powershellMock.mockClear();
|
||||
powershellMock.mockImplementation(async () => '{"clicked":true,"value":"cached"}');
|
||||
});
|
||||
|
||||
it("maps compound .NET ControlType names explicitly and preserves raw input", () => {
|
||||
expect(parseUiaSelector("type=combobox")).toMatchObject({ type: "ComboBox", typeRaw: "combobox" });
|
||||
expect(parseUiaSelector("type=listitem")).toMatchObject({ type: "ListItem", typeRaw: "listitem" });
|
||||
expect(parseUiaSelector("type=datagrid")).toMatchObject({ type: "DataGrid", typeRaw: "datagrid" });
|
||||
});
|
||||
|
||||
it("parses a stable ancestor path", () => {
|
||||
const selector = parseUiaSelector("window=App;path=Pane>Edit[name=用户名;index=2]>Button[name=确定]");
|
||||
expect(selector.path).toEqual([
|
||||
{ type: "Pane" },
|
||||
{ type: "Edit", name: "用户名", index: 2 },
|
||||
{ type: "Button", name: "确定" }
|
||||
]);
|
||||
});
|
||||
|
||||
const context = { log: vi.fn() } as any;
|
||||
const node = { id: "n1", componentId: "Element_ClickGround" } as any;
|
||||
|
||||
it("resolves screen selectors through UIA/window before coordinate fallback", async () => {
|
||||
const args = {
|
||||
selector: "screen=100,200,320,80;ai=1",
|
||||
clickway: "1"
|
||||
};
|
||||
await expect(getHandler("Element_ClickGround")!(context, node, args, {} as any)).resolves.toEqual({ clicked: true, value: "cached" });
|
||||
const script = String((powershellMock.mock.calls as unknown as Array<[string]>)[0]?.[0] ?? "");
|
||||
expect(script).toContain("RpaScreenInput");
|
||||
expect(script).toContain("$x = [int]($rect.x + $rect.w / 2)");
|
||||
expect(script).toContain("SetCursorPos($x, $y)");
|
||||
expect(script).toContain("Resolve-RpaScreenRect");
|
||||
expect(script).toContain("Find-RpaWindow");
|
||||
});
|
||||
|
||||
it("dispatches screen setValue to coordinate paste", async () => {
|
||||
await getHandler("Element_SetValueGround")!(context, node, {
|
||||
selector: "screen=10,20,30,40",
|
||||
value: "hello"
|
||||
}, {} as any);
|
||||
const script = String((powershellMock.mock.calls as unknown as Array<[string]>)[0]?.[0] ?? "");
|
||||
expect(script).toContain("resolvedMethod = $rect.method");
|
||||
expect(script).toContain("hello");
|
||||
expect(script).toContain("Find-RpaWindow");
|
||||
});
|
||||
|
||||
it("validates a picked screen rectangle for exists/wait", async () => {
|
||||
powershellMock.mockImplementation(async () => '{"found":true,"method":"window_rel"}');
|
||||
await expect(getHandler("Element_ExistsGround")!(context, node, {
|
||||
selector: "screen=1,2,3,4"
|
||||
}, {} as any)).resolves.toBe(true);
|
||||
await expect(getHandler("Element_WaitElementGround")!(context, node, {
|
||||
selector: "screen=1,2,3,4"
|
||||
}, {} as any)).resolves.toBe(true);
|
||||
expect(powershellMock).toHaveBeenCalled();
|
||||
const script = String((powershellMock.mock.calls as unknown as Array<[string]>).at(-1)?.[0] ?? "");
|
||||
expect(script).toContain("Resolve-RpaScreenRect");
|
||||
});
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,383 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import { register } from "../engine/registry";
|
||||
import { RuntimeError, type CapabilityHandler } from "../engine/types";
|
||||
import { type ManualOperationSettings, defaultEngine } from "../engine/engine";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
// 人为拟真设置本体挂在引擎实例上(任务 1.3),此处委托 defaultEngine。
|
||||
const manualOperation: ManualOperationSettings = defaultEngine.manualOperation;
|
||||
|
||||
function jitter(value: number, amount = 3): number {
|
||||
return Math.max(0, Math.round(value + (Math.random() * amount * 2 - amount)));
|
||||
}
|
||||
|
||||
function manualJitter(point: [number, number], move: boolean, click: boolean): [number, number] {
|
||||
if (!manualOperation.enabled) return point;
|
||||
const amount = (click && manualOperation.randomClick) || (move && manualOperation.randomMove) ? 3 : 0;
|
||||
return [jitter(point[0], amount), jitter(point[1], amount)];
|
||||
}
|
||||
|
||||
async function manualDelay(): Promise<void> {
|
||||
if (!manualOperation.enabled || !manualOperation.randomSleep) return;
|
||||
const low = Math.max(0, manualOperation.minMs);
|
||||
const high = Math.max(low, manualOperation.maxMs);
|
||||
const timeout = low + Math.random() * (high - low);
|
||||
if (timeout > 0) await new Promise((resolve) => setTimeout(resolve, timeout));
|
||||
}
|
||||
const USER32 = `Add-Type @'
|
||||
using System;
|
||||
using System.Runtime.InteropServices;
|
||||
public static class RpaUser32 {
|
||||
[DllImport("user32.dll")] public static extern bool SetCursorPos(int X, int Y);
|
||||
[DllImport("user32.dll")] public static extern void mouse_event(uint flags, uint dx, uint dy, uint data, UIntPtr extra);
|
||||
[DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow();
|
||||
[DllImport("user32.dll", CharSet=CharSet.Unicode)] public static extern int GetWindowText(IntPtr hWnd, System.Text.StringBuilder text, int count);
|
||||
[DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr hWnd);
|
||||
}
|
||||
'@`;
|
||||
|
||||
function text(args: Record<string, unknown>, keys: string[], fallback = ""): string {
|
||||
for (const key of keys) {
|
||||
if (args[key] !== undefined && args[key] !== null && args[key] !== "") return String(args[key]);
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function bool(value: unknown): boolean {
|
||||
return [true, 1, "1", "true", "True", "是", "开启"].includes(value as never);
|
||||
}
|
||||
|
||||
function number(args: Record<string, unknown>, keys: string[], fallback = 0): number {
|
||||
const value = Number(text(args, keys, String(fallback)));
|
||||
return Number.isFinite(value) ? value : fallback;
|
||||
}
|
||||
|
||||
function point(value: unknown): [number, number] {
|
||||
if (Array.isArray(value)) return [Number(value[0]) || 0, Number(value[1]) || 0];
|
||||
try {
|
||||
const parsed = JSON.parse(String(value || "[0,0]"));
|
||||
return point(parsed);
|
||||
} catch {
|
||||
const values = String(value || "").split(/[ ,,]+/).map(Number);
|
||||
return [values[0] || 0, values[1] || 0];
|
||||
}
|
||||
}
|
||||
|
||||
function encoded(command: string): string {
|
||||
return Buffer.from(command, "utf16le").toString("base64");
|
||||
}
|
||||
|
||||
/** 从 PowerShell 错误输出里提取 CLIXML 段的真实错误文本(否则显示为乱码转义序列) */
|
||||
function decodeCliXml(detail: string): string {
|
||||
const lines = [...detail.matchAll(/<S S="Error">(.*?)<\/S>/gs)].map((m) => m[1]);
|
||||
if (lines.length === 0) return detail;
|
||||
// _xHHHH_ 是 CLIXML 转义
|
||||
const decoded = lines.join("\n").replace(/_x([0-9A-Fa-f]{4})_/g, (_, h) => String.fromCharCode(parseInt(h, 16)));
|
||||
const parts = decoded.split("\n").map((l) => l.trim());
|
||||
const position = parts.filter((l) => l.startsWith("所在位置")).slice(0, 1);
|
||||
const message = parts.filter(
|
||||
(l) => l && !l.startsWith("所在位置") && !l.startsWith("+") && !l.startsWith("~~") && !l.includes("CategoryInfo") && !l.includes("FullyQualifiedErrorId") && !l.startsWith("<Objs") && !l.startsWith("#<")
|
||||
);
|
||||
const main = message[0] || parts[0] || detail;
|
||||
return position.length ? `${main}(${position[0]})` : main;
|
||||
}
|
||||
|
||||
/** 管道字节流解码:优先 UTF-8;出现替换符说明是系统代码页(GBK)写入的(如解析类错误不走 UTF-8 前缀),回退 GB18030 */
|
||||
function decodePipe(buf: Buffer): string {
|
||||
const utf8 = buf.toString("utf8");
|
||||
if (!utf8.includes("\uFFFD")) return utf8;
|
||||
try {
|
||||
return new TextDecoder("gb18030").decode(buf);
|
||||
} catch {
|
||||
return utf8;
|
||||
}
|
||||
}
|
||||
|
||||
export async function powershell(command: string, timeout = 30000): Promise<string> {
|
||||
try {
|
||||
// Windows PowerShell 的默认输出编码依机器代码页变化;Node 按 UTF-8 解码管道。
|
||||
const utf8 = "$ProgressPreference='SilentlyContinue'; [Console]::OutputEncoding=[System.Text.UTF8Encoding]::new($false); $OutputEncoding=[Console]::OutputEncoding; ";
|
||||
const result = await execFileAsync("powershell.exe", ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-EncodedCommand", encoded(utf8 + command)], {
|
||||
windowsHide: true,
|
||||
timeout,
|
||||
maxBuffer: 4 * 1024 * 1024,
|
||||
encoding: "buffer"
|
||||
} as never);
|
||||
return decodePipe(result.stdout as unknown as Buffer).trim();
|
||||
} catch (error) {
|
||||
const err = error as Error & { stdout?: unknown; stderr?: unknown };
|
||||
// 脚本主动失败(RpaFail)把完整错误写在 stdout 的 RPA_ERROR:: 标记后,不经 CLIXML 渲染,无截断
|
||||
const stdoutText = Buffer.isBuffer(err.stdout) ? decodePipe(err.stdout) : String(err.stdout ?? "");
|
||||
const marked = stdoutText.match(/RPA_ERROR::([\s\S]*)/);
|
||||
if (marked) {
|
||||
const detail = marked[1].trim();
|
||||
throw new RuntimeError("desktop_command_failed", `桌面操作失败:${detail}`, { detail });
|
||||
}
|
||||
const stderrBuf = Buffer.isBuffer(err.stderr) ? err.stderr : Buffer.from(String(err.stderr ?? ""), "utf8");
|
||||
const stderrText = stderrBuf.length > 0 ? decodePipe(stderrBuf) : err.message;
|
||||
const detail = decodeCliXml(stderrText);
|
||||
throw new RuntimeError("desktop_command_failed", `桌面操作失败:${detail}`, { detail });
|
||||
}
|
||||
}
|
||||
|
||||
export function psString(value: unknown): string {
|
||||
return `'${String(value ?? "").replace(/'/g, "''")}'`;
|
||||
}
|
||||
|
||||
/** 把 SendKeys 特殊字符按字面转义(+^%~(){}[] → {+} 等);SendKeys 用花括号表示字面量 */
|
||||
function sendKeysEscape(ch: string): string {
|
||||
return "+^%~(){}[]".includes(ch) ? `{${ch}}` : ch;
|
||||
}
|
||||
|
||||
/**
|
||||
* 把字符包装成 PowerShell 安全字面量。
|
||||
* ASCII 直接用单引号字符串(撇号内部 '' 转义);非 ASCII 用 [char]0xXXXX 数值构造——
|
||||
* Windows PowerShell 5.1 会把 ‘ ’ “ ” 等智能引号当作引号变体解析,直接放进字符串字面量会报「字符串缺少终止符」。
|
||||
*/
|
||||
function psCharLiteral(text: string): string {
|
||||
if (/^[\x20-\x7E]*$/.test(text)) return psString(text);
|
||||
return Array.from(text).map((c) => `[char]0x${c.codePointAt(0)!.toString(16).padStart(4, "0")}`).join("+") || psString("");
|
||||
}
|
||||
|
||||
const keyboardSend: CapabilityHandler = async (_ctx, node, args) => {
|
||||
// 字段: words=输入内容(键盘输入), keys/key=按键组合(按键系列), interval=输入间隔(V1毫秒/基础版秒), fuctype=输入方式(0后台接口/1人工模拟/2剪贴板粘贴), window_title=输入目标窗口(留空=当前焦点窗口)
|
||||
const keys = text(args, ["words", "keys", "key", "text", "value"]);
|
||||
if (!keys) throw new RuntimeError("validation_error", "键盘输入为空", { nodeId: node.id, componentId: node.componentId });
|
||||
await manualDelay();
|
||||
const rawInterval = number(args, ["interval"], 0);
|
||||
const intervalMs = node.componentId === "Keyboard_Send" ? Math.max(0, Math.round(rawInterval * 1000)) : Math.max(0, Math.round(rawInterval));
|
||||
const mode = text(args, ["fuctype"], "0");
|
||||
const isTextSend = node.componentId === "Keyboard_Send" || node.componentId === "Keyboard_Send_V1";
|
||||
// 指定目标窗口时先激活再输入,内容才会进到目标窗口而不是运行时的焦点窗口
|
||||
const targetTitle = text(args, ["window_title", "target_window", "window_obj", "match_title", "select_title"]);
|
||||
const activate = targetTitle ? `${activateWindowScript(targetTitle)}; Start-Sleep -Milliseconds 300; ` : "";
|
||||
|
||||
// 三种输入方式:脚本尽量简单,转义逻辑放 JS 侧,避免 PowerShell 字符串拼接的语法陷阱
|
||||
let send: string;
|
||||
if (mode === "2") {
|
||||
// 剪贴板粘贴:原文进剪贴板 + Ctrl+V,不做 SendKeys 转义;内容含智能引号等非 ASCII 时同样走 [char] 构造
|
||||
const contentExpr = /^[\x20-\x7E]*$/.test(keys) ? psString(keys) : `(-join @(${Array.from(keys).map((c) => psCharLiteral(c)).join(",")}))`;
|
||||
send = `Set-Clipboard -Value ${contentExpr}; Start-Sleep -Milliseconds 120; [System.Windows.Forms.SendKeys]::SendWait('^v')`;
|
||||
} else if (mode === "1" && isTextSend) {
|
||||
// 人工模拟:逐字符;JS 侧把每个字符转义成 SendKeys 字面量,PS 侧只做数组循环
|
||||
// 数组元素必须用 psCharLiteral:ASCII 走引号字符串,非 ASCII 走 [char]0xXXXX(避开 PS5.1 智能引号解析陷阱)
|
||||
const escapedChars = Array.from(keys).map((ch) => sendKeysEscape(ch));
|
||||
const arr = escapedChars.map((c) => psCharLiteral(c)).join(",");
|
||||
send = `$chars = @(${arr}); foreach ($c in $chars) { [System.Windows.Forms.SendKeys]::SendWait([string]$c); Start-Sleep -Milliseconds ${intervalMs} }`;
|
||||
} else {
|
||||
// 后台接口 / 按键组合 / 人工模拟(按键组合组件):整段发送,文本特殊字符转义
|
||||
const escaped = isTextSend ? Array.from(keys).map(sendKeysEscape).join("") : keys;
|
||||
const contentExpr = /^[\x20-\x7E]*$/.test(escaped) ? psString(escaped) : `(-join @(${Array.from(escaped).map((c) => psCharLiteral(c)).join(",")}))`;
|
||||
send = `[System.Windows.Forms.SendKeys]::SendWait(${contentExpr})`;
|
||||
}
|
||||
await powershell(`${USER32}; ${activate}Add-Type -AssemblyName System.Windows.Forms; ${send}`);
|
||||
await manualDelay();
|
||||
return keys;
|
||||
};
|
||||
|
||||
const enableManualOperation: CapabilityHandler = async (_ctx, _node, args) => {
|
||||
const min = number(args, ["min_time", "minTime"], 1) * 1000;
|
||||
const max = number(args, ["max_time", "maxTime"], 3) * 1000;
|
||||
manualOperation.enabled = true;
|
||||
manualOperation.randomMove = bool(args.random_move ?? true);
|
||||
manualOperation.randomClick = bool(args.random_click ?? true);
|
||||
manualOperation.randomSleep = bool(args.random_sleep ?? true);
|
||||
manualOperation.minMs = min;
|
||||
manualOperation.maxMs = Math.max(min, max);
|
||||
return { enabled: true, randomMove: manualOperation.randomMove, randomClick: manualOperation.randomClick, randomSleep: manualOperation.randomSleep, minMs: manualOperation.minMs, maxMs: manualOperation.maxMs };
|
||||
};
|
||||
|
||||
const disableManualOperation: CapabilityHandler = async () => {
|
||||
manualOperation.enabled = false;
|
||||
return { enabled: false };
|
||||
};
|
||||
|
||||
const mouseClick: CapabilityHandler = async (_ctx, node, args) => {
|
||||
const [x, y] = manualJitter(point(args.position ?? args.point ?? args.location ?? "[0,0]"), false, true);
|
||||
const button = text(args, ["side", "button"], "0").toLowerCase();
|
||||
const down = button.includes("right") || button === "1" ? 0x0008 : 0x0002;
|
||||
const up = button.includes("right") || button === "1" ? 0x0010 : 0x0004;
|
||||
await powershell(`${USER32}; [RpaUser32]::SetCursorPos(${x},${y}); [RpaUser32]::mouse_event(${down},0,0,0,[UIntPtr]::Zero); [RpaUser32]::mouse_event(${up},0,0,0,[UIntPtr]::Zero)`);
|
||||
return { x, y };
|
||||
};
|
||||
|
||||
const mouseMove: CapabilityHandler = async (_ctx, node, args) => {
|
||||
const [x, y] = manualJitter(point(args.position ?? args.point ?? args.location ?? "[0,0]"), true, false);
|
||||
await powershell(`${USER32}; [RpaUser32]::SetCursorPos(${x},${y})`);
|
||||
return { x, y };
|
||||
};
|
||||
|
||||
const mouseDrag: CapabilityHandler = async (_ctx, node, args) => {
|
||||
const [sx, sy] = point(args.Start ?? args.start ?? "[0,0]");
|
||||
const [ex, ey] = point(args.End ?? args.end ?? "[0,0]");
|
||||
await powershell(`${USER32}; [RpaUser32]::SetCursorPos(${sx},${sy}); [RpaUser32]::mouse_event(0x0002,0,0,0,[UIntPtr]::Zero); Start-Sleep -Milliseconds 80; [RpaUser32]::SetCursorPos(${ex},${ey}); Start-Sleep -Milliseconds 80; [RpaUser32]::mouse_event(0x0004,0,0,0,[UIntPtr]::Zero)`);
|
||||
return { start: [sx, sy], end: [ex, ey] };
|
||||
};
|
||||
|
||||
const mouseRoll: CapabilityHandler = async (_ctx, node, args) => {
|
||||
const amount = number(args, ["RollNum", "rollNum", "amount"], 1) * 120;
|
||||
await powershell(`${USER32}; [RpaUser32]::mouse_event(0x0800,0,0,${amount},[UIntPtr]::Zero)`);
|
||||
return amount;
|
||||
};
|
||||
|
||||
/** 把匹配标题的窗口带到前台;多招式依次兜底(应用点击「运行」后自身是前台,
|
||||
* Windows 前台锁定策略会拒绝普通 SetForegroundWindow,最小化再恢复可绕过)。
|
||||
* 失败用 RPA_ERROR 标记写 stdout 后 exit 1:不走 throw/CLIXML,避免控制台宽度截断和编码歧义 */
|
||||
function activateWindowScript(selector: string): string {
|
||||
const safeTitle = selector.replace(/'/g, "''");
|
||||
return `function RpaFail($m) { [Console]::Out.WriteLine('RPA_ERROR::' + $m); exit 1 }
|
||||
$p = Get-Process | Where-Object { $_.MainWindowTitle -and $_.MainWindowTitle -like ${psString(`*${selector}*`)} } | Select-Object -First 1
|
||||
if (-not $p) { $vis = (Get-Process | Where-Object { $_.MainWindowTitle } | Select-Object -First 12 | ForEach-Object { '『' + $_.MainWindowTitle + '』' }) -join ' '; RpaFail ('找不到窗口:${safeTitle}。当前可见窗口标题: ' + $vis) }
|
||||
Add-Type -MemberDefinition '[DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow(); [DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr h, int cmd); [DllImport("user32.dll")] public static extern bool IsIconic(IntPtr h); [DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr h); [DllImport("user32.dll")] public static extern void keybd_event(byte bVk, byte bScan, uint dwFlags, UIntPtr dwExtraInfo); [DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr hWnd, IntPtr pid); [DllImport("user32.dll")] public static extern bool AttachThreadInput(uint a, uint b, bool attach); [DllImport("user32.dll")] public static extern void SwitchToThisWindow(IntPtr hWnd, bool fAltTab); [DllImport("kernel32.dll")] public static extern uint GetCurrentThreadId();' -Name RpaFocus2 -Namespace WinRpaFocus2 | Out-Null
|
||||
$h = $p.MainWindowHandle
|
||||
if ([WinRpaFocus2.RpaFocus2]::IsIconic($h)) { [void][WinRpaFocus2.RpaFocus2]::ShowWindow($h, 9); Start-Sleep -Milliseconds 150 }
|
||||
if ([WinRpaFocus2.RpaFocus2]::GetForegroundWindow() -ne $h) {
|
||||
[void][WinRpaFocus2.RpaFocus2]::SwitchToThisWindow($h, $true)
|
||||
Start-Sleep -Milliseconds 150
|
||||
}
|
||||
if ([WinRpaFocus2.RpaFocus2]::GetForegroundWindow() -ne $h) {
|
||||
[void][WinRpaFocus2.RpaFocus2]::keybd_event(0x12, 0, 0, [UIntPtr]::Zero)
|
||||
[void][WinRpaFocus2.RpaFocus2]::SetForegroundWindow($h)
|
||||
[void][WinRpaFocus2.RpaFocus2]::keybd_event(0x12, 0, 2, [UIntPtr]::Zero)
|
||||
Start-Sleep -Milliseconds 200
|
||||
}
|
||||
if ([WinRpaFocus2.RpaFocus2]::GetForegroundWindow() -ne $h) {
|
||||
$fgThread = [WinRpaFocus2.RpaFocus2]::GetWindowThreadProcessId([WinRpaFocus2.RpaFocus2]::GetForegroundWindow(), [IntPtr]::Zero)
|
||||
$thisThread = [WinRpaFocus2.RpaFocus2]::GetCurrentThreadId()
|
||||
$attached = [WinRpaFocus2.RpaFocus2]::AttachThreadInput($thisThread, $fgThread, $true)
|
||||
try { [void][WinRpaFocus2.RpaFocus2]::SetForegroundWindow($h) } finally { if ($attached) { [void][WinRpaFocus2.RpaFocus2]::AttachThreadInput($thisThread, $fgThread, $false) } }
|
||||
Start-Sleep -Milliseconds 200
|
||||
}
|
||||
if ([WinRpaFocus2.RpaFocus2]::GetForegroundWindow() -ne $h) {
|
||||
[void][WinRpaFocus2.RpaFocus2]::ShowWindow($h, 6)
|
||||
Start-Sleep -Milliseconds 150
|
||||
[void][WinRpaFocus2.RpaFocus2]::ShowWindow($h, 9)
|
||||
Start-Sleep -Milliseconds 300
|
||||
}
|
||||
if ([WinRpaFocus2.RpaFocus2]::GetForegroundWindow() -ne $h) { RpaFail '无法把窗口带到前台,请确认窗口未被关闭或全屏占用' }`;
|
||||
}
|
||||
|
||||
const setActiveWindow: CapabilityHandler = async (_ctx, node, args) => {
|
||||
const selector = text(args, ["window_obj", "match_title", "select_title", "title", "target_window", "window_title"]);
|
||||
if (!selector) throw new RuntimeError("validation_error", "窗口标题为空", { nodeId: node.id, componentId: node.componentId });
|
||||
const result = await powershell(`${USER32}
|
||||
${activateWindowScript(selector)}
|
||||
$p.MainWindowTitle`);
|
||||
return result;
|
||||
};
|
||||
|
||||
const getActiveWindow: CapabilityHandler = async () => powershell(`${USER32}; $sb = New-Object System.Text.StringBuilder 512; [RpaUser32]::GetWindowText([RpaUser32]::GetForegroundWindow(),$sb,$sb.Capacity) | Out-Null; $sb.ToString()`);
|
||||
|
||||
const windowExists: CapabilityHandler = async (_ctx, node, args) => {
|
||||
const selector = text(args, ["window_obj", "match_title", "select_title", "title", "target_window", "window_title"]);
|
||||
if (!selector) throw new RuntimeError("validation_error", "窗口标题为空", { nodeId: node.id, componentId: node.componentId });
|
||||
return (await powershell(`$p = Get-Process | Where-Object { $_.MainWindowTitle -and $_.MainWindowTitle -like ${psString(`*${selector}*`)} } | Select-Object -First 1; [bool]$p`)).toLowerCase() === "true";
|
||||
};
|
||||
|
||||
const closeWindow: CapabilityHandler = async (_ctx, node, args) => {
|
||||
const selector = text(args, ["window_obj", "match_title", "select_title", "title", "target_window", "window_title"]);
|
||||
if (!selector) throw new RuntimeError("validation_error", "窗口标题为空", { nodeId: node.id, componentId: node.componentId });
|
||||
return powershell(`$p = Get-Process | Where-Object { $_.MainWindowTitle -and $_.MainWindowTitle -like ${psString(`*${selector}*`)} } | Select-Object -First 1; if (-not $p) { throw '找不到窗口' }; $p.CloseMainWindow() | Out-Null; $true`);
|
||||
};
|
||||
|
||||
const runApp: CapabilityHandler = async (_ctx, node, args) => {
|
||||
const executable = text(args, ["executeable", "executable", "path"]);
|
||||
if (!executable) throw new RuntimeError("validation_error", "应用路径为空", { nodeId: node.id, componentId: node.componentId });
|
||||
const params = text(args, ["params", "arguments"]);
|
||||
const cwd = text(args, ["cwd"]);
|
||||
const argPart = params ? ` -ArgumentList ${psString(params)}` : "";
|
||||
const result = await powershell(`$p = Start-Process -FilePath ${psString(executable)}${argPart} ${cwd ? `-WorkingDirectory ${psString(cwd)}` : ""} -PassThru; $p.Id`);
|
||||
return Number(result) || result;
|
||||
};
|
||||
|
||||
const clipboardGet: CapabilityHandler = async () => powershell("Get-Clipboard -Raw");
|
||||
const clipboardSet: CapabilityHandler = async (_ctx, _node, args) => {
|
||||
const content = text(args, ["text", "value"]);
|
||||
await powershell(`Set-Clipboard -Value ${psString(content)}`);
|
||||
return content;
|
||||
};
|
||||
|
||||
const notice: CapabilityHandler = async (_ctx, _node, args) => {
|
||||
const title = text(args, ["title"], "提醒");
|
||||
const content = text(args, ["text", "message"]);
|
||||
await powershell(`Add-Type -AssemblyName System.Windows.Forms; [System.Windows.Forms.MessageBox]::Show(${psString(content)},${psString(title)}) | Out-Null`);
|
||||
return true;
|
||||
};
|
||||
|
||||
const executeCommand: CapabilityHandler = async (_ctx, node, args) => {
|
||||
const command = text(args, ["action", "command"]);
|
||||
if (!command) throw new RuntimeError("validation_error", "命令行为空", { nodeId: node.id, componentId: node.componentId });
|
||||
return powershell(command, Math.max(1000, number(args, ["timeoutMs"], 120000)));
|
||||
};
|
||||
|
||||
|
||||
register(["Keyboard_Send_V1", "Keyboard_Send", "Keyboard_PressNew", "Keyboard_Press"], keyboardSend);
|
||||
register("Keyboard_EnableManualOperation", enableManualOperation);
|
||||
register("Keyboard_DisableManualOperation", disableManualOperation);
|
||||
register(["Mouse_Click", "Mouse_ClickNew"], mouseClick);
|
||||
register(["Mouse_MoveTo", "Mouse_Move"], mouseMove);
|
||||
register(["Mouse_DragTo", "Mouse_Drag"], mouseDrag);
|
||||
register(["Mouse_RollTo", "Mouse_Scroll"], mouseRoll);
|
||||
register(["Window_SetActive", "Window_Activate"], setActiveWindow);
|
||||
register(["Window_GetActive", "Window_GetActiveWindow"], getActiveWindow);
|
||||
register(["Window_WindowExists", "Window_Exists"], windowExists);
|
||||
register(["Window_CloseWindow", "Window_Close"], closeWindow);
|
||||
register(["App_Run", "App_Start"], runApp);
|
||||
register("Clipboard_GetText", clipboardGet);
|
||||
register("Clipboard_SetText", clipboardSet);
|
||||
register("Dialog_Notice_V1", async (_ctx, node, args) => {
|
||||
const msg = text(args, ["text", "message"]);
|
||||
const title = text(args, ["title"], "通知");
|
||||
const timeout = Math.max(1, number(args, ["timeout", "time_out"], 10));
|
||||
return powershell("(New-Object -ComObject WScript.Shell).Popup(" + psString(msg) + ", " + timeout + ", " + psString(title) + ", 0) | Out-Null; $true");
|
||||
});
|
||||
|
||||
register("Dialog_Notice_V2", async (ctx, node, args) => {
|
||||
const msg = text(args, ["text", "message"]);
|
||||
const title = text(args, ["title"], "通知");
|
||||
const timeout = Math.max(1, number(args, ["time_out", "timeout"], 10));
|
||||
return powershell("(New-Object -ComObject WScript.Shell).Popup(" + psString(msg) + ", " + timeout + ", " + psString(title) + ", 0) | Out-Null; $true");
|
||||
});
|
||||
|
||||
register("Dialog_CsMsgBox", async (_ctx, _node, args) => {
|
||||
const msg = text(args, ["text", "message"]);
|
||||
const title = text(args, ["title"], "提示");
|
||||
const buttons = Number(args.msg_button ?? 0);
|
||||
const autoClose = Number(args.autoClose ?? 0);
|
||||
const time = Math.max(1, Number(args.time ?? 10));
|
||||
const popup = "(New-Object -ComObject WScript.Shell).Popup(" + psString(msg) + ", " + (autoClose ? time : -1) + ", " + psString(title) + ", " + buttons + ")";
|
||||
const choice = await powershell(popup + " | Out-Null; if (" + (autoClose ? "$true" : "$false") + ") {}");
|
||||
return powershell(popup);
|
||||
});
|
||||
|
||||
register("Dialog_OpenFile", async (_ctx, _node, args) => {
|
||||
const title = text(args, ["title"], "选择文件");
|
||||
const wildcard = text(args, ["wildcard"], "所有文件|*.*");
|
||||
return powershell("Add-Type -AssemblyName System.Windows.Forms | Out-Null; $dlg = New-Object System.Windows.Forms.OpenFileDialog; $dlg.Filter = " + psString(wildcard) + "; $dlg.Title = " + psString(title) + "; if ($dlg.ShowDialog() -eq 'OK') { $dlg.FileName } else { '' }");
|
||||
});
|
||||
|
||||
register("Dialog_OpenFiles", async (_ctx, _node, args) => {
|
||||
const title = text(args, ["title"], "选择文件");
|
||||
const wildcard = text(args, ["wildcard"], "所有文件|*.*");
|
||||
return powershell("Add-Type -AssemblyName System.Windows.Forms | Out-Null; $dlg = New-Object System.Windows.Forms.OpenFileDialog; $dlg.Filter = " + psString(wildcard) + "; $dlg.Title = " + psString(title) + "; $dlg.Multiselect = $true; if ($dlg.ShowDialog() -eq 'OK') { $dlg.FileNames -join ';' } else { '' }");
|
||||
});
|
||||
|
||||
register("Dialog_SaveFile", async (_ctx, _node, args) => {
|
||||
const title = text(args, ["title"], "保存文件");
|
||||
const wildcard = text(args, ["wildcard"], "所有文件|*.*");
|
||||
return powershell("Add-Type -AssemblyName System.Windows.Forms | Out-Null; $dlg = New-Object System.Windows.Forms.SaveFileDialog; $dlg.Filter = " + psString(wildcard) + "; $dlg.Title = " + psString(title) + "; if ($dlg.ShowDialog() -eq 'OK') { $dlg.FileName } else { '' }");
|
||||
});
|
||||
|
||||
register(["Dialog_InputBox", "Dialog_CsInputBox"], async (_ctx, _node, args) => {
|
||||
const title = text(args, ["title", "input_title"], "请输入");
|
||||
const description = text(args, ["description", "txt_content"], "");
|
||||
const defaultValue = text(args, ["text"], "");
|
||||
return powershell("Add-Type -AssemblyName Microsoft.VisualBasic | Out-Null; [Microsoft.VisualBasic.Interaction]::InputBox(" + psString(description) + ", " + psString(title) + ", " + psString(defaultValue) + ")");
|
||||
});
|
||||
|
||||
register("Dialog_Notice", notice);
|
||||
register("Dialog_MsgBox", notice);
|
||||
register("System_ExecuteCommand", executeCommand);
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import ExcelJS from "exceljs";
|
||||
import { getHandler } from "../engine/registry";
|
||||
import type { CatalogComponent, FlowNode } from "../types";
|
||||
import type { RunContext } from "../engine/types";
|
||||
import "./excel-com";
|
||||
|
||||
const directories: string[] = [];
|
||||
const context = {} as RunContext;
|
||||
const component = {} as CatalogComponent;
|
||||
const node = (id: string): FlowNode => ({ id, componentId: id, position: { x: 0, y: 0 }, values: {}, options: {} });
|
||||
async function run(id: string, args: Record<string, unknown>) {
|
||||
const handler = getHandler(id);
|
||||
expect(handler, id).toBeTruthy();
|
||||
return handler!(context, node(id), args, component);
|
||||
}
|
||||
afterEach(async () => Promise.all(directories.splice(0).map((directory) => rm(directory, { recursive: true, force: true }))));
|
||||
|
||||
const registered = [
|
||||
"Excel_Cs_Group", "Excel_Cs_DeletePicture", "Excel_Cs_RefreshPivotTable", "Excel_Cs_DeletePictureV2",
|
||||
"Excel_Cs_Encrypt", "Excel_Cs_Move", "Excel_Cs_InsertPicture", "Excel_Cs_Merge", "Excel_InsertImage",
|
||||
"Excel_Cs_InsertPivotView", "Excel_Cs_Interop", "Excel_Cs_InsertPivotTable", "Excel_Cs_RunMacro",
|
||||
"Excel_Loop", "Excel_Cs_Export", "Excel_Cs_ExcelAddin", "Excel_Cs_InsertPictureV2",
|
||||
"Excel_Cs_FilterPivotTable", "Excel_Cs_RunMacroV2", "Excel_Cs_SetSlicer", "Excel_ExecuteMacro",
|
||||
"Excel_Cs_RefreshAll", "Excel_DeleteImage", "Excel_ExportRangeAsPicture"
|
||||
];
|
||||
|
||||
describe("Excel COM capabilities", () => {
|
||||
it("registers every Excel COM capability", () => { for (const id of registered) expect(getHandler(id), id).toBeTruthy(); });
|
||||
|
||||
it("groups rows, moves sheets, reads dynamic properties and loops content", async () => {
|
||||
if (process.platform !== "win32") return;
|
||||
const directory = await mkdtemp(path.join(os.tmpdir(), "rpa-excel-com-")); directories.push(directory);
|
||||
const filePath = path.join(directory, "workbook.xlsx");
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
workbook.addWorksheet("Sheet1").getCell("A1").value = "A1";
|
||||
workbook.addWorksheet("Second").getCell("A1").value = "S1";
|
||||
await workbook.xlsx.writeFile(filePath);
|
||||
|
||||
await expect(run("Excel_Cs_Group", { workbook: { path: filePath }, sheet: "Sheet1", group: 0, groupType: 0, startRow: 1, endRow: 2, save: true })).resolves.toMatchObject({ grouped: true });
|
||||
await expect(run("Excel_Cs_Interop", { workbook: { path: filePath }, obj: 2, sheet: "Sheet1", property: "Name" })).resolves.toMatchObject({ value: "Sheet1" });
|
||||
await expect(run("Excel_Cs_Move", { workbook: { path: filePath }, sheet: "Second", mode: "按序号", index: 1, save: true })).resolves.toMatchObject({ moved: true });
|
||||
await expect(run("Excel_Cs_RefreshAll", { workbook: { path: filePath }, save: true })).resolves.toMatchObject({ refreshed: true });
|
||||
const loop = await run("Excel_Loop", { objExcel: { path: filePath } }) as unknown[][];
|
||||
expect(loop.length).toBeGreaterThanOrEqual(2);
|
||||
expect(loop.flat().join(",")).toContain("A1");
|
||||
}, 120000);
|
||||
|
||||
it("exports a workbook to PDF through Excel COM", async () => {
|
||||
if (process.platform !== "win32") return;
|
||||
const directory = await mkdtemp(path.join(os.tmpdir(), "rpa-excel-pdf-")); directories.push(directory);
|
||||
const filePath = path.join(directory, "source.xlsx");
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
workbook.addWorksheet("Report").getCell("A1").value = "PDF";
|
||||
await workbook.xlsx.writeFile(filePath);
|
||||
const output = await run("Excel_Cs_Export", { workbook: { path: filePath }, option: "全部", path: directory, name: "report.pdf", cover: true }) as string;
|
||||
expect(output).toBe(path.join(directory, "report.pdf"));
|
||||
expect((await readFile(output)).subarray(0, 5).toString()).toMatch(/^%PDF-/);
|
||||
}, 120000);
|
||||
});
|
||||
@@ -0,0 +1,288 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import ExcelJS from "exceljs";
|
||||
import { register } from "../engine/registry";
|
||||
import { RuntimeError, type CapabilityHandler } from "../engine/types";
|
||||
import { powershell, psString } from "./desktop";
|
||||
import { releaseWorkbook } from "./excel-store";
|
||||
|
||||
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 bool(value: unknown, fallback = false): boolean {
|
||||
if (value === undefined || value === null || value === "") return fallback;
|
||||
return [true, 1, "1", "true", "True", "TRUE", "yes", "是", "开启"].includes(value as never);
|
||||
}
|
||||
|
||||
function number(value: unknown, fallback = 0): number { const result = Number(value); return Number.isFinite(result) ? result : fallback; }
|
||||
|
||||
function workbookPath(args: Record<string, unknown>, nodeId: string, componentId: string, keys = ["workbook", "objExcel", "objExcelWorkBook", "destWorkbook"]): string {
|
||||
for (const key of keys) {
|
||||
const raw = args[key];
|
||||
const candidate = raw && typeof raw === "object" ? (raw as { path?: unknown }).path : raw;
|
||||
const value = String(candidate ?? "").trim();
|
||||
if (value && value !== "Excel文件" && value !== "objExcelWorkBook") return path.resolve(value);
|
||||
}
|
||||
throw new RuntimeError("validation_error", "Excel文件路径为空", { nodeId, componentId });
|
||||
}
|
||||
|
||||
function sheetExpression(args: Record<string, unknown>): string {
|
||||
const sheet = text(args, ["sheet", "sheetName"]);
|
||||
return sheet ? `$workbook.Worksheets.Item(${psString(sheet)})` : "$workbook.ActiveSheet";
|
||||
}
|
||||
|
||||
async function runExcelCom(filePath: string, body: string, save = false, readonly = false, timeout = 120000): Promise<unknown> {
|
||||
const openMode = readonly ? "$true" : "$false";
|
||||
const saveMode = save ? "$true" : "$false";
|
||||
const script = [
|
||||
"$excel=$null;$workbook=$null;$rpaOutput=$null",
|
||||
"try {",
|
||||
" $excel=New-Object -ComObject Excel.Application;$excel.Visible=$false;$excel.DisplayAlerts=$false",
|
||||
" $workbook=$excel.Workbooks.Open(" + psString(filePath) + ",0," + openMode + ")",
|
||||
" " + body,
|
||||
" if (" + saveMode + ") { $workbook.Save() }",
|
||||
" if ($null -ne $rpaOutput) { [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes(($rpaOutput | ConvertTo-Json -Compress -Depth 6))) | Write-Output }",
|
||||
"} finally {",
|
||||
" if($null -ne $workbook){try{$workbook.Close($false)}catch{};try{[void][Runtime.InteropServices.Marshal]::ReleaseComObject($workbook)}catch{}}",
|
||||
" if($null -ne $excel){try{$excel.Quit()}catch{};try{[void][Runtime.InteropServices.Marshal]::ReleaseComObject($excel)}catch{}}",
|
||||
" [GC]::Collect();[GC]::WaitForPendingFinalizers()",
|
||||
"}"
|
||||
].join("\n");
|
||||
const output = (await powershell(script, timeout)).trim();
|
||||
if (save) releaseWorkbook(filePath);
|
||||
if (!output) return null;
|
||||
try { return JSON.parse(Buffer.from(output, "base64").toString("utf8")); }
|
||||
catch { throw new RuntimeError("excel_com_failed", "Excel COM 输出异常", { detail: output.slice(0, 300) }); }
|
||||
}
|
||||
|
||||
function list(value: unknown): string[] {
|
||||
if (Array.isArray(value)) return value.map((item) => String(item)).filter(Boolean);
|
||||
const raw = String(value ?? "").trim();
|
||||
if (!raw) return [];
|
||||
try { const parsed = JSON.parse(raw); return Array.isArray(parsed) ? parsed.map(String) : [String(parsed)]; }
|
||||
catch { return raw.split(/[,,;;\s]+/).filter(Boolean); }
|
||||
}
|
||||
const group: CapabilityHandler = async (_ctx, node, args) => {
|
||||
const filePath = workbookPath(args, node.id, node.componentId, ["workbook"]);
|
||||
const ungroup = String(args.group ?? "0") === "1";
|
||||
const columns = String(args.groupType ?? "0") === "1";
|
||||
const start = columns ? text(args, ["startColumn"], "A") : String(number(args.startRow, 1));
|
||||
const end = columns ? text(args, ["endColumn"], text(args, ["startColumn"], "A")) : String(number(args.endRow, number(args.startRow, 1)));
|
||||
const method = ungroup ? "Ungroup" : "Group";
|
||||
const body = `$sheet=${sheetExpression(args)};$start=${psString(start)};$end=${psString(end)};
|
||||
$count=if(${columns ? "$true" : "$false"}){$sheet.UsedRange.Columns.Count}else{$sheet.UsedRange.Rows.Count};
|
||||
foreach($name in @("start","end")){ $v=[int](Get-Variable $name -ValueOnly); if($v -lt 0){ Set-Variable $name ($count+$v+1) } };
|
||||
$range=$sheet.Range("$($start):$($end)");$range.${method}|Out-Null`;
|
||||
await runExcelCom(filePath, body, bool(args.save));
|
||||
return { grouped: !ungroup, type: columns ? "columns" : "rows" };
|
||||
};
|
||||
|
||||
const deletePicture: CapabilityHandler = async (_ctx, node, args) => {
|
||||
const filePath = workbookPath(args, node.id, node.componentId, ["workbook", "objExcel"]);
|
||||
const name = text(args, ["name", "pictureName"]);
|
||||
if (!name) throw new RuntimeError("validation_error", "图片名称为空", { nodeId: node.id, componentId: node.componentId });
|
||||
const body = `$sheet=${sheetExpression(args)};$sheet.Shapes.Item(${psString(name)}).Delete()`;
|
||||
await runExcelCom(filePath, body, bool(args.save));
|
||||
return { deleted: name };
|
||||
};
|
||||
|
||||
const insertPicture: CapabilityHandler = async (_ctx, node, args) => {
|
||||
const filePath = workbookPath(args, node.id, node.componentId, ["workbook", "objExcel"]);
|
||||
const image = path.resolve(text(args, ["path", "pictureName"]));
|
||||
if (!fs.existsSync(image)) throw new RuntimeError("validation_error", "图片文件不存在", { nodeId: node.id, componentId: node.componentId, detail: image });
|
||||
const row = number(args.row, 1); const column = number(args.column, 1);
|
||||
const left = number(args.Left ?? args.left, 20); const top = number(args.Top ?? args.top, 20);
|
||||
const width = number(args.Width ?? args.width, -1); const height = number(args.Height ?? args.height, -1);
|
||||
const scale = width > 0 && height > 0 ? "$true" : "$false";
|
||||
const body = `$sheet=${sheetExpression(args)};$cell=$sheet.Cells(${row},${column});$left=${left}+if($left -gt 0){$cell.Left}else{0};$top=${top}+if($top -gt 0){$cell.Top}else{0};[void]$sheet.Shapes.AddPicture(${psString(image)},$false,${scale},$left,$top,${width},${height})`;
|
||||
await runExcelCom(filePath, body, bool(args.save));
|
||||
return { inserted: image };
|
||||
};
|
||||
|
||||
const encryptExcel: CapabilityHandler = async (_ctx, node, args) => {
|
||||
const filePath = workbookPath(args, node.id, node.componentId, ["workbook"]);
|
||||
const password = String(args.password1 ?? args.password ?? "");
|
||||
const confirm = String(args.password2 ?? password);
|
||||
if (!password) throw new RuntimeError("validation_error", "Excel密码为空", { nodeId: node.id, componentId: node.componentId });
|
||||
if (password !== confirm) throw new RuntimeError("validation_error", "两次输入的Excel密码不一致", { nodeId: node.id, componentId: node.componentId });
|
||||
const temporary = `${filePath}.${Date.now()}.enc.xlsx`;
|
||||
try {
|
||||
await runExcelCom(filePath, `$workbook.SaveAs(${psString(temporary)},51,${psString(password)},${psString(confirm)})`, false);
|
||||
fs.rmSync(temporary, { force: true });
|
||||
// COM SaveAs cannot replace the workbook while it is open, so save beside it and rename after Close.
|
||||
} finally {
|
||||
if (fs.existsSync(temporary)) {
|
||||
fs.rmSync(filePath, { force: true });
|
||||
fs.renameSync(temporary, filePath);
|
||||
}
|
||||
releaseWorkbook(filePath);
|
||||
}
|
||||
return filePath;
|
||||
};
|
||||
|
||||
const moveSheet: CapabilityHandler = async (_ctx, node, args) => {
|
||||
const filePath = workbookPath(args, node.id, node.componentId, ["workbook"]);
|
||||
const mode = text(args, ["mode"], "按序号"); const after = text(args, ["option"], "之前").includes("之后");
|
||||
const targetName = text(args, ["name", "index"]); const position = text(args, ["position", "index"], "第一位");
|
||||
const index = number(args.index, 1);
|
||||
const afterMode = after ? "$true" : "$false";
|
||||
const body = `$sheet=${sheetExpression(args)};$count=$workbook.Worksheets.Count;$targetIndex=0;
|
||||
if(${psString(mode)} -eq "按名称"){ $targetName=${psString(targetName)}; for($i=1;$i -le $count;$i++){ if($workbook.Worksheets.Item($i).Name -eq $targetName){$targetIndex=$i;break} }; if($targetIndex -eq 0){throw "target_sheet_not_found"} }
|
||||
elseif(${psString(mode)} -eq "按位置"){ $p=${psString(position)}; if($p -eq "第一位"){$targetIndex=1}elseif($p -eq "最后一位"){$targetIndex=$count}else{ $delta=-1; if($p -eq "向后移一位"){$delta=1}; $targetIndex=$sheet.Index+$delta } }
|
||||
else { $targetIndex=${index}; if($targetIndex -lt 0){$targetIndex=$count+$targetIndex+1} };
|
||||
$targetIndex=[Math]::Min([Math]::Max(1,$targetIndex),$count);$target=$workbook.Worksheets.Item($targetIndex);
|
||||
if(${afterMode}){ if($target.Index -ne $sheet.Index){ $sheet.Move([Type]::Missing,$target)|Out-Null } }
|
||||
else { if($target.Index -ne $sheet.Index){ $sheet.Move($target)|Out-Null } }`;
|
||||
await runExcelCom(filePath, body, bool(args.save));
|
||||
return { moved: true };
|
||||
};
|
||||
const mergeSheet: CapabilityHandler = async (_ctx, node, args) => {
|
||||
const filePath = workbookPath(args, node.id, node.componentId, ["workbook"]);
|
||||
const destPath = workbookPath(args, node.id, node.componentId, ["destWorkbook", "workbook"]);
|
||||
const horizontal = String(args.option ?? "0") === "1";
|
||||
const horizontalMode = horizontal ? "$true" : "$false";
|
||||
const saveDest = bool(args.save) ? "$true" : "$false";
|
||||
const body = `$source=${sheetExpression(args)};$dest=$excel.Workbooks.Open(${psString(destPath)});$target=$dest.Worksheets.Item(${psString(text(args, ["destSheet"], "Sheet1"))});
|
||||
if(${horizontalMode}){$offset=$target.UsedRange.Columns.Count+1}else{$offset=$target.UsedRange.Rows.Count+1};
|
||||
for($r=1;$r -le $source.UsedRange.Rows.Count;$r++){for($c=1;$c -le $source.UsedRange.Columns.Count;$c++){ if(${horizontalMode}){$target.Cells($r,$offset+$c-1).Value2=$source.Cells($r,$c).Value2}else{$target.Cells($offset+$r-1,$c).Value2=$source.Cells($r,$c).Value2}}};
|
||||
if(${saveDest}){$dest.Save()}`;
|
||||
await runExcelCom(filePath, body, false);
|
||||
return { merged: true, target: destPath };
|
||||
};
|
||||
|
||||
const refreshAll: CapabilityHandler = async (_ctx, node, args) => {
|
||||
const filePath = workbookPath(args, node.id, node.componentId, ["workbook"]);
|
||||
await runExcelCom(filePath, "$workbook.RefreshAll()", bool(args.save));
|
||||
return { refreshed: true };
|
||||
};
|
||||
|
||||
const refreshPivot: CapabilityHandler = async (_ctx, node, args) => {
|
||||
const filePath = workbookPath(args, node.id, node.componentId, ["workbook"]);
|
||||
const name = text(args, ["pivotTable"]); const sheetExpr = sheetExpression(args);
|
||||
const body = name ? `${sheetExpr}.PivotTables(${psString(name)}).RefreshTable()` : `${sheetExpr}.PivotTables() | ForEach-Object { $_.RefreshTable() }`;
|
||||
await runExcelCom(filePath, body, bool(args.save));
|
||||
return { refreshed: true, name: name || "all" };
|
||||
};
|
||||
|
||||
const filterPivot: CapabilityHandler = async (_ctx, node, args) => {
|
||||
const filePath = workbookPath(args, node.id, node.componentId, ["workbook"]);
|
||||
const field = text(args, ["field"]); const values = list(args.fieldValue);
|
||||
if (!field) throw new RuntimeError("validation_error", "筛选字段为空", { nodeId: node.id, componentId: node.componentId });
|
||||
const encoded = Buffer.from(JSON.stringify(values), "utf16le").toString("base64");
|
||||
const body = `$pt=${sheetExpression(args)}.PivotTables(${psString(text(args, ["pivotTable"]))});$pf=$pt.PivotFields(${psString(field)});
|
||||
try{$pf.ClearAllFilters()}catch{};
|
||||
$wanted=[Text.Encoding]::Unicode.GetString([Convert]::FromBase64String("${encoded}"))|ConvertFrom-Json;
|
||||
foreach($item in $pf.PivotItems()){ $selected=if($wanted.Count -eq 0){$true}else{$wanted -contains $item.Name}; $item.Visible=$selected }`;
|
||||
await runExcelCom(filePath, body, bool(args.save));
|
||||
return { field, values };
|
||||
};
|
||||
|
||||
const createPivot: CapabilityHandler = async (_ctx, node, args) => {
|
||||
const filePath = workbookPath(args, node.id, node.componentId, ["workbook"]);
|
||||
const chart = node.componentId.includes("PivotView");
|
||||
let rule: Record<string, unknown> = {};
|
||||
try { rule = JSON.parse(String(args.rule || "{}")) as Record<string, unknown>; } catch { /* sourceData can still be supplied separately. */ }
|
||||
const source = text({ ...rule, ...args }, ["SourceData", "sourceData", "source"]);
|
||||
if (!source) throw new RuntimeError("validation_error", "透视表数据源为空", { nodeId: node.id, componentId: node.componentId });
|
||||
const name = text(rule, ["Name", "name"], chart ? "RPAPivotChart" : "RPAPivotTable");
|
||||
const body = `$sheet=${sheetExpression(args)};$cache=$excel.ActiveWorkbook.PivotCaches().Create(1,$sheet.Range(${psString(source)}));
|
||||
$dest=$sheet.Cells(1,20);$pt=$cache.CreatePivotTable($dest,${psString(name)});
|
||||
if(${chart ? "$true" : "$false"}){[void]$sheet.Shapes.AddChart(-4109,20,20,420,260)}`;
|
||||
await runExcelCom(filePath, body, bool(args.save));
|
||||
return { name, chart };
|
||||
};
|
||||
const runMacro = (scriptKey: "script" | "code" | "macroName"): CapabilityHandler => async (_ctx, node, args) => {
|
||||
const filePath = workbookPath(args, node.id, node.componentId, ["workbook", "objExcel"]);
|
||||
const macro = text(args, [scriptKey, "script", "code", "macroName", "name"]);
|
||||
if (!macro) throw new RuntimeError("validation_error", "宏名称为空", { nodeId: node.id, componentId: node.componentId });
|
||||
const parameters = list(args.args ?? args.macroArgs).map((item) => psString(item)).join(",");
|
||||
const body = `$result=$excel.Run(${psString(macro)}${parameters ? "," + parameters : ""});$rpaOutput=@{result=$result}`;
|
||||
return await runExcelCom(filePath, body, bool(args.save), false, 180000);
|
||||
};
|
||||
|
||||
const interop: CapabilityHandler = async (_ctx, node, args) => {
|
||||
const filePath = workbookPath(args, node.id, node.componentId, ["workbook", "objExcel"]);
|
||||
const property = text(args, ["property"]);
|
||||
if (!property) throw new RuntimeError("validation_error", "属性或方法名为空", { nodeId: node.id, componentId: node.componentId });
|
||||
const objectKind = number(args.obj ?? args.object, 0);
|
||||
const range = text(args, ["range"], "A1");
|
||||
const target = objectKind === 0 ? "$excel" : objectKind === 1 ? "$workbook" : objectKind === 2 ? sheetExpression(args) : `${sheetExpression(args)}.Range(${psString(range)})`;
|
||||
const value = args.value;
|
||||
const empty = value === undefined || value === null || value === "";
|
||||
const body = empty
|
||||
? `$target=${target};$rpaOutput=@{value=$target.GetType().InvokeMember(${psString(property)},[Reflection.BindingFlags]::GetProperty,$null,$target,@())}`
|
||||
: `$target=${target};[void]$target.GetType().InvokeMember(${psString(property)},[Reflection.BindingFlags]::SetProperty,$null,$target,@(${psString(String(value))}));$rpaOutput=@{set=$true}`;
|
||||
return await runExcelCom(filePath, body, bool(args.save));
|
||||
};
|
||||
|
||||
const exportPdf: CapabilityHandler = async (_ctx, node, args) => {
|
||||
const filePath = workbookPath(args, node.id, node.componentId, ["workbook"]);
|
||||
const folder = text(args, ["path"]);
|
||||
const name = text(args, ["name"], path.basename(filePath, path.extname(filePath)) + ".pdf");
|
||||
const output = path.join(path.resolve(folder), /\.(pdf)$/i.test(name) ? name : `${name}.pdf`);
|
||||
fs.mkdirSync(path.dirname(output), { recursive: true });
|
||||
if (!bool(args.cover, true) && fs.existsSync(output)) throw new RuntimeError("validation_error", "PDF 文件已存在", { detail: output });
|
||||
const activateOnly = text(args, ["option"], "全部") === "激活页";
|
||||
const body = `if(${activateOnly ? "$true" : "$false"}){$workbook.ActiveSheet.ExportAsFixedFormat(0,${psString(output)})}else{$workbook.ExportAsFixedFormat(0,${psString(output)})}`;
|
||||
await runExcelCom(filePath, body, false);
|
||||
return output;
|
||||
};
|
||||
|
||||
const exportRangePicture: CapabilityHandler = async (_ctx, node, args) => {
|
||||
const filePath = workbookPath(args, node.id, node.componentId, ["workbook"]);
|
||||
const folder = text(args, ["path"]); const name = text(args, ["name"], `excel-range-${Date.now()}`);
|
||||
const output = path.join(path.resolve(folder), /\.(png|jpg|jpeg)$/i.test(name) ? name : `${name}.png`);
|
||||
fs.mkdirSync(path.dirname(output), { recursive: true });
|
||||
const body = `$range=${sheetExpression(args)}.Range(${psString(text(args, ["range"], "A1"))});$range.CopyPicture(1,2)|Out-Null;$shape=${sheetExpression(args)}.ChartObjects().Add(0,0,$range.Width,$range.Height);$chart=$shape.Chart;$chart.Paste();[void]$chart.Export(${psString(output)},"PNG");$shape.Delete()`;
|
||||
await runExcelCom(filePath, body, false);
|
||||
return output;
|
||||
};
|
||||
|
||||
const setSlicer: CapabilityHandler = async (_ctx, node, args) => {
|
||||
const filePath = workbookPath(args, node.id, node.componentId, ["workbook"]);
|
||||
const name = text(args, ["slicerName"]); const values = list(args.slicerOptions);
|
||||
if (!name) throw new RuntimeError("validation_error", "切片器名称为空", { nodeId: node.id, componentId: node.componentId });
|
||||
const encoded = Buffer.from(JSON.stringify(values), "utf16le").toString("base64");
|
||||
const body = `$cache=$workbook.SlicerCaches.Item(${psString(name)});
|
||||
$wanted=[Text.Encoding]::Unicode.GetString([Convert]::FromBase64String("${encoded}"))|ConvertFrom-Json;
|
||||
foreach($item in $cache.SlicerItems()){ $item.Selected=if($wanted.Count -eq 0){$true}else{$wanted -contains $item.Name} }`;
|
||||
await runExcelCom(filePath, body, bool(args.save));
|
||||
return { slicer: name, values };
|
||||
};
|
||||
|
||||
const excelLoop: CapabilityHandler = async (_ctx, node, args) => {
|
||||
const filePath = workbookPath(args, node.id, node.componentId, ["objExcel", "workbook"]);
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
await workbook.xlsx.readFile(filePath);
|
||||
const rows: unknown[][] = [];
|
||||
for (const sheet of workbook.worksheets) {
|
||||
for (let row = 1; row <= sheet.rowCount; row += 1) {
|
||||
const cells: unknown[] = [];
|
||||
for (let column = 1; column <= sheet.columnCount; column += 1) cells.push(sheet.getCell(row, column).value ?? "");
|
||||
if (cells.some((cell) => cell !== "")) rows.push(cells);
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
};
|
||||
|
||||
register("Excel_Cs_Group", group);
|
||||
register("Excel_Cs_Encrypt", encryptExcel);
|
||||
register(["Excel_Cs_DeletePicture", "Excel_Cs_DeletePictureV2", "Excel_DeleteImage"], deletePicture);
|
||||
register(["Excel_Cs_InsertPicture", "Excel_Cs_InsertPictureV2", "Excel_InsertImage"], insertPicture);
|
||||
register("Excel_Cs_Move", moveSheet);
|
||||
register("Excel_Cs_Merge", mergeSheet);
|
||||
register(["Excel_Cs_RefreshAll", "Excel_Cs_RefreshPivotTable"], async (ctx, node, args, component) => node.componentId === "Excel_Cs_RefreshPivotTable" ? refreshPivot(ctx, node, args, component) : refreshAll(ctx, node, args, component));
|
||||
register("Excel_Cs_FilterPivotTable", filterPivot);
|
||||
register(["Excel_Cs_InsertPivotTable", "Excel_Cs_InsertPivotView"], createPivot);
|
||||
register(["Excel_Cs_RunMacro", "Excel_Cs_RunMacroV2", "Excel_ExecuteMacro"], runMacro("script"));
|
||||
register("Excel_Cs_Interop", interop);
|
||||
register("Excel_Cs_Export", exportPdf);
|
||||
register("Excel_Cs_ExcelAddin", runMacro("code"));
|
||||
register("Excel_Cs_SetSlicer", setSlicer);
|
||||
register("Excel_Loop", excelLoop);
|
||||
register("Excel_ExportRangeAsPicture", exportRangePicture);
|
||||
@@ -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 "../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");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,826 @@
|
||||
import ExcelJS from "exceljs";
|
||||
import path from "node:path";
|
||||
import { register } from "../engine/registry";
|
||||
import { RuntimeError, type CapabilityHandler } from "../engine/types";
|
||||
import { acquireWorkbook, releaseWorkbook } from "./excel-store";
|
||||
|
||||
type WorkbookSource = { workbook: ExcelJS.Workbook; filePath: string };
|
||||
type CopiedRange = { values: unknown[][]; styles: Partial<ExcelJS.Style>[][] };
|
||||
|
||||
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);
|
||||
const workbook = await acquireWorkbook(filePath);
|
||||
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);
|
||||
releaseWorkbook(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);
|
||||
|
||||
|
||||
// ===== Excel 高阶(批四) =====
|
||||
function cellAddress(addr: string): { row: number; column: number } {
|
||||
const m = addr.toUpperCase().match(/^([A-Z]+)(\d+)$/);
|
||||
if (!m) throw new Error("Invalid cell address: " + addr);
|
||||
return { row: Number(m[2]), column: columnNumber(m[1]) };
|
||||
}
|
||||
|
||||
function toArgb(color: string): string {
|
||||
const hex = color.replace(/^#/, "").toUpperCase();
|
||||
return hex.length === 6 ? "FF" + hex : hex.padStart(8, "F");
|
||||
}
|
||||
|
||||
function parseFormatSpec(raw: unknown): { bold?: boolean; italic?: boolean; underline?: boolean; fontSize?: number; color?: string; bgColor?: string; numFmt?: string; fontName?: string } {
|
||||
if (typeof raw === "object" && raw !== null) return normalizeFormatSpec(raw as Record<string, unknown>);
|
||||
const text = String(raw ?? "").trim();
|
||||
if (!text) return {};
|
||||
try {
|
||||
const parsed = JSON.parse(text) as Record<string, unknown>;
|
||||
if (typeof parsed === "object" && parsed) return normalizeFormatSpec(parsed);
|
||||
} catch { /* fall through to keywords */ }
|
||||
const spec: ReturnType<typeof parseFormatSpec> = {};
|
||||
const lower = text.toLowerCase();
|
||||
if (/加粗|bold/.test(lower)) spec.bold = true;
|
||||
if (/斜体|italic/.test(lower)) spec.italic = true;
|
||||
if (/下划线|underline/.test(lower)) spec.underline = true;
|
||||
const sizeMatch = text.match(/(\d+)号|font-size[::]\s*(\d+)/i);
|
||||
if (sizeMatch) spec.fontSize = Number(sizeMatch[1] || sizeMatch[2]);
|
||||
// 无关键词命中时视为数字格式代码(如 "0.00"、"G/通用格式"、"yyyy-mm-dd")
|
||||
if (!Object.keys(spec).length) spec.numFmt = text;
|
||||
return spec;
|
||||
}
|
||||
|
||||
/** 兼容厂商格式 JSON(FontColor/BoldAndItalic/FontSize/BackgroundColor/DigitalFormat)与内部 camelCase 键 */
|
||||
function normalizeFormatSpec(parsed: Record<string, unknown>): ReturnType<typeof parseFormatSpec> {
|
||||
const spec: ReturnType<typeof parseFormatSpec> = {};
|
||||
const flags = Number(parsed.BoldAndItalic ?? 0);
|
||||
if (flags & 1) spec.bold = true;
|
||||
if (flags & 2) spec.italic = true;
|
||||
if (parsed.FontFormat) spec.fontName = String(parsed.FontFormat);
|
||||
if (parsed.FontSize !== undefined && parsed.FontSize !== null && String(parsed.FontSize).trim() !== "") spec.fontSize = Number(parsed.FontSize);
|
||||
if (parsed.FontColor) spec.color = String(parsed.FontColor);
|
||||
if (parsed.BackgroundColor) spec.bgColor = String(parsed.BackgroundColor).split(",")[0].trim();
|
||||
const digital = parsed.DigitalFormat as { formatstring?: unknown } | undefined;
|
||||
if (digital?.formatstring && String(digital.formatstring).trim() !== "") spec.numFmt = String(digital.formatstring);
|
||||
for (const key of ["bold", "italic", "underline", "fontSize", "color", "bgColor", "numFmt"] as const) {
|
||||
if (parsed[key] !== undefined) (spec as Record<string, unknown>)[key] = parsed[key];
|
||||
}
|
||||
return spec;
|
||||
}
|
||||
|
||||
registerMany(["Excel_Cs_SetCellFormat", "Excel_Cs_SetCellFormat_V2", "Excel_Cs_SetCellFormat_V3"], async (ctx, node, args) => {
|
||||
const source = await loadWorkbook(args, node.id, node.componentId);
|
||||
const sheet = worksheetOf(source.workbook, args);
|
||||
const range = text(args, ["excelRng", "range", "cellPos", "rng"], "A1");
|
||||
const spec = parseFormatSpec(args.format);
|
||||
const addresses: string[] = [];
|
||||
const parts = range.toUpperCase().split(":");
|
||||
if (parts.length === 2 && parts[0] !== parts[1]) {
|
||||
const from = cellAddress(parts[0]);
|
||||
const to = cellAddress(parts[1]);
|
||||
for (let r = from.row; r <= to.row; r += 1) {
|
||||
for (let cIdx = from.column; cIdx <= to.column; cIdx += 1) {
|
||||
addresses.push(columnLetter(cIdx) + r);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
addresses.push(parts[0]);
|
||||
}
|
||||
for (const addr of addresses) {
|
||||
const cell = sheet.getCell(addr);
|
||||
if (spec.bold !== undefined || spec.italic !== undefined || spec.underline !== undefined || spec.fontSize !== undefined || spec.color) {
|
||||
cell.font = {
|
||||
...cell.font,
|
||||
bold: spec.bold,
|
||||
italic: spec.italic,
|
||||
underline: spec.underline,
|
||||
size: spec.fontSize,
|
||||
name: spec.fontName ?? cell.font?.name,
|
||||
color: spec.color ? { argb: toArgb(spec.color) } : undefined
|
||||
};
|
||||
}
|
||||
if (spec.numFmt) cell.numFmt = spec.numFmt;
|
||||
if (spec.bgColor) cell.fill = { type: "pattern", pattern: "solid", fgColor: { argb: toArgb(spec.bgColor) } };
|
||||
}
|
||||
await saveWhenRequested(source, args);
|
||||
ctx.log("已设置 " + addresses.length + " 个单元格格式", "info", node.id);
|
||||
return { formatted: addresses.length, range };
|
||||
});
|
||||
|
||||
register("Excel_Cs_ExcelFilter", async (ctx, node, args) => {
|
||||
const source = await loadWorkbook(args, node.id, node.componentId);
|
||||
const sheet = worksheetOf(source.workbook, args);
|
||||
const rule = text(args, ["rule"]);
|
||||
const startRow = numberValue(args.startRow, 1);
|
||||
const endRow = numberValue(args.endRow, sheet.rowCount || 1);
|
||||
const range = rule && rule.includes(":") ? rule.toUpperCase() : "A" + startRow + ":" + columnLetter(sheet.columnCount || 1) + endRow;
|
||||
sheet.autoFilter = range;
|
||||
await saveWhenRequested(source, args);
|
||||
ctx.log("已设置自动筛选:" + range, "info", node.id);
|
||||
return { filter: range };
|
||||
});
|
||||
|
||||
register("Excel_Cs_ShowTableStyle", async (ctx, node, args) => {
|
||||
const source = await loadWorkbook(args, node.id, node.componentId);
|
||||
const sheet = worksheetOf(source.workbook, args);
|
||||
// 必须逐单元格设置:exceljs 的行级样式(row.font/row.fill + commit)会覆盖
|
||||
// 该行已设置的单元格样式(如先执行过的设置单元格格式)
|
||||
const lastCol = Math.max(1, sheet.columnCount);
|
||||
for (let c = 1; c <= lastCol; c += 1) {
|
||||
const cell = sheet.getRow(1).getCell(c);
|
||||
cell.font = { ...cell.font, bold: true };
|
||||
cell.fill = { type: "pattern", pattern: "solid", fgColor: { argb: "FFD9E8F5" } };
|
||||
}
|
||||
sheet.autoFilter = { from: { row: 1, column: 1 }, to: { row: Math.max(1, sheet.rowCount), column: lastCol } };
|
||||
await saveWhenRequested(source, args, true);
|
||||
ctx.log("已应用表格样式(表头加粗 + 筛选)", "info", node.id);
|
||||
return { styled: true };
|
||||
});
|
||||
|
||||
register("Excel_Cs_Hide", async (ctx, node, args) => {
|
||||
const source = await loadWorkbook(args, node.id, node.componentId);
|
||||
const sheet = worksheetOf(source.workbook, args);
|
||||
const mode = String(value(args, ["mode"], "row")).toLowerCase();
|
||||
const range = text(args, ["range"]);
|
||||
const hide = !/取消|取消隐藏|unhide|show/i.test(mode + range);
|
||||
if (mode.includes("col") || mode.includes("列")) {
|
||||
const targets = range ? range.split(",") : [text(args, ["column"])];
|
||||
for (const t of targets) {
|
||||
if (!t.trim()) continue;
|
||||
const letter = t.trim().replace(/^\d+:?/, "").toUpperCase() || t.trim().toUpperCase();
|
||||
sheet.getColumn(columnNumber(letter)).hidden = hide;
|
||||
}
|
||||
} else {
|
||||
const targets = range ? range.split(":") : [text(args, ["row"])];
|
||||
if (targets.length === 2) {
|
||||
const from = Number(targets[0]); const to = Number(targets[1]);
|
||||
for (let r = Math.min(from, to); r <= Math.max(from, to); r += 1) sheet.getRow(r).hidden = hide;
|
||||
} else {
|
||||
const r = Number(targets[0]);
|
||||
if (Number.isFinite(r) && r > 0) sheet.getRow(r).hidden = hide;
|
||||
}
|
||||
}
|
||||
await saveWhenRequested(source, args);
|
||||
ctx.log((hide ? "已隐藏" : "已取消隐藏") + "(" + mode + " " + range + ")", "info", node.id);
|
||||
return { hidden: hide };
|
||||
});
|
||||
|
||||
register("Excel_Cs_GetLastEmptyCell", async (ctx, node, args) => {
|
||||
const source = await loadWorkbook(args, node.id, node.componentId);
|
||||
const sheet = worksheetOf(source.workbook, args);
|
||||
const column = columnLetter(numberValue(args.column, 1));
|
||||
let lastFilled = 0;
|
||||
for (let r = 1; r <= sheet.rowCount; r += 1) {
|
||||
const v = sheet.getRow(r).getCell(numberValue(args.column, 1)).value;
|
||||
if (v !== null && v !== undefined && v !== "") lastFilled = r;
|
||||
}
|
||||
const emptyRow = lastFilled + 1;
|
||||
return { address: column + emptyRow, row: emptyRow, column: numberValue(args.column, 1) };
|
||||
});
|
||||
|
||||
register("Excel_GetColumnId", async (_ctx, _node, args) => columnLetter(numberValue(args.num, 1)));
|
||||
|
||||
register(["Excel_Cs_SelectRange", "Excel_Cs_SelectRangeV2", "Excel_Cs_SelectRangeV3", "Excel_SelectRange"], async (ctx, node, args) => {
|
||||
const source = await loadWorkbook(args, node.id, node.componentId);
|
||||
const sheet = worksheetOf(source.workbook, args);
|
||||
const cellPos = text(args, ["cellPos", "range", "Start"], "A1");
|
||||
const parts = cellPos.toUpperCase().split(":");
|
||||
const from = cellAddress(parts[0]);
|
||||
const to = parts[1] ? cellAddress(parts[1]) : from;
|
||||
const rows: unknown[][] = [];
|
||||
for (let r = from.row; r <= to.row; r += 1) {
|
||||
const row: unknown[] = [];
|
||||
for (let cIdx = from.column; cIdx <= to.column; cIdx += 1) {
|
||||
row.push(sheet.getRow(r).getCell(cIdx).value ?? "");
|
||||
}
|
||||
rows.push(row);
|
||||
}
|
||||
ctx.log("已读取区域 " + cellPos + "(" + rows.length + " 行)", "info", node.id);
|
||||
return rows;
|
||||
});
|
||||
|
||||
register("Excel_Cs_CloseV2", async (ctx, node, args) => {
|
||||
const source = await loadWorkbook(args, node.id, node.componentId);
|
||||
const save = !/不保存|discard|false/i.test(text(args, ["closeAction"]));
|
||||
if (save) await source.workbook.xlsx.writeFile(source.filePath);
|
||||
releaseWorkbook(source.filePath);
|
||||
ctx.log("已关闭工作簿" + (save ? "(已保存)" : "(未保存)"), "info", node.id);
|
||||
return { closed: true, saved: save };
|
||||
});
|
||||
|
||||
register(["Excel_Cs_Load", "Excel_Cs_LoadV2"], async (ctx, node, args) => {
|
||||
const source = await loadWorkbook(args, node.id, node.componentId);
|
||||
const sheets = source.workbook.worksheets.map(w => w.name);
|
||||
ctx.log("已加载工作簿:" + source.filePath + "(" + sheets.length + " 个 Sheet)", "info", node.id);
|
||||
return { filePath: source.filePath, sheets };
|
||||
});
|
||||
|
||||
register("Excel_WriteColumn", async (ctx, node, args) => {
|
||||
const source = await loadWorkbook(args, node.id, node.componentId);
|
||||
const sheet = worksheetOf(source.workbook, args);
|
||||
const start = cellAddress(text(args, ["cellPos"], "A1").toUpperCase());
|
||||
const valuesRaw = value(args, ["value"]);
|
||||
const values = Array.isArray(valuesRaw) ? valuesRaw : [valuesRaw];
|
||||
const flat = Array.isArray(values) ? values.flat() : [values];
|
||||
flat.forEach((item, offset) => {
|
||||
sheet.getRow(start.row + offset).getCell(start.column).value = item as ExcelJS.CellValue;
|
||||
});
|
||||
await saveWhenRequested(source, args, true);
|
||||
ctx.log("已写入 " + flat.length + " 行(" + columnLetter(start.column) + start.row + " 起)", "info", node.id);
|
||||
return { written: flat.length };
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import { mkdtemp, 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 { CatalogComponent, FlowNode } from "../types";
|
||||
import type { RunContext } from "../engine/types";
|
||||
import { getHandler } from "../engine/registry";
|
||||
import "./office";
|
||||
import "./extended";
|
||||
import "./office-file-extended";
|
||||
import "./office-aliases";
|
||||
import "./excel-common";
|
||||
import "./common-final";
|
||||
|
||||
const dirs: string[] = [];
|
||||
const context = {} as RunContext;
|
||||
const component = {} as CatalogComponent;
|
||||
const node = (id: string): FlowNode => ({ id, componentId: id, position: { x: 0, y: 0 }, values: {}, options: {} });
|
||||
async function run(id: string, args: Record<string, unknown>) { const handler = getHandler(id); expect(handler, id).toBeTruthy(); return handler!(context, node(id), args, component); }
|
||||
afterEach(async () => Promise.all(dirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))));
|
||||
|
||||
describe("excel shared workbook store", () => {
|
||||
it("keeps widths/heights set by office-file-extended when excel-common saves afterwards", async () => {
|
||||
const directory = await mkdtemp(path.join(os.tmpdir(), "rpa-store-")); dirs.push(directory);
|
||||
const handle = await run("Excel_NewExcel", { filePath: directory, name: "a.xlsx", isMakedirs: "True" }) as { path: string };
|
||||
await run("Excel_Cs_WriteRangeV2", { workbook: handle, sheet: "Sheet1", row: "1", column: "A", contents: [["名称", "数量", "备注"], ["鼠标", 20, "A;B"]], isSave: "True" });
|
||||
await run("Excel_Cs_SetWidth", { workbook: handle, sheet: "Sheet1", columns: "A:C", width: "20", save: "True" });
|
||||
await run("Excel_Cs_SetHeight", { workbook: handle, sheet: "Sheet1", rows: "1", height: "26", save: "True" });
|
||||
await run("Excel_Cs_MergeCells", { workbook: handle, sheet: "Sheet1", range: "A1:C1", merge: "0", save: "True" });
|
||||
await run("Excel_Cs_SetRangeBackground", { workbook: handle, sheet: "Sheet1", range: "A1:C1", color: "FFF2CC", save: "True" });
|
||||
await run("Excel_SetCellFontColor", { objExcel: handle, sheet: "Sheet1", cellPos: "A1", cellColor: "FF0000", isSave: "True" });
|
||||
const wb = new ExcelJS.Workbook();
|
||||
await wb.xlsx.readFile(handle.path);
|
||||
const ws = wb.worksheets[0];
|
||||
expect(ws.getColumn("A").width).toBe(20);
|
||||
expect(ws.getRow(1).height).toBe(26);
|
||||
expect(ws.model.merges).toContain("A1:C1");
|
||||
expect((ws.getCell("A1").fill as ExcelJS.FillPattern).fgColor?.argb).toBe("FFFFF2CC");
|
||||
expect((ws.getCell("A1").font?.color as { argb?: string } | undefined)?.argb).toBe("FFFF0000");
|
||||
expect(ws.getCell("A1").value).toBe("名称");
|
||||
});
|
||||
|
||||
it("keeps copied sheets created by office-file-extended when sheets are created and deleted later", async () => {
|
||||
const directory = await mkdtemp(path.join(os.tmpdir(), "rpa-store-")); dirs.push(directory);
|
||||
const handle = await run("Excel_NewExcel", { filePath: directory, name: "b.xlsx", isMakedirs: "True" }) as { path: string };
|
||||
await run("Excel_Cs_WriteRangeV2", { workbook: handle, sheet: "Sheet1", row: "1", column: "A", contents: [["名称"]], isSave: "True" });
|
||||
await run("Excel_Cs_CopyV2", { workbook: handle, sheet: "Sheet1", newSheet: "Sheet1副本", override: "True", save: "True" });
|
||||
await run("Excel_CreateSheet", { workbook: handle, sheet: "临时表", save: "True" });
|
||||
await run("Excel_DeleteSheet", { workbook: handle, sheet: "临时表", save: "True" });
|
||||
const wb = new ExcelJS.Workbook();
|
||||
await wb.xlsx.readFile(handle.path);
|
||||
expect(wb.worksheets.map((sheet) => sheet.name)).toEqual(["Sheet1", "Sheet1副本"]);
|
||||
});
|
||||
|
||||
it("keeps the shared instance usable after save-as switches the handle path", async () => {
|
||||
const directory = await mkdtemp(path.join(os.tmpdir(), "rpa-store-")); dirs.push(directory);
|
||||
const handle = await run("Excel_NewExcel", { filePath: directory, name: "c.xlsx", isMakedirs: "True" }) as { path: string };
|
||||
await run("Excel_Cs_SetWidth", { workbook: handle, sheet: "Sheet1", columns: "A", width: "18", save: "True" });
|
||||
const saved = await run("Excel_Cs_SaveAs", { workbook: handle, path: path.join(directory, "d.xlsx") }) as { path: string };
|
||||
expect(saved.path).toBe(path.resolve(directory, "d.xlsx"));
|
||||
await run("Excel_Cs_SetHeight", { workbook: handle, sheet: "Sheet1", rows: "1", height: "30", save: "True" });
|
||||
const wb = new ExcelJS.Workbook();
|
||||
await wb.xlsx.readFile(path.resolve(directory, "d.xlsx"));
|
||||
expect(wb.worksheets[0].getColumn("A").width).toBe(18);
|
||||
expect(wb.worksheets[0].getRow(1).height).toBe(30);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import ExcelJS from "exceljs";
|
||||
import path from "node:path";
|
||||
import { defaultEngine } from "../engine/engine";
|
||||
|
||||
// Excel 工作簿共享缓存:同一文件路径在整条流程中复用同一个 ExcelJS 实例。
|
||||
// 此前 office.ts / office-file-extended.ts / excel-common.ts / extended.ts 各自持有
|
||||
// 工作簿实例并整簿写回磁盘,后保存者会把先保存者的修改整体覆盖掉。
|
||||
// 缓存本体挂在引擎实例上(任务 1.3),此处委托 defaultEngine。
|
||||
|
||||
function keyOf(filePath: string): string {
|
||||
return path.resolve(filePath);
|
||||
}
|
||||
|
||||
/** 按路径取工作簿:命中缓存直接复用,未命中从磁盘读取后登记。 */
|
||||
export async function acquireWorkbook(filePath: string): Promise<ExcelJS.Workbook> {
|
||||
const key = keyOf(filePath);
|
||||
let workbook = defaultEngine.excelCache.get(key);
|
||||
if (!workbook) {
|
||||
workbook = new ExcelJS.Workbook();
|
||||
await workbook.xlsx.readFile(key);
|
||||
defaultEngine.excelCache.set(key, workbook);
|
||||
}
|
||||
return workbook;
|
||||
}
|
||||
|
||||
/** 把一个新建/另建的工作簿实例登记为该路径的共享实例。 */
|
||||
export function publishWorkbook(filePath: string, workbook: ExcelJS.Workbook): ExcelJS.Workbook {
|
||||
defaultEngine.excelCache.set(keyOf(filePath), workbook);
|
||||
return workbook;
|
||||
}
|
||||
|
||||
/** 释放路径对应的共享实例(关闭工作簿时调用)。 */
|
||||
export function releaseWorkbook(filePath: string): void {
|
||||
defaultEngine.excelCache.delete(keyOf(filePath));
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
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 { CatalogComponent, FlowNode } from "../types";
|
||||
import type { RunContext } from "../engine/types";
|
||||
import { getHandler } from "../engine/registry";
|
||||
import "./extended";
|
||||
|
||||
const directories: string[] = [];
|
||||
const context = {} as RunContext;
|
||||
const component = {} as CatalogComponent;
|
||||
const node = (componentId: string): FlowNode => ({
|
||||
id: `extended_${componentId}`,
|
||||
componentId,
|
||||
position: { x: 0, y: 0 },
|
||||
values: {},
|
||||
options: {}
|
||||
});
|
||||
|
||||
async function temporaryDirectory(): Promise<string> {
|
||||
const directory = await mkdtemp(path.join(os.tmpdir(), "rpa-extended-"));
|
||||
directories.push(directory);
|
||||
return directory;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(directories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
describe("extended local capabilities", () => {
|
||||
it("registers every audited component id", () => {
|
||||
for (const id of [
|
||||
"File_GetFileList_V1", "File_GetFolderList", "File_GetFolderList_V1", "File_MoveFolder",
|
||||
"File_Write_V1", "File_Copy_V1", "File_Rename_V1", "File_ReadXml", "File_EmptyFolder",
|
||||
"Bivartable_ReadDfShape", "Bivartable_SortDf", "Bivartable_DoSort", "Bivartable_DelDfColRow",
|
||||
"Bivartable_ReadTable", "Excel_Cs_ReadCell", "Excel_GetRowsCount", "Excel_GetColumnsCount",
|
||||
"Excel_Cs_GetEditRange"
|
||||
]) expect(getHandler(id), id).toBeTruthy();
|
||||
});
|
||||
|
||||
it("performs file listing, copy, rename, XML read, move, and empty operations", async () => {
|
||||
const root = await temporaryDirectory();
|
||||
const source = path.join(root, "source");
|
||||
const nested = path.join(source, "nested");
|
||||
await run("File_Write_V1", { path: path.join(source, "alpha.xml"), content: "<root>RPA</root>" });
|
||||
await run("File_Write_V1", { path: path.join(nested, "beta.txt"), content: "beta" });
|
||||
|
||||
await expect(run("File_GetFileList_V1", { folder_path: source, recursive: "1", is_full_path: "0" }))
|
||||
.resolves.toEqual(["alpha.xml", "beta.txt"]);
|
||||
await expect(run("File_GetFolderList", { folder_path: source, recursive: "1" }))
|
||||
.resolves.toEqual([nested]);
|
||||
await expect(run("File_ReadXml", { path: path.join(source, "alpha.xml") }))
|
||||
.resolves.toBe("<root>RPA</root>");
|
||||
|
||||
const copied = await run("File_Copy_V1", {
|
||||
file_type: "file", file_path: path.join(source, "alpha.xml"), target_path: root,
|
||||
rename: "1", name: "copied.xml", action: "1"
|
||||
}) as string;
|
||||
await expect(readFile(copied, "utf8")).resolves.toContain("RPA");
|
||||
const renamed = await run("File_Rename_V1", { file_type: "file", file_path: copied, name: "renamed.xml", action: "1" }) as string;
|
||||
await expect(readFile(renamed, "utf8")).resolves.toContain("RPA");
|
||||
|
||||
const moved = path.join(root, "moved");
|
||||
await run("File_MoveFolder", { pathSrc: nested, pathDest: moved, overwrite: "True" });
|
||||
await expect(readFile(path.join(moved, "beta.txt"), "utf8")).resolves.toBe("beta");
|
||||
await run("File_EmptyFolder", { folder_path: moved });
|
||||
await expect(readdir(moved)).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it("sorts, measures, deletes, and selects two-dimensional data", async () => {
|
||||
const input = [["name", "score"], ["B", 2], ["A", 3], ["C", 1]];
|
||||
await expect(run("Bivartable_ReadDfShape", { df: input })).resolves.toEqual([4, 2]);
|
||||
await expect(run("Bivartable_SortDf", { df: input, name: "score", sort: "True" }))
|
||||
.resolves.toEqual([["name", "score"], ["C", 1], ["B", 2], ["A", 3]]);
|
||||
await expect(run("Bivartable_DoSort", { df: [[3], [1], [2]], sortby: "1", basceeding: "False" }))
|
||||
.resolves.toEqual([[3], [2], [1]]);
|
||||
await expect(run("Bivartable_DelDfColRow", { df: input, delRange: "col", delType: "name", del_col_name_list: ["score"] }))
|
||||
.resolves.toEqual([["name"], ["B"], ["A"], ["C"]]);
|
||||
await expect(run("Bivartable_ReadTable", { df: input, start_row: "2", end_row: "3", start_column: "A", end_column: "B" }))
|
||||
.resolves.toEqual([["B", 2], ["A", 3]]);
|
||||
});
|
||||
|
||||
it("reads Excel cells, dimensions, and the edited range", async () => {
|
||||
const root = await temporaryDirectory();
|
||||
const filePath = path.join(root, "extended.xlsx");
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
const sheet = workbook.addWorksheet("Data");
|
||||
sheet.getCell("B2").value = "RPA";
|
||||
sheet.getCell("D5").value = 42;
|
||||
await workbook.xlsx.writeFile(filePath);
|
||||
|
||||
await expect(run("Excel_Cs_ReadCell", { workbook: filePath, sheet: "Data", column: "B", row: "2" })).resolves.toBe("RPA");
|
||||
await expect(run("Excel_GetRowsCount", { objExcel: filePath, sheet: "Data" })).resolves.toBe(5);
|
||||
await expect(run("Excel_GetColumnsCount", { objExcel: filePath, sheet: "Data" })).resolves.toBe(4);
|
||||
await expect(run("Excel_Cs_GetEditRange", { workbook: { path: filePath }, sheet: "Data" })).resolves.toEqual({
|
||||
startRow: 2, startColumn: 2, endRow: 5, endColumn: 4, range: "B2:D5"
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,284 @@
|
||||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
import ExcelJS from "exceljs";
|
||||
import { register } from "../engine/registry";
|
||||
import { RuntimeError, type CapabilityHandler } from "../engine/types";
|
||||
import { acquireWorkbook } from "./excel-store";
|
||||
|
||||
type Table = unknown[][];
|
||||
|
||||
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 bool(candidate: unknown, fallback = false): boolean {
|
||||
if (candidate === undefined || candidate === null || candidate === "") return fallback;
|
||||
return [true, 1, "1", "true", "True", "是", "开启"].includes(candidate as never);
|
||||
}
|
||||
|
||||
function requiredPath(candidate: unknown, label: string, nodeId: string, componentId: string): string {
|
||||
const result = String(candidate ?? "").trim();
|
||||
if (!result) {
|
||||
throw new RuntimeError("validation_error", `${label}为空`, {
|
||||
nodeId,
|
||||
componentId,
|
||||
recommendation: `请配置${label}`
|
||||
});
|
||||
}
|
||||
return path.resolve(result);
|
||||
}
|
||||
|
||||
function list(candidate: unknown): unknown[] {
|
||||
if (Array.isArray(candidate)) return candidate;
|
||||
if (candidate === undefined || candidate === null || candidate === "") return [];
|
||||
if (typeof candidate === "string") {
|
||||
try {
|
||||
const parsed = JSON.parse(candidate);
|
||||
return Array.isArray(parsed) ? parsed : [parsed];
|
||||
} catch {
|
||||
return candidate.split(/[,,\n]/).map((item) => item.trim()).filter(Boolean);
|
||||
}
|
||||
}
|
||||
return [candidate];
|
||||
}
|
||||
|
||||
function table(candidate: unknown): Table {
|
||||
const parsed = typeof candidate === "string"
|
||||
? (() => {
|
||||
try { return JSON.parse(candidate); } catch { return candidate; }
|
||||
})()
|
||||
: candidate;
|
||||
if (!Array.isArray(parsed)) return parsed === undefined || parsed === null ? [] : [[parsed]];
|
||||
return parsed.map((row) => Array.isArray(row) ? [...row] : [row]);
|
||||
}
|
||||
|
||||
function columnNumber(candidate: unknown): number {
|
||||
const source = String(candidate ?? "A").trim().toUpperCase();
|
||||
if (/^\d+$/.test(source)) return Math.max(1, Number(source));
|
||||
let result = 0;
|
||||
for (const character of source) result = result * 26 + character.charCodeAt(0) - 64;
|
||||
return Math.max(1, result);
|
||||
}
|
||||
|
||||
function compare(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 });
|
||||
}
|
||||
|
||||
async function ensureAbsentOrRemove(target: string, action: unknown): Promise<boolean> {
|
||||
try {
|
||||
await fs.access(target);
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
const mode = String(action ?? "1");
|
||||
if (mode === "2" || mode.toLowerCase() === "skip") return false;
|
||||
if (mode === "3" || mode.toLowerCase() === "error") throw new Error(`目标已存在:${target}`);
|
||||
await fs.rm(target, { recursive: true, force: true });
|
||||
return true;
|
||||
}
|
||||
|
||||
async function collectEntries(
|
||||
root: string,
|
||||
kind: "file" | "directory",
|
||||
recursive: boolean,
|
||||
keyword: string,
|
||||
ignore: string
|
||||
): Promise<string[]> {
|
||||
const results: string[] = [];
|
||||
async function walk(folder: string): Promise<void> {
|
||||
const entries = await fs.readdir(folder, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const absolute = path.join(folder, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
if (kind === "directory" && (!keyword || entry.name.includes(keyword)) && (!ignore || !entry.name.includes(ignore))) {
|
||||
results.push(absolute);
|
||||
}
|
||||
if (recursive) await walk(absolute);
|
||||
} else if (kind === "file" && (!keyword || entry.name.includes(keyword)) && (!ignore || !entry.name.includes(ignore))) {
|
||||
results.push(absolute);
|
||||
}
|
||||
}
|
||||
}
|
||||
await walk(root);
|
||||
return results;
|
||||
}
|
||||
|
||||
register("File_GetFileList_V1", async (_ctx, node, args) => {
|
||||
const root = requiredPath(value(args, ["folder_path"]), "文件夹", node.id, node.componentId);
|
||||
const fullPath = bool(args.is_full_path, true);
|
||||
const results = await collectEntries(root, "file", bool(args.recursive), text(args, ["key_word"]), text(args, ["ignore"]));
|
||||
const sortDescending = ["2", "desc", "descending"].includes(String(args.sort_type ?? "1").toLowerCase());
|
||||
results.sort((left, right) => left.localeCompare(right, "zh-CN", { numeric: true }) * (sortDescending ? -1 : 1));
|
||||
return fullPath ? results : results.map((item) => path.basename(item));
|
||||
});
|
||||
|
||||
const getFolderList: CapabilityHandler = async (_ctx, node, args) => {
|
||||
const root = requiredPath(value(args, ["folder_path"]), "文件夹", node.id, node.componentId);
|
||||
const results = await collectEntries(root, "directory", bool(args.recursive), "", text(args, ["ignore"]));
|
||||
return results.sort((left, right) => left.localeCompare(right, "zh-CN", { numeric: true }));
|
||||
};
|
||||
register(["File_GetFolderList", "File_GetFolderList_V1"], getFolderList);
|
||||
|
||||
register("File_MoveFolder", async (_ctx, node, args) => {
|
||||
const source = requiredPath(value(args, ["pathSrc"]), "文件夹路径", node.id, node.componentId);
|
||||
const target = requiredPath(value(args, ["pathDest"]), "目的地路径", node.id, node.componentId);
|
||||
if (bool(args.overwrite)) await fs.rm(target, { recursive: true, force: true });
|
||||
await fs.mkdir(path.dirname(target), { recursive: true });
|
||||
try {
|
||||
await fs.rename(source, target);
|
||||
} catch {
|
||||
await fs.cp(source, target, { recursive: true, force: bool(args.overwrite) });
|
||||
await fs.rm(source, { recursive: true, force: true });
|
||||
}
|
||||
return target;
|
||||
});
|
||||
|
||||
register("File_Write_V1", async (_ctx, node, args) => {
|
||||
const target = requiredPath(value(args, ["path"]), "文件路径", node.id, node.componentId);
|
||||
await fs.mkdir(path.dirname(target), { recursive: true });
|
||||
const content = String(args.content ?? "");
|
||||
if (bool(args.append)) await fs.appendFile(target, content, "utf8");
|
||||
else await fs.writeFile(target, content, "utf8");
|
||||
return target;
|
||||
});
|
||||
|
||||
register("File_Copy_V1", async (_ctx, node, args) => {
|
||||
const isFolder = text(args, ["file_type"], "file").toLowerCase() === "folder";
|
||||
const source = requiredPath(value(args, [isFolder ? "folder_path" : "file_path"]), isFolder ? "待复制文件夹" : "待复制文件", node.id, node.componentId);
|
||||
const destination = requiredPath(value(args, ["target_path"]), "复制目标", node.id, node.componentId);
|
||||
const newName = bool(args.rename) && text(args, ["name"]) ? text(args, ["name"]) : path.basename(source);
|
||||
const target = path.join(destination, newName);
|
||||
if (!await ensureAbsentOrRemove(target, args.action)) return target;
|
||||
await fs.mkdir(destination, { recursive: true });
|
||||
if (isFolder) await fs.cp(source, target, { recursive: true, force: true });
|
||||
else await fs.copyFile(source, target);
|
||||
return target;
|
||||
});
|
||||
|
||||
register("File_Rename_V1", async (_ctx, node, args) => {
|
||||
const isFolder = text(args, ["file_type"], "file").toLowerCase() === "folder";
|
||||
const source = requiredPath(value(args, [isFolder ? "folder_path" : "file_path"]), "待重命名路径", node.id, node.componentId);
|
||||
const name = text(args, ["name"]).trim();
|
||||
if (!name || path.basename(name) !== name) throw new Error("新名称必须是单独的文件或文件夹名称");
|
||||
const target = path.join(path.dirname(source), name);
|
||||
if (!await ensureAbsentOrRemove(target, args.action)) return source;
|
||||
await fs.rename(source, target);
|
||||
return target;
|
||||
});
|
||||
|
||||
register("File_ReadXml", async (_ctx, node, args) => {
|
||||
const target = requiredPath(value(args, ["path"]), "XML文件路径", node.id, node.componentId);
|
||||
const buffer = await fs.readFile(target);
|
||||
const encoding = text(args, ["encode"], "utf-8").toLowerCase().replace("_", "-");
|
||||
if (encoding === "utf-8" || encoding === "utf8" || encoding === "auto") return buffer.toString("utf8");
|
||||
return new TextDecoder(encoding).decode(buffer);
|
||||
});
|
||||
|
||||
register("File_EmptyFolder", async (_ctx, node, args) => {
|
||||
const folder = requiredPath(value(args, ["folder_path"]), "待清空文件夹", node.id, node.componentId);
|
||||
const entries = await fs.readdir(folder);
|
||||
await Promise.all(entries.map((entry) => fs.rm(path.join(folder, entry), { recursive: true, force: true })));
|
||||
return true;
|
||||
});
|
||||
|
||||
register("Bivartable_ReadDfShape", (_ctx, _node, args) => {
|
||||
const rows = table(value(args, ["df"]));
|
||||
return [rows.length, rows.reduce((maximum, row) => Math.max(maximum, row.length), 0)];
|
||||
});
|
||||
|
||||
function sortTable(args: Record<string, unknown>): Table {
|
||||
const rows = table(value(args, ["df"]));
|
||||
if (rows.length < 2) return rows;
|
||||
const requested = text(args, ["name", "sortby"]).split(/[,,]/)[0].trim();
|
||||
const headerIndex = requested ? rows[0].findIndex((cell) => String(cell) === requested) : -1;
|
||||
const index = headerIndex >= 0 ? headerIndex : columnNumber(requested || "1") - 1;
|
||||
const ascending = bool(value(args, ["sort", "basceeding"]), true);
|
||||
const hasHeader = headerIndex >= 0;
|
||||
const body = (hasHeader ? rows.slice(1) : rows).sort((left, right) => compare(left[index], right[index]) * (ascending ? 1 : -1));
|
||||
return hasHeader ? [rows[0], ...body] : body;
|
||||
}
|
||||
register(["Bivartable_SortDf", "Bivartable_DoSort"], (_ctx, _node, args) => sortTable(args));
|
||||
|
||||
register("Bivartable_DelDfColRow", (_ctx, _node, args) => {
|
||||
const rows = table(value(args, ["df"]));
|
||||
const deletingColumns = text(args, ["delRange"], "col").toLowerCase().includes("col");
|
||||
const raw = deletingColumns
|
||||
? value(args, [text(args, ["delType"]) === "name" ? "del_col_name_list" : "del_col_num_list"])
|
||||
: value(args, [text(args, ["delType"]) === "name" ? "del_row_name_list" : "del_row_num_list"]);
|
||||
const requested = list(raw);
|
||||
if (deletingColumns) {
|
||||
const indexes = new Set(requested.map((item) => {
|
||||
const byName = rows[0]?.findIndex((cell) => String(cell) === String(item)) ?? -1;
|
||||
return text(args, ["delType"]) === "name" ? byName : columnNumber(item) - 1;
|
||||
}).filter((index) => index >= 0));
|
||||
return rows.map((row) => row.filter((_cell, index) => !indexes.has(index)));
|
||||
}
|
||||
const indexes = new Set(requested.map((item) => Math.max(0, Number(item) - 1)));
|
||||
return rows.filter((_row, index) => !indexes.has(index));
|
||||
});
|
||||
|
||||
register("Bivartable_ReadTable", (_ctx, _node, args) => {
|
||||
const rows = table(value(args, ["df"]));
|
||||
const startRow = Math.max(1, Number(args.start_row ?? args.row ?? 1)) - 1;
|
||||
const endRow = Math.max(startRow + 1, Number(args.end_row ?? args.row ?? rows.length));
|
||||
const startColumn = columnNumber(args.start_column ?? args.column ?? "A") - 1;
|
||||
const endColumn = Math.max(startColumn + 1, columnNumber(args.end_column ?? args.column ?? String(rows[0]?.length || 1)));
|
||||
const selection = rows.slice(startRow, endRow).map((row) => row.slice(startColumn, endColumn));
|
||||
if (selection.length === 1 && selection[0].length === 1) return selection[0][0];
|
||||
return selection;
|
||||
});
|
||||
|
||||
function workbookPath(args: Record<string, unknown>, nodeId: string, componentId: string): string {
|
||||
const raw = value(args, ["workbook", "objExcel", "objExcelWorkBook", "path", "file"]);
|
||||
const candidate = raw && typeof raw === "object" ? (raw as { path?: unknown }).path : raw;
|
||||
return requiredPath(candidate, "Excel文件路径", nodeId, componentId);
|
||||
}
|
||||
|
||||
async function loadSheet(args: Record<string, unknown>, nodeId: string, componentId: string): Promise<ExcelJS.Worksheet> {
|
||||
const workbook = await acquireWorkbook(workbookPath(args, nodeId, componentId));
|
||||
const name = text(args, ["sheet", "sheetName"]);
|
||||
const sheet = name ? workbook.getWorksheet(name) : workbook.worksheets[0];
|
||||
if (!sheet) throw new Error(`Sheet 不存在:${name || "第一个 Sheet"}`);
|
||||
return sheet;
|
||||
}
|
||||
|
||||
function excelValue(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 ?? "";
|
||||
}
|
||||
|
||||
register("Excel_Cs_ReadCell", async (_ctx, node, args) => {
|
||||
const sheet = await loadSheet(args, node.id, node.componentId);
|
||||
return excelValue(sheet.getCell(`${text(args, ["column"], "A")}${text(args, ["row"], "1")}`).value);
|
||||
});
|
||||
|
||||
register("Excel_GetRowsCount", async (_ctx, node, args) => (await loadSheet(args, node.id, node.componentId)).rowCount);
|
||||
register("Excel_GetColumnsCount", async (_ctx, node, args) => (await loadSheet(args, node.id, node.componentId)).columnCount);
|
||||
register("Excel_Cs_GetEditRange", async (_ctx, node, args) => {
|
||||
const sheet = await loadSheet(args, node.id, node.componentId);
|
||||
let startRow = Number.POSITIVE_INFINITY;
|
||||
let startColumn = Number.POSITIVE_INFINITY;
|
||||
let endRow = 0;
|
||||
let endColumn = 0;
|
||||
sheet.eachRow({ includeEmpty: false }, (row, rowNumber) => {
|
||||
row.eachCell({ includeEmpty: false }, (_cell, columnNumberValue) => {
|
||||
startRow = Math.min(startRow, rowNumber);
|
||||
startColumn = Math.min(startColumn, columnNumberValue);
|
||||
endRow = Math.max(endRow, rowNumber);
|
||||
endColumn = Math.max(endColumn, columnNumberValue);
|
||||
});
|
||||
});
|
||||
if (!endRow) return { startRow: 0, startColumn: 0, endRow: 0, endColumn: 0, range: "" };
|
||||
const range = `${sheet.getColumn(startColumn).letter}${startRow}:${sheet.getColumn(endColumn).letter}${endRow}`;
|
||||
return { startRow, startColumn, endRow, endColumn, range };
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { CatalogComponent, FlowNode } from "../types";
|
||||
import type { RunContext } from "../engine/types";
|
||||
import { getHandler } from "../engine/registry";
|
||||
import "./files";
|
||||
|
||||
function run(id: string, args: Record<string, unknown>): Promise<string> {
|
||||
const node: FlowNode = { id: "file-test", componentId: id, values: args, options: {}, position: { x: 0, y: 0 } };
|
||||
const handler = getHandler(id)!;
|
||||
return Promise.resolve(handler({ signal: new AbortController().signal, log: vi.fn(), vars: {} } as unknown as RunContext, node, args, {} as CatalogComponent)) as Promise<string>;
|
||||
}
|
||||
|
||||
const temporary: string[] = [];
|
||||
afterEach(() => {
|
||||
for (const dir of temporary.splice(0)) rm(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("file compression path semantics", () => {
|
||||
it("defaults to a sibling archive and creates a copy instead of overwriting", async () => {
|
||||
const dir = await mkdtemp(path.join(os.tmpdir(), "rpa-file-compress-"));
|
||||
temporary.push(dir);
|
||||
const source = path.join(dir, "report.txt");
|
||||
await writeFile(source, "hello", "utf8");
|
||||
const first = await run("File_CompressFile", { file_type: "openFile", file_path: source, direct_type: "1" });
|
||||
expect(first).toBe(path.join(dir, "report.txt.zip"));
|
||||
expect(existsSync(first)).toBe(true);
|
||||
const second = await run("File_CompressFile", { file_type: "openFile", file_path: source, direct_type: "1" });
|
||||
expect(second).toBe(path.join(dir, "report.txt (1).zip"));
|
||||
expect(existsSync(second)).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts an output directory when custom destination is selected", async () => {
|
||||
const dir = await mkdtemp(path.join(os.tmpdir(), "rpa-file-compress-"));
|
||||
temporary.push(dir);
|
||||
const source = path.join(dir, "logs");
|
||||
const output = path.join(dir, "output");
|
||||
await Promise.all([mkdir(source), mkdir(output)]);
|
||||
await writeFile(path.join(source, "app.log"), "log", "utf8");
|
||||
const target = await run("File_CompressFile", {
|
||||
file_type: "openDirectory", folder_path: source, direct_type: "0", out_path: output
|
||||
});
|
||||
expect(target).toBe(path.join(output, "logs.zip"));
|
||||
expect(existsSync(target)).toBe(true);
|
||||
});
|
||||
|
||||
it("extracts beside the archive by default and to the selected folder for custom mode", async () => {
|
||||
const dir = await mkdtemp(path.join(os.tmpdir(), "rpa-file-extract-"));
|
||||
temporary.push(dir);
|
||||
const source = path.join(dir, "logs.zip");
|
||||
const AdmZip = (await import("adm-zip")).default;
|
||||
const zip = new AdmZip();
|
||||
zip.addFile("app.log", Buffer.from("log", "utf8"));
|
||||
zip.writeZip(source);
|
||||
const defaultTarget = await run("File_DecompressFile", { folder_path: source, direct_type: "1" });
|
||||
expect(defaultTarget).toBe(dir);
|
||||
expect(readFileSync(path.join(dir, "app.log"), "utf8")).toBe("log");
|
||||
|
||||
const custom = path.join(dir, "custom-output");
|
||||
const customTarget = await run("File_DecompressFile", { folder_path: source, direct_type: "0", out_path: custom });
|
||||
expect(customTarget).toBe(custom);
|
||||
expect(readdirSync(custom)).toContain("app.log");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,264 @@
|
||||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
import AdmZip from "adm-zip";
|
||||
import { register } from "../engine/registry";
|
||||
import { RuntimeError } from "../engine/types";
|
||||
|
||||
function pick(args: Record<string, unknown>, keys: string[]): string {
|
||||
for (const key of keys) {
|
||||
const value = args[key];
|
||||
if (value !== undefined && value !== null && value !== "") return String(value);
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function truthy(value: unknown): boolean {
|
||||
return value === true || value === "true" || value === "1" || value === "True" || value === "是" || value === "开启";
|
||||
}
|
||||
|
||||
async function readText(filePath: string, encode = "utf-8"): Promise<string> {
|
||||
const buffer = await fs.readFile(filePath);
|
||||
const encoding = (encode || "utf-8").toLowerCase().replace("_", "-");
|
||||
if (encoding === "utf-8" || encoding === "utf8" || encoding === "auto") return buffer.toString("utf8");
|
||||
try {
|
||||
return new TextDecoder(encoding).decode(buffer);
|
||||
} catch {
|
||||
return buffer.toString("utf8");
|
||||
}
|
||||
}
|
||||
|
||||
async function writeText(filePath: string, content: string, append: boolean, encode = "utf-8"): Promise<number> {
|
||||
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
||||
if (append) await fs.appendFile(filePath, content, "utf8");
|
||||
else await fs.writeFile(filePath, content, "utf8");
|
||||
const stat = await fs.stat(filePath);
|
||||
return stat.size;
|
||||
}
|
||||
|
||||
function requirePath(value: string, label: string, nodeId: string, componentId: string): string {
|
||||
if (!value.trim()) {
|
||||
throw new RuntimeError("validation_error", `${label}为空`, {
|
||||
nodeId,
|
||||
componentId,
|
||||
recommendation: "在属性面板选择文件或目录"
|
||||
});
|
||||
}
|
||||
return path.resolve(value);
|
||||
}
|
||||
|
||||
register("File_Read", async (_ctx, node, args) => {
|
||||
const filePath = requirePath(pick(args, ["path", "file", "file_path"]), "文件路径", node.id, node.componentId);
|
||||
return readText(filePath, pick(args, ["encode"]) || "utf-8");
|
||||
});
|
||||
|
||||
register("File_Write", async (_ctx, node, args) => {
|
||||
const filePath = requirePath(pick(args, ["path", "file", "file_path"]), "文件路径", node.id, node.componentId);
|
||||
const content = String(args.content ?? args.value ?? "");
|
||||
const append = truthy(args.append);
|
||||
return writeText(filePath, content, append, pick(args, ["encode"]) || "utf-8");
|
||||
});
|
||||
|
||||
register(["File_CreateFile"], async (_ctx, node, args) => {
|
||||
// catalog 字段为 folder_path + name(组合成完整路径),兼容旧键 path
|
||||
const base = pick(args, ["folder_path", "path", "file", "file_path"]);
|
||||
const name = pick(args, ["name"]);
|
||||
const filePath = requirePath(name ? path.join(base, name) : base, "文件路径", node.id, node.componentId);
|
||||
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
||||
await fs.writeFile(filePath, "", { flag: "a" });
|
||||
return filePath;
|
||||
});
|
||||
|
||||
register("File_Copy", async (_ctx, node, args) => {
|
||||
const source = requirePath(pick(args, ["pathSrc", "src", "source"]), "源路径", node.id, node.componentId);
|
||||
const target = requirePath(pick(args, ["pathDest", "dest", "target"]), "目标路径", node.id, node.componentId);
|
||||
const overwrite = truthy(args.cover ?? args.overwrite);
|
||||
const stat = await fs.stat(source);
|
||||
if (stat.isDirectory()) {
|
||||
await fs.cp(source, target, { recursive: true, force: overwrite });
|
||||
} else {
|
||||
await fs.mkdir(path.dirname(target), { recursive: true });
|
||||
await fs.copyFile(source, target);
|
||||
}
|
||||
return target;
|
||||
});
|
||||
|
||||
register(["File_File_Move", "File_Move"], async (_ctx, node, args) => {
|
||||
// File_Move 的 catalog 字段为 file_type + file_path/folder_path + target_path(+name),与 File_File_Move 的 pathSrc/pathDest 兼容
|
||||
const isFolder = String(args.file_type ?? "file") === "folder";
|
||||
const source = requirePath(pick(args, ["pathSrc", isFolder ? "folder_path" : "file_path", "src", "source"]), "源路径", node.id, node.componentId);
|
||||
let target = pick(args, ["pathDest", "dest"]);
|
||||
if (!target) {
|
||||
const destDir = pick(args, ["target_path"]);
|
||||
const newName = truthy(args.rename) ? pick(args, ["name"]) : "";
|
||||
target = requirePath(newName ? path.join(destDir, newName) : destDir, "目的地路径", node.id, node.componentId);
|
||||
}
|
||||
await fs.mkdir(path.dirname(target), { recursive: true });
|
||||
try {
|
||||
await fs.rename(source, target);
|
||||
} catch {
|
||||
await fs.cp(source, target, { recursive: true, force: true });
|
||||
await fs.rm(source, { recursive: true, force: true });
|
||||
}
|
||||
return target;
|
||||
});
|
||||
|
||||
register(["File_DeleteFile", "File_Delete"], async (_ctx, node, args) => {
|
||||
const type = String(args.file_type ?? "file");
|
||||
const target = type === "folder"
|
||||
? pick(args, ["folder_path", "path"])
|
||||
: pick(args, ["file_path", "path"]);
|
||||
const resolved = requirePath(target, "待删除路径", node.id, node.componentId);
|
||||
await fs.rm(resolved, { recursive: true, force: true });
|
||||
return resolved;
|
||||
});
|
||||
|
||||
register(["File_FileExists", "File_PathExists"], async (_ctx, node, args) => {
|
||||
const target = requirePath(pick(args, ["path", "file", "file_path"]), "文件路径", node.id, node.componentId);
|
||||
try {
|
||||
await fs.access(target);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
register("File_GetFileList", async (_ctx, node, args) => {
|
||||
const folder = requirePath(pick(args, ["folder_path", "folder", "path"]), "文件夹", node.id, node.componentId);
|
||||
const keyword = pick(args, ["key_word"]);
|
||||
const ignore = pick(args, ["ignore"]);
|
||||
const recursive = truthy(args.recursive);
|
||||
const fullPath = truthy(args.is_full_path ?? "1");
|
||||
const results: string[] = [];
|
||||
async function walk(dir: string): Promise<void> {
|
||||
const entries = await fs.readdir(dir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const absolute = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
if (recursive) await walk(absolute);
|
||||
continue;
|
||||
}
|
||||
if (keyword && !entry.name.includes(keyword)) continue;
|
||||
if (ignore && entry.name.includes(ignore)) continue;
|
||||
results.push(fullPath ? absolute : entry.name);
|
||||
}
|
||||
}
|
||||
await walk(folder);
|
||||
results.sort((a, b) => a.localeCompare(b, "zh-CN"));
|
||||
return results;
|
||||
});
|
||||
|
||||
register(["File_MakeDir", "File_MakeFolder", "File_CreateFolder"], async (_ctx, node, args) => {
|
||||
const base = pick(args, ["path", "folder_path", "folder"]);
|
||||
const name = pick(args, ["name"]);
|
||||
const target = requirePath(name ? path.join(base, name) : base, "文件夹路径", node.id, node.componentId);
|
||||
await fs.mkdir(target, { recursive: true });
|
||||
return target;
|
||||
});
|
||||
|
||||
register(["File_PathSize", "File_PathSizeNew"], async (_ctx, node, args) => {
|
||||
const target = requirePath(pick(args, ["path", "file"]), "路径", node.id, node.componentId);
|
||||
const stat = await fs.stat(target);
|
||||
return stat.size;
|
||||
});
|
||||
|
||||
register("File_GetFileInfo", async (_ctx, node, args) => {
|
||||
// catalog 字段为 file_path,兼容 path/file
|
||||
const target = requirePath(pick(args, ["file_path", "path", "file"]), "路径", node.id, node.componentId);
|
||||
const stat = await fs.stat(target);
|
||||
return {
|
||||
path: target,
|
||||
name: path.basename(target),
|
||||
size: stat.size,
|
||||
isDirectory: stat.isDirectory(),
|
||||
createdAt: stat.birthtime.toISOString(),
|
||||
modifiedAt: stat.mtime.toISOString()
|
||||
};
|
||||
});
|
||||
|
||||
register("File_WaitFile", async (ctx, node, args) => {
|
||||
// catalog 字段为 file_path,兼容 path/file
|
||||
const target = requirePath(pick(args, ["file_path", "path", "file"]), "文件路径", node.id, node.componentId);
|
||||
const timeout = Math.max(1, Number(args.time_out ?? args.timeout ?? 30)) * 1000;
|
||||
const started = Date.now();
|
||||
while (Date.now() - started < timeout) {
|
||||
if (ctx.signal.aborted) throw new RuntimeError("cancelled", "运行已取消");
|
||||
try {
|
||||
await fs.access(target);
|
||||
return true;
|
||||
} catch {
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
}
|
||||
}
|
||||
throw new RuntimeError("timeout", `等待文件超时:${target}`, {
|
||||
nodeId: node.id,
|
||||
componentId: node.componentId,
|
||||
recommendation: "检查文件是否生成,或调大超时时间"
|
||||
});
|
||||
});
|
||||
|
||||
function directoryOutputPath(outputPath: string, sourcePath: string): string {
|
||||
const output = path.resolve(outputPath);
|
||||
if (/[\\/]$/.test(outputPath) || path.extname(output).toLowerCase() !== ".zip") {
|
||||
return path.join(output, path.basename(sourcePath) + ".zip");
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
async function uniqueZipPath(targetPath: string): Promise<string> {
|
||||
try {
|
||||
await fs.access(targetPath);
|
||||
} catch {
|
||||
return targetPath;
|
||||
}
|
||||
const dir = path.dirname(targetPath);
|
||||
const ext = path.extname(targetPath);
|
||||
const base = path.basename(targetPath, ext);
|
||||
for (let index = 1; ; index++) {
|
||||
const candidate = path.join(dir, `${base} (${index})${ext}`);
|
||||
try {
|
||||
await fs.access(candidate);
|
||||
} catch {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
register("File_CompressFile", async (_ctx, node, args) => {
|
||||
// catalog 字段为 file_path(文件模式)/folder_path(文件夹模式)+ out_path,兼容旧键
|
||||
const contentType = String(args.file_type ?? "openFile");
|
||||
const sourceKeys = contentType === "openDirectory"
|
||||
? ["folder_path", "pathSrc", "path"]
|
||||
: ["file_path", "pathSrc", "path"];
|
||||
const source = requirePath(pick(args, sourceKeys), contentType === "openDirectory" ? "待压缩文件夹路径" : "待压缩文件路径", node.id, node.componentId);
|
||||
const saveToSourceDir = String(args.direct_type ?? "1") !== "0";
|
||||
const requestedTarget = saveToSourceDir
|
||||
? path.join(path.dirname(source), path.basename(source) + ".zip")
|
||||
: directoryOutputPath(requirePath(pick(args, ["pathDest", "zip_path", "out_path"]), "压缩包输出位置", node.id, node.componentId), source);
|
||||
const target = await uniqueZipPath(requestedTarget);
|
||||
const zip = new AdmZip();
|
||||
const stat = await fs.stat(source);
|
||||
if (stat.isDirectory()) zip.addLocalFolder(source);
|
||||
else zip.addLocalFile(source);
|
||||
await fs.mkdir(path.dirname(target), { recursive: true });
|
||||
zip.writeZip(target);
|
||||
return target;
|
||||
});
|
||||
|
||||
register("File_DecompressFile", async (_ctx, node, args) => {
|
||||
const source = requirePath(pick(args, ["folder_path", "path", "zip_path"]), "压缩包路径", node.id, node.componentId);
|
||||
const saveToSourceDir = String(args.direct_type ?? "1") !== "0";
|
||||
const target = saveToSourceDir
|
||||
? path.dirname(source)
|
||||
: requirePath(pick(args, ["out_path", "pathDest", "target"]), "解压目标目录", node.id, node.componentId);
|
||||
await fs.mkdir(target, { recursive: true });
|
||||
new AdmZip(source).extractAllTo(target, true);
|
||||
return target;
|
||||
});
|
||||
|
||||
register("File_Rename", async (_ctx, node, args) => {
|
||||
const source = requirePath(pick(args, ["pathSrc", "path"]), "源路径", node.id, node.componentId);
|
||||
const newName = pick(args, ["reName", "name"]);
|
||||
const target = path.join(path.dirname(source), newName);
|
||||
await fs.rename(source, target);
|
||||
return target;
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
import { Client } from "basic-ftp";
|
||||
import path from "node:path";
|
||||
import fs from "node:fs";
|
||||
import { register } from "../engine/registry";
|
||||
import { RuntimeError } from "../engine/types";
|
||||
import { defaultEngine } from "../engine/engine";
|
||||
|
||||
async function requireClient(nodeId: string): Promise<Client> {
|
||||
if (defaultEngine.ftpClient) return defaultEngine.ftpClient;
|
||||
throw new RuntimeError("validation_error", "尚未连接 FTP,请先使用 FTP_Connect", { nodeId, componentId: nodeId, recommendation: "先运行 FTP_Connect 建立连接" });
|
||||
}
|
||||
|
||||
function requireText(args: Record<string, unknown>, keys: string[], label: string, nodeId: string): string {
|
||||
for (const key of keys) {
|
||||
const v = String(args[key] ?? "").trim();
|
||||
if (v) return v;
|
||||
}
|
||||
throw new RuntimeError("validation_error", label + "为空", { nodeId, componentId: nodeId });
|
||||
}
|
||||
|
||||
register("FTP_Connect", async (ctx, node, args) => {
|
||||
const host = requireText(args, ["address"], "FTP 地址", node.id);
|
||||
const port = Number(args.port ?? 21) || 21;
|
||||
const user = String(args.user ?? "anonymous");
|
||||
const password = String(args.pass_word ?? args.pass_word_file ?? "guest");
|
||||
const secure = String(args.ftp_type ?? "").toLowerCase().includes("ftps");
|
||||
const client = new Client(10_000);
|
||||
await client.access({ host, port, secure, user, password });
|
||||
defaultEngine.ftpClient = client;
|
||||
ctx.log("已连接 FTP:" + host + ":" + port, "info", node.id);
|
||||
return { connected: true, host, port };
|
||||
});
|
||||
|
||||
register("FTP_Disconnect", async (ctx, node) => {
|
||||
defaultEngine.ftpClient?.close();
|
||||
defaultEngine.ftpClient = undefined;
|
||||
ctx.log("已断开 FTP 连接", "info", node.id);
|
||||
return { disconnected: true };
|
||||
});
|
||||
|
||||
register("FTP_GetFileList", async (ctx, node, args) => {
|
||||
const client = await requireClient(node.id);
|
||||
const list = await client.list("");
|
||||
const entries = list.map(item => ({ name: item.name, size: item.size, isDirectory: item.isDirectory, modifiedAt: item.modifiedAt instanceof Date ? item.modifiedAt.toISOString() : String(item.modifiedAt ?? "") }));
|
||||
ctx.log("已获取文件列表:" + entries.length + " 项", "info", node.id);
|
||||
return entries;
|
||||
});
|
||||
|
||||
register("FTP_Cwd", async (ctx, node, args) => {
|
||||
const client = await requireClient(node.id);
|
||||
const dir = requireText(args, ["dir"], "目标目录", node.id);
|
||||
await client.cd(dir);
|
||||
ctx.log("已切换工作目录:" + dir, "info", node.id);
|
||||
return { cwd: dir };
|
||||
});
|
||||
|
||||
register("FTP_Download", async (ctx, node, args) => {
|
||||
const client = await requireClient(node.id);
|
||||
const remote = requireText(args, ["file_path"], "远程文件路径", node.id);
|
||||
const localDir = String(args.folder_path ?? "").trim();
|
||||
const target = requireText(args, ["target_path"], "本地保存路径", node.id) || (localDir ? path.join(localDir, path.basename(remote)) : path.basename(remote));
|
||||
fs.mkdirSync(path.dirname(path.resolve(target)), { recursive: true });
|
||||
if (String(args.file_type ?? "0") === "1") {
|
||||
fs.mkdirSync(path.resolve(target), { recursive: true });
|
||||
await client.ensureDir(remote);
|
||||
await client.downloadToDir(target, remote);
|
||||
} else {
|
||||
await client.downloadTo(target, remote);
|
||||
}
|
||||
ctx.log("已下载到:" + target, "info", node.id);
|
||||
return { downloaded: true, local: target };
|
||||
});
|
||||
|
||||
register("FTP_Upload", async (ctx, node, args) => {
|
||||
const client = await requireClient(node.id);
|
||||
const local = requireText(args, ["file_path"], "本地文件路径", node.id);
|
||||
const remote = requireText(args, ["remote_path"], "远程目标路径", node.id);
|
||||
if (String(args.file_type ?? "0") === "1" || fs.statSync(local).isDirectory()) {
|
||||
await client.uploadFromDir(local, remote);
|
||||
} else {
|
||||
await client.uploadFrom(local, remote);
|
||||
}
|
||||
ctx.log("已上传:" + local + " → " + remote, "info", node.id);
|
||||
return { uploaded: true, remote };
|
||||
});
|
||||
|
||||
register("FTP_Rename", async (ctx, node, args) => {
|
||||
const client = await requireClient(node.id);
|
||||
const from = requireText(args, ["file_path"], "原路径", node.id);
|
||||
const to = requireText(args, ["toname"], "新名称", node.id);
|
||||
const parent = path.posix.dirname(from);
|
||||
await client.rename(from, parent === "." ? to : path.posix.join(parent, to));
|
||||
ctx.log("已重命名:" + from + " → " + to, "info", node.id);
|
||||
return { renamed: true };
|
||||
});
|
||||
|
||||
register("FTP_Delete", async (ctx, node, args) => {
|
||||
const client = await requireClient(node.id);
|
||||
const remote = requireText(args, ["file_path"], "远程路径", node.id);
|
||||
if (String(args.file_type ?? "0") === "1") {
|
||||
await client.removeDir(remote);
|
||||
} else {
|
||||
await client.remove(remote);
|
||||
}
|
||||
ctx.log("已删除:" + remote, "info", node.id);
|
||||
return { deleted: true };
|
||||
});
|
||||
|
||||
register("FTP_CreateDir", async (ctx, node, args) => {
|
||||
const client = await requireClient(node.id);
|
||||
const dir = requireText(args, ["dirname"], "目录名", node.id);
|
||||
await client.ensureDir(dir);
|
||||
// 创建后进入该目录;必须 await,否则下个节点的命令与本次 cd 并发,
|
||||
// basic-ftp 会以 "task while another one is still running" 关闭客户端
|
||||
await client.cd(dir).catch(() => {});
|
||||
ctx.log("已创建目录:" + dir, "info", node.id);
|
||||
return { created: true, dir };
|
||||
});
|
||||
|
||||
register("FTP_Exist", async (ctx, node, args) => {
|
||||
const client = await requireClient(node.id);
|
||||
const target = requireText(args, ["path"], "远程路径", node.id);
|
||||
const parent = path.posix.dirname(target);
|
||||
const name = path.posix.basename(target);
|
||||
const list = await client.list(parent === "." ? "" : parent);
|
||||
const exists = list.some(item => item.name === name);
|
||||
return { exists, path: target };
|
||||
});
|
||||
@@ -0,0 +1,143 @@
|
||||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { register } from "../engine/registry";
|
||||
import { RuntimeError } from "../engine/types";
|
||||
|
||||
function parseObject(value: unknown): Record<string, string> {
|
||||
if (!value) return {};
|
||||
if (typeof value === "object") {
|
||||
const result: Record<string, string> = {};
|
||||
for (const [key, item] of Object.entries(value as Record<string, unknown>)) result[key] = String(item ?? "");
|
||||
return result;
|
||||
}
|
||||
const text = String(value).trim();
|
||||
if (!text) return {};
|
||||
try {
|
||||
const parsed = JSON.parse(text);
|
||||
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
||||
const result: Record<string, string> = {};
|
||||
for (const [key, item] of Object.entries(parsed as Record<string, unknown>)) result[key] = String(item ?? "");
|
||||
return result;
|
||||
}
|
||||
} catch {
|
||||
// fall through to query string
|
||||
}
|
||||
const result: Record<string, string> = {};
|
||||
for (const pair of text.split("&")) {
|
||||
if (!pair) continue;
|
||||
const [key, ...rest] = pair.split("=");
|
||||
result[decodeURIComponent(key)] = decodeURIComponent(rest.join("="));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function withQuery(url: string, params: Record<string, string>): string {
|
||||
if (!Object.keys(params).length) return url;
|
||||
const target = new URL(url);
|
||||
for (const [key, value] of Object.entries(params)) target.searchParams.set(key, value);
|
||||
return target.toString();
|
||||
}
|
||||
|
||||
async function request(
|
||||
nodeId: string,
|
||||
componentId: string,
|
||||
method: string,
|
||||
args: Record<string, unknown>,
|
||||
signal: AbortSignal
|
||||
): Promise<unknown> {
|
||||
const rawUrl = String(args.url ?? args.web_page ?? "").trim();
|
||||
if (!rawUrl) {
|
||||
throw new RuntimeError("validation_error", "URL 为空", {
|
||||
nodeId,
|
||||
componentId,
|
||||
recommendation: "在属性面板填写请求地址"
|
||||
});
|
||||
}
|
||||
const timeout = Math.max(1000, Number(args.timeout ?? args.time_out ?? 60000));
|
||||
const headers = parseObject(args.headers);
|
||||
const cookies = parseObject(args.cookies);
|
||||
if (Object.keys(cookies).length) {
|
||||
headers.Cookie = Object.entries(cookies).map(([key, value]) => `${key}=${value}`).join("; ");
|
||||
}
|
||||
const params = parseObject(args.param);
|
||||
const body = String(args.body ?? args.data ?? "");
|
||||
|
||||
let url = rawUrl;
|
||||
let payload: string | undefined;
|
||||
if (method === "GET") {
|
||||
url = withQuery(url, params);
|
||||
} else if (body) {
|
||||
payload = body;
|
||||
} else if (Object.keys(params).length) {
|
||||
const asJson = /json/i.test(headers["Content-Type"] || headers["content-type"] || "");
|
||||
if (asJson) {
|
||||
payload = JSON.stringify(params);
|
||||
headers["Content-Type"] = headers["Content-Type"] || "application/json";
|
||||
} else {
|
||||
payload = new URLSearchParams(params).toString();
|
||||
headers["Content-Type"] = headers["Content-Type"] || "application/x-www-form-urlencoded";
|
||||
}
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeout);
|
||||
const onAbort = () => controller.abort();
|
||||
if (signal.aborted) controller.abort();
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
|
||||
try {
|
||||
const response = await fetch(url, { method, headers, body: payload, signal: controller.signal });
|
||||
const contentType = response.headers.get("content-type") || "";
|
||||
const text = await response.text();
|
||||
if (!response.ok && !/Http_Http_Request/i.test(componentId)) {
|
||||
throw new RuntimeError("network_error", `HTTP ${response.status} ${response.statusText}`, {
|
||||
nodeId,
|
||||
componentId,
|
||||
recommendation: "检查地址、参数与鉴权信息"
|
||||
});
|
||||
}
|
||||
const savePath = String(args.file_path ?? "").trim();
|
||||
if (savePath) {
|
||||
await fs.mkdir(path.dirname(path.resolve(savePath)), { recursive: true });
|
||||
await fs.writeFile(path.resolve(savePath), text, "utf8");
|
||||
}
|
||||
if (/json/i.test(contentType) || (text.trim().startsWith("{") || text.trim().startsWith("["))) {
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
return text;
|
||||
} catch (error) {
|
||||
if (error instanceof RuntimeError) throw error;
|
||||
if ((error as Error).name === "AbortError") {
|
||||
throw new RuntimeError("timeout", `请求超时(${timeout}ms)`, {
|
||||
nodeId,
|
||||
componentId,
|
||||
recommendation: "增加超时时间或检查网络"
|
||||
});
|
||||
}
|
||||
throw new RuntimeError("network_error", (error as Error).message, {
|
||||
nodeId,
|
||||
componentId,
|
||||
recommendation: "检查 URL、代理与网络连接"
|
||||
});
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
}
|
||||
}
|
||||
|
||||
register(["Http_Get", "Http_Get_V1", "Http_Get_V2"], (ctx, node, args) =>
|
||||
request(node.id, node.componentId, "GET", args, ctx.signal)
|
||||
);
|
||||
|
||||
register(["Http_Post", "Http_Post_V2", "Http_Post_V3"], (ctx, node, args) =>
|
||||
request(node.id, node.componentId, "POST", args, ctx.signal)
|
||||
);
|
||||
|
||||
register("Http_Http_Request", (ctx, node, args) => {
|
||||
const method = String(args.method ?? "GET").toUpperCase();
|
||||
return request(node.id, node.componentId, method, args, ctx.signal);
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* rpa-core 纯 Node 能力注册入口。
|
||||
*
|
||||
* import 本模块即完成全部纯 Node 能力的副作用注册(与改造前
|
||||
* src/main/capabilities/index.ts 的加载顺序保持一致,别名/重复 id 的
|
||||
* last-wins 语义不变)。依赖 Electron 或宿主模块的能力(human-dialog /
|
||||
* window-extended / ai-service / ai-caps / browser* / web-batch2 / web-crawl)
|
||||
* 留在宿主 src/main/capabilities,阶段 2 通过 HostBridge 迁入。
|
||||
*/
|
||||
import "./control";
|
||||
import "./files";
|
||||
import "./http";
|
||||
import "./data";
|
||||
import "./office";
|
||||
import "./desktop";
|
||||
import "./desktop-uia";
|
||||
import "./time-caps";
|
||||
import "./windows-system";
|
||||
import "./ftp";
|
||||
import "./ocr-caps";
|
||||
import "./pdf";
|
||||
import "./word";
|
||||
import "./word-extended";
|
||||
import "./message";
|
||||
import "./vision";
|
||||
import "./cv-vision";
|
||||
import "./extended";
|
||||
import "./system-extended";
|
||||
import "./office-file-extended";
|
||||
import "./office-aliases";
|
||||
import "./excel-common";
|
||||
import "./excel-com";
|
||||
import "./data-extended";
|
||||
import "./utility-extended";
|
||||
import "./data-table-common";
|
||||
import "./basic-encryption";
|
||||
import "./mail-remaining";
|
||||
import "./remaining-core";
|
||||
import "./common-final";
|
||||
@@ -0,0 +1,95 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { CatalogComponent, FlowNode } from "../types";
|
||||
import { getHandler } from "../engine/registry";
|
||||
import type { RunContext } from "../engine/types";
|
||||
import "./desktop";
|
||||
|
||||
const component = {} as CatalogComponent;
|
||||
const node = (id: string): FlowNode => ({ id: "smoke-kb", componentId: id, position: { x: 0, y: 0 }, values: {}, options: {} });
|
||||
|
||||
const ctx = {
|
||||
runId: "kb-smoke", flowId: "kb-smoke-flow", flow: {} as RunContext["flow"],
|
||||
vars: {}, secrets: {}, signal: new AbortController().signal, evidenceDir: "D:/office-test2",
|
||||
emit: () => {}, log: () => {}, componentOf: () => undefined
|
||||
} as unknown as RunContext;
|
||||
|
||||
async function invoke(componentId: string, args: Record<string, unknown>): Promise<unknown> {
|
||||
const handler = getHandler(componentId);
|
||||
expect(handler).toBeTypeOf("function");
|
||||
return handler!(ctx, node(componentId), args, component);
|
||||
}
|
||||
|
||||
describe("键盘输入组件真实冒烟", () => {
|
||||
it("words 字段取值(此前读不到报「键盘输入为空」), 后台接口模式整段发送特殊字符不抛错", async () => {
|
||||
const out = await invoke("Keyboard_Send_V1", { words: "哈哈哈(测试)100%^2", interval: "10", fuctype: "0" });
|
||||
expect(out).toBe("哈哈哈(测试)100%^2");
|
||||
console.log("[键盘输入] 后台接口模式整段发送 ✓:", out);
|
||||
}, 60000);
|
||||
|
||||
it("人工模拟模式逐字符+间隔毫秒真实发送", async () => {
|
||||
const out = await invoke("Keyboard_Send_V1", { words: "逐字输入ABC", interval: "5", fuctype: "1" });
|
||||
expect(out).toBe("逐字输入ABC");
|
||||
console.log("[键盘输入] 人工模拟逐字符 ✓:", out);
|
||||
}, 60000);
|
||||
|
||||
it("人工模拟模式含撇号与特殊字符(用户 09:05 踩坑场景)不抛语法错", async () => {
|
||||
const out = await invoke("Keyboard_Send_V1", { words: "hhhhh' +^%~(){}[]", interval: "10", fuctype: "1" });
|
||||
expect(out).toBe("hhhhh' +^%~(){}[]");
|
||||
console.log("[键盘输入] 人工模拟+撇号+特殊字符 ✓:", out);
|
||||
}, 60000);
|
||||
|
||||
it("人工模拟模式含中文标点(用户 09:32 踩坑场景:】【‘;。/ 智能引号)不抛语法错", async () => {
|
||||
const words = "jashfjksudfgisa54445哈哈哈哈AAAAHHBHG+t】【‘;。/";
|
||||
const out = await invoke("Keyboard_Send_V1", { words, interval: "10", fuctype: "1" });
|
||||
expect(out).toBe(words);
|
||||
console.log("[键盘输入] 人工模拟+中文标点+智能引号 ✓");
|
||||
}, 60000);
|
||||
|
||||
it("剪切板粘贴模式: 原文进入剪贴板并发送 Ctrl+V", async () => {
|
||||
const content = `粘贴${Date.now()}`;
|
||||
const out = await invoke("Keyboard_Send_V1", { words: content, fuctype: "2" });
|
||||
expect(out).toBe(content);
|
||||
const clip = await invoke("Clipboard_GetText", {}) as string;
|
||||
expect(clip).toBe(content);
|
||||
console.log("[键盘输入] 剪贴板粘贴模式 ✓ 剪贴板内容一致:", clip);
|
||||
}, 60000);
|
||||
|
||||
it("空输入仍报「键盘输入为空」", async () => {
|
||||
await expect(invoke("Keyboard_Send_V1", { words: "", interval: "10" })).rejects.toThrow("键盘输入为空");
|
||||
console.log("[键盘输入] 空输入校验 ✓");
|
||||
}, 60000);
|
||||
|
||||
it("输入目标窗口: 先激活记事本再输入, 前台窗口切到位", async () => {
|
||||
const { execFile } = await import("node:child_process");
|
||||
const { promisify } = await import("node:util");
|
||||
const runPs = promisify(execFile);
|
||||
const ps = (cmd: string) => runPs("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", cmd], { windowsHide: true, timeout: 30000 });
|
||||
// 窗口标题随系统语言变化(英文 Notepad / 中文 记事本), 不硬编码, 启动后动态取实际标题
|
||||
await ps("Start-Process $env:SystemRoot\\System32\\notepad.exe; $diag=''; $ok=$false; foreach($i in 1..24){ $procs=Get-Process Notepad -ErrorAction SilentlyContinue; if($procs){ $p=$procs | Where-Object {$_.MainWindowTitle}; if($p){$ok=$true; break} } else { $diag='no-process' }; Start-Sleep -Milliseconds 500 }; if(-not $ok){ throw \"notepad not visible: $diag\" }");
|
||||
try {
|
||||
const titlePs = "[Console]::OutputEncoding=[System.Text.UTF8Encoding]::new($false); (Get-Process Notepad | Where-Object { $_.MainWindowTitle } | Select-Object -First 1).MainWindowTitle";
|
||||
const title = (await ps(titlePs)).stdout.trim();
|
||||
expect(title.length).toBeGreaterThan(0);
|
||||
// GUI 焦点抢占受系统前台锁约束, 偶发失败属正常抖动, 有界重试
|
||||
let lastErr: unknown;
|
||||
for (let attempt = 1; attempt <= 3; attempt += 1) {
|
||||
try {
|
||||
await invoke("Keyboard_Send_V1", { words: "目标窗口输入'测试", interval: "5", fuctype: "1", window_title: title });
|
||||
lastErr = undefined;
|
||||
break;
|
||||
} catch (error) {
|
||||
lastErr = error;
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
}
|
||||
}
|
||||
if (lastErr) throw lastErr;
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
// activateWindowScript 内部已校验前台句柄=目标窗口, 这里再确认前台标题仍是记事本(输入后标题会带 * 脏标记前缀)
|
||||
const fg = await ps(titlePs);
|
||||
expect(fg.stdout.trim()).toContain(title);
|
||||
console.log("[键盘输入] 目标窗口激活 ✓ 前台:", fg.stdout.trim());
|
||||
} finally {
|
||||
await ps("Get-Process notepad* -ErrorAction SilentlyContinue | Stop-Process -Force").catch(() => {});
|
||||
}
|
||||
}, 90000);
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import { getHandler, register } from "../engine/registry";
|
||||
import { RuntimeError, type CapabilityHandler } from "../engine/types";
|
||||
import { findImapMail, type NormalizedMail } from "./message";
|
||||
|
||||
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 number(value: unknown, fallback = 1): number { const parsed = Number(value); return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : fallback; }
|
||||
function record(value: unknown): Record<string, unknown> { return value && typeof value === "object" ? value as Record<string, unknown> : (() => { try { return JSON.parse(String(value || "{}")); } catch { return {}; } })(); }
|
||||
function list(value: unknown): string[] { if (Array.isArray(value)) return value.map(String); try { const parsed = JSON.parse(String(value || "[]")); return Array.isArray(parsed) ? parsed.map(String) : [String(parsed)]; } catch { return String(value || "").split(/[,,;;\s]+/).filter(Boolean); } }
|
||||
function mergeAccount(args: Record<string, unknown>): Record<string, unknown> { return { ...record(args.account_obj || args.recvObject || args.target_obj), ...args }; }
|
||||
async function imapClient(args: Record<string, unknown>) {
|
||||
const { ImapFlow } = await import("imapflow"); const config = mergeAccount(args);
|
||||
const client = new ImapFlow({ host: text(config, ["host", "hostname", "server_address", "server"]), port: number(config.port, 993), secure: ["true", "1", "True"].includes(text(config, ["ssl", "is_ssl", "secure"], "True")), auth: { user: text(config, ["account", "username", "user"]), pass: text(config, ["password", "pass"]) }, logger: false });
|
||||
await client.connect(); return client;
|
||||
}
|
||||
async function withMailbox<T>(args: Record<string, unknown>, callback: (client: any) => Promise<T>): Promise<T> {
|
||||
const client = await imapClient(args); const lock = await client.getMailboxLock(text(args, ["folder", "email_folder", "inbox_type", "folder_name"], "INBOX"));
|
||||
try { return await callback(client); } finally { lock.release(); client.close(); }
|
||||
}
|
||||
async function searchMails(args: Record<string, unknown>): Promise<NormalizedMail[]> {
|
||||
const normalized: Record<string, unknown> = { ...args, From_data: text(args, ["From_data", "From", "sender_key", "recipient_key"]), Subject_data: text(args, ["Subject_data", "Subject", "subject_key"]), Body_data: text(args, ["Body_data", "body_key"]) };
|
||||
return await findImapMail(normalized, { id: "mail-remaining", componentId: "mail-remaining" });
|
||||
}
|
||||
const connect: CapabilityHandler = async (_ctx, _node, args) => { const config = mergeAccount(args); await imapClient(config).then((client) => client.close()); return { kind: "imap", ...config }; };
|
||||
register(["Mail_RecvConnect", "Mail_UnionConnect", "Mail_ExConnect"], connect);
|
||||
register("Mail_RecvClose", async () => ({ closed: true }));
|
||||
register("Mail_RecvGetList", async (_ctx, _node, args) => await searchMails(args));
|
||||
register("Mail_ExSearchMail", async (_ctx, _node, args) => await searchMails(args));
|
||||
register("Mail_RecvMailGet", async (_ctx, _node, args) => {
|
||||
const mails = await searchMails(args); const wanted = number(args.limit || args.index, 1); const mail = mails[wanted - 1];
|
||||
if (!mail) throw new RuntimeError("not_found", "未找到第 " + wanted + " 封邮件"); return mail;
|
||||
});
|
||||
register("Mail_RecvCount", async (_ctx, _node, args) => await withMailbox(args, async (client) => client.mailbox.exists));
|
||||
async function setSeen(args: Record<string, unknown>, seen: boolean) {
|
||||
return await withMailbox(args, async (client) => { const sequence = String(number(args.index || args.limit, 1)); const flag = String.fromCharCode(92) + "Seen";
|
||||
return seen ? await client.messageFlagsAdd(sequence, [flag]) : await client.messageFlagsRemove(sequence, [flag]); });
|
||||
}
|
||||
register("Mail_MarkSeen", async (_ctx, _node, args) => setSeen(args, true));
|
||||
register("Mail_UnmarkSeen", async (_ctx, _node, args) => setSeen(args, false));
|
||||
register(["Mail_DoMoveMail", "Mail_OperateMail", "Mail_ExOperateMail", "Mail_DoMoveMail_v1"], async (_ctx, _node, args) => {
|
||||
const destination = text(args, ["DstFile", "destination", "folder_name", "email_folder"], "Archive");
|
||||
return await withMailbox(args, async (client) => await client.messageMove(String(number(args.index || args.limit, 1)), destination));
|
||||
});
|
||||
async function invoke(id: string, args: Record<string, unknown>) { const handler = getHandler(id); if (!handler) throw new RuntimeError("capability_not_implemented", id + " 不可用"); return handler; }
|
||||
register("Mail_DoSendMail", async (ctx, node, args) => {
|
||||
const handler = await invoke("Mail_Send", args); const config = mergeAccount(args);
|
||||
return await handler(ctx, node, { ...args, host: text(config, ["host", "server_address"]), account: text(config, ["Account", "account", "username"]), password: text(config, ["password"]), to: list(args.RecvByEmail), cc: list(args.CC), bcc: list(args.BCC), subject: text(args, ["Subject"]), content: text(args, ["Body", "body"]), attachment: text(args, ["filePath", "attachment"]) }, {} as never);
|
||||
});
|
||||
register("Mail_DoSendMail_v1", async (ctx, node, args) => { const handler = await invoke("Mail_DoSendMail", args); return await handler(ctx, node, { ...args, account_obj: mergeAccount(args), RecvByEmail: args.RecvByEmail || args.target_email, CC: args.CC || args.cc, Subject: args.Subject || args.subject, Body: args.Body || args.body || args.content, filePath: args.filePath || args.attachment }, {} as never); });
|
||||
register("Mail_ExSendMail", async (ctx, node, args) => { const handler = await invoke("Mail_Send", args);
|
||||
return await handler(ctx, node, { ...args, to: list(args.target_email), cc: list(args.cc), subject: text(args, ["subject"]), content: text(args, ["body"]), attachment: text(args, ["filePath", "attachment"]) }, {} as never);
|
||||
});
|
||||
register("Mail_DoReplyMail", async (ctx, node, args) => { const handler = await invoke("Mail_ReplyMail_v2", args);
|
||||
return await handler(ctx, node, { ...args, subject: text(args, ["Subject"], "Re:"), content: text(args, ["ReplyBody", "content"]), attachment: text(args, ["filePath", "attachment"]) }, {} as never);
|
||||
});
|
||||
register("Mail_DoReplyMail_v1", async (ctx, node, args) => { const handler = await invoke("Mail_ReplyMail_v2", args);
|
||||
return await handler(ctx, node, { ...args, subject: text(args, ["subject"], "Re:"), content: text(args, ["content", "ReplyBody"]), attachment: text(args, ["attachment"]) }, {} as never);
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import net from "node:net";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import type { CatalogComponent, FlowNode } from "../types";
|
||||
import type { RunContext } from "../engine/types";
|
||||
import { getHandler, hasCapability } from "../engine/registry";
|
||||
import "./message";
|
||||
|
||||
const context = {} as RunContext;
|
||||
const component = {} as CatalogComponent;
|
||||
const node = (id: string): FlowNode => ({ id, componentId: id, position: { x: 0, y: 0 }, values: {}, options: {} });
|
||||
const ids = [
|
||||
"Mail_FindMail", "Mail_FindMail_v1", "Mail_FindMail_v2",
|
||||
"Mail_ReplyMail", "Mail_ReplyMail_v1", "Mail_ReplyMail_v2",
|
||||
"Mail_DoGetMailMessage", "Mail_DoGetMailMessage_v1", "Mail_DoGetMailMessage_v2"
|
||||
];
|
||||
|
||||
afterEach(async () => {});
|
||||
|
||||
describe("mail receive and reply", () => {
|
||||
it("registers find, reply and Outlook message capabilities", () => {
|
||||
for (const id of ids) expect(hasCapability(id), id).toBe(true);
|
||||
});
|
||||
|
||||
it("replies through SMTP with the selected mail sender", async () => {
|
||||
let received = "";
|
||||
const server = net.createServer((socket) => {
|
||||
socket.setEncoding("utf8");
|
||||
socket.write("220 localhost ESMTP\r\n");
|
||||
let buffer = "";
|
||||
let dataMode = false;
|
||||
socket.on("data", (chunk) => {
|
||||
buffer += chunk;
|
||||
const lines = buffer.split("\r\n");
|
||||
buffer = lines.pop() || "";
|
||||
for (const line of lines) {
|
||||
if (dataMode) {
|
||||
if (line === ".") {
|
||||
dataMode = false;
|
||||
socket.write("250 queued\r\n");
|
||||
} else received += line + "\n";
|
||||
} else if (/^EHLO/i.test(line)) socket.write("250-localhost\r\n250 OK\r\n");
|
||||
else if (/^MAIL FROM/i.test(line)) socket.write("250 ok\r\n");
|
||||
else if (/^RCPT TO/i.test(line)) socket.write("250 ok\r\n");
|
||||
else if (/^DATA/i.test(line)) { dataMode = true; socket.write("354 end with dot\r\n"); }
|
||||
else if (/^QUIT/i.test(line)) { socket.write("221 bye\r\n"); socket.end(); }
|
||||
else socket.write("250 OK\r\n");
|
||||
}
|
||||
});
|
||||
});
|
||||
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
const port = (server.address() as net.AddressInfo).port;
|
||||
try {
|
||||
const handler = getHandler("Mail_ReplyMail_v2");
|
||||
expect(handler).toBeTruthy();
|
||||
const result = await handler!(context, node("Mail_ReplyMail_v2"), {
|
||||
account_obj: { host: "127.0.0.1", port, account: "robot@example.com", secure: "False" },
|
||||
mail_obj: { from: "owner@example.com", subject: "status", messageId: "<abc@example.com>" },
|
||||
content: "reply delivered"
|
||||
}, component);
|
||||
expect(result).toMatchObject({ accepted: ["owner@example.com"] });
|
||||
expect(received).toContain("reply delivered");
|
||||
expect(received).toContain("Re: status");
|
||||
} finally {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
}
|
||||
}, 30000);
|
||||
});
|
||||
@@ -0,0 +1,174 @@
|
||||
import nodemailer from "nodemailer";
|
||||
import { register } from "../engine/registry";
|
||||
import { RuntimeError } from "../engine/types";
|
||||
import { powershell } from "./desktop";
|
||||
|
||||
function text(args: Record<string, unknown>, keys: string[], fallback = ""): string {
|
||||
for (const key of keys) if (args[key] !== undefined && args[key] !== null && args[key] !== "") return String(args[key]);
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function list(value: string): string[] {
|
||||
return value.split(/[;,,;\s]+/).map((item) => item.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
function accountObject(value: unknown): Record<string, unknown> {
|
||||
if (value && typeof value === "object") return value as Record<string, unknown>;
|
||||
if (typeof value === "string" && value.trim().startsWith("{")) {
|
||||
try { return JSON.parse(value); } catch { return {}; }
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
register(["Mail_Send", "Mail_Send_V1", "Mail_UnionSend", "Mail_UnionSend_v1"], async (_ctx, node, args) => {
|
||||
const configured = accountObject(args.account_obj);
|
||||
const account = text({ ...configured, ...args }, ["account", "from", "username", "user"]);
|
||||
const password = text({ ...configured, ...args }, ["password", "pass"]);
|
||||
const host = text({ ...configured, ...args }, ["host", "smtp", "smtpHost", "server"]);
|
||||
if (!host || !account) throw new Error(`邮件配置不完整(${node.componentId})`);
|
||||
const port = Number(text({ ...configured, ...args }, ["port", "smtpPort"], "25"));
|
||||
const secure = ["true", "1", "是", "True"].includes(text({ ...configured, ...args }, ["is_ssl", "secure", "ssl"], "False"));
|
||||
const transporter = nodemailer.createTransport({ host, port, secure, auth: password ? { user: account, pass: password } : undefined });
|
||||
try {
|
||||
const result = await transporter.sendMail({
|
||||
from: account,
|
||||
to: list(text(args, ["to", "receiver"])),
|
||||
cc: list(text(args, ["Cc", "cc"])),
|
||||
bcc: list(text(args, ["Bcc", "bcc"])),
|
||||
subject: text(args, ["subject"]),
|
||||
text: text(args, ["content", "body"]),
|
||||
attachments: text(args, ["attachment"]) ? [{ path: text(args, ["attachment"]) }] : undefined
|
||||
});
|
||||
return { messageId: result.messageId, accepted: result.accepted };
|
||||
} finally {
|
||||
transporter.close();
|
||||
}
|
||||
});
|
||||
|
||||
export interface NormalizedMail {
|
||||
uid: string; from: string; to: string[]; cc: string[]; subject: string;
|
||||
text: string; html: string; date: string; messageId: string; hasAttachment: boolean;
|
||||
attachmentNames: string[];
|
||||
}
|
||||
|
||||
export function normalizeAccount(args: Record<string, unknown>): Record<string, unknown> {
|
||||
return accountObject(args.account_obj ?? args.accountObj ?? args.account);
|
||||
}
|
||||
|
||||
function addressList(value: unknown): string[] {
|
||||
if (!value) return [];
|
||||
if (Array.isArray(value)) return value.map((item) => String(typeof item === "object" && item && "text" in (item as Record<string, unknown>) ? (item as { text: unknown }).text : item)).filter(Boolean);
|
||||
if (typeof value === "object") { const record = value as { text?: unknown; value?: unknown }; return addressList(record.text ?? record.value); }
|
||||
return String(value).split(/[;,,;\s]+/).filter(Boolean);
|
||||
}
|
||||
|
||||
export function normalizeMail(input: Record<string, unknown>, uid = ""): NormalizedMail {
|
||||
const attachments = Array.isArray(input.attachments) ? input.attachments as Array<Record<string, unknown>> : [];
|
||||
return {
|
||||
uid: String(input.uid ?? uid),
|
||||
from: String(input.from ?? input.sender ?? ""),
|
||||
to: addressList(input.to), cc: addressList(input.cc),
|
||||
subject: String(input.subject ?? ""),
|
||||
text: String(input.text ?? input.body ?? ""),
|
||||
html: String(input.html ?? ""),
|
||||
date: input.date instanceof Date ? input.date.toISOString() : String(input.date ?? input.receivedTime ?? ""),
|
||||
messageId: String(input.messageId ?? ""),
|
||||
hasAttachment: Boolean(input.hasAttachment ?? attachments.length),
|
||||
attachmentNames: attachments.map((item) => String(item.filename ?? item.name ?? "")).filter(Boolean)
|
||||
};
|
||||
}
|
||||
|
||||
function matches(mail: NormalizedMail, args: Record<string, unknown>): boolean {
|
||||
const contains = (needle: unknown, haystack: string): boolean => !needle || haystack.toLowerCase().includes(String(needle).toLowerCase());
|
||||
return contains(args.From_data, mail.from)
|
||||
&& contains(args.To_data, mail.to.join(" "))
|
||||
&& contains(args.Subject_data, mail.subject)
|
||||
&& contains(args.Body_data, mail.text + "\n" + mail.html)
|
||||
&& (args.attachment === undefined || String(args.attachment) === "2" || (String(args.attachment) === "1") === mail.hasAttachment);
|
||||
}
|
||||
|
||||
export async function findImapMail(args: Record<string, unknown>, node: { id: string; componentId: string }): Promise<NormalizedMail[]> {
|
||||
const configured = normalizeAccount(args);
|
||||
const merged = { ...args, ...configured };
|
||||
const account = text(merged, ["account", "username", "user", "from"]);
|
||||
const password = text(merged, ["password", "pass", "token"]);
|
||||
const host = text(merged, ["imap", "imapHost", "imap_host", "server", "host"]);
|
||||
if (!host || !account) throw new RuntimeError("validation_error", "IMAP 配置不完整:需要 host 和 account", { nodeId: node.id, componentId: node.componentId });
|
||||
const port = Number(text(merged, ["imapPort", "imap_port", "recvPort", "port"], "993"));
|
||||
const secure = ["true", "1", "是", "True"].includes(text(merged, ["imap_ssl", "imapSsl", "is_ssl", "secure"], "True"));
|
||||
const folder = text(args, ["folder_name", "email_folder", "folder"], "INBOX");
|
||||
const { ImapFlow } = await import("imapflow");
|
||||
const client = new ImapFlow({ host, port, secure, auth: { user: account, pass: password }, logger: false });
|
||||
const mails: NormalizedMail[] = [];
|
||||
try {
|
||||
await client.connect();
|
||||
const lock = await client.getMailboxLock(folder);
|
||||
try {
|
||||
const range = String(args.search_range ?? "ALL").toUpperCase();
|
||||
const criteria: Record<string, unknown> = range === "UNSEEN" ? { seen: false } : range === "SEEN" ? { seen: true } : range === "FLAGGED" ? { flagged: true } : range === "ANSWERED" ? { answered: true } : range === "DELETED" ? { deleted: true } : range === "DRAFT" ? { draft: true } : { all: true };
|
||||
if (args.From_data) criteria.from = String(args.From_data);
|
||||
if (args.To_data) criteria.to = String(args.To_data);
|
||||
if (args.Subject_data) criteria.subject = String(args.Subject_data);
|
||||
const uids = await client.search(criteria as never, { uid: true }) || [];
|
||||
const selected = Math.max(1, Number(args.email_num ?? 1) || 1);
|
||||
for (let index = uids.length - 1; index >= 0 && mails.length < Math.max(20, selected); index -= 1) {
|
||||
const download = await client.fetchOne(String(uids[index]), { uid: true, envelope: true, flags: true, bodyStructure: true, source: true }, { uid: true });
|
||||
if (!download || !download.source) continue;
|
||||
const { simpleParser } = await import("mailparser");
|
||||
const parsed = await simpleParser(download.source);
|
||||
const mail = normalizeMail(parsed as unknown as Record<string, unknown>, String(download.uid));
|
||||
if (matches(mail, args)) mails.push(mail);
|
||||
}
|
||||
} finally { lock.release(); }
|
||||
} finally { client.close(); }
|
||||
const selected = Math.max(1, Number(args.email_num ?? 1) || 1);
|
||||
return mails.slice(0, selected);
|
||||
}
|
||||
|
||||
register(["Mail_FindMail", "Mail_FindMail_v1", "Mail_FindMail_v2"], async (_ctx, node, args) => findImapMail(args, node));
|
||||
|
||||
register(["Mail_ReplyMail", "Mail_ReplyMail_v1", "Mail_ReplyMail_v2"], async (_ctx, node, args) => {
|
||||
const configured = accountObject(args.mail_obj ?? args.email_obj ?? args.account_obj);
|
||||
const mail = configured && Object.keys(configured).length ? configured as unknown as NormalizedMail : args.mail as unknown as NormalizedMail;
|
||||
const merged = { ...args, ...normalizeAccount(args) };
|
||||
const account = text(merged, ["account", "username", "user", "from"]);
|
||||
const password = text(merged, ["password", "pass"]);
|
||||
const host = text(merged, ["smtp", "smtpHost", "smtp_host", "server", "host"]);
|
||||
if (!host || !account) throw new RuntimeError("validation_error", "SMTP 配置不完整:需要 host 和 account", { nodeId: node.id, componentId: node.componentId });
|
||||
const port = Number(text(merged, ["smtpPort", "smtp_port", "sendPort", "port"], "465"));
|
||||
const secure = ["true", "1", "是", "True"].includes(text(merged, ["smtp_ssl", "smtpSsl", "is_ssl", "secure"], "True"));
|
||||
const transporter = nodemailer.createTransport({ host, port, secure, auth: password ? { user: account, pass: password } : undefined });
|
||||
try {
|
||||
const result = await transporter.sendMail({
|
||||
from: account,
|
||||
to: mail?.from ? [mail.from] : list(text(args, ["to", "receiver"])),
|
||||
subject: text(args, ["subject"], mail?.subject ? "Re: " + mail.subject : "Re:"),
|
||||
text: text(args, ["content", "body"]),
|
||||
inReplyTo: mail?.messageId, references: mail?.messageId,
|
||||
attachments: text(args, ["attachment"]) ? [{ path: text(args, ["attachment"]) }] : undefined
|
||||
});
|
||||
return { messageId: result.messageId, accepted: result.accepted };
|
||||
} finally { transporter.close(); }
|
||||
});
|
||||
|
||||
function outlookScript(args: Record<string, unknown>): string {
|
||||
const criteria = Buffer.from(JSON.stringify({ to: String(args.To_data ?? ""), from: String(args.From_data ?? ""), subject: String(args.Subject_data ?? ""), body: String(args.Body_data ?? "") }), "utf8").toString("base64");
|
||||
return [
|
||||
"$outlook=New-Object -ComObject Outlook.Application;$ns=$outlook.GetNamespace('MAPI')",
|
||||
`$folderPath=${JSON.stringify(String(args.folder_name || args.email_folder || "INBOX"))}`,
|
||||
"$folder=$ns.GetDefaultFolder(6);foreach($part in ($folderPath -split '/')){ if($part -and $part -ne 'INBOX'){ $folder=$folder.Folders.Item($part) } }",
|
||||
"$folder.Items.Sort('[ReceivedTime]',$true)|Out-Null;$rows=@();$count=[Math]::Min(500,$folder.Items.Count)",
|
||||
`$criteria=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${criteria}'))|ConvertFrom-Json`,
|
||||
"for($i=1;$i -le $count;$i++){ $item=$folder.Items.Item($i); $subject=[string]$item.Subject; $body=[string]$item.Body; $to=[string]$item.To; $from=[string]$item.SenderEmailAddress; if(($criteria.to -and $to -notlike ('*'+$criteria.to+'*')) -or ($criteria.from -and $from -notlike ('*'+$criteria.from+'*')) -or ($criteria.subject -and $subject -notlike ('*'+$criteria.subject+'*')) -or ($criteria.body -and $body -notlike ('*'+$criteria.body+'*'))){continue}; $bodyB64=[Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($body)); $rows += @{uid=[string]$item.EntryID;from=$from;to=$to;subject=$subject;text=$bodyB64;html='';date=$item.ReceivedTime.ToString('o');messageId=[string]$item.EntryID;hasAttachment=($item.Attachments.Count -gt 0);attachmentNames=@()}; if($rows.Count -ge 100){break} }",
|
||||
"$json=$rows|ConvertTo-Json -Compress -Depth 4;[Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($json))|Write-Output"
|
||||
].join(";");
|
||||
}
|
||||
|
||||
register(["Mail_DoGetMailMessage", "Mail_DoGetMailMessage_v1", "Mail_DoGetMailMessage_v2"], async (_ctx, node, args) => {
|
||||
const output = await powershell(outlookScript(args), 120000);
|
||||
if (!output.trim()) throw new RuntimeError("outlook_unavailable", "Outlook COM 未返回数据;请确认 Outlook 已安装并至少配置一个邮箱", { nodeId: node.id, componentId: node.componentId });
|
||||
const rows = JSON.parse(Buffer.from(output.trim(), "base64").toString("utf8"));
|
||||
const mails = (Array.isArray(rows) ? rows : [rows]).map((row: Record<string, unknown>) => normalizeMail({ ...row, text: row.text ? Buffer.from(String(row.text), "base64").toString("utf8") : "" }));
|
||||
const selected = Math.max(1, Number(args.email_num ?? 1) || 1);
|
||||
return mails.slice(0, selected);
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
import path from "node:path";
|
||||
import { promises as fs } from "node:fs";
|
||||
import { register } from "../engine/registry";
|
||||
import { RuntimeError } from "../engine/types";
|
||||
import { defaultEngine } from "../engine/engine";
|
||||
|
||||
interface OcrWord { text: string; x: number; y: number; width: number; height: number; confidence: number }
|
||||
interface RapidOcrLine { text: string; mean: number; box?: number[][] }
|
||||
|
||||
async function getOcr(): Promise<{ detect: (image: string) => Promise<{ texts: RapidOcrLine[] }> }> {
|
||||
if (defaultEngine.ocrInstance) return defaultEngine.ocrInstance as { detect: (image: string) => Promise<{ texts: RapidOcrLine[] }> };
|
||||
const mod = await import("@gutenye/ocr-node");
|
||||
const instance = await mod.default.create();
|
||||
defaultEngine.ocrInstance = instance;
|
||||
return instance;
|
||||
}
|
||||
|
||||
function requireImage(args: Record<string, unknown>, nodeId: string): string {
|
||||
// uiSelector 是 OcrOffline_GetOcrTextNew 目录里唯一的图片字段(P0-1 修复)
|
||||
for (const key of ["image_path", "imagePath", "file_path", "filePath", "path", "image", "pictureSource", "uiSelector"]) {
|
||||
const v = String(args[key] ?? "").trim();
|
||||
if (v) return v;
|
||||
}
|
||||
throw new RuntimeError("validation_error", "图片路径为空", { nodeId, componentId: nodeId, recommendation: "填写本地图片路径(png/jpg/bmp)" });
|
||||
}
|
||||
|
||||
function boxToRect(box: number[][] | undefined): { x: number; y: number; width: number; height: number } {
|
||||
const points = box ?? [];
|
||||
const xs = points.map(p => p[0]);
|
||||
const ys = points.map(p => p[1]);
|
||||
const minX = Math.min(...(xs.length ? xs : [0]));
|
||||
const minY = Math.min(...(ys.length ? ys : [0]));
|
||||
const maxX = Math.max(...(xs.length ? xs : [0]));
|
||||
const maxY = Math.max(...(ys.length ? ys : [0]));
|
||||
return { x: minX, y: minY, width: maxX - minX, height: maxY - minY };
|
||||
}
|
||||
|
||||
register("OcrOffline_GetOcrTextNew", async (ctx, node, args) => {
|
||||
const imagePath = requireImage(args, node.id);
|
||||
const ocr = await getOcr();
|
||||
const result = await ocr.detect(imagePath);
|
||||
const text = result.texts.map(t => t.text).join("\n");
|
||||
ctx.log("OCR 识别完成:" + text.length + " 字符 / " + result.texts.length + " 行", "info", node.id);
|
||||
return text;
|
||||
});
|
||||
|
||||
register("OcrOffline_GetOcrTextPosition", async (ctx, node, args) => {
|
||||
const imagePath = requireImage(args, node.id);
|
||||
const ocr = await getOcr();
|
||||
const result = await ocr.detect(imagePath);
|
||||
const words = result.texts.map(t => {
|
||||
const rect = boxToRect(t.box);
|
||||
return { text: t.text, x: rect.x, y: rect.y, width: rect.width, height: rect.height, confidence: t.mean };
|
||||
});
|
||||
ctx.log("OCR 定位完成:" + words.length + " 行", "info", node.id);
|
||||
return words;
|
||||
});
|
||||
|
||||
/** 按垂直中心聚类成行(行高 60% 为阈值),行内按 x 排序,返回二维文本表格 */
|
||||
function groupOcrRows(words: RapidOcrLine[]): string[][] {
|
||||
const prepared = words
|
||||
.filter((word) => String(word.text ?? "").trim())
|
||||
.map((word) => {
|
||||
const box = word.box ?? [];
|
||||
const xs = box.map((point) => point[0]);
|
||||
const ys = box.map((point) => point[1]);
|
||||
const x = xs.length ? Math.min(...xs) : 0;
|
||||
const y = ys.length ? Math.min(...ys) : 0;
|
||||
const height = ys.length ? Math.max(...ys) - y : 0;
|
||||
return { text: word.text, x, cy: y + height / 2, h: height };
|
||||
})
|
||||
.sort((a, b) => a.cy - b.cy || a.x - b.x);
|
||||
const rows: Array<{ cy: number; h: number; cells: typeof prepared }> = [];
|
||||
let current: (typeof rows)[number] | null = null;
|
||||
for (const item of prepared) {
|
||||
if (!current || Math.abs(item.cy - current.cy) > Math.max(current.h, item.h, 8) * 0.6) {
|
||||
current = { cy: item.cy, h: item.h, cells: [] };
|
||||
rows.push(current);
|
||||
}
|
||||
current.cells.push(item);
|
||||
}
|
||||
return rows.map((row) => row.cells.sort((a, b) => a.x - b.x).map((cell) => cell.text.trim()));
|
||||
}
|
||||
|
||||
register("OcrOffline_GetOcrTable", async (ctx, node, args) => {
|
||||
const imagePath = requireImage(args, node.id);
|
||||
const ocr = await getOcr();
|
||||
const result = await ocr.detect(imagePath);
|
||||
const rows = groupOcrRows(result.texts);
|
||||
ctx.log("OCR 表格识别完成:" + rows.length + " 行", "info", node.id);
|
||||
|
||||
// showTable=1:写入流程数据表(与 Bivartable 系列组件共享同一份数据簿)
|
||||
if (String(args.showTable ?? "1") === "1") {
|
||||
ctx.vars["__rpaDatabook"] = rows;
|
||||
ctx.log("已写入流程数据表", "info", node.id);
|
||||
}
|
||||
|
||||
// isExcel=1:另存为 Excel 文件
|
||||
let excelFile = "";
|
||||
if (String(args.isExcel ?? "0") === "1") {
|
||||
const ExcelJS = (await import("exceljs")).default;
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
const sheet = workbook.addWorksheet("Sheet1");
|
||||
sheet.addRows(rows);
|
||||
const rawFolder = String(args.excelPath ?? "").trim();
|
||||
// ${res} 流程资源目录占位与空值统一落到运行证据目录
|
||||
const folder = rawFolder && rawFolder !== "${res}" ? rawFolder : ctx.evidenceDir;
|
||||
const fileName = String(args.excelName ?? "表格数据.xlsx").replace(/\$\{randomString\}/g, Math.random().toString(36).slice(2, 8));
|
||||
excelFile = path.resolve(path.isAbsolute(fileName) ? fileName : path.join(folder, fileName));
|
||||
await fs.mkdir(path.dirname(excelFile), { recursive: true });
|
||||
await workbook.xlsx.writeFile(excelFile);
|
||||
ctx.log("已保存 Excel:" + excelFile, "info", node.id);
|
||||
}
|
||||
|
||||
return excelFile ? { rows, excelFile } : rows;
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getHandler } from "../engine/registry";
|
||||
import type { CatalogComponent, FlowNode } from "../types";
|
||||
import type { RunContext } from "../engine/types";
|
||||
import "./ocr-caps";
|
||||
import "./remaining-core";
|
||||
|
||||
const ctx = {
|
||||
signal: new AbortController().signal,
|
||||
log: () => {},
|
||||
vars: {},
|
||||
evidenceDir: "D:/office-test2/ocr-test"
|
||||
} as unknown as RunContext;
|
||||
|
||||
const node: FlowNode = {
|
||||
id: "smoke",
|
||||
componentId: "smoke",
|
||||
position: { x: 0, y: 0 },
|
||||
values: {},
|
||||
options: {},
|
||||
};
|
||||
const ocrFixtures = [
|
||||
"D:/office-test2/ocr-test/general.png",
|
||||
"D:/office-test2/ocr-test/code.png",
|
||||
"D:/office-test2/ocr-test/table.png",
|
||||
];
|
||||
const ocrFixturesReady = ocrFixtures.every((file) => existsSync(file));
|
||||
|
||||
async function runOcr(id: string, args: Record<string, unknown>) {
|
||||
const handler = getHandler(id);
|
||||
expect(handler, `${id} 应已注册`).toBeTruthy();
|
||||
return handler!(ctx, node, args, {} as CatalogComponent);
|
||||
}
|
||||
|
||||
describe.skipIf(!ocrFixturesReady)("OCR 组件冒烟(本地离线识别,真实模型推理)", () => {
|
||||
it("OCR_GeneralTextV2 通用文本", async () => {
|
||||
const out = await runOcr("OCR_GeneralTextV2", { path: "D:/office-test2/ocr-test/general.png" });
|
||||
console.log("[GeneralTextV2]", JSON.stringify(out));
|
||||
expect(String(out)).toContain("张三");
|
||||
}, 300000);
|
||||
|
||||
it("OCR_KeyValueIdentification 键值对", async () => {
|
||||
const out = await runOcr("OCR_KeyValueIdentification", { image_path: "D:/office-test2/ocr-test/general.png" });
|
||||
console.log("[KeyValue]", JSON.stringify(out));
|
||||
expect(String(out).length).toBeGreaterThan(0);
|
||||
}, 300000);
|
||||
|
||||
it("OCR_AmountIdentification 金额", async () => {
|
||||
const out = await runOcr("OCR_AmountIdentification", { path: "D:/office-test2/ocr-test/general.png" });
|
||||
console.log("[Amount]", JSON.stringify(out));
|
||||
expect(String(out).length).toBeGreaterThan(0);
|
||||
}, 300000);
|
||||
|
||||
it("OCR_CalculationCode 算术验证码", async () => {
|
||||
const out = await runOcr("OCR_CalculationCode", { path: "D:/office-test2/ocr-test/code.png" });
|
||||
console.log("[CalcCode]", JSON.stringify(out));
|
||||
expect(String(out).length).toBeGreaterThan(0);
|
||||
}, 300000);
|
||||
|
||||
it("OCR_CommonEnglishNumbersCode 普通验证码", async () => {
|
||||
const out = await runOcr("OCR_CommonEnglishNumbersCode", { path: "D:/office-test2/ocr-test/code.png" });
|
||||
console.log("[CommonCode]", JSON.stringify(out));
|
||||
expect(String(out).length).toBeGreaterThan(0);
|
||||
}, 300000);
|
||||
|
||||
it("OCR_IdCardV2 卡证族代表", async () => {
|
||||
const out = await runOcr("OCR_IdCardV2", { path: "D:/office-test2/ocr-test/general.png" });
|
||||
console.log("[IdCardV2]", JSON.stringify(out));
|
||||
expect(String(out).length).toBeGreaterThan(0);
|
||||
}, 300000);
|
||||
|
||||
it("OCR_VatInvoiceV2 票据族代表", async () => {
|
||||
const out = await runOcr("OCR_VatInvoiceV2", { path: "D:/office-test2/ocr-test/table.png" });
|
||||
console.log("[VatInvoiceV2]", JSON.stringify(out));
|
||||
expect(String(out).length).toBeGreaterThan(0);
|
||||
}, 300000);
|
||||
|
||||
it("OcrOffline_GetOcrTextNew 离线文本", async () => {
|
||||
const out = await runOcr("OcrOffline_GetOcrTextNew", { uiSelector: "D:/office-test2/ocr-test/general.png" });
|
||||
console.log("[OcrTextNew]", JSON.stringify(out));
|
||||
expect(String(out)).toContain("订单号");
|
||||
}, 300000);
|
||||
|
||||
it("OcrOffline_GetOcrTextPosition 离线定位", async () => {
|
||||
const out = await runOcr("OcrOffline_GetOcrTextPosition", { image_path: "D:/office-test2/ocr-test/general.png" });
|
||||
const words = out as Array<{ text: string; x: number; y: number }>;
|
||||
console.log("[OcrPosition]", JSON.stringify(words?.slice(0, 3)));
|
||||
expect(Array.isArray(words)).toBe(true);
|
||||
expect(words.length).toBeGreaterThan(0);
|
||||
expect(words[0]).toHaveProperty("x");
|
||||
}, 300000);
|
||||
|
||||
it("OcrOffline_GetOcrTable 离线表格", async () => {
|
||||
const out = await runOcr("OcrOffline_GetOcrTable", { image_path: "D:/office-test2/ocr-test/table.png", showTable: "1", isExcel: "0" });
|
||||
const rows = out as string[][];
|
||||
console.log("[OcrTable]", JSON.stringify(rows));
|
||||
expect(Array.isArray(rows)).toBe(true);
|
||||
expect(rows.length).toBeGreaterThan(0);
|
||||
}, 300000);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import { readFile, mkdtemp, 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 "../types";
|
||||
import type { RunContext } from "../engine/types";
|
||||
import { getHandler } from "../engine/registry";
|
||||
import "./office";
|
||||
import "./extended";
|
||||
import "./office-file-extended";
|
||||
import { EXCEL_COMMON_ALIASES } from "./office-aliases";
|
||||
|
||||
const dirs: string[] = [];
|
||||
const context = {} as RunContext;
|
||||
const component = {} as CatalogComponent;
|
||||
const node = (id: string): FlowNode => ({ id, componentId: id, position: { x: 0, y: 0 }, values: {}, options: {} });
|
||||
async function run(id: string, args: Record<string, unknown>) { const handler = getHandler(id); expect(handler, id).toBeTruthy(); return handler!(context, node(id), args, component); }
|
||||
afterEach(async () => Promise.all(dirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))));
|
||||
|
||||
describe("common Excel aliases", () => {
|
||||
it("registers only real catalog aliases", async () => {
|
||||
// vitest 工作目录为仓库根,包内测试统一以 cwd 定位仓库资源
|
||||
const catalog = JSON.parse(await readFile(path.resolve(process.cwd(), "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");
|
||||
});
|
||||
|
||||
it("accepts the Excel_NewExcel catalog keys (filePath / isMakedirs)", async () => {
|
||||
const directory = await mkdtemp(path.join(os.tmpdir(), "rpa-excel-new-")); dirs.push(directory);
|
||||
const handle = await run("Excel_NewExcel", { filePath: directory, name: "catalog-keys.xlsx", isMakedirs: "True" }) as { path: string };
|
||||
expect(handle.path).toBe(path.resolve(directory, "catalog-keys.xlsx"));
|
||||
});
|
||||
|
||||
it("rejects a missing directory when isMakedirs is False", async () => {
|
||||
const directory = path.join(await mkdtemp(path.join(os.tmpdir(), "rpa-excel-new-")), "missing");
|
||||
dirs.push(directory);
|
||||
await expect(run("Excel_NewExcel", { filePath: directory, name: "none.xlsx", isMakedirs: "False" })).rejects.toThrow("目录不存在");
|
||||
});
|
||||
|
||||
it("opens a workbook through the Excel_OpenExcel filePath key", async () => {
|
||||
const directory = await mkdtemp(path.join(os.tmpdir(), "rpa-excel-open-")); dirs.push(directory);
|
||||
const created = await run("Excel_NewExcel", { filePath: directory, name: "open-me.xlsx" }) as { path: string };
|
||||
await expect(run("Excel_OpenExcel", { filePath: created.path })).resolves.toMatchObject({ path: created.path });
|
||||
});
|
||||
|
||||
it("renames a sheet through the Excel_RenameSheet newSheet key", async () => {
|
||||
const directory = await mkdtemp(path.join(os.tmpdir(), "rpa-excel-rename-")); dirs.push(directory);
|
||||
const created = await run("Excel_NewExcel", { filePath: directory, name: "rename-me.xlsx" }) as { path: string };
|
||||
await expect(run("Excel_RenameSheet", { objExcel: created, sheet: "Sheet1", newSheet: "库存表", isSave: "True" })).resolves.toBeTruthy();
|
||||
const reopened = await run("Excel_OpenExcel", { filePath: created.path }) as { id: string };
|
||||
await expect(run("Excel_GetSheetsName", { objExcel: reopened })).resolves.toEqual(["库存表"]);
|
||||
});
|
||||
|
||||
it("writes a range through the Excel_WriteRange catalog keys (objExcel/cellPos/data/isSave)", async () => {
|
||||
const directory = await mkdtemp(path.join(os.tmpdir(), "rpa-excel-range-")); dirs.push(directory);
|
||||
const created = await run("Excel_NewExcel", { filePath: directory, name: "range.xlsx" }) as { path: string };
|
||||
await run("Excel_WriteRange", { objExcel: created, sheet: "Sheet1", cellPos: "B2", data: [[1, "壹"], [2, "贰"]], isSave: "True" });
|
||||
const reopened = await run("Excel_OpenExcel", { filePath: created.path }) as { id: string };
|
||||
await expect(run("Excel_Cs_ReadContentV2", { workbook: reopened, sheet: "Sheet1", excelRng: "B2:C3" })).resolves.toEqual([[1, "壹"], [2, "贰"]]);
|
||||
});
|
||||
|
||||
it("saves as through the Excel_SaveAs catalog keys (filename/isAbsolutePath)", async () => {
|
||||
const directory = await mkdtemp(path.join(os.tmpdir(), "rpa-excel-saveas-")); dirs.push(directory);
|
||||
const created = await run("Excel_NewExcel", { filePath: directory, name: "origin.xlsx" }) as { path: string };
|
||||
await run("Excel_WriteCell", { workbook: created, sheet: "Sheet1", row: 1, column: "A", content: "saveas" });
|
||||
const savedAs = await run("Excel_SaveAs", { objExcel: created, filename: path.join(directory, "legacy-saveas.xlsx"), isAbsolutePath: "True" }) as { path: string };
|
||||
expect(savedAs.path).toBe(path.resolve(directory, "legacy-saveas.xlsx"));
|
||||
const reopened = await run("Excel_OpenExcel", { filePath: savedAs.path }) as { id: string };
|
||||
await expect(run("Excel_Cs_ReadContentV2", { workbook: reopened, sheet: "Sheet1", excelRng: "A1" })).resolves.toEqual([["saveas"]]);
|
||||
});
|
||||
|
||||
it("resolves a relative Excel_SaveAs filename against the original workbook directory", async () => {
|
||||
const directory = await mkdtemp(path.join(os.tmpdir(), "rpa-excel-saveas-rel-")); dirs.push(directory);
|
||||
const created = await run("Excel_NewExcel", { filePath: directory, name: "rel-origin.xlsx" }) as { path: string };
|
||||
const savedAs = await run("Excel_SaveAs", { objExcel: created, filename: "rel-target.xlsx", isAbsolutePath: "False" }) as { path: string };
|
||||
expect(savedAs.path).toBe(path.resolve(directory, "rel-target.xlsx"));
|
||||
});
|
||||
|
||||
it("combines the Excel_Cs_SaveAs directory path with the name field", async () => {
|
||||
const directory = await mkdtemp(path.join(os.tmpdir(), "rpa-excel-saveas-cs-")); dirs.push(directory);
|
||||
const created = await run("Excel_NewExcel", { filePath: directory, name: "cs-origin.xlsx" }) as { path: string };
|
||||
const targetDir = path.join(directory, "nested");
|
||||
const savedAs = await run("Excel_Cs_SaveAs", { workbook: created, path: targetDir, name: "cs-target.xlsx" }) as { path: string };
|
||||
expect(savedAs.path).toBe(path.resolve(targetDir, "cs-target.xlsx"));
|
||||
const reopened = new ExcelJS.Workbook();
|
||||
await reopened.xlsx.readFile(savedAs.path);
|
||||
expect(reopened.worksheets.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("clears a range through the Excel_ClearRange cellPos key", async () => {
|
||||
const directory = await mkdtemp(path.join(os.tmpdir(), "rpa-excel-clear-")); dirs.push(directory);
|
||||
const created = await run("Excel_NewExcel", { filePath: directory, name: "clear.xlsx" }) as { path: string };
|
||||
await run("Excel_WriteRange", { objExcel: created, sheet: "Sheet1", cellPos: "A1", data: [["甲", "乙"], ["丙", "丁"]] });
|
||||
await run("Excel_ClearRange", { objExcel: created, sheet: "Sheet1", cellPos: "A1:B2", isFormat: "False", isSave: "True" });
|
||||
const reopened = await run("Excel_OpenExcel", { filePath: created.path }) as { id: string };
|
||||
await expect(run("Excel_Cs_ReadContentV2", { workbook: reopened, sheet: "Sheet1", excelRng: "A1:B2" })).resolves.toEqual([["", ""], ["", ""]]);
|
||||
});
|
||||
|
||||
it("clears through the Excel_Cs_ClearRange startRow/startColumn keys", async () => {
|
||||
const directory = await mkdtemp(path.join(os.tmpdir(), "rpa-excel-clear-cs-")); dirs.push(directory);
|
||||
const created = await run("Excel_NewExcel", { filePath: directory, name: "clear-cs.xlsx" }) as { path: string };
|
||||
await run("Excel_WriteRange", { objExcel: created, sheet: "Sheet1", cellPos: "A1", data: [["1", "2"], ["3", "4"]] });
|
||||
await run("Excel_Cs_ClearRange", { objExcel: created, sheet: "Sheet1", startRow: "2", startColumn: "A", lastRow: "2", lastColumn: "B", mode: "内容", save: "True" });
|
||||
const reopened = await run("Excel_OpenExcel", { filePath: created.path }) as { id: string };
|
||||
await expect(run("Excel_Cs_ReadContentV2", { workbook: reopened, sheet: "Sheet1", excelRng: "A2:B2" })).resolves.toEqual([["", ""]]);
|
||||
await expect(run("Excel_Cs_ReadContentV2", { workbook: reopened, sheet: "Sheet1", excelRng: "A1:B1" })).resolves.toEqual([["1", "2"]]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import { getHandler, register } from "../engine/registry";
|
||||
import { RuntimeError, type CapabilityHandler } from "../engine/types";
|
||||
|
||||
const mappings: Record<string, string[]> = {
|
||||
Excel_Cs_NewV5: ["Excel_NewExcel", "Excel_Cs_NewV2", "Excel_Cs_NewV3", "Excel_Cs_NewV4"],
|
||||
Excel_Cs_OpenV4: ["Excel_Cs_OpenV2", "Excel_Cs_OpenV3", "Excel_OpenExcel", "Excel_OpenExcelNew"],
|
||||
Excel_Cs_ReadContentV2: ["Excel_Cs_ReadContent", "Excel_Cs_ReadContentV3", "Excel_Cs_ReadContentV4", "Excel_ReadRangeNew"],
|
||||
Excel_Cs_WriteCell: ["Excel_Cs_WriteCellV2", "Excel_WriteCellNew"],
|
||||
Excel_Cs_WriteRange: ["Excel_WriteRangeNew", "Excel_Cs_WriteContent"],
|
||||
Excel_Cs_ReadCell: ["Excel_ReadCell", "Excel_ReadCellNew"],
|
||||
Excel_Cs_ClearRangeV4: ["Excel_ClearRange", "Excel_Cs_ClearRange", "Excel_Cs_ClearRangeV2", "Excel_Cs_ClearRangeV3"],
|
||||
Excel_Cs_MergeCells: ["Excel_Cs_MergeCellsV2"],
|
||||
Excel_Cs_SetWidth: ["Excel_Cs_SetWidthV1", "Excel_SetColumnWidth"],
|
||||
Excel_Cs_SetHeight: ["Excel_Cs_SetHeightV1", "Excel_SetRowHeight"],
|
||||
Excel_Cs_DeleteRow: ["Excel_DeleteRow"],
|
||||
Excel_Cs_DeleteColumn: ["Excel_DeleteColumn"],
|
||||
Excel_Cs_InsertRow: ["Excel_InsertRow", "Excel_Cs_InsertRowV2", "Excel_Cs_InsertRowV3"],
|
||||
Excel_Cs_InsertColumn: ["Excel_InsertColumn", "Excel_Cs_InsertColumnV2", "Excel_Cs_InsertColumnV3"],
|
||||
Excel_Cs_CopyV2: ["Excel_Cs_Copy", "Excel_CopySheet"]
|
||||
};
|
||||
|
||||
function delegate(baseId: string): CapabilityHandler {
|
||||
return (ctx, node, args, component) => {
|
||||
const base = getHandler(baseId);
|
||||
if (!base) throw new RuntimeError("capability_not_implemented", `基础 Excel 能力不可用:${baseId}`, { nodeId: node.id, componentId: node.componentId });
|
||||
return base(ctx, node, args, component);
|
||||
};
|
||||
}
|
||||
|
||||
for (const [baseId, aliases] of Object.entries(mappings)) register(aliases, delegate(baseId));
|
||||
|
||||
export const EXCEL_COMMON_ALIASES = Object.values(mappings).flat();
|
||||
@@ -0,0 +1,126 @@
|
||||
import ExcelJS from "exceljs";
|
||||
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 "../types";
|
||||
import { getHandler } from "../engine/registry";
|
||||
import type { RunContext } from "../engine/types";
|
||||
import "./office-file-extended";
|
||||
|
||||
const directories: string[] = [];
|
||||
const context = {} as RunContext;
|
||||
const component = {} as CatalogComponent;
|
||||
const node = (componentId: string): FlowNode => ({
|
||||
id: `office_file_extended_${componentId}`,
|
||||
componentId,
|
||||
position: { x: 0, y: 0 },
|
||||
values: {},
|
||||
options: {}
|
||||
});
|
||||
|
||||
async function temporaryDirectory(): Promise<string> {
|
||||
const directory = await mkdtemp(path.join(os.tmpdir(), "rpa-office-file-extended-"));
|
||||
directories.push(directory);
|
||||
return directory;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(directories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
describe("extended Office and file capabilities", () => {
|
||||
it("registers audited catalog component ids", async () => {
|
||||
const catalog = JSON.parse(await readFile(path.resolve("resources/catalog.json"), "utf8")) as { components: Array<{ id: string }> };
|
||||
const catalogIds = new Set(catalog.components.map((item) => item.id));
|
||||
for (const id of [
|
||||
"Excel_Cs_ReadRowCount", "Excel_Cs_ReadCellFormula", "Excel_Cs_GetPath", "Excel_GetPath",
|
||||
"Excel_Cs_ConvertColumn", "Excel_Cs_GetEmptyCell", "Excel_Cs_ReadColumn", "Excel_GetColDataNew",
|
||||
"Excel_Cs_DeleteRow", "Excel_Cs_DeleteColumn", "Excel_Cs_ClearRangeV4", "Excel_Cs_MergeCells",
|
||||
"Excel_Cs_SetHeight", "Excel_Cs_SetWidth", "Excel_Cs_CopyV2", "PDF_CreateNewPdf", "PDF_GetPdfInformation"
|
||||
]) {
|
||||
expect(catalogIds.has(id), `${id} should exist in catalog.json`).toBe(true);
|
||||
expect(getHandler(id), `${id} should be registered`).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
it("reads formulas, rows, columns, paths, and empty cells from a real workbook", async () => {
|
||||
const directory = await temporaryDirectory();
|
||||
const filePath = path.join(directory, "read.xlsx");
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
const sheet = workbook.addWorksheet("Data");
|
||||
sheet.getCell("A1").value = "name";
|
||||
sheet.getCell("A2").value = "alpha";
|
||||
sheet.getCell("A3").value = "beta";
|
||||
sheet.getCell("B1").value = 2;
|
||||
sheet.getCell("B2").value = 3;
|
||||
sheet.getCell("B3").value = { formula: "SUM(B1:B2)", result: 5 };
|
||||
await workbook.xlsx.writeFile(filePath);
|
||||
|
||||
await expect(run("Excel_Cs_ReadRowCount", { workbook: filePath, sheet: "Data", range: "all" })).resolves.toBe(3);
|
||||
await expect(run("Excel_Cs_ReadCellFormula", { workbook: filePath, sheet: "Data", column: "B", row: 3 })).resolves.toBe("SUM(B1:B2)");
|
||||
await expect(run("Excel_Cs_GetEmptyCell", { workbook: filePath, sheet: "Data", column: "A", row: 1 })).resolves.toBe("A4");
|
||||
await expect(run("Excel_Cs_ReadColumn", { workbook: filePath, sheet: "Data", columnName: "A" })).resolves.toEqual(["name", "alpha", "beta"]);
|
||||
await expect(run("Excel_GetColDataNew", { objExcel: filePath, sheet: "Data", column: "B" })).resolves.toEqual([2, 3, 5]);
|
||||
await expect(run("Excel_Cs_GetPath", { workbook: { path: filePath } })).resolves.toBe(filePath);
|
||||
await expect(run("Excel_GetPath", { objExcel: filePath })).resolves.toBe(filePath);
|
||||
await expect(run("Excel_Cs_ConvertColumn", { convertInput: 28, convertType: "0" })).resolves.toBe("AB");
|
||||
await expect(run("Excel_Cs_ConvertColumn", { convertInput: "AB", convertType: "1" })).resolves.toBe(28);
|
||||
});
|
||||
|
||||
it("mutates rows, columns, ranges, dimensions, merged cells, and sheets on disk", async () => {
|
||||
const directory = await temporaryDirectory();
|
||||
const filePath = path.join(directory, "mutate.xlsx");
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
const sheet = workbook.addWorksheet("Data");
|
||||
sheet.addRows([["a", "b", "c"], [1, 2, 3], [4, 5, 6]]);
|
||||
await workbook.xlsx.writeFile(filePath);
|
||||
|
||||
await run("Excel_Cs_SetHeight", { workbook: filePath, sheet: "Data", rows: "1-2", height: 25, save: "1" });
|
||||
await run("Excel_Cs_SetWidth", { workbook: filePath, sheet: "Data", columns: "A,B", width: 18, save: "1" });
|
||||
await run("Excel_Cs_ClearRangeV4", { workbook: filePath, sheet: "Data", excelRng: "B2:B2", save: "1" });
|
||||
await run("Excel_Cs_MergeCells", { workbook: filePath, sheet: "Data", range: "A4:B4", merge: "0", save: "1" });
|
||||
await run("Excel_Cs_CopyV2", { workbook: filePath, sheet: "Data", newSheet: "Copy", save: "1" });
|
||||
await run("Excel_Cs_DeleteRow", { workbook: filePath, sheet: "Copy", row: 2, mode: "single", save: "1" });
|
||||
await run("Excel_Cs_DeleteColumn", { workbook: filePath, sheet: "Copy", column: "C", mode: "single", save: "1" });
|
||||
|
||||
const saved = new ExcelJS.Workbook();
|
||||
await saved.xlsx.readFile(filePath);
|
||||
const data = saved.getWorksheet("Data")!;
|
||||
const copy = saved.getWorksheet("Copy")!;
|
||||
expect(data.getRow(1).height).toBe(25);
|
||||
expect(data.getColumn("A").width).toBe(18);
|
||||
expect(data.getCell("B2").value).toBeNull();
|
||||
expect(data.getCell("A4").isMerged).toBe(true);
|
||||
expect(copy.getCell("A2").value).toBe(4);
|
||||
expect(copy.getCell("C1").value).toBeNull();
|
||||
});
|
||||
|
||||
it("extracts selected pages and returns metadata from real PDF files", async () => {
|
||||
const directory = await temporaryDirectory();
|
||||
const sourcePath = path.join(directory, "source.pdf");
|
||||
const document = await PDFDocument.create();
|
||||
document.setTitle("RPA Test");
|
||||
document.setAuthor("Codex");
|
||||
document.addPage([100, 100]);
|
||||
document.addPage([200, 200]);
|
||||
document.addPage([300, 300]);
|
||||
await writeFile(sourcePath, await document.save());
|
||||
|
||||
const output = await run("PDF_CreateNewPdf", { fileInPath: sourcePath, pdfRange: "pages", pdfNum: "1,3", newPdfPath: directory }) as string;
|
||||
const extracted = await PDFDocument.load(await readFile(output));
|
||||
expect(extracted.getPageCount()).toBe(2);
|
||||
expect(extracted.getPage(0).getWidth()).toBe(100);
|
||||
expect(extracted.getPage(1).getWidth()).toBe(300);
|
||||
await expect(run("PDF_GetPdfInformation", { fileInPath: sourcePath })).resolves.toMatchObject({
|
||||
path: sourcePath, fileName: "source.pdf", pageCount: 3, title: "RPA Test", author: "Codex"
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,276 @@
|
||||
import ExcelJS from "exceljs";
|
||||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { PDFDocument } from "pdf-lib";
|
||||
import { register } from "../engine/registry";
|
||||
import { RuntimeError, type CapabilityHandler } from "../engine/types";
|
||||
import { acquireWorkbook } from "./excel-store";
|
||||
|
||||
type WorkbookSource = { workbook: ExcelJS.Workbook; filePath: string };
|
||||
|
||||
function value(args: Record<string, unknown>, keys: string[], fallback: unknown = ""): unknown {
|
||||
for (const key of keys) if (args[key] !== undefined && args[key] !== null && args[key] !== "") return args[key];
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function text(args: Record<string, unknown>, keys: string[], fallback = ""): string {
|
||||
return String(value(args, keys, fallback));
|
||||
}
|
||||
|
||||
function truthy(candidate: unknown): boolean {
|
||||
return [true, 1, "1", "true", "True", "yes", "on"].includes(candidate as never);
|
||||
}
|
||||
|
||||
function requiredPath(candidate: unknown, label: string, 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", `${label} is empty`, { nodeId, componentId, recommendation: `Configure ${label}` });
|
||||
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"]), "Excel path", nodeId, componentId);
|
||||
const workbook = await acquireWorkbook(filePath);
|
||||
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>): Promise<void> {
|
||||
if (truthy(value(args, ["save", "isSave"]))) 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 {
|
||||
if (!Number.isInteger(index) || index < 1) throw new Error(`Invalid column number: ${index}`);
|
||||
let current = 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 parseList(candidate: unknown, converter: (item: string) => number): number[] {
|
||||
if (Array.isArray(candidate)) return candidate.flatMap((item) => parseList(item, converter));
|
||||
const raw = String(candidate ?? "").trim();
|
||||
if (!raw) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (Array.isArray(parsed)) return parsed.flatMap((item) => parseList(item, converter));
|
||||
} catch { /* parse as a delimited value */ }
|
||||
return raw.split(/[,;\s]+/).filter(Boolean).flatMap((part) => {
|
||||
const match = part.match(/^([A-Za-z]+|\d+)[-:]([A-Za-z]+|\d+)$/);
|
||||
if (!match) return [converter(part)];
|
||||
const start = converter(match[1]);
|
||||
const end = converter(match[2]);
|
||||
return Array.from({ length: Math.abs(end - start) + 1 }, (_, index) => Math.min(start, end) + index);
|
||||
});
|
||||
}
|
||||
|
||||
const readRowCount: CapabilityHandler = async (_ctx, node, args) => {
|
||||
const source = await loadWorkbook(args, node.id, node.componentId);
|
||||
const sheet = worksheetOf(source.workbook, args);
|
||||
const byColumn = text(args, ["range"]).includes("列");
|
||||
const column = columnNumber(value(args, ["column"], "A"));
|
||||
let count = 0;
|
||||
sheet.eachRow({ includeEmpty: false }, (row) => {
|
||||
if (truthy(args.ignoreHidden) && row.hidden) return;
|
||||
if (!byColumn || cellValue(row.getCell(column).value) !== "") count += 1;
|
||||
});
|
||||
return count;
|
||||
};
|
||||
register("Excel_Cs_ReadRowCount", readRowCount);
|
||||
|
||||
register("Excel_Cs_ReadCellFormula", async (_ctx, node, args) => {
|
||||
const source = await loadWorkbook(args, node.id, node.componentId);
|
||||
const sheet = worksheetOf(source.workbook, args);
|
||||
const address = `${columnLetter(columnNumber(value(args, ["column"], "A")))}${Math.max(1, Number(value(args, ["row"], 1)))}`;
|
||||
const candidate = sheet.getCell(address).value;
|
||||
return candidate && typeof candidate === "object" && "formula" in candidate ? candidate.formula : "";
|
||||
});
|
||||
|
||||
const getExcelPath: CapabilityHandler = (_ctx, node, args) =>
|
||||
requiredPath(value(args, ["workbook", "objExcel", "objExcelWorkBook", "file", "path"]), "Excel path", node.id, node.componentId);
|
||||
register(["Excel_Cs_GetPath", "Excel_GetPath"], getExcelPath);
|
||||
|
||||
register("Excel_Cs_ConvertColumn", (_ctx, _node, args) => {
|
||||
const input = value(args, ["convertInput", "column"], 1);
|
||||
return text(args, ["convertType"], "0") === "0" ? columnLetter(Number(input)) : columnNumber(input);
|
||||
});
|
||||
|
||||
register("Excel_Cs_GetEmptyCell", async (_ctx, node, args) => {
|
||||
const source = await loadWorkbook(args, node.id, node.componentId);
|
||||
const sheet = worksheetOf(source.workbook, args);
|
||||
const column = columnNumber(value(args, ["column"], "A"));
|
||||
let row = Math.max(1, Number(value(args, ["row"], 1)));
|
||||
while (cellValue(sheet.getCell(row, column).value) !== "") row += 1;
|
||||
return `${columnLetter(column)}${row}`;
|
||||
});
|
||||
|
||||
const readColumn: CapabilityHandler = async (_ctx, node, args) => {
|
||||
const source = await loadWorkbook(args, node.id, node.componentId);
|
||||
const sheet = worksheetOf(source.workbook, args);
|
||||
const column = columnNumber(value(args, ["columnName", "column", "cellPos"], "A"));
|
||||
const startRow = Math.max(1, Number(value(args, ["row", "startRow"], 1)));
|
||||
const values = Array.from({ length: Math.max(0, sheet.rowCount - startRow + 1) }, (_, index) => cellValue(sheet.getCell(startRow + index, column).value));
|
||||
// 触碰过尾部空行(如定位空单元格)会撑大 rowCount,裁掉尾部空值避免误报
|
||||
while (values.length > 0 && (values[values.length - 1] === "" || values[values.length - 1] === null || values[values.length - 1] === undefined)) values.pop();
|
||||
return values;
|
||||
};
|
||||
register(["Excel_Cs_ReadColumn", "Excel_GetColDataNew"], readColumn);register("Excel_Cs_DeleteRow", async (_ctx, node, args) => {
|
||||
const source = await loadWorkbook(args, node.id, node.componentId);
|
||||
const sheet = worksheetOf(source.workbook, args);
|
||||
const mode = text(args, ["mode"]);
|
||||
const input = mode.includes("多") ? args.rows : mode.includes("范围") ? `${value(args, ["startRow"], 1)}-${value(args, ["lastRow"], 1)}` : value(args, ["row"], 1);
|
||||
const rows = parseList(input, Number);
|
||||
for (const row of [...new Set(rows)].sort((a, b) => b - a)) sheet.spliceRows(row, 1);
|
||||
await saveWhenRequested(source, args);
|
||||
return rows.length;
|
||||
});
|
||||
|
||||
register("Excel_Cs_DeleteColumn", async (_ctx, node, args) => {
|
||||
const source = await loadWorkbook(args, node.id, node.componentId);
|
||||
const sheet = worksheetOf(source.workbook, args);
|
||||
const mode = text(args, ["mode"]);
|
||||
const input = mode.includes("多") ? args.columns : mode.includes("范围") ? `${value(args, ["startColumn"], "A")}-${value(args, ["lastColumn"], "A")}` : value(args, ["column"], "A");
|
||||
const columns = parseList(input, columnNumber);
|
||||
for (const column of [...new Set(columns)].sort((a, b) => b - a)) sheet.spliceColumns(column, 1);
|
||||
await saveWhenRequested(source, args);
|
||||
return columns.length;
|
||||
});
|
||||
|
||||
register("Excel_Cs_ClearRangeV4", async (_ctx, node, args) => {
|
||||
const source = await loadWorkbook(args, node.id, node.componentId);
|
||||
const sheet = worksheetOf(source.workbook, args);
|
||||
// 兼容各别名版本的键:V4=excelRng、V2/V3=range、Legacy Excel_ClearRange=cellPos、V1=startRow/startColumn/lastRow/lastColumn
|
||||
let range = text(args, ["excelRng", "range", "cellPos"]);
|
||||
if (!range) {
|
||||
const startRow = text(args, ["startRow"]);
|
||||
const startColumn = text(args, ["startColumn"]);
|
||||
if (startRow && startColumn) {
|
||||
const lastRow = text(args, ["lastRow"], startRow);
|
||||
const lastColumn = text(args, ["lastColumn"], startColumn);
|
||||
range = `${startColumn}${startRow}:${lastColumn}${lastRow}`;
|
||||
}
|
||||
}
|
||||
if (!range) throw new RuntimeError("validation_error", "Range is empty", { nodeId: node.id, componentId: node.componentId });
|
||||
const [start, end = start] = range.split(":");
|
||||
const first = sheet.getCell(start);
|
||||
const last = sheet.getCell(end);
|
||||
// 清除模式:全部=内容+格式,格式=仅格式,其余(内容/默认)=仅内容;Legacy isFormat=True 同样清格式
|
||||
const mode = text(args, ["clearType", "mode"], "内容");
|
||||
const clearFormat = mode.includes("全部") || mode.includes("格式") || truthy(value(args, ["isFormat"]));
|
||||
const clearValue = !mode.includes("格式");
|
||||
for (let row = first.row; row <= last.row; row += 1) for (let column = first.col; column <= last.col; column += 1) {
|
||||
const cell = sheet.getCell(row, column);
|
||||
if (clearValue) cell.value = null;
|
||||
if (clearFormat) cell.style = {};
|
||||
}
|
||||
await saveWhenRequested(source, args);
|
||||
return range;
|
||||
});
|
||||
|
||||
register("Excel_Cs_MergeCells", async (_ctx, node, args) => {
|
||||
const source = await loadWorkbook(args, node.id, node.componentId);
|
||||
const sheet = worksheetOf(source.workbook, args);
|
||||
const range = text(args, ["range", "excelRng"]);
|
||||
if (!range) throw new RuntimeError("validation_error", "Range is empty", { nodeId: node.id, componentId: node.componentId });
|
||||
if (text(args, ["merge"], "0") === "0") sheet.mergeCells(range); else sheet.unMergeCells(range);
|
||||
await saveWhenRequested(source, args);
|
||||
return range;
|
||||
});
|
||||
|
||||
register("Excel_Cs_SetHeight", async (_ctx, node, args) => {
|
||||
const source = await loadWorkbook(args, node.id, node.componentId);
|
||||
const sheet = worksheetOf(source.workbook, args);
|
||||
const rows = parseList(value(args, ["rows", "row"], 1), Number);
|
||||
for (const row of rows) sheet.getRow(row).height = Number(value(args, ["height"], 14.5));
|
||||
await saveWhenRequested(source, args);
|
||||
return rows.length;
|
||||
});
|
||||
|
||||
register("Excel_Cs_SetWidth", async (_ctx, node, args) => {
|
||||
const source = await loadWorkbook(args, node.id, node.componentId);
|
||||
const sheet = worksheetOf(source.workbook, args);
|
||||
const columns = parseList(value(args, ["columns", "column"], "A"), columnNumber);
|
||||
for (const column of columns) sheet.getColumn(column).width = Number(value(args, ["width"], 8.38));
|
||||
await saveWhenRequested(source, args);
|
||||
return columns.length;
|
||||
});
|
||||
|
||||
register("Excel_Cs_CopyV2", async (_ctx, node, args) => {
|
||||
const source = await loadWorkbook(args, node.id, node.componentId);
|
||||
const sheet = worksheetOf(source.workbook, args);
|
||||
const name = text(args, ["newSheet", "name"], `${sheet.name}_copy`);
|
||||
const existing = source.workbook.getWorksheet(name);
|
||||
if (existing) {
|
||||
if (!truthy(args.override)) throw new RuntimeError("validation_error", `Sheet already exists: ${name}`, { nodeId: node.id, componentId: node.componentId });
|
||||
source.workbook.removeWorksheet(existing.id);
|
||||
}
|
||||
const copy = source.workbook.addWorksheet(name);
|
||||
sheet.eachRow({ includeEmpty: true }, (row, rowNumber) => {
|
||||
const target = copy.getRow(rowNumber);
|
||||
target.values = row.values;
|
||||
target.height = row.height;
|
||||
row.eachCell({ includeEmpty: true }, (cell, column) => { target.getCell(column).style = { ...cell.style }; });
|
||||
});
|
||||
sheet.columns.forEach((column, index) => { copy.getColumn(index + 1).width = column.width; });
|
||||
await saveWhenRequested(source, args);
|
||||
return name;
|
||||
});
|
||||
|
||||
function parsePdfPages(raw: string, pageCount: number): number[] {
|
||||
// 页码留空时返回空集:由调用方抛出「No valid PDF pages」,避免静默复制全本
|
||||
if (!raw.trim()) return [];
|
||||
const pages = new Set<number>();
|
||||
for (const value of parseList(raw, Number)) if (value >= 1 && value <= pageCount) pages.add(value - 1);
|
||||
return [...pages];
|
||||
}
|
||||
|
||||
register("PDF_CreateNewPdf", async (_ctx, node, args) => {
|
||||
const sourcePath = requiredPath(value(args, ["fileInPath", "file", "path"]), "PDF path", node.id, node.componentId);
|
||||
const destination = requiredPath(value(args, ["newPdfPath", "output", "outPath"]), "output path", node.id, node.componentId);
|
||||
const outputPath = path.extname(destination).toLowerCase() === ".pdf" ? destination : path.join(destination, `${path.basename(sourcePath, path.extname(sourcePath))}-new.pdf`);
|
||||
const source = await PDFDocument.load(await fs.readFile(sourcePath), { ignoreEncryption: true });
|
||||
const indices = text(args, ["pdfRange"], "all") === "all" ? source.getPageIndices() : parsePdfPages(text(args, ["pdfNum"]), source.getPageCount());
|
||||
if (!indices.length) throw new RuntimeError("validation_error", "No valid PDF pages", { nodeId: node.id, componentId: node.componentId });
|
||||
const target = await PDFDocument.create();
|
||||
for (const page of await target.copyPages(source, indices)) target.addPage(page);
|
||||
await fs.mkdir(path.dirname(outputPath), { recursive: true });
|
||||
await fs.writeFile(outputPath, await target.save());
|
||||
return outputPath;
|
||||
});
|
||||
|
||||
register("PDF_GetPdfInformation", async (_ctx, node, args) => {
|
||||
const filePath = requiredPath(value(args, ["fileInPath", "file", "path"]), "PDF path", node.id, node.componentId);
|
||||
const document = await PDFDocument.load(await fs.readFile(filePath), { ignoreEncryption: true, updateMetadata: false });
|
||||
const stat = await fs.stat(filePath);
|
||||
return {
|
||||
path: filePath, fileName: path.basename(filePath), pageCount: document.getPageCount(), size: stat.size,
|
||||
title: document.getTitle() ?? "", author: document.getAuthor() ?? "", subject: document.getSubject() ?? "",
|
||||
creator: document.getCreator() ?? "", producer: document.getProducer() ?? "",
|
||||
creationDate: document.getCreationDate()?.toISOString() ?? "", modificationDate: document.getModificationDate()?.toISOString() ?? ""
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,249 @@
|
||||
import ExcelJS from "exceljs";
|
||||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { register } from "../engine/registry";
|
||||
import { RuntimeError, type CapabilityHandler } from "../engine/types";
|
||||
import { defaultEngine } from "../engine/engine";
|
||||
import { acquireWorkbook, publishWorkbook, releaseWorkbook } from "./excel-store";
|
||||
|
||||
interface ExcelHandle {
|
||||
id: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
// 句柄 id → 工作簿;本体挂在引擎实例上(任务 1.3),此处委托 defaultEngine。
|
||||
const workbooks = defaultEngine.excelHandles;
|
||||
|
||||
function value(args: Record<string, unknown>, keys: string[], fallback = ""): unknown {
|
||||
for (const key of keys) {
|
||||
if (args[key] !== undefined && args[key] !== null && args[key] !== "") return args[key];
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function text(args: Record<string, unknown>, keys: string[], fallback = ""): string {
|
||||
return String(value(args, keys, fallback));
|
||||
}
|
||||
|
||||
function bool(valueToRead: unknown, fallback = false): boolean {
|
||||
if (valueToRead === undefined || valueToRead === null || valueToRead === "") return fallback;
|
||||
if (typeof valueToRead === "string") return ["true", "1", "yes", "是", "开启"].includes(valueToRead.trim().toLowerCase());
|
||||
return [true, "true", "1", 1, "是", "开启"].includes(valueToRead as never);
|
||||
}
|
||||
|
||||
function required(valueToRead: unknown, label: string, nodeId: string, componentId: string): string {
|
||||
const result = String(valueToRead || "").trim();
|
||||
if (!result) throw new RuntimeError("validation_error", `${label}为空`, { nodeId, componentId, recommendation: `请配置${label}` });
|
||||
return result;
|
||||
}
|
||||
|
||||
function newHandle(filePath: string, workbook: ExcelJS.Workbook): ExcelHandle {
|
||||
const handle = { id: `excel_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 7)}`, path: filePath };
|
||||
workbooks.set(handle.id, workbook);
|
||||
publishWorkbook(filePath, workbook);
|
||||
return handle;
|
||||
}
|
||||
|
||||
async function resolveWorkbook(args: Record<string, unknown>, nodeId: string, componentId: string): Promise<{ handle: ExcelHandle; workbook: ExcelJS.Workbook }> {
|
||||
const raw = value(args, ["workbook", "objExcel", "objExcelWorkBook", "file", "path"]);
|
||||
const id = typeof raw === "object" && raw !== null ? String((raw as Partial<ExcelHandle>).id || "") : String(raw || "");
|
||||
const existing = id ? workbooks.get(id) : undefined;
|
||||
// 直接返回流程变量持有的原始句柄对象:saveAs 等处理器对其 path 的改写需要
|
||||
// 同步反映到后续节点引用的同一个对象上
|
||||
if (existing) return { handle: raw as ExcelHandle, workbook: existing };
|
||||
const filePath = required(typeof raw === "object" ? (raw as Partial<ExcelHandle>).path : raw, "Excel文件路径", nodeId, componentId);
|
||||
const workbook = await acquireWorkbook(filePath);
|
||||
return { handle: { id, path: filePath }, workbook };
|
||||
}
|
||||
|
||||
function sheetOf(workbook: ExcelJS.Workbook, name: string): ExcelJS.Worksheet {
|
||||
const sheet = name ? workbook.getWorksheet(name) : workbook.worksheets[0];
|
||||
if (!sheet) throw new Error(`Sheet 不存在:${name || "第一个 Sheet"}`);
|
||||
return sheet;
|
||||
}
|
||||
|
||||
function parseRange(range: string, sheet: ExcelJS.Worksheet): { start: string; end?: string } {
|
||||
if (range.trim()) {
|
||||
const [start, end] = range.split(":").map((item) => item.trim());
|
||||
return { start, end };
|
||||
}
|
||||
const lastRow = Math.max(1, sheet.rowCount);
|
||||
const lastColumn = Math.max(1, sheet.columnCount);
|
||||
return { start: "A1", end: `${sheet.getColumn(lastColumn).letter}${lastRow}` };
|
||||
}
|
||||
|
||||
function cellValue(valueToRead: ExcelJS.CellValue): unknown {
|
||||
if (valueToRead && typeof valueToRead === "object" && "text" in valueToRead) return valueToRead.text;
|
||||
if (valueToRead && typeof valueToRead === "object" && "result" in valueToRead) return valueToRead.result;
|
||||
return valueToRead ?? "";
|
||||
}
|
||||
|
||||
function tableFromSheet(sheet: ExcelJS.Worksheet, range: string): unknown[][] {
|
||||
const target = parseRange(range, sheet);
|
||||
const startRow = Number(sheet.getCell(target.start).row);
|
||||
const endRow = target.end ? Number(sheet.getCell(target.end).row) : sheet.rowCount;
|
||||
const rows = sheet.getRows(startRow, Math.max(1, endRow - startRow + 1)) || [];
|
||||
const startColumn = Number(sheet.getCell(target.start).col);
|
||||
const endColumn = target.end ? Number(sheet.getCell(target.end).col) : sheet.columnCount;
|
||||
return rows.map((row) => {
|
||||
const values: unknown[] = [];
|
||||
for (let column = startColumn; column <= endColumn; column += 1) values.push(cellValue(row.getCell(column).value));
|
||||
return values;
|
||||
});
|
||||
}
|
||||
|
||||
function rowsFrom(valueToRead: unknown): unknown[][] {
|
||||
if (Array.isArray(valueToRead)) {
|
||||
return valueToRead.map((row) => Array.isArray(row) ? row : [row]);
|
||||
}
|
||||
if (typeof valueToRead === "string") {
|
||||
try {
|
||||
return rowsFrom(JSON.parse(valueToRead));
|
||||
} catch {
|
||||
return [[valueToRead]];
|
||||
}
|
||||
}
|
||||
return [[valueToRead]];
|
||||
}
|
||||
|
||||
const newExcel: CapabilityHandler = async (_ctx, node, args) => {
|
||||
const folder = required(value(args, ["filePath", "path", "folder"]), "文件路径", node.id, node.componentId);
|
||||
const name = text(args, ["name"], "新建工作簿.xlsx");
|
||||
const filePath = path.resolve(folder, path.extname(name) ? name : `${name}.xlsx`);
|
||||
const makedirs = bool(value(args, ["isMakedirs"]), true);
|
||||
if (!makedirs) {
|
||||
try {
|
||||
await fs.access(path.dirname(filePath));
|
||||
} catch {
|
||||
throw new RuntimeError("validation_error", `目录不存在:${path.dirname(filePath)}`, { nodeId: node.id, componentId: node.componentId, recommendation: "将「是否创建该路径」设为 True,或先创建目录" });
|
||||
}
|
||||
}
|
||||
if (!bool(value(args, ["override"]), true)) {
|
||||
try {
|
||||
await fs.access(filePath);
|
||||
throw new RuntimeError("validation_error", `文件已存在:${filePath}`, { nodeId: node.id, componentId: node.componentId, recommendation: "开启覆盖同名文件,或修改文件名称" });
|
||||
} catch (error) {
|
||||
if (error instanceof RuntimeError) throw error;
|
||||
}
|
||||
}
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
workbook.addWorksheet("Sheet1");
|
||||
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
||||
await workbook.xlsx.writeFile(filePath);
|
||||
return newHandle(filePath, workbook);
|
||||
};
|
||||
|
||||
const openExcel: CapabilityHandler = async (_ctx, node, args) => {
|
||||
const filePath = path.resolve(required(value(args, ["file", "filePath", "path"]), "Excel文件", node.id, node.componentId));
|
||||
const workbook = await acquireWorkbook(filePath);
|
||||
return newHandle(filePath, workbook);
|
||||
};
|
||||
|
||||
const readExcel: CapabilityHandler = async (_ctx, node, args) => {
|
||||
const { workbook } = await resolveWorkbook(args, node.id, node.componentId);
|
||||
const sheet = sheetOf(workbook, text(args, ["sheet", "sheetName"]));
|
||||
return tableFromSheet(sheet, text(args, ["range", "excelRng"]));
|
||||
};
|
||||
|
||||
const writeCell: CapabilityHandler = async (_ctx, node, args) => {
|
||||
const { handle, workbook } = await resolveWorkbook(args, node.id, node.componentId);
|
||||
const sheet = sheetOf(workbook, text(args, ["sheet", "sheetName"]));
|
||||
const address = `${text(args, ["column"], "A")}${text(args, ["row"], "1")}`;
|
||||
sheet.getCell(address).value = value(args, ["content", "value"]) as ExcelJS.CellValue;
|
||||
if (bool(value(args, ["save"]), true)) await workbook.xlsx.writeFile(handle.path);
|
||||
return handle;
|
||||
};
|
||||
|
||||
const writeRange: CapabilityHandler = async (_ctx, node, args) => {
|
||||
const { handle, workbook } = await resolveWorkbook(args, node.id, node.componentId);
|
||||
const sheet = sheetOf(workbook, text(args, ["sheet", "sheetName"]));
|
||||
const cellPos = text(args, ["cellPos"]);
|
||||
const cellMatch = cellPos.match(/^([A-Za-z]+)(\d+)$/);
|
||||
const column = cellMatch ? cellMatch[1] : text(args, ["column"], "A");
|
||||
const row = cellMatch ? cellMatch[2] : text(args, ["row"], "1");
|
||||
const start = `${column}${row}`;
|
||||
const startCell = sheet.getCell(start);
|
||||
const startRow = startCell.row;
|
||||
const startColumn = startCell.col;
|
||||
for (const [rowIndex, row] of rowsFrom(value(args, ["contents", "data", "table"])).entries()) {
|
||||
for (const [columnIndex, cell] of row.entries()) sheet.getCell(startRow + rowIndex, startColumn + columnIndex).value = cell as ExcelJS.CellValue;
|
||||
}
|
||||
if (bool(value(args, ["save", "isSave"]), true)) await workbook.xlsx.writeFile(handle.path);
|
||||
return handle;
|
||||
};
|
||||
|
||||
const saveExcel: CapabilityHandler = async (_ctx, node, args) => {
|
||||
const { handle, workbook } = await resolveWorkbook(args, node.id, node.componentId);
|
||||
await workbook.xlsx.writeFile(handle.path);
|
||||
return handle.path;
|
||||
};
|
||||
|
||||
const saveAsExcel: CapabilityHandler = async (_ctx, node, args) => {
|
||||
const { handle, workbook } = await resolveWorkbook(args, node.id, node.componentId);
|
||||
// CS 版(Excel_Cs_SaveAs)用 path=目录 + name=文件名;Legacy(Excel_SaveAs)用 filename=完整路径
|
||||
const rawName = text(args, ["name"]);
|
||||
const raw = rawName
|
||||
? path.resolve(text(args, ["path", "file", "savePath"], ""), rawName)
|
||||
: required(value(args, ["path", "file", "savePath", "filename", "saveFilePath"]), "另存为路径", node.id, node.componentId);
|
||||
// Legacy Excel_SaveAs 用 isAbsolutePath 标记 filename 是否为绝对路径,相对路径基于原文件目录
|
||||
const absolute = rawName ? true : bool(value(args, ["isAbsolutePath"]), true);
|
||||
const previousPath = handle.path;
|
||||
handle.path = absolute ? path.resolve(raw) : path.resolve(path.dirname(previousPath), raw);
|
||||
await fs.mkdir(path.dirname(handle.path), { recursive: true });
|
||||
await workbook.xlsx.writeFile(handle.path);
|
||||
// 另存后同一实例以新路径登记,旧路径的缓存不再代表最新内容,释放之
|
||||
publishWorkbook(handle.path, workbook);
|
||||
if (path.resolve(previousPath) !== handle.path) releaseWorkbook(previousPath);
|
||||
return handle;
|
||||
};
|
||||
|
||||
register(["Excel_Cs_NewV5", "Excel_Cs_New", "Excel_New"], newExcel);
|
||||
register(["Excel_Cs_OpenV4", "Excel_Cs_Open", "Excel_Open"], openExcel);
|
||||
register(["Excel_Cs_ReadContentV2", "Excel_Cs_ReadRangeV2", "Excel_ReadRange"], readExcel);
|
||||
register(["Excel_Cs_WriteCell", "Excel_WriteCell"], writeCell);
|
||||
register(["Excel_Cs_WriteRange", "Excel_WriteRange"], writeRange);
|
||||
register(["Excel_Save", "Excel_Cs_Save", "Excel_SaveV2"], saveExcel);
|
||||
register(["Excel_Cs_SaveAs", "Excel_SaveAs"], saveAsExcel);
|
||||
register(["Excel_GetSheetsName", "Excel_GetSheetNames"], async (_ctx, node, args) => {
|
||||
const { workbook } = await resolveWorkbook(args, node.id, node.componentId);
|
||||
return workbook.worksheets.map((sheet) => sheet.name);
|
||||
});
|
||||
register(["Excel_Cs_Rename", "Excel_RenameSheet"], async (_ctx, node, args) => {
|
||||
const { handle, workbook } = await resolveWorkbook(args, node.id, node.componentId);
|
||||
const oldName = required(value(args, ["sheet", "oldName"]), "原 Sheet 名称", node.id, node.componentId);
|
||||
const newName = required(value(args, ["newSheet", "newName", "name", "sheetName"]), "新 Sheet 名称", node.id, node.componentId);
|
||||
const sheet = sheetOf(workbook, oldName);
|
||||
sheet.name = newName;
|
||||
await workbook.xlsx.writeFile(handle.path);
|
||||
return handle;
|
||||
});
|
||||
register(["Excel_Cs_Insert", "Excel_InsertSheet"], async (_ctx, node, args) => {
|
||||
const { handle, workbook } = await resolveWorkbook(args, node.id, node.componentId);
|
||||
const name = required(value(args, ["name", "sheetName"]), "Sheet名称", node.id, node.componentId);
|
||||
if (workbook.getWorksheet(name)) throw new RuntimeError("validation_error", `Sheet 已存在:${name}`, { nodeId: node.id, componentId: node.componentId });
|
||||
workbook.addWorksheet(name);
|
||||
if (bool(value(args, ["save"]), false)) await workbook.xlsx.writeFile(handle.path);
|
||||
return handle;
|
||||
});
|
||||
register("Excel_Cs_InsertRow", async (_ctx, node, args) => {
|
||||
const { handle, workbook } = await resolveWorkbook(args, node.id, node.componentId);
|
||||
const sheet = sheetOf(workbook, text(args, ["sheet", "sheetName"]));
|
||||
const index = Math.max(1, Number(value(args, ["index"], "1")) || 1);
|
||||
const count = Math.max(1, Number(value(args, ["count"], "1")) || 1);
|
||||
const rows = rowsFrom(value(args, ["contents"]));
|
||||
const inserts = Array.from({ length: count }, (_, rowIndex) => rows[rowIndex] || []);
|
||||
sheet.spliceRows(index, 0, ...inserts);
|
||||
if (bool(value(args, ["save"]), true)) await workbook.xlsx.writeFile(handle.path);
|
||||
return handle;
|
||||
});
|
||||
register("Excel_Cs_InsertColumn", async (_ctx, node, args) => {
|
||||
const { handle, workbook } = await resolveWorkbook(args, node.id, node.componentId);
|
||||
const sheet = sheetOf(workbook, text(args, ["sheet", "sheetName"]));
|
||||
const index = Math.max(1, Number(value(args, ["index"], "1")) || 1);
|
||||
const count = Math.max(1, Number(value(args, ["count"], "1")) || 1);
|
||||
const values = rowsFrom(value(args, ["contents"])).flat();
|
||||
const inserts = Array.from({ length: count }, (_, columnIndex) => [values[columnIndex] ?? ""]);
|
||||
sheet.spliceColumns(index, 0, ...inserts);
|
||||
if (bool(value(args, ["save"]), true)) await workbook.xlsx.writeFile(handle.path);
|
||||
return handle;
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { PDFDocument, StandardFonts } from "pdf-lib";
|
||||
import { PNG } from "pngjs";
|
||||
import { getHandler } from "../engine/registry";
|
||||
import type { CatalogComponent, FlowNode } from "../types";
|
||||
import type { RunContext } from "../engine/types";
|
||||
import "./pdf";
|
||||
|
||||
const directories: string[] = [];
|
||||
const context = {} as RunContext;
|
||||
const component = {} as CatalogComponent;
|
||||
const node = (id: string): FlowNode => ({ id, componentId: id, position: { x: 0, y: 0 }, values: {}, options: {} });
|
||||
async function run(id: string, args: Record<string, unknown>) {
|
||||
const handler = getHandler(id);
|
||||
expect(handler, id).toBeTruthy();
|
||||
return handler!(context, node(id), args, component);
|
||||
}
|
||||
afterEach(async () => Promise.all(directories.splice(0).map((directory) => rm(directory, { recursive: true, force: true }))));
|
||||
|
||||
describe("PDF high-level capabilities", () => {
|
||||
it("creates a pdf fixture", async () => {
|
||||
const directory = await mkdtempSafe();
|
||||
const pdf = await PDFDocument.create();
|
||||
const page = pdf.addPage([220, 120]);
|
||||
const font = await pdf.embedFont(StandardFonts.Helvetica);
|
||||
const png = new PNG({ width: 8, height: 8 });
|
||||
png.data.fill(255); png.data[0] = 20; png.data[1] = 90; png.data[2] = 200; png.data[3] = 255;
|
||||
page.drawImage(await pdf.embedPng(PNG.sync.write(png)), { x: 140, y: 70, width: 40, height: 40 });
|
||||
for (const [value, x, y] of [["Name", 24, 90], ["Age", 120, 90], ["Alice", 24, 72], ["30", 120, 72]] as Array<[string, number, number]>) page.drawText(value, { x, y, size: 9, font });
|
||||
await writeFile(path.join(directory, "source.pdf"), await pdf.save());
|
||||
const filePath = path.join(directory, "source.pdf");
|
||||
expect((await readFile(path.join(directory, "source.pdf"))).byteLength).toBeGreaterThan(0);
|
||||
const images = await run("PDF_GetPdfImg", { fileInPath: filePath, pdfRange: "all", pdfNum: "1", imgSavePath: directory, imageType: "png" }) as string[];
|
||||
expect(images.length).toBeGreaterThan(0);
|
||||
expect((await readFile(images[0])).subarray(0, 4)).toEqual(Buffer.from([0x89, 0x50, 0x4e, 0x47]));
|
||||
|
||||
await expect(run("PDF_GetPdfTable", { fileInPath: filePath, pdfRange: "all", pdfNum: "1" })).resolves.toEqual([["Name", "Age"], ["Alice", "30"]]);
|
||||
|
||||
const encryptedPath = path.join(directory, "encrypted.pdf");
|
||||
await writeFile(encryptedPath, await readFile(filePath));
|
||||
await expect(run("PDF_PdfEncryption", { fileInPath: encryptedPath, passWord: "secret" })).resolves.toBe(encryptedPath);
|
||||
const pdfjs = await import("pdfjs-dist/legacy/build/pdf.mjs");
|
||||
const opened = await pdfjs.getDocument({ data: new Uint8Array(await readFile(encryptedPath)), password: "secret" }).promise;
|
||||
expect(opened.numPages).toBe(1);
|
||||
}, 60000);
|
||||
});
|
||||
async function mkdtempSafe(): Promise<string> {
|
||||
const directory = await (await import("node:fs/promises")).mkdtemp(path.join(os.tmpdir(), "rpa-pdf-high-"));
|
||||
directories.push(directory);
|
||||
return directory;
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
import { promises as fs } from "node:fs";
|
||||
import fsSync from "node:fs";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import { PDFDocument } from "pdf-lib";
|
||||
import path from "node:path";
|
||||
import { register } from "../engine/registry";
|
||||
import { RuntimeError, type CapabilityHandler } from "../engine/types";
|
||||
|
||||
function text(args: Record<string, unknown>, keys: string[], fallback = ""): string {
|
||||
for (const key of keys) if (args[key] !== undefined && args[key] !== null && args[key] !== "") return String(args[key]);
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function required(value: string, label: string, nodeId: string, componentId: string): string {
|
||||
if (!value.trim()) throw new RuntimeError("validation_error", `${label}为空`, { nodeId, componentId });
|
||||
return path.resolve(value);
|
||||
}
|
||||
|
||||
const pageCount: CapabilityHandler = async (_ctx, node, args) => {
|
||||
const filePath = required(text(args, ["fileInPath", "path", "file"]), "PDF文件路径", node.id, node.componentId);
|
||||
const document = await PDFDocument.load(await fs.readFile(filePath), { ignoreEncryption: true });
|
||||
return document.getPageCount();
|
||||
};
|
||||
|
||||
const concat: CapabilityHandler = async (_ctx, node, args) => {
|
||||
const folder = required(text(args, ["newPdfPath", "folder", "path"]), "输出目录", node.id, node.componentId);
|
||||
const name = text(args, ["newPdfName", "name"], "合并结果.pdf");
|
||||
const output = path.join(folder, path.extname(name) ? name : `${name}.pdf`);
|
||||
const rawList = text(args, ["pdfList", "files"]);
|
||||
let files: string[] = [];
|
||||
try {
|
||||
const parsed = JSON.parse(rawList);
|
||||
files = Array.isArray(parsed) ? parsed.map(String) : [String(parsed)];
|
||||
} catch {
|
||||
files = rawList.split(/[\r\n;,]+/).map((item) => item.trim()).filter(Boolean);
|
||||
}
|
||||
if (!files.length) throw new RuntimeError("validation_error", "待合并 PDF 列表为空", { nodeId: node.id, componentId: node.componentId });
|
||||
const target = await PDFDocument.create();
|
||||
for (const file of files) {
|
||||
const source = await PDFDocument.load(await fs.readFile(path.resolve(file)), { ignoreEncryption: true });
|
||||
const pages = await target.copyPages(source, source.getPageIndices());
|
||||
pages.forEach((page) => target.addPage(page));
|
||||
}
|
||||
await fs.mkdir(path.dirname(output), { recursive: true });
|
||||
await fs.writeFile(output, await target.save());
|
||||
return output;
|
||||
};
|
||||
|
||||
register(["PDF_GetPdfPageNum", "PDF_GetPageCount"], pageCount);
|
||||
register("PDF_ConcatPdf", concat);
|
||||
register("PDF_GetPdfText", async (_ctx, node, args) => {
|
||||
const filePath = required(text(args, ["fileInPath", "path", "file"]), "PDF文件路径", node.id, node.componentId);
|
||||
const pdfjs = await import("pdfjs-dist/legacy/build/pdf.mjs");
|
||||
const document = await pdfjs.getDocument({ data: new Uint8Array(await fs.readFile(filePath)) }).promise;
|
||||
const pages = text(args, ["pdfNum", "page"]);
|
||||
const indices = pages ? pages.split(/[ ,,;;]+/).map((item) => Number(item)).filter((item) => item > 0) : Array.from({ length: document.numPages }, (_, index) => index + 1);
|
||||
const chunks: string[] = [];
|
||||
for (const index of indices) {
|
||||
const page = await document.getPage(index);
|
||||
const content = await page.getTextContent();
|
||||
chunks.push(content.items.map((item) => ("str" in item ? item.str : "")).join(""));
|
||||
}
|
||||
return chunks.join("\n");
|
||||
});
|
||||
|
||||
type PdfJsDocument = Awaited<ReturnType<typeof import("pdfjs-dist/legacy/build/pdf.mjs")["getDocument"]>["promise"]>;
|
||||
|
||||
function standardFontUrl(): string | undefined {
|
||||
const candidates = [
|
||||
process.env.RPA_PDFJS_STANDARD_FONTS,
|
||||
path.join(process.cwd(), "node_modules", "pdfjs-dist", "standard_fonts"),
|
||||
path.join(process.execPath, "resources", "app.asar", "node_modules", "pdfjs-dist", "standard_fonts"),
|
||||
path.join(process.execPath, "..", "resources", "app.asar", "node_modules", "pdfjs-dist", "standard_fonts")
|
||||
].filter(Boolean) as string[];
|
||||
const found = candidates.find((candidate) => fsSync.existsSync(candidate));
|
||||
return found ? pathToFileURL(path.join(found, path.sep)).href : undefined;
|
||||
}
|
||||
|
||||
class LocalStandardFontFactory {
|
||||
baseUrl: string;
|
||||
constructor(options: { baseUrl: string }) { this.baseUrl = options.baseUrl; }
|
||||
async fetch({ filename }: { filename: string }): Promise<Uint8Array> {
|
||||
return new Uint8Array(await fs.readFile(path.join(fileURLToPath(this.baseUrl), filename)));
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPdf(filePath: string, password: string) {
|
||||
const canvasModule = await import("@napi-rs/canvas");
|
||||
const globals = globalThis as unknown as Record<string, unknown>;
|
||||
globals.Path2D = canvasModule.Path2D;
|
||||
globals.DOMMatrix = canvasModule.DOMMatrix;
|
||||
globals.ImageData = canvasModule.ImageData;
|
||||
const pdfjs = await import("pdfjs-dist/legacy/build/pdf.mjs");
|
||||
return { pdfjs, document: await pdfjs.getDocument({ data: new Uint8Array(await fs.readFile(filePath)), password: password || undefined, isEvalSupported: false, useSystemFonts: false, standardFontDataUrl: standardFontUrl(), StandardFontDataFactory: LocalStandardFontFactory }).promise };
|
||||
}
|
||||
|
||||
function pageIndices(document: PdfJsDocument, raw: unknown): number[] {
|
||||
const range = String(raw ?? "all").trim().toLowerCase();
|
||||
if (!range || range === "all") return Array.from({ length: document.numPages }, (_, index) => index + 1);
|
||||
const result = new Set<number>();
|
||||
for (const part of range.split(/[,,;;\s]+/).filter(Boolean)) {
|
||||
const match = part.match(/^(\d+)-(\d+)$/);
|
||||
if (match) {
|
||||
const start = Math.max(1, Number(match[1])); const end = Math.min(document.numPages, Number(match[2]));
|
||||
for (let index = start; index <= end; index += 1) result.add(index);
|
||||
} else {
|
||||
const index = Number(part);
|
||||
if (index >= 1 && index <= document.numPages) result.add(Math.floor(index));
|
||||
}
|
||||
}
|
||||
const values = [...result].sort((left, right) => left - right);
|
||||
if (!values.length) throw new Error(`PDF 页码范围无效:${range}`);
|
||||
return values;
|
||||
}
|
||||
|
||||
async function renderPage(page: Awaited<ReturnType<PdfJsDocument["getPage"]>>, scale = 2): Promise<Buffer> {
|
||||
const canvasModule = await import("@napi-rs/canvas");
|
||||
const viewport = page.getViewport({ scale });
|
||||
const canvas = canvasModule.createCanvas(Math.ceil(viewport.width), Math.ceil(viewport.height));
|
||||
const context = canvas.getContext("2d");
|
||||
context.fillStyle = "white"; context.fillRect(0, 0, canvas.width, canvas.height);
|
||||
await page.render({ canvasContext: context as unknown as CanvasRenderingContext2D, viewport }).promise;
|
||||
return canvas.toBuffer("image/png");
|
||||
}
|
||||
|
||||
function saveImage(folder: string, prefix: string, suffix: string, data: Buffer, type: string): string {
|
||||
const ext = /jpe?g/i.test(type) ? "jpg" : "png";
|
||||
const output = path.join(path.resolve(folder), `${prefix}_${suffix}_${Date.now()}.${ext}`);
|
||||
fsSync.mkdirSync(path.dirname(output), { recursive: true });
|
||||
fsSync.writeFileSync(output, data);
|
||||
return output;
|
||||
}
|
||||
|
||||
async function rawImageToBuffer(image: Record<string, unknown>, type: string): Promise<Buffer> {
|
||||
const width = Number(image.width || 0); const height = Number(image.height || 0);
|
||||
const data = image.data;
|
||||
if (!width || !height || !(data instanceof Uint8Array) || data.length === 0) throw new Error("PDF 图片数据无效");
|
||||
if (data[0] === 0xff && data[1] === 0xd8) return Buffer.from(data);
|
||||
const canvasModule = await import("@napi-rs/canvas");
|
||||
const canvas = canvasModule.createCanvas(width, height); const context = canvas.getContext("2d");
|
||||
const target = context.createImageData(width, height); const kind = Number(image.kind || 0);
|
||||
if (kind === 1) {
|
||||
const bytesPerRow = Math.ceil(width / 8);
|
||||
for (let y = 0; y < height; y += 1) for (let x = 0; x < width; x += 1) {
|
||||
const bit = (data[y * bytesPerRow + (x >> 3)] >> (7 - (x & 7))) & 1; const output = (y * width + x) * 4; const color = bit ? 255 : 0;
|
||||
target.data[output] = color; target.data[output + 1] = color; target.data[output + 2] = color; target.data[output + 3] = 255;
|
||||
}
|
||||
} else if (kind === 3) { target.data.set(data.subarray(0, Math.min(data.length, target.data.length))); }
|
||||
else {
|
||||
for (let source = 0, targetIndex = 0; source < data.length && targetIndex < target.data.length; source += 3, targetIndex += 4) {
|
||||
target.data[targetIndex] = data[source]; target.data[targetIndex + 1] = data[source + 1] ?? 0; target.data[targetIndex + 2] = data[source + 2] ?? 0; target.data[targetIndex + 3] = 255;
|
||||
}
|
||||
}
|
||||
context.putImageData(target, 0, 0);
|
||||
return /jpe?g/i.test(type) ? canvas.toBuffer("image/jpeg") : canvas.toBuffer("image/png");
|
||||
}
|
||||
|
||||
async function objectImage(page: Awaited<ReturnType<PdfJsDocument["getPage"]>>, objId: unknown): Promise<Record<string, unknown> | null> {
|
||||
if (typeof objId !== "string") return null;
|
||||
const objects = objId.startsWith("g_") ? page.commonObjs : page.objs;
|
||||
try { if (objects.has(objId)) return objects.get(objId) as Record<string, unknown>; } catch {}
|
||||
return await new Promise((resolve) => { try { objects.get(objId, (value: unknown) => resolve(value as Record<string, unknown>)); } catch { resolve(null); } });
|
||||
}
|
||||
|
||||
const encryption: CapabilityHandler = async (_ctx, node, args) => {
|
||||
const filePath = required(text(args, ["fileInPath", "path", "file"]), "PDF文件路径", node.id, node.componentId);
|
||||
const password = String(args.passWord ?? args.password ?? "");
|
||||
if (!password) throw new RuntimeError("validation_error", "PDF密码为空", { nodeId: node.id, componentId: node.componentId });
|
||||
const { PDFDocument } = await import("@cantoo/pdf-lib");
|
||||
const document = await PDFDocument.load(await fs.readFile(filePath), { ignoreEncryption: true });
|
||||
document.encrypt({ userPassword: password, ownerPassword: password, permissions: { printing: "highResolution", copying: true, modifying: false } });
|
||||
const temporary = `${filePath}.${Date.now()}.enc`;
|
||||
await fs.writeFile(temporary, await document.save());
|
||||
await fs.rename(temporary, filePath);
|
||||
return filePath;
|
||||
};
|
||||
|
||||
const extractImages: CapabilityHandler = async (_ctx, node, args) => {
|
||||
const filePath = required(text(args, ["fileInPath", "path", "file"]), "PDF文件路径", node.id, node.componentId);
|
||||
const folder = required(text(args, ["imgSavePath", "pdfSavePath", "folder", "path"]), "图片保存路径", node.id, node.componentId);
|
||||
const type = text(args, ["imageType", "type"], "png");
|
||||
const prefix = text(args, ["imageName", "name"], "pdf-image");
|
||||
const { pdfjs, document } = await loadPdf(filePath, String(args.pdfPassWord ?? args.password ?? ""));
|
||||
const outputs: string[] = [];
|
||||
for (const pageNumber of pageIndices(document, args.pdfNum)) {
|
||||
const page = await document.getPage(pageNumber);
|
||||
const opList = await page.getOperatorList();
|
||||
let sequence = 0;
|
||||
for (let index = 0; index < opList.fnArray.length; index += 1) {
|
||||
const op = opList.fnArray[index]; const commandArgs = opList.argsArray[index] as unknown[];
|
||||
let image: Record<string, unknown> | null = null;
|
||||
if (op === pdfjs.OPS.paintImageXObject || op === pdfjs.OPS.paintImageXObjectRepeat) image = await objectImage(page, commandArgs[0]);
|
||||
else if (op === pdfjs.OPS.paintInlineImageXObject) image = commandArgs[0] as Record<string, unknown>;
|
||||
else if (op === pdfjs.OPS.paintInlineImageXObjectGroup) image = commandArgs[0] as Record<string, unknown>;
|
||||
if (!image) continue;
|
||||
sequence += 1;
|
||||
outputs.push(saveImage(folder, `${prefix}-p${pageNumber}`, String(sequence), await rawImageToBuffer(image, type), type));
|
||||
}
|
||||
}
|
||||
return outputs;
|
||||
};
|
||||
|
||||
const pdfToImages: CapabilityHandler = async (_ctx, node, args) => {
|
||||
const filePath = required(text(args, ["fileInPath", "path", "file"]), "PDF文件路径", node.id, node.componentId);
|
||||
const folder = required(text(args, ["pdfSavePath", "imgSavePath", "folder", "path"]), "图片保存路径", node.id, node.componentId);
|
||||
const type = text(args, ["imageType", "type"], "png"); const prefix = text(args, ["imageName", "name"], "pdf-page");
|
||||
const { document } = await loadPdf(filePath, String(args.pdfPassWord ?? args.password ?? ""));
|
||||
const outputs: string[] = [];
|
||||
for (const pageNumber of pageIndices(document, args.pdfNum)) {
|
||||
const buffer = await renderPage(await document.getPage(pageNumber));
|
||||
outputs.push(saveImage(folder, prefix, String(pageNumber), buffer, type));
|
||||
}
|
||||
return outputs;
|
||||
};
|
||||
|
||||
interface TextToken { x: number; y: number; value: string; }
|
||||
|
||||
async function extractTableRows(page: Awaited<ReturnType<PdfJsDocument["getPage"]>>): Promise<string[][]> {
|
||||
const content = await page.getTextContent();
|
||||
const tokens = content.items.flatMap((item) => {
|
||||
if (!("str" in item) || !item.str.trim()) return [];
|
||||
const transform = (item as { transform?: number[] }).transform ?? [1,0,0,1,0,0];
|
||||
return [{ x: Number(transform[4] || 0), y: Number(transform[5] || 0), value: item.str.trim() }];
|
||||
});
|
||||
if (tokens.length < 2) return [];
|
||||
const lines = new Map<number, TextToken[]>();
|
||||
for (const token of [...tokens].sort((left, right) => right.y - left.y || left.x - right.x)) {
|
||||
const lineKey = [...lines.keys()].find((key) => Math.abs(key - token.y) <= 3.5) ?? token.y;
|
||||
if (!lines.has(lineKey)) lines.set(lineKey, []);
|
||||
lines.get(lineKey)!.push(token);
|
||||
}
|
||||
const lineList = [...lines.entries()].map(([y, values]) => ({ y, values: values.sort((left, right) => left.x - right.x) })).sort((left, right) => right.y - left.y);
|
||||
const counts = new Map<number, number>();
|
||||
for (const line of lineList) for (const token of line.values) {
|
||||
const key = Math.round(token.x / 5) * 5;
|
||||
counts.set(key, (counts.get(key) || 0) + 1);
|
||||
}
|
||||
const columns = [...counts.entries()].filter(([, count]) => count >= Math.min(2, lineList.length)).sort((left, right) => left[0] - right[0]).map(([key]) => key);
|
||||
if (columns.length < 2) return [];
|
||||
const rows: string[][] = [];
|
||||
for (const line of lineList) {
|
||||
const cells = columns.map(() => "");
|
||||
for (const token of line.values) {
|
||||
let nearest = 0;
|
||||
for (let index = 1; index < columns.length; index += 1) if (Math.abs(token.x - columns[index]) < Math.abs(token.x - columns[nearest])) nearest = index;
|
||||
cells[nearest] = cells[nearest] ? `${cells[nearest]} ${token.value}` : token.value;
|
||||
}
|
||||
if (cells.filter(Boolean).length >= 2) rows.push(cells);
|
||||
}
|
||||
return rows.length >= 2 ? rows : [];
|
||||
}
|
||||
|
||||
const readTables: CapabilityHandler = async (_ctx, node, args) => {
|
||||
const filePath = required(text(args, ["fileInPath", "path", "file"]), "PDF文件路径", node.id, node.componentId);
|
||||
const { document } = await loadPdf(filePath, String(args.pdfPassWord ?? args.password ?? ""));
|
||||
const rows: string[][] = [];
|
||||
for (const pageNumber of pageIndices(document, args.pdfNum)) rows.push(...await extractTableRows(await document.getPage(pageNumber)));
|
||||
return rows;
|
||||
};
|
||||
|
||||
register("PDF_PdfEncryption", encryption);
|
||||
register("PDF_GetPdfImg", extractImages);
|
||||
register("PDF_PdfToImg", pdfToImages);
|
||||
register("PDF_GetPdfTable", readTables);
|
||||
@@ -0,0 +1,59 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getHandler } from "../engine/registry";
|
||||
import type { CatalogComponent, FlowNode } from "../types";
|
||||
import type { RunContext } from "../engine/types";
|
||||
import "./vision";
|
||||
import "./remaining-core";
|
||||
|
||||
const ctx = {
|
||||
signal: new AbortController().signal,
|
||||
log: () => {},
|
||||
vars: {},
|
||||
evidenceDir: "D:/office-test2/ocr-test"
|
||||
} as unknown as RunContext;
|
||||
|
||||
const node = { id: "smoke-rp", componentId: "Image_RelativePosition", position: { x: 0, y: 0 }, values: {}, options: {} };
|
||||
const relativePositionFixtures = [
|
||||
"D:/office-test2/ocr-test/general.png",
|
||||
"D:/office-test2/ocr-test/order-template.png",
|
||||
];
|
||||
const relativePositionFixturesReady = relativePositionFixtures.every((file) => existsSync(file));
|
||||
|
||||
describe.skipIf(!relativePositionFixturesReady)("对比图像相对位置冒烟", () => {
|
||||
it("小图在大图中命中并返回坐标", async () => {
|
||||
const handler = getHandler("Image_RelativePosition");
|
||||
expect(handler).toBeTruthy();
|
||||
const out = await handler!(
|
||||
ctx,
|
||||
node,
|
||||
{
|
||||
source: "D:/office-test2/ocr-test/general.png",
|
||||
template: "D:/office-test2/ocr-test/order-template.png"
|
||||
},
|
||||
{} as CatalogComponent
|
||||
);
|
||||
console.log("[RelativePosition 正向]", JSON.stringify(out));
|
||||
const hit = out as { x: number; y: number; width: number; height: number; score: number };
|
||||
expect(hit.x).toBeGreaterThanOrEqual(0);
|
||||
expect(hit.y).toBeGreaterThan(0);
|
||||
expect(hit.score).toBeGreaterThan(0.9);
|
||||
}, 120000);
|
||||
|
||||
it("填反(大图填进查找目标)自动互换后仍命中", async () => {
|
||||
const logs: string[] = [];
|
||||
const ctxWithLog = { ...ctx, log: (m: string) => logs.push(m) } as unknown as RunContext;
|
||||
const handler = getHandler("Image_RelativePosition")!;
|
||||
const out = await handler!(
|
||||
ctxWithLog,
|
||||
node,
|
||||
{
|
||||
source: "D:/office-test2/ocr-test/order-template.png",
|
||||
template: "D:/office-test2/ocr-test/general.png"
|
||||
},
|
||||
{} as CatalogComponent
|
||||
);
|
||||
console.log("[RelativePosition 互换]", JSON.stringify(out), "logs:", logs.join(" | "));
|
||||
expect(out).toBeTruthy();
|
||||
}, 120000);
|
||||
});
|
||||
@@ -0,0 +1,272 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { getHandler, register } from "../engine/registry";
|
||||
import { runFlow } from "../engine/runtime";
|
||||
import { RuntimeError, type CapabilityHandler, type RunContext } from "../engine/types";
|
||||
import { defaultEngine } from "../engine/engine";
|
||||
import { getSubflowSource } from "../engine/host-bridge";
|
||||
import { powershell, psString } from "./desktop";
|
||||
import { findImage } from "./vision";
|
||||
|
||||
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 num(args: Record<string, unknown>, keys: string[], fallback = 0): number { const value = Number(text(args, keys, String(fallback))); return Number.isFinite(value) ? value : fallback; }
|
||||
function bool(value: unknown, fallback = false): boolean { if (value === undefined || value === null || value === "") return fallback; return [true, 1, "1", "true", "True", "是", "开启"].includes(value as never); }
|
||||
function list(value: unknown): string[] {
|
||||
if (Array.isArray(value)) return value.map(String);
|
||||
const raw = String(value ?? "").trim(); if (!raw) return [];
|
||||
try { const parsed = JSON.parse(raw); return Array.isArray(parsed) ? parsed.map(String) : [String(parsed)]; }
|
||||
catch { return raw.split(/[,,;;\n]+/).map((item) => item.trim()).filter(Boolean); }
|
||||
}
|
||||
function record(value: unknown): Record<string, unknown> { return value && typeof value === "object" ? value as Record<string, unknown> : (() => { try { return JSON.parse(String(value || "{}")); } catch { return {}; } })(); }
|
||||
function required(value: string, label: string, node: { id: string; componentId: string }): string {
|
||||
if (!value.trim()) throw new RuntimeError("validation_error", label + "为空", { nodeId: node.id, componentId: node.componentId });
|
||||
return value;
|
||||
}
|
||||
|
||||
// 连接 id → 数据库连接;本体挂在引擎实例上(任务 1.3)。
|
||||
const dbConnections = defaultEngine.dbConnections as Map<string, any>;
|
||||
function dbConfig(args: Record<string, unknown>): Record<string, unknown> {
|
||||
const inline = record(args.DatabaseConfig || args.config || args.connectionID);
|
||||
const merged = { ...inline, ...args };
|
||||
return { ...inline, type: text(merged, ["dtype", "type"], "mysql"), host: text(merged, ["host", "server", "server_address"], "127.0.0.1"), port: num(merged, ["port"], 0), user: text(merged, ["user", "username", "account"], ""), password: text(merged, ["password", "pass"], ""), database: text(merged, ["database", "db", "catalog"], "") };
|
||||
}
|
||||
async function databaseClient(config: Record<string, unknown>) {
|
||||
const type = String(config.type || "mysql").toLowerCase();
|
||||
if (type.includes("mysql")) { const mysql = await import("mysql2/promise"); return await mysql.createConnection(config as never); }
|
||||
if (type.includes("postgres") || type.includes("pgsql")) { const { Client } = await import("pg"); const client = new Client({ host: String(config.host), port: Number(config.port || 5432), user: String(config.user), password: String(config.password), database: String(config.database) }); await client.connect(); return client; }
|
||||
if (type.includes("sqlserver") || type.includes("mssql")) { const sql = await import("mssql"); return await sql.connect(config as never); }
|
||||
throw new RuntimeError("validation_error", "暂不支持的数据库类型:" + type);
|
||||
}
|
||||
async function rowsOf(client: any, sql: string, params: string[]) {
|
||||
const result = await client.query(sql, params);
|
||||
return result.rows || result.recordset || [];
|
||||
}
|
||||
async function runDb(args: Record<string, unknown>, sql: string) {
|
||||
const key = String(args.connectionID || "inline");
|
||||
let client = dbConnections.get(key);
|
||||
if (!client) { client = await databaseClient(dbConfig(args)); dbConnections.set(key, client); }
|
||||
return await rowsOf(client, sql, list(args.paras));
|
||||
}
|
||||
|
||||
register("Database_DBConnect", async (_ctx, _node, args) => {
|
||||
const id = "db_" + Date.now().toString(36); const client = await databaseClient(dbConfig(args)); dbConnections.set(id, client);
|
||||
return { connectionID: id };
|
||||
});
|
||||
register(["Database_SingleSQLQuery", "Database_AllSQLQuery", "Database_AllSQLQuery_V1"], async (_ctx, node, args) => runDb(args, required(text(args, ["sql"]), "SQL", node)));
|
||||
register(["Database_SQLExecute", "Database_BatchSQLExecute"], async (_ctx, node, args) => {
|
||||
const statements = list(args.sql).length ? list(args.sql) : [required(text(args, ["sql"]), "SQL", node)];
|
||||
for (const statement of statements) await runDb(args, statement); return { executed: statements.length };
|
||||
});
|
||||
register("Database_BatchInsertExecute", async (_ctx, node, args) => {
|
||||
const parsed = record(args.data); const rows = Array.isArray(parsed) ? parsed : Array.isArray(parsed.value) ? parsed.value : [];
|
||||
for (const row of rows) await runDb(args, required(text(args, ["sql"]), "SQL", node)); return { inserted: rows.length };
|
||||
});
|
||||
register("Database_CloseDBConnect", async (_ctx, _node, args) => {
|
||||
const key = String(args.connectionID || ""); const client = dbConnections.get(key);
|
||||
if (client) { if (client.close) await client.close(); if (client.end) await client.end(); }
|
||||
dbConnections.delete(key); return { closed: true };
|
||||
});
|
||||
async function callSubflow(ctx: RunContext, node: { id: string; componentId: string }, args: Record<string, unknown>) {
|
||||
// 子流程仓库属宿主职责:经 host-bridge 注入(阶段 2 正式化),包内不 import "../flows"
|
||||
const source = getSubflowSource();
|
||||
if (!source) throw new RuntimeError("validation_error", "子流程源未注入", { nodeId: node.id, componentId: node.componentId, recommendation: "宿主需在启动时调用 setSubflowSource()" });
|
||||
const wanted = text(args, ["module_name", "module", "flow"]);
|
||||
const rows = await source.listFlows(); const found = rows.find((row) => row.id === wanted || row.name === wanted);
|
||||
if (!found) throw new RuntimeError("validation_error", "子流程不存在:" + wanted, { nodeId: node.id, componentId: node.componentId });
|
||||
const { randomUUID } = await import("node:crypto");
|
||||
const child = await source.getFlow(found.id); const inputs = record(args.args || args.input);
|
||||
return await runFlow(child, { runId: randomUUID(), evidenceDir: ctx.evidenceDir, signal: ctx.signal, emit: ctx.emit, log: ctx.log, componentOf: ctx.componentOf, browser: ctx.browser, variables: { ...ctx.vars, ...inputs } });
|
||||
}
|
||||
register("child_modules_v1", async (ctx, node, args) => callSubflow(ctx, node, args));
|
||||
register("concurrent_child_process_v1", async (ctx, node, args) => {
|
||||
const tasks = Array.from({ length: Math.max(1, num(args, ["count"], 2)) }, () => callSubflow(ctx, node, args));
|
||||
return bool(args.is_async) ? { started: true } : await Promise.all(tasks);
|
||||
});
|
||||
register("Element_exception_monitor_v2", async (ctx, node, args) => {
|
||||
ctx.log("异常监控已启用:" + text(args, ["event_type", "monitor_type"], "element"), bool(args.is_exits) ? "info" : "warn", node.id);
|
||||
return { monitoring: true };
|
||||
});
|
||||
|
||||
register("FileTypeConversion_Office2pdf", async (_ctx, node, args) => {
|
||||
const input = path.resolve(text(args, ["fileInPath", "path"])); const folder = path.resolve(text(args, ["fileOutPath", "folder"], path.dirname(input)));
|
||||
fs.mkdirSync(folder, { recursive: true }); const output = path.join(folder, path.basename(input, path.extname(input)) + ".pdf");
|
||||
const ext = path.extname(input).toLowerCase();
|
||||
if ([".doc", ".docx", ".rtf"].includes(ext)) await powershell(`$w=New-Object -ComObject Word.Application;$w.Visible=$false;$d=$w.Documents.Open(${psString(input)});$d.SaveAs([ref]${psString(output)},[ref]17);$d.Close($false);$w.Quit()`);
|
||||
else if ([".xls", ".xlsx", ".csv"].includes(ext)) await powershell(`$e=New-Object -ComObject Excel.Application;$e.Visible=$false;$w=$e.Workbooks.Open(${psString(input)});$w.ExportAsFixedFormat(0,${psString(output)});$w.Close($false);$e.Quit()`);
|
||||
else if ([".ppt", ".pptx"].includes(ext)) await powershell(`$p=New-Object -ComObject PowerPoint.Application;$pre=$p.Presentations.Open(${psString(input)},$true,$false,$false);$pre.SaveAs(${psString(output)},32);$pre.Close()`);
|
||||
else throw new RuntimeError("validation_error", "不支持的 Office 文件:" + ext);
|
||||
return output;
|
||||
});
|
||||
register("FileTypeConversion_Pdf2image2", async (ctx, node, args) => {
|
||||
const pdf = path.resolve(text(args, ["pdfPath", "path"])); const folder = path.resolve(text(args, ["imageDir", "folder"], path.dirname(pdf)));
|
||||
const handler = getHandler("PDF_PdfToImg"); if (!handler) throw new RuntimeError("capability_not_implemented", "PDF 渲染能力不可用");
|
||||
return await handler(ctx, node, { ...args, fileInPath: pdf, pdfSavePath: folder, imageName: path.basename(pdf, path.extname(pdf)), imageType: "png" }, {} as never);
|
||||
});
|
||||
function sheetId(args: Record<string, unknown>): string {
|
||||
const url = text(args, ["url", "spreadsheetUrl"]); const match = url.match(/\/spreadsheets\/d\/([a-zA-Z0-9-_]+)/);
|
||||
if (!match) throw new RuntimeError("validation_error", "无效 Google Sheets URL:" + url); return match[1];
|
||||
}
|
||||
function sheetRange(args: Record<string, unknown>): string {
|
||||
const sheet = encodeURIComponent(text(args, ["sheetName"], "Sheet1"));
|
||||
const start = text(args, ["startPosition", "start"], "A1"); const end = text(args, ["endPosition", "end"], "Z1000");
|
||||
return `${sheet}!${start}:${end}`;
|
||||
}
|
||||
async function googleRequest(args: Record<string, unknown>, pathUrl: string, init: RequestInit = {}) {
|
||||
const id = sheetId(args); const token = text(args, ["accessToken", "access_token", "token"]);
|
||||
const key = text(args, ["apiKey", "api_key", "key"]); const headers: Record<string,string> = {};
|
||||
if (token) headers.Authorization = "Bearer " + token;
|
||||
const separator = pathUrl.includes("?") ? "&" : "?";
|
||||
const response = await fetch(`https://sheets.googleapis.com/v4/spreadsheets/${id}${pathUrl}${key ? separator + "key=" + key : ""}`, { ...init, headers: { ...headers, ...(init.headers || {}) } });
|
||||
if (!response.ok) throw new RuntimeError("external_service_failed", "Google Sheets API " + response.status + ": " + await response.text());
|
||||
return await response.json();
|
||||
}
|
||||
register("GoogleTable_GetAllData", async (_ctx, node, args) => { const out = await googleRequest(args, `/values/${sheetRange(args)}?majorDimension=ROWS`); return out.values || []; });
|
||||
register("GoogleTable_GetAllSheetsName", async (_ctx, _node, args) => { const out = await googleRequest(args, "?fields=sheets.properties.title"); return (out.sheets || []).map((sheet: any) => sheet.properties.title); });
|
||||
async function googleWrite(args: Record<string, unknown>) {
|
||||
if (!text(args, ["accessToken", "access_token", "token"])) throw new RuntimeError("validation_error", "Google Sheets 写入需要 accessToken");
|
||||
return await googleRequest(args, `/values/${sheetRange(args)}?valueInputOption=USER_ENTERED`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ values: (() => { const value = args.Values || args.values; if (Array.isArray(value)) return value.map((row) => Array.isArray(row) ? row : [row]); try { const parsed = JSON.parse(String(value || "[]")); return Array.isArray(parsed) ? parsed.map((row: any) => Array.isArray(row) ? row : [row]) : [[parsed]]; } catch { return [[value]]; } })() }) });
|
||||
}
|
||||
for (const id of ["GoogleTable_WriteToSheetRegionColumns", "GoogleTable_WriteToSheetRegionRows", "GoogleTable_WriteToSheetRegionregion"]) register(id, async (_ctx, _node, args) => googleWrite(args));
|
||||
function googleCell(args: Record<string, unknown>) { return `${encodeURIComponent(text(args, ["sheetName"], "Sheet1"))}!${text(args, ["cellName", "cell"], "A1")}`; }
|
||||
register("GoogleTable_WriteToSheetCell", async (_ctx, _node, args) => googleRequest(args, `/values/${googleCell(args)}?valueInputOption=RAW`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ values: [[args.value]] }) }));
|
||||
register("GoogleTable_WriteToSheetCellFormula", async (_ctx, _node, args) => {
|
||||
const value = String(args.value || ""); return googleRequest(args, `/values/${googleCell(args)}?valueInputOption=USER_ENTERED`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ values: [[value.startsWith("=") ? value : "=" + value]] }) });
|
||||
});
|
||||
register("GoogleTable_GetSheetRowCount", async (_ctx, _node, args) => { const out = await googleRequest(args, `/values/${encodeURIComponent(text(args, ["sheetName"], "Sheet1"))}`); return (out.values || []).length; });
|
||||
register("GoogleTable_DownloadSheets", async (_ctx, _node, args) => {
|
||||
const format = /xlsx/i.test(text(args, ["format"], "csv")) ? "xlsx" : "csv";
|
||||
const response = await fetch(`https://docs.google.com/spreadsheets/d/${sheetId(args)}/export?format=${format}`);
|
||||
if (!response.ok) throw new RuntimeError("external_service_failed", "Google 导出失败:" + response.status);
|
||||
const file = path.resolve(process.cwd(), `google-sheet-${Date.now()}.${format}`);
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true }); fs.writeFileSync(file, Buffer.from(await response.arrayBuffer())); return file;
|
||||
});
|
||||
register("GoogleTable_CopyPasteSheet", async (_ctx, _node, args) => {
|
||||
const source = `${encodeURIComponent(text(args, ["sourceSheetName"], "Sheet1"))}!${text(args, ["sourceStart"], "A1")}:${text(args, ["sourceEnd"], "Z1000")}`;
|
||||
const target = `${encodeURIComponent(text(args, ["destSheetName"], "Copy"))}!${text(args, ["destStart"], "A1")}:${text(args, ["destEnd"], "Z1000")}`;
|
||||
const values = await googleRequest(args, `/values/${source}`);
|
||||
return await googleRequest(args, `/values/${target}?valueInputOption=USER_ENTERED`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ values: values.values || [] }) });
|
||||
});
|
||||
register("GoogleTable_GetColumnsByTableTitle", async (_ctx, _node, args) => {
|
||||
const titles = list(args.tableTitles); const values = await googleRequest(args, `/values/${encodeURIComponent(text(args, ["sheetName"], "Sheet1"))}`);
|
||||
const row = (values.values || [])[num(args, ["rowNum"], 0)] || [];
|
||||
return titles.map((title) => ({ title, value: row[Number(title)] ?? "" }));
|
||||
});
|
||||
register("GoogleTable_ChangeSheet", async (_ctx, _node, args) => {
|
||||
const out = await googleRequest(args, "?fields=sheets.properties"); const wanted = text(args, ["sheetName"], "Sheet1");
|
||||
const sheet = (out.sheets || []).find((item: any) => item.properties.title === wanted); if (!sheet) throw new RuntimeError("not_found", "Google Sheet 不存在:" + wanted); return sheet.properties;
|
||||
});
|
||||
register("GoogleTable_RenameSheet", async (_ctx, _node, args) => {
|
||||
const out = await googleRequest(args, "?fields=sheets.properties.sheetId"); const oldName = text(args, ["originalSheetName"]);
|
||||
const sheet = (out.sheets || []).find((item: any) => item.properties.title === oldName); if (!sheet) throw new RuntimeError("not_found", "Google Sheet 不存在:" + oldName);
|
||||
return await googleRequest(args, ":batchUpdate", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ requests: [{ updateSheet: { properties: { sheetId: sheet.properties.sheetId, title: text(args, ["modifySheetName"]) }, fields: "title" } }] }) });
|
||||
});
|
||||
// Image_Exists / Image_Location / Image_Wait / Image_Move 统一由 vision.ts 注册到新查找管线
|
||||
// (虚拟桌面多屏拼接 + 相似度语义 + JPG 模板)。此处旧实现用 GDI CopyFromScreen 只截主屏
|
||||
// 且 accuracy 语义颠倒, 已整体迁出删除。
|
||||
async function captureScreen(file: string) {
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
await powershell(`Add-Type -AssemblyName System.Drawing;Add-Type -AssemblyName System.Windows.Forms;$bounds=[System.Windows.Forms.Screen]::PrimaryScreen.Bounds;$bitmap=New-Object System.Drawing.Bitmap $bounds.Width,$bounds.Height;$graphics=[System.Drawing.Graphics]::FromImage($bitmap);$graphics.CopyFromScreen($bounds.Location,[System.Drawing.Point]::Empty,$bounds.Size);$bitmap.Save(${psString(file)});$graphics.Dispose();$bitmap.Dispose()`);
|
||||
}
|
||||
// 图片Base64编解码: 统一组件按 mode 分流(encode=图片→Base64, decode=Base64→图片)。
|
||||
// Image_Base64Decode 已隐藏进该组件,但保留注册以防旧流程直接引用旧 id。
|
||||
register("Image_Base64Encode", async (_ctx, node, args) => {
|
||||
const mode = text(args, ["mode"], "encode");
|
||||
if (mode === "decode") {
|
||||
const value = text(args, ["val", "value"]).replace(/^data:[^;]+;base64,/, "");
|
||||
if (!value.trim()) throw new RuntimeError("validation_error", "Base64编码不能为空", { nodeId: node.id, componentId: node.componentId, recommendation: "填写 base64 字符串,或用 ${变量} 引用上游编码输出" });
|
||||
const custom = text(args, ["output"]).trim();
|
||||
const file = custom ? path.resolve(custom) : path.resolve(process.cwd(), `image-${Date.now()}.png`);
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
fs.writeFileSync(file, Buffer.from(value, "base64"));
|
||||
return file;
|
||||
}
|
||||
const imageArg = text(args, ["path", "image"]);
|
||||
if (!imageArg.trim()) throw new RuntimeError("validation_error", "图片对象不能为空", { nodeId: node.id, componentId: node.componentId, recommendation: "填写源图片路径,或用 ${变量} 引用上游截图输出" });
|
||||
const source = path.resolve(imageArg);
|
||||
if (!fs.existsSync(source)) throw new RuntimeError("validation_error", `找不到图片文件:${source}`, { nodeId: node.id, componentId: node.componentId, recommendation: "确认路径正确,或先用「屏幕截图」组件生成图片" });
|
||||
return fs.readFileSync(source).toString("base64");
|
||||
});
|
||||
register("Image_Base64Decode", async (_ctx, _node, args) => {
|
||||
const value = text(args, ["val", "value"]).replace(/^data:[^;]+;base64,/, ""); const file = path.resolve(process.cwd(), `image-${Date.now()}.png`);
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true }); fs.writeFileSync(file, Buffer.from(value, "base64")); return file;
|
||||
});
|
||||
register("Image_CropImg", async (_ctx, node, args) => {
|
||||
const rawInput = text(args, ["file_path", "imagePath", "path"]);
|
||||
if (!rawInput.trim()) throw new RuntimeError("validation_error", "图片地址不能为空", { nodeId: node.id, componentId: node.componentId, recommendation: "填写要裁剪的源图片完整路径(如 D:\\images\\shot.png),该组件是从已有图片文件里裁剪区域,不是截屏" });
|
||||
const input = path.resolve(rawInput);
|
||||
if (!fs.existsSync(input)) throw new RuntimeError("validation_error", `找不到图片文件:${input}`, { nodeId: node.id, componentId: node.componentId, recommendation: "确认路径正确;若要截取屏幕内容,请改用「截图图像 (Image_Screenshot)」组件" });
|
||||
const output = path.resolve(text(args, ["output"], input.replace(/(\.[^.]+)$/, "-crop$1")));
|
||||
const x = num(args, ["xstart"], 0), y = num(args, ["ystart"], 0), width = num(args, ["xend"], 100) - x, height = num(args, ["yend"], 100) - y;
|
||||
if (width <= 0 || height <= 0) throw new RuntimeError("validation_error", `裁剪区域无效(宽 ${width} × 高 ${height})`, { nodeId: node.id, componentId: node.componentId, recommendation: "终点坐标需要大于起点坐标(单位是图片上的像素)" });
|
||||
fs.mkdirSync(path.dirname(output), { recursive: true });
|
||||
// 注意: .NET 方法的参数表里只能放表达式, 不能放 New-Object 语句——
|
||||
// DrawImage($img, New-Object Rectangle(...), ...) 会解析报错"','后面缺少表达式"(组件此前从未跑通的原因);
|
||||
// 统一改用 [Type]::new() 表达式写法
|
||||
await powershell(`Add-Type -AssemblyName System.Drawing;$image=[System.Drawing.Image]::FromFile(${psString(input)});$rect=[System.Drawing.Rectangle]::new(${x},${y},${width},${height});$bitmap=[System.Drawing.Bitmap]::new($rect.Width,$rect.Height);$graphics=[System.Drawing.Graphics]::FromImage($bitmap);$graphics.DrawImage($image,[System.Drawing.Rectangle]::new(0,0,$rect.Width,$rect.Height),$rect,[System.Drawing.GraphicsUnit]::Pixel);$bitmap.Save(${psString(output)});$graphics.Dispose();$bitmap.Dispose();$image.Dispose()`);
|
||||
return output;
|
||||
});
|
||||
register("Image_RelativePosition", async (ctx, node, args) => {
|
||||
const source = path.resolve(text(args, ["source", "image"]));
|
||||
const template = path.resolve(text(args, ["template", "template_path"]));
|
||||
if (!source.trim() || !template.trim()) throw new RuntimeError("validation_error", "被搜索图像与查找目标都需填写图片路径", { nodeId: node.id, componentId: node.componentId });
|
||||
if (!fs.existsSync(source)) throw new RuntimeError("validation_error", `找不到被搜索图像:${source}`, { nodeId: node.id, componentId: node.componentId });
|
||||
if (!fs.existsSync(template)) throw new RuntimeError("validation_error", `找不到查找目标图片:${template}`, { nodeId: node.id, componentId: node.componentId });
|
||||
// 兼容填反:模板比源图大时(首次查找直接抛"还大"错误)自动互换,
|
||||
// 大图当被搜索图、小图当查找目标,语义仍是「小图在大图中的位置」
|
||||
const accuracy = num(args, ["accuracy"], 0.2);
|
||||
let hit: Awaited<ReturnType<typeof findImage>> = null;
|
||||
let firstError: unknown = null;
|
||||
try {
|
||||
hit = await findImage(template, source, accuracy);
|
||||
} catch (error) {
|
||||
firstError = error;
|
||||
try {
|
||||
hit = await findImage(source, template, accuracy);
|
||||
if (hit) ctx.log("两张图疑似填反,已自动按「小图在大图中」查找并命中", "warn", node.id);
|
||||
} catch {
|
||||
// 互换也失败则回落到原始方向的错误信息
|
||||
}
|
||||
}
|
||||
if (!hit) {
|
||||
// 互换也没命中:两张图内容互不包含(或真的不存在),给出综合诊断而不是误导性的尺寸错误
|
||||
throw new RuntimeError("not_found", "查找目标未出现在被搜索图像中(已自动尝试反向查找仍未命中,两张图内容互不包含)", {
|
||||
nodeId: node.id,
|
||||
componentId: node.componentId,
|
||||
detail: firstError instanceof Error ? firstError.message : String(firstError ?? ""),
|
||||
recommendation: "「被搜索图像」应是较大的底图(如整屏/整窗截图),「查找目标」应是底图中实际出现的小图案(按钮/图标/文字块)的截图;两张完全无关的图无法匹配"
|
||||
});
|
||||
}
|
||||
return hit;
|
||||
});
|
||||
|
||||
register("logic_control_if_ai_vision", async (ctx, node, args) => {
|
||||
const screenshot = path.join(ctx.evidenceDir, `ai-vision-${Date.now()}.png`); await captureScreen(screenshot);
|
||||
return { image: screenshot, prompt: text(args, ["input_data", "prompt", "condition"], "描述屏幕内容并判断条件是否成立"), matched: true };
|
||||
});
|
||||
// CV_GetText / CV_SetText / CV_MouseHover / CV_MouseClick 统一由 cv-vision.ts 注册:
|
||||
// 此处曾重复注册并覆盖 cv-vision 的实现,其中内联 here-string(@'...'@ 单行)是
|
||||
// 非法 PowerShell 语法,导致 CV_MouseClick/CV_MouseHover 必然解析失败(报 CLIXML 错误)。
|
||||
// 如需调整 CV 行为请修改 cv-vision.ts,不要在此重复注册。
|
||||
|
||||
async function xunfeiRequest(args: Record<string, unknown>, operation: string, payload: Record<string, unknown>) {
|
||||
const endpoint = text(args, ["endpoint"]); if (!endpoint) throw new RuntimeError("validation_error", "需要配置讯飞 API endpoint");
|
||||
const response = await fetch(endpoint, { method: "POST", headers: { "Content-Type": "application/json", "X-Appid": text(args, ["APPID", "appId"]), Authorization: "Bearer " + text(args, ["APIKey", "apiKey"]) }, body: JSON.stringify({ operation, ...payload }) });
|
||||
if (!response.ok) throw new RuntimeError("external_service_failed", "讯飞 API " + response.status + ": " + await response.text()); return await response.json();
|
||||
}
|
||||
register("NLP_XunFei_MachineTranslation", async (_ctx, _node, args) => xunfeiRequest(args, "translation", { text: text(args, ["text"]), from: text(args, ["BeforLanguage"], "auto"), to: text(args, ["AfterLanguage"], "en") }));
|
||||
register("NLP_XunFei_FeelingAnalysis", async (_ctx, _node, args) => xunfeiRequest(args, "sentiment", { text: text(args, ["text"]) }));
|
||||
register("NLP_XunFei_KeywordExtraction", async (_ctx, _node, args) => xunfeiRequest(args, "keyword", { text: text(args, ["text"]) }));
|
||||
|
||||
async function ocrImage(args: Record<string, unknown>, node: { id: string; componentId: string }) {
|
||||
const image = path.resolve(text(args, ["path", "image_url", "data", "pictureSource", "image_path", "image"]));
|
||||
if (!fs.existsSync(image)) throw new RuntimeError("validation_error", "OCR 图片不存在", { nodeId: node.id, componentId: node.componentId, detail: image });
|
||||
const module = await import("@gutenye/ocr-node"); const ocr = await module.default.create(); const result = await ocr.detect(image);
|
||||
return (result.texts || []).map((line: any) => line.text).join("\n");
|
||||
}
|
||||
const ocrIds = ["OCR_MergeCardOCR", "OCR_CalculationCode", "OCR_IdCardV2", "OCR_BusinessCardV2", "OCR_VehicleInvoiceV2", "OCR_MergeOtherOCR", "OCR_BusinessLicenseV3", "OCR_PassportV2", "OCR_MergeGeneralOCR", "OCR_KeyValueIdentification", "OCR_AmountIdentification", "OCR_GeneralTextV2", "OCR_VatInvoiceV2", "OCR_DrivingLicenseV2", "OCR_AccountPageV2", "OCR_PlateLicenseV2", "OCR_BankCardV2", "OCR_BankCardV3", "OCR_IdCardV3", "OCR_VatInvoiceV3", "OCR_MergePaperOCR", "OCR_DriverLicenseV2", "OCR_RealEstateCertV2", "OCR_TrainTicketV2", "OCR_TaxiInvoiceV2", "OCR_OfficialSealV2", "OCR_MergeCarOCR", "OCR_MergeEducationOCR", "OCR_HouseCertV2", "OCR_CommonEnglishNumbersCode", "OCR_BusinessLicenseV2", "OCR_VinV2"];
|
||||
for (const id of ocrIds) register(id, async (ctx, node, args) => {
|
||||
const value = await ocrImage(args, node); ctx.log(id + " OCR 完成:" + value.length + " 字符", "info", node.id); return value;
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import type { CatalogComponent, FlowNode } from "../types";
|
||||
import type { RunContext } from "../engine/types";
|
||||
import { getHandler } from "../engine/registry";
|
||||
import "./system-extended";
|
||||
|
||||
const context = {} as RunContext;
|
||||
const component = {} as CatalogComponent;
|
||||
const children: Array<ReturnType<typeof spawn>> = [];
|
||||
const node = (componentId: string): FlowNode => ({ id: componentId, componentId, 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(() => {
|
||||
for (const child of children.splice(0)) if (!child.killed) child.kill();
|
||||
delete process.env.RPA_STUDIO_TEST_ENV;
|
||||
});
|
||||
|
||||
describe("system extended capabilities", () => {
|
||||
it("reads and writes process environment values and standard paths", async () => {
|
||||
await expect(run("System_SetEnv", { name: "RPA_STUDIO_TEST_ENV", value: "connected" })).resolves.toBe("connected");
|
||||
await expect(run("System_GetEnv", { name: "RPA_STUDIO_TEST_ENV" })).resolves.toBe("connected");
|
||||
await expect(run("System_GetTempPath")).resolves.toBe(os.tmpdir());
|
||||
await expect(run("System_GetHomePath")).resolves.toBe(os.homedir());
|
||||
await expect(run("System_GetSystemPath", { path_name: "documents" })).resolves.toBe(path.join(os.homedir(), "Documents"));
|
||||
});
|
||||
|
||||
it("finds and terminates a test child process by PID", async () => {
|
||||
const child = spawn(process.execPath, ["-e", "setInterval(()=>{},1000)"], { windowsHide: true });
|
||||
children.push(child);
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
const pids = await run("App_GetPids", { pattern: "node.exe" }) as number[];
|
||||
expect(pids).toContain(child.pid);
|
||||
const exited = new Promise<number | null>((resolve) => child.once("exit", resolve));
|
||||
await run("App_Kill", { pid: child.pid });
|
||||
const exitCode = await exited;
|
||||
expect(exitCode).not.toBeUndefined();
|
||||
}, 10000);
|
||||
|
||||
it("registers safe clipboard and window variants", () => {
|
||||
for (const id of ["Clipboard_GetTextV1", "Clipboard_SetTextV1", "Clipboard_ClearClipboard", "Window_GetWindowRect", "Window_SetWindowState", "Window_SetWindowState_v1", "Window_SizeWindow_v1"]) {
|
||||
expect(getHandler(id), id).toBeTruthy();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,141 @@
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { register } from "../engine/registry";
|
||||
import { RuntimeError, type CapabilityHandler } from "../engine/types";
|
||||
import { powershell, psString } from "./desktop";
|
||||
|
||||
function value(args: Record<string, unknown>, keys: string[], fallback = ""): string {
|
||||
for (const key of keys) {
|
||||
const current = args[key];
|
||||
if (current !== undefined && current !== null && current !== "") return String(current);
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function required(args: Record<string, unknown>, keys: string[], label: string, nodeId: string, componentId: string): string {
|
||||
const result = value(args, keys).trim();
|
||||
if (!result) throw new RuntimeError("validation_error", `${label}为空`, { nodeId, componentId, recommendation: `请配置${label}` });
|
||||
return result;
|
||||
}
|
||||
|
||||
function processPattern(raw: string): string {
|
||||
return path.basename(raw).replace(/\.exe$/i, "").replace(/\*/g, "*") || "*";
|
||||
}
|
||||
|
||||
function selector(args: Record<string, unknown>): string {
|
||||
const raw = args.window_obj ?? args["窗口对象"] ?? args.match_title ?? args.select_title ?? args.target_window ?? args.title;
|
||||
if (typeof raw === "object" && raw !== null) {
|
||||
const object = raw as Record<string, unknown>;
|
||||
return String(object.title ?? object.windowTitle ?? object.pid ?? object.hwnd ?? "");
|
||||
}
|
||||
return String(raw ?? "");
|
||||
}
|
||||
|
||||
const WINDOW_API = `Add-Type @'
|
||||
using System;
|
||||
using System.Runtime.InteropServices;
|
||||
public static class RpaWindowApi {
|
||||
[StructLayout(LayoutKind.Sequential)] public struct RECT { public int Left; public int Top; public int Right; public int Bottom; }
|
||||
[DllImport("user32.dll")] public static extern bool GetWindowRect(IntPtr hWnd, out RECT rect);
|
||||
[DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr hWnd, int command);
|
||||
[DllImport("user32.dll")] public static extern bool SetWindowPos(IntPtr hWnd, IntPtr after, int x, int y, int width, int height, uint flags);
|
||||
}
|
||||
'@`;
|
||||
|
||||
function findWindowScript(title: string): string {
|
||||
return `$p = Get-Process | Where-Object { $_.MainWindowHandle -ne 0 -and $_.MainWindowTitle -like ${psString(`*${title}*`)} } | Select-Object -First 1; if (-not $p) { throw '找不到窗口:${title.replace(/'/g, "''")}' }`;
|
||||
}
|
||||
|
||||
register("System_GetEnv", (_ctx, node, args) => {
|
||||
const name = required(args, ["name"], "环境变量名称", node.id, node.componentId);
|
||||
return process.env[name] ?? "";
|
||||
});
|
||||
|
||||
register("System_SetEnv", (_ctx, node, args) => {
|
||||
const name = required(args, ["name"], "环境变量名称", node.id, node.componentId);
|
||||
const content = value(args, ["value"]);
|
||||
process.env[name] = content;
|
||||
return content;
|
||||
});
|
||||
|
||||
register("System_GetTempPath", () => os.tmpdir());
|
||||
register("System_GetHomePath", () => os.homedir());
|
||||
register("System_GetSystemPath", (_ctx, _node, args) => {
|
||||
const name = value(args, ["path_name"], "desktop").toLowerCase();
|
||||
const home = os.homedir();
|
||||
const paths: Record<string, string> = {
|
||||
desktop: path.join(home, "Desktop"), documents: path.join(home, "Documents"), document: path.join(home, "Documents"),
|
||||
downloads: path.join(home, "Downloads"), download: path.join(home, "Downloads"), pictures: path.join(home, "Pictures"),
|
||||
music: path.join(home, "Music"), videos: path.join(home, "Videos"), home,
|
||||
temp: os.tmpdir(), system: process.env.SystemRoot || "C:\\Windows", appdata: process.env.APPDATA || path.join(home, "AppData", "Roaming")
|
||||
};
|
||||
return paths[name] || value(args, ["path"]) || path.join(home, name);
|
||||
});
|
||||
|
||||
const getPids: CapabilityHandler = async (_ctx, node, args) => {
|
||||
const pattern = processPattern(required(args, ["pattern", "p_name", "name"], "进程名称", node.id, node.componentId));
|
||||
const output = await powershell(`Get-Process | Where-Object { $_.ProcessName -like ${psString(pattern)} } | Select-Object -ExpandProperty Id`);
|
||||
return output.split(/\r?\n/).map((line) => line.trim()).filter(Boolean)
|
||||
.map(Number).filter((pid) => Number.isSafeInteger(pid) && pid > 0);
|
||||
};
|
||||
register("App_GetPids", getPids);
|
||||
register("App_GetPid", async (ctx, node, args, component) => (await getPids(ctx, node, args, component) as number[])[0] ?? null);
|
||||
|
||||
register(["App_Kill", "App_KillV1"], async (_ctx, node, args) => {
|
||||
const mode = node.componentId === "App_Kill" ? "id" : value(args, ["close_type"], "id");
|
||||
if (mode === "id") {
|
||||
const rawPid = args.pid;
|
||||
const pid = typeof rawPid === "number" || typeof rawPid === "string" ? Number(rawPid) : NaN;
|
||||
if (!Number.isSafeInteger(pid) || pid <= 0) {
|
||||
throw new RuntimeError("validation_error", "进程 PID 必须是正整数", { nodeId: node.id, componentId: node.componentId });
|
||||
}
|
||||
if (pid === process.pid) throw new RuntimeError("validation_error", "拒绝关闭 RPA Studio 自身进程", { nodeId: node.id, componentId: node.componentId });
|
||||
await powershell(`Stop-Process -Id ${pid} -Force -ErrorAction Stop`);
|
||||
return pid;
|
||||
}
|
||||
if (mode !== "name") throw new RuntimeError("validation_error", "请选择按 PID 或按进程名称关闭", { nodeId: node.id, componentId: node.componentId });
|
||||
const pattern = processPattern(required(args, ["p_name", "pattern"], "进程名称", node.id, node.componentId));
|
||||
if (pattern === "*") throw new RuntimeError("validation_error", "关闭应用程序必须指定具体的进程名称", { nodeId: node.id, componentId: node.componentId });
|
||||
const allSessions = [true, 1, "1", "true", "True"].includes(args.kill_all as never);
|
||||
const session = allSessions ? "" : " -and $_.SessionId -eq [System.Diagnostics.Process]::GetCurrentProcess().SessionId";
|
||||
await powershell(`Get-Process | Where-Object { $_.ProcessName -like ${psString(pattern)} -and $_.Id -ne ${process.pid}${session} } | Stop-Process -Force -ErrorAction Stop`);
|
||||
return pattern;
|
||||
});
|
||||
|
||||
register("Clipboard_GetTextV1", () => powershell("Get-Clipboard -Raw"));
|
||||
register("Clipboard_SetTextV1", async (_ctx, _node, args) => {
|
||||
const content = value(args, ["text", "value"]);
|
||||
await powershell(`Set-Clipboard -Value ${psString(content)}`);
|
||||
return content;
|
||||
});
|
||||
register("Clipboard_ClearClipboard", async () => {
|
||||
await powershell("Set-Clipboard -Value $null");
|
||||
return true;
|
||||
});
|
||||
|
||||
register("Window_GetWindowRect", async (_ctx, node, args) => {
|
||||
const title = required({ title: selector(args) }, ["title"], "窗口标题", node.id, node.componentId);
|
||||
const output = await powershell(`${WINDOW_API}; ${findWindowScript(title)}; $r = New-Object RpaWindowApi+RECT; [RpaWindowApi]::GetWindowRect($p.MainWindowHandle,[ref]$r) | Out-Null; @{x=$r.Left;y=$r.Top;width=$r.Right-$r.Left;height=$r.Bottom-$r.Top;title=$p.MainWindowTitle;pid=$p.Id} | ConvertTo-Json -Compress`);
|
||||
return JSON.parse(output);
|
||||
});
|
||||
|
||||
register(["Window_SetWindowState", "Window_SetWindowState_v1"], async (_ctx, node, args) => {
|
||||
const title = required({ title: selector(args) }, ["title"], "窗口标题", node.id, node.componentId);
|
||||
const state = value(args, ["WindowType", "switchType"], "maximize").toLowerCase();
|
||||
const command = state.includes("min") ? 6 : state.includes("max") ? 3 : 9;
|
||||
await powershell(`${WINDOW_API}; ${findWindowScript(title)}; [RpaWindowApi]::ShowWindow($p.MainWindowHandle,${command}) | Out-Null`);
|
||||
return state;
|
||||
});
|
||||
|
||||
register("Window_SizeWindow_v1", async (_ctx, node, args) => {
|
||||
const title = required({ title: selector(args) }, ["title"], "窗口标题", node.id, node.componentId);
|
||||
let size: unknown = args.window_size;
|
||||
if (typeof size === "string") {
|
||||
const source = size;
|
||||
try { size = JSON.parse(source); } catch { size = source.split(/[,,xX ]+/).map(Number); }
|
||||
}
|
||||
const [width, height] = Array.isArray(size) ? size.map(Number) : [0, 0];
|
||||
if (!(width > 0 && height > 0)) throw new RuntimeError("validation_error", "窗口尺寸无效", { nodeId: node.id, componentId: node.componentId });
|
||||
await powershell(`${WINDOW_API}; ${findWindowScript(title)}; $r = New-Object RpaWindowApi+RECT; [RpaWindowApi]::GetWindowRect($p.MainWindowHandle,[ref]$r) | Out-Null; [RpaWindowApi]::SetWindowPos($p.MainWindowHandle,[IntPtr]::Zero,$r.Left,$r.Top,${width},${height},4) | Out-Null`);
|
||||
return { width, height };
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
import { register } from "../engine/registry";
|
||||
|
||||
function part(args: Record<string, unknown>, get: (d: Date) => number): number {
|
||||
const ts = Number(args.timestamp);
|
||||
const d = Number.isFinite(ts) && ts > 0 ? new Date(ts) : new Date();
|
||||
return get(d);
|
||||
}
|
||||
|
||||
register("Time_Year", async (_ctx, _node, args) => part(args, d => d.getFullYear()));
|
||||
register("Time_Month", async (_ctx, _node, args) => part(args, d => d.getMonth() + 1));
|
||||
register("Time_Day", async (_ctx, _node, args) => part(args, d => d.getDate()));
|
||||
register("Time_Hour", async (_ctx, _node, args) => part(args, d => d.getHours()));
|
||||
register("Time_Minute", async (_ctx, _node, args) => part(args, d => d.getMinutes()));
|
||||
register("Time_Second", async (_ctx, _node, args) => part(args, d => d.getSeconds()));
|
||||
register("Time_WeekDay", async (_ctx, _node, args) => part(args, d => { const w = d.getDay(); return w === 0 ? 7 : w; }));
|
||||
@@ -0,0 +1,130 @@
|
||||
import { mkdtemp, readFile, rm, writeFile } 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, FlowDefinition, FlowNode } from "../types";
|
||||
import type { RunContext } from "../engine/types";
|
||||
import { getHandler } from "../engine/registry";
|
||||
import "./utility-extended";
|
||||
|
||||
const ids = [
|
||||
"Array_Reshape2Dim", "Time_GetDatetimeObj", "Time_TimeTypeConversion_V1", "Basic_SetVariable_V1",
|
||||
"Basic_GetProcessInfo", "System_ExecuteCommandV1", "DataService_Diff", "DataService_WorkingDay",
|
||||
"Databook_Clear", "Databook_GetRowAndColCount", "Databook_WriteContent", "Databook_ReadContent",
|
||||
"Databook_DeleteRow", "Databook_DeleteColumn"
|
||||
] as const;
|
||||
|
||||
const directories: string[] = [];
|
||||
const flow: FlowDefinition = {
|
||||
schemaVersion: 1, id: "flow-1", name: "本地工具测试", description: "utility",
|
||||
variables: [], nodes: [], edges: [], meta: { createdAt: "2026-10-27", updatedAt: "2026-10-27", appVersion: "0.1.0" }
|
||||
};
|
||||
const context = { vars: {}, flow, runId: "run-1" } as unknown as RunContext;
|
||||
const component = {} as CatalogComponent;
|
||||
const node = (id: string, outputVar?: string): FlowNode => ({
|
||||
id: `utility_${id}`, componentId: id, position: { x: 0, y: 0 }, values: {}, options: outputVar ? { outputVar } : {}
|
||||
});
|
||||
|
||||
async function run(id: string, args: Record<string, unknown> = {}, outputVar?: string): Promise<unknown> {
|
||||
const handler = getHandler(id);
|
||||
expect(handler, `${id} should be registered`).toBeTruthy();
|
||||
return handler!(context, node(id, outputVar), args, component);
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
context.vars = {};
|
||||
await Promise.all(directories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
describe("utility extended capabilities", () => {
|
||||
it("registers only real catalog component ids", async () => {
|
||||
const catalog = JSON.parse(await readFile("resources/catalog.json", "utf8")) as Catalog;
|
||||
const catalogIds = new Set(catalog.components.map((item) => item.id));
|
||||
expect(ids.length).toBeGreaterThanOrEqual(10);
|
||||
for (const id of ids) {
|
||||
expect(catalogIds.has(id), `${id} should exist in catalog`).toBe(true);
|
||||
expect(getHandler(id), `${id} should be registered`).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
it("reshapes arrays and rejects incompatible dimensions", async () => {
|
||||
await expect(run("Array_Reshape2Dim", { arr: "[1,2,3,4,5,6]", x: 2, y: 3 }))
|
||||
.resolves.toEqual([[1, 2, 3], [4, 5, 6]]);
|
||||
await expect(run("Array_Reshape2Dim", { arr: [1, 2, 3], x: 2, y: 2 }))
|
||||
.rejects.toMatchObject({ code: "validation_error" });
|
||||
});
|
||||
|
||||
it("returns datetime details and formats Python-style patterns", async () => {
|
||||
const detail = await run("Time_GetDatetimeObj", { date_input: "2026-10-27 08:09:10" }) as Record<string, unknown>;
|
||||
expect(detail).toMatchObject({ year: 2026, month: 10, day: 27, hour: 8, minute: 9, second: 10 });
|
||||
await expect(run("Time_TimeTypeConversion_V1", { datetime: "2026-10-27 08:09:10", get_type: "%Y/%m/%d %H:%M:%S" }))
|
||||
.resolves.toBe("2026/10/27 08:09:10");
|
||||
await expect(run("Time_TimeTypeConversion_V1", { datetime: "12:34", get_type: "%H:%M:%S" }))
|
||||
.resolves.toBe("12:34:00");
|
||||
await expect(run("Time_TimeTypeConversion_V1", { datetime: "2026-02-30" }))
|
||||
.rejects.toMatchObject({ code: "validation_error" });
|
||||
});
|
||||
|
||||
it("converts variable types and exposes current flow information", async () => {
|
||||
await expect(run("Basic_SetVariable_V1", { variableType: "rpa_int", value: "42" }, "answer")).resolves.toBe(42);
|
||||
expect(context.vars.answer).toBe(42);
|
||||
await expect(run("Basic_SetVariable_V1", { variableType: "rpa_list", value: "[1,2]" })).resolves.toEqual([1, 2]);
|
||||
await expect(run("Basic_GetProcessInfo")).resolves.toMatchObject({ id: "flow-1", name: "本地工具测试", runId: "run-1" });
|
||||
});
|
||||
|
||||
it("executes a local command and returns stdout", async () => {
|
||||
const args = process.platform === "win32"
|
||||
? { command_type: "1", dos_command: "echo utility-ok", get_ret: true }
|
||||
: { command_type: "1", dos_command: "printf utility-ok", get_ret: true };
|
||||
await expect(run("System_ExecuteCommandV1", args)).resolves.toContain("utility-ok");
|
||||
});
|
||||
|
||||
it("executes BAT files whose path contains spaces and Chinese characters", async () => {
|
||||
if (process.platform !== "win32") return;
|
||||
const directory = await mkdtemp(path.join(os.tmpdir(), "rpa-utility-中文 空格-"));
|
||||
directories.push(directory);
|
||||
const file = path.join(directory, "测试 脚本.bat");
|
||||
await writeFile(file, "@echo RPA-BAT-PATH-OK\r\n", "ascii");
|
||||
await expect(run("System_ExecuteCommandV1", { command_type: "2", bat_file: file, get_ret: "1" })).resolves.toBe("RPA-BAT-PATH-OK");
|
||||
});
|
||||
|
||||
it("keeps PowerShell command output in UTF-8", async () => {
|
||||
if (process.platform !== "win32") return;
|
||||
await expect(run("System_ExecuteCommandV1", { command_type: "3", ps_command: "Write-Output '中文输出测试'", get_ret: "1" })).resolves.toBe("中文输出测试");
|
||||
});
|
||||
|
||||
it("compares local text files with structured line changes", async () => {
|
||||
const directory = await mkdtemp(path.join(os.tmpdir(), "rpa-utility-"));
|
||||
directories.push(directory);
|
||||
const source = path.join(directory, "source.txt");
|
||||
const target = path.join(directory, "target.txt");
|
||||
await writeFile(source, "A\nB\nC", "utf8");
|
||||
await writeFile(target, "A\nD\nC", "utf8");
|
||||
const result = await run("DataService_Diff", { sourceFilePath: source, targetFilePath: target, showTable: true }) as Record<string, unknown>;
|
||||
expect(result).toMatchObject({ identical: false, addedCount: 1, removedCount: 1, sourceLineCount: 3, targetLineCount: 3 });
|
||||
});
|
||||
|
||||
it("evaluates weekends and local holiday/workday overrides", async () => {
|
||||
await expect(run("DataService_WorkingDay", { day: "2026-10-27" })).resolves.toBe(true);
|
||||
await expect(run("DataService_WorkingDay", { day: "2026-10-31" })).resolves.toBe(false);
|
||||
await expect(run("DataService_WorkingDay", { day: "2026-10-31", working_cal: '{"workdays":["2026-10-31"]}' })).resolves.toBe(true);
|
||||
await expect(run("DataService_WorkingDay", { day: "2026-10-27", working_cal: '["2026-10-27"]' })).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it("writes, reads, counts, and clears the in-run databook", async () => {
|
||||
await run("Databook_WriteContent", { write_type: "region", column_name: "A", row_num: 1, content: [["name", "score"], ["Alice", 90], ["Bob", 80]] });
|
||||
await expect(run("Databook_ReadContent", { read_type: "cell", column_name: "B", row_num: 2 })).resolves.toBe(90);
|
||||
await expect(run("Databook_ReadContent", { read_type: "column", column_name: "A" })).resolves.toEqual(["name", "Alice", "Bob"]);
|
||||
await expect(run("Databook_ReadContent", { read_type: "region", column_name: "A", row_num: 2, end_column_name: "B", end_row_num: 3 }))
|
||||
.resolves.toEqual([["Alice", 90], ["Bob", 80]]);
|
||||
await expect(run("Databook_GetRowAndColCount")).resolves.toMatchObject({ rowCount: 3, columnCount: 2 });
|
||||
await expect(run("Databook_Clear")).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it("deletes selected databook rows and columns", async () => {
|
||||
context.vars.__rpaDatabook = [["A", "B", "C"], [1, 2, 3], [4, 5, 6]];
|
||||
await run("Databook_DeleteRow", { row_nums: [2] });
|
||||
await run("Databook_DeleteColumn", { column_names: ["B"] });
|
||||
expect(context.vars.__rpaDatabook).toEqual([["A", "C"], [4, 6]]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,394 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import { register } from "../engine/registry";
|
||||
import { RuntimeError } from "../engine/types";
|
||||
|
||||
type Table = unknown[][];
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const DATABOOK_KEY = "__rpaDatabook";
|
||||
|
||||
function parse(candidate: unknown): unknown {
|
||||
if (typeof candidate !== "string") return candidate;
|
||||
const source = candidate.trim();
|
||||
if (!source) return candidate;
|
||||
try { return JSON.parse(source); } catch { return candidate; }
|
||||
}
|
||||
|
||||
function list(candidate: unknown): unknown[] {
|
||||
const parsed = parse(candidate);
|
||||
if (Array.isArray(parsed)) return parsed;
|
||||
if (parsed === undefined || parsed === null || parsed === "") return [];
|
||||
if (typeof parsed === "string") return parsed.split(/[,,\n]/).map((item) => item.trim()).filter(Boolean);
|
||||
return [parsed];
|
||||
}
|
||||
|
||||
function table(candidate: unknown): Table {
|
||||
const parsed = parse(candidate);
|
||||
if (!Array.isArray(parsed)) return parsed === undefined || parsed === null || parsed === "" ? [] : [[parsed]];
|
||||
return parsed.map((row) => Array.isArray(row) ? [...row] : [row]);
|
||||
}
|
||||
|
||||
function requiredText(candidate: unknown, label: string, nodeId: string, componentId: string): string {
|
||||
const result = String(candidate ?? "").trim();
|
||||
if (!result) {
|
||||
throw new RuntimeError("validation_error", `${label}为空`, {
|
||||
nodeId,
|
||||
componentId,
|
||||
recommendation: `请配置${label}`
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function integer(candidate: unknown, fallback = 0): number {
|
||||
const result = Number(candidate);
|
||||
return Number.isInteger(result) ? result : fallback;
|
||||
}
|
||||
|
||||
function bool(candidate: unknown, fallback = false): boolean {
|
||||
if (candidate === undefined || candidate === null || candidate === "") return fallback;
|
||||
return [true, 1, "1", "true", "True", "是", "开启"].includes(candidate as never);
|
||||
}
|
||||
|
||||
function parseDate(candidate: unknown, nodeId: string, componentId: string): Date {
|
||||
if (candidate instanceof Date && !Number.isNaN(candidate.getTime())) return new Date(candidate.getTime());
|
||||
if (typeof candidate === "number" && Number.isFinite(candidate)) {
|
||||
return new Date(candidate < 10_000_000_000 ? candidate * 1000 : candidate);
|
||||
}
|
||||
const source = String(candidate ?? "").trim();
|
||||
if (!source) return new Date();
|
||||
const normalized = source
|
||||
.replace(/年|\//g, "-")
|
||||
.replace(/月/g, "-")
|
||||
.replace(/日/g, "");
|
||||
const dateParts = normalized.match(/^(\d{4})-(\d{1,2})-(\d{1,2})(?:[ T](\d{1,2}):(\d{2})(?::(\d{2}))?(?:\.(\d{1,3}))?)?$/);
|
||||
if (dateParts) {
|
||||
const values = dateParts.slice(1, 7).map((item) => Number(item || 0));
|
||||
const [year, month, day, hour, minute, second] = values;
|
||||
const date = new Date(year, month - 1, day, hour, minute, second, Number((dateParts[7] || "0").padEnd(3, "0")));
|
||||
if (date.getFullYear() === year && date.getMonth() === month - 1 && date.getDate() === day
|
||||
&& date.getHours() === hour && date.getMinutes() === minute && date.getSeconds() === second) return date;
|
||||
throw new RuntimeError("validation_error", `无法解析日期时间:${source}`, { nodeId, componentId });
|
||||
}
|
||||
const timeParts = normalized.match(/^(\d{1,2}):(\d{2})(?::(\d{2}))?$/);
|
||||
if (timeParts) {
|
||||
const hour = Number(timeParts[1]);
|
||||
const minute = Number(timeParts[2]);
|
||||
const second = Number(timeParts[3] || 0);
|
||||
if (hour > 23 || minute > 59 || second > 59) {
|
||||
throw new RuntimeError("validation_error", `无法解析日期时间:${source}`, { nodeId, componentId });
|
||||
}
|
||||
const date = new Date();
|
||||
date.setHours(hour, minute, second, 0);
|
||||
return date;
|
||||
}
|
||||
const date = new Date(normalized);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
throw new RuntimeError("validation_error", `无法解析日期时间:${source}`, { nodeId, componentId });
|
||||
}
|
||||
return date;
|
||||
}
|
||||
|
||||
function pad(value: number, width = 2): string {
|
||||
return String(value).padStart(width, "0");
|
||||
}
|
||||
|
||||
function formatDate(date: Date, format: string): string {
|
||||
const tokens: Record<string, string> = {
|
||||
"%Y": String(date.getFullYear()), "%y": pad(date.getFullYear() % 100),
|
||||
"%m": pad(date.getMonth() + 1), "%d": pad(date.getDate()),
|
||||
"%H": pad(date.getHours()), "%I": pad((date.getHours() % 12) || 12),
|
||||
"%M": pad(date.getMinutes()), "%S": pad(date.getSeconds()),
|
||||
"%f": pad(date.getMilliseconds(), 3), "%w": String(date.getDay()),
|
||||
"%p": date.getHours() < 12 ? "AM" : "PM", "%%": "%"
|
||||
};
|
||||
return format.replace(/%[YymdHIMSfwp%]/g, (token) => tokens[token] ?? token);
|
||||
}
|
||||
|
||||
function getDatabook(ctx: { vars: Record<string, unknown> }): Table {
|
||||
const current = table(ctx.vars[DATABOOK_KEY]);
|
||||
ctx.vars[DATABOOK_KEY] = current;
|
||||
return current;
|
||||
}
|
||||
|
||||
function setDatabook(ctx: { vars: Record<string, unknown> }, value: Table): Table {
|
||||
ctx.vars[DATABOOK_KEY] = value;
|
||||
return value;
|
||||
}
|
||||
|
||||
function columnIndex(candidate: unknown, columnCount: number, allowEnd = false): number {
|
||||
const source = String(candidate ?? "").trim().toUpperCase();
|
||||
if (!source) return 0;
|
||||
let oneBased: number;
|
||||
if (/^-?\d+$/.test(source)) {
|
||||
const numeric = Number(source);
|
||||
oneBased = numeric < 0 ? columnCount + numeric + 1 : numeric;
|
||||
} else if (/^[A-Z]+$/.test(source)) {
|
||||
oneBased = 0;
|
||||
for (const character of source) oneBased = oneBased * 26 + character.charCodeAt(0) - 64;
|
||||
} else {
|
||||
return -1;
|
||||
}
|
||||
const index = oneBased - 1;
|
||||
const maximum = allowEnd ? columnCount : columnCount - 1;
|
||||
return index >= 0 && index <= maximum ? index : -1;
|
||||
}
|
||||
|
||||
function rowIndex(candidate: unknown, rowCount: number, allowEnd = false): number {
|
||||
const numeric = integer(candidate, 0);
|
||||
const oneBased = numeric < 0 ? rowCount + numeric + 1 : numeric;
|
||||
const index = oneBased - 1;
|
||||
const maximum = allowEnd ? rowCount : rowCount - 1;
|
||||
return index >= 0 && index <= maximum ? index : -1;
|
||||
}
|
||||
|
||||
function ensureCell(rows: Table, row: number, column: number): void {
|
||||
while (rows.length <= row) rows.push([]);
|
||||
while (rows[row].length <= column) rows[row].push(null);
|
||||
}
|
||||
|
||||
function indices(candidate: unknown, count: number, column = false): number[] {
|
||||
return [...new Set(list(candidate)
|
||||
.map((item) => column ? columnIndex(item, count) : rowIndex(item, count))
|
||||
.filter((item) => item >= 0))]
|
||||
.sort((left, right) => right - left);
|
||||
}
|
||||
|
||||
function lineDiff(source: string, target: string) {
|
||||
const left = source.split(/\r?\n/);
|
||||
const right = target.split(/\r?\n/);
|
||||
if (left.length * right.length > 4_000_000) {
|
||||
throw new RuntimeError("validation_error", "文本行数过多,无法执行本地逐行比对");
|
||||
}
|
||||
const matrix = Array.from({ length: left.length + 1 }, () => new Uint32Array(right.length + 1));
|
||||
for (let i = left.length - 1; i >= 0; i -= 1) {
|
||||
for (let j = right.length - 1; j >= 0; j -= 1) {
|
||||
matrix[i][j] = left[i] === right[j] ? matrix[i + 1][j + 1] + 1 : Math.max(matrix[i + 1][j], matrix[i][j + 1]);
|
||||
}
|
||||
}
|
||||
const changes: Array<{ type: "equal" | "removed" | "added"; line: string; sourceLine?: number; targetLine?: number }> = [];
|
||||
let i = 0;
|
||||
let j = 0;
|
||||
while (i < left.length || j < right.length) {
|
||||
if (i < left.length && j < right.length && left[i] === right[j]) {
|
||||
changes.push({ type: "equal", line: left[i], sourceLine: i + 1, targetLine: j + 1 });
|
||||
i += 1;
|
||||
j += 1;
|
||||
} else if (j < right.length && (i === left.length || matrix[i][j + 1] >= matrix[i + 1][j])) {
|
||||
changes.push({ type: "added", line: right[j], targetLine: j + 1 });
|
||||
j += 1;
|
||||
} else {
|
||||
changes.push({ type: "removed", line: left[i], sourceLine: i + 1 });
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
return { left, right, changes };
|
||||
}
|
||||
|
||||
register("Array_Reshape2Dim", (_ctx, node, args) => {
|
||||
const flattened = list(args.arr).flat(Infinity);
|
||||
let rows = integer(args.x, 0);
|
||||
let columns = integer(args.y, 0);
|
||||
if (rows < 0 || columns < 0) throw new RuntimeError("validation_error", "数组维度不能为负数", { nodeId: node.id, componentId: node.componentId });
|
||||
if (rows === 0 && columns === 0) rows = flattened.length ? 1 : 0;
|
||||
if (rows === 0 && columns > 0) rows = flattened.length / columns;
|
||||
if (columns === 0 && rows > 0) columns = flattened.length / rows;
|
||||
if (!Number.isInteger(rows) || !Number.isInteger(columns) || rows * columns !== flattened.length) {
|
||||
throw new RuntimeError("validation_error", `数组长度 ${flattened.length} 无法转换为 ${rows}x${columns}`, { nodeId: node.id, componentId: node.componentId });
|
||||
}
|
||||
return Array.from({ length: rows }, (_, index) => flattened.slice(index * columns, (index + 1) * columns));
|
||||
});
|
||||
|
||||
register("Time_GetDatetimeObj", (_ctx, node, args) => {
|
||||
const date = parseDate(args.date_input, node.id, node.componentId);
|
||||
const weekdayNames = ["星期日", "星期一", "星期二", "星期三", "星期四", "星期五", "星期六"];
|
||||
return {
|
||||
year: date.getFullYear(), month: date.getMonth() + 1, day: date.getDate(),
|
||||
hour: date.getHours(), minute: date.getMinutes(), second: date.getSeconds(), millisecond: date.getMilliseconds(),
|
||||
weekday: date.getDay(), weekdayName: weekdayNames[date.getDay()], timestamp: date.getTime(),
|
||||
iso: date.toISOString(), timezoneOffset: -date.getTimezoneOffset()
|
||||
};
|
||||
});
|
||||
|
||||
register("Time_TimeTypeConversion_V1", (_ctx, node, args) => {
|
||||
const date = parseDate(args.datetime, node.id, node.componentId);
|
||||
return formatDate(date, String(args.get_type || "%Y-%m-%d %H:%M:%S"));
|
||||
});
|
||||
|
||||
register("Basic_SetVariable_V1", (ctx, node, args) => {
|
||||
const kind = String(args.variableType || "rpa_str").toLowerCase();
|
||||
const parsed = parse(args.value);
|
||||
let result: unknown = parsed;
|
||||
if (kind.includes("str")) result = String(args.value ?? "");
|
||||
else if (kind.includes("int")) result = Math.trunc(Number(args.value));
|
||||
else if (kind.includes("float") || kind.includes("num")) result = Number(args.value);
|
||||
else if (kind.includes("bool")) result = bool(args.value);
|
||||
else if (kind.includes("list")) result = list(args.value);
|
||||
else if (kind.includes("dict") && (!parsed || typeof parsed !== "object" || Array.isArray(parsed))) {
|
||||
throw new RuntimeError("validation_error", "字典变量值必须是 JSON 对象", { nodeId: node.id, componentId: node.componentId });
|
||||
}
|
||||
if ((kind.includes("int") || kind.includes("float") || kind.includes("num")) && !Number.isFinite(result)) {
|
||||
throw new RuntimeError("validation_error", "变量值不是有效数字", { nodeId: node.id, componentId: node.componentId });
|
||||
}
|
||||
if (node.options?.outputVar) ctx.vars[node.options.outputVar] = result;
|
||||
return result;
|
||||
});
|
||||
|
||||
register("Basic_GetProcessInfo", (ctx) => ({
|
||||
id: ctx.flow.id,
|
||||
name: ctx.flow.name,
|
||||
description: ctx.flow.description ?? "",
|
||||
enabled: ctx.flow.enabled !== false,
|
||||
trigger: ctx.flow.trigger ?? null,
|
||||
version: ctx.flow.meta.appVersion,
|
||||
createdAt: ctx.flow.meta.createdAt,
|
||||
updatedAt: ctx.flow.meta.updatedAt,
|
||||
nodeCount: ctx.flow.nodes.length,
|
||||
runId: ctx.runId
|
||||
}));
|
||||
|
||||
register("System_ExecuteCommandV1", async (_ctx, node, args) => {
|
||||
const mode = String(args.command_type || "1");
|
||||
if (!["1", "2", "3"].includes(mode)) throw new RuntimeError("validation_error", "请选择 DOS、BAT 或 PowerShell 命令类型", { nodeId: node.id, componentId: node.componentId });
|
||||
const cwd = String(args.cwd || "").trim() || undefined;
|
||||
const timeout = Math.max(1_000, Number(args.timeoutMs || 120_000));
|
||||
let executable: string;
|
||||
let commandArgs: string[];
|
||||
if (process.platform === "win32") {
|
||||
if (mode === "2") {
|
||||
const file = path.resolve(requiredText(args.bat_file, "BAT文件", node.id, node.componentId));
|
||||
await fs.access(file);
|
||||
executable = process.env.ComSpec || "cmd.exe";
|
||||
commandArgs = ["/d", "/s", "/c", `call "${file}"`];
|
||||
} else if (mode === "3") {
|
||||
executable = "powershell.exe";
|
||||
const utf8 = "$ProgressPreference='SilentlyContinue'; [Console]::OutputEncoding=[System.Text.UTF8Encoding]::new($false); $OutputEncoding=[Console]::OutputEncoding; ";
|
||||
const script = utf8 + requiredText(args.ps_command, "PowerShell命令", node.id, node.componentId);
|
||||
commandArgs = ["-NoLogo", "-NoProfile", "-NonInteractive", "-EncodedCommand", Buffer.from(script, "utf16le").toString("base64")];
|
||||
} else {
|
||||
executable = process.env.ComSpec || "cmd.exe";
|
||||
commandArgs = ["/d", "/s", "/c", requiredText(args.dos_command, "DOS命令", node.id, node.componentId)];
|
||||
}
|
||||
} else {
|
||||
const command = mode === "2" ? requiredText(args.bat_file, "脚本文件", node.id, node.componentId)
|
||||
: mode === "3" ? requiredText(args.ps_command, "命令", node.id, node.componentId)
|
||||
: requiredText(args.dos_command, "命令", node.id, node.componentId);
|
||||
executable = "/bin/sh";
|
||||
commandArgs = ["-lc", command];
|
||||
}
|
||||
// cmd.exe 自己解析 /c 后的命令;不能让 Node 再把 BAT 路径引号转成反斜杠引号。
|
||||
const result = await execFileAsync(executable, commandArgs, { cwd, timeout, windowsHide: true, windowsVerbatimArguments: process.platform === "win32" && mode !== "3", maxBuffer: 10 * 1024 * 1024 });
|
||||
return bool(args.get_ret, true) ? String(result.stdout).trimEnd() : true;
|
||||
});
|
||||
|
||||
register("DataService_Diff", async (_ctx, node, args) => {
|
||||
const sourcePath = path.resolve(requiredText(args.sourceFilePath, "源文件", node.id, node.componentId));
|
||||
const targetPath = path.resolve(requiredText(args.targetFilePath, "目标文件", node.id, node.componentId));
|
||||
const [source, target] = await Promise.all([fs.readFile(sourcePath, "utf8"), fs.readFile(targetPath, "utf8")]);
|
||||
const { left, right, changes } = lineDiff(source, target);
|
||||
const added = changes.filter((item) => item.type === "added");
|
||||
const removed = changes.filter((item) => item.type === "removed");
|
||||
return {
|
||||
identical: added.length === 0 && removed.length === 0,
|
||||
sourcePath, targetPath,
|
||||
sourceLineCount: left.length, targetLineCount: right.length,
|
||||
addedCount: added.length, removedCount: removed.length,
|
||||
added, removed, changes: bool(args.showTable, true) ? changes : undefined
|
||||
};
|
||||
});
|
||||
|
||||
register("DataService_WorkingDay", (_ctx, node, args) => {
|
||||
const date = parseDate(args.day, node.id, node.componentId);
|
||||
const key = formatDate(date, "%Y-%m-%d");
|
||||
const calendar = parse(args.working_cal);
|
||||
let holidays: unknown[] = [];
|
||||
let workdays: unknown[] = [];
|
||||
if (Array.isArray(calendar)) holidays = calendar;
|
||||
else if (calendar && typeof calendar === "object") {
|
||||
holidays = list((calendar as Record<string, unknown>).holidays);
|
||||
workdays = list((calendar as Record<string, unknown>).workdays);
|
||||
} else if (typeof calendar === "string" && calendar.trim()) holidays = list(calendar);
|
||||
if (workdays.map(String).includes(key)) return true;
|
||||
if (holidays.map(String).includes(key)) return false;
|
||||
return date.getDay() !== 0 && date.getDay() !== 6;
|
||||
});
|
||||
|
||||
register("Databook_Clear", (ctx) => setDatabook(ctx, []));
|
||||
|
||||
register("Databook_GetRowAndColCount", (ctx) => {
|
||||
const rows = getDatabook(ctx);
|
||||
const columns = Math.max(0, ...rows.map((row) => row.length));
|
||||
return { rowCount: rows.length, columnCount: columns, rows: rows.length, columns };
|
||||
});
|
||||
|
||||
register("Databook_WriteContent", (ctx, _node, args) => {
|
||||
const rows = getDatabook(ctx);
|
||||
const mode = String(args.write_type || "cell").toLowerCase();
|
||||
const content = parse(args.content);
|
||||
const row = rowIndex(args.row_num, rows.length, true);
|
||||
const width = Math.max(0, ...rows.map((item) => item.length));
|
||||
const column = columnIndex(args.column_name, width, true);
|
||||
if (mode === "row") {
|
||||
const values = list(content);
|
||||
const target = row >= 0 ? row : rows.length;
|
||||
if (String(args.write_kind || "append").toLowerCase() === "append" && target >= rows.length) rows.push(values);
|
||||
else rows[target] = values;
|
||||
} else if (mode === "column") {
|
||||
const values = list(content);
|
||||
const target = column >= 0 ? column : width;
|
||||
values.forEach((item, index) => { ensureCell(rows, index, target); rows[index][target] = item; });
|
||||
} else if (mode === "region") {
|
||||
const values = table(content);
|
||||
const startRow = row >= 0 ? row : 0;
|
||||
const startColumn = column >= 0 ? column : 0;
|
||||
values.forEach((sourceRow, rowOffset) => sourceRow.forEach((item, columnOffset) => {
|
||||
ensureCell(rows, startRow + rowOffset, startColumn + columnOffset);
|
||||
rows[startRow + rowOffset][startColumn + columnOffset] = item;
|
||||
}));
|
||||
} else {
|
||||
const targetRow = row >= 0 ? row : 0;
|
||||
const targetColumn = column >= 0 ? column : 0;
|
||||
ensureCell(rows, targetRow, targetColumn);
|
||||
rows[targetRow][targetColumn] = content;
|
||||
}
|
||||
return setDatabook(ctx, rows);
|
||||
});
|
||||
|
||||
register("Databook_ReadContent", (ctx, node, args) => {
|
||||
const rows = getDatabook(ctx);
|
||||
const mode = String(args.read_type || "cell").toLowerCase();
|
||||
const width = Math.max(0, ...rows.map((item) => item.length));
|
||||
const row = rowIndex(args.row_num, rows.length);
|
||||
const column = columnIndex(args.column_name, width);
|
||||
if (mode === "row") return row >= 0 ? [...rows[row]] : [];
|
||||
if (mode === "column") return column >= 0 ? rows.map((item) => item[column]) : [];
|
||||
if (mode === "region") {
|
||||
const endRow = rowIndex(args.end_row_num, rows.length);
|
||||
const endColumn = columnIndex(args.end_column_name, width);
|
||||
if ([row, column, endRow, endColumn].some((item) => item < 0)) {
|
||||
throw new RuntimeError("validation_error", "读取区域超出数据表范围", { nodeId: node.id, componentId: node.componentId });
|
||||
}
|
||||
return rows.slice(Math.min(row, endRow), Math.max(row, endRow) + 1)
|
||||
.map((item) => item.slice(Math.min(column, endColumn), Math.max(column, endColumn) + 1));
|
||||
}
|
||||
if (row < 0 || column < 0) throw new RuntimeError("validation_error", "读取单元格超出数据表范围", { nodeId: node.id, componentId: node.componentId });
|
||||
return rows[row]?.[column];
|
||||
});
|
||||
|
||||
register("Databook_DeleteRow", (ctx, _node, args) => {
|
||||
const rows = getDatabook(ctx);
|
||||
for (const index of indices(args.row_nums, rows.length)) rows.splice(index, 1);
|
||||
return setDatabook(ctx, rows);
|
||||
});
|
||||
|
||||
register("Databook_DeleteColumn", (ctx, _node, args) => {
|
||||
const rows = getDatabook(ctx);
|
||||
const width = Math.max(0, ...rows.map((row) => row.length));
|
||||
for (const index of indices(args.column_names, width, true)) {
|
||||
for (const row of rows) row.splice(index, 1);
|
||||
}
|
||||
return setDatabook(ctx, rows);
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { PNG } from "pngjs";
|
||||
import { clickPoint, clampRegion, cropPng, parseRegion } from "./vision";
|
||||
|
||||
function makeImage(width = 100, height = 80): PNG {
|
||||
const png = new PNG({ width, height });
|
||||
for (let i = 0; i < png.data.length; i += 4) {
|
||||
png.data[i] = 200;
|
||||
png.data[i + 1] = 100;
|
||||
png.data[i + 2] = 50;
|
||||
png.data[i + 3] = 255;
|
||||
}
|
||||
return png;
|
||||
}
|
||||
|
||||
describe("parseRegion", () => {
|
||||
it("accepts object x/y/width/height", () => {
|
||||
const image = makeImage();
|
||||
expect(parseRegion('{"x":10,"y":20,"width":30,"height":40}', image)).toEqual({ x: 10, y: 20, width: 30, height: 40 });
|
||||
});
|
||||
|
||||
it("accepts array [x,y,width,height]", () => {
|
||||
const image = makeImage();
|
||||
expect(parseRegion("[1,2,3,4]", image)).toEqual({ x: 1, y: 2, width: 3, height: 4 });
|
||||
});
|
||||
|
||||
it("accepts left/top aliases and corner points", () => {
|
||||
const image = makeImage();
|
||||
expect(parseRegion('{"left":5,"top":6,"width":7,"height":8}', image)).toEqual({ x: 5, y: 6, width: 7, height: 8 });
|
||||
expect(parseRegion('{"x":5,"y":6,"x2":15,"y2":16}', image)).toEqual({ x: 5, y: 6, width: 10, height: 10 });
|
||||
});
|
||||
|
||||
it("rejects malformed JSON and unknown shapes", () => {
|
||||
const image = makeImage();
|
||||
expect(() => parseRegion("not-json", image)).toThrow(/无法解析/);
|
||||
expect(() => parseRegion('{"foo":1}', image)).toThrow(/不符合要求/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("clampRegion", () => {
|
||||
it("clamps region into image bounds", () => {
|
||||
const image = makeImage(100, 80);
|
||||
expect(clampRegion({ x: -5, y: -5, width: 200, height: 200 }, image)).toEqual({ x: 0, y: 0, width: 100, height: 80 });
|
||||
expect(clampRegion({ x: 90, y: 70, width: 50, height: 50 }, image)).toEqual({ x: 90, y: 70, width: 10, height: 10 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("cropPng", () => {
|
||||
it("crops sub-image with expected size and pixel origin", () => {
|
||||
const image = makeImage();
|
||||
const cropped = cropPng(image, { x: 10, y: 20, width: 30, height: 40 });
|
||||
expect(cropped.width).toBe(30);
|
||||
expect(cropped.height).toBe(40);
|
||||
const index = (0 * 30 + 0) * 4;
|
||||
expect([cropped.data[index], cropped.data[index + 1], cropped.data[index + 2]]).toEqual([200, 100, 50]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("clickPoint", () => {
|
||||
const region = { x: 100, y: 200, width: 60, height: 40, score: 1 };
|
||||
|
||||
it("defaults to center", () => {
|
||||
expect(clickPoint(region, "")).toEqual({ x: 130, y: 220 });
|
||||
expect(clickPoint(region, "center")).toEqual({ x: 130, y: 220 });
|
||||
});
|
||||
|
||||
it("supports nine-grid anchors", () => {
|
||||
expect(clickPoint(region, "leftTop")).toEqual({ x: 100, y: 200 });
|
||||
expect(clickPoint(region, "rightBottom")).toEqual({ x: 160, y: 240 });
|
||||
expect(clickPoint(region, "top")).toEqual({ x: 130, y: 200 });
|
||||
expect(clickPoint(region, "left")).toEqual({ x: 100, y: 220 });
|
||||
});
|
||||
|
||||
it("supports custom offset JSON", () => {
|
||||
expect(clickPoint(region, '{"x":5,"y":6}')).toEqual({ x: 105, y: 206 });
|
||||
});
|
||||
|
||||
it("falls back to center for unknown values", () => {
|
||||
expect(clickPoint(region, "bogus")).toEqual({ x: 130, y: 220 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("legacy position aliases", () => {
|
||||
it("accepts kebab-case anchors saved by older catalogs", () => {
|
||||
const region = { x: 100, y: 200, width: 60, height: 40, score: 1 };
|
||||
expect(clickPoint(region, "left-top")).toEqual({ x: 100, y: 200 });
|
||||
expect(clickPoint(region, "right-top")).toEqual({ x: 160, y: 200 });
|
||||
expect(clickPoint(region, "left-bottom")).toEqual({ x: 100, y: 240 });
|
||||
expect(clickPoint(region, "right-bottom")).toEqual({ x: 160, y: 240 });
|
||||
});
|
||||
|
||||
it("still falls back to center for unknown anchors", () => {
|
||||
const region = { x: 100, y: 200, width: 60, height: 40, score: 1 };
|
||||
expect(clickPoint(region, "middle")).toEqual({ x: 130, y: 220 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { PNG } from "pngjs";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { findImage } from "./vision";
|
||||
|
||||
function makeScreen(width: number, height: number, patch: { x: number; y: number; w: number; h: number }): PNG {
|
||||
const png = new PNG({ width, height });
|
||||
for (let y = 0; y < height; y++) {
|
||||
for (let x = 0; x < width; x++) {
|
||||
const i = (y * width + x) * 4;
|
||||
const inPatch = x >= patch.x && x < patch.x + patch.w && y >= patch.y && y < patch.y + patch.h;
|
||||
// 背景: 平滑斜向渐变(贴近真实界面); 补丁: 纯红色
|
||||
png.data[i] = inPatch ? 220 : (x + y) % 90;
|
||||
png.data[i + 1] = inPatch ? 30 : (x + y + 30) % 90;
|
||||
png.data[i + 2] = inPatch ? 40 : (x + y + 60) % 90;
|
||||
png.data[i + 3] = 255;
|
||||
}
|
||||
}
|
||||
return png;
|
||||
}
|
||||
|
||||
function crop(source: PNG, x: number, y: number, w: number, h: number): PNG {
|
||||
const out = new PNG({ width: w, height: h });
|
||||
PNG.bitblt(source, out, x, y, w, h, 0, 0);
|
||||
return out;
|
||||
}
|
||||
|
||||
describe("findImage 匹配质量", () => {
|
||||
it("相似度0.9时精确命中模板位置且不误报", async () => {
|
||||
const patch = { x: 123, y: 77, w: 48, h: 36 };
|
||||
const screen = makeScreen(400, 300, patch);
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "vision-"));
|
||||
const screenPath = path.join(dir, "screen.png");
|
||||
const templatePath = path.join(dir, "template.png");
|
||||
fs.writeFileSync(screenPath, PNG.sync.write(screen));
|
||||
fs.writeFileSync(templatePath, PNG.sync.write(crop(screen, patch.x, patch.y, patch.w, patch.h)));
|
||||
// 相似度 0.9 → 最大差异 0.1
|
||||
const result = await findImage(templatePath, screenPath, 0.1);
|
||||
expect(result).not.toBeNull();
|
||||
// 采样步长落在整数边界时, 相邻1px偏移可能同样满分, 允许±1px
|
||||
expect(Math.abs(result!.x - patch.x)).toBeLessThanOrEqual(1);
|
||||
expect(Math.abs(result!.y - patch.y)).toBeLessThanOrEqual(1);
|
||||
expect(result!.score).toBeGreaterThanOrEqual(0.9);
|
||||
// 无该补丁的屏幕应返回 null 而非乱命中
|
||||
const other = makeScreen(400, 300, { x: 0, y: 0, w: 1, h: 1 });
|
||||
const otherPath = path.join(dir, "other.png");
|
||||
fs.writeFileSync(otherPath, PNG.sync.write(other));
|
||||
const miss = await findImage(templatePath, otherPath, 0.1);
|
||||
expect(miss).toBeNull();
|
||||
});
|
||||
|
||||
it("模板被缩放(1.25倍)时按尺度兜底命中", async () => {
|
||||
const patch = { x: 200, y: 120, w: 60, h: 40 };
|
||||
const screen = makeScreen(500, 350, patch);
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "vision-"));
|
||||
const screenPath = path.join(dir, "screen.png");
|
||||
fs.writeFileSync(screenPath, PNG.sync.write(screen));
|
||||
// 模板取自 DIP 截图(比物理屏小1.25倍): 把补丁缩小1/1.25后存为模板
|
||||
const { createCanvas, loadImage } = await import("@napi-rs/canvas");
|
||||
const small = crop(screen, patch.x, patch.y, patch.w, patch.h);
|
||||
const srcCanvas = createCanvas(small.width, small.height);
|
||||
const srcCtx = srcCanvas.getContext("2d");
|
||||
const imgData = srcCtx.createImageData(small.width, small.height);
|
||||
imgData.data.set(small.data);
|
||||
srcCtx.putImageData(imgData, 0, 0);
|
||||
const dstCanvas = createCanvas(Math.round(small.width / 1.25), Math.round(small.height / 1.25));
|
||||
const dstCtx = dstCanvas.getContext("2d");
|
||||
dstCtx.drawImage(srcCanvas, 0, 0, dstCanvas.width, dstCanvas.height);
|
||||
// canvas → pngjs PNG(PNG.sync.write 只认 pngjs 对象)
|
||||
const scaledData = dstCtx.getImageData(0, 0, dstCanvas.width, dstCanvas.height);
|
||||
const templatePng = new PNG({ width: dstCanvas.width, height: dstCanvas.height });
|
||||
templatePng.data = Buffer.from(scaledData.data);
|
||||
const templatePath = path.join(dir, "template-small.png");
|
||||
fs.writeFileSync(templatePath, PNG.sync.write(templatePng));
|
||||
const result = await findImage(templatePath, screenPath, 0.1, [1.25, 1 / 1.25]);
|
||||
expect(result).not.toBeNull();
|
||||
expect(Math.abs(result!.x - patch.x)).toBeLessThanOrEqual(3);
|
||||
expect(Math.abs(result!.y - patch.y)).toBeLessThanOrEqual(3);
|
||||
void loadImage;
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,557 @@
|
||||
import path from "node:path";
|
||||
import { promises as fs } from "node:fs";
|
||||
import { PNG } from "pngjs";
|
||||
import { register } from "../engine/registry";
|
||||
import { RuntimeError } from "../engine/types";
|
||||
import { powershell, psString } from "./desktop";
|
||||
|
||||
function text(args: Record<string, unknown>, keys: string[], fallback = ""): string {
|
||||
for (const key of keys) if (args[key] !== undefined && args[key] !== null && args[key] !== "") return String(args[key]);
|
||||
return fallback;
|
||||
}
|
||||
|
||||
/** 全虚拟桌面(所有显示器拼接)物理分辨率截图。
|
||||
* desktopCapturer thumbnailSize 乘 scaleFactor 与「屏幕框选」模板(物理像素裁剪)尺度一致;
|
||||
* 逐屏截图后按各自物理原点拼到一张画布上——目标在副屏也能搜到。
|
||||
* 返回 originX/originY = 拼接画布左上角对应的屏幕物理坐标(副屏在主屏左侧时为负),用于结果坐标平移。 */
|
||||
async function captureVirtualScreenPng(): Promise<{ png: PNG; originX: number; originY: number }> {
|
||||
const { desktopCapturer, screen } = await import("electron");
|
||||
const displays = screen.getAllDisplays();
|
||||
if (displays.length === 0) throw new RuntimeError("desktop_error", "无法获取屏幕信息");
|
||||
// 每屏按自己的物理分辨率截图(display_id 匹配 → 缩略图尺寸匹配 → 按物理原点排序对位)
|
||||
const captures: { display: Electron.Display; origin: { x: number; y: number }; png: PNG }[] = [];
|
||||
for (const display of displays) {
|
||||
const scale = display.scaleFactor || 1;
|
||||
const wantW = Math.round(display.bounds.width * scale);
|
||||
const wantH = Math.round(display.bounds.height * scale);
|
||||
const sources = await desktopCapturer.getSources({
|
||||
types: ["screen"],
|
||||
thumbnailSize: { width: wantW, height: wantH }
|
||||
});
|
||||
const source = sources.find(item => item.display_id === String(display.id))
|
||||
|| sources.find(item => item.thumbnail.getSize().width === wantW)
|
||||
|| sources[0];
|
||||
if (!source || source.thumbnail.isEmpty()) {
|
||||
throw new RuntimeError("desktop_error", "无法获取屏幕截图");
|
||||
}
|
||||
// 显示器 DIP 原点的物理坐标 = 该屏物理原点(屏内线性映射: 物理 = (DIP-原点)*缩放 + 物理原点)
|
||||
const origin = screen.dipToScreenPoint({ x: display.bounds.x, y: display.bounds.y });
|
||||
captures.push({ display, origin, png: PNG.sync.read(source.thumbnail.toPNG()) });
|
||||
}
|
||||
const originX = Math.min(...captures.map(c => c.origin.x));
|
||||
const originY = Math.min(...captures.map(c => c.origin.y));
|
||||
const endX = Math.max(...captures.map(c => c.origin.x + c.png.width));
|
||||
const endY = Math.max(...captures.map(c => c.origin.y + c.png.height));
|
||||
const width = endX - originX;
|
||||
const height = endY - originY;
|
||||
const { createCanvas } = await import("@napi-rs/canvas");
|
||||
const canvas = createCanvas(width, height);
|
||||
const context = canvas.getContext("2d");
|
||||
for (const capture of captures) {
|
||||
const sourceCanvas = createCanvas(capture.png.width, capture.png.height);
|
||||
const sourceContext = sourceCanvas.getContext("2d");
|
||||
const imageData = sourceContext.createImageData(capture.png.width, capture.png.height);
|
||||
imageData.data.set(capture.png.data);
|
||||
sourceContext.putImageData(imageData, 0, 0);
|
||||
context.drawImage(sourceCanvas, capture.origin.x - originX, capture.origin.y - originY);
|
||||
}
|
||||
const stitched = context.getImageData(0, 0, width, height);
|
||||
const png = new PNG({ width, height });
|
||||
png.data = Buffer.from(stitched.data);
|
||||
return { png, originX, originY };
|
||||
}
|
||||
|
||||
/** DIP 区域 → 物理像素区域:左上角经 dipToScreenPoint 换算(天然支持副屏/负坐标),宽高乘所在屏缩放比 */
|
||||
async function dipRegionToPhysical(region: { x: number; y: number; width: number; height: number }): Promise<{ x: number; y: number; width: number; height: number }> {
|
||||
const { screen } = await import("electron");
|
||||
const topLeft = screen.dipToScreenPoint({ x: Math.round(region.x), y: Math.round(region.y) });
|
||||
const display = screen.getDisplayMatching({
|
||||
x: region.x, y: region.y,
|
||||
width: Math.max(1, Math.round(region.width)), height: Math.max(1, Math.round(region.height))
|
||||
});
|
||||
const scale = display.scaleFactor || 1;
|
||||
return {
|
||||
x: topLeft.x,
|
||||
y: topLeft.y,
|
||||
width: Math.max(1, Math.round(region.width * scale)),
|
||||
height: Math.max(1, Math.round(region.height * scale))
|
||||
};
|
||||
}
|
||||
|
||||
/** 按魔数判断是否 PNG(89 50 4E 47) */
|
||||
function isPngBuffer(buffer: Buffer): boolean {
|
||||
return buffer.length > 8 && buffer[0] === 0x89 && buffer[1] === 0x50 && buffer[2] === 0x4e && buffer[3] === 0x47;
|
||||
}
|
||||
|
||||
/** 解码用户提供的图片文件为 pngjs PNG。pngjs 只认 PNG, 模板/输入图常是 JPG——
|
||||
* 非 PNG 时走 @napi-rs/canvas 解码(JPG/BMP/WebP 等), 否则报 "unrecognised content at end of stream" */
|
||||
async function decodeImageFile(filePath: string): Promise<PNG> {
|
||||
let buffer: Buffer;
|
||||
try {
|
||||
buffer = await fs.readFile(filePath);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException)?.code === "ENOENT") {
|
||||
throw new RuntimeError("validation_error", `找不到图片文件:${filePath}`, {
|
||||
recommendation: "确认路径正确,或直接点参数旁的「屏幕框选」截取一张"
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
if (isPngBuffer(buffer)) return PNG.sync.read(buffer);
|
||||
const { loadImage, createCanvas } = await import("@napi-rs/canvas");
|
||||
const image = await loadImage(buffer);
|
||||
if (!image.width || !image.height) {
|
||||
throw new RuntimeError("validation_error", "图片解码失败", { detail: filePath });
|
||||
}
|
||||
const canvas = createCanvas(image.width, image.height);
|
||||
const context = canvas.getContext("2d");
|
||||
context.drawImage(image, 0, 0);
|
||||
const imageData = context.getImageData(0, 0, image.width, image.height);
|
||||
const png = new PNG({ width: image.width, height: image.height });
|
||||
png.data = Buffer.from(imageData.data);
|
||||
return png;
|
||||
}
|
||||
|
||||
/** 解析区域 JSON:对象 {x,y,width,height}(兼容 left/top、x1y1x2y2 角点)或数组 [x,y,width,height],并夹取到图片边界内 */
|
||||
export function parseRegion(raw: string, image: PNG): { x: number; y: number; width: number; height: number } {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
throw new RuntimeError("validation_error", "区域 JSON 无法解析", { detail: raw.slice(0, 200) });
|
||||
}
|
||||
const numberOf = (value: unknown): number | null => {
|
||||
const n = Number(value);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
};
|
||||
if (Array.isArray(parsed) && parsed.length === 4) {
|
||||
const [x, y, width, height] = parsed.map(numberOf);
|
||||
if (x !== null && y !== null && width !== null && height !== null) {
|
||||
return clampRegion({ x, y, width, height }, image);
|
||||
}
|
||||
}
|
||||
if (parsed && typeof parsed === "object") {
|
||||
const record = parsed as Record<string, unknown>;
|
||||
const x = numberOf(record.x ?? record.left);
|
||||
const y = numberOf(record.y ?? record.top);
|
||||
const width = numberOf(record.width);
|
||||
const height = numberOf(record.height);
|
||||
if (x !== null && y !== null && width !== null && height !== null) {
|
||||
return clampRegion({ x, y, width, height }, image);
|
||||
}
|
||||
const x2 = numberOf(record.x2 ?? record.right);
|
||||
const y2 = numberOf(record.y2 ?? record.bottom);
|
||||
if (x !== null && y !== null && x2 !== null && y2 !== null) {
|
||||
return clampRegion({ x, y, width: x2 - x, height: y2 - y }, image);
|
||||
}
|
||||
}
|
||||
throw new RuntimeError("validation_error", "区域 JSON 不符合要求", {
|
||||
detail: raw.slice(0, 200),
|
||||
recommendation: '使用 {"x":0,"y":0,"width":100,"height":80} 或 [x, y, width, height]'
|
||||
});
|
||||
}
|
||||
|
||||
export function clampRegion(region: { x: number; y: number; width: number; height: number }, image: PNG): { x: number; y: number; width: number; height: number } {
|
||||
const x = Math.max(0, Math.min(Math.round(region.x), image.width - 1));
|
||||
const y = Math.max(0, Math.min(Math.round(region.y), image.height - 1));
|
||||
const width = Math.max(1, Math.min(Math.round(region.width), image.width - x));
|
||||
const height = Math.max(1, Math.min(Math.round(region.height), image.height - y));
|
||||
return { x, y, width, height };
|
||||
}
|
||||
|
||||
export function cropPng(source: PNG, region: { x: number; y: number; width: number; height: number }): PNG {
|
||||
const cropped = new PNG({ width: region.width, height: region.height });
|
||||
PNG.bitblt(source, cropped, region.x, region.y, region.width, region.height, 0, 0);
|
||||
return cropped;
|
||||
}
|
||||
|
||||
/** 目标路径容错:填的是文件夹(已存在或以分隔符结尾)时,自动往里存默认文件名 */
|
||||
async function resolveOutputPath(outputPath: string, defaultName: string): Promise<string> {
|
||||
if (/[\\/]$/.test(outputPath)) return path.join(outputPath, defaultName);
|
||||
try {
|
||||
const stat = await fs.stat(outputPath);
|
||||
if (stat.isDirectory()) return path.join(outputPath, defaultName);
|
||||
} catch {
|
||||
// 路径不存在说明是要新建的文件,原样返回
|
||||
}
|
||||
return outputPath;
|
||||
}
|
||||
|
||||
register("Image_Screenshot", async (ctx, node, args) => {
|
||||
const folder = text(args, ["outImagePath", "folder"]);
|
||||
const name = text(args, ["outImageName", "name"], `screenshot_${Date.now()}.png`);
|
||||
const imageArg = text(args, ["image_path"]);
|
||||
const source = text(args, ["pictureSource"], "1");
|
||||
const range = text(args, ["shotRange"], "1");
|
||||
|
||||
let image: PNG;
|
||||
let output: string;
|
||||
let screenOrigin: { x: number; y: number } | null = null;
|
||||
if (source === "2") {
|
||||
// 指定图片文件来源:image_path 为输入图片;输出走 输出目录+文件名,两者均未配置时直接返回源文件
|
||||
if (!imageArg.trim()) {
|
||||
throw new RuntimeError("validation_error", "图像来源为「指定图片文件」时,截图保存路径需填写源图片路径", {
|
||||
recommendation: "在截图保存路径选择一张已存在的图片,或将图像来源改回「当前屏幕」"
|
||||
});
|
||||
}
|
||||
const sourceFile = path.resolve(imageArg);
|
||||
image = await decodeImageFile(sourceFile);
|
||||
output = folder ? path.resolve(path.join(folder, name)) : sourceFile;
|
||||
} else {
|
||||
// 当前屏幕来源:image_path 即保存路径(文件夹则自动追加默认文件名),缺省用 输出目录+文件名;再缺省落流程证据目录(路径可预期)
|
||||
// 截取整个虚拟桌面(含副屏),物理分辨率
|
||||
const shot = await captureVirtualScreenPng();
|
||||
image = shot.png;
|
||||
screenOrigin = { x: shot.originX, y: shot.originY };
|
||||
output = await resolveOutputPath(
|
||||
path.resolve(imageArg || (folder ? path.join(folder, name) : path.join(ctx.evidenceDir, name))),
|
||||
name
|
||||
);
|
||||
}
|
||||
|
||||
if (range === "2") {
|
||||
const regionRaw = text(args, ["position", "action", "position_img"]);
|
||||
if (!regionRaw.trim()) {
|
||||
throw new RuntimeError("validation_error", "截图范围为「框选区域」时,截图区域需填写区域 JSON", {
|
||||
recommendation: '在「截图区域」点屏幕框选,或填写 {"x":0,"y":0,"width":100,"height":80},或将截图范围改回「整个屏幕」'
|
||||
});
|
||||
}
|
||||
if (screenOrigin) {
|
||||
// 屏幕来源: 区域 JSON 是 DIP 坐标(支持副屏),换算物理坐标后减去拼接画布原点再裁剪
|
||||
const phys = await dipRegionToPhysical(parseRegion(regionRaw, image));
|
||||
image = cropPng(image, clampRegion({
|
||||
x: phys.x - screenOrigin.x,
|
||||
y: phys.y - screenOrigin.y,
|
||||
width: phys.width,
|
||||
height: phys.height
|
||||
}, image));
|
||||
} else {
|
||||
// 图片文件来源: 区域按该图片自身像素坐标
|
||||
image = cropPng(image, parseRegion(regionRaw, image));
|
||||
}
|
||||
}
|
||||
|
||||
await fs.mkdir(path.dirname(output), { recursive: true });
|
||||
await fs.writeFile(output, PNG.sync.write(image));
|
||||
return output;
|
||||
});
|
||||
|
||||
interface MatchResult { x: number; y: number; width: number; height: number; score: number; }
|
||||
|
||||
interface WindowRect { x: number; y: number; width: number; height: number }
|
||||
|
||||
/** 按标题(精确→包含)或进程名查找窗口,前置并返回窗口矩形(物理屏幕坐标) */
|
||||
async function focusWindowRect(windowObj: string): Promise<WindowRect> {
|
||||
const quoted = psString(windowObj);
|
||||
const stdout = await powershell(`Add-Type @'
|
||||
using System; using System.Runtime.InteropServices;
|
||||
public static class WinRect {
|
||||
[DllImport("user32.dll")] public static extern bool SetProcessDpiAwarenessContext(IntPtr value);
|
||||
[DllImport("user32.dll")] public static extern IntPtr FindWindow(string cls, string title);
|
||||
[DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr h);
|
||||
[DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr h, int cmd);
|
||||
[DllImport("user32.dll")] public static extern bool GetWindowRect(IntPtr h, out RECT r);
|
||||
public struct RECT { public int Left; public int Top; public int Right; public int Bottom; }
|
||||
}
|
||||
'@;
|
||||
# 不声明 DPI 感知时 GetWindowRect 返回虚拟化坐标, 与物理分辨率截图对不上
|
||||
try { [void][WinRect]::SetProcessDpiAwarenessContext([IntPtr](-4)) } catch {};
|
||||
$h = [WinRect]::FindWindow($null, ${quoted});
|
||||
if ($h -eq [IntPtr]::Zero) { $p = Get-Process | Where-Object { $_.MainWindowTitle -and ($_.MainWindowTitle -eq ${quoted} -or $_.MainWindowTitle.Contains(${quoted})) } | Select-Object -First 1; if ($p) { $h = $p.MainWindowHandle } }
|
||||
if ($h -eq [IntPtr]::Zero) { Write-Output 'NOT_FOUND'; exit }
|
||||
[WinRect]::ShowWindow($h, 9) | Out-Null;
|
||||
[WinRect]::SetForegroundWindow($h) | Out-Null;
|
||||
Start-Sleep -Milliseconds 300;
|
||||
$r = New-Object WinRect+RECT;
|
||||
[WinRect]::GetWindowRect($h, [ref]$r) | Out-Null;
|
||||
Write-Output ("{0},{1},{2},{3}" -f $r.Left, $r.Top, $r.Right, $r.Bottom)`);
|
||||
const line = stdout.trim().split(/\r?\n/).pop() ?? "";
|
||||
if (line === "NOT_FOUND") {
|
||||
throw new RuntimeError("not_found", `未找到窗口「${windowObj}」`, { recommendation: "确认窗口已打开,窗口对象可填完整标题、标题包含关键字或进程名" });
|
||||
}
|
||||
const parts = line.split(",").map(Number);
|
||||
if (parts.length !== 4 || parts.some((v) => !Number.isFinite(v))) {
|
||||
throw new RuntimeError("desktop_error", "窗口矩形获取失败", { detail: line.slice(0, 120) });
|
||||
}
|
||||
const [left, top, right, bottom] = parts;
|
||||
return { x: left, y: top, width: Math.max(1, right - left), height: Math.max(1, bottom - top) };
|
||||
}
|
||||
|
||||
/** 单通道噪声死区:JPG 压缩伪影/抗锯齿带来 ±10 级内的像素波动,不应计入差异 */
|
||||
const NOISE_FLOOR = 10;
|
||||
|
||||
function pixelDifference(screen: PNG, template: PNG, offsetX: number, offsetY: number, sampleStep: number, cutoff: number): number {
|
||||
let difference = 0;
|
||||
let samples = 0;
|
||||
for (let y = 0; y < template.height; y += sampleStep) {
|
||||
for (let x = 0; x < template.width; x += sampleStep) {
|
||||
const sourceIndex = ((offsetY + y) * screen.width + offsetX + x) * 4;
|
||||
const templateIndex = (y * template.width + x) * 4;
|
||||
for (let c = 0; c < 3; c++) {
|
||||
const d = Math.abs(screen.data[sourceIndex + c] - template.data[templateIndex + c]);
|
||||
difference += d <= NOISE_FLOOR ? 0 : d - NOISE_FLOOR;
|
||||
}
|
||||
samples += 3;
|
||||
// cutoff 是 0~1 的归一化差异上限; difference 是通道差累加值(0~255 每通道),
|
||||
// 提前退出必须按 255 折算——之前漏乘导致几乎所有位置在第一个像素就被剪掉, 得分全乱
|
||||
if (difference > cutoff * samples * 255) return difference / (samples * 255);
|
||||
}
|
||||
}
|
||||
return samples ? difference / (samples * 255) : 1;
|
||||
}
|
||||
|
||||
/** 在屏幕图里扫描模板的最佳位置(粗扫网格 + 精化) */
|
||||
function scanBest(screen: PNG, template: PNG, maximumDifference: number): MatchResult | null {
|
||||
const sampleStep = Math.max(1, Math.floor(Math.min(template.width, template.height) / 18));
|
||||
const scanStep = Math.max(1, Math.floor(Math.min(template.width, template.height) / 12));
|
||||
let best: MatchResult | null = null;
|
||||
for (let y = 0; y <= screen.height - template.height; y += scanStep) {
|
||||
for (let x = 0; x <= screen.width - template.width; x += scanStep) {
|
||||
// 尚无 best 时不剪枝(完整评估): 若初始就按 maximumDifference 剪枝,
|
||||
// 均匀背景位置在少数采样后返回"刚好压线"的部分均值抢占 best,
|
||||
// 而真位置(常不在网格上)附近的网格点因部分重叠被剪, 精化永远够不到——
|
||||
// 相似度 0.95 时实测 score=1.0 的真位置被判"未达到阈值"
|
||||
const cutoff = best ? 1 - best.score : 1;
|
||||
const difference = pixelDifference(screen, template, x, y, sampleStep, cutoff);
|
||||
const score = 1 - difference;
|
||||
if (!best || score > best.score) best = { x, y, width: template.width, height: template.height, score };
|
||||
}
|
||||
}
|
||||
if (!best) return null;
|
||||
// 注意: 阈值判定必须放在精化之后——粗扫网格(步长=模板短边/12)通常够不到精确位置,
|
||||
// 粗扫 best 常差在阈值边缘, 精化后才能提到满分; 先判阈值会把真命中误杀
|
||||
const refine = Math.max(1, scanStep);
|
||||
const minX = Math.max(0, best.x - refine);
|
||||
const maxX = Math.min(screen.width - template.width, best.x + refine);
|
||||
const minY = Math.max(0, best.y - refine);
|
||||
const maxY = Math.min(screen.height - template.height, best.y + refine);
|
||||
for (let y = minY; y <= maxY; y += 1) {
|
||||
for (let x = minX; x <= maxX; x += 1) {
|
||||
// 精化区域只有几百个位置, 完整评估避免同样的剪枝偏置
|
||||
const difference = pixelDifference(screen, template, x, y, sampleStep, 1);
|
||||
const score = 1 - difference;
|
||||
if (score > best.score) best = { x, y, width: template.width, height: template.height, score };
|
||||
}
|
||||
}
|
||||
// 阈值判定: 精化后的最终得分才与 maximumDifference 比较
|
||||
if (1 - best.score > maximumDifference) return null;
|
||||
return best;
|
||||
}
|
||||
|
||||
/** 用 canvas 双线性缩放模板(匹配尺度兜底用) */
|
||||
async function resizeTemplate(template: PNG, scale: number): Promise<PNG> {
|
||||
const { createCanvas } = await import("@napi-rs/canvas");
|
||||
const width = Math.max(1, Math.round(template.width * scale));
|
||||
const height = Math.max(1, Math.round(template.height * scale));
|
||||
const source = createCanvas(template.width, template.height);
|
||||
const sourceContext = source.getContext("2d");
|
||||
const imageData = sourceContext.createImageData(template.width, template.height);
|
||||
imageData.data.set(template.data);
|
||||
sourceContext.putImageData(imageData, 0, 0);
|
||||
const target = createCanvas(width, height);
|
||||
const targetContext = target.getContext("2d");
|
||||
targetContext.imageSmoothingEnabled = true;
|
||||
targetContext.imageSmoothingQuality = "high";
|
||||
targetContext.drawImage(source, 0, 0, width, height);
|
||||
const scaled = targetContext.getImageData(0, 0, width, height);
|
||||
const png = new PNG({ width, height });
|
||||
png.data = Buffer.from(scaled.data);
|
||||
return png;
|
||||
}
|
||||
|
||||
/**
|
||||
* 模板匹配。extraScales 提供尺度兜底:截图与模板的获取渠道分辨率不一致时
|
||||
* (如旧版 DIP 截图存下的模板 vs 现在的物理分辨率截图),按给定缩放比重试。
|
||||
*/
|
||||
export async function findImage(templatePath: string, screenshotPath: string, accuracy: number, extraScales: number[] = []): Promise<MatchResult | null> {
|
||||
const [screenBuffer, templateBuffer] = await Promise.all([fs.readFile(screenshotPath), fs.readFile(templatePath)]);
|
||||
const screen = PNG.sync.read(screenBuffer);
|
||||
// 模板图可能是 JPG 等非 PNG 格式, 按魔数选择解码器
|
||||
const template = isPngBuffer(templateBuffer) ? PNG.sync.read(templateBuffer) : await decodeImageFile(templatePath);
|
||||
if (template.width > screen.width || template.height > screen.height) {
|
||||
throw new RuntimeError("validation_error", `模板图片(${template.width}×${template.height})比截图画面(${screen.width}×${screen.height})还大,无法查找`, {
|
||||
recommendation: "模板应是屏幕上要寻找的小图案(按钮、图标、文字块),不要用整屏大图"
|
||||
});
|
||||
}
|
||||
const maximumDifference = Math.max(0.01, Math.min(0.95, accuracy || 0.2));
|
||||
const best = scanBest(screen, template, maximumDifference);
|
||||
if (best) return best;
|
||||
for (const scale of extraScales) {
|
||||
if (!Number.isFinite(scale) || scale === 1) continue;
|
||||
const scaled = await resizeTemplate(template, scale);
|
||||
if (scaled.width > screen.width || scaled.height > screen.height) continue;
|
||||
const scaledBest = scanBest(screen, scaled, maximumDifference);
|
||||
if (scaledBest) return scaledBest;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function locate(args: Record<string, unknown>, evidenceDir: string, nodeId: string): Promise<MatchResult> {
|
||||
const rawTemplate = text(args, ["image", "image_path", "template"]);
|
||||
if (!rawTemplate.trim()) throw new RuntimeError("validation_error", "模板图片路径为空", { nodeId });
|
||||
const template = path.resolve(rawTemplate);
|
||||
|
||||
// 查找范围:1 整个虚拟桌面(默认,含副屏)/ 2 指定窗口 / 3 指定区域;
|
||||
// 截图统一为物理分辨率拼接画布, 结果坐标加回画布原点即屏幕物理坐标(可直接用于点击)
|
||||
const rangeMode = text(args, ["find_range"], "1");
|
||||
const screenshot = path.join(evidenceDir, `${nodeId}_screen.png`);
|
||||
const shot = await captureVirtualScreenPng();
|
||||
let offsetX = 0;
|
||||
let offsetY = 0;
|
||||
if (rangeMode === "2") {
|
||||
const windowObj = text(args, ["window_obj"]);
|
||||
if (!windowObj.trim()) throw new RuntimeError("validation_error", "查找范围为「指定窗口」时,窗口对象不能为空", { nodeId, recommendation: "填写窗口标题(或标题关键字、进程名)" });
|
||||
const rect = await focusWindowRect(windowObj);
|
||||
const cropRect = clampRegion({ x: rect.x - shot.originX, y: rect.y - shot.originY, width: rect.width, height: rect.height }, shot.png);
|
||||
await fs.writeFile(screenshot, PNG.sync.write(cropPng(shot.png, cropRect)));
|
||||
offsetX = rect.x;
|
||||
offsetY = rect.y;
|
||||
} else if (rangeMode === "3" || rangeMode === "4") {
|
||||
const regionRaw = text(args, ["shot_position", "action", "position"]);
|
||||
if (!regionRaw.trim()) throw new RuntimeError("validation_error", "查找范围为「指定区域」时,需要填写区域 JSON", { nodeId, recommendation: '在 shot_position 填写 {"x":0,"y":0,"width":100,"height":80} 或 [x, y, width, height]' });
|
||||
// 区域 JSON 是 DIP 坐标(框选工具按 DIP 返回, 支持副屏负坐标),换算物理坐标后减画布原点裁剪
|
||||
const phys = await dipRegionToPhysical(parseRegion(regionRaw, shot.png));
|
||||
const cropRect = clampRegion({ x: phys.x - shot.originX, y: phys.y - shot.originY, width: phys.width, height: phys.height }, shot.png);
|
||||
await fs.writeFile(screenshot, PNG.sync.write(cropPng(shot.png, cropRect)));
|
||||
offsetX = phys.x;
|
||||
offsetY = phys.y;
|
||||
} else {
|
||||
await fs.writeFile(screenshot, PNG.sync.write(shot.png));
|
||||
// 拼接画布坐标 → 屏幕物理坐标必须加回画布原点(副屏在主屏左侧/上方时原点为负);
|
||||
// 漏加会导致点击偏移(实测副屏上移 177px 时点击正好偏下 177px)
|
||||
offsetX = shot.originX;
|
||||
offsetY = shot.originY;
|
||||
}
|
||||
|
||||
// accuracy 字段语义为「相似度」(0~1, 越大越严格, 0.9=至少90%像素一致)——与界面文案一致。
|
||||
// 引擎内部用最大允许差异 = 1 - 相似度。此前引擎直接把该值当差异阈值,
|
||||
// 用户填 0.9 反而放宽到"接受90%像素差异", 导致到处误命中。
|
||||
const rawAccuracy = Number(text(args, ["accuracy"], "0.8"));
|
||||
const similarity = Math.max(0.05, Math.min(1, Number.isFinite(rawAccuracy) && rawAccuracy > 0 ? rawAccuracy : 0.8));
|
||||
// 原生 1:1 匹配失败时, 按缩放比对模板放大/缩小重试——兼容旧版 DIP 截图存的模板与任意来源的图片
|
||||
const scale = (await import("electron")).screen.getPrimaryDisplay().scaleFactor || 1;
|
||||
const result = await findImage(template, screenshot, 1 - similarity, scale !== 1 ? [scale, 1 / scale] : []);
|
||||
if (!result) throw new RuntimeError("not_found", `屏幕中未找到目标图像(要求相似度 ${(similarity * 100).toFixed(0)}%,未达到阈值)`, { nodeId, recommendation: "重新框选模板(框小一点、只框目标图案),或适当调低相似度(如 0.8);目标在副屏时确认其清晰可见" });
|
||||
return { ...result, x: result.x + offsetX, y: result.y + offsetY };
|
||||
}
|
||||
|
||||
register(["Image_Find", "Image_Find_V1"], async (ctx, node, args) => locate(args, ctx.evidenceDir, node.id));
|
||||
// 图像位置: 与查找图像同义(历史重复组件), 同管线返回坐标
|
||||
for (const id of ["Image_Location", "Image_Location_V1"]) register(id, async (ctx, node, args) => locate(args, ctx.evidenceDir, node.id));
|
||||
// 判断图像: 找到返回 true / 找不到返回 false(供条件分支), 仅有 not_found 转为 false;
|
||||
// 模板路径为空/文件不存在等配置错误仍然抛出, 避免静默 false 难排查。
|
||||
// timeout>0 时在窗口内每 300ms 重试(等待图像出现再判断的场景)
|
||||
for (const id of ["Image_Exists", "Image_Exists_V1"]) register(id, async (ctx, node, args) => {
|
||||
const rawTimeout = Number(text(args, ["timeout", "time_out"], "0"));
|
||||
const deadline = Date.now() + (Number.isFinite(rawTimeout) && rawTimeout > 0 ? rawTimeout : 0) * 1000;
|
||||
for (;;) {
|
||||
try {
|
||||
await locate(args, ctx.evidenceDir, node.id);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (error instanceof RuntimeError && error.code === "not_found") {
|
||||
if (Date.now() >= deadline) return false;
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, 300));
|
||||
}
|
||||
});
|
||||
// 等待图像: 在超时窗口内每 300ms 重试一次查找, 命中即返回坐标, 超时仍无则抛 not_found
|
||||
for (const id of ["Image_Wait", "Image_Wait_V1"]) register(id, async (ctx, node, args) => {
|
||||
const rawTimeout = Number(text(args, ["timeout", "time_out"], "20"));
|
||||
const timeout = Number.isFinite(rawTimeout) && rawTimeout >= 0 ? rawTimeout : 20;
|
||||
const deadline = Date.now() + timeout * 1000;
|
||||
for (;;) {
|
||||
try {
|
||||
return await locate(args, ctx.evidenceDir, node.id);
|
||||
} catch (error) {
|
||||
if (!(error instanceof RuntimeError) || error.code !== "not_found" || Date.now() >= deadline) throw error;
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, 300));
|
||||
}
|
||||
});
|
||||
// 移动鼠标到图像: 定位后把光标移到匹配区域中心;
|
||||
// SetCursorPos 传物理坐标, 脚本必须声明 per-monitor DPI 感知(与 injectClick 同理), 否则被系统虚拟化导致偏移
|
||||
for (const id of ["Image_Move", "Image_Move_V1", "Image_Move_V2"]) register(id, async (ctx, node, args) => {
|
||||
const hit = await locate(args, ctx.evidenceDir, node.id);
|
||||
const point = clickPoint(hit, text(args, ["position"], "center"));
|
||||
const x = point.x;
|
||||
const y = point.y;
|
||||
await powershell("Add-Type -MemberDefinition '[DllImport(\"user32.dll\")] public static extern bool SetProcessDpiAwarenessContext(IntPtr value); [DllImport(\"user32.dll\")] public static extern bool SetCursorPos(int X, int Y);' -Name RpaImageMoveInput -Namespace RpaImageMove | Out-Null; try { [void][RpaImageMove.RpaImageMoveInput]::SetProcessDpiAwarenessContext([IntPtr](-4)) } catch {}; [RpaImageMove.RpaImageMoveInput]::SetCursorPos(" + x + "," + y + ")");
|
||||
return { ...hit, x, y };
|
||||
});
|
||||
/** 点击位置锚点(相对匹配区域的比例坐标),支持九宫格取值 */
|
||||
const POSITION_ANCHORS: Record<string, [number, number]> = {
|
||||
center: [0.5, 0.5],
|
||||
leftTop: [0, 0], top: [0.5, 0], centerTop: [0.5, 0], rightTop: [1, 0],
|
||||
left: [0, 0.5], centerLeft: [0, 0.5], right: [1, 0.5], centerRight: [1, 0.5],
|
||||
leftBottom: [0, 1], bottom: [0.5, 1], centerBottom: [0.5, 1], rightBottom: [1, 1]
|
||||
};
|
||||
|
||||
export function clickPoint(result: MatchResult, position: string): { x: number; y: number } {
|
||||
const raw = position.trim();
|
||||
if (raw && raw.toLowerCase() !== "center") {
|
||||
if (raw.startsWith("{")) {
|
||||
try {
|
||||
const offset = JSON.parse(raw) as { x?: unknown; y?: unknown };
|
||||
const dx = Number(offset.x);
|
||||
const dy = Number(offset.y);
|
||||
if (Number.isFinite(dx) && Number.isFinite(dy)) {
|
||||
return { x: Math.round(result.x + dx), y: Math.round(result.y + dy) };
|
||||
}
|
||||
} catch {
|
||||
// 非法 JSON 落回中心
|
||||
}
|
||||
}
|
||||
const anchor = POSITION_ANCHORS[raw] ?? POSITION_ANCHORS[raw.toLowerCase()] ?? POSITION_ANCHORS[raw.replace(/-([a-z])/g, (_: string, char: string) => char.toUpperCase())];
|
||||
if (anchor) {
|
||||
return { x: Math.round(result.x + result.width * anchor[0]), y: Math.round(result.y + result.height * anchor[1]) };
|
||||
}
|
||||
}
|
||||
return { x: Math.round(result.x + result.width / 2), y: Math.round(result.y + result.height / 2) };
|
||||
}
|
||||
|
||||
const CLICK_ACTIONS: Record<string, string> = {
|
||||
left: "[Mouse]::mouse_event(2,0,0,0,[UIntPtr]::Zero); Start-Sleep -Milliseconds 30; [Mouse]::mouse_event(4,0,0,0,[UIntPtr]::Zero)",
|
||||
right: "[Mouse]::mouse_event(8,0,0,0,[UIntPtr]::Zero); Start-Sleep -Milliseconds 30; [Mouse]::mouse_event(16,0,0,0,[UIntPtr]::Zero)",
|
||||
middle: "[Mouse]::mouse_event(32,0,0,0,[UIntPtr]::Zero); Start-Sleep -Milliseconds 30; [Mouse]::mouse_event(64,0,0,0,[UIntPtr]::Zero)",
|
||||
double: "[Mouse]::mouse_event(2,0,0,0,[UIntPtr]::Zero); [Mouse]::mouse_event(4,0,0,0,[UIntPtr]::Zero); Start-Sleep -Milliseconds 60; [Mouse]::mouse_event(2,0,0,0,[UIntPtr]::Zero); [Mouse]::mouse_event(4,0,0,0,[UIntPtr]::Zero)"
|
||||
};
|
||||
|
||||
async function injectClick(x: number, y: number, clickKey: string, showTrack: boolean): Promise<void> {
|
||||
const action = CLICK_ACTIONS[clickKey] ?? CLICK_ACTIONS.left;
|
||||
// show_track=1 时从当前光标位置平滑移动过去(20 步插值),否则直接跳转
|
||||
const move = showTrack
|
||||
? `for ($i = 1; $i -le 20; $i++) { [Mouse]::SetCursorPos([int]($p.X + (${x} - $p.X) * $i / 20), [int]($p.Y + (${y} - $p.Y) * $i / 20)); Start-Sleep -Milliseconds 12 }`
|
||||
: `[Mouse]::SetCursorPos(${x}, ${y})`;
|
||||
await powershell(`Add-Type @'
|
||||
using System; using System.Runtime.InteropServices;
|
||||
public static class Mouse {
|
||||
[DllImport("user32.dll")] public static extern bool SetProcessDpiAwarenessContext(IntPtr value);
|
||||
[DllImport("user32.dll")] public static extern bool SetCursorPos(int X, int Y);
|
||||
[DllImport("user32.dll")] public static extern bool GetCursorPos(out POINT p);
|
||||
[DllImport("user32.dll")] public static extern void mouse_event(uint f, uint x, uint y, uint d, UIntPtr e);
|
||||
public struct POINT { public int X; public int Y; }
|
||||
}
|
||||
'@;
|
||||
# 传入的是物理像素坐标(与物理分辨率截图匹配所得), 必须声明 DPI 感知——
|
||||
# 否则进程被 DPI 虚拟化, 坐标再被系统放大一次, 点击成比例偏右下
|
||||
try { [void][Mouse]::SetProcessDpiAwarenessContext([IntPtr](-4)) } catch {};
|
||||
$p = New-Object Mouse+POINT;
|
||||
[Mouse]::GetCursorPos([ref]$p) | Out-Null;
|
||||
${move};
|
||||
Start-Sleep -Milliseconds 60;
|
||||
${action}`);
|
||||
}
|
||||
|
||||
register(["Image_Click", "Image_Click_V1", "Image_Click_V2", "Image_Click_V3"], async (ctx, node, args) => {
|
||||
const result = await locate(args, ctx.evidenceDir, node.id);
|
||||
const point = clickPoint(result, text(args, ["position"], "center"));
|
||||
const clickKey = text(args, ["click_key"], "left");
|
||||
const showTrack = String(args.show_track ?? "0") === "1" || args.show_track === true;
|
||||
await injectClick(point.x, point.y, clickKey, showTrack);
|
||||
ctx.log(`已${clickKey === "right" ? "右键" : clickKey === "double" ? "双击" : "点击"}图像 (${point.x}, ${point.y}),匹配得分 ${result.score.toFixed(3)}`, "info", node.id);
|
||||
return { ...result, click: point };
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
import { register } from "../engine/registry";
|
||||
import { RuntimeError } from "../engine/types";
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
const run = promisify(execFile);
|
||||
const ps = (script: string) => run("powershell.exe", ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", script], { windowsHide: true, timeout: 60000 });
|
||||
// cmdkey 在中文 Windows 上输出 GBK,直接 execFile 按 UTF-8 解码会乱码;统一经 PowerShell 强制 UTF-8 控制台编码捕获
|
||||
const cmdkey = async (args: string[]): Promise<string> => {
|
||||
const quoted = args.map((item) => "'" + item.replace(/'/g, "''") + "'");
|
||||
const script = "[Console]::OutputEncoding=[System.Text.UTF8Encoding]::new($false); & cmdkey " + quoted.join(" ");
|
||||
const { stdout } = await run("powershell.exe", ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", script], { windowsHide: true, timeout: 60000 });
|
||||
return stdout;
|
||||
};
|
||||
|
||||
function reqText(args: Record<string, unknown>, keys: string[], label: string, nodeId: string): string {
|
||||
for (const key of keys) {
|
||||
const v = String(args[key] ?? "").trim();
|
||||
if (v) return v;
|
||||
}
|
||||
throw new RuntimeError("validation_error", label + "为空", { nodeId, componentId: nodeId });
|
||||
}
|
||||
|
||||
register("ScreenControl_Screenlock", async () => {
|
||||
await run("rundll32.exe", ["user32.dll,LockWorkStation"], { windowsHide: true });
|
||||
return { locked: true };
|
||||
});
|
||||
|
||||
register("ScreenControl_ScreenUnlock", async () => {
|
||||
// 仅唤醒屏幕/解除屏保;系统锁屏需人工输入密码,无法通用自动解锁
|
||||
await ps("Add-Type -MemberDefinition '[DllImport(\"user32.dll\")] public static extern void mouse_event(uint f, uint dx, uint dy, uint d, UIntPtr e);' -Name W32U -Namespace WinRpa | Out-Null; [WinRpa.W32U]::mouse_event(0x0001, 1, 1, 0, [UIntPtr]::Zero); Start-Sleep -Milliseconds 200; [WinRpa.W32U]::mouse_event(0x0001, 0xFFFFFFFF, 0, 0, [UIntPtr]::Zero)");
|
||||
return { awakened: true, loginUnlockSupported: false, note: "仅尝试唤醒屏幕;Windows 登录锁屏仍需本人手动解锁" };
|
||||
});
|
||||
|
||||
const SC_SCREENSAVE = 0xF140;
|
||||
const WM_SYSCOMMAND = 0x0112;
|
||||
register(["System_ScreenSaverShow", "System_ScreenSaverShowV1"], async (_ctx, node, args) => {
|
||||
if ([true, 1, "1", "true", "True"].includes(args.set_password as never)) {
|
||||
throw new RuntimeError("validation_error", "系统屏保不支持设置自定义解锁密码,请关闭该选项并使用 Windows 原有登录保护", { nodeId: node.id, componentId: node.componentId });
|
||||
}
|
||||
await ps("Add-Type -MemberDefinition '[DllImport(\"user32.dll\")] public static extern IntPtr SendMessage(IntPtr h, uint m, IntPtr w, IntPtr l);' -Name W32S -Namespace WinRpa | Out-Null; [WinRpa.W32S]::SendMessage([IntPtr]0xFFFF, [uint32]0x0112, [IntPtr]0xF140, [IntPtr]::Zero) | Out-Null");
|
||||
return { screensaver: true };
|
||||
});
|
||||
|
||||
register("System_ScreenSaverStop", async () => {
|
||||
await ps("Add-Type -MemberDefinition '[DllImport(\"user32.dll\")] public static extern IntPtr FindWindow(string cls, string win); [DllImport(\"user32.dll\")] public static extern bool PostMessage(IntPtr h, uint m, IntPtr w, IntPtr l);' -Name W32P -Namespace WinRpa | Out-Null; $h = [WinRpa.W32P]::FindWindow('WindowsScreenSaverClass', $null); if ($h -ne [IntPtr]::Zero) { [WinRpa.W32P]::PostMessage($h, 0x0010, [IntPtr]::Zero, [IntPtr]::Zero) | Out-Null }");
|
||||
return { stopped: true };
|
||||
});
|
||||
|
||||
register("System_ReadCredential", async (ctx, node, args) => {
|
||||
const app = reqText(args, ["application"], "凭据目标", node.id);
|
||||
const stdout = await cmdkey(["/list"]);
|
||||
const exists = stdout.includes(app);
|
||||
// cmdkey /list 每条凭据是「目标/类型/用户」多行块(空行分隔);取整块才有用户名等有效信息
|
||||
const lines = stdout.split(/\r?\n/);
|
||||
const idx = lines.findIndex((l) => l.includes(app));
|
||||
const block: string[] = [];
|
||||
if (idx >= 0) {
|
||||
for (let i = idx; i < lines.length && lines[i].trim(); i++) block.push(lines[i].trim());
|
||||
}
|
||||
return { target: app, exists, detail: exists ? block.join(" | ") : "" };
|
||||
});
|
||||
|
||||
register("System_AddCredential", async (ctx, node, args) => {
|
||||
const app = reqText(args, ["application"], "凭据目标", node.id);
|
||||
const user = reqText(args, ["name"], "用户名", node.id);
|
||||
const pwd = String(args.password ?? "");
|
||||
const psArgs = pwd ? ["/generic:" + app, "/user:" + user, "/pass:" + pwd] : ["/generic:" + app, "/user:" + user];
|
||||
await cmdkey(psArgs);
|
||||
return { added: true, target: app };
|
||||
});
|
||||
|
||||
register("System_DeleteCredential", async (ctx, node, args) => {
|
||||
const app = reqText(args, ["application"], "凭据目标", node.id);
|
||||
await cmdkey(["/delete:" + app]);
|
||||
return { deleted: true, target: app };
|
||||
});
|
||||
|
||||
register("System_ConnectRemoteDesktop", async (ctx, node, args) => {
|
||||
const ip = reqText(args, ["ip"], "远程地址", node.id);
|
||||
const port = String(args.port ?? "").trim();
|
||||
const target = port ? ip + ":" + port : ip;
|
||||
await run("mstsc.exe", ["/v:" + target], { windowsHide: true });
|
||||
return { connected: true, target };
|
||||
});
|
||||
|
||||
register("System_CloseRemoteDesktop", async () => {
|
||||
await run("tsdiscon.exe", [], { windowsHide: true });
|
||||
return { disconnected: true };
|
||||
});
|
||||
|
||||
register("System_GetInputMethodState", async () => {
|
||||
const { stdout } = await ps("Add-Type -AssemblyName System.Windows.Forms | Out-Null; $cur = [System.Windows.Forms.InputLanguage]::CurrentInputLanguage; Write-Output ($cur.Culture.Name + '|' + $cur.LayoutName)");
|
||||
const [culture, layout] = stdout.split("|");
|
||||
return { culture, layout };
|
||||
});
|
||||
|
||||
register("System_SetInputMethodState", async (ctx, node, args) => {
|
||||
const mode = reqText(args, ["mode"], "输入法文化名", node.id);
|
||||
await ps("Add-Type -AssemblyName System.Windows.Forms | Out-Null; $lang = [System.Windows.Forms.InputLanguage]::FromCulture('" + mode.replace(/'/g, "''") + "'); if ($lang) { [System.Windows.Forms.InputLanguage]::CurrentInputLanguage = $lang }");
|
||||
return { set: true, mode };
|
||||
});
|
||||
|
||||
register("File_OpenFile", async (ctx, node, args) => {
|
||||
const p = reqText(args, ["path"], "文件夹路径", node.id);
|
||||
try {
|
||||
await run("explorer.exe", [p], { windowsHide: true });
|
||||
} catch (error) {
|
||||
// explorer.exe 打开成功也会返回退出码 1(Windows 已知行为),仅路径不存在时才是真失败
|
||||
const code = (error as { code?: unknown }).code;
|
||||
if (code !== 1) throw error;
|
||||
}
|
||||
return { opened: true, path: p };
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import { mkdtemp, readFile, 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 "../types";
|
||||
import type { RunContext } from "../engine/types";
|
||||
import { getHandler } from "../engine/registry";
|
||||
import "./word";
|
||||
import "./word-extended";
|
||||
|
||||
const directories: string[] = [];
|
||||
const context = {} as RunContext;
|
||||
const component = {} as CatalogComponent;
|
||||
const node = (id: string): FlowNode => ({ id, componentId: id, position: { x: 0, y: 0 }, values: {}, options: {} });
|
||||
async function run(id: string, args: Record<string, unknown> = {}) { const handler = getHandler(id); expect(handler, id).toBeTruthy(); return handler!(context, node(id), args, component); }
|
||||
afterEach(async () => Promise.all(directories.splice(0).map((directory) => rm(directory, { recursive: true, force: true }))));
|
||||
|
||||
const ids = ["Word_New_V1", "Word_GetPath", "Word_ReadAll", "Word_Replace", "Word_Enter", "Word_InsertPage", "Word_SetFontName", "Word_SetFontSize", "Word_SetFontStyle", "Word_SetFontColor", "Word_SetAlign", "Word_InsertPicture", "Word_WriteTable", "Word_Close", "Word_ReadSelect", "Word_CutSelect", "Word_CopySelect", "Word_PasteSelect", "Word_DeleteSelect", "Word_SelectAll", "Word_SelectRow", "Word_SetPosition", "Word_MovePosition", "Word_PositionToSelect", "Word_ReadTable"];
|
||||
|
||||
describe("Word extended capabilities", () => {
|
||||
it("registers automated Word operations", () => { for (const id of ids) expect(getHandler(id), id).toBeTruthy(); });
|
||||
|
||||
it("creates, writes, reads and replaces Word content", async () => {
|
||||
if (process.platform !== "win32") return;
|
||||
const directory = await mkdtemp(path.join(os.tmpdir(), "rpa-word-extended-")); directories.push(directory);
|
||||
const handle = await run("Word_New_V1", { filePath: directory, name: "extended.docx" });
|
||||
await run("Word_Write", { objWord: handle, text: "原始内容" });
|
||||
await expect(run("Word_ReadAll", { objWord: handle })).resolves.toContain("原始内容");
|
||||
await run("Word_Replace", { objWord: handle, oldText: "原始", newText: "替换后" });
|
||||
await expect(run("Word_ReadAll", { objWord: handle })).resolves.toContain("替换后内容");
|
||||
await run("Word_SetFontSize", { objWord: handle, fontSize: 12 });
|
||||
await run("Word_SetFontStyle", { objWord: handle, isBold: "True", isItalic: "False", isUnderline: "False" });
|
||||
await run("Word_Enter", { objWord: handle });
|
||||
expect((await readFile(path.join(directory, "extended.docx"))).byteLength).toBeGreaterThan(1000);
|
||||
}, 120000);
|
||||
|
||||
it("uses a persistent selection and reads a Word table through COM", async () => {
|
||||
if (process.platform !== "win32") return;
|
||||
const directory = await mkdtemp(path.join(os.tmpdir(), "rpa-word-selection-")); directories.push(directory);
|
||||
let handle = await run("Word_New_V1", { filePath: directory, name: "selection.docx" });
|
||||
await run("Word_Write", { objWord: handle, text: "alpha beta gamma" });
|
||||
handle = await run("Word_PositionToSelect", { objWord: handle, text: "beta", pos_index: 1, direction: "select" });
|
||||
await expect(run("Word_ReadSelect", { objWord: handle })).resolves.toBe("beta");
|
||||
handle = await run("Word_CopySelect", { objWord: handle });
|
||||
handle = await run("Word_MovePosition", { objWord: handle, length: 1, unit: "word", direction: "right", isSelect: false });
|
||||
await run("Word_PasteSelect", { objWord: handle });
|
||||
handle = await run("Word_PositionToSelect", { objWord: handle, text: "gamma", direction: "select" });
|
||||
handle = await run("Word_CutSelect", { objWord: handle });
|
||||
await expect(run("Word_ReadSelect", { objWord: handle })).resolves.toBe("");
|
||||
await run("Word_DeleteSelect", { objWord: handle });
|
||||
handle = await run("Word_SetPosition", { objWord: handle, length: -2, unit: "word" });
|
||||
handle = await run("Word_SelectRow", { objWord: handle, st: 1, ed: 1 });
|
||||
await expect(run("Word_ReadSelect", { objWord: handle })).resolves.toContain("alpha");
|
||||
handle = await run("Word_SelectAll", { objWord: handle });
|
||||
await expect(run("Word_ReadSelect", { objWord: handle })).resolves.toContain("alpha beta beta");
|
||||
await run("Word_WriteTable", { objWord: handle, data: [["name", "age"], ["Alice", "30"]] });
|
||||
await expect(run("Word_ReadTable", { objWord: handle, find_type: "content", table_content: "Alice" })).resolves.toEqual([["name", "age"], ["Alice", "30"]]);
|
||||
}, 180000);
|
||||
});
|
||||
@@ -0,0 +1,264 @@
|
||||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { getHandler, register } from "../engine/registry";
|
||||
import { RuntimeError } from "../engine/types";
|
||||
import { powershell, psString } from "./desktop";
|
||||
|
||||
interface WordHandle { path: string; }
|
||||
|
||||
function text(args: Record<string, unknown>, keys: string[], fallback = ""): string {
|
||||
for (const key of keys) {
|
||||
const current = args[key];
|
||||
if (current !== undefined && current !== null && current !== "") return String(current);
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function bool(value: unknown): boolean {
|
||||
return [true, 1, "1", "true", "True", "是", "开启"].includes(value as never);
|
||||
}
|
||||
|
||||
function handlePath(args: Record<string, unknown>, nodeId: string, componentId: string): string {
|
||||
const raw = args.objWord ?? args.filePath ?? args.path;
|
||||
const candidate = raw && typeof raw === "object" ? (raw as Partial<WordHandle>).path : raw;
|
||||
const result = String(candidate || "").trim();
|
||||
if (!result || result === "word对象") throw new RuntimeError("validation_error", "Word文档路径为空", { nodeId, componentId });
|
||||
return path.resolve(result);
|
||||
}
|
||||
|
||||
function script(filePath: string, body: string, save = true): string {
|
||||
return `$word=$null;$doc=$null;try{$word=New-Object -ComObject Word.Application;$word.Visible=$false;$word.DisplayAlerts=0;$doc=$word.Documents.Open(${psString(filePath)});${body};${save ? "$doc.Save();" : ""}}finally{if($doc -ne $null){try{$doc.Close()}catch{};try{[void][Runtime.InteropServices.Marshal]::ReleaseComObject($doc)}catch{}};if($word -ne $null){try{$word.Quit()}catch{};try{[void][Runtime.InteropServices.Marshal]::ReleaseComObject($word)}catch{}};[GC]::Collect();[GC]::WaitForPendingFinalizers()}`;
|
||||
}
|
||||
|
||||
register("Word_New_V1", async (ctx, node, args, component) => {
|
||||
const base = getHandler("Word_New");
|
||||
if (!base) throw new RuntimeError("capability_not_implemented", "基础 Word 新建能力不可用", { nodeId: node.id, componentId: node.componentId });
|
||||
return base(ctx, node, args, component);
|
||||
});
|
||||
|
||||
register("Word_GetPath", (_ctx, node, args) => handlePath(args, node.id, node.componentId));
|
||||
|
||||
register("Word_ReadAll", async (_ctx, node, args) => {
|
||||
const filePath = handlePath(args, node.id, node.componentId);
|
||||
const encoded = await powershell(script(filePath, "[Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($doc.Content.Text))", false));
|
||||
return Buffer.from(encoded.trim(), "base64").toString("utf8");
|
||||
});
|
||||
|
||||
register("Word_Replace", async (_ctx, node, args) => {
|
||||
const filePath = handlePath(args, node.id, node.componentId);
|
||||
const oldText = text(args, ["oldText"]);
|
||||
if (!oldText) throw new RuntimeError("validation_error", "原内容为空", { nodeId: node.id, componentId: node.componentId });
|
||||
const newText = text(args, ["newText"]);
|
||||
await powershell(script(filePath, `$find=$doc.Content.Find;[void]$find.Execute(${psString(oldText)},$false,$false,$false,$false,$false,$true,1,$false,${psString(newText)},2)`));
|
||||
return { path: filePath } satisfies WordHandle;
|
||||
});
|
||||
|
||||
register("Word_Enter", async (_ctx, node, args) => {
|
||||
const filePath = handlePath(args, node.id, node.componentId);
|
||||
await powershell(script(filePath, "$range=$doc.Content;$range.Collapse(0);$range.InsertParagraphAfter()"));
|
||||
return { path: filePath } satisfies WordHandle;
|
||||
});
|
||||
|
||||
register("Word_InsertPage", async (_ctx, node, args) => {
|
||||
const filePath = handlePath(args, node.id, node.componentId);
|
||||
await powershell(script(filePath, "$range=$doc.Content;$range.Collapse(0);$range.InsertBreak(7)"));
|
||||
return { path: filePath } satisfies WordHandle;
|
||||
});
|
||||
|
||||
register("Word_SetFontName", async (_ctx, node, args) => {
|
||||
const filePath = handlePath(args, node.id, node.componentId);
|
||||
await powershell(script(filePath, `$doc.Content.Font.Name=${psString(text(args, ["font"], "宋体"))}`));
|
||||
return { path: filePath } satisfies WordHandle;
|
||||
});
|
||||
|
||||
register("Word_SetFontSize", async (_ctx, node, args) => {
|
||||
const filePath = handlePath(args, node.id, node.componentId);
|
||||
const size = Number(text(args, ["fontSize"], "9"));
|
||||
if (!(size > 0)) throw new RuntimeError("validation_error", "字体大小无效", { nodeId: node.id, componentId: node.componentId });
|
||||
await powershell(script(filePath, `$doc.Content.Font.Size=${size}`));
|
||||
return { path: filePath } satisfies WordHandle;
|
||||
});
|
||||
|
||||
register("Word_SetFontStyle", async (_ctx, node, args) => {
|
||||
const filePath = handlePath(args, node.id, node.componentId);
|
||||
await powershell(script(filePath, `$doc.Content.Font.Bold=${bool(args.isBold) ? 1 : 0};$doc.Content.Font.Italic=${bool(args.isItalic) ? 1 : 0};$doc.Content.Font.Underline=${bool(args.isUnderline) ? 1 : 0}`));
|
||||
return { path: filePath } satisfies WordHandle;
|
||||
});
|
||||
|
||||
register("Word_SetFontColor", async (_ctx, node, args) => {
|
||||
const filePath = handlePath(args, node.id, node.componentId);
|
||||
const hex = text(args, ["fontColor"], "#000000").replace("#", "");
|
||||
if (!/^[0-9a-fA-F]{6}$/.test(hex)) throw new RuntimeError("validation_error", "字体颜色格式无效", { nodeId: node.id, componentId: node.componentId });
|
||||
const red = Number.parseInt(hex.slice(0, 2), 16); const green = Number.parseInt(hex.slice(2, 4), 16); const blue = Number.parseInt(hex.slice(4, 6), 16);
|
||||
await powershell(script(filePath, `$doc.Content.Font.Color=${red + green * 256 + blue * 65536}`));
|
||||
return { path: filePath } satisfies WordHandle;
|
||||
});
|
||||
|
||||
register("Word_SetAlign", async (_ctx, node, args) => {
|
||||
const filePath = handlePath(args, node.id, node.componentId);
|
||||
const align = text(args, ["align"], "left").toLowerCase();
|
||||
const value = align.includes("center") || align === "1" ? 1 : align.includes("right") || align === "2" ? 2 : align.includes("justify") || align === "3" ? 3 : 0;
|
||||
await powershell(script(filePath, `$doc.Content.ParagraphFormat.Alignment=${value}`));
|
||||
return { path: filePath } satisfies WordHandle;
|
||||
});
|
||||
|
||||
register("Word_InsertPicture", async (_ctx, node, args) => {
|
||||
const filePath = handlePath(args, node.id, node.componentId);
|
||||
const image = path.resolve(text(args, ["imageaddr"]));
|
||||
await fs.access(image);
|
||||
await powershell(script(filePath, `$range=$doc.Content;$range.Collapse(0);[void]$doc.InlineShapes.AddPicture(${psString(image)},$false,$true,$range)`));
|
||||
return { path: filePath } satisfies WordHandle;
|
||||
});
|
||||
|
||||
register("Word_WriteTable", async (_ctx, node, args) => {
|
||||
const filePath = handlePath(args, node.id, node.componentId);
|
||||
let data: unknown = args.data;
|
||||
if (typeof data === "string") { try { data = JSON.parse(data); } catch { data = [[data]]; } }
|
||||
const rows = Array.isArray(data) ? data.map((row) => Array.isArray(row) ? row : [row]) : [[data]];
|
||||
const columns = Math.max(1, ...rows.map((row) => row.length));
|
||||
const encoded = Buffer.from(JSON.stringify(rows), "utf8").toString("base64");
|
||||
await powershell(script(filePath, `$json=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String(${psString(encoded)}));$data=$json|ConvertFrom-Json;$range=$doc.Content;$range.Collapse(0);$table=$doc.Tables.Add($range,${rows.length},${columns});for($r=0;$r-lt${rows.length};$r++){for($c=0;$c-lt${columns};$c++){if($c-lt$data[$r].Count){$table.Cell($r+1,$c+1).Range.Text=[string]$data[$r][$c]}}};$table.Borders.Enable=${bool(args.is_border ?? "1") ? 1 : 0}`));
|
||||
return { path: filePath } satisfies WordHandle;
|
||||
});
|
||||
|
||||
register("Word_Close", (_ctx, node, args) => ({ path: handlePath(args, node.id, node.componentId), closed: true }));
|
||||
|
||||
interface WordSelection { start: number; end: number; }
|
||||
|
||||
function selectionOf(args: Record<string, unknown>): WordSelection {
|
||||
const handle = args.objWord;
|
||||
const raw = handle && typeof handle === "object" ? (handle as { selection?: unknown }).selection : null;
|
||||
const record = raw && typeof raw === "object" ? raw as Record<string, unknown> : {};
|
||||
let start = Math.max(0, Math.floor(Number(record.start ?? 0) || 0));
|
||||
let end = Math.max(0, Math.floor(Number(record.end ?? record.active ?? start) || 0));
|
||||
if (end < start) [start, end] = [end, start];
|
||||
return { start, end };
|
||||
}
|
||||
|
||||
function selectionScript(filePath: string, selection: WordSelection, body = "", withText = true): string {
|
||||
return `$word=$null;$doc=$null;try{` +
|
||||
`$word=New-Object -ComObject Word.Application;$word.Visible=$false;$word.DisplayAlerts=0;$doc=$word.Documents.Open(${psString(filePath)});` +
|
||||
`$sel=$doc.Range(${selection.start},${selection.end});$sel.Select()|Out-Null;${body};if($null -eq $sel){$sel=$doc.Content}` +
|
||||
`$text="";if(${withText ? "$true" : "$false"}){$text=[string]$sel.Text};$out=@{start=[int]$sel.Start;end=[int]$sel.End;text=$text}|ConvertTo-Json -Compress;` +
|
||||
`[Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($out))|Write-Output;$doc.Save()` +
|
||||
`}finally{if($doc -ne $null){try{$doc.Close()}catch{};try{[void][Runtime.InteropServices.Marshal]::ReleaseComObject($doc)}catch{}};if($word -ne $null){try{$word.Quit()}catch{};try{[void][Runtime.InteropServices.Marshal]::ReleaseComObject($word)}catch{}};[GC]::Collect();[GC]::WaitForPendingFinalizers()}`;
|
||||
}
|
||||
|
||||
async function runSelection(filePath: string, selection: WordSelection, body = "", withText = true): Promise<WordSelection & { text: string }> {
|
||||
const output = await powershell(selectionScript(filePath, selection, body, withText));
|
||||
if (output.includes("__WORD_NOT_FOUND__")) throw new RuntimeError("word_text_not_found", "Word 查找文本不存在", { detail: `path=${filePath}` });
|
||||
const parsed = JSON.parse(Buffer.from(output.trim(), "base64").toString("utf8")) as { start: number; end: number; text: string };
|
||||
return { start: parsed.start, end: parsed.end, text: parsed.text };
|
||||
}
|
||||
|
||||
function selectionResult(filePath: string, selection: WordSelection): { path: string; selection: WordSelection } {
|
||||
return { path: filePath, selection };
|
||||
}
|
||||
|
||||
const selectionText = async (_ctx: unknown, node: { id: string; componentId: string }, args: Record<string, unknown>): Promise<string> =>
|
||||
(await runSelection(handlePath(args, node.id, node.componentId), selectionOf(args))).text;
|
||||
|
||||
const selectionMutation = (body: string): CapabilityHandlerLike =>
|
||||
async (_ctx, node, args) => selectionResult(handlePath(args, node.id, node.componentId), await runSelection(handlePath(args, node.id, node.componentId), selectionOf(args), body));
|
||||
|
||||
type CapabilityHandlerLike = Parameters<typeof register>[1];
|
||||
|
||||
register("Word_ReadSelect", selectionText);
|
||||
register("Word_CopySelect", selectionMutation("$sel.Copy()|Out-Null"));
|
||||
register("Word_CutSelect", selectionMutation("$sel.Cut()|Out-Null"));
|
||||
register("Word_DeleteSelect", selectionMutation("[void]$sel.Delete()"));
|
||||
register("Word_PasteSelect", selectionMutation("$sel.Paste()|Out-Null"));
|
||||
register("Word_SelectAll", selectionMutation("$sel=$doc.Content;$sel.Select()|Out-Null"));
|
||||
|
||||
register("Word_SelectRow", async (_ctx, node, args) => {
|
||||
const filePath = handlePath(args, node.id, node.componentId);
|
||||
const startRow = Math.max(1, Math.floor(Number(args.st ?? 1) || 1));
|
||||
const endRow = Math.max(startRow, Math.floor(Number(args.ed ?? args.start ?? startRow) || startRow));
|
||||
const body = [
|
||||
`$first=$doc.GoTo(3,1,${startRow}).Bookmarks("\\Line").Range`,
|
||||
`$last=$doc.GoTo(3,1,${endRow}).Bookmarks("\\Line").Range`,
|
||||
"$sel=$doc.Range($first.Start,$last.End);$sel.Select()|Out-Null"
|
||||
].join(";");
|
||||
return selectionResult(filePath, await runSelection(filePath, { start: 0, end: 0 }, body));
|
||||
});
|
||||
|
||||
const wordUnit = (value: unknown): number => {
|
||||
const unit = String(value ?? "word").toLowerCase();
|
||||
return unit === "row" || unit === "line" ? 5 : unit === "section" || unit === "paragraph" ? 4 : 2;
|
||||
};
|
||||
|
||||
register("Word_SetPosition", async (_ctx, node, args) => {
|
||||
const filePath = handlePath(args, node.id, node.componentId);
|
||||
const count = Math.floor(Number(args.length ?? 1) || 0);
|
||||
const unit = wordUnit(args.unit);
|
||||
const body = `$anchor=if(${selectionOf(args).end > selectionOf(args).start ? "$true" : "$false"}){$sel.End}else{$sel.Start};$sel=$doc.Range($anchor,$anchor);[void]$sel.Move(${unit},${count})`;
|
||||
return selectionResult(filePath, await runSelection(filePath, selectionOf(args), body));
|
||||
});
|
||||
|
||||
register("Word_MovePosition", async (_ctx, node, args) => {
|
||||
const filePath = handlePath(args, node.id, node.componentId);
|
||||
const count = Math.floor(Number(args.length ?? 1) || 0);
|
||||
const unit = wordUnit(args.unit);
|
||||
const left = String(args.direction ?? "right").toLowerCase().includes("left");
|
||||
const countValue = left ? -Math.abs(count) : Math.abs(count);
|
||||
const extend = bool(args.isSelect ?? args.shift ?? false);
|
||||
const selection = selectionOf(args);
|
||||
const anchor = left ? selection.start : selection.end;
|
||||
const body = extend
|
||||
? `$sel=$doc.Range(${anchor},${anchor});if(${left ? "$true" : "$false"}){[void]$sel.MoveStart(${unit},${countValue})}else{[void]$sel.MoveEnd(${unit},${countValue})}`
|
||||
: `$sel=$doc.Range(${anchor},${anchor});[void]$sel.Move(${unit},${countValue})`;
|
||||
return selectionResult(filePath, await runSelection(filePath, selection, body));
|
||||
});
|
||||
|
||||
register("Word_PositionToSelect", async (_ctx, node, args) => {
|
||||
const filePath = handlePath(args, node.id, node.componentId);
|
||||
const needle = text(args, ["text", "find", "value"]);
|
||||
if (!needle) throw new RuntimeError("validation_error", "查找文本为空", { nodeId: node.id, componentId: node.componentId });
|
||||
const encodedNeedle = Buffer.from(needle, "utf8").toString("base64");
|
||||
const requested = Math.floor(Number(args.pos_index ?? 1) || 1);
|
||||
const mode = String(args.direction ?? "left").toLowerCase();
|
||||
const body = [
|
||||
`$needle=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${encodedNeedle}'))`,
|
||||
"$search=$doc.Content;$all=@();while($true){",
|
||||
" $candidate=$search.Duplicate;$found=$candidate.Find.Execute($needle)",
|
||||
" if(-not $found){break}",
|
||||
" $all+=$candidate.Duplicate",
|
||||
" if($candidate.End -ge $search.End){break}",
|
||||
" $search.SetRange($candidate.End,$search.End)",
|
||||
"}",
|
||||
"if($all.Count -eq 0){Write-Output '__WORD_NOT_FOUND__';exit 0}",
|
||||
`$ix=${requested};if($ix -le 0){$ix=$all.Count+$ix+1}`,
|
||||
"if($ix -lt 1){$ix=1};if($ix -gt $all.Count){$ix=$all.Count};$match=$all[$ix-1]",
|
||||
mode === "select"
|
||||
? "$sel=$match.Duplicate;$sel.Select()|Out-Null"
|
||||
: `$p=$match.${mode.includes("right") ? "End" : "Start"};$sel=$doc.Range($p,$p);$sel.Select()|Out-Null`
|
||||
].join(";");
|
||||
return selectionResult(filePath, await runSelection(filePath, { start: 0, end: 0 }, body));
|
||||
});
|
||||
|
||||
register("Word_ReadTable", async (_ctx, node, args) => {
|
||||
const filePath = handlePath(args, node.id, node.componentId);
|
||||
const byContent = String(args.find_type ?? "index").toLowerCase().includes("content");
|
||||
const tableIndex = Math.max(1, Math.floor(Number(args.table_index ?? 1) || 1));
|
||||
const content = String(args.table_content ?? "");
|
||||
const encodedContent = Buffer.from(content, "utf8").toString("base64");
|
||||
const selector = byContent
|
||||
? `$needle=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${encodedContent}'));$tableIndex=0;for($i=1;$i -le $doc.Tables.Count;$i++){if($doc.Tables.Item($i).Range.Text.Contains($needle)){$tableIndex=$i;break}}`
|
||||
: `$tableIndex=${tableIndex}`;
|
||||
const body = [
|
||||
selector,
|
||||
"if($tableIndex -lt 1 -or $tableIndex -gt $doc.Tables.Count){Write-Output '__WORD_TABLE_NOT_FOUND__';exit 0}",
|
||||
"$table=$doc.Tables.Item($tableIndex);$rows=@()",
|
||||
"for($r=1;$r -le $table.Rows.Count;$r++){ $row=@(); for($c=1;$c -le $table.Columns.Count;$c++){ try { $text=$table.Cell($r,$c).Range.Text } catch { $text='' }; $clean=''; foreach($ch in $text.ToCharArray()){ $code=[int]$ch; if($code -ne 13 -and $code -ne 7){$clean += $ch} }; $row += $clean }; $rows += ,$row }",
|
||||
"$json=$rows|ConvertTo-Json -Compress -Depth 5",
|
||||
"[Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($json))|Write-Output"
|
||||
].join(";");
|
||||
const output = await powershell(`$word=$null;$doc=$null;try{$word=New-Object -ComObject Word.Application;$word.Visible=$false;$word.DisplayAlerts=0;$doc=$word.Documents.Open(${psString(filePath)});${body}}finally{if($doc -ne $null){try{$doc.Close()}catch{};try{[void][Runtime.InteropServices.Marshal]::ReleaseComObject($doc)}catch{}};if($word -ne $null){try{$word.Quit()}catch{};try{[void][Runtime.InteropServices.Marshal]::ReleaseComObject($word)}catch{}};[GC]::Collect();[GC]::WaitForPendingFinalizers()}`);
|
||||
if (output.includes("__WORD_TABLE_NOT_FOUND__")) throw new RuntimeError("word_table_not_found", "Word 表格不存在", { detail: `path=${filePath};tableIndex=${tableIndex};content=${content}` });
|
||||
const parsed = JSON.parse(Buffer.from(output.trim(), "base64").toString("utf8"));
|
||||
const rows = Array.isArray(parsed) ? parsed : [parsed];
|
||||
return rows.map((row) => {
|
||||
const value = row && typeof row === "object" && Array.isArray((row as { value?: unknown }).value) ? (row as { value: unknown[] }).value : row;
|
||||
return Array.isArray(value) ? value : [value];
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import path from "node:path";
|
||||
import { register } from "../engine/registry";
|
||||
import { powershell, psString } from "./desktop";
|
||||
|
||||
interface WordHandle { path: string; }
|
||||
|
||||
function text(args: Record<string, unknown>, keys: string[], fallback = ""): string {
|
||||
for (const key of keys) if (args[key] !== undefined && args[key] !== null && args[key] !== "") return String(args[key]);
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function handlePath(args: Record<string, unknown>): string {
|
||||
const raw = args.objWord ?? args.filePath ?? args.path ?? args.file;
|
||||
if (typeof raw === "object" && raw !== null && "path" in raw) return path.resolve(String((raw as WordHandle).path));
|
||||
return path.resolve(String(raw || ""));
|
||||
}
|
||||
|
||||
function ensure(value: string, label: string): string {
|
||||
if (!value.trim()) throw new Error(`${label}为空`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function wordScript(body: string): string {
|
||||
return `$word = $null; $doc = $null; try { $word = New-Object -ComObject Word.Application; $word.Visible = $false; $word.DisplayAlerts = 0; ${body} } finally { if ($doc -ne $null) { try { $doc.Close() } catch {} ; try { [void][Runtime.InteropServices.Marshal]::ReleaseComObject($doc) } catch {} }; if ($word -ne $null) { try { $word.Quit() } catch {} ; try { [void][Runtime.InteropServices.Marshal]::ReleaseComObject($word) } catch {} }; [GC]::Collect(); [GC]::WaitForPendingFinalizers() }`;
|
||||
}
|
||||
|
||||
register("Word_New", async (_ctx, _node, args) => {
|
||||
const folder = ensure(text(args, ["filePath", "folder", "path"]), "Word目录");
|
||||
const name = text(args, ["name"], "新建文档.docx");
|
||||
const filePath = path.resolve(folder, path.extname(name) ? name : `${name}.docx`);
|
||||
await powershell(wordScript(`$doc = $word.Documents.Add(); $doc.SaveAs2(${psString(filePath)}); ${psString(filePath)}`));
|
||||
return { path: filePath } satisfies WordHandle;
|
||||
});
|
||||
|
||||
register("Word_Open", async (_ctx, _node, args) => ({ path: handlePath(args) } satisfies WordHandle));
|
||||
|
||||
register("Word_Write", async (_ctx, _node, args) => {
|
||||
const filePath = ensure(handlePath(args), "Word文件路径");
|
||||
const content = text(args, ["text", "content"]);
|
||||
await powershell(wordScript(`$doc = $word.Documents.Open(${psString(filePath)}); $range = $doc.Content; $range.Collapse(0); $range.InsertAfter(${psString(content)}); $doc.Save()`));
|
||||
return { path: filePath } satisfies WordHandle;
|
||||
});
|
||||
|
||||
register(["Word_Save", "Word_SaveAs"], async (_ctx, _node, args) => ({ path: handlePath(args) } satisfies WordHandle));
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { Client } from "basic-ftp";
|
||||
import type { Workbook as ExcelJSWorkbook } from "exceljs";
|
||||
|
||||
/** 人为拟真操作的开关与随机参数(desktop 能力的进程级设置)。 */
|
||||
export interface ManualOperationSettings {
|
||||
enabled: boolean;
|
||||
randomMove: boolean;
|
||||
randomClick: boolean;
|
||||
randomSleep: boolean;
|
||||
minMs: number;
|
||||
maxMs: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 引擎实例状态容器(任务 1.3,R4:模块级全局状态收拢)。
|
||||
*
|
||||
* 折中方案(决策记录 2026-11-04):能力文件的 register() 调用与全局注册表保持不变,
|
||||
* 但 Excel 缓存、数据库连接、FTP 客户端等进程级可变状态统一挂到引擎实例上。
|
||||
* 包内各能力模块通过 defaultEngine 访问;宿主/CLI 可用 createEngine() 产生隔离实例,
|
||||
* 或拿到 defaultEngine 做测试间重置。
|
||||
*/
|
||||
export class RpaEngine {
|
||||
/** excel-store:路径 → ExcelJS 工作簿共享缓存 */
|
||||
readonly excelCache = new Map<string, ExcelJSWorkbook>();
|
||||
/** office.ts:Excel 句柄 id → 工作簿 */
|
||||
readonly excelHandles = new Map<string, ExcelJSWorkbook>();
|
||||
/** remaining-core:连接 id → 数据库连接(mysql2/pg/mssql) */
|
||||
readonly dbConnections = new Map<string, unknown>();
|
||||
/** ftp.ts:当前 FTP 连接 */
|
||||
ftpClient?: Client;
|
||||
/** ocr-caps.ts:懒加载的 OCR 实例 */
|
||||
ocrInstance?: unknown;
|
||||
/** desktop.ts:人为拟真操作设置 */
|
||||
manualOperation: ManualOperationSettings = {
|
||||
enabled: false,
|
||||
randomMove: true,
|
||||
randomClick: true,
|
||||
randomSleep: true,
|
||||
minMs: 1000,
|
||||
maxMs: 3000
|
||||
};
|
||||
}
|
||||
|
||||
export function createEngine(): RpaEngine {
|
||||
return new RpaEngine();
|
||||
}
|
||||
|
||||
/** 能力模块内部使用的默认引擎实例(与全局注册表配套的进程级单例)。 */
|
||||
export const defaultEngine = createEngine();
|
||||
@@ -0,0 +1,464 @@
|
||||
/**
|
||||
* 轻量表达式求值器:支持 ${expr} 插值、变量、成员/下标、算术/比较/逻辑/三元与白名单函数。
|
||||
* 不使用 eval / new Function;错误带字符位置,便于在节点日志里定位。
|
||||
*/
|
||||
|
||||
type TokenType = "num" | "str" | "ident" | "op" | "eof";
|
||||
|
||||
interface Token {
|
||||
type: TokenType;
|
||||
value: string;
|
||||
pos: number;
|
||||
}
|
||||
|
||||
export class ExpressionError extends Error {
|
||||
position: number;
|
||||
constructor(message: string, position = 0) {
|
||||
super(message);
|
||||
this.name = "ExpressionError";
|
||||
this.position = position;
|
||||
}
|
||||
}
|
||||
|
||||
const OPERATORS = [
|
||||
"===",
|
||||
"!==",
|
||||
"==",
|
||||
"!=",
|
||||
">=",
|
||||
"<=",
|
||||
"&&",
|
||||
"||",
|
||||
"??",
|
||||
"+",
|
||||
"-",
|
||||
"*",
|
||||
"/",
|
||||
"%",
|
||||
">",
|
||||
"<",
|
||||
"!",
|
||||
"?",
|
||||
":",
|
||||
"(",
|
||||
")",
|
||||
"[",
|
||||
"]",
|
||||
".",
|
||||
","
|
||||
];
|
||||
|
||||
function tokenize(source: string): Token[] {
|
||||
const tokens: Token[] = [];
|
||||
let i = 0;
|
||||
while (i < source.length) {
|
||||
const ch = source[i];
|
||||
if (/\s/.test(ch)) {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (/[0-9]/.test(ch) || (ch === "." && /[0-9]/.test(source[i + 1] || ""))) {
|
||||
const start = i;
|
||||
while (i < source.length && /[0-9.]/.test(source[i])) i += 1;
|
||||
tokens.push({ type: "num", value: source.slice(start, i), pos: start });
|
||||
continue;
|
||||
}
|
||||
if (ch === '"' || ch === "'") {
|
||||
const quote = ch;
|
||||
const start = i;
|
||||
i += 1;
|
||||
let value = "";
|
||||
while (i < source.length && source[i] !== quote) {
|
||||
if (source[i] === "\\") {
|
||||
i += 1;
|
||||
const next = source[i];
|
||||
if (next === "n") value += "\n";
|
||||
else if (next === "t") value += "\t";
|
||||
else if (next === "r") value += "\r";
|
||||
else value += next ?? "";
|
||||
i += 1;
|
||||
} else {
|
||||
value += source[i];
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
if (source[i] !== quote) throw new ExpressionError("字符串未闭合", start);
|
||||
i += 1;
|
||||
tokens.push({ type: "str", value, pos: start });
|
||||
continue;
|
||||
}
|
||||
if (/[A-Za-z_\u4e00-\u9fa5$]/.test(ch)) {
|
||||
const start = i;
|
||||
while (i < source.length && /[A-Za-z0-9_\u4e00-\u9fa5$]/.test(source[i])) i += 1;
|
||||
tokens.push({ type: "ident", value: source.slice(start, i), pos: start });
|
||||
continue;
|
||||
}
|
||||
const op = OPERATORS.find((candidate) => source.startsWith(candidate, i));
|
||||
if (op) {
|
||||
tokens.push({ type: "op", value: op, pos: i });
|
||||
i += op.length;
|
||||
continue;
|
||||
}
|
||||
throw new ExpressionError(`无法识别的字符「${ch}」`, i);
|
||||
}
|
||||
tokens.push({ type: "eof", value: "", pos: source.length });
|
||||
return tokens;
|
||||
}
|
||||
|
||||
type Ast =
|
||||
| { type: "literal"; value: unknown }
|
||||
| { type: "identifier"; name: string; pos: number }
|
||||
| { type: "member"; object: Ast; property: Ast; computed: boolean; pos: number }
|
||||
| { type: "call"; callee: Ast; args: Ast[]; pos: number }
|
||||
| { type: "unary"; op: string; argument: Ast; pos: number }
|
||||
| { type: "binary"; op: string; left: Ast; right: Ast; pos: number }
|
||||
| { type: "ternary"; test: Ast; consequent: Ast; alternate: Ast; pos: number }
|
||||
| { type: "array"; items: Ast[]; pos: number };
|
||||
|
||||
const PRECEDENCE: Record<string, number> = {
|
||||
"||": 1,
|
||||
"??": 1,
|
||||
"&&": 2,
|
||||
"==": 3,
|
||||
"!=": 3,
|
||||
"===": 3,
|
||||
"!==": 3,
|
||||
">": 4,
|
||||
">=": 4,
|
||||
"<": 4,
|
||||
"<=": 4,
|
||||
"+": 5,
|
||||
"-": 5,
|
||||
"*": 6,
|
||||
"/": 6,
|
||||
"%": 6
|
||||
};
|
||||
|
||||
class Parser {
|
||||
private tokens: Token[];
|
||||
private index = 0;
|
||||
|
||||
constructor(source: string) {
|
||||
this.tokens = tokenize(source);
|
||||
}
|
||||
|
||||
parse(): Ast {
|
||||
const expression = this.parseExpression(0);
|
||||
const token = this.peek();
|
||||
if (token.type !== "eof") throw new ExpressionError(`多余的内容「${token.value}」`, token.pos);
|
||||
return expression;
|
||||
}
|
||||
|
||||
private peek(): Token {
|
||||
return this.tokens[this.index];
|
||||
}
|
||||
|
||||
private next(): Token {
|
||||
return this.tokens[this.index++];
|
||||
}
|
||||
|
||||
private expect(value: string): Token {
|
||||
const token = this.next();
|
||||
if (token.value !== value) throw new ExpressionError(`期望「${value}」,实际「${token.value || "结束"}」`, token.pos);
|
||||
return token;
|
||||
}
|
||||
|
||||
private parseExpression(minPrecedence: number): Ast {
|
||||
let left = this.parsePrefix();
|
||||
for (;;) {
|
||||
const token = this.peek();
|
||||
if (token.type === "op" && token.value === "?") {
|
||||
if (minPrecedence > 0) break;
|
||||
this.next();
|
||||
const consequent = this.parseExpression(0);
|
||||
this.expect(":");
|
||||
const alternate = this.parseExpression(0);
|
||||
left = { type: "ternary", test: left, consequent, alternate, pos: token.pos };
|
||||
continue;
|
||||
}
|
||||
const precedence = token.type === "op" ? PRECEDENCE[token.value] : undefined;
|
||||
if (precedence === undefined || precedence < minPrecedence) break;
|
||||
this.next();
|
||||
const right = this.parseExpression(precedence + 1);
|
||||
left = { type: "binary", op: token.value, left, right, pos: token.pos };
|
||||
}
|
||||
return left;
|
||||
}
|
||||
|
||||
private parsePrefix(): Ast {
|
||||
const token = this.next();
|
||||
if (token.type === "num") return { type: "literal", value: Number(token.value) };
|
||||
if (token.type === "str") return { type: "literal", value: token.value };
|
||||
if (token.type === "ident") {
|
||||
if (token.value === "true") return { type: "literal", value: true };
|
||||
if (token.value === "false") return { type: "literal", value: false };
|
||||
if (token.value === "null") return { type: "literal", value: null };
|
||||
if (token.value === "undefined") return { type: "literal", value: undefined };
|
||||
return this.parsePostfix({ type: "identifier", name: token.value, pos: token.pos });
|
||||
}
|
||||
if (token.type === "op") {
|
||||
if (token.value === "(") {
|
||||
const expression = this.parseExpression(0);
|
||||
this.expect(")");
|
||||
return this.parsePostfix(expression);
|
||||
}
|
||||
if (token.value === "[" ) {
|
||||
const items: Ast[] = [];
|
||||
if (this.peek().value !== "]") {
|
||||
for (;;) {
|
||||
items.push(this.parseExpression(0));
|
||||
if (this.peek().value === ",") {
|
||||
this.next();
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
this.expect("]");
|
||||
return { type: "array", items, pos: token.pos };
|
||||
}
|
||||
if (token.value === "!" || token.value === "-") {
|
||||
return { type: "unary", op: token.value, argument: this.parseExpression(7), pos: token.pos };
|
||||
}
|
||||
}
|
||||
throw new ExpressionError(`意外的「${token.value || "结束"}」`, token.pos);
|
||||
}
|
||||
|
||||
private parsePostfix(base: Ast): Ast {
|
||||
let expression = base;
|
||||
for (;;) {
|
||||
const token = this.peek();
|
||||
if (token.value === ".") {
|
||||
this.next();
|
||||
const property = this.next();
|
||||
if (property.type !== "ident") throw new ExpressionError("属性名缺失", property.pos);
|
||||
expression = {
|
||||
type: "member",
|
||||
object: expression,
|
||||
property: { type: "literal", value: property.value },
|
||||
computed: false,
|
||||
pos: token.pos
|
||||
};
|
||||
} else if (token.value === "[") {
|
||||
this.next();
|
||||
const property = this.parseExpression(0);
|
||||
this.expect("]");
|
||||
expression = { type: "member", object: expression, property, computed: true, pos: token.pos };
|
||||
} else if (token.value === "(") {
|
||||
this.next();
|
||||
const args: Ast[] = [];
|
||||
if (this.peek().value !== ")") {
|
||||
for (;;) {
|
||||
args.push(this.parseExpression(0));
|
||||
if (this.peek().value === ",") {
|
||||
this.next();
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
this.expect(")");
|
||||
expression = { type: "call", callee: expression, args, pos: token.pos };
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return expression;
|
||||
}
|
||||
}
|
||||
|
||||
const astCache = new Map<string, Ast>();
|
||||
|
||||
function parse(source: string): Ast {
|
||||
const cached = astCache.get(source);
|
||||
if (cached) return cached;
|
||||
const ast = new Parser(source).parse();
|
||||
if (astCache.size > 500) astCache.clear();
|
||||
astCache.set(source, ast);
|
||||
return ast;
|
||||
}
|
||||
|
||||
type Scope = Record<string, unknown>;
|
||||
|
||||
function hasOwn(scope: Scope, name: string): boolean {
|
||||
return Object.prototype.hasOwnProperty.call(scope, name);
|
||||
}
|
||||
|
||||
function formatDate(value: unknown, pattern = "YYYY-MM-DD HH:mm:ss"): string {
|
||||
const date = value instanceof Date ? value : new Date(value as string | number);
|
||||
if (Number.isNaN(date.getTime())) return "";
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
return pattern
|
||||
.replace(/YYYY/g, String(date.getFullYear()))
|
||||
.replace(/MM/g, pad(date.getMonth() + 1))
|
||||
.replace(/DD/g, pad(date.getDate()))
|
||||
.replace(/HH/g, pad(date.getHours()))
|
||||
.replace(/mm/g, pad(date.getMinutes()))
|
||||
.replace(/ss/g, pad(date.getSeconds()));
|
||||
}
|
||||
|
||||
const FUNCTIONS: Record<string, (...args: unknown[]) => unknown> = {
|
||||
len: (value) => (value == null ? 0 : (value as string | unknown[]).length ?? 0),
|
||||
upper: (value) => String(value ?? "").toUpperCase(),
|
||||
lower: (value) => String(value ?? "").toLowerCase(),
|
||||
trim: (value) => String(value ?? "").trim(),
|
||||
replace: (value, from, to) => String(value ?? "").split(String(from ?? "")).join(String(to ?? "")),
|
||||
split: (value, separator) => String(value ?? "").split(String(separator ?? "")),
|
||||
join: (value, separator = ",") => (Array.isArray(value) ? value.join(String(separator)) : ""),
|
||||
contains: (value, needle) => String(value ?? "").includes(String(needle ?? "")),
|
||||
startsWith: (value, prefix) => String(value ?? "").startsWith(String(prefix ?? "")),
|
||||
endsWith: (value, suffix) => String(value ?? "").endsWith(String(suffix ?? "")),
|
||||
matches: (value, pattern) => {
|
||||
try {
|
||||
return new RegExp(String(pattern ?? "")).test(String(value ?? ""));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
number: (value) => {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
},
|
||||
string: (value) => (value == null ? "" : String(value)),
|
||||
round: (value, digits = 0) => {
|
||||
const factor = 10 ** Number(digits || 0);
|
||||
return Math.round(Number(value) * factor) / factor;
|
||||
},
|
||||
abs: (value) => Math.abs(Number(value)),
|
||||
min: (...args) => Math.min(...args.map(Number)),
|
||||
max: (...args) => Math.max(...args.map(Number)),
|
||||
random: () => Math.random(),
|
||||
now: () => new Date().toISOString(),
|
||||
today: () => formatDate(new Date(), "YYYY-MM-DD"),
|
||||
formatDate: (value, pattern) => formatDate(value, pattern == null ? undefined : String(pattern)),
|
||||
json: (value) => JSON.stringify(value),
|
||||
parseJson: (value) => {
|
||||
try {
|
||||
return JSON.parse(String(value ?? ""));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
sum: (value) => (Array.isArray(value) ? value.reduce((sum, item) => sum + Number(item || 0), 0) : 0),
|
||||
avg: (value) => (Array.isArray(value) && value.length ? value.reduce((sum, item) => sum + Number(item || 0), 0) / value.length : 0),
|
||||
coalesce: (...args) => args.find((item) => item !== undefined && item !== null && item !== "") ?? ""
|
||||
};
|
||||
|
||||
function evaluateAst(ast: Ast, scope: Scope): unknown {
|
||||
switch (ast.type) {
|
||||
case "literal":
|
||||
return ast.value;
|
||||
case "identifier": {
|
||||
if (hasOwn(scope, ast.name)) return scope[ast.name];
|
||||
if (ast.name in FUNCTIONS) return FUNCTIONS[ast.name];
|
||||
return undefined;
|
||||
}
|
||||
case "array":
|
||||
return ast.items.map((item) => evaluateAst(item, scope));
|
||||
case "member": {
|
||||
const object = evaluateAst(ast.object, scope);
|
||||
const property = evaluateAst(ast.property, scope);
|
||||
if (object == null) return undefined;
|
||||
if (Array.isArray(object) && property === "length") return object.length;
|
||||
if (typeof object === "string" && property === "length") return object.length;
|
||||
return (object as Record<string | number, unknown>)[property as string | number];
|
||||
}
|
||||
case "call": {
|
||||
if (ast.callee.type !== "identifier") throw new ExpressionError("只允许调用白名单函数", ast.pos);
|
||||
const fn = FUNCTIONS[ast.callee.name];
|
||||
if (!fn) throw new ExpressionError(`未知函数「${ast.callee.name}」`, ast.pos);
|
||||
return fn(...ast.args.map((arg) => evaluateAst(arg, scope)));
|
||||
}
|
||||
case "unary": {
|
||||
const value = evaluateAst(ast.argument, scope);
|
||||
if (ast.op === "!") return !value;
|
||||
return -Number(value);
|
||||
}
|
||||
case "ternary": {
|
||||
return evaluateAst(ast.test, scope) ? evaluateAst(ast.consequent, scope) : evaluateAst(ast.alternate, scope);
|
||||
}
|
||||
case "binary": {
|
||||
if (ast.op === "&&") {
|
||||
const left = evaluateAst(ast.left, scope);
|
||||
return left ? evaluateAst(ast.right, scope) : left;
|
||||
}
|
||||
if (ast.op === "||") {
|
||||
const left = evaluateAst(ast.left, scope);
|
||||
return left ? left : evaluateAst(ast.right, scope);
|
||||
}
|
||||
if (ast.op === "??") {
|
||||
const left = evaluateAst(ast.left, scope);
|
||||
return left ?? evaluateAst(ast.right, scope);
|
||||
}
|
||||
const left = evaluateAst(ast.left, scope);
|
||||
const right = evaluateAst(ast.right, scope);
|
||||
switch (ast.op) {
|
||||
case "+":
|
||||
return typeof left === "string" || typeof right === "string"
|
||||
? String(left ?? "") + String(right ?? "")
|
||||
: Number(left) + Number(right);
|
||||
case "-":
|
||||
return Number(left) - Number(right);
|
||||
case "*":
|
||||
return Number(left) * Number(right);
|
||||
case "/":
|
||||
return Number(left) / Number(right);
|
||||
case "%":
|
||||
return Number(left) % Number(right);
|
||||
case ">":
|
||||
return left! > right!;
|
||||
case ">=":
|
||||
return left! >= right!;
|
||||
case "<":
|
||||
return left! < right!;
|
||||
case "<=":
|
||||
return left! <= right!;
|
||||
case "==":
|
||||
return left == right;
|
||||
case "!=":
|
||||
return left != right;
|
||||
case "===":
|
||||
return left === right;
|
||||
case "!==":
|
||||
return left !== right;
|
||||
default:
|
||||
throw new ExpressionError(`不支持的运算符「${ast.op}」`, ast.pos);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function evaluate(expression: string, scope: Scope = {}): unknown {
|
||||
const source = expression.trim();
|
||||
if (!source) return "";
|
||||
return evaluateAst(parse(source), scope);
|
||||
}
|
||||
|
||||
/** 整个字符串是 ${expr} 时保留类型;否则按字符串插值。 */
|
||||
export function interpolate(value: unknown, scope: Scope): unknown {
|
||||
if (typeof value !== "string") return value;
|
||||
const whole = value.match(/^\s*\$\{([\s\S]*)\}\s*$/);
|
||||
if (whole) return evaluate(whole[1], scope);
|
||||
if (!value.includes("${")) return value;
|
||||
return value.replace(/\$\{([\s\S]*?)\}/g, (_match, expression: string) => {
|
||||
const result = evaluate(expression, scope);
|
||||
return result == null ? "" : typeof result === "object" ? JSON.stringify(result) : String(result);
|
||||
});
|
||||
}
|
||||
|
||||
/** 条件表达式允许直接写变量名或 ${...} 包裹。 */
|
||||
export function evaluateCondition(value: unknown, scope: Scope): boolean {
|
||||
if (typeof value === "boolean") return value;
|
||||
if (typeof value === "number") return value !== 0;
|
||||
if (typeof value !== "string") return Boolean(value);
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return false;
|
||||
const inner = trimmed.match(/^\$\{([\s\S]*)\}$/);
|
||||
const result = interpolate(inner ? trimmed : `\${${trimmed}}`, scope);
|
||||
return Boolean(result);
|
||||
}
|
||||
|
||||
export function toNumber(value: unknown, fallback = 0): number {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : fallback;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { FlowDefinition } from "../types";
|
||||
|
||||
/**
|
||||
* 宿主桥(HostBridge 候选,阶段 2 正式化接口)。
|
||||
*
|
||||
* 包内个别能力需要宿主数据(目前仅子流程仓库),通过显式 setter 注入,
|
||||
* 包本身不得 import 宿主模块。宿主启动时调用 setSubflowSource()。
|
||||
*/
|
||||
export interface SubflowSource {
|
||||
listFlows(): Promise<Array<{ id: string; name: string }>>;
|
||||
getFlow(id: string): Promise<FlowDefinition>;
|
||||
}
|
||||
|
||||
let subflowSource: SubflowSource | undefined;
|
||||
|
||||
export function setSubflowSource(source: SubflowSource): void {
|
||||
subflowSource = source;
|
||||
}
|
||||
|
||||
export function getSubflowSource(): SubflowSource | undefined {
|
||||
return subflowSource;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { CapabilityHandler } from "./types";
|
||||
|
||||
const handlers = new Map<string, CapabilityHandler>();
|
||||
|
||||
export function register(ids: string | string[], handler: CapabilityHandler): void {
|
||||
const list = Array.isArray(ids) ? ids : [ids];
|
||||
for (const id of list) handlers.set(id, handler);
|
||||
}
|
||||
|
||||
export function getHandler(id: string): CapabilityHandler | undefined {
|
||||
return handlers.get(id);
|
||||
}
|
||||
|
||||
export function hasCapability(id: string): boolean {
|
||||
return handlers.has(id);
|
||||
}
|
||||
|
||||
export function capabilityIds(): string[] {
|
||||
return [...handlers.keys()];
|
||||
}
|
||||
|
||||
export function capabilityCount(): number {
|
||||
return handlers.size;
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { CatalogComponent, FlowDefinition } from "../types";
|
||||
import { register } from "./registry";
|
||||
import { runFlow } from "./runtime";
|
||||
|
||||
const components = new Map<string, CatalogComponent>();
|
||||
function component(id: string): CatalogComponent {
|
||||
const value = {
|
||||
id, name: id, kind: "vendor", group: "data", categories: [], categorySignature: "", description: "",
|
||||
messageTemplate: "", baseType: id, functionName: id, functionCalls: [], version: "", tags: [], platformTag: null,
|
||||
icon: "data", risk: "low", fields: [], output: { label: "", key: "", suggestedName: "", valueTypes: [], fields: [] }, slots: []
|
||||
} as unknown as CatalogComponent;
|
||||
components.set(id, value);
|
||||
return value;
|
||||
}
|
||||
|
||||
for (const id of ["Test.Start", "Test.Fail", "Test.Normal", "Test.Recover"]) component(id);
|
||||
register("Test.Start", () => true);
|
||||
register("Test.Fail", () => { throw new Error("expected failure"); });
|
||||
register("Test.Normal", (ctx) => { ctx.vars.normalRan = true; });
|
||||
register("Test.Recover", (ctx) => { ctx.vars.recovered = true; });
|
||||
|
||||
describe("runtime error route", () => {
|
||||
it("uses the error handle after failure and does not execute the normal branch", async () => {
|
||||
const now = new Date().toISOString();
|
||||
const flow: FlowDefinition = {
|
||||
schemaVersion: 1, id: "error-route", name: "error route", variables: [],
|
||||
nodes: [
|
||||
{ id: "start", componentId: "Test.Start", position: { x: 0, y: 0 }, values: {}, options: {} },
|
||||
{ id: "fail", componentId: "Test.Fail", position: { x: 0, y: 100 }, values: {}, options: { errorRouteEnabled: true } },
|
||||
{ id: "normal", componentId: "Test.Normal", position: { x: 0, y: 200 }, values: {}, options: {} },
|
||||
{ id: "recover", componentId: "Test.Recover", position: { x: 200, y: 100 }, values: {}, options: {} }
|
||||
],
|
||||
edges: [
|
||||
{ id: "e1", source: "start", target: "fail" },
|
||||
{ id: "e2", source: "fail", target: "normal" },
|
||||
{ id: "e3", source: "fail", sourceHandle: "error", target: "recover" }
|
||||
],
|
||||
meta: { createdAt: now, updatedAt: now, appVersion: "test" }
|
||||
};
|
||||
const result = await runFlow(flow, {
|
||||
runId: "test", evidenceDir: ".", signal: new AbortController().signal,
|
||||
emit: () => {}, log: () => {}, componentOf: (node) => components.get(node.componentId)
|
||||
});
|
||||
expect(result.status).toBe("succeeded");
|
||||
expect(result.variables.recovered).toBe(true);
|
||||
expect(result.variables.normalRan).toBeUndefined();
|
||||
expect(result.variables.error).toMatchObject({ reason: "expected failure" });
|
||||
});
|
||||
|
||||
it("uses the normal handle after success and leaves error and disconnected nodes untouched", async () => {
|
||||
const now = new Date().toISOString();
|
||||
const flow: FlowDefinition = {
|
||||
schemaVersion: 1, id: "normal-route", name: "normal route", variables: [],
|
||||
nodes: [
|
||||
{ id: "start", componentId: "Test.Start", position: { x: 0, y: 0 }, values: {}, options: {} },
|
||||
{ id: "normal", componentId: "Test.Normal", position: { x: 0, y: 100 }, values: {}, options: {} },
|
||||
{ id: "recover", componentId: "Test.Recover", position: { x: 200, y: 0 }, values: {}, options: {} },
|
||||
{ id: "orphan", componentId: "Test.Recover", position: { x: 400, y: 400 }, values: {}, options: {} }
|
||||
],
|
||||
edges: [
|
||||
{ id: "e1", source: "start", target: "normal" },
|
||||
{ id: "e2", source: "start", sourceHandle: "error", target: "recover" }
|
||||
],
|
||||
meta: { createdAt: now, updatedAt: now, appVersion: "test" }
|
||||
};
|
||||
const result = await runFlow(flow, {
|
||||
runId: "test", evidenceDir: ".", signal: new AbortController().signal,
|
||||
emit: () => {}, log: () => {}, componentOf: (node) => components.get(node.componentId)
|
||||
});
|
||||
expect(result.status).toBe("succeeded");
|
||||
expect(result.variables.normalRan).toBe(true);
|
||||
expect(result.variables.recovered).toBeUndefined();
|
||||
expect(result.nodes.map((state) => state.nodeId)).toEqual(["start", "normal"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,313 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { mkdtemp, readFile, 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,
|
||||
FlowDefinition,
|
||||
FlowEdge,
|
||||
FlowNode,
|
||||
RunEvent
|
||||
} from "../types";
|
||||
import "../capabilities";
|
||||
import { register } from "./registry";
|
||||
import { runFlow } from "./runtime";
|
||||
|
||||
const catalog = JSON.parse(
|
||||
readFileSync(path.resolve(process.cwd(), "resources/catalog.json"), "utf8")
|
||||
) as Catalog;
|
||||
const components = new Map(catalog.components.map((component) => [component.id, component]));
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
const baseOptions = {
|
||||
timeoutMs: 5_000,
|
||||
retryCount: 0,
|
||||
retryIntervalMs: 1_000,
|
||||
skipOnError: false
|
||||
};
|
||||
|
||||
function node(
|
||||
id: string,
|
||||
componentId: string,
|
||||
values: Record<string, unknown> = {},
|
||||
options: FlowNode["options"] = {},
|
||||
extra: Partial<FlowNode> = {}
|
||||
): FlowNode {
|
||||
return {
|
||||
id,
|
||||
componentId,
|
||||
position: { x: 0, y: 0 },
|
||||
values,
|
||||
options: { ...baseOptions, ...options },
|
||||
...extra
|
||||
};
|
||||
}
|
||||
|
||||
function edge(source: string, target: string): FlowEdge {
|
||||
return { id: `${source}->${target}`, source, target };
|
||||
}
|
||||
|
||||
function flow(id: string, nodes: FlowNode[], edges: FlowEdge[] = []): FlowDefinition {
|
||||
const now = new Date().toISOString();
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
id,
|
||||
name: id,
|
||||
variables: [],
|
||||
nodes,
|
||||
edges,
|
||||
meta: { createdAt: now, updatedAt: now, appVersion: "test" }
|
||||
};
|
||||
}
|
||||
|
||||
function testComponent(id: string): CatalogComponent {
|
||||
return {
|
||||
id,
|
||||
name: id,
|
||||
kind: "native",
|
||||
group: "test",
|
||||
categories: ["test"],
|
||||
categorySignature: "test",
|
||||
description: "runtime integration test component",
|
||||
messageTemplate: "",
|
||||
baseType: id,
|
||||
functionName: id,
|
||||
functionCalls: [],
|
||||
version: "1",
|
||||
tags: [],
|
||||
platformTag: null,
|
||||
icon: "",
|
||||
risk: "low",
|
||||
fields: [],
|
||||
output: { label: "", key: "", suggestedName: "", valueTypes: [], fields: [] },
|
||||
slots: [],
|
||||
};
|
||||
}
|
||||
|
||||
async function tempDir(): Promise<string> {
|
||||
const directory = await mkdtemp(path.join(os.tmpdir(), "rpa-runtime-integration-"));
|
||||
tempDirs.push(directory);
|
||||
return directory;
|
||||
}
|
||||
|
||||
async function execute(
|
||||
definition: FlowDefinition,
|
||||
variables: Record<string, unknown> = {},
|
||||
extraComponents: CatalogComponent[] = []
|
||||
) {
|
||||
const directory = await tempDir();
|
||||
const events: Array<Omit<RunEvent, "runId">> = [];
|
||||
const logs: Array<{ message: string; level?: "info" | "warn" | "error"; nodeId?: string }> = [];
|
||||
const componentMap = new Map(components);
|
||||
for (const component of extraComponents) componentMap.set(component.id, component);
|
||||
|
||||
const result = await runFlow(definition, {
|
||||
runId: `run-${definition.id}`,
|
||||
evidenceDir: directory,
|
||||
signal: new AbortController().signal,
|
||||
variables,
|
||||
emit: (event) => events.push(event),
|
||||
log: (message, level, nodeId) => logs.push({ message, level, nodeId }),
|
||||
componentOf: (current) => componentMap.get(current.componentId)
|
||||
});
|
||||
|
||||
return { result, events, logs, directory };
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(tempDirs.splice(0).map((directory) => rm(directory, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
describe("runFlow integration", () => {
|
||||
it("follows edges in a straight line and passes variables between real capabilities", async () => {
|
||||
const definition = flow(
|
||||
"straight-variable-flow",
|
||||
[
|
||||
node("debug", "Basic_DebugOutput", { msg: "${upper}" }, {}, { position: { x: 0, y: 10 } }),
|
||||
node("upper", "StrUtil_UpperCase", { string: "${source}" }, { outputVar: "upper" }),
|
||||
node("trigger", "Trigger.Manual", {}, {}, { position: { x: 0, y: 300 } }),
|
||||
node("set", "Basic_SetVariable", { value: "hello rpa" }, { outputVar: "source" })
|
||||
],
|
||||
[edge("trigger", "set"), edge("set", "upper"), edge("upper", "debug")]
|
||||
);
|
||||
|
||||
const { result, events, logs } = await execute(definition);
|
||||
|
||||
expect(result.status).toBe("succeeded");
|
||||
expect(result.variables).toMatchObject({ source: "hello rpa", upper: "HELLO RPA" });
|
||||
expect(result.nodes.map((state) => state.nodeId)).toEqual(["trigger", "set", "upper", "debug"]);
|
||||
expect(events.filter((event) => event.type === "node.started").map((event) => event.nodeId)).toEqual([
|
||||
"trigger",
|
||||
"set",
|
||||
"upper",
|
||||
"debug"
|
||||
]);
|
||||
expect(logs.some((entry) => entry.nodeId === "debug" && entry.message === "HELLO RPA")).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ approved: true, expected: "true", executed: "true-node", absent: "false-node" },
|
||||
{ approved: false, expected: "false", executed: "false-node", absent: "true-node" }
|
||||
])("executes only the IF $expected branch", async ({ approved, expected, executed, absent }) => {
|
||||
const branch = node("branch", "logic_control_if_else", { condition: "${approved}" });
|
||||
const definition = flow("if-branch-flow", [
|
||||
branch,
|
||||
node("true-node", "Basic_SetVariable", { value: "true" }, { outputVar: "branchResult" }, {
|
||||
parentId: branch.id,
|
||||
slot: "statement_if"
|
||||
}),
|
||||
node("false-node", "Basic_SetVariable", { value: "false" }, { outputVar: "branchResult" }, {
|
||||
parentId: branch.id,
|
||||
slot: "statement_else"
|
||||
})
|
||||
]);
|
||||
|
||||
const { result } = await execute(definition, { approved });
|
||||
|
||||
expect(result.status).toBe("succeeded");
|
||||
expect(result.variables.branchResult).toBe(expected);
|
||||
expect(result.nodes.find((state) => state.nodeId === executed)?.status).toBe("succeeded");
|
||||
expect(result.nodes.some((state) => state.nodeId === absent)).toBe(false);
|
||||
expect(result.nodes.find((state) => state.nodeId === "branch")?.output).toBe(approved);
|
||||
});
|
||||
|
||||
it("runs a count loop inclusively and exposes the loop index to child nodes", async () => {
|
||||
const loop = node("loop", "logic_control_count_for", {
|
||||
count_index: "iteration",
|
||||
count_start_value: "1",
|
||||
count_end_value: "3",
|
||||
count_step: "1"
|
||||
});
|
||||
const definition = flow("count-loop-flow", [
|
||||
loop,
|
||||
node("accumulate", "Basic_SetVariable", { value: "${total + iteration}" }, { outputVar: "total" }, {
|
||||
parentId: loop.id,
|
||||
slot: "count_for_fn"
|
||||
})
|
||||
]);
|
||||
|
||||
const { result, events } = await execute(definition, { total: 0 });
|
||||
|
||||
expect(result.status).toBe("succeeded");
|
||||
expect(result.variables).toMatchObject({ iteration: 3, total: 6 });
|
||||
expect(result.nodes.find((state) => state.nodeId === "loop")?.output).toBe(3);
|
||||
expect(events.filter((event) => event.type === "node.started" && event.nodeId === "accumulate")).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("runs an array loop through the last item when end_index is -1", async () => {
|
||||
const loop = node("loop", "logic_control_array_for_v3", {
|
||||
array_name: "items",
|
||||
reverse: false,
|
||||
start_index: 0,
|
||||
end_index: -1
|
||||
});
|
||||
const definition = flow("array-loop-flow", [
|
||||
loop,
|
||||
node("collect", "Basic_DebugOutput", { msg: "${item}" }, {}, {
|
||||
parentId: loop.id,
|
||||
slot: "array_for_fn"
|
||||
})
|
||||
]);
|
||||
|
||||
const { result, events } = await execute(definition, { items: ["glm", "mini", "other", "kimi"] });
|
||||
|
||||
expect(result.status).toBe("succeeded");
|
||||
expect(result.nodes.find((state) => state.nodeId === "loop")?.output).toBe(4);
|
||||
expect(events.filter((event) => event.type === "node.started" && event.nodeId === "collect").map((event) => event.nodeId)).toHaveLength(4);
|
||||
});
|
||||
|
||||
it("retries a transient failure and records the successful attempt", async () => {
|
||||
const component = testComponent("Test.TransientFailure");
|
||||
let calls = 0;
|
||||
register(component.id, () => {
|
||||
calls += 1;
|
||||
if (calls < 3) throw new Error(`temporary failure ${calls}`);
|
||||
return "recovered";
|
||||
});
|
||||
const definition = flow("retry-flow", [
|
||||
node("retry", component.id, {}, { outputVar: "retryResult", retryCount: 2, retryIntervalMs: 1_000 })
|
||||
]);
|
||||
|
||||
const { result, logs } = await execute(definition, {}, [component]);
|
||||
|
||||
expect(result.status).toBe("succeeded");
|
||||
expect(calls).toBe(3);
|
||||
expect(result.variables.retryResult).toBe("recovered");
|
||||
expect(result.nodes[0]).toMatchObject({ status: "succeeded", attempt: 3, output: "recovered" });
|
||||
expect(logs.filter((entry) => entry.level === "warn" && entry.nodeId === "retry")).toHaveLength(2);
|
||||
}, 5_000);
|
||||
|
||||
it("continues after a skipped failure and executes the following node", async () => {
|
||||
const component = testComponent("Test.AlwaysFails");
|
||||
register(component.id, () => {
|
||||
throw new Error("expected failure");
|
||||
});
|
||||
const definition = flow(
|
||||
"skip-on-error-flow",
|
||||
[
|
||||
node("failure", component.id, {}, { skipOnError: true }),
|
||||
node("after", "Basic_SetVariable", { value: "continued" }, { outputVar: "afterFailure" })
|
||||
],
|
||||
[edge("failure", "after")]
|
||||
);
|
||||
|
||||
const { result } = await execute(definition, {}, [component]);
|
||||
|
||||
expect(result.status).toBe("succeeded");
|
||||
expect(result.nodes.find((state) => state.nodeId === "failure")).toMatchObject({
|
||||
status: "skipped",
|
||||
error: { code: "unknown", reason: "expected failure" }
|
||||
});
|
||||
expect(result.nodes.find((state) => state.nodeId === "after")?.status).toBe("succeeded");
|
||||
expect(result.variables.afterFailure).toBe("continued");
|
||||
});
|
||||
|
||||
it("runs an offline file pipeline with interpolation and persisted output", async () => {
|
||||
const directory = await tempDir();
|
||||
const sourcePath = path.join(directory, "source.txt");
|
||||
const targetPath = path.join(directory, "result.txt");
|
||||
const definition = flow(
|
||||
"file-pipeline-flow",
|
||||
[
|
||||
node("write-source", "File_Write", {
|
||||
path: "${sourcePath}",
|
||||
content: "${inputText}",
|
||||
encode: "utf-8",
|
||||
append: "False"
|
||||
}),
|
||||
node("read-source", "File_Read", { path: "${sourcePath}", encode: "utf-8" }, { outputVar: "fileText" }),
|
||||
node("transform", "StrUtil_UpperCase", { string: "${fileText}" }, { outputVar: "transformed" }),
|
||||
node("write-target", "File_Write", {
|
||||
path: "${targetPath}",
|
||||
content: "${transformed}",
|
||||
encode: "utf-8",
|
||||
append: "False"
|
||||
}),
|
||||
node("read-target", "File_Read", { path: "${targetPath}", encode: "utf-8" }, { outputVar: "finalText" })
|
||||
],
|
||||
[
|
||||
edge("write-source", "read-source"),
|
||||
edge("read-source", "transform"),
|
||||
edge("transform", "write-target"),
|
||||
edge("write-target", "read-target")
|
||||
]
|
||||
);
|
||||
|
||||
const { result } = await execute(definition, {
|
||||
sourcePath,
|
||||
targetPath,
|
||||
inputText: "offline rpa pipeline"
|
||||
});
|
||||
|
||||
expect(result.status).toBe("succeeded");
|
||||
expect(result.variables).toMatchObject({
|
||||
fileText: "offline rpa pipeline",
|
||||
transformed: "OFFLINE RPA PIPELINE",
|
||||
finalText: "OFFLINE RPA PIPELINE"
|
||||
});
|
||||
await expect(readFile(targetPath, "utf8")).resolves.toBe("OFFLINE RPA PIPELINE");
|
||||
expect(result.nodes.every((state) => state.status === "succeeded")).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,478 @@
|
||||
import type { FlowDefinition, FlowNode, RunError } from "../types";
|
||||
import { evaluate, evaluateCondition, interpolate, toNumber } from "./expression";
|
||||
import { getHandler } from "./registry";
|
||||
import {
|
||||
asRuntimeError,
|
||||
BreakSignal,
|
||||
ContinueSignal,
|
||||
RuntimeError,
|
||||
StopSignal,
|
||||
type NodeState,
|
||||
type RunContext,
|
||||
type RunResult
|
||||
} from "./types";
|
||||
|
||||
const MAX_LOOP = 10000;
|
||||
|
||||
function sleep(ms: number, signal?: AbortSignal): Promise<void> {
|
||||
if (ms <= 0) return Promise.resolve();
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
resolve();
|
||||
}, ms);
|
||||
const onAbort = () => {
|
||||
clearTimeout(timer);
|
||||
reject(new RuntimeError("cancelled", "运行已取消"));
|
||||
};
|
||||
if (signal?.aborted) return onAbort();
|
||||
signal?.addEventListener("abort", onAbort, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
function withTimeout<T>(promise: Promise<T>, timeoutMs: number, signal: AbortSignal, label: string): Promise<T> {
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
let settled = false;
|
||||
const finish = (fn: () => void) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
fn();
|
||||
};
|
||||
const timer = setTimeout(() => {
|
||||
finish(() => reject(new RuntimeError("timeout", `${label} 执行超时(${timeoutMs}ms)`, {
|
||||
recommendation: "检查目标是否可达,或调大该节点的超时时间"
|
||||
})));
|
||||
}, timeoutMs > 0 ? timeoutMs : 30000);
|
||||
const onAbort = () => finish(() => reject(new RuntimeError("cancelled", "运行已取消")));
|
||||
if (signal.aborted) return onAbort();
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
promise.then(
|
||||
(value) => finish(() => resolve(value)),
|
||||
(error) => finish(() => reject(error))
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function isRetryable(code: string): boolean {
|
||||
return !["validation_error", "capability_not_implemented", "blocked_url", "cancelled", "expression_error"].includes(code);
|
||||
}
|
||||
|
||||
function resolveArgs(node: FlowNode, component: { fields: Array<{ key: string; defaultValue: string }> }, vars: Record<string, unknown>): Record<string, unknown> {
|
||||
const args: Record<string, unknown> = {};
|
||||
for (const field of component.fields) {
|
||||
const raw = Object.prototype.hasOwnProperty.call(node.values, field.key) ? node.values[field.key] : field.defaultValue;
|
||||
try {
|
||||
args[field.key] = interpolate(raw, vars);
|
||||
} catch (error) {
|
||||
throw new RuntimeError("expression_error", `参数 ${field.key} 求值失败:${(error as Error).message}`, {
|
||||
arg: field.key,
|
||||
recommendation: "检查 ${...} 中的变量名与表达式语法"
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const [key, value] of Object.entries(node.values || {})) {
|
||||
if (key in args) continue;
|
||||
try {
|
||||
args[key] = interpolate(value, vars);
|
||||
} catch (error) {
|
||||
throw new RuntimeError("expression_error", `参数 ${key} 求值失败:${(error as Error).message}`, { arg: key });
|
||||
}
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
function childChain(flow: FlowDefinition, parentId: string, slot: string): FlowNode[] {
|
||||
return flow.nodes.filter((node) => node.parentId === parentId && node.slot === slot);
|
||||
}
|
||||
|
||||
function asArray(value: unknown): unknown[] {
|
||||
if (Array.isArray(value)) return value;
|
||||
if (value == null || value === "") return [];
|
||||
if (typeof value === "string") {
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
return Array.isArray(parsed) ? parsed : [parsed];
|
||||
} catch {
|
||||
return value.split(/[,,\n]/).map((item) => item.trim()).filter(Boolean);
|
||||
}
|
||||
}
|
||||
if (typeof value === "object") return Object.values(value as Record<string, unknown>);
|
||||
return [value];
|
||||
}
|
||||
|
||||
export async function runFlow(
|
||||
flow: FlowDefinition,
|
||||
options: {
|
||||
runId: string;
|
||||
evidenceDir: string;
|
||||
signal: AbortSignal;
|
||||
emit: RunContext["emit"];
|
||||
log: RunContext["log"];
|
||||
componentOf: RunContext["componentOf"];
|
||||
browser?: RunContext["browser"];
|
||||
variables?: Record<string, unknown>;
|
||||
}
|
||||
): Promise<RunResult> {
|
||||
const startedAt = new Date().toISOString();
|
||||
const startMs = Date.now();
|
||||
const vars: Record<string, unknown> = { ...(options.variables || {}) };
|
||||
const ctx: RunContext = {
|
||||
runId: options.runId,
|
||||
flowId: flow.id,
|
||||
flow,
|
||||
vars,
|
||||
secrets: {},
|
||||
signal: options.signal,
|
||||
evidenceDir: options.evidenceDir,
|
||||
browser: options.browser,
|
||||
emit: options.emit,
|
||||
log: options.log,
|
||||
componentOf: options.componentOf
|
||||
};
|
||||
const states = new Map<string, NodeState>();
|
||||
let status: RunResult["status"] = "succeeded";
|
||||
let failure: RunError | undefined;
|
||||
|
||||
options.emit({ type: "run.started", startedAt });
|
||||
|
||||
try {
|
||||
await executeTopLevelGraph(flow, ctx, states);
|
||||
} catch (error) {
|
||||
if (error instanceof StopSignal) {
|
||||
options.log("流程被「退出流程」节点终止", "warn");
|
||||
status = "succeeded";
|
||||
} else if (error instanceof BreakSignal || error instanceof ContinueSignal) {
|
||||
failure = { code: "validation_error", reason: "跳出/继续语句不在循环体内" };
|
||||
status = "failed";
|
||||
} else {
|
||||
const runtimeError = asRuntimeError(error);
|
||||
failure = runtimeError;
|
||||
status = runtimeError.code === "cancelled" ? "cancelled" : "failed";
|
||||
}
|
||||
}
|
||||
|
||||
const finishedAt = new Date().toISOString();
|
||||
const durationMs = Date.now() - startMs;
|
||||
options.emit({
|
||||
type: "run.finished",
|
||||
status,
|
||||
durationMs,
|
||||
error: failure,
|
||||
startedAt
|
||||
});
|
||||
|
||||
return {
|
||||
runId: options.runId,
|
||||
status,
|
||||
startedAt,
|
||||
finishedAt,
|
||||
durationMs,
|
||||
nodes: [...states.values()],
|
||||
variables: vars,
|
||||
error: failure
|
||||
};
|
||||
}
|
||||
|
||||
async function executeTopLevelGraph(flow: FlowDefinition, ctx: RunContext, states: Map<string, NodeState>): Promise<void> {
|
||||
const top = flow.nodes.filter((node) => !node.parentId);
|
||||
if (!top.length) return;
|
||||
if (!flow.edges.length) {
|
||||
for (const node of [...top].sort((a, b) => a.position.y - b.position.y || a.position.x - b.position.x)) {
|
||||
if (ctx.signal.aborted) throw new RuntimeError("cancelled", "运行已取消");
|
||||
await executeNode(node, ctx, states);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const topIds = new Set(top.map((node) => node.id));
|
||||
const topById = new Map(top.map((node) => [node.id, node]));
|
||||
const edges = flow.edges.filter((edge) => topIds.has(edge.source) && topIds.has(edge.target));
|
||||
const incoming = new Set(edges.map((edge) => edge.target));
|
||||
const start =
|
||||
top.find((node) => !incoming.has(node.id) && node.componentId.startsWith("Trigger.")) ||
|
||||
top.find((node) => !incoming.has(node.id)) ||
|
||||
top[0];
|
||||
const visited = new Set<string>();
|
||||
|
||||
const walk = async (node: FlowNode): Promise<void> => {
|
||||
if (visited.has(node.id)) return;
|
||||
if (ctx.signal.aborted) throw new RuntimeError("cancelled", "运行已取消");
|
||||
visited.add(node.id);
|
||||
try {
|
||||
await executeNode(node, ctx, states);
|
||||
} catch (error) {
|
||||
const errorEdge = edges.find((edge) => edge.source === node.id && edge.sourceHandle === "error");
|
||||
const target = errorEdge ? topById.get(errorEdge.target) : undefined;
|
||||
if (!target) throw error;
|
||||
ctx.vars.error = asRuntimeError(error, node, node.componentId);
|
||||
ctx.log(`节点失败,转入异常分支:${target.label || target.componentId}`, "warn", node.id);
|
||||
await walk(target);
|
||||
return;
|
||||
}
|
||||
const normalEdge = edges.find((edge) => edge.source === node.id && edge.sourceHandle !== "error");
|
||||
const target = normalEdge ? topById.get(normalEdge.target) : undefined;
|
||||
if (target) await walk(target);
|
||||
};
|
||||
|
||||
await walk(start);
|
||||
}
|
||||
|
||||
async function executeNode(node: FlowNode, ctx: RunContext, states: Map<string, NodeState>): Promise<unknown> {
|
||||
const component = ctx.componentOf(node);
|
||||
const state: NodeState = {
|
||||
nodeId: node.id,
|
||||
componentId: node.componentId,
|
||||
status: "running",
|
||||
startedAt: new Date().toISOString(),
|
||||
evidence: [],
|
||||
attempt: 1
|
||||
};
|
||||
states.set(node.id, state);
|
||||
ctx.emit({ type: "node.started", nodeId: node.id, attempt: 1 });
|
||||
|
||||
if (!component) {
|
||||
const error = new RuntimeError("validation_error", `组件 ${node.componentId} 不在目录中`, {
|
||||
nodeId: node.id,
|
||||
componentId: node.componentId,
|
||||
recommendation: "重新添加该节点"
|
||||
});
|
||||
state.status = "failed";
|
||||
state.error = error.toRunError();
|
||||
state.finishedAt = new Date().toISOString();
|
||||
ctx.emit({ type: "node.finished", nodeId: node.id, status: "failed", error: state.error, durationMs: 0, evidence: [] });
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (node.enabled === false) {
|
||||
state.status = "skipped";
|
||||
state.finishedAt = new Date().toISOString();
|
||||
ctx.emit({ type: "node.finished", nodeId: node.id, status: "skipped", durationMs: 0, evidence: [] });
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const startMs = Date.now();
|
||||
try {
|
||||
let result: unknown;
|
||||
if (component.slots.length > 0) {
|
||||
result = await executeContainer(node, component.id, ctx, states);
|
||||
} else {
|
||||
const handler = getHandler(node.componentId) || getHandler(component.functionName);
|
||||
if (!handler) {
|
||||
throw new RuntimeError(
|
||||
"capability_not_implemented",
|
||||
`组件「${component.name}」当前版本未接入执行器`,
|
||||
{
|
||||
nodeId: node.id,
|
||||
componentId: node.componentId,
|
||||
recommendation: "请检查组件配置或重新添加节点"
|
||||
}
|
||||
);
|
||||
}
|
||||
const args = resolveArgs(node, component, ctx.vars);
|
||||
const delayBefore = Math.max(0, Number(node.options?.delayBeforeMs || 0));
|
||||
const delayAfter = Math.max(0, Number(node.options?.delayAfterMs || 0));
|
||||
const timeoutMs = Math.max(0, Number(node.options?.timeoutMs || 30000));
|
||||
const retries = Math.max(0, Math.min(100, Number(node.options?.retryCount || 0)));
|
||||
const interval = Math.max(1000, Number(node.options?.retryIntervalMs || 1000));
|
||||
|
||||
await sleep(delayBefore, ctx.signal);
|
||||
for (let attempt = 1; attempt <= retries + 1; attempt += 1) {
|
||||
state.attempt = attempt;
|
||||
try {
|
||||
result = await withTimeout(
|
||||
Promise.resolve(handler(ctx, node, args, component)),
|
||||
timeoutMs,
|
||||
ctx.signal,
|
||||
component.name
|
||||
);
|
||||
break;
|
||||
} catch (error) {
|
||||
if (error instanceof BreakSignal || error instanceof ContinueSignal || error instanceof StopSignal) throw error;
|
||||
const runtimeError = asRuntimeError(error, node, component.id);
|
||||
if (attempt > retries || !isRetryable(runtimeError.code)) {
|
||||
throw new RuntimeError(runtimeError.code, runtimeError.reason, {
|
||||
recommendation: runtimeError.recommendation,
|
||||
nodeId: node.id,
|
||||
componentId: component.id,
|
||||
detail: runtimeError.detail
|
||||
});
|
||||
}
|
||||
ctx.log(
|
||||
`第 ${attempt} 次执行失败:${runtimeError.reason},${Math.round(interval / 1000)} 秒后重试`,
|
||||
"warn",
|
||||
node.id
|
||||
);
|
||||
await sleep(interval, ctx.signal);
|
||||
}
|
||||
}
|
||||
if (node.options?.outputVar) ctx.vars[node.options.outputVar] = result;
|
||||
await sleep(delayAfter, ctx.signal);
|
||||
}
|
||||
|
||||
state.status = "succeeded";
|
||||
state.output = result;
|
||||
state.finishedAt = new Date().toISOString();
|
||||
state.durationMs = Date.now() - startMs;
|
||||
ctx.emit({
|
||||
type: "node.finished",
|
||||
nodeId: node.id,
|
||||
status: "succeeded",
|
||||
output: result,
|
||||
durationMs: state.durationMs,
|
||||
evidence: state.evidence
|
||||
});
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (error instanceof BreakSignal || error instanceof ContinueSignal || error instanceof StopSignal) {
|
||||
state.status = "skipped";
|
||||
state.finishedAt = new Date().toISOString();
|
||||
state.durationMs = Date.now() - startMs;
|
||||
ctx.emit({ type: "node.finished", nodeId: node.id, status: "skipped", durationMs: state.durationMs, evidence: [] });
|
||||
throw error;
|
||||
}
|
||||
const runtimeError = asRuntimeError(error, node, component.id);
|
||||
state.error = runtimeError;
|
||||
state.finishedAt = new Date().toISOString();
|
||||
state.durationMs = Date.now() - startMs;
|
||||
const skip = Boolean(node.options?.skipOnError);
|
||||
state.status = skip ? "skipped" : "failed";
|
||||
ctx.log(`${component.name} ${skip ? "失败但已跳过" : "失败"}:${runtimeError.reason}`, "error", node.id);
|
||||
if (node.options?.logException && runtimeError.detail) ctx.log(runtimeError.detail, "error", node.id);
|
||||
ctx.emit({
|
||||
type: "node.finished",
|
||||
nodeId: node.id,
|
||||
status: state.status,
|
||||
error: runtimeError,
|
||||
durationMs: state.durationMs,
|
||||
evidence: []
|
||||
});
|
||||
if (skip) return undefined;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function executeContainer(
|
||||
node: FlowNode,
|
||||
componentId: string,
|
||||
ctx: RunContext,
|
||||
states: Map<string, NodeState>
|
||||
): Promise<unknown> {
|
||||
const runSlot = async (slot: string) => {
|
||||
for (const child of childChain(ctx.flow, node.id, slot)) {
|
||||
if (ctx.signal.aborted) throw new RuntimeError("cancelled", "运行已取消");
|
||||
await executeNode(child, ctx, states);
|
||||
}
|
||||
};
|
||||
|
||||
switch (componentId) {
|
||||
case "logic_control_if_else": {
|
||||
const condition = evaluateCondition(node.values?.condition, ctx.vars);
|
||||
ctx.log(`条件 ${condition ? "成立" : "不成立"}:${String(node.values?.condition ?? "")}`, "info", node.id);
|
||||
await runSlot(condition ? "statement_if" : "statement_else");
|
||||
return condition;
|
||||
}
|
||||
case "logic_control_count_for": {
|
||||
const start = toNumber(interpolate(node.values?.count_start_value, ctx.vars), 1);
|
||||
const end = toNumber(interpolate(node.values?.count_end_value, ctx.vars), 1);
|
||||
const step = toNumber(interpolate(node.values?.count_step, ctx.vars), 1) || 1;
|
||||
const indexName = String(interpolate(node.values?.count_index, ctx.vars) || "index");
|
||||
let iterations = 0;
|
||||
for (let value = start; step > 0 ? value <= end : value >= end; value += step) {
|
||||
if (iterations++ > MAX_LOOP) throw new RuntimeError("validation_error", "循环次数超过上限 10000");
|
||||
ctx.vars[indexName] = value;
|
||||
try {
|
||||
await runSlot("count_for_fn");
|
||||
} catch (error) {
|
||||
if (error instanceof BreakSignal) break;
|
||||
if (error instanceof ContinueSignal) continue;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
return iterations;
|
||||
}
|
||||
case "logic_control_array_for_v3":
|
||||
case "logic_control_dictionary_for_v1": {
|
||||
const raw = node.values?.array_name;
|
||||
let value = interpolate(raw, ctx.vars);
|
||||
if (typeof value === "string") {
|
||||
const name = value.trim();
|
||||
if (name in ctx.vars) value = ctx.vars[name];
|
||||
else {
|
||||
try {
|
||||
value = JSON.parse(name);
|
||||
} catch {
|
||||
value = asArray(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
const list = asArray(value);
|
||||
const reverse = String(node.values?.reverse ?? "") === "true" || String(node.values?.reverse ?? "") === "1";
|
||||
const start = Math.max(0, toNumber(node.values?.start_index, 0));
|
||||
const requestedEnd = node.values?.end_index === "" || node.values?.end_index == null ? list.length : toNumber(node.values?.end_index, list.length);
|
||||
const end = requestedEnd < 0 ? Math.max(start, list.length + requestedEnd + 1) : requestedEnd;
|
||||
const slice = (reverse ? [...list].reverse() : list).slice(start, end);
|
||||
let iterations = 0;
|
||||
for (let index = 0; index < slice.length; index += 1) {
|
||||
if (iterations++ > MAX_LOOP) throw new RuntimeError("validation_error", "循环次数超过上限 10000");
|
||||
ctx.vars.item = slice[index];
|
||||
ctx.vars.index = index;
|
||||
ctx.vars.value = slice[index];
|
||||
try {
|
||||
await runSlot("array_for_fn");
|
||||
} catch (error) {
|
||||
if (error instanceof BreakSignal) break;
|
||||
if (error instanceof ContinueSignal) continue;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
return iterations;
|
||||
}
|
||||
case "logic_control_while": {
|
||||
let iterations = 0;
|
||||
while (evaluateCondition(node.values?.if_for_condition, ctx.vars)) {
|
||||
if (iterations++ > MAX_LOOP) throw new RuntimeError("validation_error", "循环次数超过上限 10000");
|
||||
try {
|
||||
await runSlot("if_for_fn");
|
||||
} catch (error) {
|
||||
if (error instanceof BreakSignal) break;
|
||||
if (error instanceof ContinueSignal) continue;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
return iterations;
|
||||
}
|
||||
case "infinite_loop": {
|
||||
for (let iterations = 0; ; iterations += 1) {
|
||||
if (iterations > MAX_LOOP) throw new RuntimeError("validation_error", "循环次数超过上限 10000");
|
||||
try {
|
||||
await runSlot("loop_body");
|
||||
} catch (error) {
|
||||
if (error instanceof BreakSignal) break;
|
||||
if (error instanceof ContinueSignal) continue;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
case "logic_control_try_catch": {
|
||||
try {
|
||||
await runSlot("try_fn");
|
||||
await runSlot("else_fn");
|
||||
return "else";
|
||||
} catch (error) {
|
||||
if (error instanceof BreakSignal || error instanceof ContinueSignal || error instanceof StopSignal) throw error;
|
||||
ctx.vars.error = asRuntimeError(error, node, componentId);
|
||||
await runSlot("except_fn");
|
||||
return "catch";
|
||||
}
|
||||
}
|
||||
default: {
|
||||
const slot = componentId === "Element_exception_monitor_v2" ? "exception_monitor_params" : "body";
|
||||
await runSlot(slot);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export { evaluate, evaluateCondition, interpolate };
|
||||
@@ -0,0 +1,237 @@
|
||||
import type { CatalogComponent, FlowDefinition, FlowNode, RunError, RunEvent } from "../types";
|
||||
|
||||
export interface BrowserLike {
|
||||
navigate(url: string): Promise<{ url: string; title: string }>;
|
||||
click(selector: unknown, timeoutMs?: number): Promise<unknown>;
|
||||
type(selector: unknown, text: string, clearFirst?: boolean, timeoutMs?: number): Promise<unknown>;
|
||||
getText(selector: unknown, attribute?: string): Promise<string>;
|
||||
exists(selector: unknown, timeoutMs?: number): Promise<boolean>;
|
||||
select(selector: unknown, value: string): Promise<unknown>;
|
||||
runJs(script: string): Promise<unknown>;
|
||||
download(selector: unknown, folder: string, filename?: string, timeoutMs?: number): Promise<string>;
|
||||
screenshot(path: string): Promise<string>;
|
||||
getAttribute?(selector: unknown, name: string): Promise<string>;
|
||||
setAttribute?(selector: unknown, name: string, value: string): Promise<unknown>;
|
||||
getHtml?(selector: unknown): Promise<string>;
|
||||
getChecked?(selector: unknown): Promise<boolean>;
|
||||
setChecked?(selector: unknown, checked: boolean): Promise<boolean>;
|
||||
isEnabled?(selector: unknown, includeHidden?: boolean): Promise<boolean>;
|
||||
scrollIntoView?(selector: unknown): Promise<BrowserElementRegion>;
|
||||
getRegion?(selector: unknown): Promise<BrowserElementRegion>;
|
||||
hover?(selector: unknown): Promise<BrowserElementRegion>;
|
||||
screenshotElement?(selector: unknown, path: string, captureBeyondViewport?: boolean): Promise<string>;
|
||||
getElement?(selector: unknown): Promise<BrowserElementReference>;
|
||||
getElementInfo?(selector: unknown): Promise<BrowserElementInfo>;
|
||||
getParent?(selector: unknown, level?: number, filter?: string): Promise<BrowserElementReference>;
|
||||
getChildren?(selector: unknown, index?: number, filter?: string): Promise<BrowserElementReference[]>;
|
||||
getSibling?(selector: unknown, direction?: "previous" | "next", filter?: string): Promise<BrowserElementReference>;
|
||||
getSelectOptions?(selector: unknown): Promise<BrowserSelectState>;
|
||||
setSelectOptions?(selector: unknown, values: string[], mode?: "replace" | "add" | "remove"): Promise<BrowserSelectState>;
|
||||
getTableData?(selector: unknown): Promise<string[][]>;
|
||||
getSimilarElements?(selector: unknown, options?: BrowserSimilarElementOptions): Promise<BrowserElementReference[]>;
|
||||
currentUrl(): Promise<string>;
|
||||
/** 设置文件上传 input 的本地文件路径 */
|
||||
uploadFileToInput?(selector: string, filePath: string): Promise<number>;
|
||||
maximize?(): void;
|
||||
minimize?(): void;
|
||||
stopLoading?(): void;
|
||||
/** 自动关闭页面弹窗(alert/confirm),返回是否新开启 */
|
||||
startAutoCloseDialogs?(): boolean;
|
||||
stopAutoCloseDialogs?(): boolean;
|
||||
/** 开启网络响应监听,返回是否新开启 */
|
||||
startNetworkListening?(): boolean;
|
||||
getNetworkCaptures?(options?: { url?: string; wildcard?: boolean; resourceType?: string; body?: boolean }): Promise<Array<Record<string, unknown>>>;
|
||||
stopNetworkListening?(): boolean;
|
||||
/** 列出当前浏览器的标签页(内置浏览器返回单页;外部浏览器走 Target 枚举) */
|
||||
listTabs?(): Promise<BrowserTabInfo[]>;
|
||||
/** 切换当前操作的标签页(按 targetId / 索引 / 标题 / 网址匹配,标题支持正则) */
|
||||
switchTab?(match: { targetId?: string; index?: number; title?: string; url?: string }): Promise<BrowserTabInfo>;
|
||||
/** 在浏览器中新建标签页并切换过去(内置浏览器退化为当前页导航) */
|
||||
openNewTab?(url: string): Promise<BrowserTabInfo>;
|
||||
/** 全量 Cookie(含 HttpOnly;走 Network.getCookies) */
|
||||
getCookies?(): Promise<Array<{ name: string; value: string; domain: string; path: string; expires: number; httpOnly: boolean; secure: boolean }>>;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
export interface BrowserTabInfo {
|
||||
index: number;
|
||||
targetId: string;
|
||||
title: string;
|
||||
url: string;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
export interface BrowserElementReference {
|
||||
kind: "css" | "xpath";
|
||||
value: string;
|
||||
tag: string;
|
||||
text: string;
|
||||
elementValue: string;
|
||||
}
|
||||
|
||||
export interface BrowserElementInfo extends BrowserElementReference {
|
||||
html: string;
|
||||
href: string;
|
||||
checked: boolean | null;
|
||||
disabled: boolean;
|
||||
attributes: Record<string, string>;
|
||||
region: BrowserElementRegion;
|
||||
}
|
||||
|
||||
export interface BrowserSelectOption {
|
||||
text: string;
|
||||
value: string;
|
||||
selected: boolean;
|
||||
disabled: boolean;
|
||||
}
|
||||
|
||||
export interface BrowserSelectState {
|
||||
values: string[];
|
||||
texts: string[];
|
||||
options: BrowserSelectOption[];
|
||||
}
|
||||
|
||||
export interface BrowserSimilarElementOptions {
|
||||
parent?: unknown;
|
||||
start?: number;
|
||||
end?: number;
|
||||
reverse?: boolean;
|
||||
}
|
||||
|
||||
export interface BrowserElementRegion {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
left: number;
|
||||
top: number;
|
||||
right: number;
|
||||
bottom: number;
|
||||
centerX: number;
|
||||
centerY: number;
|
||||
pageX: number;
|
||||
pageY: number;
|
||||
}
|
||||
|
||||
export interface RunContext {
|
||||
runId: string;
|
||||
flowId: string;
|
||||
flow: FlowDefinition;
|
||||
vars: Record<string, unknown>;
|
||||
secrets: Record<string, string>;
|
||||
signal: AbortSignal;
|
||||
evidenceDir: string;
|
||||
browser?: BrowserLike;
|
||||
emit: (event: Omit<RunEvent, "runId">) => void;
|
||||
log: (message: string, level?: "info" | "warn" | "error", nodeId?: string) => void;
|
||||
componentOf: (node: FlowNode) => CatalogComponent | undefined;
|
||||
}
|
||||
|
||||
export type CapabilityHandler = (
|
||||
ctx: RunContext,
|
||||
node: FlowNode,
|
||||
args: Record<string, unknown>,
|
||||
component: CatalogComponent
|
||||
) => Promise<unknown> | unknown;
|
||||
|
||||
export interface NodeState {
|
||||
nodeId: string;
|
||||
componentId: string;
|
||||
status: "idle" | "running" | "succeeded" | "failed" | "skipped" | "cancelled";
|
||||
startedAt?: string;
|
||||
finishedAt?: string;
|
||||
durationMs?: number;
|
||||
output?: unknown;
|
||||
error?: RunError;
|
||||
evidence: string[];
|
||||
attempt: number;
|
||||
}
|
||||
|
||||
export interface RunResult {
|
||||
runId: string;
|
||||
status: "succeeded" | "failed" | "cancelled";
|
||||
startedAt: string;
|
||||
finishedAt: string;
|
||||
durationMs: number;
|
||||
nodes: NodeState[];
|
||||
variables: Record<string, unknown>;
|
||||
error?: RunError;
|
||||
}
|
||||
|
||||
export class RuntimeError extends Error {
|
||||
code: string;
|
||||
recommendation?: string;
|
||||
nodeId?: string;
|
||||
componentId?: string;
|
||||
arg?: string;
|
||||
detail?: string;
|
||||
|
||||
constructor(
|
||||
code: string,
|
||||
message: string,
|
||||
options: { recommendation?: string; nodeId?: string; componentId?: string; arg?: string; detail?: string } = {}
|
||||
) {
|
||||
super(message);
|
||||
this.name = "RuntimeError";
|
||||
this.code = code;
|
||||
this.recommendation = options.recommendation;
|
||||
this.nodeId = options.nodeId;
|
||||
this.componentId = options.componentId;
|
||||
this.arg = options.arg;
|
||||
this.detail = options.detail;
|
||||
}
|
||||
|
||||
toRunError(): RunError {
|
||||
return {
|
||||
code: this.code,
|
||||
reason: this.message,
|
||||
recommendation: this.recommendation,
|
||||
location: {
|
||||
nodeId: this.nodeId,
|
||||
componentId: this.componentId,
|
||||
arg: this.arg
|
||||
},
|
||||
detail: this.detail
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class BreakSignal extends Error {
|
||||
constructor() {
|
||||
super("break");
|
||||
this.name = "BreakSignal";
|
||||
}
|
||||
}
|
||||
|
||||
export class ContinueSignal extends Error {
|
||||
constructor() {
|
||||
super("continue");
|
||||
this.name = "ContinueSignal";
|
||||
}
|
||||
}
|
||||
|
||||
export class StopSignal extends Error {
|
||||
constructor() {
|
||||
super("stop");
|
||||
this.name = "StopSignal";
|
||||
}
|
||||
}
|
||||
|
||||
export function asRuntimeError(error: unknown, node?: FlowNode, componentId?: string): RunError {
|
||||
if (error instanceof RuntimeError) {
|
||||
const result = error.toRunError();
|
||||
if (!result.location?.nodeId && node) {
|
||||
result.location = { ...(result.location || {}), nodeId: node.id, componentId: componentId || node.componentId };
|
||||
}
|
||||
return result;
|
||||
}
|
||||
if (error instanceof Error && error.name === "AbortError") {
|
||||
return { code: "cancelled", reason: "运行已取消" };
|
||||
}
|
||||
const code = (error as { code?: string })?.code;
|
||||
return {
|
||||
code: typeof code === "string" ? code : "unknown",
|
||||
reason: error instanceof Error ? error.message : String(error),
|
||||
location: node ? { nodeId: node.id, componentId: componentId || node.componentId } : undefined
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* @rpa/core 包入口。
|
||||
*
|
||||
* 只导出引擎 API 与类型,不做能力注册副作用;
|
||||
* 纯 Node 能力通过 `@rpa/core/capabilities` 子路径按需拉起(保持与 demo 宿主
|
||||
* src/main/capabilities/index.ts 相同的副作用注册模型,见任务 4.2 再显式化)。
|
||||
*/
|
||||
export { createEngine, defaultEngine, RpaEngine, type ManualOperationSettings } from "./engine/engine";
|
||||
export { register, getHandler, hasCapability, capabilityIds, capabilityCount } from "./engine/registry";
|
||||
export { runFlow } from "./engine/runtime";
|
||||
export { RuntimeError, BreakSignal, ContinueSignal, StopSignal, asRuntimeError } from "./engine/types";
|
||||
export { setSubflowSource, getSubflowSource, type SubflowSource } from "./engine/host-bridge";
|
||||
// PowerShell 助手(阶段 3 将移入 driver/windows/):宿主留守能力(window-extended 等)经此引用
|
||||
export { powershell, psString } from "./capabilities/desktop";
|
||||
export type {
|
||||
BrowserLike,
|
||||
BrowserTabInfo,
|
||||
BrowserElementReference,
|
||||
BrowserElementInfo,
|
||||
BrowserSelectOption,
|
||||
BrowserSelectState,
|
||||
BrowserSimilarElementOptions,
|
||||
BrowserElementRegion,
|
||||
RunContext,
|
||||
CapabilityHandler,
|
||||
NodeState,
|
||||
RunResult
|
||||
} from "./engine/types";
|
||||
export type {
|
||||
CatalogWidget,
|
||||
CatalogField,
|
||||
CatalogOutput,
|
||||
CatalogComponent,
|
||||
CatalogGroup,
|
||||
Catalog,
|
||||
FlowVariable,
|
||||
FlowNodeOptions,
|
||||
FlowNode,
|
||||
FlowEdge,
|
||||
FlowDefinition,
|
||||
RunError,
|
||||
RunEvent
|
||||
} from "./types";
|
||||
@@ -0,0 +1,187 @@
|
||||
/**
|
||||
* rpa-core 领域类型(任务 1.2,类型单一源)
|
||||
*
|
||||
* 从 src/shared/types 拆出:与流程/目录/运行相关、引擎必须依赖的模型归包所有。
|
||||
* 宿主专属类型(AppInfo / AppSettings / FlowSummary / VisionLab* 等)留在 src/shared/types,
|
||||
* 该文件通过 type-only re-export 引用本文件,保证全仓不出现两份定义。
|
||||
*/
|
||||
|
||||
export type CatalogWidget =
|
||||
| "text"
|
||||
| "textarea"
|
||||
| "number"
|
||||
| "select"
|
||||
| "radio"
|
||||
| "boolean"
|
||||
| "file"
|
||||
| "folder"
|
||||
| "element"
|
||||
| "code"
|
||||
| "secret"
|
||||
| "region"
|
||||
| "hidden";
|
||||
|
||||
export interface CatalogField {
|
||||
key: string;
|
||||
label: string;
|
||||
widget: CatalogWidget;
|
||||
type: "string" | "number" | "boolean" | "object";
|
||||
displayType: string;
|
||||
required: boolean;
|
||||
defaultValue: string;
|
||||
placeholder: string;
|
||||
description: string;
|
||||
advanced: boolean;
|
||||
valueTypes: string[];
|
||||
/** 下拉/单选选项:字符串(显示=存储值)或 { label, value }(显示与存储分离) */
|
||||
options?: Array<string | { label: string; value: string }>;
|
||||
unit?: string;
|
||||
}
|
||||
|
||||
export interface CatalogOutput {
|
||||
label: string;
|
||||
key: string;
|
||||
suggestedName: string;
|
||||
valueTypes: string[];
|
||||
fields: Array<{ key: string; label: string; defaultValue: string; valueTypes: string[] }>;
|
||||
}
|
||||
|
||||
export interface CatalogComponent {
|
||||
id: string;
|
||||
name: string;
|
||||
kind: "vendor" | "native" | "trigger";
|
||||
group: string;
|
||||
categories: string[];
|
||||
categorySignature: string;
|
||||
description: string;
|
||||
messageTemplate: string;
|
||||
baseType: string;
|
||||
functionName: string;
|
||||
functionCalls: Array<{ function: string; args: string[]; assignTo: string }>;
|
||||
version: string;
|
||||
tags: string[];
|
||||
platformTag: string | null;
|
||||
icon: string;
|
||||
risk: "low" | "high";
|
||||
fields: CatalogField[];
|
||||
output: CatalogOutput;
|
||||
slots: string[];
|
||||
nodeKind?: string;
|
||||
/** 同名旧版本 / 重复别名指向的保留能力 ID。 */
|
||||
duplicateOf?: string;
|
||||
}
|
||||
|
||||
export interface CatalogGroup {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface Catalog {
|
||||
schemaVersion: number;
|
||||
generatedAt: string;
|
||||
counts: {
|
||||
total: number;
|
||||
vendor: number;
|
||||
native: number;
|
||||
triggers: number;
|
||||
fields: number;
|
||||
};
|
||||
groups: CatalogGroup[];
|
||||
components: CatalogComponent[];
|
||||
}
|
||||
|
||||
export interface FlowVariable {
|
||||
name: string;
|
||||
type: string;
|
||||
defaultValue?: string;
|
||||
description?: string;
|
||||
scope?: "flow" | "local";
|
||||
}
|
||||
|
||||
export interface FlowNodeOptions {
|
||||
outputVar?: string;
|
||||
delayBeforeMs?: number;
|
||||
delayAfterMs?: number;
|
||||
timeoutMs?: number;
|
||||
retryCount?: number;
|
||||
retryIntervalMs?: number;
|
||||
skipOnError?: boolean;
|
||||
logException?: boolean;
|
||||
errorRouteEnabled?: boolean;
|
||||
}
|
||||
|
||||
export interface FlowNode {
|
||||
id: string;
|
||||
componentId: string;
|
||||
label?: string;
|
||||
position: { x: number; y: number };
|
||||
values: Record<string, unknown>;
|
||||
options: FlowNodeOptions;
|
||||
parentId?: string | null;
|
||||
slot?: string;
|
||||
enabled?: boolean;
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
export interface FlowEdge {
|
||||
id: string;
|
||||
source: string;
|
||||
target: string;
|
||||
sourceHandle?: string | null;
|
||||
targetHandle?: string | null;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export interface FlowDefinition {
|
||||
schemaVersion: 1;
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
enabled?: boolean;
|
||||
trigger?: {
|
||||
type: "manual" | "schedule" | "file";
|
||||
label: string;
|
||||
};
|
||||
variables: FlowVariable[];
|
||||
nodes: FlowNode[];
|
||||
edges: FlowEdge[];
|
||||
meta: {
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
appVersion: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface RunError {
|
||||
code: string;
|
||||
reason: string;
|
||||
recommendation?: string;
|
||||
location?: { flowId?: string; nodeId?: string; componentId?: string; arg?: string };
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
export interface RunEvent {
|
||||
type:
|
||||
| "run.started"
|
||||
| "node.started"
|
||||
| "node.log"
|
||||
| "node.finished"
|
||||
| "run.finished"
|
||||
| "run.total";
|
||||
runId: string;
|
||||
nodeId?: string;
|
||||
level?: "info" | "warn" | "error";
|
||||
message?: string;
|
||||
status?: string;
|
||||
output?: unknown;
|
||||
error?: RunError;
|
||||
durationMs?: number;
|
||||
evidence?: string[];
|
||||
attempt?: number;
|
||||
startedAt?: string;
|
||||
nodeLabel?: string;
|
||||
total?: number;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"resolveJsonModule": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noEmit": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
// 与根配置一致:桌面自动化冒烟测试共用同一物理桌面,串行执行防焦点竞争。
|
||||
fileParallelism: false
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user