Compare commits
11
Commits
fbdfb782bb
...
7d96badb1f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7d96badb1f | ||
|
|
9cb4eeab96 | ||
|
|
4fb0f64d4f | ||
|
|
cf05e4aa6f | ||
|
|
f675cc0086 | ||
|
|
12de8fbf58 | ||
|
|
dd27ec27f2 | ||
|
|
442eeaf9c1 | ||
|
|
cb68af5a0f | ||
|
|
5de47ee420 | ||
|
|
76f2160950 |
No files matched your search
@@ -51,8 +51,9 @@ docs 现状分析、实施方案
|
||||
|
||||
## 边界
|
||||
|
||||
- 1016 项目录中当前有 509 项通过能力注册表或运行时容器标记为可执行,覆盖控制/触发、除数据库外的数据处理、文件、网页元素层级/表格/下拉框及 DOM 高级能力、HTTP、Excel 常用历史版本与表格辅助、系统路径与环境变量、进程管理、桌面键鼠/窗口对象/剪贴板/对话框、PDF、Word 文档编辑、SMTP 邮件变体、文本差异、工作日判断、屏幕截图、离线图像查找与点击;其余灰标「待接入」。运行前检查会提前定位待接入组件、必填参数、多出口与断链节点。
|
||||
- 1016 项目录中当前有 619 项通过能力注册表或运行时容器标记为可执行,覆盖控制/触发、除数据库外的数据处理、文件、网页元素层级/表格/下拉框/浏览器控制/Cookie/下载及 DOM 高级能力、HTTP、Excel 常用读写/格式/筛选/历史版本、系统路径与环境变量、进程管理、桌面键鼠/窗口对象/剪贴板图片/对话框、PDF、Word 文档编辑、SMTP 与群机器人 Webhook、文本差异、工作日判断、屏幕截图、离线图像查找与点击;其余灰标「待接入」。运行前检查会提前定位待接入组件、必填参数、多出口与断链节点。
|
||||
- 内置本地演示站默认监听 `127.0.0.1:17863`;新用户首次启动会获得“本地网页订单演示”任务,可离线验证网页输入、点击和文本读取。
|
||||
- 编排首页提供“安装示例任务”,可安装网页订单、文件文本、Excel读写、剪贴板、Word文档和异常分支 6 条固定示例;重复安装会更新同名示例,不会无限复制。
|
||||
- 浏览器可信输入与定位机制参考 `C:\rpa-demo`:CDP Input、唯一定位、遮挡检查和输入回读校验;不使用 `el.click()` 伪造点击。
|
||||
- 不包含厂商代码、二进制、模型或前端资产;目录是从厂商元数据派生的静态定义。
|
||||
- 运行期不访问云服务;HTTP/邮件属于用户显式配置的外部系统。
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 21 KiB |
+1
-1
@@ -4,7 +4,7 @@
|
||||
> 依据:`D:\code\Agent-Employee`(只读)、`C:\rpa-demo`(只读)、2026-10-26 需求确认
|
||||
> 本方案是执行基线:范围、契约、排期、验收、降级顺序都在这里;实施中变更必须回写本文件。
|
||||
|
||||
> **2026-10-27 当前状态**:计划点名的代表能力已全部进入注册表或运行时容器,覆盖控制/触发、除数据库外的数据处理、文件、网页元素层级/表格/下拉框及 DOM 高级能力、HTTP、Excel 常用历史版本与表格辅助、系统路径与环境变量、进程管理、桌面键鼠/窗口对象/剪贴板/对话框、PDF、Word 文档编辑、SMTP 邮件变体、文本差异、工作日判断、屏幕截图、离线图像查找与点击;启动后目录显示 **509 项可执行 / 1016 项总目录**。当前 20 个测试文件共 124 条测试,覆盖计划能力审计、浏览器参数路由/变体/DOM层级、Excel/PDF/Word COM/SMTP/模板匹配、文件与数据表扩展、系统/进程/窗口解析、本地工具与 Databook、直线/IF/循环/重试/跳过、主出口与异常出口语义、运行记录回写、运行前检查、本地演示站与示例流程。数据处理仅剩 8 个明确排除的数据库组件。设置页可查看分类覆盖和处理器 ID 数量。未通过实际场景验证的变体仍不计入最终交付数量。
|
||||
> **2026-10-27 当前状态**:常用本地能力已进入注册表或运行时容器,覆盖控制/触发、除数据库外的数据处理、文件、网页元素层级/表格/下拉框/浏览器控制/Cookie/下载及 DOM 高级能力、HTTP、Excel 常用读写/格式/筛选/历史版本、系统路径与环境变量、进程管理、桌面键鼠/窗口对象/剪贴板图片/对话框、PDF、Word 文档编辑、SMTP 与群机器人 Webhook、文本差异、工作日判断、屏幕截图、离线图像查找与点击;启动后目录显示 **619 项可执行 / 1016 项总目录**。当前 23 个测试文件共 138 条测试,浏览器 E2E 真实结果为“订单创建成功:RPA 客户,金额 128”。数据处理仅剩 8 个明确排除的数据库组件。设置页可查看分类覆盖和处理器 ID 数量。此后以集中场景测试与稳定性修复为主,不再追求厂商云端、硬件和专用通道数量。
|
||||
|
||||
> 浏览器端到端门禁:`npm run test:e2e` 使用隔离用户目录启动应用和本地演示站,真实执行打开网页、两次可信输入、点击提交和读取结果;期望结果必须精确包含“订单创建成功:RPA 客户,金额 128”。浏览器输入、点击、定位歧义和遮挡检查机制已对照 `C:\rpa-demo/actions.js` 校准。
|
||||
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
import process from "node:process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const root = path.dirname(fileURLToPath(import.meta.url));
|
||||
const packageJson = JSON.parse(readFileSync(path.join(root, "package.json"), "utf8"));
|
||||
|
||||
/** @type {import('electron-builder').Configuration} */
|
||||
export default {
|
||||
appId: "com.rpastudio.desktop",
|
||||
productName: "RPA Studio",
|
||||
artifactName: "RPA-Studio-Setup-${version}-win-${arch}.${ext}",
|
||||
directories: {
|
||||
output: path.join(os.tmpdir(), "RPA-Studio-electron-builder"),
|
||||
buildResources: path.join(root, "build")
|
||||
},
|
||||
files: [
|
||||
"out/**/*",
|
||||
"package.json"
|
||||
],
|
||||
extraResources: [
|
||||
{ from: path.join(root, "resources", "catalog.json"), to: "catalog.json" },
|
||||
{ from: path.join(root, "resources", "fixture"), to: "fixture" }
|
||||
],
|
||||
asar: true,
|
||||
compression: "maximum",
|
||||
npmRebuild: false,
|
||||
electronVersion: packageJson.devDependencies.electron,
|
||||
electronDist: path.join(root, "node_modules", "electron", "dist"),
|
||||
win: {
|
||||
target: [{ target: "nsis", arch: ["x64"] }],
|
||||
icon: path.join(root, "build", "icon.ico"),
|
||||
signAndEditExecutable: true
|
||||
},
|
||||
nsis: {
|
||||
oneClick: false,
|
||||
perMachine: false,
|
||||
allowElevation: false,
|
||||
allowToChangeInstallationDirectory: true,
|
||||
installerLanguages: ["zh_CN"],
|
||||
language: "2052",
|
||||
shortcutName: "RPA Studio",
|
||||
uninstallDisplayName: "RPA Studio ${version}",
|
||||
createDesktopShortcut: true,
|
||||
createStartMenuShortcut: true,
|
||||
runAfterFinish: true,
|
||||
deleteAppDataOnUninstall: false
|
||||
},
|
||||
publish: null
|
||||
};
|
||||
Generated
+1786
-537
File diff suppressed because it is too large.
Load diff
+3
-2
@@ -30,7 +30,6 @@
|
||||
"zustand": "^5.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@electron/packager": "^18.3.6",
|
||||
"@types/adm-zip": "^0.5.7",
|
||||
"@types/node": "^22.10.2",
|
||||
"@types/nodemailer": "^6.4.17",
|
||||
@@ -39,9 +38,11 @@
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^4.7.0",
|
||||
"electron": "31.7.7",
|
||||
"electron-builder": "^26.15.7",
|
||||
"electron-vite": "^6.0.0",
|
||||
"typescript": "^5.7.2",
|
||||
"vite": "^7.1.0",
|
||||
"vitest": "^2.1.8"
|
||||
}
|
||||
},
|
||||
"author": "RPA Studio"
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
import { PNG } from "pngjs";
|
||||
|
||||
const root = process.cwd();
|
||||
const output = path.join(root, "build", "icon.ico");
|
||||
const size = 256;
|
||||
const png = new PNG({ width: size, height: size });
|
||||
const glyph = [
|
||||
"0111000000",
|
||||
"1000100000",
|
||||
"1000100000",
|
||||
"1000100000",
|
||||
"1111100000",
|
||||
"1000100000",
|
||||
"1000100000",
|
||||
"1000100000",
|
||||
"1000100000",
|
||||
"1000100000"
|
||||
];
|
||||
|
||||
function insideRoundedSquare(x, y, radius) {
|
||||
const clampX = Math.min(Math.max(x, radius), size - 1 - radius);
|
||||
const clampY = Math.min(Math.max(y, radius), size - 1 - radius);
|
||||
return (x - clampX) ** 2 + (y - clampY) ** 2 <= radius ** 2;
|
||||
}
|
||||
|
||||
for (let y = 0; y < size; y += 1) {
|
||||
for (let x = 0; x < size; x += 1) {
|
||||
const index = (size * y + x) << 2;
|
||||
const visible = insideRoundedSquare(x, y, 34);
|
||||
const gradient = (x + y) / (2 * (size - 1));
|
||||
png.data[index] = visible ? Math.round(37 + (30 - 37) * gradient) : 0;
|
||||
png.data[index + 1] = visible ? Math.round(99 + (58 - 99) * gradient) : 0;
|
||||
png.data[index + 2] = visible ? Math.round(235 + (138 - 235) * gradient) : 0;
|
||||
png.data[index + 3] = visible ? 255 : 0;
|
||||
|
||||
if (visible) {
|
||||
const glyphX = Math.round((x - 78) / 10);
|
||||
const glyphY = Math.round((y - 68) / 12);
|
||||
if (glyphY >= 0 && glyphY < glyph.length && glyphX >= 0 && glyphX < glyph[glyphY].length && glyph[glyphY][glyphX] === "1") {
|
||||
png.data[index] = 255;
|
||||
png.data[index + 1] = 255;
|
||||
png.data[index + 2] = 255;
|
||||
png.data[index + 3] = 255;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const chunks = [];
|
||||
png.pack()
|
||||
.on("data", (chunk) => chunks.push(chunk))
|
||||
.on("end", () => {
|
||||
const image = Buffer.concat(chunks);
|
||||
const directory = Buffer.alloc(6);
|
||||
directory.writeUInt16LE(0, 0);
|
||||
directory.writeUInt16LE(1, 2);
|
||||
directory.writeUInt16LE(1, 4);
|
||||
|
||||
const entry = Buffer.alloc(16);
|
||||
entry[0] = 0;
|
||||
entry[1] = 0;
|
||||
entry[2] = 0;
|
||||
entry[3] = 0;
|
||||
entry.writeUInt16LE(1, 4);
|
||||
entry.writeUInt16LE(32, 6);
|
||||
entry.writeUInt32LE(image.length, 8);
|
||||
entry.writeUInt32LE(22, 12);
|
||||
|
||||
fs.mkdirSync(path.dirname(output), { recursive: true });
|
||||
fs.writeFileSync(output, Buffer.concat([directory, entry, image]));
|
||||
console.log(`[icon] generated ${output} (${image.length + 22} bytes)`);
|
||||
})
|
||||
.on("error", (error) => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
+150
-41
@@ -1,18 +1,22 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { spawn, spawnSync } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
import AdmZip from "adm-zip";
|
||||
import packager from "@electron/packager";
|
||||
|
||||
const root = process.cwd();
|
||||
const packageJson = JSON.parse(fs.readFileSync(path.join(root, "package.json"), "utf8"));
|
||||
const version = packageJson.version;
|
||||
const releaseRoot = path.resolve(root, "release");
|
||||
const releaseRoot = path.resolve(root, "release", "dist");
|
||||
const builderWork = path.join(os.tmpdir(), "RPA-Studio-electron-builder");
|
||||
const builderUnpacked = path.join(builderWork, "win-unpacked");
|
||||
const productFolderName = `RPA-Studio-${version}-win-x64`;
|
||||
const productFolder = path.join(releaseRoot, productFolderName);
|
||||
const zipPath = path.join(releaseRoot, `${productFolderName}.zip`);
|
||||
const setupPath = path.join(releaseRoot, `RPA-Studio-Setup-${version}-win-x64.exe`);
|
||||
const builderSetupPath = path.join(builderWork, `RPA-Studio-Setup-${version}-win-x64.exe`);
|
||||
|
||||
function assertInsideRelease(target) {
|
||||
const relative = path.relative(releaseRoot, path.resolve(target));
|
||||
@@ -26,56 +30,95 @@ function run(command, args) {
|
||||
if (result.status !== 0) throw new Error(`${command} ${args.join(" ")} 失败,退出码 ${result.status}`);
|
||||
}
|
||||
|
||||
function delay(milliseconds) {
|
||||
return new Promise((resolve) => setTimeout(resolve, milliseconds));
|
||||
}
|
||||
|
||||
function killProcessTree(pid) {
|
||||
if (process.platform === "win32") {
|
||||
spawnSync("taskkill", ["/PID", String(pid), "/T", "/F"], { stdio: "ignore", windowsHide: true });
|
||||
}
|
||||
}
|
||||
|
||||
function removeTree(target) {
|
||||
fs.rmSync(target, { recursive: true, force: true, maxRetries: 10, retryDelay: 500 });
|
||||
}
|
||||
|
||||
async function verifyLaunch(executable, label) {
|
||||
const userData = path.join(os.tmpdir(), `RPA-Studio-UserData-${Date.now()}-${Math.random().toString(16).slice(2)}`);
|
||||
const child = spawn(executable, [`--user-data-dir=${userData}`], {
|
||||
cwd: path.dirname(executable),
|
||||
stdio: "ignore",
|
||||
windowsHide: false
|
||||
});
|
||||
await delay(8000);
|
||||
const exited = child.exitCode !== null || child.signalCode !== null;
|
||||
if (!exited) {
|
||||
killProcessTree(child.pid);
|
||||
await delay(2000);
|
||||
}
|
||||
removeTree(userData);
|
||||
if (exited) throw new Error(`${label} 启动后提前退出,退出码 ${child.exitCode}`);
|
||||
console.log(`[package] ${label} launch verified`);
|
||||
}
|
||||
|
||||
fs.mkdirSync(releaseRoot, { recursive: true });
|
||||
for (const target of [productFolder, zipPath]) {
|
||||
for (const entry of fs.readdirSync(releaseRoot)) {
|
||||
if (entry.startsWith(".installer")) {
|
||||
const legacyTarget = path.join(releaseRoot, entry);
|
||||
assertInsideRelease(legacyTarget);
|
||||
fs.rmSync(legacyTarget, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
for (const target of [productFolder, zipPath, setupPath]) {
|
||||
assertInsideRelease(target);
|
||||
fs.rmSync(target, { recursive: true, force: true });
|
||||
}
|
||||
removeTree(builderWork);
|
||||
|
||||
console.log("[package] typecheck");
|
||||
run("npm", ["run", "typecheck"]);
|
||||
console.log("[package] tests");
|
||||
run("npm", ["test"]);
|
||||
console.log("[package] browser e2e");
|
||||
run("npm", ["run", "test:e2e"]);
|
||||
console.log("[package] build");
|
||||
run("npm", ["run", "build"]);
|
||||
|
||||
console.log("[package] electron packager");
|
||||
const outputs = await packager({
|
||||
dir: root,
|
||||
name: "RPA Studio",
|
||||
platform: "win32",
|
||||
arch: "x64",
|
||||
out: releaseRoot,
|
||||
overwrite: true,
|
||||
prune: true,
|
||||
asar: true,
|
||||
electronVersion: packageJson.devDependencies.electron,
|
||||
appVersion: version,
|
||||
win32metadata: {
|
||||
CompanyName: "RPA Studio",
|
||||
FileDescription: "RPA Studio Desktop Automation",
|
||||
ProductName: "RPA Studio",
|
||||
InternalName: "RPA Studio"
|
||||
},
|
||||
ignore: [
|
||||
/^[/\\]\.git($|[/\\])/,
|
||||
/^[/\\]release($|[/\\])/,
|
||||
/^[/\\]_analysis($|[/\\])/,
|
||||
/^[/\\]docs($|[/\\])/,
|
||||
/^[/\\]scripts($|[/\\])/,
|
||||
/^[/\\]src($|[/\\])/,
|
||||
/\.test\.[cm]?[jt]sx?$/
|
||||
]
|
||||
});
|
||||
const electronDist = path.join(root, "node_modules", "electron", "dist");
|
||||
if (!fs.existsSync(path.join(electronDist, "electron.exe"))) {
|
||||
throw new Error(`本机 Electron 运行时不完整:${electronDist}`);
|
||||
}
|
||||
|
||||
const packaged = outputs[0];
|
||||
if (!packaged || !fs.existsSync(path.join(packaged, "RPA Studio.exe"))) {
|
||||
throw new Error(`打包产物无效:${packaged || "未返回目录"}`);
|
||||
console.log("[package] icon");
|
||||
run("node", ["scripts/generate-icon.mjs"]);
|
||||
|
||||
console.log("[package] electron-builder nsis");
|
||||
run(path.join(root, "node_modules", ".bin", "electron-builder.cmd"), [
|
||||
"--win",
|
||||
"--x64",
|
||||
"--config",
|
||||
path.join(root, "electron-builder.config.mjs")
|
||||
]);
|
||||
|
||||
if (!fs.existsSync(builderSetupPath)) {
|
||||
throw new Error(`electron-builder 未生成安装包:${builderSetupPath}`);
|
||||
}
|
||||
if (path.resolve(packaged) !== path.resolve(productFolder)) {
|
||||
assertInsideRelease(packaged);
|
||||
fs.renameSync(packaged, productFolder);
|
||||
if (!fs.existsSync(path.join(builderUnpacked, "RPA Studio.exe"))) {
|
||||
throw new Error(`electron-builder 未生成应用目录:${builderUnpacked}`);
|
||||
}
|
||||
const packagedCatalogPath = path.join(builderUnpacked, "resources", "catalog.json");
|
||||
const packagedFixturePath = path.join(builderUnpacked, "resources", "fixture", "index.html");
|
||||
if (!fs.existsSync(packagedCatalogPath) || !fs.existsSync(packagedFixturePath)) {
|
||||
throw new Error("打包产物缺少运行资源:resources/catalog.json 或 resources/fixture/index.html");
|
||||
}
|
||||
const packagedCatalog = JSON.parse(fs.readFileSync(packagedCatalogPath, "utf8"));
|
||||
if (!Array.isArray(packagedCatalog.components) || packagedCatalog.components.length === 0) {
|
||||
throw new Error("打包产物 resources/catalog.json 无效:组件目录为空");
|
||||
}
|
||||
fs.cpSync(builderUnpacked, productFolder, { recursive: true });
|
||||
fs.copyFileSync(builderSetupPath, setupPath);
|
||||
removeTree(builderWork);
|
||||
|
||||
const git = spawnSync("git", ["rev-parse", "--short", "HEAD"], { cwd: root, encoding: "utf8" });
|
||||
const revision = git.status === 0 ? git.stdout.trim() : "unknown";
|
||||
@@ -97,17 +140,83 @@ fs.writeFileSync(path.join(productFolder, "使用说明.txt"), [
|
||||
"3. 任务、流程和运行记录默认保存在当前 Windows 用户的 AppData\\Roaming\\rpa-studio。",
|
||||
"4. Word 自动化需要本机安装 Microsoft Word;其他文件级能力无需安装 Office。",
|
||||
"5. 首次启动如被 Windows SmartScreen 提示,请核对同目录 SHA256SUMS.txt;当前构建未进行商业代码签名。",
|
||||
"6. 如果双击没有窗口,请运行 启动诊断.cmd,并把自动打开的 startup.log 发给开发人员。",
|
||||
"",
|
||||
`版本:${version}`,
|
||||
`源码提交:${revision}`,
|
||||
`构建时间:${builtAt}`
|
||||
].join("\r\n"), "utf8");
|
||||
fs.writeFileSync(path.join(productFolder, "系统要求.txt"), [
|
||||
"RPA Studio 系统要求",
|
||||
"",
|
||||
"- Windows 10 64 位或 Windows 11 64 位",
|
||||
"- 建议至少 4 GB 内存和 500 MB 可用磁盘空间",
|
||||
"- 必须先完整解压,不能直接在 ZIP 压缩包中双击 exe",
|
||||
"- Word 自动化需要本机安装 Microsoft Word",
|
||||
"- 当前版本未购买商业代码签名证书,SmartScreen 可能显示未知发布者",
|
||||
"- 软件默认关闭 GPU 硬件加速,以兼容远程桌面、虚拟机和旧显卡驱动"
|
||||
].join("\r\n"), "utf8");
|
||||
fs.writeFileSync(path.join(productFolder, "启动诊断.cmd"), [
|
||||
"@echo off",
|
||||
"chcp 65001 >nul",
|
||||
"set ELECTRON_ENABLE_LOGGING=1",
|
||||
"set LOG=%APPDATA%\\rpa-studio\\logs\\startup.log",
|
||||
"start \"RPA Studio\" \"%~dp0RPA Studio.exe\" --enable-logging --v=1",
|
||||
"timeout /t 8 /nobreak >nul",
|
||||
"if exist \"%LOG%\" (start notepad.exe \"%LOG%\") else (echo 未生成启动日志,请检查安全软件拦截记录。 & pause)"
|
||||
].join("\r\n"), "utf8");
|
||||
fs.writeFileSync(path.join(productFolder, "安装到本机.cmd"), [
|
||||
"@echo off",
|
||||
"chcp 65001 >nul",
|
||||
"set \"TARGET=%LOCALAPPDATA%\\Programs\\RPA Studio\"",
|
||||
"if not exist \"%TARGET%\" mkdir \"%TARGET%\"",
|
||||
"robocopy \"%~dp0\" \"%TARGET%\" /E /R:2 /W:1 /NFL /NDL /NJH /NJS /NP >nul",
|
||||
"powershell -NoProfile -ExecutionPolicy Bypass -Command \"$w=New-Object -ComObject WScript.Shell;$s=$w.CreateShortcut([IO.Path]::Combine([Environment]::GetFolderPath('Desktop'),'RPA Studio.lnk'));$s.TargetPath=[IO.Path]::Combine($env:LOCALAPPDATA,'Programs','RPA Studio','RPA Studio.exe');$s.WorkingDirectory=[IO.Path]::Combine($env:LOCALAPPDATA,'Programs','RPA Studio');$s.Save()\"",
|
||||
"start \"RPA Studio\" \"%TARGET%\\RPA Studio.exe\"",
|
||||
"exit /b 0"
|
||||
].join("\r\n"), "utf8");
|
||||
|
||||
console.log("[package] portable smoke");
|
||||
const portableSmokeRoot = fs.mkdtempSync(path.join(os.tmpdir(), "RPA-Studio-Portable-Smoke-"));
|
||||
const portableSmokeFolder = path.join(portableSmokeRoot, productFolderName);
|
||||
fs.cpSync(productFolder, portableSmokeFolder, { recursive: true });
|
||||
await verifyLaunch(path.join(portableSmokeFolder, "RPA Studio.exe"), "portable");
|
||||
removeTree(portableSmokeRoot);
|
||||
|
||||
console.log("[package] zip");
|
||||
const zip = new AdmZip();
|
||||
zip.addLocalFolder(productFolder, productFolderName);
|
||||
zip.writeZip(zipPath);
|
||||
const sha256 = createHash("sha256").update(fs.readFileSync(zipPath)).digest("hex");
|
||||
fs.writeFileSync(path.join(releaseRoot, "SHA256SUMS.txt"), `${sha256} ${path.basename(zipPath)}\r\n`, "utf8");
|
||||
const zipSha256 = createHash("sha256").update(fs.readFileSync(zipPath)).digest("hex");
|
||||
const setupSha256 = createHash("sha256").update(fs.readFileSync(setupPath)).digest("hex");
|
||||
|
||||
console.log(JSON.stringify({ productFolder, zipPath, sha256 }, null, 2));
|
||||
console.log("[package] nsis smoke");
|
||||
const installSmokeRoot = fs.mkdtempSync(path.join(os.tmpdir(), "RPA-Studio-NSIS-Smoke-"));
|
||||
const installSmokeSetup = path.join(installSmokeRoot, path.basename(setupPath));
|
||||
const installSmokeTarget = path.join(installSmokeRoot, "RPAStudio");
|
||||
fs.copyFileSync(setupPath, installSmokeSetup);
|
||||
const installed = spawnSync(installSmokeSetup, ["/S", `/D=${installSmokeTarget}`], {
|
||||
cwd: installSmokeRoot,
|
||||
stdio: "inherit",
|
||||
windowsHide: true,
|
||||
timeout: 180000
|
||||
});
|
||||
if (installed.status !== 0 || installed.error) {
|
||||
throw new Error(`NSIS 安装包冒烟失败,退出码 ${installed.status}:${installed.error?.message || "unknown error"}`);
|
||||
}
|
||||
if (!fs.existsSync(path.join(installSmokeTarget, "RPA Studio.exe"))) {
|
||||
throw new Error("NSIS 安装包冒烟失败:未生成主程序");
|
||||
}
|
||||
if (!fs.existsSync(path.join(installSmokeTarget, "resources", "catalog.json"))) {
|
||||
throw new Error("NSIS 安装包冒烟失败:catalog.json 未安装");
|
||||
}
|
||||
await verifyLaunch(path.join(installSmokeTarget, "RPA Studio.exe"), "installed");
|
||||
removeTree(installSmokeRoot);
|
||||
|
||||
const sums = [
|
||||
`${zipSha256} ${path.basename(zipPath)}`,
|
||||
`${setupSha256} ${path.basename(setupPath)}`
|
||||
];
|
||||
fs.writeFileSync(path.join(releaseRoot, "SHA256SUMS.txt"), `${sums.join("\r\n")}\r\n`, "utf8");
|
||||
|
||||
console.log(JSON.stringify({ productFolder, zipPath, setupPath, zipSha256, setupSha256 }, null, 2));
|
||||
@@ -1,6 +1,7 @@
|
||||
import { BrowserWindow } from "electron";
|
||||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
import type { ElementPickResult } from "../../shared/types";
|
||||
import {
|
||||
RuntimeError,
|
||||
type BrowserElementInfo,
|
||||
@@ -711,6 +712,155 @@ export class BrowserSession implements BrowserLike {
|
||||
return this.win.webContents.getURL();
|
||||
}
|
||||
|
||||
async pickElement(timeoutMs = 90000): Promise<import("../../shared/types").ElementPickResult> {
|
||||
const url = this.win.webContents.getURL();
|
||||
if (!url || url === "about:blank") {
|
||||
throw new RuntimeError("no_page", "浏览器还没有打开网页", { recommendation: "先运行或配置「打开网页」,再拾取元素" });
|
||||
}
|
||||
this.win.show();
|
||||
this.win.focus();
|
||||
const result = await this.evaluate<ElementPickResult>(`(() => new Promise((resolve, reject) => {
|
||||
const timeout = ${JSON.stringify(timeoutMs)};
|
||||
if (window.__rpaElementPicker?.cancel) window.__rpaElementPicker.cancel();
|
||||
|
||||
let host = null;
|
||||
let root = null;
|
||||
let box = null;
|
||||
let badge = null;
|
||||
let target = null;
|
||||
let timer = 0;
|
||||
let finished = false;
|
||||
|
||||
const cssEscape = (value) => {
|
||||
if (globalThis.CSS && typeof CSS.escape === 'function') return CSS.escape(String(value));
|
||||
return String(value).replace(/[^a-zA-Z0-9_-]/g, (char) => '\\\\' + char);
|
||||
};
|
||||
const isUnique = (candidate) => {
|
||||
try { return document.querySelectorAll(candidate).length === 1; } catch { return false; }
|
||||
};
|
||||
const cssPath = (node) => {
|
||||
if (node.id) {
|
||||
const candidate = '#' + cssEscape(node.id);
|
||||
if (isUnique(candidate)) return candidate;
|
||||
}
|
||||
const testId = node.getAttribute('data-testid');
|
||||
if (testId) {
|
||||
const candidate = '[data-testid="' + cssEscape(testId) + '"]';
|
||||
if (isUnique(candidate)) return candidate;
|
||||
}
|
||||
const parts = [];
|
||||
let current = node;
|
||||
while (current && current.nodeType === 1) {
|
||||
let part = current.tagName.toLowerCase();
|
||||
if (current.id) {
|
||||
part += '#' + cssEscape(current.id);
|
||||
parts.unshift(part);
|
||||
break;
|
||||
}
|
||||
const parent = current.parentElement;
|
||||
if (parent) {
|
||||
const siblings = [...parent.children].filter((item) => item.tagName === current.tagName);
|
||||
if (siblings.length > 1) part += ':nth-of-type(' + (siblings.indexOf(current) + 1) + ')';
|
||||
}
|
||||
parts.unshift(part);
|
||||
current = parent;
|
||||
}
|
||||
return parts.join(' > ');
|
||||
};
|
||||
const describe = (node) => {
|
||||
const rect = node.getBoundingClientRect();
|
||||
return {
|
||||
selector: 'css:' + cssPath(node),
|
||||
tag: node.tagName.toLowerCase(),
|
||||
id: node.id || undefined,
|
||||
className: node.getAttribute('class') || undefined,
|
||||
name: node.getAttribute('name') || undefined,
|
||||
type: node.getAttribute('type') || undefined,
|
||||
placeholder: node.getAttribute('placeholder') || undefined,
|
||||
label: node.getAttribute('aria-label') || node.getAttribute('placeholder') || node.getAttribute('name') || (node.innerText || '').trim().slice(0, 100) || undefined,
|
||||
text: (node.innerText || node.textContent || '').trim().slice(0, 200) || undefined,
|
||||
value: String(node.value ?? '') || undefined,
|
||||
url: location.href
|
||||
};
|
||||
};
|
||||
const updateBox = () => {
|
||||
if (!target || !box || !badge) return;
|
||||
const rect = target.getBoundingClientRect();
|
||||
box.style.left = Math.max(0, rect.left - 3) + 'px';
|
||||
box.style.top = Math.max(0, rect.top - 3) + 'px';
|
||||
box.style.width = Math.max(4, rect.width + 6) + 'px';
|
||||
box.style.height = Math.max(4, rect.height + 6) + 'px';
|
||||
const tag = target.tagName.toLowerCase();
|
||||
const name = target.getAttribute('aria-label') || target.getAttribute('placeholder') || target.getAttribute('name') || target.id || '';
|
||||
badge.textContent = '拾取:' + tag + (name ? ' · ' + name.slice(0, 60) : '');
|
||||
badge.style.left = Math.max(0, rect.left) + 'px';
|
||||
badge.style.top = Math.max(0, rect.top - 30) + 'px';
|
||||
};
|
||||
const firstElement = (event) => {
|
||||
for (const item of event.composedPath()) if (item instanceof Element) return item;
|
||||
return event.target instanceof Element ? event.target : null;
|
||||
};
|
||||
const move = (event) => {
|
||||
if (finished) return;
|
||||
target = firstElement(event);
|
||||
updateBox();
|
||||
};
|
||||
const click = (event) => {
|
||||
if (finished) return;
|
||||
const selected = firstElement(event);
|
||||
if (!selected) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
event.stopImmediatePropagation();
|
||||
cleanup();
|
||||
resolve(describe(selected));
|
||||
};
|
||||
const key = (event) => {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
cleanup();
|
||||
reject(new Error('已取消元素拾取'));
|
||||
}
|
||||
};
|
||||
const cleanup = () => {
|
||||
if (finished) return;
|
||||
finished = true;
|
||||
clearTimeout(timer);
|
||||
window.removeEventListener('mousemove', move, true);
|
||||
window.removeEventListener('mousedown', click, true);
|
||||
window.removeEventListener('click', click, true);
|
||||
window.removeEventListener('scroll', updateBox, true);
|
||||
window.removeEventListener('resize', updateBox);
|
||||
window.removeEventListener('keydown', key, true);
|
||||
host?.remove();
|
||||
delete window.__rpaElementPicker;
|
||||
};
|
||||
|
||||
host = document.createElement('div');
|
||||
host.style.cssText = 'position:fixed;inset:0;width:100vw;height:100vh;z-index:2147483647;pointer-events:none;';
|
||||
root = host.attachShadow({ mode: 'closed' });
|
||||
box = document.createElement('div');
|
||||
badge = document.createElement('div');
|
||||
box.style.cssText = 'position:fixed;border:2px solid #2563eb;background:rgba(37,99,235,.16);border-radius:3px;pointer-events:none;';
|
||||
badge.style.cssText = 'position:fixed;z-index:2147483647;padding:4px 7px;border-radius:6px;background:#2563eb;color:#fff;font:600 12px/16px system-ui,sans-serif;pointer-events:none;white-space:nowrap;';
|
||||
root.append(box, badge);
|
||||
document.documentElement.append(host);
|
||||
window.addEventListener('mousemove', move, true);
|
||||
window.addEventListener('mousedown', click, true);
|
||||
window.addEventListener('click', click, true);
|
||||
window.addEventListener('scroll', updateBox, true);
|
||||
window.addEventListener('resize', updateBox);
|
||||
window.addEventListener('keydown', key, true);
|
||||
window.__rpaElementPicker = { cancel: () => { cleanup(); reject(new Error('已取消元素拾取')); } };
|
||||
timer = setTimeout(() => {
|
||||
cleanup();
|
||||
reject(new Error('拾取超时,请重新点击拾取'));
|
||||
}, timeout);
|
||||
}))()`);
|
||||
return result;
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
if (!this.win.isDestroyed()) this.win.close();
|
||||
globalThis.__rpaBrowserWindow = null;
|
||||
@@ -726,6 +876,10 @@ export async function browserSession(): Promise<BrowserSession> {
|
||||
return BrowserSession.create();
|
||||
}
|
||||
|
||||
export function hasBrowserSession(): boolean {
|
||||
return Boolean(globalThis.__rpaBrowserWindow && !globalThis.__rpaBrowserWindow.isDestroyed());
|
||||
}
|
||||
|
||||
export function assertAllowedUrl(url: string): URL {
|
||||
return assertUrl(url);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { CatalogComponent, FlowNode } from "../../shared/types";
|
||||
import type { BrowserLike, RunContext } from "../engine/types";
|
||||
import { getHandler } from "../engine/registry";
|
||||
import { BROWSER_CONTROL_IDS } from "./browser-control";
|
||||
|
||||
const component = {} as CatalogComponent; const node = (id: string): FlowNode => ({ id, componentId: id, position: { x: 0, y: 0 }, values: {}, options: {} });
|
||||
function fake(): BrowserLike { return { navigate: vi.fn(async (url) => ({ url, title: "title" })), click: vi.fn(), type: vi.fn(), getText: vi.fn(), exists: vi.fn(async () => true), select: vi.fn(), runJs: vi.fn(async (script) => script.includes("document.title") ? "Title" : script.includes("document.cookie") ? "a=1" : script.includes("readyState") ? true : true), download: vi.fn(async (_s, folder, file) => `${folder}/${file}`), screenshot: vi.fn(async (target) => target), currentUrl: vi.fn(async () => "http://127.0.0.1/"), close: vi.fn() }; }
|
||||
function context(browser: BrowserLike): RunContext { return { runId: "r", flowId: "f", flow: {} as any, vars: {}, secrets: {}, signal: new AbortController().signal, evidenceDir: "C:/evidence", browser, emit: vi.fn(), log: vi.fn(), componentOf: vi.fn() }; }
|
||||
async function run(ctx: RunContext, id: string, args: Record<string, unknown> = {}) { const handler = getHandler(id); expect(handler, id).toBeTruthy(); return handler!(ctx, node(id), args, component); }
|
||||
|
||||
describe("browser control capabilities", () => {
|
||||
let browser: BrowserLike; let ctx: RunContext;
|
||||
beforeEach(() => { browser = fake(); ctx = context(browser); });
|
||||
it("registers only real catalog IDs", () => { const catalog = JSON.parse(readFileSync(resolve("resources/catalog.json"), "utf8")); const ids = new Set(catalog.components.map((item: any) => item.id)); for (const id of BROWSER_CONTROL_IDS) { expect(ids.has(id), id).toBe(true); expect(getHandler(id), id).toBeTruthy(); } });
|
||||
it("routes navigation metadata, scrolling, cookies and screenshots", async () => { await expect(run(ctx, "WebBrowser_GetTitle")).resolves.toBe("Title"); await expect(run(ctx, "WebBrowser_GetUrl")).resolves.toBe("http://127.0.0.1/"); await run(ctx, "WebBrowser_SetScrollPos", { x: 1, y: 2 }); expect(browser.runJs).toHaveBeenCalled(); await expect(run(ctx, "WebBrowser_GetCookie", { name: "a" })).resolves.toBe("1"); await run(ctx, "WebBrowser_GetBrowserPic", { path: "C:/evidence/page.png" }); expect(browser.screenshot).toHaveBeenCalled(); });
|
||||
it("routes waits, exists, close and direct downloads", async () => { await expect(run(ctx, "Element_ExistsV1", { uiSelector: "css:#a" })).resolves.toBe(true); await expect(run(ctx, "Element_WaitElement_v5", { uiSelector: "css:#a", time_out: 0 })).resolves.toBe(true); await run(ctx, "WebBrowser_DownFile", { url: "http://127.0.0.1/file.csv", savepath: "C:/downloads", filename: "file.csv" }); expect(browser.download).toHaveBeenCalledWith(expect.stringContaining("rpa-download"), "C:/downloads", "file.csv", 10000); await run(ctx, "WebBrowser_CloseWeb"); expect(browser.close).toHaveBeenCalled(); });
|
||||
it("rejects unsupported request headers for direct downloads", async () => { await expect(run(ctx, "WebBrowser_DownFile", { url: "http://127.0.0.1/file", savepath: "C:/d", headers: '{"x":"y"}' })).rejects.toMatchObject({ code: "validation_error" }); });
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import path from "node:path";
|
||||
import { browserSession } from "../browser/session";
|
||||
import { register } from "../engine/registry";
|
||||
import { RuntimeError, type BrowserLike, type RunContext } from "../engine/types";
|
||||
|
||||
async function session(ctx: RunContext): Promise<BrowserLike> { if (!ctx.browser) ctx.browser = await browserSession(); return ctx.browser; }
|
||||
function selector(args: Record<string, unknown>): unknown { const value = args.uiSelector ?? args.download_selector ?? args.selector; if (value === undefined || value === null || value === "") throw new RuntimeError("validation_error", "元素选择器为空"); return value; }
|
||||
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; }
|
||||
|
||||
register("WebBrowser_Reload", async (ctx) => { const browser = await session(ctx); return browser.navigate(await browser.currentUrl()); });
|
||||
register("WebBrowser_GoBack", async (ctx) => { const browser = await session(ctx); await browser.runJs("history.back(); true"); return true; });
|
||||
register("WebBrowser_GoForward", async (ctx) => { const browser = await session(ctx); await browser.runJs("history.forward(); true"); return true; });
|
||||
register("WebBrowser_GetTitle", async (ctx) => session(ctx).then((browser) => browser.runJs("document.title")));
|
||||
register("WebBrowser_GetUrl", async (ctx) => session(ctx).then((browser) => browser.currentUrl()));
|
||||
register("WebBrowser_GetDocument", async (ctx) => session(ctx).then((browser) => browser.runJs("document.documentElement.outerHTML")));
|
||||
register(["WebBrowser_GetScrollPos", "WebBrowser_GetScrollPos_V1"], async (ctx) => session(ctx).then((browser) => browser.runJs("({x:window.scrollX,y:window.scrollY})")));
|
||||
register(["WebBrowser_SetScrollPos", "WebBrowser_SetScrollPos_V1"], async (ctx, _node, args) => {
|
||||
const browser = await session(ctx); const x = Number(args.x ?? args.left ?? 0); const y = Number(args.y ?? args.top ?? args.position ?? 0); await browser.runJs(`window.scrollTo(${Number.isFinite(x) ? x : 0},${Number.isFinite(y) ? y : 0}); true`); return { x, y };
|
||||
});
|
||||
|
||||
register(["WebBrowser_GetCookies", "WebBrowser_GetCookieNew"], async (ctx) => {
|
||||
const raw = String(await (await session(ctx)).runJs("document.cookie")); const result: Record<string, string> = {}; for (const part of raw.split(";")) { const [name, ...rest] = part.trim().split("="); if (name) result[name] = decodeURIComponent(rest.join("=")); } return result;
|
||||
});
|
||||
register("WebBrowser_GetCookie", async (ctx, _node, args) => {
|
||||
const name = text(args, ["name"]); const cookies = await getCookies(ctx); return cookies[name] ?? "";
|
||||
});
|
||||
async function getCookies(ctx: RunContext): Promise<Record<string, string>> { const raw = String(await (await session(ctx)).runJs("document.cookie")); return Object.fromEntries(raw.split(";").map((part) => part.trim()).filter(Boolean).map((part) => { const [name, ...rest] = part.split("="); return [name, decodeURIComponent(rest.join("="))]; })); }
|
||||
register("WebBrowser_SetCookies", async (ctx, node, args) => {
|
||||
let cookies: unknown = args.cookies; if (typeof cookies === "string") { try { cookies = JSON.parse(cookies); } catch { throw new RuntimeError("validation_error", "Cookie JSON无效", { nodeId: node.id, componentId: node.componentId }); } }
|
||||
if (!cookies || typeof cookies !== "object") throw new RuntimeError("validation_error", "Cookie对象为空", { nodeId: node.id, componentId: node.componentId }); const browser = await session(ctx);
|
||||
for (const [name, value] of Object.entries(cookies as Record<string, unknown>)) await browser.runJs(`document.cookie=${JSON.stringify(`${name}=${encodeURIComponent(String(value))}; path=/`)}; true`); return getCookies(ctx);
|
||||
});
|
||||
register("WebBrowser_SetCookies_V1", async (ctx, node, args) => {
|
||||
const name = text(args, ["cookie_name"]); if (!name) throw new RuntimeError("validation_error", "Cookie name为空", { nodeId: node.id, componentId: node.componentId });
|
||||
const value = encodeURIComponent(text(args, ["cookie_value"])); const cookiePath = text(args, ["cookie_path"], "/"); const domain = text(args, ["cookie_domain"]); const expires = text(args, ["cookie_expires"]); const secure = String(args.secure ?? "0") === "1";
|
||||
const cookie = `${name}=${value}; path=${cookiePath}${domain ? `; domain=${domain}` : ""}${expires ? `; expires=${expires}` : ""}${secure ? "; secure" : ""}`; await (await session(ctx)).runJs(`document.cookie=${JSON.stringify(cookie)}; true`); return cookie;
|
||||
});
|
||||
|
||||
register("WebBrowser_Wait", async (ctx) => {
|
||||
const browser = await session(ctx); const deadline = Date.now() + 15000; do { try { if (await browser.runJs("document.readyState === 'complete'")) return true; } catch {} await new Promise((resolve) => setTimeout(resolve, 100)); } while (Date.now() < deadline); return false;
|
||||
});
|
||||
register(["WebBrowser_CloseWeb", "WebBrowser_CloseWeb_V1", "WebBrowser_CloseWeb_V2", "WebBrowser_Close_V1"], async (ctx) => { const browser = await session(ctx); await browser.close(); ctx.browser = undefined; return true; });
|
||||
register(["WebBrowser_GetBrowserPic", "WebBrowser_GetBrowserPicV1"], async (ctx, node, args) => { const browser = await session(ctx); const target = path.resolve(text(args, ["path", "file_path", "image_path"], path.join(ctx.evidenceDir, `${node.id}.png`))); return browser.screenshot(target); });
|
||||
register("Element_ExistsV1", async (ctx, _node, args) => (await session(ctx)).exists(selector(args)));
|
||||
register("Element_WaitElement_v5", async (ctx, _node, args) => { const browser = await session(ctx); const target = selector(args); const expected = String(args.choose ?? "1") !== "3"; const deadline = Date.now() + Math.max(0, Number(args.time_out ?? args.timeout ?? 10)) * 1000; do { if ((await browser.exists(target)) === expected) return true; await new Promise((resolve) => setTimeout(resolve, 200)); } while (Date.now() <= deadline); return false; });
|
||||
|
||||
register(["WebBrowser_DownFile", "WebBrowser_DownFile_V1", "WebBrowser_DownloadFile", "WebBrowser_DownloadFile_V1"], async (ctx, node, args) => {
|
||||
const browser = await session(ctx); const headers = text(args, ["headers"], "{}"); const body = text(args, ["body", "param"], "{}"); if (!["", "{}"].includes(headers) || !["", "{}"].includes(body)) throw new RuntimeError("validation_error", "当前下载能力暂不支持自定义Headers或请求体", { nodeId: node.id, componentId: node.componentId });
|
||||
const folder = text(args, ["savepath", "save_path"]); if (!folder) throw new RuntimeError("validation_error", "下载目录为空", { nodeId: node.id, componentId: node.componentId }); const filename = text(args, ["filename", "file_name"]); const seconds = Number(args.download_time ?? 0); const timeout = seconds > 0 ? seconds * 1000 : Number(args.timeout ?? 10000) * (node.componentId.endsWith("_V1") && node.componentId.includes("DownFile") ? 1000 : 1);
|
||||
const directUrl = text(args, ["url"]); if (!directUrl) return browser.download(selector(args), folder, filename, timeout);
|
||||
const temporaryId = `rpa-download-${Date.now().toString(36)}`; await browser.runJs(`(()=>{const a=document.createElement('a');a.id=${JSON.stringify(temporaryId)};a.href=${JSON.stringify(directUrl)};a.style.display='none';document.body.appendChild(a);return true})()`);
|
||||
try { return await browser.download(`css:#${temporaryId}`, folder, filename, timeout); } finally { try { await browser.runJs(`document.getElementById(${JSON.stringify(temporaryId)})?.remove();true`); } catch {} }
|
||||
});
|
||||
|
||||
export const BROWSER_CONTROL_IDS = ["WebBrowser_Reload", "WebBrowser_GoBack", "WebBrowser_GoForward", "WebBrowser_GetTitle", "WebBrowser_GetUrl", "WebBrowser_GetDocument", "WebBrowser_GetScrollPos", "WebBrowser_GetScrollPos_V1", "WebBrowser_SetScrollPos", "WebBrowser_SetScrollPos_V1", "WebBrowser_GetCookies", "WebBrowser_GetCookieNew", "WebBrowser_GetCookie", "WebBrowser_SetCookies", "WebBrowser_SetCookies_V1", "WebBrowser_Wait", "WebBrowser_CloseWeb", "WebBrowser_CloseWeb_V1", "WebBrowser_CloseWeb_V2", "WebBrowser_Close_V1", "WebBrowser_GetBrowserPic", "WebBrowser_GetBrowserPicV1", "Element_ExistsV1", "Element_WaitElement_v5", "WebBrowser_DownFile", "WebBrowser_DownFile_V1", "WebBrowser_DownloadFile", "WebBrowser_DownloadFile_V1"] as const;
|
||||
@@ -0,0 +1,39 @@
|
||||
import { createServer } from "node:http";
|
||||
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { PDFDocument } from "pdf-lib";
|
||||
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 "./desktop"; import "./pdf"; import "./system-extended"; import "./window-extended"; import "./common-final";
|
||||
|
||||
const dirs: string[] = []; const context = { vars: {} } 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<string, unknown> = {}) { const handler = getHandler(id); expect(handler, id).toBeTruthy(); return handler!(context, node(id), args, component); }
|
||||
afterEach(async () => { context.vars = {}; await Promise.all(dirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))); });
|
||||
|
||||
describe("final common capabilities", () => {
|
||||
it("registers window, keyboard, clipboard, system, PDF and webhook components", () => {
|
||||
for (const id of ["Window_ActiveWindow", "Window_CloseWindow_v1", "Window_GetScreenSize", "Window_WindowList", "Window_GetRect", "Window_MaximumSize", "Window_SetWindowShow", "Window_WaitWindow", "Keyboard_DriveSend", "Keyboard_DriveSend_V1", "Keyboard_DriveSend_V2", "Clipboard_SetImage", "Clipboard_SetImageV1", "Clipboard_SaveImage", "Clipboard_SaveImageV1", "System_PlaySound", "CustomConfig_GetConfig", "Basic_BusinessOrderCount", "PDF_ConcatPdfV1", "PDF_SetPdfWaterMark", "PDF_SetPdfWaterMarkV1", "MessageNotification_DingTalk_Message", "MessageNotification_Feishu_Message", "MessageNotification_WXWork_Message"]) expect(getHandler(id), id).toBeTruthy();
|
||||
});
|
||||
|
||||
it("reads custom config and increments business counters", async () => {
|
||||
await expect(run("CustomConfig_GetConfig", { jsonlist: [{ name: "timeout", value: 30 }], configName: "timeout" })).resolves.toBe(30);
|
||||
await expect(run("Basic_BusinessOrderCount", { start_count: 10, count: 2 })).resolves.toBe(12);
|
||||
await expect(run("Basic_BusinessOrderCount", { start_count: 10, count: 2 })).resolves.toBe(14);
|
||||
});
|
||||
|
||||
it("adds an ASCII PDF watermark", async () => {
|
||||
const directory = await mkdtemp(path.join(os.tmpdir(), "rpa-common-final-")); dirs.push(directory); const input = path.join(directory, "input.pdf"); const pdf = await PDFDocument.create(); pdf.addPage([300, 300]); await writeFile(input, await pdf.save());
|
||||
const output = await run("PDF_SetPdfWaterMark", { fileInPath: input, markTextInfo: "RPA", newPdfName: "watermark.pdf" }) as string;
|
||||
expect((await readFile(output)).byteLength).toBeGreaterThan(100);
|
||||
});
|
||||
|
||||
it("posts the three webhook formats to a local server", async () => {
|
||||
const bodies: unknown[] = []; const server = createServer((request, response) => { let raw = ""; request.on("data", (chunk) => raw += chunk); request.on("end", () => { bodies.push(JSON.parse(raw)); response.writeHead(200, { "Content-Type": "application/json" }); response.end('{"ok":true}'); }); });
|
||||
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve)); const address = server.address(); const webhook = `http://127.0.0.1:${typeof address === "object" && address ? address.port : 0}/hook`;
|
||||
try { await run("MessageNotification_DingTalk_Message", { webhook, content_text: "ding" }); await run("MessageNotification_Feishu_Message", { webhook, content_text: "fei" }); await run("MessageNotification_WXWork_Message", { webhook, content_text: "wx" }); expect(bodies).toHaveLength(3); } finally { await new Promise<void>((resolve) => server.close(() => resolve())); }
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
import { createHmac } from "node:crypto";
|
||||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { PDFDocument, StandardFonts, degrees, rgb } from "pdf-lib";
|
||||
import { getHandler, register } from "../engine/registry";
|
||||
import { RuntimeError, type CapabilityHandler } from "../engine/types";
|
||||
import { powershell, psString } from "./desktop";
|
||||
|
||||
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 delegate(baseId: string): CapabilityHandler {
|
||||
return (ctx, node, args, component) => { const base = getHandler(baseId); if (!base) throw new RuntimeError("capability_not_implemented", `基础能力不可用:${baseId}`, { nodeId: node.id, componentId: node.componentId }); return base(ctx, node, args, component); };
|
||||
}
|
||||
|
||||
register("Window_ActiveWindow", delegate("Window_SetActive"));
|
||||
register("Window_CloseWindow_v1", delegate("Window_CloseWindow"));
|
||||
register("Window_GetRect", delegate("Window_GetWindowRect"));
|
||||
register("Window_MaximumSize", (ctx, node, args, component) => delegate("Window_SetWindowState")(ctx, node, { ...args, WindowType: "maximize" }, component));
|
||||
register(["Keyboard_DriveSend", "Keyboard_DriveSend_V1", "Keyboard_DriveSend_V2"], delegate("Keyboard_Send_V1"));
|
||||
|
||||
register("Window_GetScreenSize", async () => {
|
||||
const output = await powershell("Add-Type -AssemblyName System.Windows.Forms; $b=[System.Windows.Forms.Screen]::PrimaryScreen.Bounds; @{width=$b.Width;height=$b.Height;x=$b.X;y=$b.Y} | ConvertTo-Json -Compress");
|
||||
return JSON.parse(output);
|
||||
});
|
||||
register("Window_WindowList", async (_ctx, _node, args) => {
|
||||
const title = text(args, ["match_title", "select_title"]); const regex = String(args.is_regex ?? "0") === "1";
|
||||
const output = await powershell(`Get-Process | Where-Object { $_.MainWindowHandle -ne 0 ${title ? (regex ? `-and $_.MainWindowTitle -match ${psString(title)}` : `-and $_.MainWindowTitle -like ${psString(`*${title}*`)}`) : ""} } | ForEach-Object { @{pid=$_.Id;title=$_.MainWindowTitle;hwnd=$_.MainWindowHandle.ToInt64()} } | ConvertTo-Json -Compress`);
|
||||
if (!output) return []; const parsed = JSON.parse(output); return Array.isArray(parsed) ? parsed : [parsed];
|
||||
});
|
||||
register("Window_SetWindowShow", async (_ctx, node, args) => {
|
||||
const title = text(args, ["窗口对象", "window_obj", "title"]); if (!title || title === "窗口对象") throw new RuntimeError("validation_error", "窗口标题为空", { nodeId: node.id, componentId: node.componentId });
|
||||
const show = !text(args, ["WindowType"], "show").toLowerCase().includes("hide");
|
||||
await powershell(`$p=Get-Process|Where-Object{$_.MainWindowTitle -like ${psString(`*${title}*`)}}|Select-Object -First 1;if(!$p){throw '找不到窗口'};Add-Type @'\nusing System;using System.Runtime.InteropServices;public static class W{[DllImport("user32.dll")]public static extern bool ShowWindow(IntPtr h,int n);}\n'@;[W]::ShowWindow($p.MainWindowHandle,${show ? 5 : 0})|Out-Null`);
|
||||
return show;
|
||||
});
|
||||
register("Window_WaitWindow", async (_ctx, node, args) => {
|
||||
const title = text(args, ["match_title", "select_title", "window_obj", "target_window"]); if (!title) throw new RuntimeError("validation_error", "窗口标题为空", { nodeId: node.id, componentId: node.componentId });
|
||||
const expected = text(args, ["switchType"], "0") !== "1"; const deadline = Date.now() + Math.max(0, Number(args.second ?? 5)) * 1000;
|
||||
do { const exists = (await powershell(`$p=Get-Process|Where-Object{$_.MainWindowTitle -like ${psString(`*${title}*`)}}|Select-Object -First 1;[bool]$p`)).toLowerCase() === "true"; if (exists === expected) return true; await new Promise((resolve) => setTimeout(resolve, 200)); } while (Date.now() <= deadline);
|
||||
return false;
|
||||
});
|
||||
|
||||
const setClipboardImage: CapabilityHandler = async (_ctx, node, args) => {
|
||||
const file = path.resolve(text(args, ["path"])); await fs.access(file).catch(() => { throw new RuntimeError("validation_error", `图片不存在:${file}`, { nodeId: node.id, componentId: node.componentId }); });
|
||||
await powershell(`Add-Type -AssemblyName System.Windows.Forms;Add-Type -AssemblyName System.Drawing;$image=[System.Drawing.Image]::FromFile(${psString(file)});[System.Windows.Forms.Clipboard]::SetImage($image);$image.Dispose()`); return file;
|
||||
};
|
||||
register(["Clipboard_SetImage", "Clipboard_SetImageV1"], setClipboardImage);
|
||||
const saveClipboardImage: CapabilityHandler = async (_ctx, node, args) => {
|
||||
const folder = path.resolve(text(args, ["path"])); const name = text(args, ["filename"], "clipboard.png"); const target = path.join(folder, path.extname(name) ? name : `${name}.png`); await fs.mkdir(folder, { recursive: true });
|
||||
await powershell(`Add-Type -AssemblyName System.Windows.Forms;Add-Type -AssemblyName System.Drawing;if(-not [System.Windows.Forms.Clipboard]::ContainsImage()){throw '剪贴板中没有图片'};$image=[System.Windows.Forms.Clipboard]::GetImage();$image.Save(${psString(target)},[System.Drawing.Imaging.ImageFormat]::Png);$image.Dispose()`).catch((error) => { throw new RuntimeError("desktop_command_failed", (error as Error).message, { nodeId: node.id, componentId: node.componentId }); }); return target;
|
||||
};
|
||||
register(["Clipboard_SaveImage", "Clipboard_SaveImageV1"], saveClipboardImage);
|
||||
|
||||
register("System_PlaySound", async (_ctx, node, args) => {
|
||||
const audio = text(args, ["audio"]); if (audio) { const file = path.resolve(audio); await fs.access(file); await powershell(`$player=New-Object System.Media.SoundPlayer ${psString(file)};$player.PlaySync()`); return file; }
|
||||
await powershell("[System.Media.SystemSounds]::Beep.Play()"); return true;
|
||||
});
|
||||
register("CustomConfig_GetConfig", (_ctx, _node, args) => {
|
||||
let configs: unknown = args.jsonlist; if (typeof configs === "string") { try { configs = JSON.parse(configs); } catch { configs = []; } }
|
||||
const name = text(args, ["configName"]); if (Array.isArray(configs)) { const found = configs.find((item) => item && typeof item === "object" && [String((item as any).name ?? ""), String((item as any).configName ?? ""), String((item as any).key ?? "")].includes(name)); return found && typeof found === "object" && "value" in found ? (found as any).value : found ?? null; }
|
||||
return configs && typeof configs === "object" ? (configs as Record<string, unknown>)[name] : null;
|
||||
});
|
||||
register("Basic_BusinessOrderCount", (ctx, _node, args) => {
|
||||
const key = "__businessOrderCount"; const start = Number(args.start_count ?? 0); const step = Number(args.count ?? 1); const current = Number(ctx.vars[key] ?? start); const next = current + step; ctx.vars[key] = next; return next;
|
||||
});
|
||||
|
||||
register("PDF_ConcatPdfV1", delegate("PDF_ConcatPdf"));
|
||||
register(["PDF_SetPdfWaterMark", "PDF_SetPdfWaterMarkV1"], async (_ctx, node, args) => {
|
||||
const input = path.resolve(text(args, ["fileInPath"])); const output = path.join(path.dirname(input), text(args, ["newPdfName"], "PDF加水印.pdf")); const content = text(args, ["markTextInfo"], "Watermark");
|
||||
if (!/^[\x20-\x7E]*$/.test(content)) throw new RuntimeError("validation_error", "当前文本水印仅支持 ASCII;中文水印需嵌入字体", { nodeId: node.id, componentId: node.componentId });
|
||||
const document = await PDFDocument.load(await fs.readFile(input), { ignoreEncryption: true }); const font = await document.embedFont(StandardFonts.Helvetica);
|
||||
for (const page of document.getPages()) { const { width, height } = page.getSize(); page.drawText(content, { x: width * 0.2, y: height * 0.5, size: 32, font, color: rgb(0.65, 0.65, 0.65), opacity: 0.35, rotate: degrees(-30) }); }
|
||||
await fs.writeFile(output, await document.save()); return output;
|
||||
});
|
||||
|
||||
async function webhook(kind: "dingtalk" | "feishu" | "wecom", args: Record<string, unknown>, nodeId: string, componentId: string) {
|
||||
let url = text(args, ["webhook"]); if (!/^https?:\/\//i.test(url)) throw new RuntimeError("validation_error", "Webhook 地址无效", { nodeId, componentId });
|
||||
const secret = text(args, ["key"]); if (kind === "dingtalk" && secret) { const timestamp = Date.now(); const sign = encodeURIComponent(createHmac("sha256", secret).update(`${timestamp}\n${secret}`).digest("base64")); url += `${url.includes("?") ? "&" : "?"}timestamp=${timestamp}&sign=${sign}`; }
|
||||
const custom = text(args, ["content_custom"]); const messageType = text(args, ["message_type"], "0"); const plain = text(args, ["content_text", "test_message"]); const markdown = text(args, ["content_markdown"]);
|
||||
let body: unknown;
|
||||
if (custom) { try { body = JSON.parse(custom); } catch { throw new RuntimeError("validation_error", "自定义消息 JSON 无效", { nodeId, componentId }); } }
|
||||
else if (kind === "feishu") body = messageType === "1" ? { msg_type: "interactive", card: { elements: [{ tag: "markdown", content: markdown || plain }] } } : { msg_type: "text", content: { text: plain } };
|
||||
else if (kind === "dingtalk") body = messageType === "1" ? { msgtype: "markdown", markdown: { title: "RPA通知", text: markdown || plain } } : { msgtype: "text", text: { content: plain }, at: { atMobiles: text(args, ["mentioned_mobile_list"]).split(/[,,]/).filter(Boolean) } };
|
||||
else body = messageType === "1" ? { msgtype: "markdown", markdown: { content: markdown || plain } } : { msgtype: "text", text: { content: plain, mentioned_mobile_list: text(args, ["mentioned_mobile_list"]).split(/[,,]/).filter(Boolean) } };
|
||||
const response = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }); const responseText = await response.text(); if (!response.ok) throw new RuntimeError("http_error", `Webhook 返回 ${response.status}: ${responseText}`, { nodeId, componentId });
|
||||
try { return JSON.parse(responseText); } catch { return responseText; }
|
||||
}
|
||||
register("MessageNotification_DingTalk_Message", (_ctx, node, args) => webhook("dingtalk", args, node.id, node.componentId));
|
||||
register("MessageNotification_Feishu_Message", (_ctx, node, args) => webhook("feishu", args, node.id, node.componentId));
|
||||
register("MessageNotification_WXWork_Message", (_ctx, node, args) => webhook("wecom", args, node.id, node.componentId));
|
||||
@@ -0,0 +1,147 @@
|
||||
import { mkdtemp, readFile, readdir, rm } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import ExcelJS from "exceljs";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import type { Catalog, CatalogComponent, FlowNode } from "../../shared/types";
|
||||
import type { RunContext } from "../engine/types";
|
||||
import { getHandler } from "../engine/registry";
|
||||
import "./index";
|
||||
import { EXCEL_COMMON_IDS } from "./excel-common";
|
||||
|
||||
const directories: string[] = [];
|
||||
const context = {} as RunContext;
|
||||
const component = {} as CatalogComponent;
|
||||
const node = (componentId: string): FlowNode => ({
|
||||
id: `excel_common_${componentId}`,
|
||||
componentId,
|
||||
position: { x: 0, y: 0 },
|
||||
values: {},
|
||||
options: {}
|
||||
});
|
||||
|
||||
async function temporaryWorkbook(): Promise<string> {
|
||||
const directory = await mkdtemp(path.join(os.tmpdir(), "rpa-excel-common-"));
|
||||
directories.push(directory);
|
||||
const filePath = path.join(directory, "common.xlsx");
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
const sheet = workbook.addWorksheet("Data");
|
||||
sheet.addRows([
|
||||
["name", "score", "group", "parts"],
|
||||
["Bob", 2, "B", "x,y"],
|
||||
["Alice", 3, "A", "m,n"],
|
||||
["Bob", 2, "B", "p,q"]
|
||||
]);
|
||||
await workbook.xlsx.writeFile(filePath);
|
||||
return filePath;
|
||||
}
|
||||
|
||||
async function run(componentId: string, args: Record<string, unknown>): Promise<unknown> {
|
||||
const handler = getHandler(componentId);
|
||||
expect(handler, `${componentId} should be registered`).toBeTruthy();
|
||||
return handler!(context, node(componentId), args, component);
|
||||
}
|
||||
|
||||
async function readWorkbook(filePath: string): Promise<ExcelJS.Workbook> {
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
await workbook.xlsx.readFile(filePath);
|
||||
return workbook;
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(directories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
describe("common Excel capabilities", () => {
|
||||
it("registers at least 25 real catalog IDs without conflicting with existing modules", async () => {
|
||||
const catalog = JSON.parse(await readFile("resources/catalog.json", "utf8")) as Catalog;
|
||||
const catalogIds = new Set(catalog.components.map((item) => item.id));
|
||||
expect(EXCEL_COMMON_IDS.length).toBeGreaterThanOrEqual(25);
|
||||
for (const id of EXCEL_COMMON_IDS) {
|
||||
expect(catalogIds.has(id), `${id} should exist in catalog.json`).toBe(true);
|
||||
expect(getHandler(id), `${id} should be registered`).toBeTruthy();
|
||||
}
|
||||
|
||||
const sourceFiles = (await readdir("src/main/capabilities"))
|
||||
.filter((file) => file.endsWith(".ts") && !file.endsWith(".test.ts") && file !== "excel-common.ts");
|
||||
const existingSource = (await Promise.all(sourceFiles.map((file) => readFile(path.join("src/main/capabilities", file), "utf8")))).join("\n");
|
||||
for (const id of EXCEL_COMMON_IDS) expect(existingSource.includes(`"${id}"`), `${id} must not already be registered`).toBe(false);
|
||||
});
|
||||
|
||||
it("writes and reads rows, columns, and ranges in a real workbook", async () => {
|
||||
const filePath = await temporaryWorkbook();
|
||||
await run("Excel_Cs_WriteRow", { workbook: filePath, sheet: "Data", row: 6, column: "A", contents: ["Carol", 9, "C"], save: 1 });
|
||||
await run("Excel_Cs_WriteColumn", { workbook: filePath, sheet: "Data", row: 2, column: "E", contents: [10, 20, 30], save: 1 });
|
||||
await run("Excel_Cs_WriteRangeV2", { workbook: filePath, sheet: "Data", row: 8, column: "A", contents: [["x", 1], ["y", 2]], save: 1 });
|
||||
|
||||
await expect(run("Excel_Cs_ReadRow", { workbook: filePath, sheet: "Data", rowIndex: 6 })).resolves.toEqual(["Carol", 9, "C", "", ""]);
|
||||
await expect(run("Excel_GetColData", { objExcel: filePath, sheet: "Data", cellPos: "E2" })).resolves.toEqual([10, 20, 30, "", "", "", "", ""]);
|
||||
await expect(run("Excel_Cs_ReadRange", { workbook: filePath, sheet: "Data", startRow: 8, startColumn: "A", lastRow: 9, lastColumn: "B" }))
|
||||
.resolves.toEqual([["x", 1], ["y", 2]]);
|
||||
await expect(run("Excel_Cs_ReadColumnCount", { workbook: filePath, sheet: "Data", row: 2 })).resolves.toBe(5);
|
||||
});
|
||||
|
||||
it("finds, replaces, sorts, filters, clears filters, and removes duplicates", async () => {
|
||||
const filePath = await temporaryWorkbook();
|
||||
await expect(run("Excel_Cs_Find", { workbook: filePath, sheet: "Data", what: "Alice", matchFirst: 1 }))
|
||||
.resolves.toEqual(["A3"]);
|
||||
await run("Excel_Cs_Search", { workbook: filePath, sheet: "Data", mode: "替换", input: "Alice", replace: "Alicia", wholeWord: 1, save: 1 });
|
||||
await run("Excel_Cs_Sort", { workbook: filePath, sheet: "Data", sortRange: "A1:D4", sortOrder: "2", header: 1, save: 1 });
|
||||
await run("Excel_Cs_Filter", { workbook: filePath, sheet: "Data", column: "C", criteria: "B", condition: "equal", startRow: 1, endRow: 4, onHeader: 1, save: 1 });
|
||||
|
||||
let workbook = await readWorkbook(filePath);
|
||||
let sheet = workbook.getWorksheet("Data")!;
|
||||
expect(sheet.getCell("A2").value).toBe("Bob");
|
||||
expect(sheet.getRow(3).hidden || sheet.getRow(4).hidden).toBe(true);
|
||||
|
||||
await run("Excel_Cs_ClearExcelFilter", { workbook: filePath, sheet: "Data", save: 1 });
|
||||
await run("Excel_Cs_DeleteDuplicates", { workbook: filePath, sheet: "Data", columns: ["A", "B", "C"], header: 1, save: 1 });
|
||||
workbook = await readWorkbook(filePath);
|
||||
sheet = workbook.getWorksheet("Data")!;
|
||||
expect(sheet.getRows(1, sheet.rowCount)!.some((row) => row.hidden)).toBe(false);
|
||||
expect(sheet.rowCount).toBe(3);
|
||||
expect(sheet.getColumn("A").values).toContain("Alicia");
|
||||
});
|
||||
|
||||
it("copies, pastes, fills, splits, and deletes real ranges", async () => {
|
||||
const filePath = await temporaryWorkbook();
|
||||
await run("Excel_CopyRange", { objExcel: filePath, sheet: "Data", cellPosRange: "A2:B3" });
|
||||
await run("Excel_Cs_PastRange", { workbook: filePath, sheet: "Data", column: "F", row: 2, save: 1 });
|
||||
await run("Excel_Cs_AutoFill", { workbook: filePath, sheet: "Data", sourceRange: "B2:B3", destination: "B2:B6", save: 1 });
|
||||
await run("Excel_Cs_ColumnSplitV2", { workbook: filePath, sheet: "Data", column: "D", destination: "J1", comma: 1, tab: 0, startRow: 2, endRow: 4, save: 1 });
|
||||
await run("Excel_Cs_DeleteRangeV3", { workbook: filePath, sheet: "Data", excelRng: "C2:C4", save: 1 });
|
||||
|
||||
const workbook = await readWorkbook(filePath);
|
||||
const sheet = workbook.getWorksheet("Data")!;
|
||||
expect(sheet.getCell("F2").value).toBe("Bob");
|
||||
expect(sheet.getCell("G3").value).toBe(3);
|
||||
expect(sheet.getCell("B6").value).toBe(6);
|
||||
expect(sheet.getCell("J2").value).toBe("x");
|
||||
expect(sheet.getCell("K2").value).toBe("y");
|
||||
expect(sheet.getCell("C2").value).toBeNull();
|
||||
});
|
||||
|
||||
it("manages sheets and persists active sheet state", async () => {
|
||||
const filePath = await temporaryWorkbook();
|
||||
await run("Excel_CreateSheet", { objExcel: filePath, sheet: "Report", isSave: true });
|
||||
await expect(run("Excel_Cs_GetName", { workbook: filePath })).resolves.toEqual(["Data", "Report"]);
|
||||
await expect(run("Excel_Cs_Active", { workbook: filePath, mode: "按名称", sheet: "Report", save: 1 })).resolves.toBe("Report");
|
||||
await run("Excel_DeleteSheet", { objExcel: filePath, sheet: "Data", isSave: true });
|
||||
await run("Excel_Cs_Close", { workbook: filePath, save: 1 });
|
||||
|
||||
const workbook = await readWorkbook(filePath);
|
||||
expect(workbook.worksheets.map((sheet) => sheet.name)).toEqual(["Report"]);
|
||||
});
|
||||
|
||||
it("sets and reads cell and range colors", async () => {
|
||||
const filePath = await temporaryWorkbook();
|
||||
await run("Excel_Cs_SetCellBackground", { workbook: filePath, sheet: "Data", column: "A", row: 2, color: "#12AB34", save: 1 });
|
||||
await run("Excel_Cs_SetRangeFontV2", { workbook: filePath, sheet: "Data", range: "A2:B3", color: "#445566", save: 1 });
|
||||
await expect(run("Excel_Cs_ReadCellBackground", { workbook: filePath, sheet: "Data", column: "A", row: 2 })).resolves.toBe("FF12AB34");
|
||||
|
||||
const workbook = await readWorkbook(filePath);
|
||||
const sheet = workbook.getWorksheet("Data")!;
|
||||
expect((sheet.getCell("A2").fill as ExcelJS.FillPattern).fgColor?.argb).toBe("FF12AB34");
|
||||
expect(sheet.getCell("B3").font.color?.argb).toBe("FF445566");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,618 @@
|
||||
import ExcelJS from "exceljs";
|
||||
import path from "node:path";
|
||||
import { register } from "../engine/registry";
|
||||
import { RuntimeError, type CapabilityHandler } from "../engine/types";
|
||||
|
||||
type WorkbookSource = { workbook: ExcelJS.Workbook; filePath: string };
|
||||
type CopiedRange = { values: unknown[][]; styles: Partial<ExcelJS.Style>[][] };
|
||||
|
||||
const workbookCache = new Map<string, ExcelJS.Workbook>();
|
||||
let copiedRange: CopiedRange | undefined;
|
||||
|
||||
function value(args: Record<string, unknown>, keys: string[], fallback: unknown = ""): unknown {
|
||||
for (const key of keys) {
|
||||
const candidate = args[key];
|
||||
if (candidate !== undefined && candidate !== null && candidate !== "") return candidate;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function text(args: Record<string, unknown>, keys: string[], fallback = ""): string {
|
||||
return String(value(args, keys, fallback));
|
||||
}
|
||||
|
||||
function truthy(candidate: unknown, fallback = false): boolean {
|
||||
if (candidate === undefined || candidate === null || candidate === "") return fallback;
|
||||
return [true, 1, "1", "true", "True", "TRUE", "yes", "是", "开启"].includes(candidate as never);
|
||||
}
|
||||
|
||||
function numberValue(candidate: unknown, fallback: number): number {
|
||||
const result = Number(candidate);
|
||||
return Number.isFinite(result) ? result : fallback;
|
||||
}
|
||||
|
||||
function requiredPath(candidate: unknown, nodeId: string, componentId: string): string {
|
||||
const raw = typeof candidate === "object" && candidate !== null ? (candidate as { path?: unknown }).path : candidate;
|
||||
const filePath = String(raw ?? "").trim();
|
||||
if (!filePath) {
|
||||
throw new RuntimeError("validation_error", "Excel path is empty", {
|
||||
nodeId,
|
||||
componentId,
|
||||
recommendation: "Configure an Excel workbook path"
|
||||
});
|
||||
}
|
||||
return path.resolve(filePath);
|
||||
}
|
||||
|
||||
async function loadWorkbook(args: Record<string, unknown>, nodeId: string, componentId: string): Promise<WorkbookSource> {
|
||||
const filePath = requiredPath(value(args, ["workbook", "objExcel", "objExcelWorkBook", "file", "path"]), nodeId, componentId);
|
||||
let workbook = workbookCache.get(filePath);
|
||||
if (!workbook) {
|
||||
workbook = new ExcelJS.Workbook();
|
||||
await workbook.xlsx.readFile(filePath);
|
||||
workbookCache.set(filePath, workbook);
|
||||
}
|
||||
return { workbook, filePath };
|
||||
}
|
||||
|
||||
function worksheetOf(workbook: ExcelJS.Workbook, args: Record<string, unknown>): ExcelJS.Worksheet {
|
||||
const name = text(args, ["sheet", "sheetName"]);
|
||||
const sheet = name ? workbook.getWorksheet(name) : workbook.worksheets[0];
|
||||
if (!sheet) throw new Error(`Sheet not found: ${name || "first sheet"}`);
|
||||
return sheet;
|
||||
}
|
||||
|
||||
async function saveWhenRequested(source: WorkbookSource, args: Record<string, unknown>, fallback = false): Promise<void> {
|
||||
if (truthy(value(args, ["save", "isSave"]), fallback)) await source.workbook.xlsx.writeFile(source.filePath);
|
||||
}
|
||||
|
||||
function columnNumber(candidate: unknown): number {
|
||||
const raw = String(candidate ?? "A").trim().toUpperCase();
|
||||
if (/^\d+$/.test(raw)) return Math.max(1, Number(raw));
|
||||
if (!/^[A-Z]+$/.test(raw)) throw new Error(`Invalid column: ${raw}`);
|
||||
let result = 0;
|
||||
for (const character of raw) result = result * 26 + character.charCodeAt(0) - 64;
|
||||
return result;
|
||||
}
|
||||
|
||||
function columnLetter(index: number): string {
|
||||
let current = Math.max(1, Math.trunc(index));
|
||||
let result = "";
|
||||
while (current > 0) {
|
||||
current -= 1;
|
||||
result = String.fromCharCode(65 + current % 26) + result;
|
||||
current = Math.floor(current / 26);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function cellValue(candidate: ExcelJS.CellValue): unknown {
|
||||
if (candidate && typeof candidate === "object" && "result" in candidate) return candidate.result ?? "";
|
||||
if (candidate && typeof candidate === "object" && "text" in candidate) return candidate.text;
|
||||
return candidate ?? "";
|
||||
}
|
||||
|
||||
function listValue(candidate: unknown): unknown[] {
|
||||
if (Array.isArray(candidate)) return candidate;
|
||||
const raw = String(candidate ?? "").trim();
|
||||
if (!raw) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
return Array.isArray(parsed) ? parsed : [parsed];
|
||||
} catch {
|
||||
try {
|
||||
const parsed = JSON.parse(raw.replace(/'/g, '"'));
|
||||
return Array.isArray(parsed) ? parsed : [parsed];
|
||||
} catch {
|
||||
return raw.split(",").map((item) => item.trim());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function tableValue(candidate: unknown): unknown[][] {
|
||||
return listValue(candidate).map((item) => Array.isArray(item) ? item : [item]);
|
||||
}
|
||||
|
||||
function rangeBounds(sheet: ExcelJS.Worksheet, range: string): { top: number; left: number; bottom: number; right: number } {
|
||||
const normalized = range.trim() || `A1:${columnLetter(Math.max(1, sheet.columnCount))}${Math.max(1, sheet.rowCount)}`;
|
||||
const [start, end = start] = normalized.split(":").map((part) => part.trim());
|
||||
const first = sheet.getCell(start);
|
||||
const last = sheet.getCell(end);
|
||||
return {
|
||||
top: Math.min(Number(first.row), Number(last.row)),
|
||||
left: Math.min(Number(first.col), Number(last.col)),
|
||||
bottom: Math.max(Number(first.row), Number(last.row)),
|
||||
right: Math.max(Number(first.col), Number(last.col))
|
||||
};
|
||||
}
|
||||
|
||||
function explicitRange(args: Record<string, unknown>, sheet: ExcelJS.Worksheet): string {
|
||||
const direct = text(args, ["excelRng", "range", "cellPosRange", "cellPos", "sortRange"]);
|
||||
if (direct) return direct;
|
||||
const top = numberValue(value(args, ["startRow", "row"]), 1);
|
||||
const left = columnNumber(value(args, ["startColumn", "column"]));
|
||||
const bottom = numberValue(value(args, ["lastRow", "endRow"]), top);
|
||||
const right = columnNumber(value(args, ["lastColumn", "endColumn"], left));
|
||||
return `${columnLetter(left)}${top}:${columnLetter(right)}${bottom || Math.max(1, sheet.rowCount)}`;
|
||||
}
|
||||
|
||||
function cellsInRange(sheet: ExcelJS.Worksheet, range: string): ExcelJS.Cell[] {
|
||||
const bounds = rangeBounds(sheet, range);
|
||||
const cells: ExcelJS.Cell[] = [];
|
||||
for (let row = bounds.top; row <= bounds.bottom; row += 1) {
|
||||
for (let column = bounds.left; column <= bounds.right; column += 1) cells.push(sheet.getCell(row, column));
|
||||
}
|
||||
return cells;
|
||||
}
|
||||
|
||||
function cloneStyle(style: Partial<ExcelJS.Style>): Partial<ExcelJS.Style> {
|
||||
return JSON.parse(JSON.stringify(style)) as Partial<ExcelJS.Style>;
|
||||
}
|
||||
|
||||
function colorArgb(candidate: unknown): string {
|
||||
if (Array.isArray(candidate)) {
|
||||
const [red = 0, green = 0, blue = 0] = candidate.map((item) => Math.max(0, Math.min(255, Number(item))));
|
||||
return `FF${red.toString(16).padStart(2, "0")}${green.toString(16).padStart(2, "0")}${blue.toString(16).padStart(2, "0")}`.toUpperCase();
|
||||
}
|
||||
const raw = String(candidate ?? "#000000").trim();
|
||||
if (raw.startsWith("[")) {
|
||||
try { return colorArgb(JSON.parse(raw)); } catch { return "FF000000"; }
|
||||
}
|
||||
const hex = raw.replace(/^#/, "").toUpperCase();
|
||||
if (/^[0-9A-F]{8}$/.test(hex)) return hex;
|
||||
return `FF${/^[0-9A-F]{6}$/.test(hex) ? hex : "000000"}`;
|
||||
}
|
||||
|
||||
function comparison(left: unknown, right: unknown): number {
|
||||
if (typeof left === "number" && typeof right === "number") return left - right;
|
||||
return String(left ?? "").localeCompare(String(right ?? ""), "zh-CN", { numeric: true, sensitivity: "base" });
|
||||
}
|
||||
|
||||
function registerMany(ids: string[], handler: CapabilityHandler): void {
|
||||
register(ids, handler);
|
||||
}
|
||||
|
||||
const writeRow: CapabilityHandler = async (_ctx, node, args) => {
|
||||
const source = await loadWorkbook(args, node.id, node.componentId);
|
||||
const sheet = worksheetOf(source.workbook, args);
|
||||
const start = text(args, ["cellPos"]);
|
||||
const row = start ? Number(sheet.getCell(start).row) : numberValue(value(args, ["row", "rowIndex"]), 1);
|
||||
const column = start ? Number(sheet.getCell(start).col) : columnNumber(value(args, ["column"]));
|
||||
listValue(value(args, ["contents", "value"])).forEach((item, index) => { sheet.getCell(row, column + index).value = item as ExcelJS.CellValue; });
|
||||
await saveWhenRequested(source, args);
|
||||
return `${columnLetter(column)}${row}`;
|
||||
};
|
||||
|
||||
const writeColumn: CapabilityHandler = async (_ctx, node, args) => {
|
||||
const source = await loadWorkbook(args, node.id, node.componentId);
|
||||
const sheet = worksheetOf(source.workbook, args);
|
||||
const start = text(args, ["cellPos"]);
|
||||
const row = start ? Number(sheet.getCell(start).row) : numberValue(value(args, ["row"]), 1);
|
||||
const column = start ? Number(sheet.getCell(start).col) : columnNumber(value(args, ["column"]));
|
||||
listValue(value(args, ["contents", "value"])).forEach((item, index) => { sheet.getCell(row + index, column).value = item as ExcelJS.CellValue; });
|
||||
await saveWhenRequested(source, args);
|
||||
return `${columnLetter(column)}${row}`;
|
||||
};
|
||||
|
||||
const writeRange: CapabilityHandler = async (_ctx, node, args) => {
|
||||
const source = await loadWorkbook(args, node.id, node.componentId);
|
||||
const sheet = worksheetOf(source.workbook, args);
|
||||
const row = numberValue(value(args, ["row", "startRow"]), 1);
|
||||
const column = columnNumber(value(args, ["column", "startColumn"]));
|
||||
tableValue(value(args, ["contents", "value"])).forEach((items, rowOffset) => {
|
||||
items.forEach((item, columnOffset) => { sheet.getCell(row + rowOffset, column + columnOffset).value = item as ExcelJS.CellValue; });
|
||||
});
|
||||
await saveWhenRequested(source, args);
|
||||
return `${columnLetter(column)}${row}`;
|
||||
};
|
||||
|
||||
const readRow: CapabilityHandler = async (_ctx, node, args) => {
|
||||
const source = await loadWorkbook(args, node.id, node.componentId);
|
||||
const sheet = worksheetOf(source.workbook, args);
|
||||
const start = text(args, ["cellPos"]);
|
||||
const row = start ? Number(sheet.getCell(start).row) : numberValue(value(args, ["row", "rowIndex"]), 1);
|
||||
const firstColumn = start ? Number(sheet.getCell(start).col) : 1;
|
||||
const result: unknown[] = [];
|
||||
for (let column = firstColumn; column <= Math.max(firstColumn, sheet.columnCount); column += 1) result.push(cellValue(sheet.getCell(row, column).value));
|
||||
return result;
|
||||
};
|
||||
|
||||
const readColumn: CapabilityHandler = async (_ctx, node, args) => {
|
||||
const source = await loadWorkbook(args, node.id, node.componentId);
|
||||
const sheet = worksheetOf(source.workbook, args);
|
||||
const start = text(args, ["cellPos"]);
|
||||
const row = start ? Number(sheet.getCell(start).row) : 1;
|
||||
const column = start ? Number(sheet.getCell(start).col) : columnNumber(value(args, ["column"]));
|
||||
const result: unknown[] = [];
|
||||
for (let current = row; current <= Math.max(row, sheet.rowCount); current += 1) result.push(cellValue(sheet.getCell(current, column).value));
|
||||
return result;
|
||||
};
|
||||
|
||||
const readRange: CapabilityHandler = async (_ctx, node, args) => {
|
||||
const source = await loadWorkbook(args, node.id, node.componentId);
|
||||
const sheet = worksheetOf(source.workbook, args);
|
||||
const bounds = rangeBounds(sheet, explicitRange(args, sheet));
|
||||
const result: unknown[][] = [];
|
||||
for (let row = bounds.top; row <= bounds.bottom; row += 1) {
|
||||
const values: unknown[] = [];
|
||||
for (let column = bounds.left; column <= bounds.right; column += 1) values.push(cellValue(sheet.getCell(row, column).value));
|
||||
result.push(values);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
const readColumnCount: CapabilityHandler = async (_ctx, node, args) => {
|
||||
const source = await loadWorkbook(args, node.id, node.componentId);
|
||||
const sheet = worksheetOf(source.workbook, args);
|
||||
const row = numberValue(value(args, ["row"]), 1);
|
||||
let last = 0;
|
||||
for (let column = 1; column <= sheet.columnCount; column += 1) {
|
||||
if (cellValue(sheet.getCell(row, column).value) !== "") last = column;
|
||||
}
|
||||
return last;
|
||||
};
|
||||
|
||||
const findOrReplace: CapabilityHandler = async (_ctx, node, args) => {
|
||||
const source = await loadWorkbook(args, node.id, node.componentId);
|
||||
const sheet = worksheetOf(source.workbook, args);
|
||||
const needle = text(args, ["what", "input"]);
|
||||
const replacement = text(args, ["replacement", "replace"]);
|
||||
const mode = text(args, ["mode"], "0");
|
||||
const replaceMode = mode === "1" || mode.includes("替换") || replacement !== "";
|
||||
const exact = truthy(value(args, ["wholeWord"])) || text(args, ["lookAt"]) === "1";
|
||||
const matchCase = truthy(value(args, ["matchCase"]));
|
||||
const range = text(args, ["range"]);
|
||||
const cells = cellsInRange(sheet, range || `A1:${columnLetter(Math.max(1, sheet.columnCount))}${Math.max(1, sheet.rowCount)}`);
|
||||
const matches: string[] = [];
|
||||
for (const cell of cells) {
|
||||
const original = String(cellValue(cell.value));
|
||||
const haystack = matchCase ? original : original.toLowerCase();
|
||||
const target = matchCase ? needle : needle.toLowerCase();
|
||||
const matched = exact ? haystack === target : haystack.includes(target);
|
||||
if (!matched) continue;
|
||||
matches.push(cell.address);
|
||||
if (replaceMode) {
|
||||
const escaped = needle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
cell.value = exact ? replacement : original.replace(new RegExp(escaped, matchCase ? "g" : "gi"), replacement);
|
||||
}
|
||||
if (truthy(value(args, ["matchFirst"]))) break;
|
||||
}
|
||||
await saveWhenRequested(source, args);
|
||||
return matches;
|
||||
};
|
||||
|
||||
const sortRange: CapabilityHandler = async (_ctx, node, args) => {
|
||||
const source = await loadWorkbook(args, node.id, node.componentId);
|
||||
const sheet = worksheetOf(source.workbook, args);
|
||||
const range = text(args, ["sortRange", "range"]) || `A1:${columnLetter(Math.max(1, sheet.columnCount))}${Math.max(1, sheet.rowCount)}`;
|
||||
const bounds = rangeBounds(sheet, range);
|
||||
const hasHeader = truthy(value(args, ["header"]));
|
||||
const dataStart = bounds.top + (hasHeader ? 1 : 0);
|
||||
const rows: unknown[][] = [];
|
||||
for (let row = dataStart; row <= bounds.bottom; row += 1) {
|
||||
const values: unknown[] = [];
|
||||
for (let column = bounds.left; column <= bounds.right; column += 1) values.push(cellValue(sheet.getCell(row, column).value));
|
||||
rows.push(values);
|
||||
}
|
||||
let sortOffset = 0;
|
||||
const rule = value(args, ["rule"]);
|
||||
if (rule) {
|
||||
try {
|
||||
const parsed = typeof rule === "string" ? JSON.parse(rule) as Record<string, unknown> : rule as Record<string, unknown>;
|
||||
sortOffset = Math.max(0, columnNumber(value(parsed, ["column", "key"], bounds.left)) - bounds.left);
|
||||
} catch { /* use first column */ }
|
||||
}
|
||||
const descending = ["2", "-1", "desc", "descending", "降序"].includes(text(args, ["sortOrder"]).toLowerCase());
|
||||
rows.sort((left, right) => comparison(left[sortOffset], right[sortOffset]) * (descending ? -1 : 1));
|
||||
rows.forEach((values, rowOffset) => values.forEach((item, columnOffset) => {
|
||||
sheet.getCell(dataStart + rowOffset, bounds.left + columnOffset).value = item as ExcelJS.CellValue;
|
||||
}));
|
||||
await saveWhenRequested(source, args);
|
||||
return rows;
|
||||
};
|
||||
|
||||
function criterionMatches(candidate: unknown, criterion: unknown, condition: string): boolean {
|
||||
const left = String(candidate ?? "");
|
||||
const right = String(criterion ?? "");
|
||||
if (["contains", "包含", "1"].includes(condition)) return left.includes(right);
|
||||
if (["notContains", "不包含", "2"].includes(condition)) return !left.includes(right);
|
||||
if (["greater", ">", "3"].includes(condition)) return Number(candidate) > Number(criterion);
|
||||
if (["less", "<", "4"].includes(condition)) return Number(candidate) < Number(criterion);
|
||||
if (["notEqual", "!=", "5"].includes(condition)) return left !== right;
|
||||
return left === right;
|
||||
}
|
||||
|
||||
const filterRows: CapabilityHandler = async (_ctx, node, args) => {
|
||||
const source = await loadWorkbook(args, node.id, node.componentId);
|
||||
const sheet = worksheetOf(source.workbook, args);
|
||||
let column = columnNumber(value(args, ["column"], "A"));
|
||||
let criterion = value(args, ["criteria"], "");
|
||||
let condition = text(args, ["condition"], "equal");
|
||||
const rule = value(args, ["rule"]);
|
||||
if (rule) {
|
||||
try {
|
||||
const parsed = typeof rule === "string" ? JSON.parse(rule) as Record<string, unknown> : rule as Record<string, unknown>;
|
||||
column = columnNumber(value(parsed, ["column"], column));
|
||||
criterion = value(parsed, ["criteria", "value"], criterion);
|
||||
condition = text(parsed, ["condition", "operator"], condition);
|
||||
} catch { /* use direct fields */ }
|
||||
}
|
||||
const startRow = numberValue(value(args, ["startRow"]), 1);
|
||||
const endRowRaw = numberValue(value(args, ["endRow"]), sheet.rowCount);
|
||||
const endRow = endRowRaw < 0 ? sheet.rowCount : endRowRaw;
|
||||
const headerRow = truthy(value(args, ["onHeader"])) ? startRow : 0;
|
||||
for (let row = startRow; row <= endRow; row += 1) {
|
||||
if (row === headerRow) continue;
|
||||
sheet.getRow(row).hidden = !criterionMatches(cellValue(sheet.getCell(row, column).value), criterion, condition);
|
||||
}
|
||||
sheet.autoFilter = { from: { row: startRow, column: 1 }, to: { row: endRow, column: Math.max(1, sheet.columnCount) } };
|
||||
await saveWhenRequested(source, args);
|
||||
return { column: columnLetter(column), criterion, condition };
|
||||
};
|
||||
|
||||
const clearFilter: CapabilityHandler = async (_ctx, node, args) => {
|
||||
const source = await loadWorkbook(args, node.id, node.componentId);
|
||||
const sheet = worksheetOf(source.workbook, args);
|
||||
for (let row = 1; row <= sheet.rowCount; row += 1) sheet.getRow(row).hidden = false;
|
||||
sheet.autoFilter = undefined;
|
||||
await saveWhenRequested(source, args);
|
||||
return true;
|
||||
};
|
||||
|
||||
const deleteDuplicates: CapabilityHandler = async (_ctx, node, args) => {
|
||||
const source = await loadWorkbook(args, node.id, node.componentId);
|
||||
const sheet = worksheetOf(source.workbook, args);
|
||||
const columns = listValue(value(args, ["columns"], "A")).map(columnNumber);
|
||||
const hasHeader = truthy(value(args, ["header"]));
|
||||
const seen = new Set<string>();
|
||||
const duplicates: number[] = [];
|
||||
for (let row = hasHeader ? 2 : 1; row <= sheet.rowCount; row += 1) {
|
||||
const key = JSON.stringify(columns.map((column) => cellValue(sheet.getCell(row, column).value)));
|
||||
if (seen.has(key)) duplicates.push(row);
|
||||
else seen.add(key);
|
||||
}
|
||||
duplicates.reverse().forEach((row) => sheet.spliceRows(row, 1));
|
||||
await saveWhenRequested(source, args);
|
||||
return duplicates.length;
|
||||
};
|
||||
|
||||
function copyCells(sheet: ExcelJS.Worksheet, range: string): CopiedRange {
|
||||
const bounds = rangeBounds(sheet, range);
|
||||
const values: unknown[][] = [];
|
||||
const styles: Partial<ExcelJS.Style>[][] = [];
|
||||
for (let row = bounds.top; row <= bounds.bottom; row += 1) {
|
||||
const valueRow: unknown[] = [];
|
||||
const styleRow: Partial<ExcelJS.Style>[] = [];
|
||||
for (let column = bounds.left; column <= bounds.right; column += 1) {
|
||||
const cell = sheet.getCell(row, column);
|
||||
valueRow.push(cell.value);
|
||||
styleRow.push(cloneStyle(cell.style));
|
||||
}
|
||||
values.push(valueRow);
|
||||
styles.push(styleRow);
|
||||
}
|
||||
return { values, styles };
|
||||
}
|
||||
|
||||
const copyRange: CapabilityHandler = async (_ctx, node, args) => {
|
||||
const source = await loadWorkbook(args, node.id, node.componentId);
|
||||
const sheet = worksheetOf(source.workbook, args);
|
||||
const range = explicitRange(args, sheet);
|
||||
copiedRange = copyCells(sheet, range);
|
||||
const destinationColumn = value(args, ["startColumn2"]);
|
||||
const destinationRow = value(args, ["startRow2"]);
|
||||
if (destinationColumn !== "" && destinationRow !== "") {
|
||||
const startColumn = columnNumber(destinationColumn);
|
||||
const startRow = numberValue(destinationRow, 1);
|
||||
copiedRange.values.forEach((items, rowOffset) => items.forEach((item, columnOffset) => {
|
||||
const cell = sheet.getCell(startRow + rowOffset, startColumn + columnOffset);
|
||||
cell.value = item as ExcelJS.CellValue;
|
||||
cell.style = cloneStyle(copiedRange!.styles[rowOffset][columnOffset]);
|
||||
}));
|
||||
await saveWhenRequested(source, args);
|
||||
}
|
||||
return copiedRange.values;
|
||||
};
|
||||
|
||||
const pasteRange: CapabilityHandler = async (_ctx, node, args) => {
|
||||
if (!copiedRange) throw new RuntimeError("validation_error", "No copied Excel range is available", { nodeId: node.id, componentId: node.componentId });
|
||||
const source = await loadWorkbook(args, node.id, node.componentId);
|
||||
const sheet = worksheetOf(source.workbook, args);
|
||||
const start = text(args, ["Start"]);
|
||||
const row = start ? Number(sheet.getCell(start).row) : numberValue(value(args, ["row"]), 1);
|
||||
const column = start ? Number(sheet.getCell(start).col) : columnNumber(value(args, ["column"]));
|
||||
const transpose = truthy(value(args, ["transpose"]));
|
||||
const values = transpose ? copiedRange.values[0].map((_, index) => copiedRange!.values.map((items) => items[index])) : copiedRange.values;
|
||||
values.forEach((items, rowOffset) => items.forEach((item, columnOffset) => {
|
||||
const cell = sheet.getCell(row + rowOffset, column + columnOffset);
|
||||
cell.value = item as ExcelJS.CellValue;
|
||||
if (!transpose) cell.style = cloneStyle(copiedRange!.styles[rowOffset][columnOffset]);
|
||||
}));
|
||||
await saveWhenRequested(source, args);
|
||||
return `${columnLetter(column)}${row}`;
|
||||
};
|
||||
|
||||
const createSheet: CapabilityHandler = async (_ctx, node, args) => {
|
||||
const source = await loadWorkbook(args, node.id, node.componentId);
|
||||
const name = text(args, ["sheet", "sheetName", "name"], `Sheet${source.workbook.worksheets.length + 1}`);
|
||||
if (source.workbook.getWorksheet(name)) throw new Error(`Sheet already exists: ${name}`);
|
||||
source.workbook.addWorksheet(name);
|
||||
await saveWhenRequested(source, args);
|
||||
return name;
|
||||
};
|
||||
|
||||
const deleteSheet: CapabilityHandler = async (_ctx, node, args) => {
|
||||
const source = await loadWorkbook(args, node.id, node.componentId);
|
||||
const sheet = worksheetOf(source.workbook, args);
|
||||
if (source.workbook.worksheets.length <= 1) throw new Error("Cannot delete the only worksheet");
|
||||
source.workbook.removeWorksheet(sheet.id);
|
||||
await saveWhenRequested(source, args);
|
||||
return sheet.name;
|
||||
};
|
||||
|
||||
const activateSheet: CapabilityHandler = async (_ctx, node, args) => {
|
||||
const source = await loadWorkbook(args, node.id, node.componentId);
|
||||
const mode = text(args, ["mode"]);
|
||||
const index = mode.includes("序号") ? numberValue(value(args, ["index"]), 1) : 0;
|
||||
const sheet = index ? source.workbook.worksheets[index - 1] : worksheetOf(source.workbook, args);
|
||||
if (!sheet) throw new Error(`Sheet not found: ${index}`);
|
||||
source.workbook.views = [{
|
||||
activeTab: source.workbook.worksheets.indexOf(sheet),
|
||||
firstSheet: 0,
|
||||
visibility: "visible",
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 12000,
|
||||
height: 8000
|
||||
}];
|
||||
await saveWhenRequested(source, args);
|
||||
return sheet.name;
|
||||
};
|
||||
|
||||
const getSheetNames: CapabilityHandler = async (_ctx, node, args) => {
|
||||
const source = await loadWorkbook(args, node.id, node.componentId);
|
||||
return source.workbook.worksheets.map((sheet) => sheet.name);
|
||||
};
|
||||
|
||||
function setFontColor(cell: ExcelJS.Cell, argb: string): void {
|
||||
cell.font = { ...(cell.font || {}), color: { argb } };
|
||||
}
|
||||
|
||||
function setBackground(cell: ExcelJS.Cell, argb: string): void {
|
||||
cell.fill = { type: "pattern", pattern: "solid", fgColor: { argb } };
|
||||
}
|
||||
|
||||
const setColor = (kind: "font" | "background", rangeMode: boolean): CapabilityHandler => async (_ctx, node, args) => {
|
||||
const source = await loadWorkbook(args, node.id, node.componentId);
|
||||
const sheet = worksheetOf(source.workbook, args);
|
||||
const range = rangeMode
|
||||
? explicitRange(args, sheet)
|
||||
: text(args, ["cellPos"]) || `${text(args, ["column"], "A")}${numberValue(value(args, ["row"]), 1)}`;
|
||||
const argb = colorArgb(value(args, ["color", "cellColor"]));
|
||||
for (const cell of cellsInRange(sheet, range)) {
|
||||
if (kind === "font") setFontColor(cell, argb);
|
||||
else setBackground(cell, argb);
|
||||
}
|
||||
await saveWhenRequested(source, args);
|
||||
return argb;
|
||||
};
|
||||
|
||||
const readBackground: CapabilityHandler = async (_ctx, node, args) => {
|
||||
const source = await loadWorkbook(args, node.id, node.componentId);
|
||||
const sheet = worksheetOf(source.workbook, args);
|
||||
const cell = sheet.getCell(numberValue(value(args, ["row"]), 1), columnNumber(value(args, ["column"])));
|
||||
const fill = cell.fill as ExcelJS.FillPattern | undefined;
|
||||
return fill?.type === "pattern" ? fill.fgColor?.argb ?? "" : "";
|
||||
};
|
||||
|
||||
const autoFill: CapabilityHandler = async (_ctx, node, args) => {
|
||||
const source = await loadWorkbook(args, node.id, node.componentId);
|
||||
const sheet = worksheetOf(source.workbook, args);
|
||||
const sourceRange = text(args, ["sourceRange", "excelRng"]);
|
||||
const destination = text(args, ["destination"]);
|
||||
const sourceBounds = rangeBounds(sheet, sourceRange);
|
||||
const destinationBounds = rangeBounds(sheet, destination);
|
||||
const sourceValues = copyCells(sheet, sourceRange).values;
|
||||
const vertical = sourceBounds.left === sourceBounds.right;
|
||||
for (let row = destinationBounds.top; row <= destinationBounds.bottom; row += 1) {
|
||||
for (let column = destinationBounds.left; column <= destinationBounds.right; column += 1) {
|
||||
const sourceRow = (row - destinationBounds.top) % sourceValues.length;
|
||||
const sourceColumn = (column - destinationBounds.left) % sourceValues[0].length;
|
||||
let next = sourceValues[sourceRow][sourceColumn];
|
||||
const sequence = vertical ? sourceValues.map((items) => items[0]) : sourceValues[0];
|
||||
const offset = vertical ? row - sourceBounds.top : column - sourceBounds.left;
|
||||
if (sequence.length >= 2 && sequence.every((item) => typeof item === "number")) {
|
||||
next = Number(sequence[0]) + (Number(sequence[1]) - Number(sequence[0])) * offset;
|
||||
}
|
||||
sheet.getCell(row, column).value = next as ExcelJS.CellValue;
|
||||
}
|
||||
}
|
||||
await saveWhenRequested(source, args);
|
||||
return destination;
|
||||
};
|
||||
|
||||
const splitColumn: CapabilityHandler = async (_ctx, node, args) => {
|
||||
const source = await loadWorkbook(args, node.id, node.componentId);
|
||||
const sheet = worksheetOf(source.workbook, args);
|
||||
const sourceColumn = columnNumber(value(args, ["column"]));
|
||||
const destination = text(args, ["destination"]);
|
||||
const destinationColumn = destination ? Number(sheet.getCell(destination).col) : sourceColumn;
|
||||
const startRow = numberValue(value(args, ["startRow"]), 1);
|
||||
const endRow = numberValue(value(args, ["endRow"]), sheet.rowCount);
|
||||
const delimiters: string[] = [];
|
||||
if (truthy(value(args, ["tab"]), true)) delimiters.push("\\t");
|
||||
if (truthy(value(args, ["semicolon"]))) delimiters.push(";");
|
||||
if (truthy(value(args, ["comma"]))) delimiters.push(",");
|
||||
if (truthy(value(args, ["space"]))) delimiters.push("\\s+");
|
||||
if (truthy(value(args, ["other"]))) delimiters.push(text(args, ["otherChar"]).replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
|
||||
const separator = new RegExp(delimiters.filter(Boolean).join("|") || "\\t");
|
||||
for (let row = startRow; row <= endRow; row += 1) {
|
||||
String(cellValue(sheet.getCell(row, sourceColumn).value)).split(separator).forEach((item, offset) => {
|
||||
sheet.getCell(row, destinationColumn + offset).value = item;
|
||||
});
|
||||
}
|
||||
await saveWhenRequested(source, args);
|
||||
return `${columnLetter(destinationColumn)}${startRow}`;
|
||||
};
|
||||
|
||||
const closeWorkbook: CapabilityHandler = async (_ctx, node, args) => {
|
||||
const source = await loadWorkbook(args, node.id, node.componentId);
|
||||
if (truthy(value(args, ["save", "isSave"]), true)) await source.workbook.xlsx.writeFile(source.filePath);
|
||||
workbookCache.delete(source.filePath);
|
||||
return source.filePath;
|
||||
};
|
||||
|
||||
const deleteRange: CapabilityHandler = async (_ctx, node, args) => {
|
||||
const source = await loadWorkbook(args, node.id, node.componentId);
|
||||
const sheet = worksheetOf(source.workbook, args);
|
||||
const range = explicitRange(args, sheet);
|
||||
for (const cell of cellsInRange(sheet, range)) cell.value = null;
|
||||
await saveWhenRequested(source, args);
|
||||
return range;
|
||||
};
|
||||
|
||||
export const EXCEL_COMMON_IDS = [
|
||||
"Excel_WriteColumn", "Excel_WriteColumnNew", "Excel_Cs_WriteColumn",
|
||||
"Excel_WriteRow", "Excel_WriteRowNew", "Excel_Cs_WriteRow", "Excel_Cs_WriteRowV2",
|
||||
"Excel_Cs_WriteRangeV2", "Excel_GetRowData", "Excel_GetRowDataNew", "Excel_Cs_ReadRow",
|
||||
"Excel_GetColData", "Excel_Cs_ReadColumnCount", "Excel_Cs_ReadRange",
|
||||
"Excel_Cs_Find", "Excel_Cs_FindV2", "Excel_Cs_Search",
|
||||
"Excel_Cs_Sort", "Excel_Cs_SortV1", "Excel_Cs_Filter", "Excel_Cs_ExcelFilter",
|
||||
"Excel_Cs_ClearExcelFilter", "Excel_Cs_EnableExcelFilter", "Excel_Cs_DeleteDuplicates",
|
||||
"Excel_CopyRange", "Excel_Cs_CopyRange", "Excel_Cs_CopyRangeV2", "Excel_Cs_CopyRangeV3",
|
||||
"Excel_PasteRange", "Excel_Cs_PastRange", "Excel_Cs_PastRangeV2",
|
||||
"Excel_CreateSheet", "Excel_DeleteSheet", "Excel_Cs_Delete", "Excel_ActiveSheet", "Excel_Cs_Active", "Excel_Cs_GetName",
|
||||
"Excel_SetCellFontColor", "Excel_Cs_SetCellFont", "Excel_SetRangeFontColor", "Excel_Cs_SetRangeFont", "Excel_Cs_SetRangeFontV2",
|
||||
"Excel_SetCellColor", "Excel_Cs_SetCellBackground", "Excel_SetRangeColor", "Excel_Cs_SetRangeBackground", "Excel_Cs_SetRangeBackgroundV2",
|
||||
"Excel_Cs_ReadCellBackground", "Excel_Cs_AutoFill", "Excel_Cs_AutoFillV2",
|
||||
"Excel_Cs_ColumnSplit", "Excel_Cs_ColumnSplitV2", "Excel_Close", "Excel_Cs_Close",
|
||||
"Excel_DeleteRange", "Excel_Cs_DeleteRange", "Excel_Cs_DeleteRangeV2", "Excel_Cs_DeleteRangeV3"
|
||||
] as const;
|
||||
|
||||
registerMany(["Excel_WriteRow", "Excel_WriteRowNew", "Excel_Cs_WriteRow", "Excel_Cs_WriteRowV2"], writeRow);
|
||||
registerMany(["Excel_WriteColumn", "Excel_WriteColumnNew", "Excel_Cs_WriteColumn"], writeColumn);
|
||||
register("Excel_Cs_WriteRangeV2", writeRange);
|
||||
registerMany(["Excel_GetRowData", "Excel_GetRowDataNew", "Excel_Cs_ReadRow"], readRow);
|
||||
register("Excel_GetColData", readColumn);
|
||||
register("Excel_Cs_ReadColumnCount", readColumnCount);
|
||||
register("Excel_Cs_ReadRange", readRange);
|
||||
registerMany(["Excel_Cs_Find", "Excel_Cs_FindV2", "Excel_Cs_Search"], findOrReplace);
|
||||
registerMany(["Excel_Cs_Sort", "Excel_Cs_SortV1"], sortRange);
|
||||
registerMany(["Excel_Cs_Filter", "Excel_Cs_ExcelFilter"], filterRows);
|
||||
registerMany(["Excel_Cs_ClearExcelFilter", "Excel_Cs_EnableExcelFilter"], clearFilter);
|
||||
register("Excel_Cs_DeleteDuplicates", deleteDuplicates);
|
||||
registerMany(["Excel_CopyRange", "Excel_Cs_CopyRange", "Excel_Cs_CopyRangeV2", "Excel_Cs_CopyRangeV3"], copyRange);
|
||||
registerMany(["Excel_PasteRange", "Excel_Cs_PastRange", "Excel_Cs_PastRangeV2"], pasteRange);
|
||||
register("Excel_CreateSheet", createSheet);
|
||||
registerMany(["Excel_DeleteSheet", "Excel_Cs_Delete"], deleteSheet);
|
||||
registerMany(["Excel_ActiveSheet", "Excel_Cs_Active"], activateSheet);
|
||||
register("Excel_Cs_GetName", getSheetNames);
|
||||
registerMany(["Excel_SetCellFontColor", "Excel_Cs_SetCellFont"], setColor("font", false));
|
||||
registerMany(["Excel_SetRangeFontColor", "Excel_Cs_SetRangeFont", "Excel_Cs_SetRangeFontV2"], setColor("font", true));
|
||||
registerMany(["Excel_SetCellColor", "Excel_Cs_SetCellBackground"], setColor("background", false));
|
||||
registerMany(["Excel_SetRangeColor", "Excel_Cs_SetRangeBackground", "Excel_Cs_SetRangeBackgroundV2"], setColor("background", true));
|
||||
register("Excel_Cs_ReadCellBackground", readBackground);
|
||||
registerMany(["Excel_Cs_AutoFill", "Excel_Cs_AutoFillV2"], autoFill);
|
||||
registerMany(["Excel_Cs_ColumnSplit", "Excel_Cs_ColumnSplitV2"], splitColumn);
|
||||
registerMany(["Excel_Close", "Excel_Cs_Close"], closeWorkbook);
|
||||
registerMany(["Excel_DeleteRange", "Excel_Cs_DeleteRange", "Excel_Cs_DeleteRangeV2", "Excel_Cs_DeleteRangeV3"], deleteRange);
|
||||
@@ -6,6 +6,7 @@ import "./browser";
|
||||
import "./browser-extended";
|
||||
import "./browser-dom";
|
||||
import "./browser-common";
|
||||
import "./browser-control";
|
||||
import "./office";
|
||||
import "./desktop";
|
||||
import "./pdf";
|
||||
@@ -17,9 +18,11 @@ import "./extended";
|
||||
import "./system-extended";
|
||||
import "./office-file-extended";
|
||||
import "./office-aliases";
|
||||
import "./excel-common";
|
||||
import "./window-extended";
|
||||
import "./data-extended";
|
||||
import "./utility-extended";
|
||||
import "./data-table-common";
|
||||
import "./common-final";
|
||||
|
||||
export { capabilityCount, capabilityIds, getHandler, hasCapability, register } from "../engine/registry";
|
||||
@@ -11,7 +11,7 @@ vi.mock("electron", () => ({
|
||||
}
|
||||
}));
|
||||
|
||||
import { createDemoFlow, createFlow, listFlows, saveFlow } from "./flows";
|
||||
import { createDemoFlow, createExampleFlows, createFlow, listFlows, saveFlow } from "./flows";
|
||||
|
||||
describe("flow summaries", () => {
|
||||
beforeEach(async () => {
|
||||
@@ -55,4 +55,12 @@ describe("flow summaries", () => {
|
||||
expect(flow.edges).toHaveLength(5);
|
||||
expect(flow.nodes.find((node) => node.id === "node_result")?.options.outputVar).toBe("订单结果");
|
||||
});
|
||||
|
||||
it("creates six stable example flows without duplicate ids", () => {
|
||||
const examples = createExampleFlows("http://127.0.0.1:17863/");
|
||||
expect(examples).toHaveLength(6);
|
||||
expect(new Set(examples.map((flow) => flow.id)).size).toBe(6);
|
||||
expect(examples.every((flow) => flow.nodes.length > 2 && flow.edges.length > 1)).toBe(true);
|
||||
expect(examples.find((flow) => flow.id === "example_error_route")?.edges.some((edge) => edge.sourceHandle === "error")).toBe(true);
|
||||
});
|
||||
});
|
||||
+78
-1
@@ -1,7 +1,7 @@
|
||||
import { app } from "electron";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import type { FlowDefinition, FlowSummary } from "../shared/types";
|
||||
import type { FlowDefinition, FlowEdge, FlowNode, FlowSummary } from "../shared/types";
|
||||
|
||||
const ID_PATTERN = /^[A-Za-z0-9_-]{3,80}$/;
|
||||
|
||||
@@ -92,6 +92,83 @@ export function createDemoFlow(url: string): FlowDefinition {
|
||||
return flow;
|
||||
}
|
||||
|
||||
function exampleFlow(id: string, name: string, description: string): FlowDefinition {
|
||||
const flow = createFlow(name);
|
||||
flow.id = id;
|
||||
flow.description = description;
|
||||
flow.nodes = [];
|
||||
flow.edges = [];
|
||||
return flow;
|
||||
}
|
||||
|
||||
function exampleNode(id: string, componentId: string, y: number, values: Record<string, unknown> = {}, outputVar?: string): FlowNode {
|
||||
return { id, componentId, position: { x: 220, y }, values, options: { timeoutMs: 30000, retryCount: 0, retryIntervalMs: 1000, skipOnError: false, logException: true, outputVar }, enabled: true };
|
||||
}
|
||||
|
||||
function chain(nodes: FlowNode[]): FlowEdge[] {
|
||||
return nodes.slice(0, -1).map((node, index) => ({ id: `edge_${node.id}_${nodes[index + 1].id}`, source: node.id, target: nodes[index + 1].id }));
|
||||
}
|
||||
|
||||
export function createExampleFlows(url: string): FlowDefinition[] {
|
||||
const browser = createDemoFlow(url);
|
||||
browser.id = "example_browser_order";
|
||||
browser.name = "示例 1 · 本地网页订单";
|
||||
|
||||
const file = exampleFlow("example_file_text", "示例 2 · 文件与文本处理", "在临时目录写入文本、读取、转换为大写并写入结果文件。");
|
||||
file.nodes = [
|
||||
exampleNode("file_start", "Trigger.Manual", 80, { entryName: "立即执行" }),
|
||||
exampleNode("file_temp", "System_GetTempPath", 210, {}, "tempPath"),
|
||||
exampleNode("file_write", "File_Write", 340, { path: "${tempPath}\\RPA-Studio-Demo\\input.txt", content: "hello rpa studio", encode: "utf-8", append: "False" }),
|
||||
exampleNode("file_read", "File_Read", 470, { path: "${tempPath}\\RPA-Studio-Demo\\input.txt", encode: "utf-8" }, "sourceText"),
|
||||
exampleNode("file_upper", "StrUtil_UpperCase", 600, { string: "${sourceText}" }, "upperText"),
|
||||
exampleNode("file_result", "File_Write", 730, { path: "${tempPath}\\RPA-Studio-Demo\\result.txt", content: "${upperText}", encode: "utf-8", append: "False" }),
|
||||
exampleNode("file_log", "Basic_DebugOutput", 860, { msg: "处理完成:${upperText}" })
|
||||
]; file.edges = chain(file.nodes);
|
||||
|
||||
const excel = exampleFlow("example_excel_orders", "示例 3 · Excel 订单读写", "在临时目录创建 Excel,写入订单数据并读取验证。");
|
||||
excel.nodes = [
|
||||
exampleNode("excel_start", "Trigger.Manual", 80, { entryName: "立即执行" }),
|
||||
exampleNode("excel_temp", "System_GetTempPath", 210, {}, "tempPath"),
|
||||
exampleNode("excel_new", "Excel_Cs_NewV5", 340, { path: "${tempPath}\\RPA-Studio-Demo", name: "orders.xlsx", override: "1", show: "0" }, "workbook"),
|
||||
exampleNode("excel_write", "Excel_Cs_WriteRange", 470, { workbook: "${workbook}", sheet: "Sheet1", row: "1", column: "A", contents: '[["客户","金额","状态"],["张三",128,"已创建"],["李四",256,"已创建"]]', save: "1" }),
|
||||
exampleNode("excel_read", "Excel_Cs_ReadRangeV2", 600, { workbook: "${workbook}", sheet: "Sheet1", range: "A1:C3", decimal: "0" }, "excelData"),
|
||||
exampleNode("excel_log", "Basic_DebugOutput", 730, { msg: "Excel内容:${excelData}" })
|
||||
]; excel.edges = chain(excel.nodes);
|
||||
|
||||
const clipboard = exampleFlow("example_clipboard", "示例 4 · 剪贴板往返", "写入并读取 Windows 剪贴板文本。");
|
||||
clipboard.nodes = [
|
||||
exampleNode("clip_start", "Trigger.Manual", 80, { entryName: "立即执行" }),
|
||||
exampleNode("clip_set", "Clipboard_SetText", 230, { text: "RPA Studio 剪贴板测试" }),
|
||||
exampleNode("clip_get", "Clipboard_GetText", 380, {}, "clipboardText"),
|
||||
exampleNode("clip_log", "Basic_DebugOutput", 530, { msg: "剪贴板内容:${clipboardText}" })
|
||||
]; clipboard.edges = chain(clipboard.nodes);
|
||||
|
||||
const word = exampleFlow("example_word_document", "示例 5 · Word 文档生成", "在临时目录创建 Word 文档、写入文本并读取验证,需要本机安装 Word。");
|
||||
word.nodes = [
|
||||
exampleNode("word_start", "Trigger.Manual", 80, { entryName: "立即执行" }),
|
||||
exampleNode("word_temp", "System_GetTempPath", 200, {}, "tempPath"),
|
||||
exampleNode("word_folder", "File_MakeDir", 320, { folder_path: "${tempPath}\\RPA-Studio-Demo" }),
|
||||
exampleNode("word_new", "Word_New_V1", 440, { filePath: "${tempPath}\\RPA-Studio-Demo", name: "demo.docx", visible: "False" }, "wordDoc"),
|
||||
exampleNode("word_write", "Word_Write", 560, { objWord: "${wordDoc}", text: "RPA Studio Word 自动化测试" }),
|
||||
exampleNode("word_read", "Word_ReadAll", 680, { objWord: "${wordDoc}" }, "wordText"),
|
||||
exampleNode("word_log", "Basic_DebugOutput", 800, { msg: "Word内容:${wordText}" })
|
||||
]; word.edges = chain(word.nodes);
|
||||
|
||||
const error = exampleFlow("example_error_route", "示例 6 · 异常分支", "故意读取不存在文件,验证右侧异常出口和错误变量。");
|
||||
error.nodes = [
|
||||
exampleNode("error_start", "Trigger.Manual", 80, { entryName: "立即执行" }),
|
||||
{ ...exampleNode("error_fail", "File_Read", 230, { path: "Z:\\RPA-DEMO-NOT-FOUND.txt", encode: "utf-8" }), options: { timeoutMs: 5000, retryCount: 0, retryIntervalMs: 1000, skipOnError: false, logException: true, errorRouteEnabled: true } },
|
||||
exampleNode("error_normal", "Basic_DebugOutput", 390, { msg: "不应执行到正常出口" }),
|
||||
{ ...exampleNode("error_recover", "Basic_DebugOutput", 230, { msg: "异常已捕获:${error.reason}" }), position: { x: 560, y: 230 } }
|
||||
]; error.edges = [
|
||||
{ id: "edge_error_start", source: "error_start", target: "error_fail" },
|
||||
{ id: "edge_error_normal", source: "error_fail", target: "error_normal" },
|
||||
{ id: "edge_error_recover", source: "error_fail", sourceHandle: "error", target: "error_recover" }
|
||||
];
|
||||
|
||||
return [browser, file, excel, clipboard, word, error];
|
||||
}
|
||||
|
||||
export function listFlows(): FlowSummary[] {
|
||||
const base = dir();
|
||||
const rows: FlowSummary[] = [];
|
||||
|
||||
+48
-2
@@ -1,17 +1,24 @@
|
||||
import { app, BrowserWindow, dialog, ipcMain, shell } from "electron";
|
||||
import path from "node:path";
|
||||
import type { AppInfo, FlowDefinition } from "../shared/types";
|
||||
import { BrowserSession, hasBrowserSession } from "./browser/session";
|
||||
import "./capabilities";
|
||||
import { catalogPath, loadCatalog } from "./catalog";
|
||||
import { createDemoFlow, createFlow, deleteFlow, getFlow, listFlows, saveFlow } from "./flows";
|
||||
import { createDemoFlow, createExampleFlows, createFlow, deleteFlow, getFlow, listFlows, saveFlow } from "./flows";
|
||||
import { capabilityCount, capabilityIds } from "./engine/registry";
|
||||
import { closeBrowserSession, startRun, stopRun } from "./run/manager";
|
||||
import { fixtureUrl, startFixtureServer, stopFixtureServer } from "./fixture/server";
|
||||
import { logMain } from "./logger";
|
||||
|
||||
let mainWindow: BrowserWindow | null = null;
|
||||
|
||||
if (process.env.RPA_ENABLE_GPU !== "1") app.disableHardwareAcceleration();
|
||||
process.on("uncaughtException", (error) => logMain("uncaughtException", error.stack || error.message));
|
||||
process.on("unhandledRejection", (reason) => logMain("unhandledRejection", reason instanceof Error ? reason.stack || reason.message : String(reason)));
|
||||
|
||||
function createWindow(): void {
|
||||
const e2e = process.env.RPA_E2E === "1";
|
||||
logMain("createWindow", { packaged: app.isPackaged, appPath: app.getAppPath(), userData: app.getPath("userData") });
|
||||
mainWindow = new BrowserWindow({
|
||||
width: 1440,
|
||||
height: 900,
|
||||
@@ -34,7 +41,18 @@ function createWindow(): void {
|
||||
}
|
||||
});
|
||||
|
||||
mainWindow.on("ready-to-show", () => { if (!e2e) mainWindow?.show(); });
|
||||
let shown = false;
|
||||
const showWindow = (source: string) => {
|
||||
if (e2e || shown || !mainWindow || mainWindow.isDestroyed()) return;
|
||||
shown = true;
|
||||
logMain("showWindow", source);
|
||||
mainWindow.show();
|
||||
mainWindow.focus();
|
||||
};
|
||||
const showFallback = setTimeout(() => showWindow("fallback-timeout"), 5000);
|
||||
mainWindow.on("ready-to-show", () => { clearTimeout(showFallback); showWindow("ready-to-show"); });
|
||||
mainWindow.on("unresponsive", () => logMain("window-unresponsive"));
|
||||
mainWindow.on("responsive", () => logMain("window-responsive"));
|
||||
mainWindow.on("closed", () => {
|
||||
mainWindow = null;
|
||||
});
|
||||
@@ -42,6 +60,14 @@ function createWindow(): void {
|
||||
if (/^https?:/i.test(url)) void shell.openExternal(url);
|
||||
return { action: "deny" };
|
||||
});
|
||||
mainWindow.webContents.on("did-fail-load", (_event, code, description, url) => {
|
||||
logMain("did-fail-load", { code, description, url });
|
||||
showWindow("did-fail-load");
|
||||
});
|
||||
mainWindow.webContents.on("render-process-gone", (_event, details) => logMain("render-process-gone", details));
|
||||
mainWindow.webContents.on("console-message", (_event, level, message, line, sourceId) => {
|
||||
if (level >= 2) logMain("renderer-console", { level, message, line, sourceId });
|
||||
});
|
||||
|
||||
const devUrl = process.env.ELECTRON_RENDERER_URL;
|
||||
if (devUrl) {
|
||||
@@ -67,6 +93,18 @@ function registerIpc(): void {
|
||||
ipcMain.handle("run:start", (event, flow: FlowDefinition) => startRun(flow, event.sender));
|
||||
ipcMain.handle("run:stop", (_event, runId?: string) => stopRun(runId));
|
||||
ipcMain.handle("run:capabilities", () => ({ count: capabilityCount(), ids: capabilityIds() }));
|
||||
ipcMain.handle("browser:pick-element", async (_event, options: { url?: string } = {}) => {
|
||||
const url = options.url?.trim();
|
||||
if (!hasBrowserSession() && !url) {
|
||||
throw new Error("请先配置或运行「打开网页」,再拾取元素");
|
||||
}
|
||||
const session = await BrowserSession.create();
|
||||
const currentUrl = await session.currentUrl();
|
||||
if (url && (currentUrl === "about:blank" || currentUrl !== url)) {
|
||||
await session.navigate(url);
|
||||
}
|
||||
return session.pickElement();
|
||||
});
|
||||
ipcMain.handle("flows:list", () => listFlows());
|
||||
ipcMain.handle("flows:create", (_event, name?: string) => {
|
||||
const flow = createFlow(name);
|
||||
@@ -76,6 +114,12 @@ function registerIpc(): void {
|
||||
const url = fixtureUrl();
|
||||
return saveFlow(url ? createDemoFlow(url) : createFlow("未命名任务"));
|
||||
});
|
||||
ipcMain.handle("flows:create-examples", () => {
|
||||
const url = fixtureUrl();
|
||||
if (!url) throw new Error("本地演示站未启动");
|
||||
for (const flow of createExampleFlows(url)) saveFlow(flow);
|
||||
return listFlows();
|
||||
});
|
||||
ipcMain.handle("flows:get", (_event, id: string) => getFlow(id));
|
||||
ipcMain.handle("flows:save", (_event, flow: FlowDefinition) => saveFlow(flow));
|
||||
ipcMain.handle("flows:delete", (_event, id: string) => {
|
||||
@@ -115,10 +159,12 @@ if (!gotLock) {
|
||||
});
|
||||
|
||||
app.whenReady().then(async () => {
|
||||
logMain("app-ready", { electron: process.versions.electron, chrome: process.versions.chrome, node: process.versions.node });
|
||||
try {
|
||||
await startFixtureServer(Number(process.env.RPA_FIXTURE_PORT || 17863));
|
||||
} catch (error) {
|
||||
console.warn("local fixture server unavailable", error);
|
||||
logMain("fixture-server-failed", error instanceof Error ? error.stack || error.message : String(error));
|
||||
}
|
||||
registerIpc();
|
||||
createWindow();
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { app } from "electron";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
export function logMain(event: string, detail: unknown = ""): void {
|
||||
try {
|
||||
const directory = path.join(app.getPath("userData"), "logs");
|
||||
fs.mkdirSync(directory, { recursive: true });
|
||||
const content = typeof detail === "string" ? detail : JSON.stringify(detail);
|
||||
fs.appendFileSync(path.join(directory, "startup.log"), `${new Date().toISOString()} [${event}] ${content}\r\n`, "utf8");
|
||||
} catch {
|
||||
// Logging must never prevent the app from starting.
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ const api = {
|
||||
list: (): Promise<FlowSummary[]> => ipcRenderer.invoke("flows:list"),
|
||||
create: (name?: string): Promise<FlowDefinition> => ipcRenderer.invoke("flows:create", name),
|
||||
createDemo: (): Promise<FlowDefinition> => ipcRenderer.invoke("flows:create-demo"),
|
||||
createExamples: (): Promise<FlowSummary[]> => ipcRenderer.invoke("flows:create-examples"),
|
||||
get: (id: string): Promise<FlowDefinition> => ipcRenderer.invoke("flows:get", id),
|
||||
save: (flow: FlowDefinition): Promise<FlowDefinition> => ipcRenderer.invoke("flows:save", flow),
|
||||
delete: (id: string): Promise<boolean> => ipcRenderer.invoke("flows:delete", id)
|
||||
@@ -22,6 +23,10 @@ const api = {
|
||||
save: (options?: { title?: string; defaultPath?: string; filters?: Array<{ name: string; extensions: string[] }> }): Promise<string | null> =>
|
||||
ipcRenderer.invoke("dialog:save", options || {})
|
||||
},
|
||||
browser: {
|
||||
pickElement: (options?: { url?: string }): Promise<import("../shared/types").ElementPickResult> =>
|
||||
ipcRenderer.invoke("browser:pick-element", options || {})
|
||||
},
|
||||
run: {
|
||||
start: (flow: FlowDefinition): Promise<{ runId: string }> => ipcRenderer.invoke("run:start", flow),
|
||||
stop: (runId?: string): Promise<boolean> => ipcRenderer.invoke("run:stop", runId),
|
||||
|
||||
@@ -149,6 +149,15 @@ export function App() {
|
||||
}
|
||||
};
|
||||
|
||||
const createExamples = async () => {
|
||||
try {
|
||||
setFlows(await window.rpa.flows.createExamples());
|
||||
showToast("已安装 6 条示例任务");
|
||||
} catch (exampleError) {
|
||||
showToast(exampleError instanceof Error ? exampleError.message : String(exampleError), "error");
|
||||
}
|
||||
};
|
||||
|
||||
const deleteTask = async (id: string) => {
|
||||
if (flows.length <= 1) {
|
||||
showToast("至少保留一个任务", "error");
|
||||
@@ -245,6 +254,7 @@ export function App() {
|
||||
<TasksPage
|
||||
flows={flows}
|
||||
onCreate={() => void createTask()}
|
||||
onCreateExamples={() => void createExamples()}
|
||||
onOpenEditor={(id) => void openEditor(id)}
|
||||
onRun={(id) => void runTask(id)}
|
||||
onDelete={(id) => void deleteTask(id)}
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import { useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import { ArrowDown, Check, Copy, Download } from "lucide-react";
|
||||
import type { RunLog } from "../store/runStore";
|
||||
|
||||
type LogFilter = "all" | "info" | "warn" | "error";
|
||||
|
||||
const LEVEL_LABEL: Record<LogFilter, string> = {
|
||||
all: "全部",
|
||||
info: "信息",
|
||||
warn: "警告",
|
||||
error: "错误"
|
||||
};
|
||||
|
||||
interface RunLogViewProps {
|
||||
logs: RunLog[];
|
||||
nameOf: (nodeId?: string) => string;
|
||||
fileName?: string;
|
||||
emptyText?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function RunLogView({ logs, nameOf, fileName = "rpa-run-logs.log", emptyText = "运行日志会显示在这里…", className }: RunLogViewProps) {
|
||||
const [filter, setFilter] = useState<LogFilter>("all");
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [pinned, setPinned] = useState(true);
|
||||
const consoleRef = useRef<HTMLDivElement>(null);
|
||||
const pinnedRef = useRef(true);
|
||||
|
||||
const counts = useMemo(() => ({
|
||||
all: logs.length,
|
||||
info: logs.filter((log) => log.level === "info").length,
|
||||
warn: logs.filter((log) => log.level === "warn").length,
|
||||
error: logs.filter((log) => log.level === "error").length
|
||||
}), [logs]);
|
||||
|
||||
const visibleLogs = useMemo(() => (
|
||||
filter === "all" ? logs : logs.filter((log) => log.level === filter)
|
||||
), [filter, logs]);
|
||||
|
||||
const logText = useMemo(() => (
|
||||
logs.map((log) => `[${log.time}] [${log.level.toUpperCase()}] ${nameOf(log.nodeId)} - ${log.message}`).join("\n")
|
||||
), [logs, nameOf]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const element = consoleRef.current;
|
||||
if (element && pinnedRef.current) element.scrollTop = element.scrollHeight;
|
||||
}, [visibleLogs]);
|
||||
|
||||
const trackPin = () => {
|
||||
const element = consoleRef.current;
|
||||
if (!element) return;
|
||||
const nextPinned = element.scrollHeight - element.scrollTop - element.clientHeight < 48;
|
||||
if (nextPinned !== pinnedRef.current) {
|
||||
pinnedRef.current = nextPinned;
|
||||
setPinned(nextPinned);
|
||||
}
|
||||
};
|
||||
|
||||
const scrollToLatest = () => {
|
||||
pinnedRef.current = true;
|
||||
const element = consoleRef.current;
|
||||
if (element) element.scrollTo({ top: element.scrollHeight, behavior: "smooth" });
|
||||
};
|
||||
|
||||
const copyLogs = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(logText);
|
||||
setCopied(true);
|
||||
window.setTimeout(() => setCopied(false), 1600);
|
||||
} catch {
|
||||
// Clipboard access can be blocked by system policy; download remains available.
|
||||
}
|
||||
};
|
||||
|
||||
const downloadLogs = () => {
|
||||
const blob = new Blob([logText], { type: "text/plain;charset=utf-8" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = fileName;
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`run-log-view ${className || ""}`}>
|
||||
<div className="run-log-toolbar">
|
||||
<div className="run-log-filters" role="group" aria-label="日志级别过滤">
|
||||
{(Object.keys(LEVEL_LABEL) as LogFilter[]).map((level) => (
|
||||
<button
|
||||
type="button"
|
||||
key={level}
|
||||
className={`run-log-filter ${level}${filter === level ? " active" : ""}`}
|
||||
onClick={() => setFilter(level)}
|
||||
aria-pressed={filter === level}
|
||||
>
|
||||
{LEVEL_LABEL[level]}
|
||||
<small>{counts[level]}</small>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<span className="spacer" />
|
||||
<button type="button" className="run-log-action" onClick={() => void copyLogs()} disabled={!logs.length}>
|
||||
{copied ? <Check className="icon" /> : <Copy className="icon" />}
|
||||
{copied ? "已复制" : "复制"}
|
||||
</button>
|
||||
<button type="button" className="run-log-action" onClick={downloadLogs} disabled={!logs.length}>
|
||||
<Download className="icon" />
|
||||
导出
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="run-log-console-wrap">
|
||||
<div className="run-log-console" ref={consoleRef} onScroll={trackPin} role="log" aria-live="polite">
|
||||
{!visibleLogs.length && <div className="run-log-empty">{logs.length ? "当前级别暂无日志。" : emptyText}</div>}
|
||||
{visibleLogs.map((log) => (
|
||||
<div className={`run-log-row ${log.level}`} key={log.id}>
|
||||
<span className="run-log-time">{log.time}</span>
|
||||
<span className="run-log-level">{LEVEL_LABEL[log.level]}</span>
|
||||
<span className="run-log-node" title={log.nodeId ? nameOf(log.nodeId) : "流程"}>{log.nodeId ? nameOf(log.nodeId) : "流程"}</span>
|
||||
<span className="run-log-message">{log.message || "—"}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{!pinned && visibleLogs.length > 0 && (
|
||||
<button type="button" className="run-log-latest" onClick={scrollToLatest}>
|
||||
<ArrowDown className="icon" />
|
||||
回到最新
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useMemo } from "react";
|
||||
import { CircleAlert, CircleCheck, History, Loader2, Play, RotateCcw, Square, Terminal, Timer } from "lucide-react";
|
||||
import { CircleAlert, CircleCheck, History, Loader2, Play, RotateCcw, Square, Terminal } from "lucide-react";
|
||||
import { useEditorStore } from "../store/editorStore";
|
||||
import { useRunStore } from "../store/runStore";
|
||||
import { RunLogView } from "../components/RunLogView";
|
||||
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
running: "运行中", succeeded: "成功", failed: "失败", skipped: "已跳过", cancelled: "已取消", idle: "待运行"
|
||||
@@ -26,7 +27,7 @@ export function RunsPage({ onBack }: { onBack: () => void }) {
|
||||
const component = catalog?.components.find((item) => item.id === node.componentId);
|
||||
map.set(node.id, node.label || component?.name || node.componentId);
|
||||
}
|
||||
return (id: string) => map.get(id) || id;
|
||||
return (id?: string) => (id ? map.get(id) || id : "流程");
|
||||
}, [catalog, flow]);
|
||||
|
||||
const entries = Object.entries(nodes);
|
||||
@@ -52,6 +53,7 @@ export function RunsPage({ onBack }: { onBack: () => void }) {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="run-main">
|
||||
<section className="panel run-detail">
|
||||
<div className="panel-head"><Play className="icon" /><strong>节点进度</strong><span className="spacer" /><span className={`status-pill ${statusClass}`}>{STATUS_LABEL[effectiveStatus]}</span></div>
|
||||
<div className="run-detail-body">
|
||||
@@ -76,11 +78,9 @@ export function RunsPage({ onBack }: { onBack: () => void }) {
|
||||
|
||||
<section className="panel run-log-panel">
|
||||
<div className="panel-head"><Terminal className="icon" /><strong>运行日志</strong><span className="spacer" /><span className="count-pill">{logs.length}</span></div>
|
||||
<div className="run-page-logs">
|
||||
{!logs.length && <div className="empty"><Timer className="icon" /><strong>暂无日志</strong><span>节点执行、重试和异常信息会实时显示。</span></div>}
|
||||
{logs.map((log) => <div className={`run-page-log ${log.level}`} key={log.id}><span>{log.time}</span><strong>{log.nodeId ? nameOf(log.nodeId) : "流程"}</strong><p>{log.message}</p></div>)}
|
||||
</div>
|
||||
<RunLogView logs={logs} nameOf={nameOf} fileName={`${runId || "run"}.log`} emptyText="节点执行、重试和异常信息会实时显示。" />
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
ListChecks,
|
||||
Pencil,
|
||||
Plus,
|
||||
PackagePlus,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Trash2,
|
||||
@@ -18,6 +19,7 @@ import type { FlowSummary } from "@shared/types";
|
||||
interface TasksPageProps {
|
||||
flows: FlowSummary[];
|
||||
onCreate: () => void;
|
||||
onCreateExamples: () => void;
|
||||
onOpenEditor: (id: string) => void;
|
||||
onRun: (id: string) => void;
|
||||
onDelete: (id: string) => void;
|
||||
@@ -43,7 +45,7 @@ function formatDuration(value?: number): string {
|
||||
|
||||
const RUN_LABEL = { succeeded: "成功", failed: "失败", cancelled: "已取消", never: "未运行" } as const;
|
||||
|
||||
export function TasksPage({ flows, onCreate, onOpenEditor, onRun, onDelete, onRefresh }: TasksPageProps) {
|
||||
export function TasksPage({ flows, onCreate, onCreateExamples, onOpenEditor, onRun, onDelete, onRefresh }: TasksPageProps) {
|
||||
const [query, setQuery] = useState("");
|
||||
const [selectedId, setSelectedId] = useState<string | null>(flows[0]?.id || null);
|
||||
|
||||
@@ -77,6 +79,9 @@ export function TasksPage({ flows, onCreate, onOpenEditor, onRun, onDelete, onRe
|
||||
<button type="button" className="icon-btn" title="刷新任务列表" onClick={onRefresh}>
|
||||
<RefreshCw className="icon" />
|
||||
</button>
|
||||
<button type="button" className="btn" onClick={onCreateExamples}>
|
||||
<PackagePlus className="icon" /> 安装示例任务
|
||||
</button>
|
||||
<button type="button" className="btn btn-primary" onClick={onCreate}>
|
||||
<Plus className="icon" /> 新建任务
|
||||
</button>
|
||||
|
||||
@@ -401,6 +401,7 @@ function FieldControl({
|
||||
onToast: (message: string, kind?: "ok" | "error") => void;
|
||||
}) {
|
||||
const raw = node.values[field.key] ?? field.defaultValue;
|
||||
const [picking, setPicking] = useState(false);
|
||||
|
||||
const pickPath = async (directories: boolean) => {
|
||||
try {
|
||||
@@ -420,6 +421,27 @@ function FieldControl({
|
||||
}
|
||||
};
|
||||
|
||||
const pickElement = async () => {
|
||||
setPicking(true);
|
||||
try {
|
||||
const currentFlow = useEditorStore.getState().flow;
|
||||
const nodeIndex = currentFlow?.nodes.findIndex((item) => item.id === node.id) ?? -1;
|
||||
const openNode = currentFlow && nodeIndex > 0
|
||||
? [...currentFlow.nodes.slice(0, nodeIndex)].reverse().find((item) => {
|
||||
const url = textOf(item.values.url).trim();
|
||||
return Boolean(url && !url.startsWith("${"));
|
||||
})
|
||||
: undefined;
|
||||
const picked = await window.rpa.browser.pickElement({ url: textOf(openNode?.values.url) || undefined });
|
||||
onChange(field.key, picked.selector);
|
||||
onToast(`已拾取 ${picked.tag}${picked.label ? ` · ${picked.label}` : ""}`);
|
||||
} catch (error) {
|
||||
onToast(error instanceof Error ? error.message : String(error), "error");
|
||||
} finally {
|
||||
setPicking(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (field.widget === "boolean") {
|
||||
return (
|
||||
<label className="checkbox-row">
|
||||
@@ -488,8 +510,8 @@ function FieldControl({
|
||||
placeholder="css:#submit / aria:button"
|
||||
onChange={(event) => onChange(field.key, event.target.value)}
|
||||
/>
|
||||
<button type="button" className="mini-btn" disabled title="Day 2 接入浏览器拾取器">
|
||||
拾取
|
||||
<button type="button" className="mini-btn" disabled={picking} onClick={() => void pickElement()} title="从运行浏览器拾取元素">
|
||||
{picking ? "拾取中" : "拾取"}
|
||||
</button>
|
||||
</div>
|
||||
) : field.widget === "secret" ? (
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useMemo } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { CircleAlert, CircleCheck, Loader2, Square, Timer, X } from "lucide-react";
|
||||
import { useEditorStore } from "../store/editorStore";
|
||||
import { useRunStore } from "../store/runStore";
|
||||
import { RunLogView } from "../components/RunLogView";
|
||||
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
running: "运行中",
|
||||
@@ -13,6 +14,7 @@ const STATUS_LABEL: Record<string, string> = {
|
||||
};
|
||||
|
||||
export function RunDrawer() {
|
||||
const [view, setView] = useState<"nodes" | "logs">("nodes");
|
||||
const running = useRunStore((state) => state.running);
|
||||
const stopping = useRunStore((state) => state.stopping);
|
||||
const nodes = useRunStore((state) => state.nodes);
|
||||
@@ -55,6 +57,24 @@ export function RunDrawer() {
|
||||
已完成 {finished.length}/{Object.keys(nodes).length}
|
||||
</span>
|
||||
<span className="spacer" />
|
||||
<div className="run-drawer-tabs" role="tablist" aria-label="运行详情视图">
|
||||
<button
|
||||
type="button"
|
||||
className={`run-drawer-tab${view === "nodes" ? " active" : ""}`}
|
||||
onClick={() => setView("nodes")}
|
||||
aria-pressed={view === "nodes"}
|
||||
>
|
||||
节点进度
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`run-drawer-tab${view === "logs" ? " active" : ""}`}
|
||||
onClick={() => setView("logs")}
|
||||
aria-pressed={view === "logs"}
|
||||
>
|
||||
运行日志
|
||||
</button>
|
||||
</div>
|
||||
{running ? (
|
||||
<button type="button" className="btn" onClick={() => void stop()} disabled={stopping}>
|
||||
<Square className="icon" /> 停止
|
||||
@@ -77,26 +97,20 @@ export function RunDrawer() {
|
||||
)}
|
||||
|
||||
<div className="run-drawer-body">
|
||||
<div className="run-node-list">
|
||||
{Object.entries(nodes).length === 0 && <div className="field-hint">等待节点启动…</div>}
|
||||
{Object.entries(nodes).map(([nodeId, value]) => (
|
||||
<div className={`run-node-item ${value.status}`} key={nodeId}>
|
||||
<span className="run-node-name">{nameOf(nodeId)}</span>
|
||||
<span className="run-node-status">{STATUS_LABEL[value.status] || value.status}</span>
|
||||
<span className="run-node-time">{value.durationMs == null ? "" : `${value.durationMs}ms`}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="run-logs">
|
||||
{logs.length === 0 && <div className="field-hint">运行日志会显示在这里…</div>}
|
||||
{logs.map((log) => (
|
||||
<div className={`run-log ${log.level}`} key={log.id}>
|
||||
<span className="run-log-time">{log.time}</span>
|
||||
<span className="run-log-node">{nameOf(log.nodeId)}</span>
|
||||
<span className="run-log-message">{log.message}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{view === "nodes" ? (
|
||||
<div className="run-node-list">
|
||||
{Object.entries(nodes).length === 0 && <div className="field-hint">等待节点启动…</div>}
|
||||
{Object.entries(nodes).map(([nodeId, value]) => (
|
||||
<div className={`run-node-item ${value.status}`} key={nodeId}>
|
||||
<span className="run-node-name">{nameOf(nodeId)}</span>
|
||||
<span className="run-node-status">{STATUS_LABEL[value.status] || value.status}</span>
|
||||
<span className="run-node-time">{value.durationMs == null ? "" : `${value.durationMs}ms`}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<RunLogView logs={logs} nameOf={nameOf} emptyText="运行日志会显示在这里…" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -575,6 +575,7 @@ textarea:focus-visible {
|
||||
|
||||
/* ---------- 画布 ---------- */
|
||||
.canvas-panel {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
@@ -1282,11 +1283,19 @@ textarea:focus-visible {
|
||||
.runs {
|
||||
height: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: 326px minmax(0, 1fr) 286px;
|
||||
grid-template-columns: 324px minmax(0, 1fr);
|
||||
gap: 14px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.run-main {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
grid-template-rows: minmax(0, 1fr) minmax(270px, 44%);
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.panel-head {
|
||||
height: 52px;
|
||||
flex: none;
|
||||
@@ -1453,6 +1462,38 @@ textarea:focus-visible {
|
||||
|
||||
.run-page-actions .btn { justify-content: center; }
|
||||
|
||||
.run-error {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 9px;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--danger-bg);
|
||||
border-radius: 10px;
|
||||
background: var(--danger-bg);
|
||||
}
|
||||
|
||||
.run-error .icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex: none;
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.run-error strong {
|
||||
display: block;
|
||||
color: var(--danger-deep);
|
||||
font-size: 11.5px;
|
||||
line-height: 17px;
|
||||
}
|
||||
|
||||
.run-error span {
|
||||
display: block;
|
||||
margin-top: 2px;
|
||||
color: var(--danger-deep);
|
||||
font-size: 10.5px;
|
||||
line-height: 15px;
|
||||
}
|
||||
|
||||
.run-node-table {
|
||||
margin-top: 10px;
|
||||
border: 1px solid var(--line);
|
||||
@@ -1484,13 +1525,277 @@ textarea:focus-visible {
|
||||
.run-node-main small { overflow: hidden; color: var(--danger); font-size: 9.5px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.run-node-duration { color: var(--muted); font-size: 10px; text-align: right; font-variant-numeric: tabular-nums; }
|
||||
|
||||
.run-page-logs { min-height: 0; flex: 1; overflow: auto; padding: 8px; }
|
||||
.run-page-log { padding: 8px; border-bottom: 1px solid var(--line); font-size: 10px; line-height: 15px; }
|
||||
.run-page-log > span { color: var(--faint); font-family: var(--mono); }
|
||||
.run-page-log > strong { display: block; margin-top: 2px; color: var(--ink-2); }
|
||||
.run-page-log > p { margin-top: 2px; color: var(--muted); overflow-wrap: anywhere; }
|
||||
.run-page-log.warn > p { color: var(--warn-deep); }
|
||||
.run-page-log.error > p { color: var(--danger); }
|
||||
/* ---------- 运行日志组件 ---------- */
|
||||
.run-log-view {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.run-log-toolbar {
|
||||
min-height: 46px;
|
||||
padding: 7px 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.run-log-filters {
|
||||
padding: 2px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
border-radius: 9px;
|
||||
background: var(--soft);
|
||||
}
|
||||
|
||||
.run-log-filter {
|
||||
height: 26px;
|
||||
padding: 0 8px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
border-radius: 7px;
|
||||
color: var(--muted);
|
||||
font-size: 10.5px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.run-log-filter:hover { color: var(--ink); }
|
||||
.run-log-filter.active {
|
||||
color: var(--blue-dark);
|
||||
background: #fff;
|
||||
box-shadow: 0 1px 4px rgba(35, 55, 72, 0.1);
|
||||
}
|
||||
|
||||
.run-log-filter small {
|
||||
min-width: 17px;
|
||||
padding: 0 4px;
|
||||
border-radius: 8px;
|
||||
background: rgba(78, 90, 107, 0.08);
|
||||
color: var(--muted);
|
||||
font-size: 9px;
|
||||
line-height: 15px;
|
||||
text-align: center;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.run-log-action {
|
||||
height: 28px;
|
||||
padding: 0 9px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: #f8fafc;
|
||||
color: var(--ink-2);
|
||||
font-size: 11px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.run-log-action .icon { width: 13px; height: 13px; }
|
||||
.run-log-action:hover:not(:disabled) { color: var(--blue-dark); border-color: rgba(34, 100, 229, 0.22); background: #fff; }
|
||||
.run-log-action:disabled { opacity: 0.45; cursor: not-allowed; }
|
||||
|
||||
.run-log-console-wrap {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.run-log-console {
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
border: 1px solid #243352;
|
||||
border-radius: 10px;
|
||||
background: #0f172a;
|
||||
color: #dce6f7;
|
||||
font-family: var(--mono);
|
||||
font-size: 11.5px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.run-log-row {
|
||||
min-width: 640px;
|
||||
padding: 7px 10px;
|
||||
display: grid;
|
||||
grid-template-columns: 74px 48px minmax(130px, 190px) minmax(0, 1fr);
|
||||
gap: 10px;
|
||||
align-items: start;
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.13);
|
||||
}
|
||||
|
||||
.run-log-row:hover { background: rgba(148, 163, 184, 0.09); }
|
||||
.run-log-time { color: #8ea3c2; font-variant-numeric: tabular-nums; white-space: nowrap; }
|
||||
.run-log-level { color: #a8b8d4; font-size: 10px; font-weight: 750; }
|
||||
.run-log-node {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: #b9c8e2;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.run-log-message {
|
||||
min-width: 0;
|
||||
color: #dce6f7;
|
||||
overflow-wrap: anywhere;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.run-log-row.warn .run-log-level,
|
||||
.run-log-row.warn .run-log-message { color: #facc6b; }
|
||||
.run-log-row.error .run-log-level,
|
||||
.run-log-row.error .run-log-message { color: #ff8080; }
|
||||
.run-log-row.error { background: rgba(248, 113, 113, 0.08); }
|
||||
|
||||
.run-log-empty {
|
||||
min-height: 160px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: #8ea3c2;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.run-log-latest {
|
||||
position: absolute;
|
||||
right: 22px;
|
||||
bottom: 22px;
|
||||
height: 30px;
|
||||
padding: 0 10px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
border: 1px solid #35486e;
|
||||
border-radius: 15px;
|
||||
background: #1e293b;
|
||||
color: #dce6f7;
|
||||
font-size: 10.5px;
|
||||
font-weight: 700;
|
||||
box-shadow: 0 8px 20px rgba(2, 6, 23, 0.32);
|
||||
}
|
||||
|
||||
.run-log-latest .icon { width: 13px; height: 13px; }
|
||||
|
||||
/* ---------- 编排运行抽屉 ---------- */
|
||||
.run-drawer {
|
||||
position: absolute;
|
||||
left: 14px;
|
||||
right: 14px;
|
||||
bottom: 14px;
|
||||
max-height: min(420px, 54%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(255, 255, 255, 0.82);
|
||||
border-radius: 14px;
|
||||
background: #fff;
|
||||
box-shadow: 0 16px 44px rgba(15, 23, 42, 0.18);
|
||||
z-index: 36;
|
||||
}
|
||||
|
||||
.run-drawer-head {
|
||||
min-height: 48px;
|
||||
padding: 8px 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: rgba(255, 255, 255, 0.96);
|
||||
}
|
||||
|
||||
.run-drawer-meta {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
font-weight: 650;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.run-drawer-meta .icon { width: 13px; height: 13px; }
|
||||
|
||||
.run-drawer-tabs {
|
||||
padding: 2px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
border-radius: 9px;
|
||||
background: var(--soft);
|
||||
}
|
||||
|
||||
.run-drawer-tab {
|
||||
height: 26px;
|
||||
padding: 0 9px;
|
||||
border-radius: 7px;
|
||||
color: var(--muted);
|
||||
font-size: 10.5px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.run-drawer-tab:hover { color: var(--ink); }
|
||||
.run-drawer-tab.active {
|
||||
color: var(--blue-dark);
|
||||
background: #fff;
|
||||
box-shadow: 0 1px 4px rgba(35, 55, 72, 0.1);
|
||||
}
|
||||
|
||||
.run-drawer-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.run-node-list {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding: 7px;
|
||||
border-right: 0;
|
||||
}
|
||||
|
||||
.run-node-item {
|
||||
min-height: 38px;
|
||||
padding: 6px 7px;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto auto;
|
||||
gap: 2px 7px;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid var(--line);
|
||||
border-left: 3px solid var(--faint);
|
||||
}
|
||||
|
||||
.run-node-item:last-child { border-bottom: 0; }
|
||||
.run-node-item.running { border-left-color: var(--blue); }
|
||||
.run-node-item.succeeded { border-left-color: var(--success); }
|
||||
.run-node-item.failed { border-left-color: var(--danger); }
|
||||
.run-node-item.skipped,
|
||||
.run-node-item.cancelled { border-left-color: var(--warn); }
|
||||
|
||||
.run-node-name {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: var(--ink);
|
||||
font-size: 11px;
|
||||
font-weight: 650;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.run-node-status { color: var(--muted); font-size: 10px; }
|
||||
.run-node-time { color: var(--faint); font-size: 9.5px; font-variant-numeric: tabular-nums; white-space: nowrap; }
|
||||
|
||||
.run-logs {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
/* ---------- 运行前检查 ---------- */
|
||||
.preflight-backdrop {
|
||||
|
||||
@@ -169,6 +169,20 @@ export interface AppInfo {
|
||||
fixtureUrl?: string;
|
||||
}
|
||||
|
||||
export interface ElementPickResult {
|
||||
selector: string;
|
||||
tag: string;
|
||||
id?: string;
|
||||
className?: string;
|
||||
name?: string;
|
||||
type?: string;
|
||||
placeholder?: string;
|
||||
label?: string;
|
||||
text?: string;
|
||||
value?: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface RunNodeState {
|
||||
nodeId: string;
|
||||
status: "idle" | "running" | "succeeded" | "failed" | "skipped" | "cancelled";
|
||||
|
||||
Reference in new issue
Block a user