diff --git a/src/main/capabilities/office-aliases.test.ts b/src/main/capabilities/office-aliases.test.ts index 2f5228d..f89e59a 100644 --- a/src/main/capabilities/office-aliases.test.ts +++ b/src/main/capabilities/office-aliases.test.ts @@ -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"]]); + }); }); diff --git a/src/main/capabilities/office-file-extended.ts b/src/main/capabilities/office-file-extended.ts index d6dcec6..789beb3 100644 --- a/src/main/capabilities/office-file-extended.ts +++ b/src/main/capabilities/office-file-extended.ts @@ -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; }); diff --git a/src/main/capabilities/office.ts b/src/main/capabilities/office.ts index 134b06d..9786cb2 100644 --- a/src/main/capabilities/office.ts +++ b/src/main/capabilities/office.ts @@ -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 }); diff --git a/src/renderer/src/canvas/FlowCanvas.tsx b/src/renderer/src/canvas/FlowCanvas.tsx index 1d2ed1f..9e52c4b 100644 --- a/src/renderer/src/canvas/FlowCanvas.tsx +++ b/src/renderer/src/canvas/FlowCanvas.tsx @@ -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)" /> - - {flow.nodes.length <= 1 && ( -
- -
- 从左侧拖入能力,开始搭建流程 - 支持搜索 1016 项目录;单击组件也可追加。 -
-
- )} ); } diff --git a/src/renderer/src/pages/EditorPage.tsx b/src/renderer/src/pages/EditorPage.tsx index b687909..8b43417 100644 --- a/src/renderer/src/pages/EditorPage.tsx +++ b/src/renderer/src/pages/EditorPage.tsx @@ -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(null); const [validationIssues, setValidationIssues] = useState(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) { addNode(componentId, position)} /> + {showGuide && ( +
+ +
+ 从左侧「组件库」拖入能力节点,开始搭建流程 + 拖拽到画布任意位置,或单击组件快速追加;支持按名称 / ID / 标签搜索。 +
+
+ )} +
可执行 {catalog.components.filter((item) => item.implemented).length} / {catalog.counts.total} 项 diff --git a/src/renderer/src/store/editorStore.ts b/src/renderer/src/store/editorStore.ts index 7fc31d4..1c2bf21 100644 --- a/src/renderer/src/store/editorStore.ts +++ b/src/renderer/src/store/editorStore.ts @@ -8,6 +8,7 @@ import type { FlowNodeOptions, FlowVariable } from "@shared/types"; +import { useRunStore } from "./runStore"; const clone = (value: T): T => structuredClone(value); @@ -111,7 +112,12 @@ export const useEditorStore = create((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 }), diff --git a/src/renderer/src/styles/app.css b/src/renderer/src/styles/app.css index cee8628..466ce26 100644 --- a/src/renderer/src/styles/app.css +++ b/src/renderer/src/styles/app.css @@ -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); }