Merge branch 'main' of http://43.140.215.244:7040/wangyukun/PRA-only-demo
This commit is contained in:
commit
69e9554250
@ -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"]]);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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;
|
||||
});
|
||||
|
||||
@ -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 });
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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} 项
|
||||
|
||||
@ -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 }),
|
||||
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user