feat: 接入本地工具与Databook能力
This commit is contained in:
parent
65591322ee
commit
b78795ccdc
@ -51,7 +51,7 @@ docs 现状分析、实施方案
|
||||
|
||||
## 边界
|
||||
|
||||
- 1016 项目录中当前有 398 项通过能力注册表或运行时容器标记为可执行,覆盖控制/触发、字符串/集合/INI/二维表格、文件、浏览器元素常用及 DOM 高级能力、HTTP、Excel/CSV与表格辅助、系统路径与环境变量、进程管理、桌面键鼠/窗口对象/剪贴板/对话框、PDF、Word、邮件发送、屏幕截图、离线图像查找与点击;其余灰标「待接入」。运行前检查会提前定位待接入组件、必填参数、多出口与断链节点。
|
||||
- 1016 项目录中当前有 412 项通过能力注册表或运行时容器标记为可执行,覆盖控制/触发、字符串/集合/INI/二维表格与 Databook、数组重塑、时间转换、文件、浏览器元素常用及 DOM 高级能力、HTTP、Excel/CSV与表格辅助、系统路径与环境变量、进程管理、桌面键鼠/窗口对象/剪贴板/对话框、PDF、Word、邮件发送、文本差异、工作日判断、屏幕截图、离线图像查找与点击;其余灰标「待接入」。运行前检查会提前定位待接入组件、必填参数、多出口与断链节点。
|
||||
- 内置本地演示站默认监听 `127.0.0.1:17863`;新用户首次启动会获得“本地网页订单演示”任务,可离线验证网页输入、点击和文本读取。
|
||||
- 浏览器可信输入与定位机制参考 `C:\rpa-demo`:CDP Input、唯一定位、遮挡检查和输入回读校验;不使用 `el.click()` 伪造点击。
|
||||
- 不包含厂商代码、二进制、模型或前端资产;目录是从厂商元数据派生的静态定义。
|
||||
|
||||
@ -4,7 +4,7 @@
|
||||
> 依据:`D:\code\Agent-Employee`(只读)、`C:\rpa-demo`(只读)、2026-10-26 需求确认
|
||||
> 本方案是执行基线:范围、契约、排期、验收、降级顺序都在这里;实施中变更必须回写本文件。
|
||||
|
||||
> **2026-10-27 当前状态**:计划点名的代表能力已全部进入注册表或运行时容器,覆盖控制/触发、字符串/集合/INI/二维表格、文件、浏览器元素常用及 DOM 高级能力、HTTP、Excel/CSV与表格辅助、系统路径与环境变量、进程管理、桌面键鼠/窗口对象/剪贴板/对话框、PDF、Word、邮件发送、屏幕截图、离线图像查找与点击;启动后目录显示 **398 项可执行 / 1016 项总目录**。当前 15 个测试文件共 99 条测试,覆盖计划能力审计、浏览器参数路由/变体/DOM、Excel/PDF/Word/SMTP/模板匹配、文件与数据扩展、系统/进程/窗口解析、直线/IF/循环/重试/跳过、主出口与异常出口语义、运行记录回写、运行前检查、本地演示站与示例流程。设置页可查看分类覆盖和处理器 ID 数量。未通过实际场景验证的变体仍不计入最终交付数量。
|
||||
> **2026-10-27 当前状态**:计划点名的代表能力已全部进入注册表或运行时容器,覆盖控制/触发、字符串/集合/INI/二维表格与 Databook、数组重塑、时间转换、文件、浏览器元素常用及 DOM 高级能力、HTTP、Excel/CSV与表格辅助、系统路径与环境变量、进程管理、桌面键鼠/窗口对象/剪贴板/对话框、PDF、Word、邮件发送、文本差异、工作日判断、屏幕截图、离线图像查找与点击;启动后目录显示 **412 项可执行 / 1016 项总目录**。当前 16 个测试文件共 108 条测试,覆盖计划能力审计、浏览器参数路由/变体/DOM、Excel/PDF/Word/SMTP/模板匹配、文件与数据扩展、系统/进程/窗口解析、本地工具与 Databook、直线/IF/循环/重试/跳过、主出口与异常出口语义、运行记录回写、运行前检查、本地演示站与示例流程。设置页可查看分类覆盖和处理器 ID 数量。未通过实际场景验证的变体仍不计入最终交付数量。
|
||||
|
||||
> 浏览器端到端门禁:`npm run test:e2e` 使用隔离用户目录启动应用和本地演示站,真实执行打开网页、两次可信输入、点击提交和读取结果;期望结果必须精确包含“订单创建成功:RPA 客户,金额 128”。浏览器输入、点击、定位歧义和遮挡检查机制已对照 `C:\rpa-demo/actions.js` 校准。
|
||||
|
||||
|
||||
@ -16,5 +16,6 @@ import "./system-extended";
|
||||
import "./office-file-extended";
|
||||
import "./window-extended";
|
||||
import "./data-extended";
|
||||
import "./utility-extended";
|
||||
|
||||
export { capabilityCount, capabilityIds, getHandler, hasCapability, register } from "../engine/registry";
|
||||
|
||||
116
src/main/capabilities/utility-extended.test.ts
Normal file
116
src/main/capabilities/utility-extended.test.ts
Normal file
@ -0,0 +1,116 @@
|
||||
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 "../../shared/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("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]]);
|
||||
});
|
||||
});
|
||||
390
src/main/capabilities/utility-extended.ts
Normal file
390
src/main/capabilities/utility-extended.ts
Normal file
@ -0,0 +1,390 @@
|
||||
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");
|
||||
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";
|
||||
commandArgs = ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", requiredText(args.ps_command, "PowerShell命令", node.id, node.componentId)];
|
||||
} 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];
|
||||
}
|
||||
const result = await execFileAsync(executable, commandArgs, { cwd, timeout, windowsHide: true, 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);
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user