This commit is contained in:
yu 2026-11-03 13:46:49 +08:00
commit 6b54decfb0
5 changed files with 148 additions and 154 deletions

View File

@ -3,9 +3,9 @@
"generatedAt": "2026-11-02T04:56:17.341Z",
"policy": "同名能力按目标域、协议和最新版本保留主能力;重复旧版本仅隐藏,不删除执行器",
"editableBefore": 894,
"keep": 658,
"hide": 236,
"editableAfter": 658,
"keep": 654,
"hide": 240,
"editableAfter": 654,
"decisions": [
{
"id": "Trigger.Manual",
@ -3308,8 +3308,9 @@
{
"id": "Image_Click_V2",
"name": "点击图像",
"status": "keep",
"reason": "目标域 / 协议内的最新主版本"
"status": "hide",
"reason": "与 V3 功能重复合并, V3 为统一版本(支持整个屏幕/指定窗口查找)",
"canonical": "Image_Click_V3"
},
{
"id": "Image_Click_V1",
@ -3354,7 +3355,7 @@
"name": "查找图像",
"canonical": "Image_Find_V1",
"status": "hide",
"reason": "同名旧版本或重复别名,保留执行兼容"
"reason": "同名旧版本, 保留执行兼容"
},
{
"id": "Image_Base64Encode",
@ -3373,7 +3374,7 @@
"name": "等待图像",
"canonical": "Image_Wait_V1",
"status": "hide",
"reason": "同名旧版本或重复别名,保留执行兼容"
"reason": "同名旧版本, 保留执行兼容"
},
{
"id": "Image_Exists_V1",
@ -3386,19 +3387,21 @@
"name": "判断图像",
"canonical": "Image_Exists_V1",
"status": "hide",
"reason": "同名旧版本或重复别名,保留执行兼容"
"reason": "同名旧版本, 保留执行兼容"
},
{
"id": "Image_Location",
"name": "图像位置",
"status": "keep",
"reason": "唯一能力"
"status": "hide",
"reason": "与查找图像功能重复, 统一由 Find_V1 提供坐标返回",
"canonical": "Image_Find_V1"
},
{
"id": "Image_Location_V1",
"name": "获取图像位置",
"status": "keep",
"reason": "唯一能力"
"status": "hide",
"reason": "与查找图像功能重复, 统一由 Find_V1 提供坐标返回",
"canonical": "Image_Find_V1"
},
{
"id": "Image_CropImg",
@ -3421,15 +3424,16 @@
{
"id": "Image_Move_V1",
"name": "移动到图像",
"status": "keep",
"reason": "目标域 / 协议内的最新主版本"
"status": "hide",
"reason": "同名旧版本, 保留执行兼容",
"canonical": "Image_Move_V2"
},
{
"id": "Image_Move",
"name": "移动到图像",
"canonical": "Image_Move_V1",
"canonical": "Image_Move_V2",
"status": "hide",
"reason": "同名旧版本或重复别名,保留执行兼容"
"reason": "同名旧版本, 保留执行兼容"
},
{
"id": "Ini_DeleteKey",

View File

@ -68050,14 +68050,9 @@
"key": "find_range",
"label": "图像查找范围",
"widget": "select",
"type": "string",
"displayType": "dropDownSelect",
"required": false,
"defaultValue": "1",
"placeholder": "",
"description": "请选择查找目标图像的范围",
"advanced": false,
"valueTypes": [],
"required": false,
"options": [
{
"label": "整个屏幕",
@ -68066,83 +68061,36 @@
{
"label": "指定窗口",
"value": "2"
},
{
"label": "指定区域",
"value": "3"
}
]
],
"description": "在整个虚拟桌面(含副屏)中查找,或仅在指定窗口范围内查找"
},
{
"key": "window_obj",
"label": "窗口对象",
"widget": "text",
"type": "string",
"displayType": "text-expression",
"required": true,
"defaultValue": "",
"placeholder": "",
"description": "目标窗口对象;留空则作用于当前活动窗口",
"advanced": false,
"valueTypes": [
"rpa_window_new"
]
},
{
"key": "action",
"label": "action",
"widget": "region",
"type": "object",
"displayType": "getScreenShot",
"required": false,
"defaultValue": "",
"placeholder": "{\"x\":0,\"y\":0,\"width\":100,\"height\":50}",
"description": "区域坐标 JSON;点右侧「屏幕框选」在屏幕上框选生成",
"advanced": false,
"valueTypes": []
},
{
"key": "position",
"label": "position",
"widget": "region",
"type": "object",
"displayType": "screenShotValue",
"required": false,
"defaultValue": "",
"placeholder": "{\"x\":0,\"y\":0,\"width\":100,\"height\":50}",
"description": "区域坐标 JSON;点右侧「屏幕框选」在屏幕上框选生成",
"advanced": false,
"valueTypes": []
"description": "目标窗口标题,留空则作用于当前活动窗口;点「选窗口」从可见窗口列表中选择"
},
{
"key": "image",
"label": "目标图像",
"widget": "text",
"type": "string",
"displayType": "imageSelector",
"required": true,
"defaultValue": "",
"placeholder": "截取或选择图像",
"description": "图片文件的完整路径,用于匹配或识别;点右侧按钮选择文件",
"advanced": false,
"valueTypes": [
"rpa_str"
]
"required": true,
"description": "模板图片文件的完整路径,用于匹配识别;点右侧「屏幕截取」可框选屏幕上的目标图案"
},
{
"key": "accuracy",
"label": "相似度",
"widget": "hidden",
"type": "string",
"displayType": "hidden",
"label": "模板相似度",
"widget": "similaritySlider",
"displayType": "similaritySlider",
"defaultValue": "0.9",
"required": true,
"defaultValue": "0.95",
"placeholder": "请输入相似度阈值",
"description": "模板相似度阈值(0~1,越大越严格):0.9 表示屏幕上至少 90% 像素与模板一致才算找到。框选的模板与实际界面光照/渲染略有差异时可适当调低,如无特殊需求建议 0.8。",
"advanced": false,
"valueTypes": [
"rpa_num"
]
"description": "相似度阈值(0~1,越大越严格):0.9 表示屏幕上至少 90% 像素与模板一致才算找到。建议 0.8~0.9。"
}
],
"output": {
@ -68199,30 +68147,59 @@
"risk": "low",
"fields": [
{
"key": "image",
"label": "图片路径",
"widget": "region",
"type": "object",
"displayType": "screenShot",
"key": "find_range",
"label": "图像查找范围",
"widget": "select",
"displayType": "dropDownSelect",
"defaultValue": "1",
"required": false,
"options": [
{
"label": "整个屏幕",
"value": "1"
},
{
"label": "指定窗口",
"value": "2"
}
],
"description": "在整个虚拟桌面(含副屏)中查找,或仅在指定窗口范围内查找"
},
{
"key": "window_obj",
"label": "窗口对象",
"widget": "text",
"displayType": "text-expression",
"defaultValue": "",
"placeholder": "",
"description": "图片文件的完整路径,用于匹配或识别;点右侧按钮选择文件",
"advanced": false,
"valueTypes": []
"required": false,
"description": "目标窗口标题,留空则作用于当前活动窗口;点「选窗口」从可见窗口列表中选择"
},
{
"key": "image",
"label": "目标图像",
"widget": "text",
"displayType": "imageSelector",
"defaultValue": "",
"required": true,
"description": "模板图片文件的完整路径,用于匹配识别;点右侧「屏幕截取」可框选屏幕上的目标图案"
},
{
"key": "accuracy",
"label": "相似度",
"widget": "text",
"type": "string",
"label": "模板相似度",
"widget": "similaritySlider",
"displayType": "similaritySlider",
"defaultValue": "0.9",
"required": true,
"description": "相似度阈值(0~1,越大越严格):0.9 表示屏幕上至少 90% 像素与模板一致才算找到。建议 0.8~0.9。"
},
{
"key": "timeout",
"label": "等待超时(秒)",
"widget": "number",
"displayType": "text-expression",
"defaultValue": "0",
"required": false,
"defaultValue": "0.8",
"placeholder": "输入内容或 ${变量}",
"description": "模板相似度阈值(0~1,越大越严格):0.9 表示屏幕上至少 90% 像素与模板一致才算找到。框选的模板与实际界面光照/渲染略有差异时可适当调低,如无特殊需求建议 0.8。",
"advanced": false,
"valueTypes": []
"description": "大于 0 时在该时间内每隔 0.3 秒重试查找,超时仍未找到才返回 false"
}
],
"output": {
@ -68545,7 +68522,7 @@
},
{
"id": "Image_Click_V3",
"name": "鼠标点击图像",
"name": "点击图像",
"kind": "vendor",
"group": "vision",
"categories": [
@ -68608,10 +68585,6 @@
{
"label": "指定窗口",
"value": "2"
},
{
"label": "指定区域",
"value": "3"
}
]
},
@ -68621,41 +68594,15 @@
"widget": "text",
"type": "string",
"displayType": "text-expression",
"required": true,
"required": false,
"defaultValue": "",
"placeholder": "",
"description": "目标窗口对象;留空则作用于当前活动窗口",
"description": "目标窗口标题,留空则作用于当前活动窗口;点「选窗口」从可见窗口列表中选择",
"advanced": false,
"valueTypes": [
"rpa_window_new"
]
},
{
"key": "action",
"label": "action",
"widget": "region",
"type": "object",
"displayType": "getScreenShot",
"required": false,
"defaultValue": "",
"placeholder": "{\"x\":0,\"y\":0,\"width\":100,\"height\":50}",
"description": "区域坐标 JSON;点右侧「屏幕框选」在屏幕上框选生成",
"advanced": false,
"valueTypes": []
},
{
"key": "shot_position",
"label": "shot_position",
"widget": "region",
"type": "object",
"displayType": "screenShotValue",
"required": false,
"defaultValue": "",
"placeholder": "{\"x\":0,\"y\":0,\"width\":100,\"height\":50}",
"description": "区域坐标 JSON;点右侧「屏幕框选」在屏幕上框选生成",
"advanced": false,
"valueTypes": []
},
{
"key": "image",
"label": "目标图像",
@ -68748,13 +68695,13 @@
{
"key": "accuracy",
"label": "相似度",
"widget": "hidden",
"widget": "similaritySlider",
"type": "string",
"displayType": "hidden",
"displayType": "similaritySlider",
"required": true,
"defaultValue": "0.95",
"defaultValue": "0.9",
"placeholder": "请输入相似度阈值",
"description": "模板相似度阈值(0~1,越大越严格):0.9 表示屏幕上至少 90% 像素与模板一致才算找到。框选的模板与实际界面光照/渲染略有差异时可适当调低,如无特殊需求建议 0.8。",
"description": "模板相似度阈值(0~1,越大越严格):0.9 表示屏幕上至少 90% 像素与模板一致才算找到。建议 0.8~0.9。",
"advanced": false,
"valueTypes": [
"rpa_num"

View File

@ -158,27 +158,13 @@ register("GoogleTable_RenameSheet", async (_ctx, _node, args) => {
const sheet = (out.sheets || []).find((item: any) => item.properties.title === oldName); if (!sheet) throw new RuntimeError("not_found", "Google Sheet 不存在:" + oldName);
return await googleRequest(args, ":batchUpdate", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ requests: [{ updateSheet: { properties: { sheetId: sheet.properties.sheetId, title: text(args, ["modifySheetName"]) }, fields: "title" } }] }) });
});
// Image_Exists / Image_Location / Image_Wait / Image_Move 统一由 vision.ts 注册到新查找管线
// (虚拟桌面多屏拼接 + 相似度语义 + JPG 模板)。此处旧实现用 GDI CopyFromScreen 只截主屏
// 且 accuracy 语义颠倒, 已整体迁出删除。
async function captureScreen(file: string) {
fs.mkdirSync(path.dirname(file), { recursive: true });
await powershell(`Add-Type -AssemblyName System.Drawing;Add-Type -AssemblyName System.Windows.Forms;$bounds=[System.Windows.Forms.Screen]::PrimaryScreen.Bounds;$bitmap=New-Object System.Drawing.Bitmap $bounds.Width,$bounds.Height;$graphics=[System.Drawing.Graphics]::FromImage($bitmap);$graphics.CopyFromScreen($bounds.Location,[System.Drawing.Point]::Empty,$bounds.Size);$bitmap.Save(${psString(file)});$graphics.Dispose();$bitmap.Dispose()`);
}
async function findOnScreen(args: Record<string, unknown>, node: { id: string; componentId: string }, timeout = 0) {
const template = path.resolve(text(args, ["image", "image_path", "template"]));
if (!fs.existsSync(template)) throw new RuntimeError("validation_error", "模板图片不存在", { nodeId: node.id, componentId: node.componentId, detail: template });
const screenshot = path.join(process.env.TEMP || process.cwd(), `image-find-${Date.now()}.png`); const deadline = Date.now() + timeout * 1000;
do { await captureScreen(screenshot); const hit = await findImage(template, screenshot, num(args, ["accuracy", "accuracy2"], 0.2));
if (hit) { try { fs.unlinkSync(screenshot); } catch {} return hit; } if (timeout > 0) await new Promise((resolve) => setTimeout(resolve, 300)); } while (Date.now() < deadline);
try { fs.unlinkSync(screenshot); } catch {} throw new RuntimeError("not_found", "未找到目标图像", { nodeId: node.id, componentId: node.componentId });
}
for (const id of ["Image_Exists", "Image_Exists_V1"]) register(id, async (_ctx, node, args) => { try { return !!(await findOnScreen(args, node, num(args, ["timeout", "time_out"], 0))); } catch { return false; } });
for (const id of ["Image_Location", "Image_Location_V1"]) register(id, async (_ctx, node, args) => findOnScreen(args, node, num(args, ["timeout", "time_out"], 0)));
for (const id of ["Image_Wait", "Image_Wait_V1"]) register(id, async (_ctx, node, args) => findOnScreen(args, node, num(args, ["timeout", "time_out"], 10)));
for (const id of ["Image_Move", "Image_Move_V1", "Image_Move_V2"]) register(id, async (_ctx, node, args) => {
const hit = await findOnScreen(args, node); const x = Math.round(hit.x + hit.width / 2); const y = Math.round(hit.y + hit.height / 2);
// 注意: PowerShell here-string(@'...'@)的 @' 后必须紧跟换行, 不能拼成单行; 用 -MemberDefinition 内联声明避开该语法
await powershell("Add-Type -MemberDefinition '[DllImport(\"user32.dll\")] public static extern bool SetCursorPos(int X, int Y);' -Name RpaImageMoveInput -Namespace RpaImageMove | Out-Null; [RpaImageMove.RpaImageMoveInput]::SetCursorPos(" + x + "," + y + ")");
return { ...hit, x, y };
});
register("Image_Base64Encode", async (_ctx, _node, args) => fs.readFileSync(path.resolve(text(args, ["path", "image"]))).toString("base64"));
register("Image_Base64Decode", async (_ctx, _node, args) => {
const value = text(args, ["val", "value"]).replace(/^data:[^;]+;base64,/, ""); const file = path.resolve(process.cwd(), `image-${Date.now()}.png`);

View File

@ -307,12 +307,19 @@ function scanBest(screen: PNG, template: PNG, maximumDifference: number): MatchR
let best: MatchResult | null = null;
for (let y = 0; y <= screen.height - template.height; y += scanStep) {
for (let x = 0; x <= screen.width - template.width; x += scanStep) {
const difference = pixelDifference(screen, template, x, y, sampleStep, best ? 1 - best.score : maximumDifference);
// 尚无 best 时不剪枝(完整评估): 若初始就按 maximumDifference 剪枝,
// 均匀背景位置在少数采样后返回"刚好压线"的部分均值抢占 best,
// 而真位置(常不在网格上)附近的网格点因部分重叠被剪, 精化永远够不到——
// 相似度 0.95 时实测 score=1.0 的真位置被判"未达到阈值"
const cutoff = best ? 1 - best.score : 1;
const difference = pixelDifference(screen, template, x, y, sampleStep, cutoff);
const score = 1 - difference;
if (!best || score > best.score) best = { x, y, width: template.width, height: template.height, score };
}
}
if (!best || 1 - best.score > maximumDifference) return null;
if (!best) return null;
// 注意: 阈值判定必须放在精化之后——粗扫网格(步长=模板短边/12)通常够不到精确位置,
// 粗扫 best 常差在阈值边缘, 精化后才能提到满分; 先判阈值会把真命中误杀
const refine = Math.max(1, scanStep);
const minX = Math.max(0, best.x - refine);
const maxX = Math.min(screen.width - template.width, best.x + refine);
@ -320,11 +327,14 @@ function scanBest(screen: PNG, template: PNG, maximumDifference: number): MatchR
const maxY = Math.min(screen.height - template.height, best.y + refine);
for (let y = minY; y <= maxY; y += 1) {
for (let x = minX; x <= maxX; x += 1) {
const difference = pixelDifference(screen, template, x, y, sampleStep, 1 - best.score);
// 精化区域只有几百个位置, 完整评估避免同样的剪枝偏置
const difference = pixelDifference(screen, template, x, y, sampleStep, 1);
const score = 1 - difference;
if (score > best.score) best = { x, y, width: template.width, height: template.height, score };
}
}
// 阈值判定: 精化后的最终得分才与 maximumDifference 比较
if (1 - best.score > maximumDifference) return null;
return best;
}
@ -426,6 +436,51 @@ async function locate(args: Record<string, unknown>, evidenceDir: string, nodeId
}
register(["Image_Find", "Image_Find_V1"], async (ctx, node, args) => locate(args, ctx.evidenceDir, node.id));
// 图像位置: 与查找图像同义(历史重复组件), 同管线返回坐标
for (const id of ["Image_Location", "Image_Location_V1"]) register(id, async (ctx, node, args) => locate(args, ctx.evidenceDir, node.id));
// 判断图像: 找到返回 true / 找不到返回 false(供条件分支), 仅有 not_found 转为 false;
// 模板路径为空/文件不存在等配置错误仍然抛出, 避免静默 false 难排查。
// timeout>0 时在窗口内每 300ms 重试(等待图像出现再判断的场景)
for (const id of ["Image_Exists", "Image_Exists_V1"]) register(id, async (ctx, node, args) => {
const rawTimeout = Number(text(args, ["timeout", "time_out"], "0"));
const deadline = Date.now() + (Number.isFinite(rawTimeout) && rawTimeout > 0 ? rawTimeout : 0) * 1000;
for (;;) {
try {
await locate(args, ctx.evidenceDir, node.id);
return true;
} catch (error) {
if (error instanceof RuntimeError && error.code === "not_found") {
if (Date.now() >= deadline) return false;
} else {
throw error;
}
}
await new Promise(resolve => setTimeout(resolve, 300));
}
});
// 等待图像: 在超时窗口内每 300ms 重试一次查找, 命中即返回坐标, 超时仍无则抛 not_found
for (const id of ["Image_Wait", "Image_Wait_V1"]) register(id, async (ctx, node, args) => {
const rawTimeout = Number(text(args, ["timeout", "time_out"], "10"));
const timeout = Number.isFinite(rawTimeout) && rawTimeout >= 0 ? rawTimeout : 10;
const deadline = Date.now() + timeout * 1000;
for (;;) {
try {
return await locate(args, ctx.evidenceDir, node.id);
} catch (error) {
if (!(error instanceof RuntimeError) || error.code !== "not_found" || Date.now() >= deadline) throw error;
}
await new Promise(resolve => setTimeout(resolve, 300));
}
});
// 移动鼠标到图像: 定位后把光标移到匹配区域中心;
// SetCursorPos 传物理坐标, 脚本必须声明 per-monitor DPI 感知(与 injectClick 同理), 否则被系统虚拟化导致偏移
for (const id of ["Image_Move", "Image_Move_V1", "Image_Move_V2"]) register(id, async (ctx, node, args) => {
const hit = await locate(args, ctx.evidenceDir, node.id);
const x = Math.round(hit.x + hit.width / 2);
const y = Math.round(hit.y + hit.height / 2);
await powershell("Add-Type -MemberDefinition '[DllImport(\"user32.dll\")] public static extern bool SetProcessDpiAwarenessContext(IntPtr value); [DllImport(\"user32.dll\")] public static extern bool SetCursorPos(int X, int Y);' -Name RpaImageMoveInput -Namespace RpaImageMove | Out-Null; try { [void][RpaImageMove.RpaImageMoveInput]::SetProcessDpiAwarenessContext([IntPtr](-4)) } catch {}; [RpaImageMove.RpaImageMoveInput]::SetCursorPos(" + x + "," + y + ")");
return { ...hit, x, y };
});
/** 点击位置锚点(相对匹配区域的比例坐标),支持九宫格取值 */
const POSITION_ANCHORS: Record<string, [number, number]> = {
center: [0.5, 0.5],

View File

@ -951,7 +951,9 @@ function FieldControl({
onChange={(event) => onChange(field.key, event.target.value)}
/>
<VarMenu variables={variables} onPick={insertVariable} />
{isWindowTarget && /window_obj|match_title|title|window/i.test(field.key) && (
{/* window_obj/窗口对象 等窗口标题语义字段直接给「选窗口」按钮(不依赖组件前缀);
其余 Window_ 系组件的字段仍按前缀+关键词匹配 */}
{(/^(window_obj|窗口对象|窗口标题|window_title|match_title)$/i.test(field.key) || (isWindowTarget && /window|title/i.test(field.key))) && (
<button type="button" className="mini-btn" disabled={picking} onClick={() => void pickWindowToField()} title="从当前可见窗口列表中选择">
{picking ? "选择中" : "选窗口"}
</button>