diff --git a/README.md b/README.md index 1fb8309..0836614 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,7 @@ docs 现状分析、实施方案 ## 边界 -- 1016 项目录中当前有 412 项通过能力注册表或运行时容器标记为可执行,覆盖控制/触发、字符串/集合/INI/二维表格与 Databook、数组重塑、时间转换、文件、浏览器元素常用及 DOM 高级能力、HTTP、Excel/CSV与表格辅助、系统路径与环境变量、进程管理、桌面键鼠/窗口对象/剪贴板/对话框、PDF、Word、邮件发送、文本差异、工作日判断、屏幕截图、离线图像查找与点击;其余灰标「待接入」。运行前检查会提前定位待接入组件、必填参数、多出口与断链节点。 +- 1016 项目录中当前有 428 项通过能力注册表或运行时容器标记为可执行,覆盖控制/触发、字符串/集合/INI/二维表格与 Databook、数组重塑、时间转换、文件、浏览器元素常用及 DOM 高级能力、HTTP、Excel/CSV与表格辅助、系统路径与环境变量、进程管理、桌面键鼠/窗口对象/剪贴板/对话框、PDF、Word 文档编辑、SMTP 邮件变体、文本差异、工作日判断、屏幕截图、离线图像查找与点击;其余灰标「待接入」。运行前检查会提前定位待接入组件、必填参数、多出口与断链节点。 - 内置本地演示站默认监听 `127.0.0.1:17863`;新用户首次启动会获得“本地网页订单演示”任务,可离线验证网页输入、点击和文本读取。 - 浏览器可信输入与定位机制参考 `C:\rpa-demo`:CDP Input、唯一定位、遮挡检查和输入回读校验;不使用 `el.click()` 伪造点击。 - 不包含厂商代码、二进制、模型或前端资产;目录是从厂商元数据派生的静态定义。 diff --git a/docs/02-实施方案.md b/docs/02-实施方案.md index 27bab7a..6921b7c 100644 --- a/docs/02-实施方案.md +++ b/docs/02-实施方案.md @@ -4,7 +4,7 @@ > 依据:`D:\code\Agent-Employee`(只读)、`C:\rpa-demo`(只读)、2026-10-26 需求确认 > 本方案是执行基线:范围、契约、排期、验收、降级顺序都在这里;实施中变更必须回写本文件。 -> **2026-10-27 当前状态**:计划点名的代表能力已全部进入注册表或运行时容器,覆盖控制/触发、字符串/集合/INI/二维表格与 Databook、数组重塑、时间转换、文件、浏览器元素常用及 DOM 高级能力、HTTP、Excel/CSV与表格辅助、系统路径与环境变量、进程管理、桌面键鼠/窗口对象/剪贴板/对话框、PDF、Word、邮件发送、文本差异、工作日判断、屏幕截图、离线图像查找与点击;启动后目录显示 **412 项可执行 / 1016 项总目录**。当前 16 个测试文件共 108 条测试,覆盖计划能力审计、浏览器参数路由/变体/DOM、Excel/PDF/Word/SMTP/模板匹配、文件与数据扩展、系统/进程/窗口解析、本地工具与 Databook、直线/IF/循环/重试/跳过、主出口与异常出口语义、运行记录回写、运行前检查、本地演示站与示例流程。设置页可查看分类覆盖和处理器 ID 数量。未通过实际场景验证的变体仍不计入最终交付数量。 +> **2026-10-27 当前状态**:计划点名的代表能力已全部进入注册表或运行时容器,覆盖控制/触发、字符串/集合/INI/二维表格与 Databook、数组重塑、时间转换、文件、浏览器元素常用及 DOM 高级能力、HTTP、Excel/CSV与表格辅助、系统路径与环境变量、进程管理、桌面键鼠/窗口对象/剪贴板/对话框、PDF、Word 文档编辑、SMTP 邮件变体、文本差异、工作日判断、屏幕截图、离线图像查找与点击;启动后目录显示 **428 项可执行 / 1016 项总目录**。当前 17 个测试文件共 110 条测试,覆盖计划能力审计、浏览器参数路由/变体/DOM、Excel/PDF/Word COM/SMTP/模板匹配、文件与数据扩展、系统/进程/窗口解析、本地工具与 Databook、直线/IF/循环/重试/跳过、主出口与异常出口语义、运行记录回写、运行前检查、本地演示站与示例流程。设置页可查看分类覆盖和处理器 ID 数量。未通过实际场景验证的变体仍不计入最终交付数量。 > 浏览器端到端门禁:`npm run test:e2e` 使用隔离用户目录启动应用和本地演示站,真实执行打开网页、两次可信输入、点击提交和读取结果;期望结果必须精确包含“订单创建成功:RPA 客户,金额 128”。浏览器输入、点击、定位歧义和遮挡检查机制已对照 `C:\rpa-demo/actions.js` 校准。 diff --git a/scripts/demo-e2e.mjs b/scripts/demo-e2e.mjs index e6eccaf..429362e 100644 --- a/scripts/demo-e2e.mjs +++ b/scripts/demo-e2e.mjs @@ -1,4 +1,4 @@ -import { spawn } from "node:child_process"; +import { spawn, spawnSync } from "node:child_process"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; @@ -85,7 +85,12 @@ try { if (result.failed?.length) throw new Error(`存在失败节点:${JSON.stringify(result.failed)}`); console.log(JSON.stringify({ ok: true, ...result }, null, 2)); } finally { - child.kill(); - await new Promise((resolve) => setTimeout(resolve, 300)); - fs.rmSync(profile, { recursive: true, force: true }); + if (process.platform === "win32") spawnSync("taskkill", ["/PID", String(child.pid), "/T", "/F"], { windowsHide: true, stdio: "ignore" }); + else child.kill("SIGKILL"); + await new Promise((resolve) => setTimeout(resolve, 500)); + try { + fs.rmSync(profile, { recursive: true, force: true, maxRetries: 12, retryDelay: 250 }); + } catch (error) { + console.warn(`E2E 临时目录稍后由系统清理:${profile}`, (error).message); + } } diff --git a/src/main/capabilities/capabilities.test.ts b/src/main/capabilities/capabilities.test.ts index 5445938..aefc38b 100644 --- a/src/main/capabilities/capabilities.test.ts +++ b/src/main/capabilities/capabilities.test.ts @@ -143,6 +143,14 @@ describe("capability registration smoke", () => { }, component); expect(received).toContain("RPA smoke"); expect(received).toContain("mail delivered"); + const union = getHandler("Mail_UnionSend_v1"); + expect(union).toBeTruthy(); + await union!(context, node("Mail_UnionSend_v1"), { + account_obj: { host: "127.0.0.1", port, account: "robot@example.com" }, + receiver: "owner@example.com", subject: "Union smoke", content: "union delivered" + }, component); + expect(received).toContain("Union smoke"); + expect(received).toContain("union delivered"); } finally { await new Promise((resolve) => server.close(() => resolve())); } diff --git a/src/main/capabilities/index.ts b/src/main/capabilities/index.ts index 148c1e4..11d6578 100644 --- a/src/main/capabilities/index.ts +++ b/src/main/capabilities/index.ts @@ -9,6 +9,7 @@ import "./office"; import "./desktop"; import "./pdf"; import "./word"; +import "./word-extended"; import "./message"; import "./vision"; import "./extended"; diff --git a/src/main/capabilities/message.ts b/src/main/capabilities/message.ts index cb44836..ee01ed9 100644 --- a/src/main/capabilities/message.ts +++ b/src/main/capabilities/message.ts @@ -10,13 +10,22 @@ function list(value: string): string[] { return value.split(/[;,,;\s]+/).map((item) => item.trim()).filter(Boolean); } -register(["Mail_Send", "Mail_UnionSend"], async (_ctx, node, args) => { - const account = text(args, ["account", "from", "account_obj"]); - const password = text(args, ["password", "pass"]); - const host = text(args, ["host", "smtp"]); +function accountObject(value: unknown): Record { + if (value && typeof value === "object") return value as Record; + if (typeof value === "string" && value.trim().startsWith("{")) { + try { return JSON.parse(value); } catch { return {}; } + } + return {}; +} + +register(["Mail_Send", "Mail_Send_V1", "Mail_UnionSend", "Mail_UnionSend_v1"], async (_ctx, node, args) => { + const configured = accountObject(args.account_obj); + const account = text({ ...configured, ...args }, ["account", "from", "username", "user"]); + const password = text({ ...configured, ...args }, ["password", "pass"]); + const host = text({ ...configured, ...args }, ["host", "smtp", "smtpHost", "server"]); if (!host || !account) throw new Error(`邮件配置不完整(${node.componentId})`); - const port = Number(text(args, ["port"], "25")); - const secure = ["true", "1", "是", "True"].includes(text(args, ["is_ssl"], "False")); + const port = Number(text({ ...configured, ...args }, ["port", "smtpPort"], "25")); + const secure = ["true", "1", "是", "True"].includes(text({ ...configured, ...args }, ["is_ssl", "secure", "ssl"], "False")); const transporter = nodemailer.createTransport({ host, port, secure, auth: password ? { user: account, pass: password } : undefined }); const result = await transporter.sendMail({ from: account, diff --git a/src/main/capabilities/word-extended.test.ts b/src/main/capabilities/word-extended.test.ts new file mode 100644 index 0000000..03cce03 --- /dev/null +++ b/src/main/capabilities/word-extended.test.ts @@ -0,0 +1,36 @@ +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +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 "./word"; +import "./word-extended"; + +const directories: 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 = {}) { const handler = getHandler(id); expect(handler, id).toBeTruthy(); return handler!(context, node(id), args, component); } +afterEach(async () => Promise.all(directories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })))); + +const ids = ["Word_New_V1", "Word_GetPath", "Word_ReadAll", "Word_Replace", "Word_Enter", "Word_InsertPage", "Word_SetFontName", "Word_SetFontSize", "Word_SetFontStyle", "Word_SetFontColor", "Word_SetAlign", "Word_InsertPicture", "Word_WriteTable", "Word_Close"]; + +describe("Word extended capabilities", () => { + it("registers automated Word operations", () => { for (const id of ids) expect(getHandler(id), id).toBeTruthy(); }); + + it("creates, writes, reads and replaces Word content", async () => { + if (process.platform !== "win32") return; + const directory = await mkdtemp(path.join(os.tmpdir(), "rpa-word-extended-")); directories.push(directory); + const handle = await run("Word_New_V1", { filePath: directory, name: "extended.docx" }); + await run("Word_Write", { objWord: handle, text: "原始内容" }); + await expect(run("Word_ReadAll", { objWord: handle })).resolves.toContain("原始内容"); + await run("Word_Replace", { objWord: handle, oldText: "原始", newText: "替换后" }); + await expect(run("Word_ReadAll", { objWord: handle })).resolves.toContain("替换后内容"); + await run("Word_SetFontSize", { objWord: handle, fontSize: 12 }); + await run("Word_SetFontStyle", { objWord: handle, isBold: "True", isItalic: "False", isUnderline: "False" }); + await run("Word_Enter", { objWord: handle }); + expect((await readFile(path.join(directory, "extended.docx"))).byteLength).toBeGreaterThan(1000); + }, 60000); +}); diff --git a/src/main/capabilities/word-extended.ts b/src/main/capabilities/word-extended.ts new file mode 100644 index 0000000..8fb454e --- /dev/null +++ b/src/main/capabilities/word-extended.ts @@ -0,0 +1,124 @@ +import { promises as fs } from "node:fs"; +import path from "node:path"; +import { getHandler, register } from "../engine/registry"; +import { RuntimeError } from "../engine/types"; +import { powershell, psString } from "./desktop"; + +interface WordHandle { path: string; } + +function text(args: Record, keys: string[], fallback = ""): string { + for (const key of keys) { + const current = args[key]; + if (current !== undefined && current !== null && current !== "") return String(current); + } + return fallback; +} + +function bool(value: unknown): boolean { + return [true, 1, "1", "true", "True", "是", "开启"].includes(value as never); +} + +function handlePath(args: Record, nodeId: string, componentId: string): string { + const raw = args.objWord ?? args.filePath ?? args.path; + const candidate = raw && typeof raw === "object" ? (raw as Partial).path : raw; + const result = String(candidate || "").trim(); + if (!result || result === "word对象") throw new RuntimeError("validation_error", "Word文档路径为空", { nodeId, componentId }); + return path.resolve(result); +} + +function script(filePath: string, body: string, save = true): string { + return `$word=$null;$doc=$null;try{$word=New-Object -ComObject Word.Application;$word.Visible=$false;$word.DisplayAlerts=0;$doc=$word.Documents.Open(${psString(filePath)});${body};${save ? "$doc.Save();" : ""}}finally{if($doc -ne $null){try{$doc.Close()}catch{};try{[void][Runtime.InteropServices.Marshal]::ReleaseComObject($doc)}catch{}};if($word -ne $null){try{$word.Quit()}catch{};try{[void][Runtime.InteropServices.Marshal]::ReleaseComObject($word)}catch{}};[GC]::Collect();[GC]::WaitForPendingFinalizers()}`; +} + +register("Word_New_V1", async (ctx, node, args, component) => { + const base = getHandler("Word_New"); + if (!base) throw new RuntimeError("capability_not_implemented", "基础 Word 新建能力不可用", { nodeId: node.id, componentId: node.componentId }); + return base(ctx, node, args, component); +}); + +register("Word_GetPath", (_ctx, node, args) => handlePath(args, node.id, node.componentId)); + +register("Word_ReadAll", async (_ctx, node, args) => { + const filePath = handlePath(args, node.id, node.componentId); + const encoded = await powershell(script(filePath, "[Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($doc.Content.Text))", false)); + return Buffer.from(encoded.trim(), "base64").toString("utf8"); +}); + +register("Word_Replace", async (_ctx, node, args) => { + const filePath = handlePath(args, node.id, node.componentId); + const oldText = text(args, ["oldText"]); + if (!oldText) throw new RuntimeError("validation_error", "原内容为空", { nodeId: node.id, componentId: node.componentId }); + const newText = text(args, ["newText"]); + await powershell(script(filePath, `$find=$doc.Content.Find;[void]$find.Execute(${psString(oldText)},$false,$false,$false,$false,$false,$true,1,$false,${psString(newText)},2)`)); + return { path: filePath } satisfies WordHandle; +}); + +register("Word_Enter", async (_ctx, node, args) => { + const filePath = handlePath(args, node.id, node.componentId); + await powershell(script(filePath, "$range=$doc.Content;$range.Collapse(0);$range.InsertParagraphAfter()")); + return { path: filePath } satisfies WordHandle; +}); + +register("Word_InsertPage", async (_ctx, node, args) => { + const filePath = handlePath(args, node.id, node.componentId); + await powershell(script(filePath, "$range=$doc.Content;$range.Collapse(0);$range.InsertBreak(7)")); + return { path: filePath } satisfies WordHandle; +}); + +register("Word_SetFontName", async (_ctx, node, args) => { + const filePath = handlePath(args, node.id, node.componentId); + await powershell(script(filePath, `$doc.Content.Font.Name=${psString(text(args, ["font"], "宋体"))}`)); + return { path: filePath } satisfies WordHandle; +}); + +register("Word_SetFontSize", async (_ctx, node, args) => { + const filePath = handlePath(args, node.id, node.componentId); + const size = Number(text(args, ["fontSize"], "9")); + if (!(size > 0)) throw new RuntimeError("validation_error", "字体大小无效", { nodeId: node.id, componentId: node.componentId }); + await powershell(script(filePath, `$doc.Content.Font.Size=${size}`)); + return { path: filePath } satisfies WordHandle; +}); + +register("Word_SetFontStyle", async (_ctx, node, args) => { + const filePath = handlePath(args, node.id, node.componentId); + await powershell(script(filePath, `$doc.Content.Font.Bold=${bool(args.isBold) ? 1 : 0};$doc.Content.Font.Italic=${bool(args.isItalic) ? 1 : 0};$doc.Content.Font.Underline=${bool(args.isUnderline) ? 1 : 0}`)); + return { path: filePath } satisfies WordHandle; +}); + +register("Word_SetFontColor", async (_ctx, node, args) => { + const filePath = handlePath(args, node.id, node.componentId); + const hex = text(args, ["fontColor"], "#000000").replace("#", ""); + if (!/^[0-9a-fA-F]{6}$/.test(hex)) throw new RuntimeError("validation_error", "字体颜色格式无效", { nodeId: node.id, componentId: node.componentId }); + const red = Number.parseInt(hex.slice(0, 2), 16); const green = Number.parseInt(hex.slice(2, 4), 16); const blue = Number.parseInt(hex.slice(4, 6), 16); + await powershell(script(filePath, `$doc.Content.Font.Color=${red + green * 256 + blue * 65536}`)); + return { path: filePath } satisfies WordHandle; +}); + +register("Word_SetAlign", async (_ctx, node, args) => { + const filePath = handlePath(args, node.id, node.componentId); + const align = text(args, ["align"], "left").toLowerCase(); + const value = align.includes("center") || align === "1" ? 1 : align.includes("right") || align === "2" ? 2 : align.includes("justify") || align === "3" ? 3 : 0; + await powershell(script(filePath, `$doc.Content.ParagraphFormat.Alignment=${value}`)); + return { path: filePath } satisfies WordHandle; +}); + +register("Word_InsertPicture", async (_ctx, node, args) => { + const filePath = handlePath(args, node.id, node.componentId); + const image = path.resolve(text(args, ["imageaddr"])); + await fs.access(image); + await powershell(script(filePath, `$range=$doc.Content;$range.Collapse(0);[void]$doc.InlineShapes.AddPicture(${psString(image)},$false,$true,$range)`)); + return { path: filePath } satisfies WordHandle; +}); + +register("Word_WriteTable", async (_ctx, node, args) => { + const filePath = handlePath(args, node.id, node.componentId); + let data: unknown = args.data; + if (typeof data === "string") { try { data = JSON.parse(data); } catch { data = [[data]]; } } + const rows = Array.isArray(data) ? data.map((row) => Array.isArray(row) ? row : [row]) : [[data]]; + const columns = Math.max(1, ...rows.map((row) => row.length)); + const encoded = Buffer.from(JSON.stringify(rows), "utf8").toString("base64"); + await powershell(script(filePath, `$json=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String(${psString(encoded)}));$data=$json|ConvertFrom-Json;$range=$doc.Content;$range.Collapse(0);$table=$doc.Tables.Add($range,${rows.length},${columns});for($r=0;$r-lt${rows.length};$r++){for($c=0;$c-lt${columns};$c++){if($c-lt$data[$r].Count){$table.Cell($r+1,$c+1).Range.Text=[string]$data[$r][$c]}}};$table.Borders.Enable=${bool(args.is_border ?? "1") ? 1 : 0}`)); + return { path: filePath } satisfies WordHandle; +}); + +register("Word_Close", (_ctx, node, args) => ({ path: handlePath(args, node.id, node.componentId), closed: true }));