feat: 支持配置全局网页元素等待
This commit is contained in:
parent
5e9f0df57f
commit
bec3d9daa4
@ -2,6 +2,16 @@ import { BrowserWindow } from "electron";
|
||||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
import type { ElementPickResult } from "../../shared/types";
|
||||
|
||||
const DEFAULT_ELEMENT_WAIT_MS = 5000;
|
||||
let globalElementWaitMs = DEFAULT_ELEMENT_WAIT_MS;
|
||||
|
||||
export function setBrowserElementWaitMs(milliseconds: number): void {
|
||||
const value = Number(milliseconds);
|
||||
globalElementWaitMs = Number.isFinite(value)
|
||||
? Math.min(600000, Math.max(0, Math.round(value)))
|
||||
: DEFAULT_ELEMENT_WAIT_MS;
|
||||
}
|
||||
import {
|
||||
RuntimeError,
|
||||
type BrowserElementInfo,
|
||||
@ -137,6 +147,18 @@ export class BrowserSession implements BrowserLike {
|
||||
}
|
||||
|
||||
private async withElement<T>(selector: unknown, operation: string): Promise<T> {
|
||||
const deadline = Date.now() + globalElementWaitMs;
|
||||
for (;;) {
|
||||
try {
|
||||
return await this.withElementOnce<T>(selector, operation);
|
||||
} catch (error) {
|
||||
if (!(error instanceof RuntimeError && error.code === "not_found") || Date.now() >= deadline) throw error;
|
||||
await this.wait(120);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async withElementOnce<T>(selector: unknown, operation: string): Promise<T> {
|
||||
const spec = selectorSpec(selector);
|
||||
const result = await this.evaluate<{ found: boolean; value?: T }>(`(() => {
|
||||
const spec = ${JSON.stringify(spec)};
|
||||
@ -241,7 +263,31 @@ export class BrowserSession implements BrowserLike {
|
||||
return { url: this.win.webContents.getURL(), title: this.win.webContents.getTitle() };
|
||||
}
|
||||
|
||||
async locate(selector: unknown): Promise<ElementInfo & { selector: string }> {
|
||||
private async wait(milliseconds: number): Promise<void> {
|
||||
await new Promise((resolve) => setTimeout(resolve, milliseconds));
|
||||
}
|
||||
|
||||
async locate(selector: unknown, timeoutMs?: number): Promise<ElementInfo & { selector: string }> {
|
||||
const waitMs = Math.max(0, timeoutMs ?? globalElementWaitMs);
|
||||
const deadline = Date.now() + waitMs;
|
||||
for (;;) {
|
||||
try {
|
||||
return await this.locateOnce(selector);
|
||||
} catch (error) {
|
||||
const canRetry = error instanceof RuntimeError && error.code === "not_found" && Date.now() < deadline;
|
||||
if (!canRetry) {
|
||||
if (error instanceof RuntimeError && error.code === "not_found" && waitMs > 0) {
|
||||
error.detail = `已等待页面渲染 ${waitMs}ms 后仍未找到元素`;
|
||||
error.recommendation = "确认页面已加载完成;如果元素在 iframe 或 Shadow DOM 中,请用拾取器重新采集";
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
await this.wait(120);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async locateOnce(selector: unknown): Promise<ElementInfo & { selector: string }> {
|
||||
const spec = selectorSpec(selector);
|
||||
const expression = `(() => {
|
||||
const spec = ${JSON.stringify(spec)};
|
||||
@ -312,8 +358,8 @@ export class BrowserSession implements BrowserLike {
|
||||
await this.cdp("Input.dispatchMouseEvent", { type: "mouseReleased", ...point, button: "left", buttons: 0, clickCount: 1 });
|
||||
}
|
||||
|
||||
async click(selector: unknown): Promise<unknown> {
|
||||
const info = await this.locate(selector);
|
||||
async click(selector: unknown, timeoutMs?: number): Promise<unknown> {
|
||||
const info = await this.locate(selector, timeoutMs);
|
||||
if (!info.visible) {
|
||||
throw new RuntimeError("not_found", `元素不可见:${info.selector}`, { recommendation: "等待元素出现或检查页面状态" });
|
||||
}
|
||||
@ -343,8 +389,8 @@ export class BrowserSession implements BrowserLike {
|
||||
return { selector: info.selector, tag: info.tag, text: info.text };
|
||||
}
|
||||
|
||||
async type(selector: unknown, text: string, clearFirst = true): Promise<unknown> {
|
||||
const info = await this.locate(selector);
|
||||
async type(selector: unknown, text: string, clearFirst = true, timeoutMs?: number): Promise<unknown> {
|
||||
const info = await this.locate(selector, timeoutMs);
|
||||
await this.mouseClick(info.rect.x + info.rect.width / 2, info.rect.y + info.rect.height / 2);
|
||||
if (clearFirst) {
|
||||
await this.cdp("Input.dispatchKeyEvent", { type: "rawKeyDown", key: "a", code: "KeyA", windowsVirtualKeyCode: 65, modifiers: 2 });
|
||||
@ -374,7 +420,7 @@ export class BrowserSession implements BrowserLike {
|
||||
|
||||
async exists(selector: unknown): Promise<boolean> {
|
||||
try {
|
||||
await this.locate(selector);
|
||||
await this.locate(selector, 0);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
|
||||
@ -3,7 +3,7 @@ import type { CatalogComponent, FlowDefinition, FlowNode, RunError, RunEvent } f
|
||||
export interface BrowserLike {
|
||||
navigate(url: string): Promise<{ url: string; title: string }>;
|
||||
click(selector: unknown, timeoutMs?: number): Promise<unknown>;
|
||||
type(selector: unknown, text: string, clearFirst?: boolean): Promise<unknown>;
|
||||
type(selector: unknown, text: string, clearFirst?: boolean, timeoutMs?: number): Promise<unknown>;
|
||||
getText(selector: unknown, attribute?: string): Promise<string>;
|
||||
exists(selector: unknown, timeoutMs?: number): Promise<boolean>;
|
||||
select(selector: unknown, value: string): Promise<unknown>;
|
||||
|
||||
@ -1,13 +1,14 @@
|
||||
import { app, BrowserWindow, dialog, ipcMain, shell } from "electron";
|
||||
import path from "node:path";
|
||||
import type { AppInfo, FlowDefinition } from "../shared/types";
|
||||
import { BrowserSession, hasBrowserSession } from "./browser/session";
|
||||
import type { AppInfo, AppSettings, FlowDefinition } from "../shared/types";
|
||||
import { BrowserSession, hasBrowserSession, setBrowserElementWaitMs } from "./browser/session";
|
||||
import "./capabilities";
|
||||
import { catalogPath, loadCatalog } from "./catalog";
|
||||
import { createDemoFlow, createExampleFlows, createFlow, deleteFlow, getFlow, listFlows, saveFlow } from "./flows";
|
||||
import { capabilityCount, capabilityIds } from "./engine/registry";
|
||||
import { closeBrowserSession, startRun, stopRun } from "./run/manager";
|
||||
import { fixtureUrl, startFixtureServer, stopFixtureServer } from "./fixture/server";
|
||||
import { loadSettings, updateSettings } from "./settings";
|
||||
import { logMain } from "./logger";
|
||||
|
||||
let mainWindow: BrowserWindow | null = null;
|
||||
@ -93,6 +94,12 @@ function registerIpc(): void {
|
||||
ipcMain.handle("run:start", (event, flow: FlowDefinition) => startRun(flow, event.sender));
|
||||
ipcMain.handle("run:stop", (_event, runId?: string) => stopRun(runId));
|
||||
ipcMain.handle("run:capabilities", () => ({ count: capabilityCount(), ids: capabilityIds() }));
|
||||
ipcMain.handle("settings:get", () => loadSettings());
|
||||
ipcMain.handle("settings:update", (_event, patch: Partial<AppSettings> = {}) => {
|
||||
const settings = updateSettings(patch);
|
||||
setBrowserElementWaitMs(settings.browserElementWaitMs);
|
||||
return settings;
|
||||
});
|
||||
ipcMain.handle("browser:pick-element", async (_event, options: { url?: string } = {}) => {
|
||||
const url = options.url?.trim();
|
||||
if (!hasBrowserSession() && !url) {
|
||||
@ -160,6 +167,7 @@ if (!gotLock) {
|
||||
|
||||
app.whenReady().then(async () => {
|
||||
logMain("app-ready", { electron: process.versions.electron, chrome: process.versions.chrome, node: process.versions.node });
|
||||
setBrowserElementWaitMs(loadSettings().browserElementWaitMs);
|
||||
try {
|
||||
await startFixtureServer(Number(process.env.RPA_FIXTURE_PORT || 17863));
|
||||
} catch (error) {
|
||||
|
||||
35
src/main/settings.test.ts
Normal file
35
src/main/settings.test.ts
Normal file
@ -0,0 +1,35 @@
|
||||
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const electronState = vi.hoisted(() => ({ userData: "" }));
|
||||
vi.mock("electron", () => ({
|
||||
app: {
|
||||
getPath: () => electronState.userData
|
||||
}
|
||||
}));
|
||||
|
||||
import { loadSettings, updateSettings } from "./settings";
|
||||
|
||||
describe("app settings", () => {
|
||||
beforeEach(async () => {
|
||||
electronState.userData = await mkdtemp(path.join(os.tmpdir(), "rpa-settings-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(electronState.userData, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("returns the default element wait when no settings file exists", () => {
|
||||
expect(loadSettings()).toEqual({ browserElementWaitMs: 5000 });
|
||||
});
|
||||
|
||||
it("persists and clamps a user-adjustable element wait", async () => {
|
||||
const saved = updateSettings({ browserElementWaitMs: 12500 });
|
||||
expect(saved).toEqual({ browserElementWaitMs: 12500 });
|
||||
expect(JSON.parse(await readFile(path.join(electronState.userData, "settings.json"), "utf8"))).toEqual(saved);
|
||||
expect(updateSettings({ browserElementWaitMs: -5 })).toEqual({ browserElementWaitMs: 0 });
|
||||
expect(loadSettings()).toEqual({ browserElementWaitMs: 0 });
|
||||
});
|
||||
});
|
||||
45
src/main/settings.ts
Normal file
45
src/main/settings.ts
Normal file
@ -0,0 +1,45 @@
|
||||
import { app } from "electron";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import type { AppSettings } from "../shared/types";
|
||||
|
||||
export const DEFAULT_APP_SETTINGS: AppSettings = {
|
||||
browserElementWaitMs: 5000
|
||||
};
|
||||
|
||||
let cache: AppSettings | null = null;
|
||||
|
||||
function settingsPath(): string {
|
||||
return path.join(app.getPath("userData"), "settings.json");
|
||||
}
|
||||
|
||||
function normalizeSettings(value: unknown): AppSettings {
|
||||
const source = (value && typeof value === "object" ? value : {}) as Partial<AppSettings>;
|
||||
const wait = Number(source.browserElementWaitMs);
|
||||
return {
|
||||
browserElementWaitMs: Number.isFinite(wait)
|
||||
? Math.min(600000, Math.max(0, Math.round(wait)))
|
||||
: DEFAULT_APP_SETTINGS.browserElementWaitMs
|
||||
};
|
||||
}
|
||||
|
||||
export function loadSettings(): AppSettings {
|
||||
try {
|
||||
cache = normalizeSettings(JSON.parse(fs.readFileSync(settingsPath(), "utf8")));
|
||||
} catch {
|
||||
cache = { ...DEFAULT_APP_SETTINGS };
|
||||
}
|
||||
return cache;
|
||||
}
|
||||
|
||||
export function saveSettings(settings: AppSettings): AppSettings {
|
||||
const normalized = normalizeSettings(settings);
|
||||
fs.mkdirSync(app.getPath("userData"), { recursive: true });
|
||||
fs.writeFileSync(settingsPath(), JSON.stringify(normalized, null, 2), "utf8");
|
||||
cache = normalized;
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function updateSettings(patch: Partial<AppSettings> = {}): AppSettings {
|
||||
return saveSettings({ ...(cache || loadSettings()), ...patch });
|
||||
}
|
||||
@ -1,5 +1,5 @@
|
||||
import { contextBridge, ipcRenderer } from "electron";
|
||||
import type { AppInfo, Catalog, FlowDefinition, FlowSummary, RunEvent } from "../shared/types";
|
||||
import type { AppInfo, AppSettings, Catalog, FlowDefinition, FlowSummary, RunEvent } from "../shared/types";
|
||||
|
||||
const api = {
|
||||
app: {
|
||||
@ -27,6 +27,10 @@ const api = {
|
||||
pickElement: (options?: { url?: string }): Promise<import("../shared/types").ElementPickResult> =>
|
||||
ipcRenderer.invoke("browser:pick-element", options || {})
|
||||
},
|
||||
settings: {
|
||||
get: (): Promise<AppSettings> => ipcRenderer.invoke("settings:get"),
|
||||
update: (patch: Partial<AppSettings>): Promise<AppSettings> => ipcRenderer.invoke("settings:update", patch || {})
|
||||
},
|
||||
run: {
|
||||
start: (flow: FlowDefinition): Promise<{ runId: string }> => ipcRenderer.invoke("run:start", flow),
|
||||
stop: (runId?: string): Promise<boolean> => ipcRenderer.invoke("run:stop", runId),
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Cpu, Database, FolderCog, Globe2, HardDrive, Info, ShieldCheck } from "lucide-react";
|
||||
import { Clock, Cpu, Database, FolderCog, Globe2, HardDrive, Info, Save, ShieldCheck } from "lucide-react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import type { AppInfo, Catalog } from "@shared/types";
|
||||
import type { AppInfo, AppSettings, Catalog } from "@shared/types";
|
||||
|
||||
type SettingsTab = "about" | "catalog" | "execution" | "browser";
|
||||
|
||||
@ -10,12 +10,40 @@ 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);
|
||||
const [settings, setSettings] = useState<AppSettings | null>(null);
|
||||
const [waitDraft, setWaitDraft] = useState("5000");
|
||||
const [waitSaving, setWaitSaving] = useState(false);
|
||||
const [waitMessage, setWaitMessage] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
void window.rpa.app.info().then(setInfo).catch(() => setInfo(null));
|
||||
void window.rpa.run.capabilities().then(setHandlers).catch(() => setHandlers(null));
|
||||
void window.rpa.settings.get().then((value) => {
|
||||
setSettings(value);
|
||||
setWaitDraft(String(value.browserElementWaitMs));
|
||||
}).catch(() => setSettings(null));
|
||||
}, []);
|
||||
|
||||
const saveElementWait = async () => {
|
||||
const value = Math.round(Number(waitDraft));
|
||||
if (!Number.isFinite(value) || value < 0 || value > 600000) {
|
||||
setWaitMessage("请输入 0 - 600000 之间的毫秒数");
|
||||
return;
|
||||
}
|
||||
setWaitSaving(true);
|
||||
setWaitMessage(null);
|
||||
try {
|
||||
const saved = await window.rpa.settings.update({ browserElementWaitMs: value });
|
||||
setSettings(saved);
|
||||
setWaitDraft(String(saved.browserElementWaitMs));
|
||||
setWaitMessage(`已保存:${saved.browserElementWaitMs} ms`);
|
||||
} catch (error) {
|
||||
setWaitMessage(error instanceof Error ? error.message : String(error));
|
||||
} finally {
|
||||
setWaitSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
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);
|
||||
@ -62,6 +90,30 @@ export function SettingsPage({ catalog }: { catalog: Catalog }) {
|
||||
{tab === "browser" && (
|
||||
<>
|
||||
<SettingCard icon={Globe2} title="浏览器访问范围"><dl className="kv"><dt>默认允许</dt><dd>全部 HTTP/HTTPS 站点</dd><dt>本地演示站</dt><dd>{info?.fixtureUrl || "未启动"}</dd><dt>协议限制</dt><dd>HTTP / HTTPS</dd><dt>用途</dt><dd>支持内网、公网和测试站点</dd></dl></SettingCard>
|
||||
<SettingCard icon={Clock} title="元素等待时间">
|
||||
<div className="field">
|
||||
<label className="field-label" htmlFor="browser-element-wait">全局默认等待(毫秒)</label>
|
||||
<input
|
||||
id="browser-element-wait"
|
||||
className="text-input"
|
||||
type="number"
|
||||
min={0}
|
||||
max={600000}
|
||||
step={100}
|
||||
value={waitDraft}
|
||||
onChange={(event) => setWaitDraft(event.target.value)}
|
||||
disabled={waitSaving}
|
||||
/>
|
||||
<span className="field-hint">所有网页元素能力共用。找不到元素时会持续等待到设定时间;0 表示不等待。</span>
|
||||
</div>
|
||||
<div className="field-row">
|
||||
<button type="button" className="btn btn-primary" onClick={() => void saveElementWait()} disabled={waitSaving}>
|
||||
<Save className="icon" />
|
||||
{waitSaving ? "保存中" : "保存设置"}
|
||||
</button>
|
||||
{waitMessage && <span className="field-hint">{waitMessage}</span>}
|
||||
</div>
|
||||
</SettingCard>
|
||||
<SettingCard icon={ShieldCheck} title="限制说明"><p className="field-hint setting-copy">浏览器导航不限制域名。只拒绝 HTTP/HTTPS 以外的协议;文件协议、脚本协议和任意本地路径不会由浏览器导航组件打开。</p></SettingCard>
|
||||
</>
|
||||
)}
|
||||
|
||||
@ -169,6 +169,14 @@ export interface AppInfo {
|
||||
fixtureUrl?: string;
|
||||
}
|
||||
|
||||
export interface AppSettings {
|
||||
browserElementWaitMs: number;
|
||||
}
|
||||
|
||||
export interface AppSettings {
|
||||
browserElementWaitMs: number;
|
||||
}
|
||||
|
||||
export interface ElementPickResult {
|
||||
selector: string;
|
||||
tag: string;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user