feat: 扩展数据窗口能力并完善设置统计
This commit is contained in:
parent
21b5186442
commit
d4799a4285
@ -50,6 +50,6 @@ docs 现状分析、实施方案
|
||||
|
||||
## 边界
|
||||
|
||||
- 1016 项目录中当前有 316 项通过能力注册表或运行时容器标记为可执行,覆盖控制/触发、数据与二维表格、文件、浏览器元素常用变体、HTTP、Excel/CSV与表格辅助、系统路径与环境变量、进程管理、桌面键鼠/窗口/剪贴板/对话框、PDF、Word、邮件发送、屏幕截图、离线图像查找与点击;其余灰标「待接入」。运行前检查会提前定位待接入组件、必填参数、多出口与断链节点。
|
||||
- 1016 项目录中当前有 363 项通过能力注册表或运行时容器标记为可执行,覆盖控制/触发、字符串/集合/INI/二维表格、文件、浏览器元素常用变体、HTTP、Excel/CSV与表格辅助、系统路径与环境变量、进程管理、桌面键鼠/窗口对象/剪贴板/对话框、PDF、Word、邮件发送、屏幕截图、离线图像查找与点击;其余灰标「待接入」。运行前检查会提前定位待接入组件、必填参数、多出口与断链节点。
|
||||
- 不包含厂商代码、二进制、模型或前端资产;目录是从厂商元数据派生的静态定义。
|
||||
- 运行期不访问云服务;HTTP/邮件属于用户显式配置的外部系统。
|
||||
|
||||
@ -4,7 +4,7 @@
|
||||
> 依据:`D:\code\Agent-Employee`(只读)、`C:\rpa-demo`(只读)、2026-10-26 需求确认
|
||||
> 本方案是执行基线:范围、契约、排期、验收、降级顺序都在这里;实施中变更必须回写本文件。
|
||||
|
||||
> **2026-10-27 当前状态**:计划点名的代表能力已全部进入注册表或运行时容器,覆盖控制/触发、数据/文本与二维表格、文件、浏览器元素常用变体、HTTP、Excel/CSV与表格辅助、系统路径与环境变量、进程管理、桌面键鼠/窗口/剪贴板/对话框、PDF、Word、邮件发送、屏幕截图、离线图像查找与点击;启动后目录显示 **316 项可执行 / 1016 项总目录**。当前 11 个测试文件共 79 条测试,覆盖计划能力审计、浏览器参数路由与变体、Excel/PDF/Word/SMTP/模板匹配、文件与二维表格扩展、系统与进程、直线/IF/循环/重试/跳过、主出口与异常出口语义、运行记录回写和运行前检查。未通过实际场景验证的变体仍不计入最终交付数量。
|
||||
> **2026-10-27 当前状态**:计划点名的代表能力已全部进入注册表或运行时容器,覆盖控制/触发、字符串/集合/INI/二维表格、文件、浏览器元素常用变体、HTTP、Excel/CSV与表格辅助、系统路径与环境变量、进程管理、桌面键鼠/窗口对象/剪贴板/对话框、PDF、Word、邮件发送、屏幕截图、离线图像查找与点击;启动后目录显示 **363 项可执行 / 1016 项总目录**。当前 13 个测试文件共 89 条测试,覆盖计划能力审计、浏览器参数路由与变体、Excel/PDF/Word/SMTP/模板匹配、文件与数据扩展、系统/进程/窗口解析、直线/IF/循环/重试/跳过、主出口与异常出口语义、运行记录回写和运行前检查。设置页可查看分类覆盖和处理器 ID 数量。未通过实际场景验证的变体仍不计入最终交付数量。
|
||||
|
||||
---
|
||||
|
||||
|
||||
132
src/main/capabilities/data-extended.test.ts
Normal file
132
src/main/capabilities/data-extended.test.ts
Normal file
@ -0,0 +1,132 @@
|
||||
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Catalog, CatalogComponent, FlowNode } from "../../shared/types";
|
||||
import type { RunContext } from "../engine/types";
|
||||
import { getHandler } from "../engine/registry";
|
||||
import "./data-extended";
|
||||
|
||||
const ids = [
|
||||
"Collection_Create", "Collection_HasItem", "Collection_Count", "Collection_Remove", "Collection_Add",
|
||||
"Ini_Read", "Ini_Write", "Ini_ListSection", "Ini_ListKey", "Ini_DeleteKey", "Ini_DeleteSection",
|
||||
"Log_Info", "Log_Error", "ExceptionUtil_Throws",
|
||||
"Bivartable_CreateDf", "Bivartable_CreateDfV1", "Bivartable_DoCreate",
|
||||
"Bivartable_GetMaxValue", "Bivartable_GetMinValue", "Bivartable_GetSumValue", "Bivartable_GetAvgValue",
|
||||
"Bivartable_SummaryCalculationDf", "Bivartable_DeleteDuplicateRow", "Bivartable_MergeDf",
|
||||
"Bivartable_DoMerge", "Bivartable_FilterDf", "Bivartable_DoFilter"
|
||||
] as const;
|
||||
|
||||
const temporaryDirectories: string[] = [];
|
||||
const log = vi.fn();
|
||||
const context = { log } as unknown as RunContext;
|
||||
const component = {} as CatalogComponent;
|
||||
const node = (componentId: string): FlowNode => ({
|
||||
id: `data_${componentId}`,
|
||||
componentId,
|
||||
position: { x: 0, y: 0 },
|
||||
values: {},
|
||||
options: {}
|
||||
});
|
||||
|
||||
async function temporaryDirectory(): Promise<string> {
|
||||
const directory = await mkdtemp(path.join(os.tmpdir(), "rpa-data-extended-"));
|
||||
temporaryDirectories.push(directory);
|
||||
return directory;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
log.mockReset();
|
||||
await Promise.all(temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
describe("extended local data capabilities", () => {
|
||||
it("registers only real catalog component ids", async () => {
|
||||
const catalog = JSON.parse(await readFile("resources/catalog.json", "utf8")) as Catalog;
|
||||
const catalogIds = new Set(catalog.components.map((item) => item.id));
|
||||
expect(ids.length).toBeGreaterThanOrEqual(15);
|
||||
for (const id of ids) {
|
||||
expect(catalogIds.has(id), `${id} should exist in catalog`).toBe(true);
|
||||
expect(getHandler(id), `${id} should be registered`).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
it("creates and mutates unique local collections", async () => {
|
||||
const collection = await run("Collection_Create") as unknown[];
|
||||
await run("Collection_Add", { collection, value: "A" });
|
||||
await run("Collection_Add", { collection, value: "A" });
|
||||
await run("Collection_Add", { collection, value: "B" });
|
||||
await expect(run("Collection_HasItem", { collection, item: "A" })).resolves.toBe(true);
|
||||
await expect(run("Collection_Count", { collection })).resolves.toBe(2);
|
||||
await run("Collection_Remove", { collection, value: "A" });
|
||||
expect(collection).toEqual(["B"]);
|
||||
});
|
||||
|
||||
it("persists, lists, reads, and deletes INI values", async () => {
|
||||
const directory = await temporaryDirectory();
|
||||
const file = path.join(directory, "nested", "settings.ini");
|
||||
await run("Ini_Write", { file, section: "App", key: "name", value: "RPA Studio" });
|
||||
await run("Ini_Write", { file, section: "App", key: "workers", value: 3 });
|
||||
await run("Ini_Write", { file, section: "Network", key: "timeout", value: 30 });
|
||||
await expect(run("Ini_Read", { file, section: "App", key: "name" })).resolves.toBe("RPA Studio");
|
||||
await expect(run("Ini_Read", { file, section: "Missing", key: "key", defaultValue: "fallback" })).resolves.toBe("fallback");
|
||||
await expect(run("Ini_ListSection", { file })).resolves.toEqual(["App", "Network"]);
|
||||
await expect(run("Ini_ListKey", { file, section: "App" })).resolves.toEqual(["name", "workers"]);
|
||||
await run("Ini_DeleteKey", { file, section: "App", key: "workers" });
|
||||
await run("Ini_DeleteSection", { file, section: "Network" });
|
||||
await expect(run("Ini_ListKey", { file, section: "App" })).resolves.toEqual(["name"]);
|
||||
await expect(run("Ini_ListSection", { file })).resolves.toEqual(["App"]);
|
||||
await expect(readFile(file, "utf8")).resolves.toContain("name=RPA Studio");
|
||||
});
|
||||
|
||||
it("writes runtime logs and raises a located user exception", async () => {
|
||||
await run("Log_Info", { msg: "started" });
|
||||
await run("Log_Error", { msg: "failed" });
|
||||
expect(log).toHaveBeenNthCalledWith(1, "started", "info", "data_Log_Info");
|
||||
expect(log).toHaveBeenNthCalledWith(2, "failed", "error", "data_Log_Error");
|
||||
await expect(run("ExceptionUtil_Throws", { msg: "stop now" })).rejects.toMatchObject({
|
||||
name: "RuntimeError", code: "user_exception", message: "stop now", nodeId: "data_ExceptionUtil_Throws"
|
||||
});
|
||||
});
|
||||
|
||||
it("creates tables from arrays, records, and column dictionaries", async () => {
|
||||
await expect(run("Bivartable_CreateDf", { data: [[1, 2], [3, 4]], columnList: ["A", "B"] }))
|
||||
.resolves.toEqual([["A", "B"], [1, 2], [3, 4]]);
|
||||
await expect(run("Bivartable_CreateDfV1", { data: [{ name: "A", score: 2 }, { name: "B", score: 4 }] }))
|
||||
.resolves.toEqual([["name", "score"], ["A", 2], ["B", 4]]);
|
||||
await expect(run("Bivartable_DoCreate", { data: { name: ["A", "B"], score: [2, 4] } }))
|
||||
.resolves.toEqual([["name", "score"], ["A", 2], ["B", 4]]);
|
||||
});
|
||||
|
||||
it("calculates scalar and grouped table aggregates", async () => {
|
||||
const input = [["team", "score"], ["A", 2], ["A", 4], ["B", 3]];
|
||||
await expect(run("Bivartable_GetMaxValue", { df: input, maxvaluecols: "score" })).resolves.toBe(4);
|
||||
await expect(run("Bivartable_GetMinValue", { df: input, minvaluecols: "score" })).resolves.toBe(2);
|
||||
await expect(run("Bivartable_GetSumValue", { df: input, sumvaluecols: "score" })).resolves.toBe(9);
|
||||
await expect(run("Bivartable_GetAvgValue", { df: input, avgvaluecols: "score", groupby: "team" }))
|
||||
.resolves.toEqual([["team", "score"], ["A", 3], ["B", 3]]);
|
||||
await expect(run("Bivartable_SummaryCalculationDf", { df: input, name: "score", method: "sum" })).resolves.toBe(9);
|
||||
});
|
||||
|
||||
it("deduplicates, merges, and filters local tables", async () => {
|
||||
const duplicated = [["id", "name"], [1, "A"], [1, "A2"], [2, "B"]];
|
||||
await expect(run("Bivartable_DeleteDuplicateRow", { df: duplicated, change_type: "name", col_name: ["id"], mode: "first" }))
|
||||
.resolves.toEqual([["id", "name"], [1, "A"], [2, "B"]]);
|
||||
const left = [["id", "name"], [1, "A"], [2, "B"]];
|
||||
const right = [["id", "score"], [1, 90], [3, 70]];
|
||||
await expect(run("Bivartable_MergeDf", { df1: left, df2: right, on_name: "id", merge_type: "left" }))
|
||||
.resolves.toEqual([["id", "name", "score"], [1, "A", 90], [2, "B", null]]);
|
||||
await expect(run("Bivartable_DoMerge", { df1: left, df2: right, onx: "id", howx: "inner" }))
|
||||
.resolves.toEqual([["id", "name", "score"], [1, "A", 90]]);
|
||||
await expect(run("Bivartable_FilterDf", { df: left, col_name: "id", choice: ">", criteria: 1 }))
|
||||
.resolves.toEqual([["id", "name"], [2, "B"]]);
|
||||
await expect(run("Bivartable_DoFilter", { df: [["id", "score"], [1, 70], [2, 90], [3, 95]], filter: "(df['id'] > 1) & (df['score'] >= 90)" }))
|
||||
.resolves.toEqual([["id", "score"], [2, 90], [3, 95]]);
|
||||
});
|
||||
});
|
||||
392
src/main/capabilities/data-extended.ts
Normal file
392
src/main/capabilities/data-extended.ts
Normal file
@ -0,0 +1,392 @@
|
||||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { register } from "../engine/registry";
|
||||
import { RuntimeError, type CapabilityHandler } from "../engine/types";
|
||||
|
||||
type Table = unknown[][];
|
||||
type IniDocument = Map<string, Map<string, string>>;
|
||||
|
||||
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(candidate: unknown): string {
|
||||
return candidate == null ? "" : String(candidate);
|
||||
}
|
||||
|
||||
function parse(candidate: unknown): unknown {
|
||||
if (typeof candidate !== "string") return candidate;
|
||||
const source = candidate.trim();
|
||||
if (!source) return candidate;
|
||||
try { return JSON.parse(source); } catch { return candidate; }
|
||||
}
|
||||
|
||||
function list(candidate: unknown): unknown[] {
|
||||
const parsed = parse(candidate);
|
||||
if (Array.isArray(parsed)) return parsed;
|
||||
if (parsed === undefined || parsed === null || parsed === "") return [];
|
||||
if (typeof parsed === "string") return parsed.split(/[,\uFF0C\n]/).map((item) => item.trim()).filter(Boolean);
|
||||
return [parsed];
|
||||
}
|
||||
|
||||
function table(candidate: unknown): Table {
|
||||
const parsed = parse(candidate);
|
||||
if (Array.isArray(parsed)) {
|
||||
if (!parsed.length) return [];
|
||||
if (parsed.every((row) => Array.isArray(row))) return parsed.map((row) => [...row as unknown[]]);
|
||||
if (parsed.every((row) => row && typeof row === "object" && !Array.isArray(row))) {
|
||||
const headers = [...new Set(parsed.flatMap((row) => Object.keys(row as Record<string, unknown>)))];
|
||||
return [headers, ...parsed.map((row) => headers.map((header) => (row as Record<string, unknown>)[header]))];
|
||||
}
|
||||
return parsed.map((item) => [item]);
|
||||
}
|
||||
if (parsed && typeof parsed === "object") {
|
||||
const source = parsed as Record<string, unknown>;
|
||||
const headers = Object.keys(source);
|
||||
const columns = headers.map((header) => list(source[header]));
|
||||
const rowCount = Math.max(0, ...columns.map((column) => column.length));
|
||||
return [headers, ...Array.from({ length: rowCount }, (_, row) => columns.map((column) => column[row]))];
|
||||
}
|
||||
return parsed === undefined || parsed === null || parsed === "" ? [] : [[parsed]];
|
||||
}
|
||||
|
||||
function columnNames(candidate: unknown): string[] {
|
||||
return list(candidate).map((item) => text(item)).filter(Boolean);
|
||||
}
|
||||
|
||||
function columnIndex(headers: unknown[], name: unknown): number {
|
||||
const source = text(name).trim();
|
||||
const byName = headers.findIndex((header) => text(header) === source);
|
||||
if (byName >= 0) return byName;
|
||||
if (/^\d+$/.test(source)) return Math.max(0, Number(source) - 1);
|
||||
if (/^[A-Za-z]+$/.test(source)) {
|
||||
let result = 0;
|
||||
for (const character of source.toUpperCase()) result = result * 26 + character.charCodeAt(0) - 64;
|
||||
return Math.max(0, result - 1);
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function selectedIndexes(rows: Table, candidate: unknown): number[] {
|
||||
const headers = rows[0] ?? [];
|
||||
const requested = columnNames(candidate);
|
||||
return requested.map((name) => columnIndex(headers, name)).filter((index) => index >= 0);
|
||||
}
|
||||
|
||||
function comparable(candidate: unknown): string | number | boolean | null {
|
||||
if (candidate === null || candidate === undefined) return null;
|
||||
if (typeof candidate === "number" || typeof candidate === "boolean") return candidate;
|
||||
const source = text(candidate).trim().replace(/^\(+|\)+$/g, "").trim();
|
||||
if (/^-?\d+(\.\d+)?$/.test(source)) return Number(source);
|
||||
if (source === "true" || source === "false") return source === "true";
|
||||
return source.replace(/^['"]|['"]$/g, "");
|
||||
}
|
||||
|
||||
function equal(left: unknown, right: unknown): boolean {
|
||||
return comparable(left) === comparable(right);
|
||||
}
|
||||
|
||||
function collection(candidate: unknown): Set<unknown> | unknown[] {
|
||||
if (candidate instanceof Set || Array.isArray(candidate)) return candidate;
|
||||
return list(candidate);
|
||||
}
|
||||
|
||||
register("Collection_Create", () => []);
|
||||
register("Collection_HasItem", (_ctx, _node, args) => {
|
||||
const source = collection(args.collection);
|
||||
return source instanceof Set ? source.has(args.item) : source.some((item) => equal(item, args.item));
|
||||
});
|
||||
register("Collection_Count", (_ctx, _node, args) => {
|
||||
const source = collection(args.collection);
|
||||
return source instanceof Set ? source.size : source.length;
|
||||
});
|
||||
register("Collection_Add", (_ctx, _node, args) => {
|
||||
const source = collection(args.collection);
|
||||
if (source instanceof Set) source.add(args.value);
|
||||
else if (!source.some((item) => equal(item, args.value))) source.push(args.value);
|
||||
return source;
|
||||
});
|
||||
register("Collection_Remove", (_ctx, _node, args) => {
|
||||
const source = collection(args.collection);
|
||||
if (source instanceof Set) source.delete(args.value);
|
||||
else {
|
||||
const index = source.findIndex((item) => equal(item, args.value));
|
||||
if (index >= 0) source.splice(index, 1);
|
||||
}
|
||||
return source;
|
||||
});
|
||||
|
||||
function requiredFile(args: Record<string, unknown>, nodeId: string, componentId: string): string {
|
||||
const candidate = text(args.file).trim();
|
||||
if (!candidate) {
|
||||
throw new RuntimeError("validation_error", "INI file path is empty", {
|
||||
nodeId, componentId, arg: "file", recommendation: "Select an INI file"
|
||||
});
|
||||
}
|
||||
return path.resolve(candidate);
|
||||
}
|
||||
|
||||
function parseIni(source: string): IniDocument {
|
||||
const document: IniDocument = new Map([["", new Map()]]);
|
||||
let section = "";
|
||||
for (const rawLine of source.replace(/^\uFEFF/, "").split(/\r?\n/)) {
|
||||
const line = rawLine.trim();
|
||||
if (!line || line.startsWith(";") || line.startsWith("#")) continue;
|
||||
const sectionMatch = line.match(/^\[([^\]]+)]$/);
|
||||
if (sectionMatch) {
|
||||
section = sectionMatch[1].trim();
|
||||
if (!document.has(section)) document.set(section, new Map());
|
||||
continue;
|
||||
}
|
||||
const separator = rawLine.indexOf("=");
|
||||
if (separator < 0) continue;
|
||||
document.get(section)!.set(rawLine.slice(0, separator).trim(), rawLine.slice(separator + 1).trim());
|
||||
}
|
||||
return document;
|
||||
}
|
||||
|
||||
function serializeIni(document: IniDocument): string {
|
||||
const lines: string[] = [];
|
||||
for (const [section, entries] of document) {
|
||||
if (section) {
|
||||
if (lines.length) lines.push("");
|
||||
lines.push(`[${section}]`);
|
||||
}
|
||||
for (const [key, entryValue] of entries) lines.push(`${key}=${entryValue}`);
|
||||
}
|
||||
return `${lines.join("\r\n")}\r\n`;
|
||||
}
|
||||
|
||||
async function loadIni(filePath: string): Promise<IniDocument> {
|
||||
try { return parseIni(await fs.readFile(filePath, "utf8")); }
|
||||
catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") return parseIni("");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveIni(filePath: string, document: IniDocument): Promise<void> {
|
||||
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
||||
await fs.writeFile(filePath, serializeIni(document), "utf8");
|
||||
}
|
||||
|
||||
register("Ini_Read", async (_ctx, node, args) => {
|
||||
const document = await loadIni(requiredFile(args, node.id, node.componentId));
|
||||
return document.get(text(args.section))?.get(text(args.key)) ?? text(args.defaultValue);
|
||||
});
|
||||
register("Ini_Write", async (_ctx, node, args) => {
|
||||
const filePath = requiredFile(args, node.id, node.componentId);
|
||||
const document = await loadIni(filePath);
|
||||
const section = text(args.section);
|
||||
if (!document.has(section)) document.set(section, new Map());
|
||||
document.get(section)!.set(text(args.key), text(args.value));
|
||||
await saveIni(filePath, document);
|
||||
return filePath;
|
||||
});
|
||||
register("Ini_ListSection", async (_ctx, node, args) => {
|
||||
const document = await loadIni(requiredFile(args, node.id, node.componentId));
|
||||
return [...document.keys()].filter(Boolean);
|
||||
});
|
||||
register("Ini_ListKey", async (_ctx, node, args) => {
|
||||
const document = await loadIni(requiredFile(args, node.id, node.componentId));
|
||||
return [...(document.get(text(args.section))?.keys() ?? [])];
|
||||
});
|
||||
register("Ini_DeleteKey", async (_ctx, node, args) => {
|
||||
const filePath = requiredFile(args, node.id, node.componentId);
|
||||
const document = await loadIni(filePath);
|
||||
const removed = document.get(text(args.section))?.delete(text(args.key)) ?? false;
|
||||
await saveIni(filePath, document);
|
||||
return removed;
|
||||
});
|
||||
register("Ini_DeleteSection", async (_ctx, node, args) => {
|
||||
const filePath = requiredFile(args, node.id, node.componentId);
|
||||
const document = await loadIni(filePath);
|
||||
const removed = document.delete(text(args.section));
|
||||
await saveIni(filePath, document);
|
||||
return removed;
|
||||
});
|
||||
|
||||
register("Log_Info", (ctx, node, args) => {
|
||||
const message = text(args.msg);
|
||||
ctx.log(message, "info", node.id);
|
||||
return message;
|
||||
});
|
||||
register("Log_Error", (ctx, node, args) => {
|
||||
const message = text(args.msg);
|
||||
ctx.log(message, "error", node.id);
|
||||
return message;
|
||||
});
|
||||
register("ExceptionUtil_Throws", (_ctx, node, args) => {
|
||||
throw new RuntimeError("user_exception", text(args.msg) || "User exception", {
|
||||
nodeId: node.id, componentId: node.componentId
|
||||
});
|
||||
});
|
||||
|
||||
const createTable: CapabilityHandler = (_ctx, _node, args) => {
|
||||
const rows = table(value(args, ["data"]));
|
||||
const columns = columnNames(args.columnList);
|
||||
if (!columns.length) return rows;
|
||||
if (!rows.length) return [columns];
|
||||
if (rows[0].length !== columns.length) throw new RuntimeError("validation_error", "Column count does not match data");
|
||||
return [columns, ...rows];
|
||||
};
|
||||
register(["Bivartable_CreateDf", "Bivartable_CreateDfV1", "Bivartable_DoCreate"], createTable);
|
||||
|
||||
function aggregate(args: Record<string, unknown>, operation: "max" | "min" | "sum" | "avg"): unknown {
|
||||
const rows = table(args.df);
|
||||
if (rows.length < 2) return 0;
|
||||
const field = operation === "max" ? "maxvaluecols" : operation === "min" ? "minvaluecols" : operation === "sum" ? "sumvaluecols" : "avgvaluecols";
|
||||
const indexes = selectedIndexes(rows, args[field]);
|
||||
if (!indexes.length) throw new RuntimeError("validation_error", "No aggregate column was found");
|
||||
const groups = selectedIndexes(rows, args.groupby);
|
||||
const calculate = (body: unknown[][], index: number): number => {
|
||||
const numbers = body.map((row) => Number(row[index])).filter(Number.isFinite);
|
||||
if (!numbers.length) return 0;
|
||||
if (operation === "max") return Math.max(...numbers);
|
||||
if (operation === "min") return Math.min(...numbers);
|
||||
const sum = numbers.reduce((total, current) => total + current, 0);
|
||||
return operation === "avg" ? sum / numbers.length : sum;
|
||||
};
|
||||
if (!groups.length) {
|
||||
const results = indexes.map((index) => calculate(rows.slice(1), index));
|
||||
return results.length === 1 ? results[0] : Object.fromEntries(indexes.map((index, position) => [text(rows[0][index]), results[position]]));
|
||||
}
|
||||
const buckets = new Map<string, unknown[][]>();
|
||||
for (const row of rows.slice(1)) {
|
||||
const key = JSON.stringify(groups.map((index) => row[index]));
|
||||
if (!buckets.has(key)) buckets.set(key, []);
|
||||
buckets.get(key)!.push(row);
|
||||
}
|
||||
return [
|
||||
[...groups.map((index) => rows[0][index]), ...indexes.map((index) => rows[0][index])],
|
||||
...[...buckets.entries()].map(([key, body]) => [...JSON.parse(key), ...indexes.map((index) => calculate(body, index))])
|
||||
];
|
||||
}
|
||||
|
||||
register("Bivartable_GetMaxValue", (_ctx, _node, args) => aggregate(args, "max"));
|
||||
register("Bivartable_GetMinValue", (_ctx, _node, args) => aggregate(args, "min"));
|
||||
register("Bivartable_GetSumValue", (_ctx, _node, args) => aggregate(args, "sum"));
|
||||
register("Bivartable_GetAvgValue", (_ctx, _node, args) => aggregate(args, "avg"));
|
||||
register("Bivartable_SummaryCalculationDf", (_ctx, _node, args) => {
|
||||
const method = text(args.method).toLowerCase();
|
||||
const operation = method.includes("max") ? "max" : method.includes("min") ? "min" : method.includes("avg") || method.includes("mean") ? "avg" : "sum";
|
||||
const field = operation === "max" ? "maxvaluecols" : operation === "min" ? "minvaluecols" : operation === "avg" ? "avgvaluecols" : "sumvaluecols";
|
||||
return aggregate({ ...args, [field]: args.name }, operation);
|
||||
});
|
||||
|
||||
register("Bivartable_DeleteDuplicateRow", (_ctx, _node, args) => {
|
||||
const rows = table(args.df);
|
||||
if (rows.length < 2) return rows;
|
||||
const requested = text(args.change_type) === "name" ? args.col_name : args.col_num;
|
||||
const indexes = selectedIndexes(rows, requested);
|
||||
const compared = indexes.length ? indexes : rows[0].map((_item, index) => index);
|
||||
const keys = rows.slice(1).map((row) => JSON.stringify(compared.map((index) => row[index])));
|
||||
const counts = new Map<string, number>();
|
||||
for (const key of keys) counts.set(key, (counts.get(key) ?? 0) + 1);
|
||||
const mode = text(args.mode).toLowerCase();
|
||||
const seen = new Set<string>();
|
||||
const body = rows.slice(1).filter((_row, index) => {
|
||||
const key = keys[index];
|
||||
if (mode === "false" || mode === "none" || mode.includes("all")) return counts.get(key) === 1;
|
||||
if (mode === "last") return keys.lastIndexOf(key) === index;
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
return [rows[0], ...body];
|
||||
});
|
||||
|
||||
function mergeTables(args: Record<string, unknown>): Table {
|
||||
const left = table(args.df1);
|
||||
const right = table(args.df2);
|
||||
if (!left.length) return right;
|
||||
if (!right.length) return left;
|
||||
const mode = text(value(args, ["merge_type", "howx"], "inner")).toLowerCase();
|
||||
const common = left[0].filter((header) => right[0].some((candidate) => text(candidate) === text(header))).map(text);
|
||||
const sameKeys = columnNames(value(args, ["on_name", "onx"]));
|
||||
const leftKeys = columnNames(value(args, ["on_left", "left_onx"], sameKeys.length ? sameKeys : common));
|
||||
const rightKeys = columnNames(value(args, ["on_right", "right_onx"], sameKeys.length ? sameKeys : common));
|
||||
if (!leftKeys.length || leftKeys.length !== rightKeys.length) throw new RuntimeError("validation_error", "Invalid table join keys");
|
||||
const leftIndexes = leftKeys.map((key) => columnIndex(left[0], key));
|
||||
const rightIndexes = rightKeys.map((key) => columnIndex(right[0], key));
|
||||
if ([...leftIndexes, ...rightIndexes].some((index) => index < 0)) throw new RuntimeError("validation_error", "Table join key does not exist");
|
||||
const rightDataIndexes = right[0].map((_header, index) => index).filter((index) => !rightIndexes.includes(index));
|
||||
const outputHeaders = [...left[0], ...rightDataIndexes.map((index) => {
|
||||
const header = text(right[0][index]);
|
||||
return left[0].some((candidate) => text(candidate) === header) ? `${header}_right` : right[0][index];
|
||||
})];
|
||||
const rightBuckets = new Map<string, unknown[][]>();
|
||||
for (const row of right.slice(1)) {
|
||||
const key = JSON.stringify(rightIndexes.map((index) => row[index]));
|
||||
if (!rightBuckets.has(key)) rightBuckets.set(key, []);
|
||||
rightBuckets.get(key)!.push(row);
|
||||
}
|
||||
const matchedRight = new Set<unknown[]>();
|
||||
const output: Table = [outputHeaders];
|
||||
for (const row of left.slice(1)) {
|
||||
const matches = rightBuckets.get(JSON.stringify(leftIndexes.map((index) => row[index]))) ?? [];
|
||||
if (matches.length) {
|
||||
for (const match of matches) {
|
||||
matchedRight.add(match);
|
||||
output.push([...row, ...rightDataIndexes.map((index) => match[index])]);
|
||||
}
|
||||
} else if (mode === "left" || mode === "outer") {
|
||||
output.push([...row, ...rightDataIndexes.map(() => null)]);
|
||||
}
|
||||
}
|
||||
if (mode === "right" || mode === "outer") {
|
||||
for (const row of right.slice(1)) {
|
||||
if (matchedRight.has(row)) continue;
|
||||
const leftPart = left[0].map((header) => {
|
||||
const keyPosition = leftKeys.indexOf(text(header));
|
||||
return keyPosition >= 0 ? row[rightIndexes[keyPosition]] : null;
|
||||
});
|
||||
output.push([...leftPart, ...rightDataIndexes.map((index) => row[index])]);
|
||||
}
|
||||
}
|
||||
return output;
|
||||
}
|
||||
register(["Bivartable_MergeDf", "Bivartable_DoMerge"], (_ctx, _node, args) => mergeTables(args));
|
||||
|
||||
function predicate(operator: string, expected: unknown): (actual: unknown) => boolean {
|
||||
const normalized = operator.trim().toLowerCase();
|
||||
if (["2", "!=", "<>", "ne"].includes(normalized)) return (actual) => !equal(actual, expected);
|
||||
if (["3", ">", "gt"].includes(normalized)) return (actual) => Number(actual) > Number(expected);
|
||||
if (["4", ">=", "gte"].includes(normalized)) return (actual) => Number(actual) >= Number(expected);
|
||||
if (["5", "<", "lt"].includes(normalized)) return (actual) => Number(actual) < Number(expected);
|
||||
if (["6", "<=", "lte"].includes(normalized)) return (actual) => Number(actual) <= Number(expected);
|
||||
if (["7", "contains"].includes(normalized)) return (actual) => text(actual).includes(text(expected));
|
||||
if (["8", "not contains"].includes(normalized)) return (actual) => !text(actual).includes(text(expected));
|
||||
if (["9", "starts", "startswith"].includes(normalized)) return (actual) => text(actual).startsWith(text(expected));
|
||||
if (["10", "ends", "endswith"].includes(normalized)) return (actual) => text(actual).endsWith(text(expected));
|
||||
return (actual) => equal(actual, expected);
|
||||
}
|
||||
|
||||
function expressionFilter(rows: Table, expression: string): unknown[][] {
|
||||
const conditions = [...expression.matchAll(/(?:\w+\s*)?\[\s*['"]([^'"]+)['"]\s*]\s*(==|!=|>=|<=|>|<)\s*([^&|]+)/g)];
|
||||
if (!conditions.length) throw new RuntimeError("validation_error", "Unsupported filter expression");
|
||||
const operators = [...expression.matchAll(/\s*([&|])\s*/g)].map((match) => match[1]);
|
||||
return rows.slice(1).filter((row) => {
|
||||
const results = conditions.map((match) => {
|
||||
const index = columnIndex(rows[0], match[1]);
|
||||
return index >= 0 && predicate(match[2], comparable(match[3]))(row[index]);
|
||||
});
|
||||
return results.slice(1).reduce((result, current, index) => operators[index] === "|" ? result || current : result && current, results[0]);
|
||||
});
|
||||
}
|
||||
|
||||
function filterTable(args: Record<string, unknown>): Table {
|
||||
const rows = table(args.df);
|
||||
if (rows.length < 2) return rows;
|
||||
const expression = text(value(args, ["rule", "filter"])).trim();
|
||||
if (expression || text(args.filter_mode).toLowerCase().includes("advanced")) return [rows[0], ...expressionFilter(rows, expression)];
|
||||
const index = columnIndex(rows[0], args.col_name);
|
||||
if (index < 0) throw new RuntimeError("validation_error", `Filter column does not exist: ${text(args.col_name)}`);
|
||||
const matches = predicate(text(args.choice || "1"), parse(args.criteria));
|
||||
return [rows[0], ...rows.slice(1).filter((row) => matches(row[index]))];
|
||||
}
|
||||
register(["Bivartable_FilterDf", "Bivartable_DoFilter"], (_ctx, _node, args) => filterTable(args));
|
||||
@ -13,5 +13,7 @@ import "./vision";
|
||||
import "./extended";
|
||||
import "./system-extended";
|
||||
import "./office-file-extended";
|
||||
import "./window-extended";
|
||||
import "./data-extended";
|
||||
|
||||
export { capabilityCount, capabilityIds, getHandler, hasCapability, register } from "../engine/registry";
|
||||
|
||||
33
src/main/capabilities/window-extended.test.ts
Normal file
33
src/main/capabilities/window-extended.test.ts
Normal file
@ -0,0 +1,33 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { CatalogComponent, FlowNode } from "../../shared/types";
|
||||
import type { RunContext } from "../engine/types";
|
||||
import { getHandler } from "../engine/registry";
|
||||
import "./window-extended";
|
||||
|
||||
const context = {} as RunContext;
|
||||
const component = {} as CatalogComponent;
|
||||
const node = (id: string): FlowNode => ({ id, componentId: id, position: { x: 0, y: 0 }, values: {}, options: {} });
|
||||
const ids = [
|
||||
"Window_ActiveWindow_v1", "Window_Get", "Window_GetWindow", "Window_GetWindow_v1", "Window_GetWindowMessage",
|
||||
"Window_GetWindowMessage_v1", "Window_SetPos", "Window_MoveWindow", "Window_MoveWindow_v1", "Window_SizeWindow",
|
||||
"Mouse_MoveToV1", "Mouse_MoveToV2", "Mouse_DragToV1", "Mouse_DragToV2", "Mouse_DragToNew",
|
||||
"Mouse_ClickCombine", "Mouse_ClickCombine_v1", "Mouse_ClickCombine_v2", "Mouse_ClickCombine_v3", "Mouse_ClickCombine_v4"
|
||||
];
|
||||
|
||||
describe("window extended capabilities", () => {
|
||||
it("registers audited window and mouse variants", () => {
|
||||
for (const id of ids) expect(getHandler(id), id).toBeTruthy();
|
||||
});
|
||||
|
||||
it("rejects missing window selectors before touching the desktop", async () => {
|
||||
for (const id of ["Window_GetWindow", "Window_ActiveWindow_v1", "Window_MoveWindow", "Window_SizeWindow"]) {
|
||||
await expect(getHandler(id)!(context, node(id), {}, component)).rejects.toMatchObject({ code: "validation_error" });
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects malformed mouse coordinates before injecting input", async () => {
|
||||
await expect(getHandler("Mouse_MoveToV1")!(context, node("Mouse_MoveToV1"), { location: "bad" }, component)).rejects.toMatchObject({ code: "validation_error" });
|
||||
await expect(getHandler("Mouse_DragToV1")!(context, node("Mouse_DragToV1"), { Start: "bad", End: "[1,2]" }, component)).rejects.toMatchObject({ code: "validation_error" });
|
||||
await expect(getHandler("Mouse_ClickCombine")!(context, node("Mouse_ClickCombine"), { target: "1", location: "[1,2]" }, component)).rejects.toMatchObject({ code: "validation_error" });
|
||||
});
|
||||
});
|
||||
133
src/main/capabilities/window-extended.ts
Normal file
133
src/main/capabilities/window-extended.ts
Normal file
@ -0,0 +1,133 @@
|
||||
import { register } from "../engine/registry";
|
||||
import { RuntimeError, type CapabilityHandler } from "../engine/types";
|
||||
import { powershell, psString } from "./desktop";
|
||||
|
||||
const WINDOW_API = `Add-Type @'
|
||||
using System;
|
||||
using System.Runtime.InteropServices;
|
||||
public static class RpaWindowExtended {
|
||||
[StructLayout(LayoutKind.Sequential)] public struct RECT { public int Left; public int Top; public int Right; public int Bottom; }
|
||||
[DllImport("user32.dll")] public static extern bool GetWindowRect(IntPtr hWnd, out RECT rect);
|
||||
[DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr hWnd);
|
||||
[DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr hWnd, int command);
|
||||
[DllImport("user32.dll")] public static extern bool SetWindowPos(IntPtr hWnd, IntPtr after, int x, int y, int width, int height, uint flags);
|
||||
[DllImport("user32.dll")] public static extern bool SetCursorPos(int x, int y);
|
||||
[DllImport("user32.dll")] public static extern void mouse_event(uint flags, uint dx, uint dy, uint data, UIntPtr extra);
|
||||
}
|
||||
'@`;
|
||||
|
||||
function value(args: Record<string, unknown>, keys: string[], fallback = ""): string {
|
||||
for (const key of keys) {
|
||||
const current = args[key];
|
||||
if (current !== undefined && current !== null && current !== "") return String(current);
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function parseObject(candidate: unknown): Record<string, unknown> {
|
||||
if (candidate && typeof candidate === "object") return candidate as Record<string, unknown>;
|
||||
if (typeof candidate === "string" && candidate.trim().startsWith("{")) {
|
||||
try { return JSON.parse(candidate); } catch { return {}; }
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
function titleOf(args: Record<string, unknown>): string {
|
||||
const objectCandidate = args.window_obj ?? args["窗口对象"] ?? args.uiWindowSelector ?? args.target_window;
|
||||
const object = parseObject(objectCandidate);
|
||||
return String(object.title ?? object.windowTitle ?? args.title ?? args.match_title ?? args.select_title ?? objectCandidate ?? "").trim();
|
||||
}
|
||||
|
||||
function requiredTitle(args: Record<string, unknown>, nodeId: string, componentId: string): string {
|
||||
const title = titleOf(args);
|
||||
if (!title || title === "{}" || title === "窗口对象") throw new RuntimeError("validation_error", "窗口标题为空", { nodeId, componentId, recommendation: "填写窗口标题或传入窗口对象" });
|
||||
return title;
|
||||
}
|
||||
|
||||
function findWindow(title: string): string {
|
||||
return `$p = Get-Process | Where-Object { $_.MainWindowHandle -ne 0 -and $_.MainWindowTitle -like ${psString(`*${title}*`)} } | Select-Object -First 1; if (-not $p) { throw '找不到窗口:${title.replace(/'/g, "''")}' }`;
|
||||
}
|
||||
|
||||
function parsePoint(candidate: unknown, label: string, nodeId: string, componentId: string): [number, number] {
|
||||
let parsed: unknown = candidate;
|
||||
if (typeof parsed === "string") {
|
||||
const source = parsed;
|
||||
try { parsed = JSON.parse(source); } catch { parsed = source.split(/[,,xX ]+/).map(Number); }
|
||||
}
|
||||
if (!Array.isArray(parsed) || parsed.length < 2 || parsed.slice(0, 2).some((item) => !Number.isFinite(Number(item)))) {
|
||||
throw new RuntimeError("validation_error", `${label}无效`, { nodeId, componentId });
|
||||
}
|
||||
return [Number(parsed[0]), Number(parsed[1])];
|
||||
}
|
||||
|
||||
async function windowObject(title: string): Promise<Record<string, unknown>> {
|
||||
const output = await powershell(`${WINDOW_API}; ${findWindow(title)}; $r = New-Object RpaWindowExtended+RECT; [RpaWindowExtended]::GetWindowRect($p.MainWindowHandle,[ref]$r) | Out-Null; @{pid=$p.Id;title=$p.MainWindowTitle;hwnd=$p.MainWindowHandle.ToInt64();x=$r.Left;y=$r.Top;width=$r.Right-$r.Left;height=$r.Bottom-$r.Top} | ConvertTo-Json -Compress`);
|
||||
return JSON.parse(output);
|
||||
}
|
||||
|
||||
const getWindow: CapabilityHandler = async (_ctx, node, args) => windowObject(requiredTitle(args, node.id, node.componentId));
|
||||
register(["Window_Get", "Window_GetWindow", "Window_GetWindow_v1"], getWindow);
|
||||
|
||||
register("Window_ActiveWindow_v1", async (_ctx, node, args) => {
|
||||
const title = requiredTitle(args, node.id, node.componentId);
|
||||
const maximize = [true, 1, "1", "true", "True"].includes(args.isMax as never);
|
||||
await powershell(`${WINDOW_API}; ${findWindow(title)}; [RpaWindowExtended]::SetForegroundWindow($p.MainWindowHandle) | Out-Null; ${maximize ? "[RpaWindowExtended]::ShowWindow($p.MainWindowHandle,3) | Out-Null" : ""}`);
|
||||
return windowObject(title);
|
||||
});
|
||||
|
||||
register(["Window_GetWindowMessage", "Window_GetWindowMessage_v1"], async (_ctx, node, args) => {
|
||||
const object = await windowObject(requiredTitle(args, node.id, node.componentId));
|
||||
const type = value(args, ["switchType"], "all").toLowerCase();
|
||||
if (type === "title") return object.title;
|
||||
if (type === "pid") return object.pid;
|
||||
if (type === "position" || type === "location") return [object.x, object.y];
|
||||
if (type === "size") return [object.width, object.height];
|
||||
return object;
|
||||
});
|
||||
|
||||
register(["Window_SetPos", "Window_MoveWindow", "Window_MoveWindow_v1", "Window_SizeWindow"], async (_ctx, node, args) => {
|
||||
const title = requiredTitle(args, node.id, node.componentId);
|
||||
const current = await windowObject(title);
|
||||
const moveOnly = node.componentId.includes("MoveWindow");
|
||||
const sizeOnly = node.componentId === "Window_SizeWindow";
|
||||
const position = args.location !== undefined ? parsePoint(args.location, "窗口位置", node.id, node.componentId) : [Number(args.x ?? current.x), Number(args.y ?? current.y)] as [number, number];
|
||||
const sizeCandidate = args.size ?? args.window_size;
|
||||
const size = sizeCandidate !== undefined ? parsePoint(sizeCandidate, "窗口尺寸", node.id, node.componentId) : [Number(args.width ?? current.width), Number(args.height ?? current.height)] as [number, number];
|
||||
const x = sizeOnly ? Number(current.x) : position[0];
|
||||
const y = sizeOnly ? Number(current.y) : position[1];
|
||||
const width = moveOnly ? Number(current.width) : size[0];
|
||||
const height = moveOnly ? Number(current.height) : size[1];
|
||||
if (!(width > 0 && height > 0)) throw new RuntimeError("validation_error", "窗口尺寸必须大于 0", { nodeId: node.id, componentId: node.componentId });
|
||||
await powershell(`${WINDOW_API}; ${findWindow(title)}; [RpaWindowExtended]::SetWindowPos($p.MainWindowHandle,[IntPtr]::Zero,${x},${y},${width},${height},4) | Out-Null`);
|
||||
return { ...(await windowObject(title)), x, y, width, height };
|
||||
});
|
||||
|
||||
const moveMouse: CapabilityHandler = async (_ctx, node, args) => {
|
||||
const [x, y] = parsePoint(args.location ?? args.position, "鼠标坐标", node.id, node.componentId);
|
||||
await powershell(`${WINDOW_API}; [RpaWindowExtended]::SetCursorPos(${x},${y}) | Out-Null`);
|
||||
return { x, y };
|
||||
};
|
||||
register(["Mouse_MoveToV1", "Mouse_MoveToV2"], moveMouse);
|
||||
|
||||
const dragMouse: CapabilityHandler = async (_ctx, node, args) => {
|
||||
const [sx, sy] = parsePoint(args.Start ?? args.start, "起始坐标", node.id, node.componentId);
|
||||
const [ex, ey] = parsePoint(args.End ?? args.end, "结束坐标", node.id, node.componentId);
|
||||
const right = ["1", "right"].includes(value(args, ["side"], "0").toLowerCase());
|
||||
const down = right ? 8 : 2;
|
||||
const up = right ? 16 : 4;
|
||||
await powershell(`${WINDOW_API}; [RpaWindowExtended]::SetCursorPos(${sx},${sy}) | Out-Null; [RpaWindowExtended]::mouse_event(${down},0,0,0,[UIntPtr]::Zero); Start-Sleep -Milliseconds 80; [RpaWindowExtended]::SetCursorPos(${ex},${ey}) | Out-Null; [RpaWindowExtended]::mouse_event(${up},0,0,0,[UIntPtr]::Zero)`);
|
||||
return { start: [sx, sy], end: [ex, ey] };
|
||||
};
|
||||
register(["Mouse_DragToV1", "Mouse_DragToV2", "Mouse_DragToNew"], dragMouse);
|
||||
|
||||
register(["Mouse_ClickCombine", "Mouse_ClickCombine_v1", "Mouse_ClickCombine_v2", "Mouse_ClickCombine_v3", "Mouse_ClickCombine_v4"], async (_ctx, node, args) => {
|
||||
if (value(args, ["target"], "0") !== "0") throw new RuntimeError("validation_error", "组合点击当前仅支持坐标目标", { nodeId: node.id, componentId: node.componentId });
|
||||
const [x, y] = parsePoint(args.location, "点击坐标", node.id, node.componentId);
|
||||
const right = value(args, ["click_key"], "left").toLowerCase() === "right";
|
||||
const doubleClick = value(args, ["click_time"], "click").toLowerCase().includes("double");
|
||||
const down = right ? 8 : 2;
|
||||
const up = right ? 16 : 4;
|
||||
const repeats = doubleClick ? 2 : 1;
|
||||
await powershell(`${WINDOW_API}; [RpaWindowExtended]::SetCursorPos(${x},${y}) | Out-Null; 1..${repeats} | ForEach-Object { [RpaWindowExtended]::mouse_event(${down},0,0,0,[UIntPtr]::Zero); [RpaWindowExtended]::mouse_event(${up},0,0,0,[UIntPtr]::Zero); Start-Sleep -Milliseconds 80 }`);
|
||||
return { x, y, button: right ? "right" : "left", clicks: repeats };
|
||||
});
|
||||
@ -1,112 +1,75 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Cpu, Database, FolderCog, HardDrive, Info } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Cpu, Database, FolderCog, Globe2, HardDrive, Info, ShieldCheck } from "lucide-react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import type { AppInfo, Catalog } from "@shared/types";
|
||||
|
||||
type SettingsTab = "about" | "catalog" | "execution" | "browser";
|
||||
|
||||
export function SettingsPage({ catalog }: { catalog: Catalog }) {
|
||||
const [tab, setTab] = useState<SettingsTab>("about");
|
||||
const [info, setInfo] = useState<AppInfo | null>(null);
|
||||
const [handlers, setHandlers] = useState<{ count: number; ids: string[] } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
void window.rpa.app.info().then(setInfo).catch(() => setInfo(null));
|
||||
void window.rpa.run.capabilities().then(setHandlers).catch(() => setHandlers(null));
|
||||
}, []);
|
||||
|
||||
const executable = catalog.components.filter((item) => item.implemented).length;
|
||||
const groups = useMemo(() => catalog.groups.map((group) => {
|
||||
const components = catalog.components.filter((component) => component.group === group.id);
|
||||
const connected = components.filter((component) => component.implemented).length;
|
||||
return { ...group, connected, pending: components.length - connected };
|
||||
}), [catalog]);
|
||||
|
||||
return (
|
||||
<div className="settings">
|
||||
<aside className="panel setting-nav">
|
||||
<button type="button" className="active">
|
||||
关于与运行环境
|
||||
</button>
|
||||
<button type="button">组件目录</button>
|
||||
<button type="button">执行与证据</button>
|
||||
<button type="button">浏览器白名单</button>
|
||||
<button type="button" className={tab === "about" ? "active" : ""} onClick={() => setTab("about")}>关于与运行环境</button>
|
||||
<button type="button" className={tab === "catalog" ? "active" : ""} onClick={() => setTab("catalog")}>组件目录</button>
|
||||
<button type="button" className={tab === "execution" ? "active" : ""} onClick={() => setTab("execution")}>执行与证据</button>
|
||||
<button type="button" className={tab === "browser" ? "active" : ""} onClick={() => setTab("browser")}>浏览器白名单</button>
|
||||
</aside>
|
||||
|
||||
<section className="panel setting-body">
|
||||
<div className="setting-card">
|
||||
<h3>
|
||||
<Info className="icon" style={{ width: 14, height: 14, display: "inline-block", marginRight: 5 }} />
|
||||
版本与运行环境
|
||||
</h3>
|
||||
<dl className="kv">
|
||||
<dt>应用版本</dt>
|
||||
<dd>{info?.version || "0.1.0"}</dd>
|
||||
<dt>Electron / Chromium</dt>
|
||||
<dd>
|
||||
{info?.electron || "-"} / {info?.chrome || "-"}
|
||||
</dd>
|
||||
<dt>Node</dt>
|
||||
<dd>{info?.node || "-"}</dd>
|
||||
<dt>运行模式</dt>
|
||||
<dd>{info?.packaged ? "打包运行(离线)" : "开发模式"}</dd>
|
||||
<dt>数据目录</dt>
|
||||
<dd>{info?.userData || "-"}</dd>
|
||||
<dt>目录文件</dt>
|
||||
<dd>{info?.catalogPath || "-"}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
{tab === "about" && (
|
||||
<>
|
||||
<SettingCard icon={Info} title="版本与运行环境">
|
||||
<dl className="kv"><dt>应用版本</dt><dd>{info?.version || "0.1.0"}</dd><dt>Electron / Chromium</dt><dd>{info?.electron || "-"} / {info?.chrome || "-"}</dd><dt>Node</dt><dd>{info?.node || "-"}</dd><dt>运行模式</dt><dd>{info?.packaged ? "打包运行(离线)" : "开发模式"}</dd><dt>数据目录</dt><dd>{info?.userData || "-"}</dd><dt>目录文件</dt><dd>{info?.catalogPath || "-"}</dd></dl>
|
||||
</SettingCard>
|
||||
<SettingCard icon={FolderCog} title="产品边界"><p className="field-hint setting-copy">组件元数据来自项目内 1016 项目录。移动端、机械臂、U 盾、远程桌面、SAP/IE 专用通道、云服务、数据库和 FTP 保持待接入,不随当前版本承诺执行。</p></SettingCard>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="setting-card">
|
||||
<h3>
|
||||
<Database className="icon" style={{ width: 14, height: 14, display: "inline-block", marginRight: 5 }} />
|
||||
组件目录
|
||||
</h3>
|
||||
<dl className="kv">
|
||||
<dt>目录总数</dt>
|
||||
<dd>{catalog.counts.total} 项</dd>
|
||||
<dt>厂商组件 / 控制 / 触发</dt>
|
||||
<dd>
|
||||
{catalog.counts.vendor} / {catalog.counts.native} / {catalog.counts.triggers}
|
||||
</dd>
|
||||
<dt>字段总数</dt>
|
||||
<dd>{catalog.counts.fields}</dd>
|
||||
<dt>已接入执行器</dt>
|
||||
<dd>
|
||||
{executable} 项({((executable / catalog.counts.total) * 100).toFixed(1)}%)
|
||||
</dd>
|
||||
<dt>待接入</dt>
|
||||
<dd>{catalog.counts.total - executable} 项(灰标展示)</dd>
|
||||
</dl>
|
||||
</div>
|
||||
{tab === "catalog" && (
|
||||
<>
|
||||
<SettingCard icon={Database} title="组件接入概览">
|
||||
<dl className="kv"><dt>目录总数</dt><dd>{catalog.counts.total} 项</dd><dt>已接入执行器</dt><dd>{executable} 项({((executable / catalog.counts.total) * 100).toFixed(1)}%)</dd><dt>待接入</dt><dd>{catalog.counts.total - executable} 项</dd><dt>注册处理器 ID</dt><dd>{handlers?.count ?? "—"}</dd></dl>
|
||||
</SettingCard>
|
||||
<div className="setting-card"><h3><Database className="icon" />分类覆盖</h3><div className="setting-group-table">{groups.map((group) => <div key={group.id}><strong>{group.label}</strong><span>{group.connected} 已接入</span><span>{group.pending} 待接入</span><progress max={group.count} value={group.connected} /></div>)}</div></div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="setting-card">
|
||||
<h3>
|
||||
<Cpu className="icon" style={{ width: 14, height: 14, display: "inline-block", marginRight: 5 }} />
|
||||
执行方式
|
||||
</h3>
|
||||
<dl className="kv">
|
||||
<dt>执行位置</dt>
|
||||
<dd>本机主进程(无云端依赖)</dd>
|
||||
<dt>浏览器</dt>
|
||||
<dd>内嵌 WebContentsView + CDP(Day 2)</dd>
|
||||
<dt>桌面</dt>
|
||||
<dd>PowerShell 桥:UIA + SendInput(Day 2)</dd>
|
||||
<dt>Office</dt>
|
||||
<dd>exceljs / COM(Day 3)</dd>
|
||||
</dl>
|
||||
</div>
|
||||
{tab === "execution" && (
|
||||
<>
|
||||
<SettingCard icon={Cpu} title="执行方式"><dl className="kv"><dt>执行位置</dt><dd>本机主进程</dd><dt>浏览器</dt><dd>独立 Electron 运行窗口 + CDP</dd><dt>桌面</dt><dd>PowerShell / user32 本地桥</dd><dt>Office</dt><dd>ExcelJS / PDF.js / pdf-lib / Word COM</dd><dt>运行前检查</dt><dd>待接入、必填、多出口、断链</dd></dl></SettingCard>
|
||||
<SettingCard icon={HardDrive} title="数据与证据"><p className="field-hint setting-copy">流程保存到数据目录的 flows/;运行结果、错误和截图写入 runs/<runId>/。任务列表会读取最近一次运行的状态、时间、耗时和失败原因。</p></SettingCard>
|
||||
<SettingCard icon={ShieldCheck} title="安全边界"><p className="field-hint setting-copy">运行期默认离线。桌面动作只在流程显式运行时执行;未接入能力不会静默成功,而是在运行前检查或节点执行时返回明确错误。</p></SettingCard>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="setting-card">
|
||||
<h3>
|
||||
<HardDrive className="icon" style={{ width: 14, height: 14, display: "inline-block", marginRight: 5 }} />
|
||||
数据与证据
|
||||
</h3>
|
||||
<p className="field-hint" style={{ lineHeight: "18px" }}>
|
||||
流程以 JSON 原子写入数据目录;运行记录与截图将写入 runs/<runId>/。运行期不加载 CDN、字体或云服务。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="setting-card">
|
||||
<h3>
|
||||
<FolderCog className="icon" style={{ width: 14, height: 14, display: "inline-block", marginRight: 5 }} />
|
||||
参考与边界
|
||||
</h3>
|
||||
<p className="field-hint" style={{ lineHeight: "18px" }}>
|
||||
组件元数据来自项目内 1016 项目录(997 厂商 + 16 控制 + 3 触发)。厂商代码、二进制与模型不随产品分发;未接入执行器的组件
|
||||
以灰标展示,运行到该节点会明确报错。
|
||||
</p>
|
||||
</div>
|
||||
{tab === "browser" && (
|
||||
<>
|
||||
<SettingCard icon={Globe2} title="浏览器域名白名单"><dl className="kv"><dt>默认允许</dt><dd>127.0.0.1、localhost、example.com</dd><dt>扩展方式</dt><dd>环境变量 RPA_ALLOWED_DOMAINS</dd><dt>格式</dt><dd>逗号分隔域名,不含协议和路径</dd></dl></SettingCard>
|
||||
<SettingCard icon={ShieldCheck} title="限制说明"><p className="field-hint setting-copy">导航只允许 HTTP/HTTPS。未列入白名单的域名会返回 blocked_url;文件协议、脚本协议和任意本地路径不会由浏览器导航组件打开。</p></SettingCard>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SettingCard({ icon: Icon, title, children }: { icon: LucideIcon; title: string; children: ReactNode }) {
|
||||
return <div className="setting-card"><h3><Icon className="icon" />{title}</h3>{children}</div>;
|
||||
}
|
||||
|
||||
@ -1653,6 +1653,39 @@ textarea:focus-visible {
|
||||
margin-bottom: 9px;
|
||||
}
|
||||
|
||||
.setting-card h3 .icon {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
display: inline-block;
|
||||
margin-right: 6px;
|
||||
vertical-align: -2px;
|
||||
}
|
||||
|
||||
.setting-copy {
|
||||
line-height: 19px;
|
||||
max-width: 880px;
|
||||
}
|
||||
|
||||
.setting-group-table {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.setting-group-table > div {
|
||||
min-height: 42px;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(150px, 1fr) 90px 90px minmax(120px, 1fr);
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid var(--line);
|
||||
font-size: 10.5px;
|
||||
}
|
||||
|
||||
.setting-group-table > div:last-child { border-bottom: 0; }
|
||||
.setting-group-table strong { font-size: 11px; }
|
||||
.setting-group-table span { color: var(--muted); }
|
||||
.setting-group-table progress { width: 100%; height: 7px; accent-color: var(--blue); }
|
||||
|
||||
.kv {
|
||||
display: grid;
|
||||
grid-template-columns: 150px minmax(0, 1fr);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user