fix(应用与系统)!: 修复组件执行与参数校验

修正 PID 空结果、目标类型判断与转换、毫秒等待。
让关闭程序和命令行按所选模式显示并校验参数。
统一 PowerShell 输出编码,修正 BAT 路径引号处理。
明确屏保和屏幕唤醒的实际能力,补充缺陷回归测试。

验证:类型检查、构建与组件回归检查通过。

BREAKING CHANGE: 判断数据类型改为返回布尔值。
需要类型名称的旧流程请改用 Basic_Type。
屏保 V1 移除未实现参数,旧自定义密码节点需重新添加。
This commit is contained in:
liuwenxin 2026-11-02 09:05:48 +08:00
parent f74d1865da
commit 5f9e28715c
16 changed files with 402 additions and 144 deletions

View File

@ -0,0 +1,68 @@
{
"Basic_JudgeDataType": {
"description": "按所选目标类型判断数据,返回布尔值 true 或 false。数值判断区分数字与数字文本。",
"fields": {
"into_type": {
"defaultValue": "number",
"description": "选择要判断的目标类型。历史默认值 1 兼容数值类型。",
"options": [
{ "label": "数值", "value": "number" },
{ "label": "整数", "value": "int" },
{ "label": "空值", "value": "null" },
{ "label": "字典", "value": "dict" },
{ "label": "数组", "value": "list" },
{ "label": "文本", "value": "string" },
{ "label": "布尔", "value": "bool" }
]
}
}
},
"Basic_DataTypeConversion": {
"description": "将数据转换为所选的数值、整数、布尔或文本类型;无法转换时给出参数错误。",
"fields": {
"into_type": {
"defaultValue": "number",
"description": "选择目标类型。历史默认值 1 兼容数值;其他数字别名 2=整数、3=布尔、4=文本。",
"options": [
{ "label": "数值", "value": "number" },
{ "label": "整数", "value": "int" },
{ "label": "布尔", "value": "bool" },
{ "label": "文本", "value": "string" }
]
}
}
},
"App_KillV1": {
"fields": {
"close_type": {
"options": [
{ "label": "按进程 PID", "value": "id" },
{ "label": "按进程名称", "value": "name" }
]
}
}
},
"System_ExecuteCommandV1": {
"fields": {
"command_type": {
"label": "命令类型",
"options": [
{ "label": "DOS 命令", "value": "1" },
{ "label": "BAT 文件", "value": "2" },
{ "label": "PowerShell 命令", "value": "3" }
]
}
}
},
"System_ScreenSaverShowV1": {
"description": "开启 Windows 已配置的系统屏保。当前组件不支持自定义屏保文字、透明度或解锁密码;登录保护遵循 Windows 系统设置。",
"messageTemplate": "开启 Windows 系统屏保",
"removeFields": ["text", "effect", "status", "set_password", "password"]
},
"ScreenControl_ScreenUnlock": {
"name": "唤醒屏幕(非登录解锁)",
"description": "仅尝试唤醒屏幕,不支持通过账号密码自动完成 Windows 登录解锁。锁屏后仍需本人手动登录。",
"messageTemplate": "尝试唤醒屏幕;Windows 登录锁屏需人工解锁",
"removeFields": ["username", "pwd"]
}
}

View File

@ -10,7 +10,7 @@
"vendor": 997,
"native": 16,
"triggers": 3,
"fields": 4300
"fields": 4293
},
"groups": [
{
@ -1633,7 +1633,17 @@
"placeholder": "",
"description": "通过进程PID、进程名称来终止进程",
"advanced": false,
"valueTypes": []
"valueTypes": [],
"options": [
{
"label": "按进程 PID",
"value": "id"
},
{
"label": "按进程名称",
"value": "name"
}
]
},
{
"key": "pid",
@ -3565,7 +3575,7 @@
"base_command"
],
"categorySignature": "judgment|base_command",
"description": "判断指定数据是否是数值、空值、字典、类型和数组,并返回true & false后保存。",
"description": "按所选目标类型判断数据,返回布尔值 true 或 false。数值判断区分数字与数字文本。",
"messageTemplate": "判断数据 $1 的类型, 然后把结果保存到 $0",
"baseType": "Basic_JudgeDataType",
"functionName": "Basic.JudgeDataType",
@ -3622,11 +3632,41 @@
"type": "string",
"displayType": "dropDownSelect",
"required": false,
"defaultValue": "1",
"defaultValue": "number",
"placeholder": "",
"description": "选择待判断目标数据的数据类型",
"description": "选择要判断的目标类型。历史默认值 1 兼容数值类型。",
"advanced": false,
"valueTypes": []
"valueTypes": [],
"options": [
{
"label": "数值",
"value": "number"
},
{
"label": "整数",
"value": "int"
},
{
"label": "空值",
"value": "null"
},
{
"label": "字典",
"value": "dict"
},
{
"label": "数组",
"value": "list"
},
{
"label": "文本",
"value": "string"
},
{
"label": "布尔",
"value": "bool"
}
]
}
],
"output": {
@ -3658,7 +3698,7 @@
"data_type_conversion"
],
"categorySignature": "data_type_conversion",
"description": "将指定的数据转为需要的类型(布尔、整数、数值、文字)后保存。",
"description": "将数据转换为所选的数值、整数、布尔或文本类型;无法转换时给出参数错误。",
"messageTemplate": "将待转换数据 $1 转换为 $2, 然后把结果保存到 $0",
"baseType": "Basic_DataTypeConversion",
"functionName": "Basic.DataTypeConversion",
@ -3714,11 +3754,29 @@
"type": "string",
"displayType": "dropDownSelect",
"required": false,
"defaultValue": "1",
"defaultValue": "number",
"placeholder": "",
"description": "Python 的基本数据类型选择。",
"description": "选择目标类型。历史默认值 1 兼容数值;其他数字别名 2=整数、3=布尔、4=文本。",
"advanced": false,
"valueTypes": []
"valueTypes": [],
"options": [
{
"label": "数值",
"value": "number"
},
{
"label": "整数",
"value": "int"
},
{
"label": "布尔",
"value": "bool"
},
{
"label": "文本",
"value": "string"
}
]
}
],
"output": {
@ -92484,15 +92542,15 @@
},
{
"id": "ScreenControl_ScreenUnlock",
"name": "屏幕解锁",
"name": "唤醒屏幕(非登录解锁)",
"kind": "vendor",
"group": "app",
"categories": [
"ScreenControl"
],
"categorySignature": "ScreenControl",
"description": "通过用户名,密码进行屏幕解锁。",
"messageTemplate": "通过用户名 $1 ,密码 $2进行屏幕解锁, 然后把结果保存到 $0",
"description": "仅尝试唤醒屏幕,不支持通过账号密码自动完成 Windows 登录解锁。锁屏后仍需本人手动登录。",
"messageTemplate": "尝试唤醒屏幕;Windows 登录锁屏需人工解锁",
"baseType": "ScreenControl_ScreenUnlock",
"functionName": "ScreenControl.ScreenUnlock",
"functionCalls": [
@ -92514,39 +92572,7 @@
"platformTag": null,
"icon": "app",
"risk": "high",
"fields": [
{
"key": "username",
"label": "用户名",
"widget": "text",
"type": "string",
"displayType": "text-expression",
"required": true,
"defaultValue": "",
"placeholder": "请输入屏幕解锁需要的用户名",
"description": "",
"advanced": false,
"valueTypes": [
"rpa_str"
]
},
{
"key": "pwd",
"label": "密码",
"widget": "secret",
"type": "string",
"displayType": "encryption",
"required": true,
"defaultValue": "",
"placeholder": "请输入屏幕解锁需要的密码",
"description": "",
"advanced": false,
"valueTypes": [
"rpa_password",
"rpa_str"
]
}
],
"fields": [],
"output": {
"label": "输出到变量",
"key": "var_name",
@ -96317,7 +96343,7 @@
"fields": [
{
"key": "command_type",
"label": "command_type",
"label": "命令类型",
"widget": "radio",
"type": "string",
"displayType": "buttonGroup",
@ -96326,7 +96352,21 @@
"placeholder": "",
"description": "",
"advanced": false,
"valueTypes": []
"valueTypes": [],
"options": [
{
"label": "DOS 命令",
"value": "1"
},
{
"label": "BAT 文件",
"value": "2"
},
{
"label": "PowerShell 命令",
"value": "3"
}
]
},
{
"key": "dos_command",
@ -96800,8 +96840,8 @@
"ScreenSaver"
],
"categorySignature": "ScreenSaver",
"description": "开启屏幕保护,防止隐私数据被偷窥。",
"messageTemplate": "屏保文字为 $0",
"description": "开启 Windows 已配置的系统屏保。当前组件不支持自定义屏保文字、透明度或解锁密码;登录保护遵循 Windows 系统设置。",
"messageTemplate": "开启 Windows 系统屏保",
"baseType": "System_ScreenSaverShow",
"functionName": "System.ScreenSaverShowV1",
"functionCalls": [
@ -96826,77 +96866,7 @@
"platformTag": null,
"icon": "app",
"risk": "high",
"fields": [
{
"key": "text",
"label": "屏保文字",
"widget": "text",
"type": "string",
"displayType": "text-expression",
"required": false,
"defaultValue": "",
"placeholder": "请输入屏保文字",
"description": "展示在屏保界面上的文字。",
"advanced": false,
"valueTypes": [
"rpa_str"
]
},
{
"key": "effect",
"label": "屏幕透明度",
"widget": "radio",
"type": "string",
"displayType": "radioGroup",
"required": false,
"defaultValue": "2",
"placeholder": "",
"description": "设置屏保的模糊、透明效果。",
"advanced": false,
"valueTypes": []
},
{
"key": "status",
"label": "流程暂停或结束时",
"widget": "radio",
"type": "string",
"displayType": "radioGroup",
"required": false,
"defaultValue": "1",
"placeholder": "",
"description": "运行结束时选择自动关闭屏保,还是手动输入密码关闭屏保。",
"advanced": false,
"valueTypes": []
},
{
"key": "set_password",
"label": "设置密码解锁",
"widget": "radio",
"type": "string",
"displayType": "radioGroup",
"required": false,
"defaultValue": "0",
"placeholder": "",
"description": "可设置解锁密码为系统密码或自定义密码,密码字符上限20",
"advanced": false,
"valueTypes": []
},
{
"key": "password",
"label": "password",
"widget": "text",
"type": "string",
"displayType": "text-expression",
"required": true,
"defaultValue": "",
"placeholder": "请输入自定义密码",
"description": "",
"advanced": false,
"valueTypes": [
"rpa_str"
]
}
],
"fields": [],
"output": {
"label": "",
"key": "",

View File

@ -12,6 +12,7 @@
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { applyCatalogOverrides } from "./catalog-overrides.mjs";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(__dirname, "..");
@ -336,7 +337,7 @@ function main() {
};
fs.mkdirSync(path.dirname(OUT_FILE), { recursive: true });
fs.writeFileSync(OUT_FILE, JSON.stringify(catalog), "utf8");
fs.writeFileSync(OUT_FILE, JSON.stringify(applyCatalogOverrides(catalog)), "utf8");
const size = fs.statSync(OUT_FILE).size;
console.log(`catalog: ${counts.total} components (${counts.vendor} vendor + ${counts.native} native + ${counts.triggers} triggers)`);
console.log(`fields: ${counts.fields}; groups: ${catalog.groups.map((g) => `${g.id}:${g.count}`).join(",")}`);

View File

@ -0,0 +1,17 @@
import fs from "node:fs";
const overrides = JSON.parse(fs.readFileSync(new URL("../resources/catalog-overrides.json", import.meta.url), "utf8"));
/** 把本项目已实现的参数契约覆盖到派生目录,避免重新生成时恢复旧的错误元数据。 */
export function applyCatalogOverrides(catalog) {
for (const component of catalog.components) {
const override = overrides[component.id];
if (!override) continue;
const { fields = {}, removeFields = [], ...metadata } = override;
Object.assign(component, metadata);
component.fields = component.fields.filter(field => !removeFields.includes(field.key))
.map(field => ({ ...field, ...fields[field.key] }));
}
catalog.counts.fields = catalog.components.reduce((count, component) => count + component.fields.length, 0);
return catalog;
}

View File

@ -0,0 +1,85 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import type { CatalogComponent, FlowNode } from "../../shared/types";
import type { RunContext } from "../engine/types";
import { getHandler } from "../engine/registry";
const mocks = vi.hoisted(() => ({ powershell: vi.fn(), execFile: vi.fn() }));
vi.mock("./desktop", () => ({ powershell: mocks.powershell, psString: (value: unknown) => `'${String(value ?? "").replace(/'/g, "''")}'` }));
vi.mock("node:child_process", () => ({ execFile: mocks.execFile }));
import "./data";
import "./control";
import "./system-extended";
import "./windows-system";
function run(id: string, args: Record<string, unknown> = {}, signal = new AbortController().signal) {
const node: FlowNode = { id: "regression", componentId: id, values: args, options: {}, position: { x: 0, y: 0 } };
return getHandler(id)!({ signal, log: vi.fn(), vars: {} } as unknown as RunContext, node, args, {} as CatalogComponent);
}
afterEach(() => { vi.useRealTimers(); vi.resetAllMocks(); });
describe("应用与系统组件缺陷回归", () => {
it("没有匹配进程时返回 [] / null,忽略空行和非法 PID", async () => {
mocks.powershell.mockResolvedValue("");
await expect(run("App_GetPids", { pattern: "missing.exe" })).resolves.toEqual([]);
await expect(run("App_GetPid", { pattern: "missing.exe" })).resolves.toBeNull();
mocks.powershell.mockResolvedValue("\r\n0\r\n123\r\nabc\r\n456\r\n");
await expect(run("App_GetPids", { pattern: "fixture.exe" })).resolves.toEqual([123, 456]);
});
it("毫秒等待读取 mileSecond,计时和取消均正确", async () => {
vi.useFakeTimers();
const waiting = Promise.resolve(run("Basic_Delay", { mileSecond: "250" }));
let done = false;
void waiting.then(() => { done = true; });
await vi.advanceTimersByTimeAsync(249);
expect(done).toBe(false);
await vi.advanceTimersByTimeAsync(1);
expect(done).toBe(true);
const controller = new AbortController();
const cancelled = Promise.resolve(run("Basic_Delay", { mileSecond: "3000" }, controller.signal));
const rejection = expect(cancelled).rejects.toMatchObject({ code: "cancelled" });
controller.abort();
await rejection;
});
it.each([
[42, "number", true], ["42", "number", false], [42, "1", true],
[[], "list", true], [{ key: 1 }, "dict", true], [null, "null", true],
[false, "bool", true], ["中文", "string", true], [3.5, "int", false]
])("按目标类型判断 %j → %s", (val, into_type, expected) => {
expect(run("Basic_JudgeDataType", { val, into_type })).toBe(expected);
});
it.each([
["42.5", "number", 42.5], ["42", "1", 42], ["-3.9", "int", -3],
["false", "bool", false], ["true", "bool", true], [42, "string", "42"]
])("执行类型转换 %j → %s", (val, into_type, expected) => {
expect(run("Basic_DataTypeConversion", { val, into_type })).toBe(expected);
});
it("非法数值或未知目标类型给出定位错误,避免静默返回原值", () => {
expect(() => run("Basic_DataTypeConversion", { val: "abc", into_type: "int" })).toThrow();
expect(() => run("Basic_DataTypeConversion", { val: 1, into_type: "unknown" })).toThrow();
expect(() => run("Basic_JudgeDataType", { val: 1, into_type: "unknown" })).toThrow();
});
it("关闭组件按所选模式执行,非法 PID 不降级为按名称批量关闭", async () => {
mocks.powershell.mockResolvedValue("");
await expect(run("App_KillV1", { close_type: "id", pid: "invalid", p_name: "fixture.exe" })).rejects.toMatchObject({ code: "validation_error" });
expect(mocks.powershell).not.toHaveBeenCalled();
await run("App_KillV1", { close_type: "name", pid: 123, p_name: "fixture.exe", kill_all: "0" });
expect(mocks.powershell.mock.calls[0][0]).toContain("SessionId");
expect(mocks.powershell.mock.calls[0][0]).not.toContain("Stop-Process -Id 123");
});
it("唤醒不声称已通过 Windows 登录解锁", async () => {
mocks.execFile.mockImplementation((_file, _args, _opts, callback) => callback(null, { stdout: "", stderr: "" }));
await expect(run("ScreenControl_ScreenUnlock")).resolves.toMatchObject({ awakened: true, loginUnlockSupported: false });
});
it("屏保自定义密码不受支持时,在触发桌面操作前明确报错", async () => {
await expect(run("System_ScreenSaverShowV1", { set_password: "1", password: "" })).rejects.toMatchObject({ code: "validation_error" });
expect(mocks.execFile).not.toHaveBeenCalled();
});
});

View File

@ -81,6 +81,7 @@ describe("capability registration smoke", () => {
const execute = getHandler("System_ExecuteCommand");
expect(execute).toBeTruthy();
await expect(execute!(context, node("System_ExecuteCommand"), { action: "Write-Output smoke" }, component)).resolves.toContain("smoke");
await expect(execute!(context, node("System_ExecuteCommand"), { action: "Write-Output '中文输出测试'" }, component)).resolves.toBe("中文输出测试");
});
it("finds an exact PNG template inside a larger screenshot", async () => {

View File

@ -30,12 +30,21 @@ register("Basic_Delay_V2", async (ctx, node, args) => {
return seconds;
});
register(["Basic_Delay_V1", "Basic_Delay"], async (ctx, node, args) => {
register("Basic_Delay_V1", async (ctx, node, args) => {
const seconds = toNumber(args.second ?? args.time ?? 1, 1);
await sleep(Math.max(0, seconds) * 1000, ctx.signal);
return seconds;
});
register("Basic_Delay", async (ctx, node, args) => {
const milliseconds = Number(args.mileSecond ?? 1000);
if (!Number.isFinite(milliseconds) || milliseconds < 0 || milliseconds > 2_147_483_647) {
throw new RuntimeError("validation_error", "延时时间必须是 0~2147483647 范围内的毫秒数", { nodeId: node.id, componentId: node.componentId });
}
await sleep(milliseconds, ctx.signal);
return milliseconds / 1000;
});
register("Basic_DebugOutput", (ctx, node, args) => {
const message = String(args.msg ?? args.message ?? "");
ctx.log(message || "(空)", "info", node.id);

View File

@ -498,12 +498,30 @@ register("Basic_Type", (_ctx, _node, args) => {
if (Array.isArray(value)) return "list";
return typeof value;
});
register("Basic_JudgeDataType", (_ctx, _node, args) => {
// 原始目录缺少选项映射。新界面使用语义值;数字别名兼容此前默认的 "1"。
const JUDGE_TYPE_ALIASES: Record<string, string> = { "1": "number", "2": "null", "3": "dict", "4": "string", "5": "list", "6": "bool", "7": "int" };
const CONVERT_TYPE_ALIASES: Record<string, string> = { "1": "number", "2": "int", "3": "bool", "4": "string" };
function targetType(raw: unknown, aliases: Record<string, string>, supported: string[], nodeId: string, componentId: string): string {
const text = String(raw ?? "number").trim().toLowerCase();
const synonyms: Record<string, string> = { float: "number", boolean: "bool", str: "string", text: "string", array: "list", dictionary: "dict", object: "dict", empty: "null" };
const type = aliases[text] || synonyms[text] || text;
if (!supported.includes(type)) throw new RuntimeError("validation_error", "目标数据类型无效,请重新选择类型", { nodeId, componentId });
return type;
}
register("Basic_JudgeDataType", (_ctx, node, args) => {
const value = first(args, ["val"]);
if (value === null || value === undefined) return "null";
if (Array.isArray(value)) return "list";
if (typeof value === "object") return "dict";
return typeof value;
const type = targetType(args.into_type, JUDGE_TYPE_ALIASES, ["number", "null", "dict", "string", "list", "bool", "int"], node.id, node.componentId);
switch (type) {
case "number": return typeof value === "number" && Number.isFinite(value);
case "int": return typeof value === "number" && Number.isInteger(value);
case "null": return value === null || value === undefined || value === "";
case "dict": return Boolean(value) && typeof value === "object" && !Array.isArray(value);
case "string": return typeof value === "string";
case "list": return Array.isArray(value);
case "bool": return typeof value === "boolean";
}
});
register("Basic_Clone", (_ctx, _node, args) => structuredClone(first(args, ["val"])));
register("Basic_RandomInt", (_ctx, _node, args) => {
@ -512,4 +530,23 @@ register("Basic_RandomInt", (_ctx, _node, args) => {
return Math.floor(min + Math.random() * (max - min + 1));
});
register("Basic_Rnd", () => Math.random());
register("Basic_DataTypeConversion", (_ctx, _node, args) => first(args, ["val"]));
register("Basic_DataTypeConversion", (_ctx, node, args) => {
const value = first(args, ["val"]);
const type = targetType(args.into_type, CONVERT_TYPE_ALIASES, ["number", "int", "bool", "string"], node.id, node.componentId);
if (type === "string") return str(value);
if (type === "bool") {
if (value == null || value === "") return false;
if (typeof value === "boolean") return value;
if (typeof value === "number" && Number.isFinite(value)) return value !== 0;
const text = String(value).trim().toLowerCase();
if (["true", "1", "是", "开启"].includes(text)) return true;
if (["false", "0", "否", "关闭"].includes(text)) return false;
throw new RuntimeError("validation_error", "输入值无法转换为布尔类型", { nodeId: node.id, componentId: node.componentId });
}
if ((typeof value !== "number" && typeof value !== "string") || (typeof value === "string" && !value.trim()) || !Number.isFinite(Number(value))) {
throw new RuntimeError("validation_error", "输入值不是有效数字", { nodeId: node.id, componentId: node.componentId });
}
const result = type === "int" ? Math.trunc(Number(value)) : Number(value);
if (type === "int" && !Number.isSafeInteger(result)) throw new RuntimeError("validation_error", "转换后的整数超出安全范围", { nodeId: node.id, componentId: node.componentId });
return result;
});

View File

@ -45,7 +45,9 @@ function encoded(command: string): string {
export async function powershell(command: string, timeout = 30000): Promise<string> {
try {
const result = await execFileAsync("powershell.exe", ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-EncodedCommand", encoded(command)], {
// Windows PowerShell 的默认输出编码依机器代码页变化;Node 按 UTF-8 解码管道。
const utf8 = "$ProgressPreference='SilentlyContinue'; [Console]::OutputEncoding=[System.Text.UTF8Encoding]::new($false); $OutputEncoding=[Console]::OutputEncoding; ";
const result = await execFileAsync("powershell.exe", ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-EncodedCommand", encoded(utf8 + command)], {
windowsHide: true,
timeout,
maxBuffer: 4 * 1024 * 1024

View File

@ -75,21 +75,30 @@ register("System_GetSystemPath", (_ctx, _node, args) => {
const getPids: CapabilityHandler = async (_ctx, node, args) => {
const pattern = processPattern(required(args, ["pattern", "p_name", "name"], "进程名称", node.id, node.componentId));
const output = await powershell(`Get-Process | Where-Object { $_.ProcessName -like ${psString(pattern)} } | Select-Object -ExpandProperty Id`);
return output.split(/\r?\n/).map(Number).filter(Number.isFinite);
return output.split(/\r?\n/).map((line) => line.trim()).filter(Boolean)
.map(Number).filter((pid) => Number.isSafeInteger(pid) && pid > 0);
};
register("App_GetPids", getPids);
register("App_GetPid", async (ctx, node, args, component) => (await getPids(ctx, node, args, component) as number[])[0] ?? null);
register(["App_Kill", "App_KillV1"], async (_ctx, node, args) => {
const pid = Number(value(args, ["pid"]));
const name = value(args, ["p_name", "pattern"]);
if (Number.isFinite(pid) && pid > 0) {
const mode = node.componentId === "App_Kill" ? "id" : value(args, ["close_type"], "id");
if (mode === "id") {
const rawPid = args.pid;
const pid = typeof rawPid === "number" || typeof rawPid === "string" ? Number(rawPid) : NaN;
if (!Number.isSafeInteger(pid) || pid <= 0) {
throw new RuntimeError("validation_error", "进程 PID 必须是正整数", { nodeId: node.id, componentId: node.componentId });
}
if (pid === process.pid) throw new RuntimeError("validation_error", "拒绝关闭 RPA Studio 自身进程", { nodeId: node.id, componentId: node.componentId });
await powershell(`Stop-Process -Id ${pid} -Force -ErrorAction Stop`);
return pid;
}
const pattern = processPattern(required({ name }, ["name"], "进程名称", node.id, node.componentId));
await powershell(`Get-Process | Where-Object { $_.ProcessName -like ${psString(pattern)} } | Stop-Process -Force -ErrorAction Stop`);
if (mode !== "name") throw new RuntimeError("validation_error", "请选择按 PID 或按进程名称关闭", { nodeId: node.id, componentId: node.componentId });
const pattern = processPattern(required(args, ["p_name", "pattern"], "进程名称", node.id, node.componentId));
if (pattern === "*") throw new RuntimeError("validation_error", "关闭应用程序必须指定具体的进程名称", { nodeId: node.id, componentId: node.componentId });
const allSessions = [true, 1, "1", "true", "True"].includes(args.kill_all as never);
const session = allSessions ? "" : " -and $_.SessionId -eq [System.Diagnostics.Process]::GetCurrentProcess().SessionId";
await powershell(`Get-Process | Where-Object { $_.ProcessName -like ${psString(pattern)} -and $_.Id -ne ${process.pid}${session} } | Stop-Process -Force -ErrorAction Stop`);
return pattern;
});

View File

@ -79,6 +79,20 @@ describe("utility extended capabilities", () => {
await expect(run("System_ExecuteCommandV1", args)).resolves.toContain("utility-ok");
});
it("executes BAT files whose path contains spaces and Chinese characters", async () => {
if (process.platform !== "win32") return;
const directory = await mkdtemp(path.join(os.tmpdir(), "rpa-utility-中文 空格-"));
directories.push(directory);
const file = path.join(directory, "测试 脚本.bat");
await writeFile(file, "@echo RPA-BAT-PATH-OK\r\n", "ascii");
await expect(run("System_ExecuteCommandV1", { command_type: "2", bat_file: file, get_ret: "1" })).resolves.toBe("RPA-BAT-PATH-OK");
});
it("keeps PowerShell command output in UTF-8", async () => {
if (process.platform !== "win32") return;
await expect(run("System_ExecuteCommandV1", { command_type: "3", ps_command: "Write-Output '中文输出测试'", get_ret: "1" })).resolves.toBe("中文输出测试");
});
it("compares local text files with structured line changes", async () => {
const directory = await mkdtemp(path.join(os.tmpdir(), "rpa-utility-"));
directories.push(directory);

View File

@ -252,6 +252,7 @@ register("Basic_GetProcessInfo", (ctx) => ({
register("System_ExecuteCommandV1", async (_ctx, node, args) => {
const mode = String(args.command_type || "1");
if (!["1", "2", "3"].includes(mode)) throw new RuntimeError("validation_error", "请选择 DOS、BAT 或 PowerShell 命令类型", { nodeId: node.id, componentId: node.componentId });
const cwd = String(args.cwd || "").trim() || undefined;
const timeout = Math.max(1_000, Number(args.timeoutMs || 120_000));
let executable: string;
@ -264,7 +265,9 @@ register("System_ExecuteCommandV1", async (_ctx, node, args) => {
commandArgs = ["/d", "/s", "/c", `call "${file}"`];
} else if (mode === "3") {
executable = "powershell.exe";
commandArgs = ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", requiredText(args.ps_command, "PowerShell命令", node.id, node.componentId)];
const utf8 = "$ProgressPreference='SilentlyContinue'; [Console]::OutputEncoding=[System.Text.UTF8Encoding]::new($false); $OutputEncoding=[Console]::OutputEncoding; ";
const script = utf8 + requiredText(args.ps_command, "PowerShell命令", node.id, node.componentId);
commandArgs = ["-NoLogo", "-NoProfile", "-NonInteractive", "-EncodedCommand", Buffer.from(script, "utf16le").toString("base64")];
} else {
executable = process.env.ComSpec || "cmd.exe";
commandArgs = ["/d", "/s", "/c", requiredText(args.dos_command, "DOS命令", node.id, node.componentId)];
@ -276,7 +279,8 @@ register("System_ExecuteCommandV1", async (_ctx, node, args) => {
executable = "/bin/sh";
commandArgs = ["-lc", command];
}
const result = await execFileAsync(executable, commandArgs, { cwd, timeout, windowsHide: true, maxBuffer: 10 * 1024 * 1024 });
// cmd.exe 自己解析 /c 后的命令;不能让 Node 再把 BAT 路径引号转成反斜杠引号。
const result = await execFileAsync(executable, commandArgs, { cwd, timeout, windowsHide: true, windowsVerbatimArguments: process.platform === "win32" && mode !== "3", maxBuffer: 10 * 1024 * 1024 });
return bool(args.get_ret, true) ? String(result.stdout).trimEnd() : true;
});

View File

@ -22,13 +22,16 @@ register("ScreenControl_Screenlock", async () => {
register("ScreenControl_ScreenUnlock", async () => {
// 仅唤醒屏幕/解除屏保;系统锁屏需人工输入密码,无法通用自动解锁
await ps("Add-Type -MemberDefinition '[DllImport(\"user32.dll\")] public static extern void mouse_event(uint f, uint dx, uint dy, uint d, UIntPtr e);' -Name W32U -Namespace WinRpa | Out-Null; [WinRpa.W32U]::mouse_event(0x0001, 1, 1, 0, [UIntPtr]::Zero); Start-Sleep -Milliseconds 200; [WinRpa.W32U]::mouse_event(0x0001, 0xFFFFFFFF, 0, 0, [UIntPtr]::Zero)");
return { awakened: true, note: "已唤醒屏幕;锁屏需人工输入密码" };
return { awakened: true, loginUnlockSupported: false, note: "仅尝试唤醒屏幕;Windows 登录锁屏仍需本人手动解锁" };
});
const SC_SCREENSAVE = 0xF140;
const WM_SYSCOMMAND = 0x0112;
register(["System_ScreenSaverShow", "System_ScreenSaverShowV1"], async () => {
await ps("Add-Type -MemberDefinition '[DllImport(\"user32.dll\")] public static extern IntPtr FindWindow(string cls, string win); [DllImport(\"user32.dll\")] public static extern IntPtr SendMessage(IntPtr h, uint m, IntPtr w, IntPtr l);' -Name W32S -Namespace WinRpa | Out-Null; [WinRpa.W32S]::SendMessage([IntPtr]0xFFFF, [UIntPtr]0x0112, [IntPtr]0xF140, [IntPtr]::Zero) | Out-Null");
register(["System_ScreenSaverShow", "System_ScreenSaverShowV1"], async (_ctx, node, args) => {
if ([true, 1, "1", "true", "True"].includes(args.set_password as never)) {
throw new RuntimeError("validation_error", "系统屏保不支持设置自定义解锁密码,请关闭该选项并使用 Windows 原有登录保护", { nodeId: node.id, componentId: node.componentId });
}
await ps("Add-Type -MemberDefinition '[DllImport(\"user32.dll\")] public static extern IntPtr SendMessage(IntPtr h, uint m, IntPtr w, IntPtr l);' -Name W32S -Namespace WinRpa | Out-Null; [WinRpa.W32S]::SendMessage([IntPtr]0xFFFF, [uint32]0x0112, [IntPtr]0xF140, [IntPtr]::Zero) | Out-Null");
return { screensaver: true };
});

View File

@ -17,6 +17,7 @@ import type { Catalog, CatalogField, FlowNode, FlowVariable } from "@shared/type
import { useEditorStore } from "../store/editorStore";
import { groupIcon } from "../components/icons";
import { containerSlots } from "../canvas/ContainerNode";
import { fieldIsActive } from "../validation/flowValidation";
type Tab = "props" | "vars" | "run";
@ -248,8 +249,9 @@ function PropertyTab({
onToast: (message: string, kind?: "ok" | "error") => void;
}) {
const [advancedOpen, setAdvancedOpen] = useState(false);
const basic = component.fields.filter((field) => !field.advanced && field.widget !== "hidden");
const advanced = component.fields.filter((field) => field.advanced && field.widget !== "hidden");
const active = component.fields.filter((field) => field.widget !== "hidden" && fieldIsActive(component.id, field.key, node.values));
const basic = active.filter((field) => !field.advanced);
const advanced = active.filter((field) => field.advanced);
const slots = component.slots.length > 0 ? containerSlots(component) : [];
return (

View File

@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import type { Catalog, FlowDefinition } from "@shared/types";
import { validateFlow } from "./flowValidation";
import { fieldIsActive, validateFlow } from "./flowValidation";
const catalog = {
schemaVersion: 1, generatedAt: "", counts: { total: 2, vendor: 1, native: 0, triggers: 1 }, groups: [],
@ -20,6 +20,24 @@ function flow(): FlowDefinition {
}
describe("flow validation", () => {
it("关闭程序按模式仅显示并校验 PID 或进程名称", () => {
expect(fieldIsActive("App_KillV1", "pid", { close_type: "id" })).toBe(true);
expect(fieldIsActive("App_KillV1", "p_name", { close_type: "id" })).toBe(false);
expect(fieldIsActive("App_KillV1", "pid", { close_type: "name" })).toBe(false);
expect(fieldIsActive("App_KillV1", "p_name", { close_type: "name" })).toBe(true);
});
it.each(["1", "2", "3"])("命令模式 %s 仅显示并校验当前命令输入", (mode) => {
const fields = ["dos_command", "bat_file", "ps_command"];
const active = fields.filter(key => fieldIsActive("System_ExecuteCommandV1", key, { command_type: mode }));
expect(active).toEqual([fields[Number(mode) - 1]]);
});
it("旧屏保元数据不再要求未启用的密码", () => {
expect(fieldIsActive("System_ScreenSaverShowV1", "password", { set_password: "0" })).toBe(false);
expect(fieldIsActive("System_ScreenSaverShowV1", "password", { set_password: "1" })).toBe(true);
});
it("reports pending components, required fields, and disconnected nodes", () => {
const issues = validateFlow(flow(), catalog);
expect(issues).toEqual(expect.arrayContaining([

View File

@ -15,7 +15,25 @@ function truthy(value: unknown): boolean {
return [true, 1, "1", "true", "True", "是", "开启"].includes(value as never);
}
/** 属性显示与必填校验使用相同的模式条件,切换模式时保留未激活字段的旧值。 */
export function fieldIsActive(componentId: string, key: string, values: Record<string, unknown>): boolean {
if (componentId === "App_KillV1") {
const mode = String(values.close_type ?? "id");
if (key === "pid") return mode === "id";
if (key === "p_name" || key === "kill_all") return mode === "name";
}
if (componentId === "System_ExecuteCommandV1") {
const mode = String(values.command_type ?? "1");
if (["dos_command", "bat_file", "ps_command"].includes(key)) {
return key === ({ "1": "dos_command", "2": "bat_file", "3": "ps_command" } as Record<string, string>)[mode];
}
}
if (componentId === "System_ScreenSaverShowV1" && key === "password") return truthy(values.set_password);
return true;
}
function requiredInContext(componentId: string, key: string, values: Record<string, unknown>): boolean {
if (!fieldIsActive(componentId, key, values)) return false;
if (key === "designated_obj" && !truthy(values.designated_info)) return false;
if (key === "cvSelector" && String(values.choose ?? "0") !== "1") return false;
if (componentId === "Image_Screenshot" && ["image_path", "outImagePath", "outImageName"].includes(key)) return false;