feat: 接入本地工具与Databook能力

This commit is contained in:
yu 2026-10-27 14:57:40 +08:00
parent 65591322ee
commit b78795ccdc
5 changed files with 509 additions and 2 deletions

View File

@ -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()` 伪造点击。
- 不包含厂商代码、二进制、模型或前端资产;目录是从厂商元数据派生的静态定义。

View File

@ -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` 校准。

View File

@ -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";

View 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]]);
});
});

View 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);
});