This commit is contained in:
yu 2026-10-28 17:02:14 +08:00
commit 69e9554250
7 changed files with 135 additions and 39 deletions

View File

@ -1,6 +1,8 @@
import { readFile, mkdtemp, rm } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import ExcelJS from "exceljs";
import { fileURLToPath } from "node:url";
import { afterEach, describe, expect, it } from "vitest";
import type { Catalog, CatalogComponent, FlowNode } from "../../shared/types";
import type { RunContext } from "../engine/types";
@ -19,7 +21,7 @@ afterEach(async () => Promise.all(dirs.splice(0).map((dir) => rm(dir, { recursiv
describe("common Excel aliases", () => {
it("registers only real catalog aliases", async () => {
const catalog = JSON.parse(await readFile("resources/catalog.json", "utf8")) as Catalog;
const catalog = JSON.parse(await readFile(path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../resources/catalog.json"), "utf8")) as Catalog;
const ids = new Set(catalog.components.map((item) => item.id));
expect(EXCEL_COMMON_ALIASES.length).toBeGreaterThanOrEqual(25);
for (const id of EXCEL_COMMON_ALIASES) { expect(ids.has(id), id).toBe(true); expect(getHandler(id), id).toBeTruthy(); }
@ -82,4 +84,34 @@ describe("common Excel aliases", () => {
const savedAs = await run("Excel_SaveAs", { objExcel: created, filename: "rel-target.xlsx", isAbsolutePath: "False" }) as { path: string };
expect(savedAs.path).toBe(path.resolve(directory, "rel-target.xlsx"));
});
it("combines the Excel_Cs_SaveAs directory path with the name field", async () => {
const directory = await mkdtemp(path.join(os.tmpdir(), "rpa-excel-saveas-cs-")); dirs.push(directory);
const created = await run("Excel_NewExcel", { filePath: directory, name: "cs-origin.xlsx" }) as { path: string };
const targetDir = path.join(directory, "nested");
const savedAs = await run("Excel_Cs_SaveAs", { workbook: created, path: targetDir, name: "cs-target.xlsx" }) as { path: string };
expect(savedAs.path).toBe(path.resolve(targetDir, "cs-target.xlsx"));
const reopened = new ExcelJS.Workbook();
await reopened.xlsx.readFile(savedAs.path);
expect(reopened.worksheets.length).toBeGreaterThan(0);
});
it("clears a range through the Excel_ClearRange cellPos key", async () => {
const directory = await mkdtemp(path.join(os.tmpdir(), "rpa-excel-clear-")); dirs.push(directory);
const created = await run("Excel_NewExcel", { filePath: directory, name: "clear.xlsx" }) as { path: string };
await run("Excel_WriteRange", { objExcel: created, sheet: "Sheet1", cellPos: "A1", data: [["甲", "乙"], ["丙", "丁"]] });
await run("Excel_ClearRange", { objExcel: created, sheet: "Sheet1", cellPos: "A1:B2", isFormat: "False", isSave: "True" });
const reopened = await run("Excel_OpenExcel", { filePath: created.path }) as { id: string };
await expect(run("Excel_Cs_ReadContentV2", { workbook: reopened, sheet: "Sheet1", excelRng: "A1:B2" })).resolves.toEqual([["", ""], ["", ""]]);
});
it("clears through the Excel_Cs_ClearRange startRow/startColumn keys", async () => {
const directory = await mkdtemp(path.join(os.tmpdir(), "rpa-excel-clear-cs-")); dirs.push(directory);
const created = await run("Excel_NewExcel", { filePath: directory, name: "clear-cs.xlsx" }) as { path: string };
await run("Excel_WriteRange", { objExcel: created, sheet: "Sheet1", cellPos: "A1", data: [["1", "2"], ["3", "4"]] });
await run("Excel_Cs_ClearRange", { objExcel: created, sheet: "Sheet1", startRow: "2", startColumn: "A", lastRow: "2", lastColumn: "B", mode: "内容", save: "True" });
const reopened = await run("Excel_OpenExcel", { filePath: created.path }) as { id: string };
await expect(run("Excel_Cs_ReadContentV2", { workbook: reopened, sheet: "Sheet1", excelRng: "A2:B2" })).resolves.toEqual([["", ""]]);
await expect(run("Excel_Cs_ReadContentV2", { workbook: reopened, sheet: "Sheet1", excelRng: "A1:B1" })).resolves.toEqual([["1", "2"]]);
});
});

View File

@ -164,12 +164,30 @@ register("Excel_Cs_DeleteColumn", async (_ctx, node, args) => {
register("Excel_Cs_ClearRangeV4", async (_ctx, node, args) => {
const source = await loadWorkbook(args, node.id, node.componentId);
const sheet = worksheetOf(source.workbook, args);
const range = text(args, ["excelRng", "range"]);
// 兼容各别名版本的键:V4=excelRng、V2/V3=range、Legacy Excel_ClearRange=cellPos、V1=startRow/startColumn/lastRow/lastColumn
let range = text(args, ["excelRng", "range", "cellPos"]);
if (!range) {
const startRow = text(args, ["startRow"]);
const startColumn = text(args, ["startColumn"]);
if (startRow && startColumn) {
const lastRow = text(args, ["lastRow"], startRow);
const lastColumn = text(args, ["lastColumn"], startColumn);
range = `${startColumn}${startRow}:${lastColumn}${lastRow}`;
}
}
if (!range) throw new RuntimeError("validation_error", "Range is empty", { nodeId: node.id, componentId: node.componentId });
const [start, end = start] = range.split(":");
const first = sheet.getCell(start);
const last = sheet.getCell(end);
for (let row = first.row; row <= last.row; row += 1) for (let column = first.col; column <= last.col; column += 1) sheet.getCell(row, column).value = null;
// 清除模式:全部=内容+格式,格式=仅格式,其余(内容/默认)=仅内容;Legacy isFormat=True 同样清格式
const mode = text(args, ["clearType", "mode"], "内容");
const clearFormat = mode.includes("全部") || mode.includes("格式") || truthy(value(args, ["isFormat"]));
const clearValue = !mode.includes("格式");
for (let row = first.row; row <= last.row; row += 1) for (let column = first.col; column <= last.col; column += 1) {
const cell = sheet.getCell(row, column);
if (clearValue) cell.value = null;
if (clearFormat) cell.style = {};
}
await saveWhenRequested(source, args);
return range;
});

View File

@ -178,9 +178,13 @@ const saveExcel: CapabilityHandler = async (_ctx, node, args) => {
const saveAsExcel: CapabilityHandler = async (_ctx, node, args) => {
const { handle, workbook } = await resolveWorkbook(args, node.id, node.componentId);
const raw = required(value(args, ["path", "file", "savePath", "filename", "saveFilePath"]), "另存为路径", 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 = bool(value(args, ["isAbsolutePath"]), true);
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 });

View File

@ -19,7 +19,6 @@ import { useEditorStore } from "../store/editorStore";
import { useRunStore } from "../store/runStore";
import { RpaNode, type RpaNodeData } from "./RpaNode";
import { ContainerNode, containerSlots, type ContainerNodeData } from "./ContainerNode";
import { MousePointerClick } from "lucide-react";
const nodeTypes = { rpa: RpaNode, container: ContainerNode };
@ -256,16 +255,6 @@ function CanvasInner({ catalog, onAddAt }: { catalog: Catalog; onAddAt: (compone
maskColor="rgba(242,245,248,.72)"
/>
</ReactFlow>
{flow.nodes.length <= 1 && (
<div className="canvas-hint">
<MousePointerClick className="icon" />
<div>
<strong>从左侧拖入能力,开始搭建流程</strong>
<span>支持搜索 1016 项目录;单击组件也可追加。</span>
</div>
</div>
)}
</div>
);
}

View File

@ -1,5 +1,5 @@
import { useCallback, useEffect, useState } from "react";
import { ArrowLeft, Check, CircleAlert, CirclePlay, Loader2, Redo2, Save, Square, Undo2, X } from "lucide-react";
import { ArrowLeft, Check, CircleAlert, CirclePlay, Lightbulb, Loader2, Redo2, Save, Square, Undo2, X } from "lucide-react";
import type { Catalog } from "@shared/types";
import { useEditorStore } from "../store/editorStore";
import { useRunStore } from "../store/runStore";
@ -37,6 +37,23 @@ export function EditorPage({ catalog, onToast, onBack }: EditorPageProps) {
const [saveConflict, setSaveConflict] = useState(false);
const [savedAt, setSavedAt] = useState<string | null>(null);
const [validationIssues, setValidationIssues] = useState<FlowValidationIssue[] | null>(null);
// 新建流程首访引导:仅第一次进入空流程页面时展示,5 秒后自动关闭
const [showGuide, setShowGuide] = useState(false);
useEffect(() => {
if (!flow || flow.nodes.length > 1) return;
const seen = window.localStorage.getItem("rpa.editor.guide.seen");
if (seen) return;
setShowGuide(true);
window.localStorage.setItem("rpa.editor.guide.seen", "1");
const timer = window.setTimeout(() => setShowGuide(false), 5000);
return () => window.clearTimeout(timer);
}, [flow?.id]);
useEffect(() => {
// 引导展示期间用户已拖入节点(不再是空流程)则立即收起
if (showGuide && flow && flow.nodes.length > 1) setShowGuide(false);
}, [flow?.nodes.length, showGuide]);
const save = useCallback(async (opts?: { interactive?: boolean }) => {
const current = useEditorStore.getState().flow;
@ -213,6 +230,16 @@ export function EditorPage({ catalog, onToast, onBack }: EditorPageProps) {
<FlowCanvas catalog={catalog} onAddAt={(componentId, position) => addNode(componentId, position)} />
{showGuide && (
<div className="editor-guide" role="status">
<span className="editor-guide-icon"><Lightbulb className="icon" /></span>
<div className="editor-guide-text">
<strong>从左侧「组件库」拖入能力节点,开始搭建流程</strong>
<span>拖拽到画布任意位置,或单击组件快速追加;支持按名称 / ID / 标签搜索。</span>
</div>
</div>
)}
<div className="canvas-badge">
<Check className="icon" />
可执行 {catalog.components.filter((item) => item.implemented).length} / {catalog.counts.total} 项

View File

@ -8,6 +8,7 @@ import type {
FlowNodeOptions,
FlowVariable
} from "@shared/types";
import { useRunStore } from "./runStore";
const clone = <T,>(value: T): T => structuredClone(value);
@ -111,7 +112,12 @@ export const useEditorStore = create<EditorState>((set, get) => {
setCatalog: (catalog) => set({ catalog }),
loadFlow: (flow) => set({ flow, selectedNodeId: null, dirty: false, past: [], future: [] }),
loadFlow: (flow) => {
// 切换流程时清掉上一次的运行面板状态,避免显示旧流程的运行结果
const runState = useRunStore.getState();
if (!runState.running) runState.clear();
set({ flow, selectedNodeId: null, dirty: false, past: [], future: [] });
},
selectNode: (id) => set({ selectedNodeId: id }),

View File

@ -2213,50 +2213,70 @@ textarea:focus-visible {
margin-top: 2px;
}
.canvas-hint {
/* 新建流程首访引导:顶部浮出,5 秒后自动淡出,不接收鼠标事件 */
.editor-guide {
position: absolute;
left: 50%;
top: 44%;
transform: translate(-50%, -50%);
z-index: 4;
min-width: 300px;
max-width: 420px;
padding: 13px 15px;
top: 52px;
transform: translateX(-50%);
z-index: 5;
max-width: 460px;
padding: 11px 16px;
display: flex;
gap: 10px;
align-items: flex-start;
gap: 11px;
align-items: center;
border: 1px solid var(--line);
border-radius: 13px;
background: rgba(255, 255, 255, 0.92);
background: rgba(255, 255, 255, 0.94);
box-shadow: var(--shadow-panel);
backdrop-filter: blur(8px);
pointer-events: none;
animation: guide-life 5s ease forwards;
}
.canvas-hint .icon {
width: 18px;
height: 18px;
color: var(--blue);
.editor-guide-icon {
width: 30px;
height: 30px;
flex: none;
margin-top: 1px;
display: grid;
place-items: center;
border-radius: 9px;
background: var(--blue-soft);
color: var(--blue-dark);
}
.canvas-hint strong {
.editor-guide-icon .icon {
width: 16px;
height: 16px;
}
.editor-guide-text strong {
display: block;
font-size: 11.5px;
font-size: 12.5px;
font-weight: 700;
color: var(--ink);
line-height: 17px;
line-height: 18px;
}
.canvas-hint span {
.editor-guide-text span {
display: block;
font-size: 10.5px;
font-size: 11px;
color: var(--muted);
line-height: 15px;
line-height: 16px;
margin-top: 2px;
}
@keyframes guide-life {
0% { opacity: 0; transform: translateX(-50%) translateY(-10px); }
6% { opacity: 1; transform: translateX(-50%) translateY(0); }
90% { opacity: 1; }
100% { opacity: 0; transform: translateX(-50%) translateY(-6px); }
}
.mono {
font-family: var(--mono);
}