289 lines
19 KiB
TypeScript
289 lines
19 KiB
TypeScript
import fs from "node:fs";
|
||
import path from "node:path";
|
||
import ExcelJS from "exceljs";
|
||
import { register } from "../engine/registry";
|
||
import { RuntimeError, type CapabilityHandler } from "../engine/types";
|
||
import { powershell, psString } from "../driver/windows/powershell";
|
||
import { releaseWorkbook } from "./excel-store";
|
||
|
||
function text(args: Record<string, unknown>, keys: string[], fallback = ""): string {
|
||
for (const key of keys) {
|
||
const value = args[key];
|
||
if (value !== undefined && value !== null && value !== "") return String(value);
|
||
}
|
||
return fallback;
|
||
}
|
||
|
||
function bool(value: unknown, fallback = false): boolean {
|
||
if (value === undefined || value === null || value === "") return fallback;
|
||
return [true, 1, "1", "true", "True", "TRUE", "yes", "是", "开启"].includes(value as never);
|
||
}
|
||
|
||
function number(value: unknown, fallback = 0): number { const result = Number(value); return Number.isFinite(result) ? result : fallback; }
|
||
|
||
function workbookPath(args: Record<string, unknown>, nodeId: string, componentId: string, keys = ["workbook", "objExcel", "objExcelWorkBook", "destWorkbook"]): string {
|
||
for (const key of keys) {
|
||
const raw = args[key];
|
||
const candidate = raw && typeof raw === "object" ? (raw as { path?: unknown }).path : raw;
|
||
const value = String(candidate ?? "").trim();
|
||
if (value && value !== "Excel文件" && value !== "objExcelWorkBook") return path.resolve(value);
|
||
}
|
||
throw new RuntimeError("validation_error", "Excel文件路径为空", { nodeId, componentId });
|
||
}
|
||
|
||
function sheetExpression(args: Record<string, unknown>): string {
|
||
const sheet = text(args, ["sheet", "sheetName"]);
|
||
return sheet ? `$workbook.Worksheets.Item(${psString(sheet)})` : "$workbook.ActiveSheet";
|
||
}
|
||
|
||
async function runExcelCom(filePath: string, body: string, save = false, readonly = false, timeout = 120000): Promise<unknown> {
|
||
const openMode = readonly ? "$true" : "$false";
|
||
const saveMode = save ? "$true" : "$false";
|
||
const script = [
|
||
"$excel=$null;$workbook=$null;$rpaOutput=$null",
|
||
"try {",
|
||
" $excel=New-Object -ComObject Excel.Application;$excel.Visible=$false;$excel.DisplayAlerts=$false",
|
||
" $workbook=$excel.Workbooks.Open(" + psString(filePath) + ",0," + openMode + ")",
|
||
" " + body,
|
||
" if (" + saveMode + ") { $workbook.Save() }",
|
||
" if ($null -ne $rpaOutput) { [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes(($rpaOutput | ConvertTo-Json -Compress -Depth 6))) | Write-Output }",
|
||
"} finally {",
|
||
" if($null -ne $workbook){try{$workbook.Close($false)}catch{};try{[void][Runtime.InteropServices.Marshal]::ReleaseComObject($workbook)}catch{}}",
|
||
" if($null -ne $excel){try{$excel.Quit()}catch{};try{[void][Runtime.InteropServices.Marshal]::ReleaseComObject($excel)}catch{}}",
|
||
" [GC]::Collect();[GC]::WaitForPendingFinalizers()",
|
||
"}"
|
||
].join("\n");
|
||
const output = (await powershell(script, timeout)).trim();
|
||
if (save) releaseWorkbook(filePath);
|
||
if (!output) return null;
|
||
try { return JSON.parse(Buffer.from(output, "base64").toString("utf8")); }
|
||
catch { throw new RuntimeError("excel_com_failed", "Excel COM 输出异常", { detail: output.slice(0, 300) }); }
|
||
}
|
||
|
||
function list(value: unknown): string[] {
|
||
if (Array.isArray(value)) return value.map((item) => String(item)).filter(Boolean);
|
||
const raw = String(value ?? "").trim();
|
||
if (!raw) return [];
|
||
try { const parsed = JSON.parse(raw); return Array.isArray(parsed) ? parsed.map(String) : [String(parsed)]; }
|
||
catch { return raw.split(/[,,;;\s]+/).filter(Boolean); }
|
||
}
|
||
const group: CapabilityHandler = async (_ctx, node, args) => {
|
||
const filePath = workbookPath(args, node.id, node.componentId, ["workbook"]);
|
||
const ungroup = String(args.group ?? "0") === "1";
|
||
const columns = String(args.groupType ?? "0") === "1";
|
||
const start = columns ? text(args, ["startColumn"], "A") : String(number(args.startRow, 1));
|
||
const end = columns ? text(args, ["endColumn"], text(args, ["startColumn"], "A")) : String(number(args.endRow, number(args.startRow, 1)));
|
||
const method = ungroup ? "Ungroup" : "Group";
|
||
const body = `$sheet=${sheetExpression(args)};$start=${psString(start)};$end=${psString(end)};
|
||
$count=if(${columns ? "$true" : "$false"}){$sheet.UsedRange.Columns.Count}else{$sheet.UsedRange.Rows.Count};
|
||
foreach($name in @("start","end")){ $v=[int](Get-Variable $name -ValueOnly); if($v -lt 0){ Set-Variable $name ($count+$v+1) } };
|
||
$range=$sheet.Range("$($start):$($end)");$range.${method}|Out-Null`;
|
||
await runExcelCom(filePath, body, bool(args.save));
|
||
return { grouped: !ungroup, type: columns ? "columns" : "rows" };
|
||
};
|
||
|
||
const deletePicture: CapabilityHandler = async (_ctx, node, args) => {
|
||
const filePath = workbookPath(args, node.id, node.componentId, ["workbook", "objExcel"]);
|
||
const name = text(args, ["name", "pictureName"]);
|
||
if (!name) throw new RuntimeError("validation_error", "图片名称为空", { nodeId: node.id, componentId: node.componentId });
|
||
const body = `$sheet=${sheetExpression(args)};$sheet.Shapes.Item(${psString(name)}).Delete()`;
|
||
await runExcelCom(filePath, body, bool(args.save));
|
||
return { deleted: name };
|
||
};
|
||
|
||
const insertPicture: CapabilityHandler = async (_ctx, node, args) => {
|
||
const filePath = workbookPath(args, node.id, node.componentId, ["workbook", "objExcel"]);
|
||
const image = path.resolve(text(args, ["path", "pictureName"]));
|
||
if (!fs.existsSync(image)) throw new RuntimeError("validation_error", "图片文件不存在", { nodeId: node.id, componentId: node.componentId, detail: image });
|
||
const row = number(args.row, 1); const column = number(args.column, 1);
|
||
const left = number(args.Left ?? args.left, 20); const top = number(args.Top ?? args.top, 20);
|
||
const width = number(args.Width ?? args.width, -1); const height = number(args.Height ?? args.height, -1);
|
||
const scale = width > 0 && height > 0 ? "$true" : "$false";
|
||
const body = `$sheet=${sheetExpression(args)};$cell=$sheet.Cells(${row},${column});$left=${left}+if($left -gt 0){$cell.Left}else{0};$top=${top}+if($top -gt 0){$cell.Top}else{0};[void]$sheet.Shapes.AddPicture(${psString(image)},$false,${scale},$left,$top,${width},${height})`;
|
||
await runExcelCom(filePath, body, bool(args.save));
|
||
return { inserted: image };
|
||
};
|
||
|
||
const encryptExcel: CapabilityHandler = async (_ctx, node, args) => {
|
||
const filePath = workbookPath(args, node.id, node.componentId, ["workbook"]);
|
||
const password = String(args.password1 ?? args.password ?? "");
|
||
const confirm = String(args.password2 ?? password);
|
||
if (!password) throw new RuntimeError("validation_error", "Excel密码为空", { nodeId: node.id, componentId: node.componentId });
|
||
if (password !== confirm) throw new RuntimeError("validation_error", "两次输入的Excel密码不一致", { nodeId: node.id, componentId: node.componentId });
|
||
const temporary = `${filePath}.${Date.now()}.enc.xlsx`;
|
||
try {
|
||
await runExcelCom(filePath, `$workbook.SaveAs(${psString(temporary)},51,${psString(password)},${psString(confirm)})`, false);
|
||
fs.rmSync(temporary, { force: true });
|
||
// COM SaveAs cannot replace the workbook while it is open, so save beside it and rename after Close.
|
||
} finally {
|
||
if (fs.existsSync(temporary)) {
|
||
fs.rmSync(filePath, { force: true });
|
||
fs.renameSync(temporary, filePath);
|
||
}
|
||
releaseWorkbook(filePath);
|
||
}
|
||
return filePath;
|
||
};
|
||
|
||
const moveSheet: CapabilityHandler = async (_ctx, node, args) => {
|
||
const filePath = workbookPath(args, node.id, node.componentId, ["workbook"]);
|
||
const mode = text(args, ["mode"], "按序号"); const after = text(args, ["option"], "之前").includes("之后");
|
||
const targetName = text(args, ["name", "index"]); const position = text(args, ["position", "index"], "第一位");
|
||
const index = number(args.index, 1);
|
||
const afterMode = after ? "$true" : "$false";
|
||
const body = `$sheet=${sheetExpression(args)};$count=$workbook.Worksheets.Count;$targetIndex=0;
|
||
if(${psString(mode)} -eq "按名称"){ $targetName=${psString(targetName)}; for($i=1;$i -le $count;$i++){ if($workbook.Worksheets.Item($i).Name -eq $targetName){$targetIndex=$i;break} }; if($targetIndex -eq 0){throw "target_sheet_not_found"} }
|
||
elseif(${psString(mode)} -eq "按位置"){ $p=${psString(position)}; if($p -eq "第一位"){$targetIndex=1}elseif($p -eq "最后一位"){$targetIndex=$count}else{ $delta=-1; if($p -eq "向后移一位"){$delta=1}; $targetIndex=$sheet.Index+$delta } }
|
||
else { $targetIndex=${index}; if($targetIndex -lt 0){$targetIndex=$count+$targetIndex+1} };
|
||
$targetIndex=[Math]::Min([Math]::Max(1,$targetIndex),$count);$target=$workbook.Worksheets.Item($targetIndex);
|
||
if(${afterMode}){ if($target.Index -ne $sheet.Index){ $sheet.Move([Type]::Missing,$target)|Out-Null } }
|
||
else { if($target.Index -ne $sheet.Index){ $sheet.Move($target)|Out-Null } }`;
|
||
await runExcelCom(filePath, body, bool(args.save));
|
||
return { moved: true };
|
||
};
|
||
const mergeSheet: CapabilityHandler = async (_ctx, node, args) => {
|
||
const filePath = workbookPath(args, node.id, node.componentId, ["workbook"]);
|
||
const destPath = workbookPath(args, node.id, node.componentId, ["destWorkbook", "workbook"]);
|
||
const horizontal = String(args.option ?? "0") === "1";
|
||
const horizontalMode = horizontal ? "$true" : "$false";
|
||
const saveDest = bool(args.save) ? "$true" : "$false";
|
||
const body = `$source=${sheetExpression(args)};$dest=$excel.Workbooks.Open(${psString(destPath)});$target=$dest.Worksheets.Item(${psString(text(args, ["destSheet"], "Sheet1"))});
|
||
if(${horizontalMode}){$offset=$target.UsedRange.Columns.Count+1}else{$offset=$target.UsedRange.Rows.Count+1};
|
||
for($r=1;$r -le $source.UsedRange.Rows.Count;$r++){for($c=1;$c -le $source.UsedRange.Columns.Count;$c++){ if(${horizontalMode}){$target.Cells($r,$offset+$c-1).Value2=$source.Cells($r,$c).Value2}else{$target.Cells($offset+$r-1,$c).Value2=$source.Cells($r,$c).Value2}}};
|
||
if(${saveDest}){$dest.Save()}`;
|
||
await runExcelCom(filePath, body, false);
|
||
return { merged: true, target: destPath };
|
||
};
|
||
|
||
const refreshAll: CapabilityHandler = async (_ctx, node, args) => {
|
||
const filePath = workbookPath(args, node.id, node.componentId, ["workbook"]);
|
||
await runExcelCom(filePath, "$workbook.RefreshAll()", bool(args.save));
|
||
return { refreshed: true };
|
||
};
|
||
|
||
const refreshPivot: CapabilityHandler = async (_ctx, node, args) => {
|
||
const filePath = workbookPath(args, node.id, node.componentId, ["workbook"]);
|
||
const name = text(args, ["pivotTable"]); const sheetExpr = sheetExpression(args);
|
||
const body = name ? `${sheetExpr}.PivotTables(${psString(name)}).RefreshTable()` : `${sheetExpr}.PivotTables() | ForEach-Object { $_.RefreshTable() }`;
|
||
await runExcelCom(filePath, body, bool(args.save));
|
||
return { refreshed: true, name: name || "all" };
|
||
};
|
||
|
||
const filterPivot: CapabilityHandler = async (_ctx, node, args) => {
|
||
const filePath = workbookPath(args, node.id, node.componentId, ["workbook"]);
|
||
const field = text(args, ["field"]); const values = list(args.fieldValue);
|
||
if (!field) throw new RuntimeError("validation_error", "筛选字段为空", { nodeId: node.id, componentId: node.componentId });
|
||
const encoded = Buffer.from(JSON.stringify(values), "utf16le").toString("base64");
|
||
const body = `$pt=${sheetExpression(args)}.PivotTables(${psString(text(args, ["pivotTable"]))});$pf=$pt.PivotFields(${psString(field)});
|
||
try{$pf.ClearAllFilters()}catch{};
|
||
$wanted=[Text.Encoding]::Unicode.GetString([Convert]::FromBase64String("${encoded}"))|ConvertFrom-Json;
|
||
foreach($item in $pf.PivotItems()){ $selected=if($wanted.Count -eq 0){$true}else{$wanted -contains $item.Name}; $item.Visible=$selected }`;
|
||
await runExcelCom(filePath, body, bool(args.save));
|
||
return { field, values };
|
||
};
|
||
|
||
const createPivot: CapabilityHandler = async (_ctx, node, args) => {
|
||
const filePath = workbookPath(args, node.id, node.componentId, ["workbook"]);
|
||
const chart = node.componentId.includes("PivotView");
|
||
let rule: Record<string, unknown> = {};
|
||
try { rule = JSON.parse(String(args.rule || "{}")) as Record<string, unknown>; } catch { /* sourceData can still be supplied separately. */ }
|
||
const source = text({ ...rule, ...args }, ["SourceData", "sourceData", "source"]);
|
||
if (!source) throw new RuntimeError("validation_error", "透视表数据源为空", { nodeId: node.id, componentId: node.componentId });
|
||
const name = text(rule, ["Name", "name"], chart ? "RPAPivotChart" : "RPAPivotTable");
|
||
const body = `$sheet=${sheetExpression(args)};$cache=$excel.ActiveWorkbook.PivotCaches().Create(1,$sheet.Range(${psString(source)}));
|
||
$dest=$sheet.Cells(1,20);$pt=$cache.CreatePivotTable($dest,${psString(name)});
|
||
if(${chart ? "$true" : "$false"}){[void]$sheet.Shapes.AddChart(-4109,20,20,420,260)}`;
|
||
await runExcelCom(filePath, body, bool(args.save));
|
||
return { name, chart };
|
||
};
|
||
const runMacro = (scriptKey: "script" | "code" | "macroName"): CapabilityHandler => async (_ctx, node, args) => {
|
||
const filePath = workbookPath(args, node.id, node.componentId, ["workbook", "objExcel"]);
|
||
const macro = text(args, [scriptKey, "script", "code", "macroName", "name"]);
|
||
if (!macro) throw new RuntimeError("validation_error", "宏名称为空", { nodeId: node.id, componentId: node.componentId });
|
||
const parameters = list(args.args ?? args.macroArgs).map((item) => psString(item)).join(",");
|
||
const body = `$result=$excel.Run(${psString(macro)}${parameters ? "," + parameters : ""});$rpaOutput=@{result=$result}`;
|
||
return await runExcelCom(filePath, body, bool(args.save), false, 180000);
|
||
};
|
||
|
||
const interop: CapabilityHandler = async (_ctx, node, args) => {
|
||
const filePath = workbookPath(args, node.id, node.componentId, ["workbook", "objExcel"]);
|
||
const property = text(args, ["property"]);
|
||
if (!property) throw new RuntimeError("validation_error", "属性或方法名为空", { nodeId: node.id, componentId: node.componentId });
|
||
const objectKind = number(args.obj ?? args.object, 0);
|
||
const range = text(args, ["range"], "A1");
|
||
const target = objectKind === 0 ? "$excel" : objectKind === 1 ? "$workbook" : objectKind === 2 ? sheetExpression(args) : `${sheetExpression(args)}.Range(${psString(range)})`;
|
||
const value = args.value;
|
||
const empty = value === undefined || value === null || value === "";
|
||
const body = empty
|
||
? `$target=${target};$rpaOutput=@{value=$target.GetType().InvokeMember(${psString(property)},[Reflection.BindingFlags]::GetProperty,$null,$target,@())}`
|
||
: `$target=${target};[void]$target.GetType().InvokeMember(${psString(property)},[Reflection.BindingFlags]::SetProperty,$null,$target,@(${psString(String(value))}));$rpaOutput=@{set=$true}`;
|
||
return await runExcelCom(filePath, body, bool(args.save));
|
||
};
|
||
|
||
const exportPdf: CapabilityHandler = async (_ctx, node, args) => {
|
||
const filePath = workbookPath(args, node.id, node.componentId, ["workbook"]);
|
||
const folder = text(args, ["path"]);
|
||
const name = text(args, ["name"], path.basename(filePath, path.extname(filePath)) + ".pdf");
|
||
const output = path.join(path.resolve(folder), /\.(pdf)$/i.test(name) ? name : `${name}.pdf`);
|
||
fs.mkdirSync(path.dirname(output), { recursive: true });
|
||
if (!bool(args.cover, true) && fs.existsSync(output)) throw new RuntimeError("validation_error", "PDF 文件已存在", { detail: output });
|
||
const activateOnly = text(args, ["option"], "全部") === "激活页";
|
||
const body = `if(${activateOnly ? "$true" : "$false"}){$workbook.ActiveSheet.ExportAsFixedFormat(0,${psString(output)})}else{$workbook.ExportAsFixedFormat(0,${psString(output)})}`;
|
||
await runExcelCom(filePath, body, false);
|
||
return output;
|
||
};
|
||
|
||
const exportRangePicture: CapabilityHandler = async (_ctx, node, args) => {
|
||
const filePath = workbookPath(args, node.id, node.componentId, ["workbook"]);
|
||
const folder = text(args, ["path"]); const name = text(args, ["name"], `excel-range-${Date.now()}`);
|
||
const output = path.join(path.resolve(folder), /\.(png|jpg|jpeg)$/i.test(name) ? name : `${name}.png`);
|
||
fs.mkdirSync(path.dirname(output), { recursive: true });
|
||
const body = `$range=${sheetExpression(args)}.Range(${psString(text(args, ["range"], "A1"))});$range.CopyPicture(1,2)|Out-Null;$shape=${sheetExpression(args)}.ChartObjects().Add(0,0,$range.Width,$range.Height);$chart=$shape.Chart;$chart.Paste();[void]$chart.Export(${psString(output)},"PNG");$shape.Delete()`;
|
||
await runExcelCom(filePath, body, false);
|
||
return output;
|
||
};
|
||
|
||
const setSlicer: CapabilityHandler = async (_ctx, node, args) => {
|
||
const filePath = workbookPath(args, node.id, node.componentId, ["workbook"]);
|
||
const name = text(args, ["slicerName"]); const values = list(args.slicerOptions);
|
||
if (!name) throw new RuntimeError("validation_error", "切片器名称为空", { nodeId: node.id, componentId: node.componentId });
|
||
const encoded = Buffer.from(JSON.stringify(values), "utf16le").toString("base64");
|
||
const body = `$cache=$workbook.SlicerCaches.Item(${psString(name)});
|
||
$wanted=[Text.Encoding]::Unicode.GetString([Convert]::FromBase64String("${encoded}"))|ConvertFrom-Json;
|
||
foreach($item in $cache.SlicerItems()){ $item.Selected=if($wanted.Count -eq 0){$true}else{$wanted -contains $item.Name} }`;
|
||
await runExcelCom(filePath, body, bool(args.save));
|
||
return { slicer: name, values };
|
||
};
|
||
|
||
const excelLoop: CapabilityHandler = async (_ctx, node, args) => {
|
||
const filePath = workbookPath(args, node.id, node.componentId, ["objExcel", "workbook"]);
|
||
const workbook = new ExcelJS.Workbook();
|
||
await workbook.xlsx.readFile(filePath);
|
||
const rows: unknown[][] = [];
|
||
for (const sheet of workbook.worksheets) {
|
||
for (let row = 1; row <= sheet.rowCount; row += 1) {
|
||
const cells: unknown[] = [];
|
||
for (let column = 1; column <= sheet.columnCount; column += 1) cells.push(sheet.getCell(row, column).value ?? "");
|
||
if (cells.some((cell) => cell !== "")) rows.push(cells);
|
||
}
|
||
}
|
||
return rows;
|
||
};
|
||
|
||
register("Excel_Cs_Group", group);
|
||
register("Excel_Cs_Encrypt", encryptExcel);
|
||
register(["Excel_Cs_DeletePicture", "Excel_Cs_DeletePictureV2", "Excel_DeleteImage"], deletePicture);
|
||
register(["Excel_Cs_InsertPicture", "Excel_Cs_InsertPictureV2", "Excel_InsertImage"], insertPicture);
|
||
register("Excel_Cs_Move", moveSheet);
|
||
register("Excel_Cs_Merge", mergeSheet);
|
||
register(["Excel_Cs_RefreshAll", "Excel_Cs_RefreshPivotTable"], async (ctx, node, args, component) => node.componentId === "Excel_Cs_RefreshPivotTable" ? refreshPivot(ctx, node, args, component) : refreshAll(ctx, node, args, component));
|
||
register("Excel_Cs_FilterPivotTable", filterPivot);
|
||
register(["Excel_Cs_InsertPivotTable", "Excel_Cs_InsertPivotView"], createPivot);
|
||
register(["Excel_Cs_RunMacro", "Excel_Cs_RunMacroV2", "Excel_ExecuteMacro"], runMacro("script"));
|
||
register("Excel_Cs_Interop", interop);
|
||
register("Excel_Cs_Export", exportPdf);
|
||
register("Excel_Cs_ExcelAddin", runMacro("code"));
|
||
register("Excel_Cs_SetSlicer", setSlicer);
|
||
register("Excel_Loop", excelLoop);
|
||
register("Excel_ExportRangeAsPicture", exportRangePicture);
|