fix: 统一 Excel 工作簿共享存储,修复多组件互相覆盖

office.ts(内存句柄表)、office-file-extended.ts(每次重读磁盘)、
excel-common.ts(按路径独立缓存)、extended.ts(loadSheet 每次重建)
各自持有工作簿实例并整簿写回同一文件,后保存者会整体覆盖先保存者:
列宽/行高/合并被背景色冲掉、复制的 Sheet 被新建 Sheet 冲掉等。

- 新增 excel-store.ts:按路径共享同一 ExcelJS 实例,四个模块统一接入
- resolveWorkbook 返回流程变量持有的原始句柄对象,另存为改写 path
  能同步到后续节点(此前改在副本上,下游仍写旧路径)
- 另存为后以新路径重新登记实例并释放旧路径缓存
- ReadColumn 裁掉尾部空值(共享实例下触碰空行会撑大 rowCount)
- 新增 3 个共享存储回归测试(格式保持/复制Sheet保持/另存后续写)
This commit is contained in:
yu committed 2026-10-28 13:32:01 +08:00
1 parent 54571eff8d
commit 9de0eeaa88
6 files changed
+128 -24

No files matched your search

+3 -8
View File
@@ -2,11 +2,11 @@ import ExcelJS from "exceljs";
import path from "node:path"; import path from "node:path";
import { register } from "../engine/registry"; import { register } from "../engine/registry";
import { RuntimeError, type CapabilityHandler } from "../engine/types"; import { RuntimeError, type CapabilityHandler } from "../engine/types";
import { acquireWorkbook, releaseWorkbook } from "./excel-store";
type WorkbookSource = { workbook: ExcelJS.Workbook; filePath: string }; type WorkbookSource = { workbook: ExcelJS.Workbook; filePath: string };
type CopiedRange = { values: unknown[][]; styles: Partial<ExcelJS.Style>[][] }; type CopiedRange = { values: unknown[][]; styles: Partial<ExcelJS.Style>[][] };
const workbookCache = new Map<string, ExcelJS.Workbook>();
let copiedRange: CopiedRange | undefined; let copiedRange: CopiedRange | undefined;
function value(args: Record<string, unknown>, keys: string[], fallback: unknown = ""): unknown { function value(args: Record<string, unknown>, keys: string[], fallback: unknown = ""): unknown {
@@ -46,12 +46,7 @@ function requiredPath(candidate: unknown, nodeId: string, componentId: string):
async function loadWorkbook(args: Record<string, unknown>, nodeId: string, componentId: string): Promise<WorkbookSource> { async function loadWorkbook(args: Record<string, unknown>, nodeId: string, componentId: string): Promise<WorkbookSource> {
const filePath = requiredPath(value(args, ["workbook", "objExcel", "objExcelWorkBook", "file", "path"]), nodeId, componentId); const filePath = requiredPath(value(args, ["workbook", "objExcel", "objExcelWorkBook", "file", "path"]), nodeId, componentId);
let workbook = workbookCache.get(filePath); const workbook = await acquireWorkbook(filePath);
if (!workbook) {
workbook = new ExcelJS.Workbook();
await workbook.xlsx.readFile(filePath);
workbookCache.set(filePath, workbook);
}
return { workbook, filePath }; return { workbook, filePath };
} }
@@ -558,7 +553,7 @@ const splitColumn: CapabilityHandler = async (_ctx, node, args) => {
const closeWorkbook: CapabilityHandler = async (_ctx, node, args) => { const closeWorkbook: CapabilityHandler = async (_ctx, node, args) => {
const source = await loadWorkbook(args, node.id, node.componentId); const source = await loadWorkbook(args, node.id, node.componentId);
if (truthy(value(args, ["save", "isSave"]), true)) await source.workbook.xlsx.writeFile(source.filePath); if (truthy(value(args, ["save", "isSave"]), true)) await source.workbook.xlsx.writeFile(source.filePath);
workbookCache.delete(source.filePath); releaseWorkbook(source.filePath);
return source.filePath; return source.filePath;
}; };
+68
View File
@@ -0,0 +1,68 @@
import { mkdtemp, rm } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import ExcelJS from "exceljs";
import { afterEach, describe, expect, it } from "vitest";
import type { CatalogComponent, FlowNode } from "../../shared/types";
import type { RunContext } from "../engine/types";
import { getHandler } from "../engine/registry";
import "./office";
import "./extended";
import "./office-file-extended";
import "./office-aliases";
import "./excel-common";
import "./common-final";
const dirs: string[] = [];
const context = {} as RunContext;
const component = {} as CatalogComponent;
const node = (id: string): FlowNode => ({ id, componentId: id, position: { x: 0, y: 0 }, values: {}, options: {} });
async function run(id: string, args: Record<string, unknown>) { const handler = getHandler(id); expect(handler, id).toBeTruthy(); return handler!(context, node(id), args, component); }
afterEach(async () => Promise.all(dirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))));
describe("excel shared workbook store", () => {
it("keeps widths/heights set by office-file-extended when excel-common saves afterwards", async () => {
const directory = await mkdtemp(path.join(os.tmpdir(), "rpa-store-")); dirs.push(directory);
const handle = await run("Excel_NewExcel", { filePath: directory, name: "a.xlsx", isMakedirs: "True" }) as { path: string };
await run("Excel_Cs_WriteRangeV2", { workbook: handle, sheet: "Sheet1", row: "1", column: "A", contents: [["名称", "数量", "备注"], ["鼠标", 20, "A;B"]], isSave: "True" });
await run("Excel_Cs_SetWidth", { workbook: handle, sheet: "Sheet1", columns: "A:C", width: "20", save: "True" });
await run("Excel_Cs_SetHeight", { workbook: handle, sheet: "Sheet1", rows: "1", height: "26", save: "True" });
await run("Excel_Cs_MergeCells", { workbook: handle, sheet: "Sheet1", range: "A1:C1", merge: "0", save: "True" });
await run("Excel_Cs_SetRangeBackground", { workbook: handle, sheet: "Sheet1", range: "A1:C1", color: "FFF2CC", save: "True" });
await run("Excel_SetCellFontColor", { objExcel: handle, sheet: "Sheet1", cellPos: "A1", cellColor: "FF0000", isSave: "True" });
const wb = new ExcelJS.Workbook();
await wb.xlsx.readFile(handle.path);
const ws = wb.worksheets[0];
expect(ws.getColumn("A").width).toBe(20);
expect(ws.getRow(1).height).toBe(26);
expect(ws.model.merges).toContain("A1:C1");
expect((ws.getCell("A1").fill as ExcelJS.FillPattern).fgColor?.argb).toBe("FFFFF2CC");
expect((ws.getCell("A1").font?.color as { argb?: string } | undefined)?.argb).toBe("FFFF0000");
expect(ws.getCell("A1").value).toBe("名称");
});
it("keeps copied sheets created by office-file-extended when sheets are created and deleted later", async () => {
const directory = await mkdtemp(path.join(os.tmpdir(), "rpa-store-")); dirs.push(directory);
const handle = await run("Excel_NewExcel", { filePath: directory, name: "b.xlsx", isMakedirs: "True" }) as { path: string };
await run("Excel_Cs_WriteRangeV2", { workbook: handle, sheet: "Sheet1", row: "1", column: "A", contents: [["名称"]], isSave: "True" });
await run("Excel_Cs_CopyV2", { workbook: handle, sheet: "Sheet1", newSheet: "Sheet1副本", override: "True", save: "True" });
await run("Excel_CreateSheet", { workbook: handle, sheet: "临时表", save: "True" });
await run("Excel_DeleteSheet", { workbook: handle, sheet: "临时表", save: "True" });
const wb = new ExcelJS.Workbook();
await wb.xlsx.readFile(handle.path);
expect(wb.worksheets.map((sheet) => sheet.name)).toEqual(["Sheet1", "Sheet1副本"]);
});
it("keeps the shared instance usable after save-as switches the handle path", async () => {
const directory = await mkdtemp(path.join(os.tmpdir(), "rpa-store-")); dirs.push(directory);
const handle = await run("Excel_NewExcel", { filePath: directory, name: "c.xlsx", isMakedirs: "True" }) as { path: string };
await run("Excel_Cs_SetWidth", { workbook: handle, sheet: "Sheet1", columns: "A", width: "18", save: "True" });
const saved = await run("Excel_Cs_SaveAs", { workbook: handle, path: path.join(directory, "d.xlsx") }) as { path: string };
expect(saved.path).toBe(path.resolve(directory, "d.xlsx"));
await run("Excel_Cs_SetHeight", { workbook: handle, sheet: "Sheet1", rows: "1", height: "30", save: "True" });
const wb = new ExcelJS.Workbook();
await wb.xlsx.readFile(path.resolve(directory, "d.xlsx"));
expect(wb.worksheets[0].getColumn("A").width).toBe(18);
expect(wb.worksheets[0].getRow(1).height).toBe(30);
});
});
+34
View File
@@ -0,0 +1,34 @@
import ExcelJS from "exceljs";
import path from "node:path";
// Excel 工作簿共享缓存:同一文件路径在整条流程中复用同一个 ExcelJS 实例。
// 此前 office.ts / office-file-extended.ts / excel-common.ts / extended.ts 各自持有
// 工作簿实例并整簿写回磁盘,后保存者会把先保存者的修改整体覆盖掉。
const cache = new Map<string, ExcelJS.Workbook>();
function keyOf(filePath: string): string {
return path.resolve(filePath);
}
/** 按路径取工作簿:命中缓存直接复用,未命中从磁盘读取后登记。 */
export async function acquireWorkbook(filePath: string): Promise<ExcelJS.Workbook> {
const key = keyOf(filePath);
let workbook = cache.get(key);
if (!workbook) {
workbook = new ExcelJS.Workbook();
await workbook.xlsx.readFile(key);
cache.set(key, workbook);
}
return workbook;
}
/** 把一个新建/另建的工作簿实例登记为该路径的共享实例。 */
export function publishWorkbook(filePath: string, workbook: ExcelJS.Workbook): ExcelJS.Workbook {
cache.set(keyOf(filePath), workbook);
return workbook;
}
/** 释放路径对应的共享实例(关闭工作簿时调用)。 */
export function releaseWorkbook(filePath: string): void {
cache.delete(keyOf(filePath));
}
+2 -2
View File
@@ -3,6 +3,7 @@ import path from "node:path";
import ExcelJS from "exceljs"; import ExcelJS from "exceljs";
import { register } from "../engine/registry"; import { register } from "../engine/registry";
import { RuntimeError, type CapabilityHandler } from "../engine/types"; import { RuntimeError, type CapabilityHandler } from "../engine/types";
import { acquireWorkbook } from "./excel-store";
type Table = unknown[][]; type Table = unknown[][];
@@ -243,8 +244,7 @@ function workbookPath(args: Record<string, unknown>, nodeId: string, componentId
} }
async function loadSheet(args: Record<string, unknown>, nodeId: string, componentId: string): Promise<ExcelJS.Worksheet> { async function loadSheet(args: Record<string, unknown>, nodeId: string, componentId: string): Promise<ExcelJS.Worksheet> {
const workbook = new ExcelJS.Workbook(); const workbook = await acquireWorkbook(workbookPath(args, nodeId, componentId));
await workbook.xlsx.readFile(workbookPath(args, nodeId, componentId));
const name = text(args, ["sheet", "sheetName"]); const name = text(args, ["sheet", "sheetName"]);
const sheet = name ? workbook.getWorksheet(name) : workbook.worksheets[0]; const sheet = name ? workbook.getWorksheet(name) : workbook.worksheets[0];
if (!sheet) throw new Error(`Sheet 不存在:${name || "第一个 Sheet"}`); if (!sheet) throw new Error(`Sheet 不存在:${name || "第一个 Sheet"}`);
@@ -4,6 +4,7 @@ import path from "node:path";
import { PDFDocument } from "pdf-lib"; import { PDFDocument } from "pdf-lib";
import { register } from "../engine/registry"; import { register } from "../engine/registry";
import { RuntimeError, type CapabilityHandler } from "../engine/types"; import { RuntimeError, type CapabilityHandler } from "../engine/types";
import { acquireWorkbook } from "./excel-store";
type WorkbookSource = { workbook: ExcelJS.Workbook; filePath: string }; type WorkbookSource = { workbook: ExcelJS.Workbook; filePath: string };
@@ -29,8 +30,7 @@ function requiredPath(candidate: unknown, label: string, nodeId: string, compone
async function loadWorkbook(args: Record<string, unknown>, nodeId: string, componentId: string): Promise<WorkbookSource> { async function loadWorkbook(args: Record<string, unknown>, nodeId: string, componentId: string): Promise<WorkbookSource> {
const filePath = requiredPath(value(args, ["workbook", "objExcel", "objExcelWorkBook", "file", "path"]), "Excel path", nodeId, componentId); const filePath = requiredPath(value(args, ["workbook", "objExcel", "objExcelWorkBook", "file", "path"]), "Excel path", nodeId, componentId);
const workbook = new ExcelJS.Workbook(); const workbook = await acquireWorkbook(filePath);
await workbook.xlsx.readFile(filePath);
return { workbook, filePath }; return { workbook, filePath };
} }
@@ -134,11 +134,12 @@ const readColumn: CapabilityHandler = async (_ctx, node, args) => {
const sheet = worksheetOf(source.workbook, args); const sheet = worksheetOf(source.workbook, args);
const column = columnNumber(value(args, ["columnName", "column", "cellPos"], "A")); const column = columnNumber(value(args, ["columnName", "column", "cellPos"], "A"));
const startRow = Math.max(1, Number(value(args, ["row", "startRow"], 1))); const startRow = Math.max(1, Number(value(args, ["row", "startRow"], 1)));
return Array.from({ length: Math.max(0, sheet.rowCount - startRow + 1) }, (_, index) => cellValue(sheet.getCell(startRow + index, column).value)); const values = Array.from({ length: Math.max(0, sheet.rowCount - startRow + 1) }, (_, index) => cellValue(sheet.getCell(startRow + index, column).value));
// 触碰过尾部空行(如定位空单元格)会撑大 rowCount,裁掉尾部空值避免误报
while (values.length > 0 && (values[values.length - 1] === "" || values[values.length - 1] === null || values[values.length - 1] === undefined)) values.pop();
return values;
}; };
register(["Excel_Cs_ReadColumn", "Excel_GetColDataNew"], readColumn); register(["Excel_Cs_ReadColumn", "Excel_GetColDataNew"], readColumn);register("Excel_Cs_DeleteRow", async (_ctx, node, args) => {
register("Excel_Cs_DeleteRow", async (_ctx, node, args) => {
const source = await loadWorkbook(args, node.id, node.componentId); const source = await loadWorkbook(args, node.id, node.componentId);
const sheet = worksheetOf(source.workbook, args); const sheet = worksheetOf(source.workbook, args);
const mode = text(args, ["mode"]); const mode = text(args, ["mode"]);
+14 -8
View File
@@ -3,6 +3,7 @@ import { promises as fs } from "node:fs";
import path from "node:path"; import path from "node:path";
import { register } from "../engine/registry"; import { register } from "../engine/registry";
import { RuntimeError, type CapabilityHandler } from "../engine/types"; import { RuntimeError, type CapabilityHandler } from "../engine/types";
import { acquireWorkbook, publishWorkbook, releaseWorkbook } from "./excel-store";
interface ExcelHandle { interface ExcelHandle {
id: string; id: string;
@@ -37,6 +38,7 @@ function required(valueToRead: unknown, label: string, nodeId: string, component
function newHandle(filePath: string, workbook: ExcelJS.Workbook): ExcelHandle { 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 }; const handle = { id: `excel_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 7)}`, path: filePath };
workbooks.set(handle.id, workbook); workbooks.set(handle.id, workbook);
publishWorkbook(filePath, workbook);
return handle; return handle;
} }
@@ -44,11 +46,12 @@ async function resolveWorkbook(args: Record<string, unknown>, nodeId: string, co
const raw = value(args, ["workbook", "objExcel", "objExcelWorkBook", "file", "path"]); 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 id = typeof raw === "object" && raw !== null ? String((raw as Partial<ExcelHandle>).id || "") : String(raw || "");
const existing = id ? workbooks.get(id) : undefined; const existing = id ? workbooks.get(id) : undefined;
if (existing) return { handle: { id, path: String((raw as Partial<ExcelHandle>).path || "") }, workbook: existing }; // 直接返回流程变量持有的原始句柄对象: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 filePath = required(typeof raw === "object" ? (raw as Partial<ExcelHandle>).path : raw, "Excel文件路径", nodeId, componentId);
const workbook = new ExcelJS.Workbook(); const workbook = await acquireWorkbook(filePath);
await workbook.xlsx.readFile(filePath); return { handle: { id, path: filePath }, workbook };
return { handle: newHandle(filePath, workbook), workbook };
} }
function sheetOf(workbook: ExcelJS.Workbook, name: string): ExcelJS.Worksheet { function sheetOf(workbook: ExcelJS.Workbook, name: string): ExcelJS.Worksheet {
@@ -129,10 +132,9 @@ const newExcel: CapabilityHandler = async (_ctx, node, args) => {
}; };
const openExcel: CapabilityHandler = async (_ctx, node, args) => { const openExcel: CapabilityHandler = async (_ctx, node, args) => {
const filePath = required(value(args, ["file", "filePath", "path"]), "Excel文件", node.id, node.componentId); const filePath = path.resolve(required(value(args, ["file", "filePath", "path"]), "Excel文件", node.id, node.componentId));
const workbook = new ExcelJS.Workbook(); const workbook = await acquireWorkbook(filePath);
await workbook.xlsx.readFile(path.resolve(filePath)); return newHandle(filePath, workbook);
return newHandle(path.resolve(filePath), workbook);
}; };
const readExcel: CapabilityHandler = async (_ctx, node, args) => { const readExcel: CapabilityHandler = async (_ctx, node, args) => {
@@ -177,9 +179,13 @@ const saveExcel: CapabilityHandler = async (_ctx, node, args) => {
const saveAsExcel: CapabilityHandler = async (_ctx, node, args) => { const saveAsExcel: CapabilityHandler = async (_ctx, node, args) => {
const { handle, workbook } = await resolveWorkbook(args, node.id, node.componentId); const { handle, workbook } = await resolveWorkbook(args, node.id, node.componentId);
const target = required(value(args, ["path", "file", "savePath"]), "另存为路径", node.id, node.componentId); const target = required(value(args, ["path", "file", "savePath"]), "另存为路径", node.id, node.componentId);
const previousPath = handle.path;
handle.path = path.resolve(target); handle.path = path.resolve(target);
await fs.mkdir(path.dirname(handle.path), { recursive: true }); await fs.mkdir(path.dirname(handle.path), { recursive: true });
await workbook.xlsx.writeFile(handle.path); await workbook.xlsx.writeFile(handle.path);
// 另存后同一实例以新路径登记,旧路径的缓存不再代表最新内容,释放之
publishWorkbook(handle.path, workbook);
if (path.resolve(previousPath) !== handle.path) releaseWorkbook(previousPath);
return handle; return handle;
}; };