From b78795ccdc26f02bead6876fdb59694b60b96860 Mon Sep 17 00:00:00 2001 From: yu <191972707@qq.com> Date: Tue, 27 Oct 2026 14:57:40 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=8E=A5=E5=85=A5=E6=9C=AC=E5=9C=B0?= =?UTF-8?q?=E5=B7=A5=E5=85=B7=E4=B8=8EDatabook=E8=83=BD=E5=8A=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- docs/02-实施方案.md | 2 +- src/main/capabilities/index.ts | 1 + .../capabilities/utility-extended.test.ts | 116 ++++++ src/main/capabilities/utility-extended.ts | 390 ++++++++++++++++++ 5 files changed, 509 insertions(+), 2 deletions(-) create mode 100644 src/main/capabilities/utility-extended.test.ts create mode 100644 src/main/capabilities/utility-extended.ts diff --git a/README.md b/README.md index 227e2ef..1fb8309 100644 --- a/README.md +++ b/README.md @@ -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()` 伪造点击。 - 不包含厂商代码、二进制、模型或前端资产;目录是从厂商元数据派生的静态定义。 diff --git a/docs/02-实施方案.md b/docs/02-实施方案.md index be294d7..27bab7a 100644 --- a/docs/02-实施方案.md +++ b/docs/02-实施方案.md @@ -4,7 +4,7 @@ > 依据:`D:\code\Agent-Employee`(只读)、`C:\rpa-demo`(只读)、2026-10-26 需求确认 > 本方案是执行基线:范围、契约、排期、验收、降级顺序都在这里;实施中变更必须回写本文件。 -> **2026-10-27 当前状态**:计划点名的代表能力已全部进入注册表或运行时容器,覆盖控制/触发、字符串/集合/INI/二维表格、文件、浏览器元素常用及 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` 校准。 diff --git a/src/main/capabilities/index.ts b/src/main/capabilities/index.ts index 3e252dd..148c1e4 100644 --- a/src/main/capabilities/index.ts +++ b/src/main/capabilities/index.ts @@ -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"; diff --git a/src/main/capabilities/utility-extended.test.ts b/src/main/capabilities/utility-extended.test.ts new file mode 100644 index 0000000..31feeab --- /dev/null +++ b/src/main/capabilities/utility-extended.test.ts @@ -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 = {}, outputVar?: string): Promise { + 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; + 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; + 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]]); + }); +}); diff --git a/src/main/capabilities/utility-extended.ts b/src/main/capabilities/utility-extended.ts new file mode 100644 index 0000000..b4d48a4 --- /dev/null +++ b/src/main/capabilities/utility-extended.ts @@ -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 = { + "%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 }): Table { + const current = table(ctx.vars[DATABOOK_KEY]); + ctx.vars[DATABOOK_KEY] = current; + return current; +} + +function setDatabook(ctx: { vars: Record }, 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).holidays); + workdays = list((calendar as Record).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); +});