feat: CV 组件搜索区域 UI 接入完成

- catalog 为 CV_MouseClick/GetText/SetText/MouseHover 新增可选「搜索区域」字段(region widget)
- 属性面板 region 字段渲染「屏幕框选」按钮, 框选后存 {x,y,width,height,imagePath}
- 运行时框选截图内 OCR 定位 + 偏移换算屏幕绝对坐标; 纯坐标范围则全屏截屏后过滤区域内命中
- 新增 2 个区域过滤测试用例(区域内命中/区域外拒绝)
This commit is contained in:
yu 2026-11-03 09:10:00 +08:00
parent 5aa113ea0e
commit d98185d1cf
3 changed files with 88 additions and 3 deletions

View File

@ -10,7 +10,7 @@
"vendor": 997,
"native": 16,
"triggers": 3,
"fields": 4293
"fields": 4297
},
"groups": [
{
@ -11482,6 +11482,19 @@
"description": "屏幕上可见的目标文本,运行时截屏后通过 OCR 定位(填按钮或界面上的文字,如「登录」)",
"advanced": false,
"valueTypes": []
},
{
"key": "searchRegion",
"label": "搜索区域",
"widget": "region",
"type": "object",
"displayType": "region",
"required": false,
"defaultValue": "",
"placeholder": "",
"description": "可选。点「屏幕框选」框住目标所在区域,运行时只在该区域内查找目标文本,屏幕上有相同文字时可避免点错;留空则全屏查找",
"advanced": false,
"valueTypes": []
}
],
"output": {
@ -11542,6 +11555,19 @@
"advanced": false,
"valueTypes": []
},
{
"key": "searchRegion",
"label": "搜索区域",
"widget": "region",
"type": "object",
"displayType": "region",
"required": false,
"defaultValue": "",
"placeholder": "",
"description": "可选。点「屏幕框选」框住目标所在区域,运行时只在该区域内查找目标文本,屏幕上有相同文字时可避免点错;留空则全屏查找",
"advanced": false,
"valueTypes": []
},
{
"key": "text",
"label": "文本",
@ -11608,6 +11634,19 @@
"advanced": false,
"valueTypes": []
},
{
"key": "searchRegion",
"label": "搜索区域",
"widget": "region",
"type": "object",
"displayType": "region",
"required": false,
"defaultValue": "",
"placeholder": "",
"description": "可选。点「屏幕框选」框住目标所在区域,运行时只在该区域内查找目标文本,屏幕上有相同文字时可避免点错;留空则全屏查找",
"advanced": false,
"valueTypes": []
},
{
"key": "offsetX",
"label": "x轴偏移量",
@ -11688,6 +11727,19 @@
"advanced": false,
"valueTypes": []
},
{
"key": "searchRegion",
"label": "搜索区域",
"widget": "region",
"type": "object",
"displayType": "region",
"required": false,
"defaultValue": "",
"placeholder": "",
"description": "可选。点「屏幕框选」框住目标所在区域,运行时只在该区域内查找目标文本,屏幕上有相同文字时可避免点错;留空则全屏查找",
"advanced": false,
"valueTypes": []
},
{
"key": "clickway",
"label": "点击类型",

View File

@ -2,7 +2,10 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
const powershellMock = vi.hoisted(() => vi.fn(async () => '{"ok":true}'));
const detectMock = vi.hoisted(() => vi.fn(async () => ({
texts: [{ text: "确认保存", box: [[0, 0], [20, 0], [20, 10], [0, 10]] }]
texts: [
{ text: "确认保存", box: [[0, 0], [20, 0], [20, 10], [0, 10]] },
{ text: "确认", box: [[100, 200], [130, 200], [130, 210], [100, 210]] }
]
})));
vi.mock("./desktop", () => ({ powershell: powershellMock }));
@ -47,4 +50,21 @@ describe("CV vision capabilities", () => {
}, {} as any)).resolves.toBe("确认保存");
expect(powershellMock).toHaveBeenCalledTimes(1);
});
it("restricts matching to the search region when provided", async () => {
// 屏幕上有两处「确认」: 区域(0,0,50,50)内只应命中 (5,5) 那处, 而不是第一个全屏命中 (115,205)
await expect(getHandler("CV_MouseClick")!({ log: vi.fn() } as any, { id: "n3", componentId: "CV_MouseClick" } as any, {
cvSelector: "确认",
searchRegion: JSON.stringify({ x: 0, y: 0, width: 50, height: 50 })
}, {} as any)).resolves.toBe(true);
const script = String((powershellMock.mock.calls as unknown as Array<[string]>)[1]?.[0] ?? "");
expect(script).toContain("SetCursorPos(10, 5)");
});
it("rejects when target is outside the search region", async () => {
await expect(getHandler("CV_MouseClick")!({ log: vi.fn() } as any, { id: "n4", componentId: "CV_MouseClick" } as any, {
cvSelector: "确认",
searchRegion: JSON.stringify({ x: 500, y: 500, width: 50, height: 50 })
}, {} as any)).rejects.toThrow("未找到目标文本");
});
});

View File

@ -772,12 +772,25 @@ function FieldControl({
placeholder={field.placeholder || ""}
onChange={(event) => onChange(field.key, event.target.value)}
/>
) : field.widget === "region" ? (
<div className="field-row">
<input
className="text-input"
value={textOf(raw)}
placeholder={field.placeholder || "点右侧「屏幕框选」在屏幕上框选生成"}
onChange={(event) => onChange(field.key, event.target.value)}
/>
<VarMenu variables={variables} onPick={insertVariable} />
<button type="button" className="mini-btn" disabled={picking} onClick={() => void pickRegionToField()} title="在屏幕上框选一块区域">
{picking ? "框选中" : "屏幕框选"}
</button>
</div>
) : (
<div className="field-row">
<input
className="text-input"
value={textOf(raw)}
placeholder={field.placeholder || (field.widget === "region" ? "区域 / 坐标 JSON" : "")}
placeholder={field.placeholder || ""}
onChange={(event) => onChange(field.key, event.target.value)}
/>
<VarMenu variables={variables} onPick={insertVariable} />