- 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被拒, 环境阻塞)
250 lines
13 KiB
TypeScript
250 lines
13 KiB
TypeScript
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;
|
||
});
|