PRA-only-demo/packages/rpa-core/src/capabilities/office.ts
yu caa8e19332 阶段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被拒, 环境阻塞)
2026-11-04 12:36:03 +08:00

250 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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