mirror of
https://github.com/hangwin/mcp-chrome.git
synced 2026-09-21 12:43:18 +08:00
feat: add builder editor
This commit is contained in:
@@ -108,6 +108,7 @@ export const STORAGE_KEYS = {
|
||||
RR_FLOWS: 'rr_flows',
|
||||
RR_RUNS: 'rr_runs',
|
||||
RR_PUBLISHED: 'rr_published_flows',
|
||||
RR_SCHEDULES: 'rr_schedules',
|
||||
} as const;
|
||||
|
||||
// Notification Configuration
|
||||
|
||||
@@ -34,6 +34,10 @@ export const BACKGROUND_MESSAGE_TYPES = {
|
||||
RR_EXPORT_FLOW: 'rr_export_flow',
|
||||
RR_EXPORT_ALL: 'rr_export_all',
|
||||
RR_IMPORT_FLOW: 'rr_import_flow',
|
||||
// Scheduling
|
||||
RR_SCHEDULE_FLOW: 'rr_schedule_flow',
|
||||
RR_UNSCHEDULE_FLOW: 'rr_unschedule_flow',
|
||||
RR_LIST_SCHEDULES: 'rr_list_schedules',
|
||||
} as const;
|
||||
|
||||
// Offscreen message types
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { TOOL_NAMES } from 'chrome-mcp-shared';
|
||||
import { handleCallTool } from '../tools';
|
||||
import {
|
||||
import type {
|
||||
Flow,
|
||||
RunLogEntry,
|
||||
RunRecord,
|
||||
@@ -13,6 +13,8 @@ import {
|
||||
StepDrag,
|
||||
StepWait,
|
||||
StepScript,
|
||||
NodeBase as DagNode,
|
||||
Edge as DagEdge,
|
||||
} from './types';
|
||||
import { appendRun } from './flow-store';
|
||||
import { locateElement } from './selector-engine';
|
||||
@@ -27,6 +29,7 @@ export interface RunOptions {
|
||||
timeoutMs?: number;
|
||||
startUrl?: string;
|
||||
args?: Record<string, any>;
|
||||
startNodeId?: string; // start executing from this node/step id if present
|
||||
}
|
||||
|
||||
export async function runFlow(flow: Flow, options: RunOptions = {}): Promise<RunResult> {
|
||||
@@ -39,12 +42,52 @@ export async function runFlow(flow: Flow, options: RunOptions = {}): Promise<Run
|
||||
}
|
||||
if (options.args) Object.assign(vars, options.args);
|
||||
|
||||
// prepare tab & binding check
|
||||
if (options.startUrl) {
|
||||
await handleCallTool({ name: TOOL_NAMES.BROWSER.NAVIGATE, args: { url: options.startUrl } });
|
||||
}
|
||||
if (options.refresh) {
|
||||
await handleCallTool({ name: TOOL_NAMES.BROWSER.NAVIGATE, args: { refresh: true } });
|
||||
// Helper: ensure target tab according to tabTarget/startUrl, and optionally refresh
|
||||
const ensureTab = async () => {
|
||||
const target = options.tabTarget || 'current';
|
||||
const startUrl = options.startUrl;
|
||||
const [active] = await chrome.tabs.query({ active: true, currentWindow: true });
|
||||
if (target === 'new') {
|
||||
let urlToOpen = startUrl;
|
||||
if (!urlToOpen) {
|
||||
// duplicate current active tab's URL when startUrl not provided
|
||||
urlToOpen = active?.url || 'about:blank';
|
||||
}
|
||||
const created = await chrome.tabs.create({ url: urlToOpen, active: true });
|
||||
// Best-effort wait for loading to begin and settle a bit
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
} else {
|
||||
// current tab target
|
||||
if (startUrl) {
|
||||
await handleCallTool({ name: TOOL_NAMES.BROWSER.NAVIGATE, args: { url: startUrl } });
|
||||
} else if (options.refresh) {
|
||||
await handleCallTool({ name: TOOL_NAMES.BROWSER.NAVIGATE, args: { refresh: true } });
|
||||
}
|
||||
}
|
||||
};
|
||||
await ensureTab();
|
||||
|
||||
// helper to apply assign mapping: { varName: 'a.b[0].c' }
|
||||
function applyAssign(target: Record<string, any>, source: any, assign: Record<string, string>) {
|
||||
const getByPath = (obj: any, path: string) => {
|
||||
try {
|
||||
const parts = path
|
||||
.replace(/\[(\d+)\]/g, '.$1')
|
||||
.split('.')
|
||||
.filter(Boolean);
|
||||
let cur = obj;
|
||||
for (const p of parts) {
|
||||
if (cur == null) return undefined;
|
||||
cur = cur[p as any];
|
||||
}
|
||||
return cur;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
for (const [k, v] of Object.entries(assign || {})) {
|
||||
target[k] = getByPath(source, String(v));
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure helper scripts are present for overlay/collectVariables
|
||||
@@ -64,7 +107,10 @@ export async function runFlow(flow: Flow, options: RunOptions = {}): Promise<Run
|
||||
if (needed.length > 0) {
|
||||
const res = await handleCallTool({
|
||||
name: TOOL_NAMES.BROWSER.SEND_COMMAND_TO_INJECT_SCRIPT,
|
||||
args: { eventName: 'collectVariables', payload: undefined },
|
||||
args: {
|
||||
eventName: 'collectVariables',
|
||||
payload: JSON.stringify({ variables: needed, useOverlay: true }),
|
||||
},
|
||||
});
|
||||
// Fallback: if direct collectVariables without payload not supported, call with explicit variables
|
||||
let values: Record<string, any> | null = null;
|
||||
@@ -84,6 +130,7 @@ export async function runFlow(flow: Flow, options: RunOptions = {}): Promise<Run
|
||||
return await chrome.tabs.sendMessage(tabId, {
|
||||
action: 'collectVariables',
|
||||
variables: needed,
|
||||
useOverlay: true,
|
||||
} as any);
|
||||
});
|
||||
if (res2 && res2.success && res2.values) values = res2.values;
|
||||
@@ -234,9 +281,32 @@ export async function runFlow(flow: Flow, options: RunOptions = {}): Promise<Run
|
||||
}
|
||||
}
|
||||
|
||||
// If DAG present, linearize to steps for M1 (default edges, topo order)
|
||||
const stepsToRun: Step[] = (() => {
|
||||
try {
|
||||
if (Array.isArray((flow as any).nodes) && (flow as any).nodes.length > 0) {
|
||||
const nodes = ((flow as any).nodes || []) as DagNode[];
|
||||
const edges = (((flow as any).edges || []) as DagEdge[]).filter(
|
||||
(e) => !e.label || e.label === 'default',
|
||||
);
|
||||
const order = topoOrder(nodes, edges);
|
||||
return order.map((n) => mapDagNodeToStep(n));
|
||||
}
|
||||
} catch {
|
||||
// ignore and fallback
|
||||
}
|
||||
return flow.steps || [];
|
||||
})();
|
||||
|
||||
// If a startNodeId is provided, slice the plan to start from that node/step id
|
||||
const startIdx = options.startNodeId
|
||||
? stepsToRun.findIndex((s) => s?.id === options.startNodeId)
|
||||
: -1;
|
||||
const steps = startIdx >= 0 ? stepsToRun.slice(startIdx) : stepsToRun.slice();
|
||||
|
||||
try {
|
||||
const pendingAfterScripts: StepScript[] = [];
|
||||
for (const step of flow.steps) {
|
||||
for (const step of steps) {
|
||||
const t0 = Date.now();
|
||||
const maxRetries = Math.max(0, step.retry?.count ?? 0);
|
||||
const baseInterval = Math.max(0, step.retry?.intervalMs ?? 0);
|
||||
@@ -266,7 +336,138 @@ export async function runFlow(flow: Flow, options: RunOptions = {}): Promise<Run
|
||||
}
|
||||
|
||||
let stepLogged = false;
|
||||
// Helper get current active tab URL and status
|
||||
const getActiveTabInfo = async () => {
|
||||
const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
|
||||
const tab = tabs[0];
|
||||
return { url: tab?.url || '', status: (tab as any)?.status || '' };
|
||||
};
|
||||
// Wait for navigation completion or readiness
|
||||
const waitForNavigation = async (prevUrl: string, timeoutMs: number) => {
|
||||
const deadline = Date.now() + Math.max(1000, Math.min(timeoutMs || 15000, 30000));
|
||||
let sawLoading = false;
|
||||
while (Date.now() < deadline) {
|
||||
const { url, status } = await getActiveTabInfo();
|
||||
if (url && url !== prevUrl) return true;
|
||||
if (status === 'loading') sawLoading = true;
|
||||
if (sawLoading && status === 'complete') return true;
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
}
|
||||
// as a last attempt, try a brief network idle wait
|
||||
try {
|
||||
await waitForNetworkIdle(2000, 800);
|
||||
return true;
|
||||
} catch (e) {
|
||||
// noop
|
||||
void 0;
|
||||
}
|
||||
throw new Error('navigation timeout');
|
||||
};
|
||||
|
||||
switch (step.type) {
|
||||
case 'http': {
|
||||
const s = step as any;
|
||||
const res = await handleCallTool({
|
||||
name: TOOL_NAMES.BROWSER.NETWORK_REQUEST,
|
||||
args: {
|
||||
url: s.url,
|
||||
method: s.method || 'GET',
|
||||
headers: s.headers || {},
|
||||
body: s.body,
|
||||
},
|
||||
});
|
||||
const text = (res as any)?.content?.find((c: any) => c.type === 'text')?.text;
|
||||
try {
|
||||
const payload = text ? JSON.parse(text) : null;
|
||||
if (s.saveAs && payload !== undefined) vars[s.saveAs] = payload;
|
||||
if (s.assign && payload !== undefined) applyAssign(vars, payload, s.assign);
|
||||
} catch {
|
||||
// ignore parse error
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'extract': {
|
||||
const s = step as any;
|
||||
const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
|
||||
const tabId = tabs?.[0]?.id;
|
||||
if (typeof tabId !== 'number') throw new Error('Active tab not found');
|
||||
let value: any = null;
|
||||
if (s.js && String(s.js).trim()) {
|
||||
const [{ result }] = await chrome.scripting.executeScript({
|
||||
target: { tabId },
|
||||
func: (code: string) => {
|
||||
try {
|
||||
return (0, eval)(code);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
args: [String(s.js)],
|
||||
} as any);
|
||||
value = result;
|
||||
} else if (s.selector) {
|
||||
const attr = String(s.attr || 'text');
|
||||
const sel = String(s.selector);
|
||||
const [{ result }] = await chrome.scripting.executeScript({
|
||||
target: { tabId },
|
||||
func: (selector: string, attr: string) => {
|
||||
try {
|
||||
const el = document.querySelector(selector) as any;
|
||||
if (!el) return null;
|
||||
if (attr === 'text' || attr === 'textContent')
|
||||
return (el.textContent || '').trim();
|
||||
return el.getAttribute ? el.getAttribute(attr) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
args: [sel, attr],
|
||||
} as any);
|
||||
value = result;
|
||||
}
|
||||
if (s.saveAs) vars[s.saveAs] = value;
|
||||
break;
|
||||
}
|
||||
case 'openTab': {
|
||||
const s = step as any;
|
||||
if (s.newWindow) {
|
||||
await chrome.windows.create({ url: s.url || undefined, focused: true });
|
||||
} else {
|
||||
await chrome.tabs.create({ url: s.url || undefined, active: true });
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'switchTab': {
|
||||
const s = step as any;
|
||||
let targetTabId: number | undefined = s.tabId;
|
||||
if (!targetTabId) {
|
||||
const tabs = await chrome.tabs.query({});
|
||||
const hit = tabs.find(
|
||||
(t) =>
|
||||
(s.urlContains && (t.url || '').includes(String(s.urlContains))) ||
|
||||
(s.titleContains && (t.title || '').includes(String(s.titleContains))),
|
||||
);
|
||||
targetTabId = (hit && hit.id) as number | undefined;
|
||||
}
|
||||
if (targetTabId) {
|
||||
await handleCallTool({
|
||||
name: TOOL_NAMES.BROWSER.SWITCH_TAB,
|
||||
args: { tabId: targetTabId },
|
||||
});
|
||||
} else {
|
||||
throw new Error('switchTab: no matching tab');
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'closeTab': {
|
||||
const s = step as any;
|
||||
const args: any = {};
|
||||
if (Array.isArray(s.tabIds) && s.tabIds.length) args.tabIds = s.tabIds;
|
||||
if (s.url) args.url = s.url;
|
||||
const res = await handleCallTool({ name: TOOL_NAMES.BROWSER.CLOSE_TABS, args });
|
||||
if ((res as any).isError) throw new Error('closeTab failed');
|
||||
break;
|
||||
}
|
||||
case 'scroll': {
|
||||
const s = step as StepScroll;
|
||||
const top = s.offset?.y ?? undefined;
|
||||
@@ -390,20 +591,34 @@ export async function runFlow(flow: Flow, options: RunOptions = {}): Promise<Run
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
const res = await handleCallTool({
|
||||
name: TOOL_NAMES.BROWSER.CLICK,
|
||||
args: {
|
||||
ref: located?.ref || (step as any).target?.ref,
|
||||
selector: !located?.ref
|
||||
? (step as any).target?.candidates?.find(
|
||||
(c: any) => c.type === 'css' || c.type === 'attr',
|
||||
)?.value
|
||||
: undefined,
|
||||
waitForNavigation: (step as any).after?.waitForNavigation || false,
|
||||
timeout: Math.max(1000, Math.min(step.timeoutMs || 10000, 30000)),
|
||||
},
|
||||
});
|
||||
const prevInfo = await getActiveTabInfo();
|
||||
let res: any;
|
||||
if (step.type === 'dblclick') {
|
||||
// Use precise CDP-based double click for robustness
|
||||
res = await handleCallTool({
|
||||
name: TOOL_NAMES.BROWSER.COMPUTER,
|
||||
args: { action: 'double_click', ref: located?.ref || (step as any).target?.ref },
|
||||
});
|
||||
} else {
|
||||
res = await handleCallTool({
|
||||
name: TOOL_NAMES.BROWSER.CLICK,
|
||||
args: {
|
||||
ref: located?.ref || (step as any).target?.ref,
|
||||
selector: !located?.ref
|
||||
? (step as any).target?.candidates?.find(
|
||||
(c: any) => c.type === 'css' || c.type === 'attr',
|
||||
)?.value
|
||||
: undefined,
|
||||
waitForNavigation: false, // we handle navigation explicitly below
|
||||
timeout: Math.max(1000, Math.min(step.timeoutMs || 10000, 30000)),
|
||||
},
|
||||
});
|
||||
}
|
||||
if ((res as any).isError) throw new Error('click failed');
|
||||
// If navigation requested, wait explicitly with retries handled by outer loop
|
||||
if ((step as any).after?.waitForNavigation) {
|
||||
await waitForNavigation(prevInfo.url, Math.max(step.timeoutMs || 15000, 3000));
|
||||
}
|
||||
if (fallbackUsed) {
|
||||
logs.push({
|
||||
stepId: step.id,
|
||||
@@ -521,16 +736,32 @@ export async function runFlow(flow: Flow, options: RunOptions = {}): Promise<Run
|
||||
case 'wait': {
|
||||
const s = step as StepWait;
|
||||
if ('text' in s.condition) {
|
||||
const res = await handleCallTool({
|
||||
name: TOOL_NAMES.BROWSER.COMPUTER,
|
||||
args: {
|
||||
action: 'wait',
|
||||
text: s.condition.text,
|
||||
appear: s.condition.appear !== false,
|
||||
timeout: Math.max(0, Math.min(step.timeoutMs || 10000, 120000)),
|
||||
},
|
||||
});
|
||||
if ((res as any).isError) throw new Error('wait text failed');
|
||||
// Use wait-helper for text appearance/disappearance for more robustness
|
||||
try {
|
||||
await handleCallTool({
|
||||
name: TOOL_NAMES.BROWSER.INJECT_SCRIPT,
|
||||
args: { type: 'ISOLATED', jsScript: '' },
|
||||
});
|
||||
} catch (e) {
|
||||
// noop
|
||||
void 0;
|
||||
}
|
||||
const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
|
||||
const tabId = tabs?.[0]?.id;
|
||||
if (typeof tabId !== 'number') throw new Error('Active tab not found');
|
||||
// Ensure wait-helper is present
|
||||
await chrome.scripting.executeScript({
|
||||
target: { tabId },
|
||||
files: ['inject-scripts/wait-helper.js'],
|
||||
world: 'ISOLATED',
|
||||
} as any);
|
||||
const resp = await chrome.tabs.sendMessage(tabId, {
|
||||
action: 'waitForText',
|
||||
text: s.condition.text,
|
||||
appear: s.condition.appear !== false,
|
||||
timeout: Math.max(0, Math.min(step.timeoutMs || 10000, 120000)),
|
||||
} as any);
|
||||
if (!resp || resp.success !== true) throw new Error('wait text failed');
|
||||
} else if ('networkIdle' in s.condition) {
|
||||
const total = Math.min(Math.max(1000, step.timeoutMs || 5000), 120000);
|
||||
const idle = Math.min(1500, Math.max(500, Math.floor(total / 3)));
|
||||
@@ -540,17 +771,22 @@ export async function runFlow(flow: Flow, options: RunOptions = {}): Promise<Run
|
||||
const delay = Math.min(step.timeoutMs || 5000, 20000);
|
||||
await new Promise((r) => setTimeout(r, delay));
|
||||
} else if ('selector' in s.condition) {
|
||||
// best-effort: simple text wait with selector string as text
|
||||
const res = await handleCallTool({
|
||||
name: TOOL_NAMES.BROWSER.COMPUTER,
|
||||
args: {
|
||||
action: 'wait',
|
||||
text: s.condition.selector,
|
||||
appear: s.condition.visible !== false,
|
||||
timeout: Math.max(0, Math.min(step.timeoutMs || 10000, 120000)),
|
||||
},
|
||||
});
|
||||
if ((res as any).isError) throw new Error('wait selector failed');
|
||||
// Use wait-helper to wait for selector visibility
|
||||
const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
|
||||
const tabId = tabs?.[0]?.id;
|
||||
if (typeof tabId !== 'number') throw new Error('Active tab not found');
|
||||
await chrome.scripting.executeScript({
|
||||
target: { tabId },
|
||||
files: ['inject-scripts/wait-helper.js'],
|
||||
world: 'ISOLATED',
|
||||
} as any);
|
||||
const resp = await chrome.tabs.sendMessage(tabId, {
|
||||
action: 'waitForSelector',
|
||||
selector: (s.condition as any).selector,
|
||||
visible: (s.condition as any).visible !== false,
|
||||
timeout: Math.max(0, Math.min(step.timeoutMs || 10000, 120000)),
|
||||
} as any);
|
||||
if (!resp || resp.success !== true) throw new Error('wait selector failed');
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -709,15 +945,28 @@ export async function runFlow(flow: Flow, options: RunOptions = {}): Promise<Run
|
||||
break;
|
||||
}
|
||||
case 'script': {
|
||||
const world = (step as any).world || 'ISOLATED';
|
||||
const code = String((step as any).code || '');
|
||||
const s = step as any;
|
||||
const world = s.world || 'ISOLATED';
|
||||
const code = String(s.code || '');
|
||||
if (!code.trim()) break;
|
||||
const wrapped = `(() => { try { ${code} } catch (e) { console.error('flow script error:', e); } })();`;
|
||||
const res = await handleCallTool({
|
||||
name: TOOL_NAMES.BROWSER.INJECT_SCRIPT,
|
||||
args: { type: world, jsScript: wrapped },
|
||||
});
|
||||
if ((res as any).isError) throw new Error('script execution failed');
|
||||
// Prefer executeScript to capture return value for saveAs/assign
|
||||
const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
|
||||
const tabId = tabs?.[0]?.id;
|
||||
if (typeof tabId !== 'number') throw new Error('Active tab not found');
|
||||
const [{ result }] = await chrome.scripting.executeScript({
|
||||
target: { tabId },
|
||||
func: (userCode: string) => {
|
||||
try {
|
||||
return (0, eval)(userCode);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
args: [code],
|
||||
world: world as any,
|
||||
} as any);
|
||||
if (s.saveAs) vars[s.saveAs] = result;
|
||||
if (s.assign && typeof s.assign === 'object') applyAssign(vars, result, s.assign);
|
||||
break;
|
||||
}
|
||||
case 'navigate': {
|
||||
@@ -757,12 +1006,24 @@ export async function runFlow(flow: Flow, options: RunOptions = {}): Promise<Run
|
||||
const world = (s as any).world || 'ISOLATED';
|
||||
const code = String((s as any).code || '');
|
||||
if (code.trim()) {
|
||||
const wrapped = `(() => { try { ${code} } catch (e) { console.error('flow script error:', e); } })();`;
|
||||
const res = await handleCallTool({
|
||||
name: TOOL_NAMES.BROWSER.INJECT_SCRIPT,
|
||||
args: { type: world, jsScript: wrapped },
|
||||
});
|
||||
if ((res as any).isError) throw new Error('script(after) execution failed');
|
||||
const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
|
||||
const tabId = tabs?.[0]?.id;
|
||||
if (typeof tabId !== 'number') throw new Error('Active tab not found');
|
||||
const [{ result }] = await chrome.scripting.executeScript({
|
||||
target: { tabId },
|
||||
func: (userCode: string) => {
|
||||
try {
|
||||
return (0, eval)(userCode);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
args: [code],
|
||||
world: world as any,
|
||||
} as any);
|
||||
if ((s as any).saveAs) vars[(s as any).saveAs] = result;
|
||||
if ((s as any).assign && typeof (s as any).assign === 'object')
|
||||
applyAssign(vars, result, (s as any).assign);
|
||||
}
|
||||
logs.push({ stepId: s.id, status: 'success', tookMs: Date.now() - tScript });
|
||||
}
|
||||
@@ -859,8 +1120,8 @@ export async function runFlow(flow: Flow, options: RunOptions = {}): Promise<Run
|
||||
runId,
|
||||
success: failed === 0,
|
||||
summary: {
|
||||
total: flow.steps.length,
|
||||
success: flow.steps.length - failed,
|
||||
total: steps.length,
|
||||
success: steps.length - failed,
|
||||
failed,
|
||||
tookMs,
|
||||
},
|
||||
@@ -870,3 +1131,73 @@ export async function runFlow(flow: Flow, options: RunOptions = {}): Promise<Run
|
||||
screenshots: { onFailure: logs.find((l) => l.status === 'failed')?.screenshotBase64 },
|
||||
};
|
||||
}
|
||||
|
||||
// --- DAG helpers (M1: default-edge serial) ---
|
||||
function topoOrder(nodes: DagNode[], edges: DagEdge[]): DagNode[] {
|
||||
const id2n = new Map(nodes.map((n) => [n.id, n] as const));
|
||||
const indeg = new Map<string, number>(nodes.map((n) => [n.id, 0] as const));
|
||||
for (const e of edges) indeg.set(e.to, (indeg.get(e.to) || 0) + 1);
|
||||
const nexts = new Map<string, string[]>(nodes.map((n) => [n.id, [] as string[]] as const));
|
||||
for (const e of edges) nexts.get(e.from)!.push(e.to);
|
||||
const q: string[] = nodes.filter((n) => (indeg.get(n.id) || 0) === 0).map((n) => n.id);
|
||||
const out: DagNode[] = [];
|
||||
while (q.length) {
|
||||
const id = q.shift()!;
|
||||
const n = id2n.get(id);
|
||||
if (!n) continue;
|
||||
out.push(n);
|
||||
for (const v of nexts.get(id)!) {
|
||||
indeg.set(v, (indeg.get(v) || 0) - 1);
|
||||
if ((indeg.get(v) || 0) === 0) q.push(v);
|
||||
}
|
||||
}
|
||||
return out.length === nodes.length ? out : nodes.slice();
|
||||
}
|
||||
|
||||
function mapDagNodeToStep(n: DagNode): Step {
|
||||
const c: any = n.config || {};
|
||||
const base = { id: n.id } as any;
|
||||
if (n.type === 'click' || n.type === 'dblclick')
|
||||
return {
|
||||
...base,
|
||||
type: n.type,
|
||||
target: c.target || { candidates: [] },
|
||||
before: c.before,
|
||||
after: c.after,
|
||||
} as any;
|
||||
if (n.type === 'fill')
|
||||
return {
|
||||
...base,
|
||||
type: 'fill',
|
||||
target: c.target || { candidates: [] },
|
||||
value: c.value || '',
|
||||
} as any;
|
||||
if (n.type === 'key') return { ...base, type: 'key', keys: c.keys || '' } as any;
|
||||
if (n.type === 'wait')
|
||||
return { ...base, type: 'wait', condition: c.condition || { text: '', appear: true } } as any;
|
||||
if (n.type === 'assert')
|
||||
return {
|
||||
...base,
|
||||
type: 'assert',
|
||||
assert: c.assert || { exists: '' },
|
||||
failStrategy: c.failStrategy,
|
||||
} as any;
|
||||
if (n.type === 'navigate') return { ...base, type: 'navigate', url: c.url || '' } as any;
|
||||
if (n.type === 'script')
|
||||
return {
|
||||
...base,
|
||||
type: 'script',
|
||||
world: c.world || 'ISOLATED',
|
||||
code: c.code || '',
|
||||
when: c.when,
|
||||
} as any;
|
||||
if (n.type === 'delay')
|
||||
return {
|
||||
...base,
|
||||
type: 'wait',
|
||||
timeoutMs: Math.max(0, Number(c.ms ?? 1000)),
|
||||
condition: { navigation: true },
|
||||
} as any;
|
||||
// Fallback: no-op script
|
||||
return { ...base, type: 'script', world: 'ISOLATED', code: '' } as any;
|
||||
}
|
||||
|
||||
@@ -108,3 +108,35 @@ export async function importFlowFromJson(json: string): Promise<Flow[]> {
|
||||
}
|
||||
return flowsToImport;
|
||||
}
|
||||
|
||||
// Scheduling support
|
||||
export type ScheduleType = 'once' | 'interval' | 'daily';
|
||||
export interface FlowSchedule {
|
||||
id: string; // schedule id
|
||||
flowId: string;
|
||||
type: ScheduleType;
|
||||
enabled: boolean;
|
||||
// when: ISO string for 'once'; HH:mm for 'daily'; minutes for 'interval'
|
||||
when: string;
|
||||
// optional variables to pass when running
|
||||
args?: Record<string, any>;
|
||||
}
|
||||
|
||||
export async function listSchedules(): Promise<FlowSchedule[]> {
|
||||
const res = await chrome.storage.local.get([STORAGE_KEYS.RR_SCHEDULES]);
|
||||
return (res[STORAGE_KEYS.RR_SCHEDULES] as FlowSchedule[]) || [];
|
||||
}
|
||||
|
||||
export async function saveSchedule(s: FlowSchedule): Promise<void> {
|
||||
const list = await listSchedules();
|
||||
const idx = list.findIndex((x) => x.id === s.id);
|
||||
if (idx >= 0) list[idx] = s;
|
||||
else list.push(s);
|
||||
await chrome.storage.local.set({ [STORAGE_KEYS.RR_SCHEDULES]: list });
|
||||
}
|
||||
|
||||
export async function removeSchedule(scheduleId: string): Promise<void> {
|
||||
const list = await listSchedules();
|
||||
const filtered = list.filter((s) => s.id !== scheduleId);
|
||||
await chrome.storage.local.set({ [STORAGE_KEYS.RR_SCHEDULES]: filtered });
|
||||
}
|
||||
|
||||
@@ -10,6 +10,10 @@ import {
|
||||
exportFlow,
|
||||
exportAllFlows,
|
||||
importFlowFromJson,
|
||||
listSchedules,
|
||||
saveSchedule,
|
||||
removeSchedule,
|
||||
type FlowSchedule,
|
||||
} from './flow-store';
|
||||
import { runFlow } from './flow-runner';
|
||||
|
||||
@@ -21,6 +25,39 @@ let lastClickIdx: number | null = null;
|
||||
let lastClickTime = 0;
|
||||
let lastNavTaggedAt = 0;
|
||||
|
||||
// Alarm helpers for schedules
|
||||
async function rescheduleAlarms() {
|
||||
const schedules = await listSchedules();
|
||||
// Clear existing rr_schedule_* alarms
|
||||
const alarms = await chrome.alarms.getAll();
|
||||
await Promise.all(
|
||||
alarms
|
||||
.filter((a) => a.name && a.name.startsWith('rr_schedule_'))
|
||||
.map((a) => chrome.alarms.clear(a.name)),
|
||||
);
|
||||
for (const s of schedules) {
|
||||
if (!s.enabled) continue;
|
||||
const name = `rr_schedule_${s.id}`;
|
||||
if (s.type === 'interval') {
|
||||
const minutes = Math.max(1, Math.floor(Number(s.when) || 0));
|
||||
await chrome.alarms.create(name, { periodInMinutes: minutes });
|
||||
} else if (s.type === 'once') {
|
||||
const whenMs = Date.parse(s.when);
|
||||
if (Number.isFinite(whenMs)) await chrome.alarms.create(name, { when: whenMs });
|
||||
} else if (s.type === 'daily') {
|
||||
// daily HH:mm local time
|
||||
const [hh, mm] = String(s.when || '00:00')
|
||||
.split(':')
|
||||
.map((x) => Number(x));
|
||||
const now = new Date();
|
||||
const next = new Date();
|
||||
next.setHours(hh || 0, mm || 0, 0, 0);
|
||||
if (next.getTime() <= now.getTime()) next.setDate(next.getDate() + 1);
|
||||
await chrome.alarms.create(name, { when: next.getTime(), periodInMinutes: 24 * 60 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureRecorderInjected(tabId: number): Promise<void> {
|
||||
// Inject helper and recorder scripts
|
||||
await chrome.scripting.executeScript({
|
||||
@@ -96,6 +133,9 @@ async function stopRecording(): Promise<{ success: boolean; flow?: Flow; error?:
|
||||
}
|
||||
|
||||
export function initRecordReplayListeners() {
|
||||
// On startup, re-schedule alarms
|
||||
rescheduleAlarms().catch(() => {});
|
||||
|
||||
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||
try {
|
||||
if (message && message.type === 'rr_recorder_event') {
|
||||
@@ -231,6 +271,40 @@ export function initRecordReplayListeners() {
|
||||
.catch((e) => sendResponse({ success: false, error: e?.message || String(e) }));
|
||||
return true;
|
||||
}
|
||||
case BACKGROUND_MESSAGE_TYPES.RR_LIST_SCHEDULES: {
|
||||
listSchedules()
|
||||
.then((s) => sendResponse({ success: true, schedules: s }))
|
||||
.catch((e) => sendResponse({ success: false, error: e?.message || String(e) }));
|
||||
return true;
|
||||
}
|
||||
case BACKGROUND_MESSAGE_TYPES.RR_SCHEDULE_FLOW: {
|
||||
const s = message.schedule as FlowSchedule;
|
||||
if (!s || !s.id || !s.flowId) {
|
||||
sendResponse({ success: false, error: 'invalid schedule' });
|
||||
return true;
|
||||
}
|
||||
saveSchedule(s)
|
||||
.then(async () => {
|
||||
await rescheduleAlarms();
|
||||
sendResponse({ success: true });
|
||||
})
|
||||
.catch((e) => sendResponse({ success: false, error: e?.message || String(e) }));
|
||||
return true;
|
||||
}
|
||||
case BACKGROUND_MESSAGE_TYPES.RR_UNSCHEDULE_FLOW: {
|
||||
const scheduleId = String(message.scheduleId || '');
|
||||
if (!scheduleId) {
|
||||
sendResponse({ success: false, error: 'invalid scheduleId' });
|
||||
return true;
|
||||
}
|
||||
removeSchedule(scheduleId)
|
||||
.then(async () => {
|
||||
await rescheduleAlarms();
|
||||
sendResponse({ success: true });
|
||||
})
|
||||
.catch((e) => sendResponse({ success: false, error: e?.message || String(e) }));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
sendResponse({ success: false, error: (err as any)?.message || String(err) });
|
||||
@@ -365,3 +439,19 @@ export function initRecordReplayListeners() {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Alarm listener executes scheduled flows
|
||||
chrome.alarms.onAlarm.addListener(async (alarm) => {
|
||||
try {
|
||||
if (!alarm?.name || !alarm.name.startsWith('rr_schedule_')) return;
|
||||
const id = alarm.name.slice('rr_schedule_'.length);
|
||||
const schedules = await listSchedules();
|
||||
const s = schedules.find((x) => x.id === id && x.enabled);
|
||||
if (!s) return;
|
||||
const flow = await getFlow(s.flowId);
|
||||
if (!flow) return;
|
||||
await runFlow(flow, { args: s.args || {}, returnLogs: false });
|
||||
} catch (e) {
|
||||
// swallow to not spam logs
|
||||
}
|
||||
});
|
||||
|
||||
@@ -52,13 +52,43 @@ export async function locateElement(
|
||||
return { ref: ensured.ref, center: ensured.center, resolvedBy: c.type };
|
||||
}
|
||||
} else if (c.type === 'aria') {
|
||||
// Best-effort: try as CSS first, otherwise ignore in M2
|
||||
const ensured = await chrome.tabs.sendMessage(tabId, {
|
||||
action: TOOL_MESSAGE_TYPES.ENSURE_REF_FOR_SELECTOR,
|
||||
selector: c.value,
|
||||
});
|
||||
if (ensured && ensured.success && ensured.ref && ensured.center) {
|
||||
return { ref: ensured.ref, center: ensured.center, resolvedBy: c.type };
|
||||
// Minimal ARIA role+name parser like: "button[name=提交]" or "textbox[name=用户名]"
|
||||
const v = String(c.value || '').trim();
|
||||
const m = v.match(/^(\w+)\s*\[\s*name\s*=\s*([^\]]+)\]$/);
|
||||
const role = m ? m[1] : '';
|
||||
const name = m ? m[2] : '';
|
||||
const cleanName = name.replace(/^['"]|['"]$/g, '');
|
||||
const ariaSelectors: string[] = [];
|
||||
if (role === 'textbox') {
|
||||
ariaSelectors.push(
|
||||
`[role="textbox"][aria-label=${JSON.stringify(cleanName)}]`,
|
||||
`input[aria-label=${JSON.stringify(cleanName)}]`,
|
||||
`textarea[aria-label=${JSON.stringify(cleanName)}]`,
|
||||
);
|
||||
} else if (role === 'button') {
|
||||
ariaSelectors.push(
|
||||
`[role="button"][aria-label=${JSON.stringify(cleanName)}]`,
|
||||
`button[aria-label=${JSON.stringify(cleanName)}]`,
|
||||
);
|
||||
} else if (role === 'link') {
|
||||
ariaSelectors.push(
|
||||
`[role="link"][aria-label=${JSON.stringify(cleanName)}]`,
|
||||
`a[aria-label=${JSON.stringify(cleanName)}]`,
|
||||
);
|
||||
}
|
||||
if (!ariaSelectors.length && role) {
|
||||
ariaSelectors.push(
|
||||
`[role=${JSON.stringify(role)}][aria-label=${JSON.stringify(cleanName)}]`,
|
||||
);
|
||||
}
|
||||
for (const sel of ariaSelectors) {
|
||||
const ensured = await chrome.tabs.sendMessage(tabId, {
|
||||
action: TOOL_MESSAGE_TYPES.ENSURE_REF_FOR_SELECTOR,
|
||||
selector: sel,
|
||||
});
|
||||
if (ensured && ensured.success && ensured.ref && ensured.center) {
|
||||
return { ref: ensured.ref, center: ensured.center, resolvedBy: c.type };
|
||||
}
|
||||
}
|
||||
} else if (c.type === 'xpath') {
|
||||
// Minimal xpath support via document.evaluate through injected helper
|
||||
|
||||
@@ -23,7 +23,12 @@ export type StepType =
|
||||
| 'wait'
|
||||
| 'assert'
|
||||
| 'script'
|
||||
| 'navigate';
|
||||
| 'navigate'
|
||||
| 'http'
|
||||
| 'extract'
|
||||
| 'openTab'
|
||||
| 'switchTab'
|
||||
| 'closeTab';
|
||||
|
||||
export interface StepBase {
|
||||
id: string;
|
||||
@@ -102,7 +107,49 @@ export type Step =
|
||||
| StepWait
|
||||
| StepAssert
|
||||
| StepScript
|
||||
| (StepBase & { type: 'navigate'; url: string });
|
||||
| (StepBase & { type: 'navigate'; url: string })
|
||||
| StepHttp
|
||||
| StepExtract
|
||||
| StepOpenTab
|
||||
| StepSwitchTab
|
||||
| StepCloseTab;
|
||||
|
||||
export interface StepHttp extends StepBase {
|
||||
type: 'http';
|
||||
method?: string;
|
||||
url: string;
|
||||
headers?: Record<string, string>;
|
||||
body?: any;
|
||||
saveAs?: string;
|
||||
assign?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface StepExtract extends StepBase {
|
||||
type: 'extract';
|
||||
selector?: string;
|
||||
attr?: string; // 'text'|'textContent' to read text
|
||||
js?: string; // custom JS that returns value
|
||||
saveAs: string;
|
||||
}
|
||||
|
||||
export interface StepOpenTab extends StepBase {
|
||||
type: 'openTab';
|
||||
url?: string;
|
||||
newWindow?: boolean;
|
||||
}
|
||||
|
||||
export interface StepSwitchTab extends StepBase {
|
||||
type: 'switchTab';
|
||||
tabId?: number;
|
||||
urlContains?: string;
|
||||
titleContains?: string;
|
||||
}
|
||||
|
||||
export interface StepCloseTab extends StepBase {
|
||||
type: 'closeTab';
|
||||
tabIds?: number[];
|
||||
url?: string;
|
||||
}
|
||||
|
||||
export interface VariableDef {
|
||||
key: string;
|
||||
@@ -112,6 +159,38 @@ export interface VariableDef {
|
||||
rules?: { required?: boolean; pattern?: string };
|
||||
}
|
||||
|
||||
export type NodeType =
|
||||
| 'click'
|
||||
| 'dblclick'
|
||||
| 'fill'
|
||||
| 'key'
|
||||
| 'wait'
|
||||
| 'assert'
|
||||
| 'script'
|
||||
| 'navigate'
|
||||
| 'openTab'
|
||||
| 'switchTab'
|
||||
| 'closeTab'
|
||||
| 'http'
|
||||
| 'extract'
|
||||
| 'delay';
|
||||
|
||||
export interface NodeBase {
|
||||
id: string;
|
||||
type: NodeType;
|
||||
name?: string;
|
||||
disabled?: boolean;
|
||||
config?: any;
|
||||
ui?: { x: number; y: number };
|
||||
}
|
||||
|
||||
export interface Edge {
|
||||
id: string;
|
||||
from: string;
|
||||
to: string;
|
||||
label?: 'default' | 'true' | 'false' | 'onError';
|
||||
}
|
||||
|
||||
export interface Flow {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -128,6 +207,10 @@ export interface Flow {
|
||||
};
|
||||
variables?: VariableDef[];
|
||||
steps: Step[];
|
||||
// Flow V2(可选):画布编排
|
||||
nodes?: NodeBase[];
|
||||
edges?: Edge[];
|
||||
subflows?: Record<string, { nodes: NodeBase[]; edges: Edge[] }>;
|
||||
}
|
||||
|
||||
export interface RunLogEntry {
|
||||
|
||||
@@ -252,7 +252,10 @@
|
||||
</div>
|
||||
<div class="rr-actions">
|
||||
<button class="semantic-engine-button" @click="runFlow(f.id)">回放</button>
|
||||
<button class="semantic-engine-button" @click="editFlow(f)">表单编辑</button>
|
||||
<button class="semantic-engine-button" @click="openBuilder(f)">画布编辑</button>
|
||||
<button class="semantic-engine-button" @click="publishFlow(f.id)">发布</button>
|
||||
<button class="semantic-engine-button" @click="openSchedule(f.id)">定时</button>
|
||||
<button class="danger-button" @click="deleteFlow(f.id)">删除</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -291,6 +294,27 @@
|
||||
@confirm="confirmClearAllData"
|
||||
@cancel="hideClearDataConfirmation"
|
||||
/>
|
||||
|
||||
<FlowEditor
|
||||
:visible="showFlowEditor"
|
||||
:flow="editingFlow"
|
||||
@close="showFlowEditor = false"
|
||||
@save="saveEditedFlow"
|
||||
/>
|
||||
<BuilderEditor
|
||||
:visible="showBuilderEditor"
|
||||
:flow="editingFlowBuilder"
|
||||
@close="showBuilderEditor = false"
|
||||
@save="saveEditedFlowFromBuilder"
|
||||
/>
|
||||
<ScheduleDialog
|
||||
:visible="showSchedule"
|
||||
:flow-id="schedulingFlowId"
|
||||
:schedules="schedules.filter((s) => s.flowId === schedulingFlowId)"
|
||||
@close="showSchedule = false"
|
||||
@save="saveSchedule"
|
||||
@remove="removeSchedule"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -310,6 +334,9 @@ import { getMessage } from '@/utils/i18n';
|
||||
import ConfirmDialog from './components/ConfirmDialog.vue';
|
||||
import ProgressIndicator from './components/ProgressIndicator.vue';
|
||||
import ModelCacheManagement from './components/ModelCacheManagement.vue';
|
||||
import FlowEditor from './components/FlowEditor.vue';
|
||||
import BuilderEditor from './components/BuilderEditor.vue';
|
||||
import ScheduleDialog from './components/ScheduleDialog.vue';
|
||||
import {
|
||||
DocumentIcon,
|
||||
DatabaseIcon,
|
||||
@@ -329,6 +356,15 @@ const filteredRrFlows = computed(() =>
|
||||
rrOnlyBound.value ? rrFlows.value.filter(isFlowBoundToCurrent) : rrFlows.value,
|
||||
);
|
||||
|
||||
// Flow editor state
|
||||
const showFlowEditor = ref(false);
|
||||
const editingFlow = ref<any | null>(null);
|
||||
const showBuilderEditor = ref(false);
|
||||
const editingFlowBuilder = ref<any | null>(null);
|
||||
const showSchedule = ref(false);
|
||||
const schedulingFlowId = ref<string | null>(null);
|
||||
const schedules = ref<any[]>([]);
|
||||
|
||||
const loadFlows = async () => {
|
||||
try {
|
||||
const res = await chrome.runtime.sendMessage({ type: BACKGROUND_MESSAGE_TYPES.RR_LIST_FLOWS });
|
||||
@@ -420,6 +456,93 @@ const deleteFlow = async (flowId: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
function editFlow(flow: any) {
|
||||
editingFlow.value = flow;
|
||||
showFlowEditor.value = true;
|
||||
}
|
||||
|
||||
function openBuilder(flow: any) {
|
||||
editingFlowBuilder.value = flow;
|
||||
showBuilderEditor.value = true;
|
||||
}
|
||||
|
||||
async function saveEditedFlow(f: any) {
|
||||
try {
|
||||
const res = await chrome.runtime.sendMessage({
|
||||
type: BACKGROUND_MESSAGE_TYPES.RR_SAVE_FLOW,
|
||||
flow: f,
|
||||
});
|
||||
if (res && res.success) {
|
||||
showFlowEditor.value = false;
|
||||
editingFlow.value = null;
|
||||
await loadFlows();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('保存失败:', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveEditedFlowFromBuilder(f: any) {
|
||||
try {
|
||||
const res = await chrome.runtime.sendMessage({
|
||||
type: BACKGROUND_MESSAGE_TYPES.RR_SAVE_FLOW,
|
||||
flow: f,
|
||||
});
|
||||
if (res && res.success) {
|
||||
showBuilderEditor.value = false;
|
||||
editingFlowBuilder.value = null;
|
||||
await loadFlows();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('保存失败:', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function openSchedule(flowId: string) {
|
||||
schedulingFlowId.value = flowId;
|
||||
await loadSchedules();
|
||||
showSchedule.value = true;
|
||||
}
|
||||
|
||||
async function loadSchedules() {
|
||||
try {
|
||||
const res = await chrome.runtime.sendMessage({
|
||||
type: BACKGROUND_MESSAGE_TYPES.RR_LIST_SCHEDULES,
|
||||
});
|
||||
if (res && res.success) schedules.value = res.schedules || [];
|
||||
} catch (e) {
|
||||
console.error('加载定时失败:', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveSchedule(schedule: any) {
|
||||
try {
|
||||
const res = await chrome.runtime.sendMessage({
|
||||
type: BACKGROUND_MESSAGE_TYPES.RR_SCHEDULE_FLOW,
|
||||
schedule,
|
||||
});
|
||||
if (res && res.success) {
|
||||
await loadSchedules();
|
||||
showSchedule.value = false;
|
||||
schedulingFlowId.value = null;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('保存定时失败:', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function removeSchedule(id: string) {
|
||||
try {
|
||||
const res = await chrome.runtime.sendMessage({
|
||||
type: BACKGROUND_MESSAGE_TYPES.RR_UNSCHEDULE_FLOW,
|
||||
scheduleId: id,
|
||||
});
|
||||
if (res && res.success) await loadSchedules();
|
||||
} catch (e) {
|
||||
console.error('删除计划失败:', e);
|
||||
}
|
||||
}
|
||||
|
||||
const nativeConnectionStatus = ref<'unknown' | 'connected' | 'disconnected'>('unknown');
|
||||
const isConnecting = ref(false);
|
||||
const nativeServerPort = ref<number>(12306);
|
||||
|
||||
@@ -0,0 +1,416 @@
|
||||
<template>
|
||||
<div v-if="visible" class="builder-modal">
|
||||
<div class="builder">
|
||||
<div class="topbar">
|
||||
<div class="left">
|
||||
<strong>编排画布</strong>
|
||||
<span class="tip">基于 VueFlow(M1:串行 DAG)</span>
|
||||
</div>
|
||||
<div class="right">
|
||||
<input
|
||||
class="search"
|
||||
v-model="search"
|
||||
placeholder="搜索节点名或选择器... 回车定位"
|
||||
@keyup.enter="focusSearch"
|
||||
/>
|
||||
<button class="btn" @click="store.undo" title="撤销 (⌘/Ctrl+Z)">撤销</button>
|
||||
<button class="btn" @click="store.redo" title="重做 (⌘/Ctrl+Shift+Z)">重做</button>
|
||||
<span class="status" :data-state="saveState">{{ saveLabel }}</span>
|
||||
<span class="status" v-if="errorsCount > 0" title="存在校验错误">{{
|
||||
`错误: ${errorsCount}`
|
||||
}}</span>
|
||||
<button class="btn" @click="importFromSteps" title="从线性步骤生成图">步骤→图</button>
|
||||
<button class="btn" @click="exportToSteps" title="用当前图覆盖步骤">图→步骤</button>
|
||||
<button class="btn" @click="store.layoutAuto" title="自动排版(简单拓扑布局)"
|
||||
>自动排版</button
|
||||
>
|
||||
<button class="btn" @click="fitAll" title="自适应视图">自适应</button>
|
||||
<button class="btn" @click="exportFlow" title="导出 JSON">导出</button>
|
||||
<label class="btn import">
|
||||
导入
|
||||
<input type="file" accept="application/json" @change="onImport" />
|
||||
</label>
|
||||
<button
|
||||
class="btn"
|
||||
:disabled="!selectedId"
|
||||
@click="runFromSelected"
|
||||
title="从选中节点回放"
|
||||
>从选中回放</button
|
||||
>
|
||||
<button v-if="errorsCount > 0" class="btn" @click="toggleErrors">错误列表</button>
|
||||
<button class="btn primary" @click="save">保存</button>
|
||||
<button class="btn" @click="$emit('close')">关闭</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="main">
|
||||
<Sidebar
|
||||
:flow="store.flowLocal"
|
||||
:palette-types="store.paletteTypes"
|
||||
@add-node="store.addNode"
|
||||
/>
|
||||
<Canvas
|
||||
:nodes="store.nodes"
|
||||
:edges="store.edges"
|
||||
:focus-node-id="focusNodeId"
|
||||
:fit-seq="fitSeq"
|
||||
@select-node="store.selectNode"
|
||||
@duplicate-node="store.duplicateNode"
|
||||
@remove-node="store.removeNode"
|
||||
@connect-from="store.connectFrom"
|
||||
@connect="store.onConnect"
|
||||
@node-dragged="store.setNodePosition"
|
||||
/>
|
||||
<PropertyPanel :node="activeNode" :highlight-field="highlightField" />
|
||||
<div v-if="showErrors && errorsCount > 0" class="error-panel">
|
||||
<div class="err-title">校验错误(点击定位)</div>
|
||||
<div class="err-list">
|
||||
<div
|
||||
v-for="(errs, nid) in validation.nodeErrors"
|
||||
:key="nid"
|
||||
class="err-item"
|
||||
@click="focusError(String(nid), errs[0])"
|
||||
>
|
||||
<div class="nid">{{ String(nid) }}</div>
|
||||
<div class="elist">
|
||||
<div v-for="e in errs" :key="e" class="e">• {{ e }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, watch, onMounted, onUnmounted, ref } from 'vue';
|
||||
import type { Flow as FlowV2 } from '@/entrypoints/background/record-replay/types';
|
||||
import { useBuilderStore } from './builder/store/useBuilderStore';
|
||||
import { nodesToSteps } from './builder/model/transforms';
|
||||
import { validateFlow } from './builder/model/validation';
|
||||
import { BACKGROUND_MESSAGE_TYPES } from '@/common/message-types';
|
||||
import Canvas from './builder/components/Canvas.vue';
|
||||
import Sidebar from './builder/components/Sidebar.vue';
|
||||
import PropertyPanel from './builder/components/PropertyPanel.vue';
|
||||
|
||||
const props = defineProps<{ visible: boolean; flow: FlowV2 | null }>();
|
||||
const emit = defineEmits(['close', 'save']);
|
||||
|
||||
const store = useBuilderStore();
|
||||
|
||||
watch(
|
||||
() => props.flow,
|
||||
(f) => {
|
||||
if (f) store.initFromFlow(f);
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
// 由于 store.activeNodeId 是一个 Ref,这里统一取其值避免 TS 比较报错
|
||||
const selectedId = computed<string | null>(() => (store.activeNodeId as any)?.value ?? null);
|
||||
const activeNode = computed(() => store.nodes.find((n) => n.id === selectedId.value) || null);
|
||||
const validation = computed(() => validateFlow(store.nodes));
|
||||
const errorsCount = computed(() => validation.value.totalErrors);
|
||||
const showErrors = ref(false);
|
||||
function toggleErrors() {
|
||||
showErrors.value = !showErrors.value;
|
||||
}
|
||||
|
||||
// 搜索与聚焦
|
||||
const search = ref('');
|
||||
const focusNodeId = ref<string | null>(null);
|
||||
const highlightField = ref<string | null>(null);
|
||||
const fitSeq = ref(0);
|
||||
function focusSearch() {
|
||||
const q = search.value.trim().toLowerCase();
|
||||
if (!q) return;
|
||||
const hit = store.nodes.find(
|
||||
(n) =>
|
||||
(n.name || '').toLowerCase().includes(q) ||
|
||||
(n.config?.target?.candidates?.[0]?.value || '').toLowerCase().includes(q),
|
||||
);
|
||||
if (hit) {
|
||||
store.selectNode(hit.id);
|
||||
focusNodeId.value = hit.id;
|
||||
setTimeout(() => (focusNodeId.value = null), 300);
|
||||
}
|
||||
}
|
||||
|
||||
function importFromSteps() {
|
||||
store.importFromSteps();
|
||||
}
|
||||
function exportToSteps() {
|
||||
store.flowLocal.steps = nodesToSteps(store.nodes, store.edges);
|
||||
}
|
||||
function save() {
|
||||
store.flowLocal.steps = nodesToSteps(store.nodes, store.edges);
|
||||
const result = JSON.parse(
|
||||
JSON.stringify({ ...store.flowLocal, nodes: store.nodes, edges: store.edges }),
|
||||
);
|
||||
emit('save', result);
|
||||
}
|
||||
|
||||
async function runFromSelected() {
|
||||
if (!selectedId.value || !store.flowLocal?.id) return;
|
||||
try {
|
||||
await save();
|
||||
const res = await chrome.runtime.sendMessage({
|
||||
type: BACKGROUND_MESSAGE_TYPES.RR_RUN_FLOW,
|
||||
flowId: store.flowLocal.id,
|
||||
options: { returnLogs: true, startNodeId: selectedId.value },
|
||||
});
|
||||
if (!(res && res.success)) console.warn('从选中节点回放失败');
|
||||
} catch (e) {
|
||||
console.error('从选中节点回放失败:', e);
|
||||
}
|
||||
}
|
||||
|
||||
function focusNode(id: string) {
|
||||
store.selectNode(id);
|
||||
focusNodeId.value = id;
|
||||
setTimeout(() => (focusNodeId.value = null), 300);
|
||||
}
|
||||
|
||||
function focusError(nid: string, msg: string) {
|
||||
const node = store.nodes.find((n) => n.id === nid);
|
||||
if (!node) return focusNode(nid);
|
||||
focusNode(nid);
|
||||
const t = node.type;
|
||||
let field: string | null = null;
|
||||
if (t === 'http') field = 'http.url';
|
||||
else if (t === 'extract')
|
||||
field = msg.includes('保存变量名') ? 'extract.saveAs' : 'extract.selector';
|
||||
else if (t === 'switchTab') field = 'switchTab.match';
|
||||
else if (t === 'navigate') field = 'navigate.url';
|
||||
else if (t === 'fill') field = msg.includes('输入值') ? 'fill.value' : 'target.candidates';
|
||||
else if (t === 'click' || t === 'dblclick') field = 'target.candidates';
|
||||
else if (t === 'script') field = msg.includes('缺少代码') ? 'script.code' : 'script.assign';
|
||||
else field = null;
|
||||
highlightField.value = field;
|
||||
setTimeout(() => (highlightField.value = null), 1500);
|
||||
}
|
||||
|
||||
function fitAll() {
|
||||
fitSeq.value++;
|
||||
}
|
||||
|
||||
async function exportFlow() {
|
||||
try {
|
||||
await save();
|
||||
const res = await chrome.runtime.sendMessage({
|
||||
type: BACKGROUND_MESSAGE_TYPES.RR_EXPORT_FLOW,
|
||||
flowId: store.flowLocal.id,
|
||||
});
|
||||
if (res && res.success) {
|
||||
const blob = new Blob([res.json], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
await chrome.downloads.download({
|
||||
url,
|
||||
filename: `${store.flowLocal.name || 'flow'}.json`,
|
||||
saveAs: true,
|
||||
} as any);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('导出失败:', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function onImport(e: Event) {
|
||||
const input = e.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
if (!file) return;
|
||||
try {
|
||||
const txt = await file.text();
|
||||
const res = await chrome.runtime.sendMessage({
|
||||
type: BACKGROUND_MESSAGE_TYPES.RR_IMPORT_FLOW,
|
||||
json: txt,
|
||||
});
|
||||
if (res && res.success) {
|
||||
if (Array.isArray(res.flows) && res.flows.length) {
|
||||
store.initFromFlow(res.flows[0]);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('导入失败:', err);
|
||||
} finally {
|
||||
input.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
// 快捷键:Delete/Backspace 删除选中,Cmd/Ctrl+D 复制,Cmd/Ctrl+S 保存
|
||||
function onKey(e: KeyboardEvent) {
|
||||
const id = selectedId.value;
|
||||
const isMeta = e.metaKey || e.ctrlKey;
|
||||
if ((e.key === 'Delete' || e.key === 'Backspace') && id) {
|
||||
e.preventDefault();
|
||||
store.removeNode(id);
|
||||
} else if (isMeta && e.key.toLowerCase?.() === 'd') {
|
||||
if (id) {
|
||||
e.preventDefault();
|
||||
store.duplicateNode(id);
|
||||
}
|
||||
} else if (isMeta && e.key.toLowerCase?.() === 'z') {
|
||||
e.preventDefault();
|
||||
if (e.shiftKey) store.redo();
|
||||
else store.undo();
|
||||
} else if (isMeta && e.key.toLowerCase?.() === 's') {
|
||||
e.preventDefault();
|
||||
save();
|
||||
}
|
||||
}
|
||||
onMounted(() => document.addEventListener('keydown', onKey));
|
||||
onUnmounted(() => document.removeEventListener('keydown', onKey));
|
||||
|
||||
// 自动保存(去抖)
|
||||
const saveState = ref<'idle' | 'saving' | 'saved'>('idle');
|
||||
const saveLabel = computed(() =>
|
||||
saveState.value === 'saving' ? '保存中…' : saveState.value === 'saved' ? '已保存' : '',
|
||||
);
|
||||
let saveTimer: any = null;
|
||||
let statusTimer: any = null;
|
||||
function scheduleAutoSave() {
|
||||
if (saveTimer) clearTimeout(saveTimer);
|
||||
saveTimer = setTimeout(async () => {
|
||||
try {
|
||||
saveState.value = 'saving';
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
save();
|
||||
saveState.value = 'saved';
|
||||
if (statusTimer) clearTimeout(statusTimer);
|
||||
statusTimer = setTimeout(() => (saveState.value = 'idle'), 1200);
|
||||
} catch {
|
||||
saveState.value = 'idle';
|
||||
}
|
||||
}, 800);
|
||||
}
|
||||
watch(
|
||||
() => [store.nodes, store.edges, store.flowLocal.name, (store.flowLocal as any).description],
|
||||
scheduleAutoSave,
|
||||
{ deep: true },
|
||||
);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.builder-modal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
z-index: 2147483646;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.builder {
|
||||
width: 96vw;
|
||||
height: 90vh;
|
||||
background: #fff;
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
.topbar {
|
||||
height: 48px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 12px;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
.topbar .left {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
.topbar .tip {
|
||||
color: #6b7280;
|
||||
font-size: 12px;
|
||||
}
|
||||
.topbar .right {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
.btn {
|
||||
border: 1px solid #d1d5db;
|
||||
background: #fff;
|
||||
border-radius: 6px;
|
||||
padding: 6px 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn.primary {
|
||||
background: #111;
|
||||
color: #fff;
|
||||
border-color: #111;
|
||||
}
|
||||
|
||||
.main {
|
||||
flex: 1;
|
||||
display: grid;
|
||||
grid-template-columns: 280px 1fr 360px;
|
||||
}
|
||||
.topbar .search {
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 6px;
|
||||
padding: 6px 10px;
|
||||
margin-right: 8px;
|
||||
min-width: 240px;
|
||||
}
|
||||
.topbar .status {
|
||||
color: #6b7280;
|
||||
font-size: 12px;
|
||||
margin-right: 8px;
|
||||
min-width: 48px;
|
||||
display: inline-block;
|
||||
}
|
||||
.error-panel {
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
top: 56px;
|
||||
width: 420px;
|
||||
max-height: 50vh;
|
||||
background: #fff;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
|
||||
padding: 10px;
|
||||
overflow: auto;
|
||||
}
|
||||
.err-title {
|
||||
font-weight: 600;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.err-item {
|
||||
display: grid;
|
||||
grid-template-columns: 120px 1fr;
|
||||
gap: 6px;
|
||||
padding: 6px;
|
||||
border: 1px solid #f3f4f6;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.err-item:hover {
|
||||
background: #f9fafb;
|
||||
}
|
||||
.err-item .nid {
|
||||
font-size: 12px;
|
||||
color: #374151;
|
||||
}
|
||||
.err-item .e {
|
||||
font-size: 12px;
|
||||
color: #ef4444;
|
||||
}
|
||||
.btn.import {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.btn.import input {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,363 @@
|
||||
<template>
|
||||
<div v-if="visible" class="rr-modal">
|
||||
<div class="rr-dialog">
|
||||
<div class="rr-header">
|
||||
<div class="title">编辑录制流</div>
|
||||
<button class="close" @click="$emit('close')">✕</button>
|
||||
</div>
|
||||
<div class="rr-body">
|
||||
<div class="row">
|
||||
<label>名称</label>
|
||||
<input v-model="local.name" />
|
||||
</div>
|
||||
<div class="row">
|
||||
<label>描述</label>
|
||||
<input v-model="local.description" />
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<div class="section-title">绑定</div>
|
||||
<div class="bindings">
|
||||
<div class="binding-row" v-for="(b, i) in local.meta.bindings" :key="i">
|
||||
<select v-model="b.type">
|
||||
<option value="domain">domain</option>
|
||||
<option value="path">path</option>
|
||||
<option value="url">url</option>
|
||||
</select>
|
||||
<input
|
||||
v-model="b.value"
|
||||
placeholder="例如 example.com 或 /login 或 https://example.com/login"
|
||||
/>
|
||||
<button class="small danger" @click="local.meta.bindings.splice(i, 1)">删除</button>
|
||||
</div>
|
||||
<button class="small" @click="local.meta.bindings.push({ type: 'domain', value: '' })"
|
||||
>添加绑定</button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<div class="section-title">变量</div>
|
||||
<div class="vars">
|
||||
<div class="var-row" v-for="(v, i) in local.variables" :key="v.key + i">
|
||||
<input v-model="v.key" placeholder="key" class="narrow" />
|
||||
<input v-model="v.label" placeholder="标签" class="narrow" />
|
||||
<label class="chk"><input type="checkbox" v-model="v.sensitive" />敏感</label>
|
||||
<input v-model="v.default" placeholder="默认值" />
|
||||
<button class="small danger" @click="local.variables.splice(i, 1)">删除</button>
|
||||
</div>
|
||||
<button
|
||||
class="small"
|
||||
@click="local.variables.push({ key: '', label: '', sensitive: false, default: '' })"
|
||||
>添加变量</button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<div class="section-title">步骤</div>
|
||||
<div class="steps">
|
||||
<div class="step" v-for="(s, i) in local.steps" :key="s.id">
|
||||
<div class="step-head">
|
||||
<div class="meta">
|
||||
<span class="badge">{{ s.type }}</span>
|
||||
<span class="id">{{ s.id }}</span>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="small" @click="moveStep(i, -1)" :disabled="i === 0">上移</button>
|
||||
<button
|
||||
class="small"
|
||||
@click="moveStep(i, 1)"
|
||||
:disabled="i === local.steps.length - 1"
|
||||
>下移</button
|
||||
>
|
||||
<button class="small danger" @click="removeStep(i)">删除</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="step-body">
|
||||
<div class="row">
|
||||
<label>超时(ms)</label>
|
||||
<input type="number" v-model.number="s.timeoutMs" />
|
||||
</div>
|
||||
<div
|
||||
class="row"
|
||||
v-if="s.type === 'click' || s.type === 'dblclick' || s.type === 'fill'"
|
||||
>
|
||||
<label>选择器候选(顺序即优先级)</label>
|
||||
<div class="cands">
|
||||
<div class="cand" v-for="(c, j) in s.target.candidates" :key="j">
|
||||
<select v-model="c.type">
|
||||
<option value="css">css</option>
|
||||
<option value="attr">attr</option>
|
||||
<option value="aria">aria</option>
|
||||
<option value="text">text</option>
|
||||
<option value="xpath">xpath</option>
|
||||
</select>
|
||||
<input v-model="c.value" placeholder="选择器或表达式" />
|
||||
<button
|
||||
class="small"
|
||||
@click="swapCand(s.target.candidates, j, j - 1)"
|
||||
:disabled="j === 0"
|
||||
>↑</button
|
||||
>
|
||||
<button
|
||||
class="small"
|
||||
@click="swapCand(s.target.candidates, j, j + 1)"
|
||||
:disabled="j === s.target.candidates.length - 1"
|
||||
>↓</button
|
||||
>
|
||||
<button class="small danger" @click="s.target.candidates.splice(j, 1)"
|
||||
>删</button
|
||||
>
|
||||
</div>
|
||||
<button
|
||||
class="small"
|
||||
@click="s.target.candidates.push({ type: 'css', value: '' })"
|
||||
>添加候选</button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row" v-if="s.type === 'fill'">
|
||||
<label>输入值</label>
|
||||
<input v-model="s.value" placeholder="支持 {var} 格式" />
|
||||
</div>
|
||||
<div class="row" v-if="s.type === 'click' || s.type === 'dblclick'">
|
||||
<label>点击后等待导航</label>
|
||||
<label class="chk"
|
||||
><input
|
||||
type="checkbox"
|
||||
v-model="(s as any).after.waitForNavigation"
|
||||
/>启用</label
|
||||
>
|
||||
</div>
|
||||
<div class="row" v-if="s.type === 'wait'">
|
||||
<label>等待条件(JSON)</label>
|
||||
<textarea v-model="jsonCondition[i]"></textarea>
|
||||
</div>
|
||||
<div class="row" v-if="s.type === 'assert'">
|
||||
<label>断言(JSON)</label>
|
||||
<textarea v-model="jsonAssert[i]"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rr-footer">
|
||||
<button class="primary" @click="save">保存</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { reactive, watch, computed } from 'vue';
|
||||
|
||||
const props = defineProps<{ visible: boolean; flow: any | null }>();
|
||||
const emit = defineEmits(['close', 'save']);
|
||||
|
||||
const local = reactive<any>({
|
||||
id: '',
|
||||
name: '',
|
||||
description: '',
|
||||
version: 1,
|
||||
meta: { bindings: [] },
|
||||
variables: [],
|
||||
steps: [],
|
||||
});
|
||||
const jsonCondition = reactive<string[]>([]);
|
||||
const jsonAssert = reactive<string[]>([]);
|
||||
|
||||
watch(
|
||||
() => props.flow,
|
||||
(f) => {
|
||||
if (!f) return;
|
||||
const clone = JSON.parse(JSON.stringify(f));
|
||||
Object.assign(local, clone);
|
||||
if (!local.meta) local.meta = {};
|
||||
if (!Array.isArray(local.meta.bindings)) local.meta.bindings = [];
|
||||
jsonCondition.length = 0;
|
||||
jsonAssert.length = 0;
|
||||
for (const s of local.steps) {
|
||||
jsonCondition.push(s.type === 'wait' ? JSON.stringify(s.condition || {}, null, 2) : '');
|
||||
jsonAssert.push(s.type === 'assert' ? JSON.stringify(s.assert || {}, null, 2) : '');
|
||||
if ((s.type === 'click' || s.type === 'dblclick' || s.type === 'fill') && !s.target)
|
||||
s.target = { candidates: [] };
|
||||
if ((s.type === 'click' || s.type === 'dblclick') && !s.after) (s as any).after = {};
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
function swapCand(arr: any[], i: number, j: number) {
|
||||
if (j < 0 || j >= arr.length) return;
|
||||
const t = arr[i];
|
||||
arr[i] = arr[j];
|
||||
arr[j] = t;
|
||||
}
|
||||
function moveStep(i: number, delta: number) {
|
||||
const j = i + delta;
|
||||
if (j < 0 || j >= local.steps.length) return;
|
||||
const t = local.steps[i];
|
||||
local.steps[i] = local.steps[j];
|
||||
local.steps[j] = t;
|
||||
}
|
||||
function removeStep(i: number) {
|
||||
local.steps.splice(i, 1);
|
||||
}
|
||||
|
||||
function save() {
|
||||
// parse json fields
|
||||
for (let i = 0; i < local.steps.length; i++) {
|
||||
const s = local.steps[i];
|
||||
if (s.type === 'wait' && jsonCondition[i]) {
|
||||
try {
|
||||
s.condition = JSON.parse(jsonCondition[i]);
|
||||
} catch {}
|
||||
}
|
||||
if (s.type === 'assert' && jsonAssert[i]) {
|
||||
try {
|
||||
s.assert = JSON.parse(jsonAssert[i]);
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
emit('save', JSON.parse(JSON.stringify(local)));
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.rr-modal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
z-index: 2147483646;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.rr-dialog {
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
max-width: 960px;
|
||||
width: 96vw;
|
||||
max-height: 90vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.rr-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
.rr-header .title {
|
||||
font-weight: 600;
|
||||
}
|
||||
.rr-header .close {
|
||||
border: none;
|
||||
background: #f3f4f6;
|
||||
border-radius: 6px;
|
||||
padding: 4px 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.rr-body {
|
||||
padding: 12px 16px;
|
||||
overflow: auto;
|
||||
}
|
||||
.row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
margin: 6px 0;
|
||||
}
|
||||
.row > label {
|
||||
width: 120px;
|
||||
color: #374151;
|
||||
}
|
||||
.row > input,
|
||||
.row > textarea,
|
||||
.row > select {
|
||||
flex: 1;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 6px;
|
||||
padding: 6px 8px;
|
||||
}
|
||||
.row > textarea {
|
||||
min-height: 64px;
|
||||
}
|
||||
.chk {
|
||||
display: inline-flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
.section {
|
||||
margin: 12px 0;
|
||||
}
|
||||
.section-title {
|
||||
font-weight: 600;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.vars .var-row input.narrow {
|
||||
width: 160px;
|
||||
}
|
||||
.steps .step {
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.steps .step-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px 10px;
|
||||
background: #f9fafb;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
.steps .step-head .badge {
|
||||
background: #eef2ff;
|
||||
color: #3730a3;
|
||||
padding: 2px 6px;
|
||||
border-radius: 6px;
|
||||
margin-right: 8px;
|
||||
}
|
||||
.steps .step-head .id {
|
||||
color: #6b7280;
|
||||
font-size: 12px;
|
||||
}
|
||||
.steps .step-body {
|
||||
padding: 8px 10px;
|
||||
}
|
||||
.small {
|
||||
font-size: 12px;
|
||||
padding: 4px 8px;
|
||||
border: 1px solid #d1d5db;
|
||||
background: #fff;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.danger {
|
||||
background: #fee2e2;
|
||||
border-color: #fecaca;
|
||||
}
|
||||
.primary {
|
||||
background: #111;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
padding: 8px 16px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.rr-footer {
|
||||
padding: 12px 16px;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.cands .cand {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
margin: 4px 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,233 @@
|
||||
<template>
|
||||
<div v-if="visible" class="rr-modal">
|
||||
<div class="rr-dialog">
|
||||
<div class="rr-header">
|
||||
<div class="title">定时执行</div>
|
||||
<button class="close" @click="$emit('close')">✕</button>
|
||||
</div>
|
||||
<div class="rr-body">
|
||||
<div class="row">
|
||||
<label>启用</label>
|
||||
<label class="chk"><input type="checkbox" v-model="enabled" />启用定时</label>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label>类型</label>
|
||||
<select v-model="type">
|
||||
<option value="interval">每隔 N 分钟</option>
|
||||
<option value="daily">每天固定时间</option>
|
||||
<option value="once">只执行一次</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="row" v-if="type === 'interval'">
|
||||
<label>间隔(分钟)</label>
|
||||
<input type="number" v-model.number="intervalMinutes" />
|
||||
</div>
|
||||
<div class="row" v-if="type === 'daily'">
|
||||
<label>时间(HH:mm)</label>
|
||||
<input v-model="dailyTime" placeholder="例如 09:30" />
|
||||
</div>
|
||||
<div class="row" v-if="type === 'once'">
|
||||
<label>时间(ISO)</label>
|
||||
<input v-model="onceAt" placeholder="例如 2025-10-05T10:00:00" />
|
||||
</div>
|
||||
<div class="row">
|
||||
<label>参数(JSON)</label>
|
||||
<textarea v-model="argsJson" placeholder='{ "username": "xx" }'></textarea>
|
||||
</div>
|
||||
<div class="section">
|
||||
<div class="section-title">已有计划</div>
|
||||
<div class="sched-list">
|
||||
<div class="sched-row" v-for="s in schedules" :key="s.id">
|
||||
<div class="meta">
|
||||
<span class="badge" :class="{ on: s.enabled, off: !s.enabled }">{{ s.type }}</span>
|
||||
<span class="desc">{{ describe(s) }}</span>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="small danger" @click="$emit('remove', s.id)">删除</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rr-footer">
|
||||
<button class="primary" @click="save">保存</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, watch } from 'vue';
|
||||
|
||||
const props = defineProps<{ visible: boolean; flowId: string | null; schedules: any[] }>();
|
||||
const emit = defineEmits(['close', 'save', 'remove']);
|
||||
|
||||
const enabled = ref(true);
|
||||
const type = ref<'interval' | 'daily' | 'once'>('interval');
|
||||
const intervalMinutes = ref(30);
|
||||
const dailyTime = ref('09:00');
|
||||
const onceAt = ref('');
|
||||
const argsJson = ref('');
|
||||
|
||||
watch(
|
||||
() => props.visible,
|
||||
(v) => {
|
||||
if (v) {
|
||||
enabled.value = true;
|
||||
type.value = 'interval';
|
||||
intervalMinutes.value = 30;
|
||||
dailyTime.value = '09:00';
|
||||
onceAt.value = '';
|
||||
argsJson.value = '';
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
function save() {
|
||||
if (!props.flowId) return;
|
||||
const schedule = {
|
||||
id: `sch_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`,
|
||||
flowId: props.flowId,
|
||||
type: type.value,
|
||||
enabled: enabled.value,
|
||||
when:
|
||||
type.value === 'interval'
|
||||
? String(intervalMinutes.value)
|
||||
: type.value === 'daily'
|
||||
? dailyTime.value
|
||||
: onceAt.value,
|
||||
args: safeParse(argsJson.value),
|
||||
} as any;
|
||||
emit('save', schedule);
|
||||
}
|
||||
|
||||
function safeParse(s: string) {
|
||||
if (!s || !s.trim()) return {};
|
||||
try {
|
||||
return JSON.parse(s);
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function describe(s: any) {
|
||||
if (s.type === 'interval') return `每 ${s.when} 分钟`;
|
||||
if (s.type === 'daily') return `每天 ${s.when}`;
|
||||
if (s.type === 'once') return `一次 ${s.when}`;
|
||||
return '';
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.rr-modal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
z-index: 2147483646;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.rr-dialog {
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
max-width: 720px;
|
||||
width: 96vw;
|
||||
max-height: 90vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.rr-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
.rr-header .title {
|
||||
font-weight: 600;
|
||||
}
|
||||
.rr-header .close {
|
||||
border: none;
|
||||
background: #f3f4f6;
|
||||
border-radius: 6px;
|
||||
padding: 4px 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.rr-body {
|
||||
padding: 12px 16px;
|
||||
overflow: auto;
|
||||
}
|
||||
.row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
margin: 6px 0;
|
||||
}
|
||||
.row > label {
|
||||
width: 120px;
|
||||
color: #374151;
|
||||
}
|
||||
.row > input,
|
||||
.row > textarea,
|
||||
.row > select {
|
||||
flex: 1;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 6px;
|
||||
padding: 6px 8px;
|
||||
}
|
||||
.row > textarea {
|
||||
min-height: 64px;
|
||||
}
|
||||
.chk {
|
||||
display: inline-flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
.sched-list .sched-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 6px 8px;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 6px;
|
||||
margin: 4px 0;
|
||||
}
|
||||
.badge {
|
||||
padding: 2px 6px;
|
||||
border-radius: 6px;
|
||||
background: #e5e7eb;
|
||||
}
|
||||
.badge.on {
|
||||
background: #dcfce7;
|
||||
}
|
||||
.badge.off {
|
||||
background: #fee2e2;
|
||||
}
|
||||
.small {
|
||||
font-size: 12px;
|
||||
padding: 4px 8px;
|
||||
border: 1px solid #d1d5db;
|
||||
background: #fff;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.danger {
|
||||
background: #fee2e2;
|
||||
border-color: #fecaca;
|
||||
}
|
||||
.primary {
|
||||
background: #111;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
padding: 8px 16px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.rr-footer {
|
||||
padding: 12px 16px;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,202 @@
|
||||
<template>
|
||||
<section class="canvas">
|
||||
<VueFlow
|
||||
v-model:nodes="vfNodes"
|
||||
v-model:edges="vfEdges"
|
||||
:min-zoom="0.2"
|
||||
:max-zoom="1.5"
|
||||
:fit-view-on-init="true"
|
||||
snap-to-grid
|
||||
:snap-grid="[15, 15]"
|
||||
@connect="onConnectInternal"
|
||||
@node-drag-stop="onNodeDragStopInternal"
|
||||
>
|
||||
<Background patternColor="#f3f4f6" :gap="20" />
|
||||
<Controls position="top-left" />
|
||||
<MiniMap :pannable="true" :zoomable="true" />
|
||||
|
||||
<template #node-default="{ id, selected }">
|
||||
<div
|
||||
:class="['vf-node-card', selected ? 'selected' : '']"
|
||||
@click.stop="emit('selectNode', id)"
|
||||
>
|
||||
<div class="node-head">
|
||||
<span class="type">{{ findNodeBase(id)?.type }}</span>
|
||||
<span class="name">{{ findNodeBase(id)?.name || id }}</span>
|
||||
</div>
|
||||
<div class="node-body">{{ summarize(findNodeBase(id)) }}</div>
|
||||
<div class="node-actions">
|
||||
<button class="mini" @click.stop="emit('connectFrom', id)">连接</button>
|
||||
<button class="mini" @click.stop="emit('duplicateNode', id)">复制</button>
|
||||
<button class="mini danger" @click.stop="emit('removeNode', id)">删除</button>
|
||||
</div>
|
||||
<Handle type="target" :position="Position.Left" />
|
||||
<Handle type="source" :position="Position.Right" />
|
||||
</div>
|
||||
</template>
|
||||
</VueFlow>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, watch } from 'vue';
|
||||
import {
|
||||
VueFlow,
|
||||
type Node as VFNode,
|
||||
type Edge as VFEdge,
|
||||
type Connection,
|
||||
Handle,
|
||||
useVueFlow,
|
||||
Position,
|
||||
} from '@vue-flow/core';
|
||||
import { Background } from '@vue-flow/background';
|
||||
import { MiniMap } from '@vue-flow/minimap';
|
||||
import { Controls } from '@vue-flow/controls';
|
||||
import '@vue-flow/core/dist/style.css';
|
||||
import '@vue-flow/core/dist/theme-default.css';
|
||||
import '@vue-flow/controls/dist/style.css';
|
||||
import '@vue-flow/minimap/dist/style.css';
|
||||
import '@vue-flow/background/dist/style.css';
|
||||
|
||||
import type { NodeBase, Edge as EdgeV2 } from '@/entrypoints/background/record-replay/types';
|
||||
import { summarizeNode as summarize } from '../model/transforms';
|
||||
|
||||
const props = defineProps<{
|
||||
nodes: NodeBase[];
|
||||
edges: EdgeV2[];
|
||||
focusNodeId?: string | null;
|
||||
fitSeq?: number;
|
||||
}>();
|
||||
const emit = defineEmits<{
|
||||
(e: 'selectNode', id: string): void;
|
||||
(e: 'duplicateNode', id: string): void;
|
||||
(e: 'removeNode', id: string): void;
|
||||
(e: 'connectFrom', id: string): void;
|
||||
(e: 'connect', src: string, dst: string): void;
|
||||
(e: 'nodeDragged', id: string, x: number, y: number): void;
|
||||
}>();
|
||||
|
||||
const vfNodes = ref<VFNode[]>([]);
|
||||
const vfEdges = ref<VFEdge[]>([]);
|
||||
defineOptions({ name: 'BuilderCanvas' });
|
||||
const { fitView, getNodes } = useVueFlow();
|
||||
|
||||
watch(
|
||||
() => props.nodes,
|
||||
(list) => {
|
||||
vfNodes.value = list.map((n) => ({
|
||||
id: n.id,
|
||||
position: { x: n.ui?.x || 0, y: n.ui?.y || 0 },
|
||||
type: 'default',
|
||||
data: {},
|
||||
}));
|
||||
},
|
||||
{ immediate: true, deep: true },
|
||||
);
|
||||
watch(
|
||||
() => props.edges,
|
||||
(list) => {
|
||||
vfEdges.value = list.map((e) => ({ id: e.id, source: e.from, target: e.to }));
|
||||
},
|
||||
{ immediate: true, deep: true },
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.focusNodeId,
|
||||
(id) => {
|
||||
if (!id) return;
|
||||
const nd = getNodes.value.find((n) => n.id === id);
|
||||
if (!nd) return;
|
||||
try {
|
||||
fitView({ nodes: [nd.id], duration: 300, padding: 0.2 });
|
||||
} catch {}
|
||||
},
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.fitSeq,
|
||||
() => {
|
||||
try {
|
||||
fitView({ duration: 300, padding: 0.2 });
|
||||
} catch {}
|
||||
},
|
||||
);
|
||||
|
||||
function findNodeBase(id: string) {
|
||||
return props.nodes.find((n) => n.id === id) || null;
|
||||
}
|
||||
|
||||
function onNodeDragStopInternal(evt: any) {
|
||||
const node = evt?.node as VFNode | undefined;
|
||||
if (!node) return;
|
||||
emit('nodeDragged', node.id, Math.round(node.position.x), Math.round(node.position.y));
|
||||
}
|
||||
|
||||
function onConnectInternal(conn: Connection) {
|
||||
if (!conn.source || !conn.target) return;
|
||||
emit('connect', conn.source, conn.target);
|
||||
// 边更新由上层状态驱动,这里无需直接修改本地 vfEdges
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.canvas {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.vf-node-card {
|
||||
width: 240px;
|
||||
background: #fff;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
.vf-node-card.selected {
|
||||
border-color: #3b82f6;
|
||||
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.2);
|
||||
}
|
||||
.node-head {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
padding: 6px 8px;
|
||||
border-bottom: 1px solid #f3f4f6;
|
||||
background: #f9fafb;
|
||||
border-radius: 8px 8px 0 0;
|
||||
}
|
||||
.node-head .type {
|
||||
background: #eef2ff;
|
||||
color: #3730a3;
|
||||
border-radius: 6px;
|
||||
padding: 2px 6px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.node-head .name {
|
||||
color: #374151;
|
||||
font-size: 12px;
|
||||
}
|
||||
.node-body {
|
||||
padding: 8px;
|
||||
color: #6b7280;
|
||||
font-size: 12px;
|
||||
min-height: 20px;
|
||||
}
|
||||
.node-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
padding: 8px;
|
||||
border-top: 1px solid #f3f4f6;
|
||||
}
|
||||
.mini {
|
||||
font-size: 12px;
|
||||
padding: 4px 8px;
|
||||
border: 1px solid #d1d5db;
|
||||
background: #fff;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.mini.danger {
|
||||
background: #fee2e2;
|
||||
border-color: #fecaca;
|
||||
}
|
||||
</style>
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
<template>
|
||||
<div class="kve">
|
||||
<div v-for="(item, i) in rows" :key="i" class="kve-row">
|
||||
<input class="kve-key" v-model="item.k" placeholder="变量名" />
|
||||
<input class="kve-val" v-model="item.v" placeholder="结果路径(如 data.items[0].id)" />
|
||||
<button class="mini" @click="move(i, -1)" :disabled="i === 0">↑</button>
|
||||
<button class="mini" @click="move(i, 1)" :disabled="i === rows.length - 1">↓</button>
|
||||
<button class="mini danger" @click="remove(i)">删</button>
|
||||
</div>
|
||||
<button class="mini" @click="add">添加映射</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { watch, reactive } from 'vue';
|
||||
|
||||
const props = defineProps<{ modelValue: Record<string, string> | undefined }>();
|
||||
const emit = defineEmits(['update:modelValue']);
|
||||
|
||||
const rows = reactive<Array<{ k: string; v: string }>>([]);
|
||||
|
||||
function syncFromModel() {
|
||||
rows.splice(0, rows.length);
|
||||
const obj = props.modelValue || {};
|
||||
for (const [k, v] of Object.entries(obj)) rows.push({ k, v: String(v) });
|
||||
}
|
||||
function syncToModel() {
|
||||
const out: Record<string, string> = {};
|
||||
for (const r of rows) if (r.k) out[r.k] = r.v || '';
|
||||
emit('update:modelValue', out);
|
||||
}
|
||||
watch(() => props.modelValue, syncFromModel, { immediate: true, deep: true });
|
||||
watch(rows, syncToModel, { deep: true });
|
||||
|
||||
function add() {
|
||||
rows.push({ k: '', v: '' });
|
||||
}
|
||||
function remove(i: number) {
|
||||
rows.splice(i, 1);
|
||||
}
|
||||
function move(i: number, d: number) {
|
||||
const j = i + d;
|
||||
if (j < 0 || j >= rows.length) return;
|
||||
const t = rows[i];
|
||||
rows[i] = rows[j];
|
||||
rows[j] = t;
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.kve {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.kve-row {
|
||||
display: grid;
|
||||
grid-template-columns: 160px 1fr auto auto auto;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
.kve-key,
|
||||
.kve-val {
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 6px;
|
||||
padding: 6px;
|
||||
}
|
||||
.mini {
|
||||
font-size: 12px;
|
||||
padding: 4px 8px;
|
||||
border: 1px solid #d1d5db;
|
||||
background: #fff;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.mini.danger {
|
||||
background: #fee2e2;
|
||||
border-color: #fecaca;
|
||||
}
|
||||
</style>
|
||||
+442
@@ -0,0 +1,442 @@
|
||||
<template>
|
||||
<aside class="panel">
|
||||
<div v-if="node">
|
||||
<div class="panel-title">属性面板</div>
|
||||
<div class="row">
|
||||
<label>节点名</label>
|
||||
<input v-model="node.name" />
|
||||
</div>
|
||||
<div class="row">
|
||||
<label>禁用</label>
|
||||
<input type="checkbox" v-model="node.disabled" />
|
||||
</div>
|
||||
<hr />
|
||||
|
||||
<template v-if="node.type === 'click' || node.type === 'fill'">
|
||||
<div class="sub-title">选择器候选(按优先级)</div>
|
||||
<div class="cands" data-field="target.candidates">
|
||||
<div class="cand" v-for="(c, i) in node.config.target.candidates" :key="i">
|
||||
<select v-model="c.type">
|
||||
<option value="css">css</option>
|
||||
<option value="attr">attr</option>
|
||||
<option value="aria">aria</option>
|
||||
<option value="text">text</option>
|
||||
<option value="xpath">xpath</option>
|
||||
</select>
|
||||
<input v-model="c.value" />
|
||||
<button
|
||||
class="mini"
|
||||
@click="swapCand(node.config.target.candidates, i, i - 1)"
|
||||
:disabled="i === 0"
|
||||
>↑</button
|
||||
>
|
||||
<button
|
||||
class="mini"
|
||||
@click="swapCand(node.config.target.candidates, i, i + 1)"
|
||||
:disabled="i === node.config.target.candidates.length - 1"
|
||||
>↓</button
|
||||
>
|
||||
<button class="mini danger" @click="node.config.target.candidates.splice(i, 1)"
|
||||
>删</button
|
||||
>
|
||||
</div>
|
||||
<button
|
||||
class="mini"
|
||||
@click="node.config.target.candidates.push({ type: 'css', value: '' })"
|
||||
>添加候选</button
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-if="node.type === 'fill'">
|
||||
<div class="row" data-field="fill.value">
|
||||
<label>输入值</label>
|
||||
<input v-model="node.config.value" placeholder="支持 {var} 格式" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-if="node.type === 'key'">
|
||||
<div class="row">
|
||||
<label>按键序列</label>
|
||||
<input v-model="node.config.keys" placeholder="例如: Backspace Enter 或 cmd+a" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-if="node.type === 'delay'">
|
||||
<div class="row">
|
||||
<label>延迟(ms)</label>
|
||||
<input type="number" v-model.number="node.config.ms" min="0" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-if="node.type === 'http'">
|
||||
<div class="row">
|
||||
<label>Method</label>
|
||||
<select v-model="node.config.method">
|
||||
<option>GET</option>
|
||||
<option>POST</option>
|
||||
<option>PUT</option>
|
||||
<option>PATCH</option>
|
||||
<option>DELETE</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="row" :class="{ invalid: !node.config.url }" data-field="http.url">
|
||||
<label>URL</label>
|
||||
<input v-model="node.config.url" />
|
||||
</div>
|
||||
<div class="row">
|
||||
<label>Headers(JSON)</label>
|
||||
<textarea v-model="headersJson"></textarea>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label>Body(JSON)</label>
|
||||
<textarea v-model="bodyJson"></textarea>
|
||||
</div>
|
||||
<div class="row" data-field="http.saveAs">
|
||||
<label>保存为</label>
|
||||
<input v-model="node.config.saveAs" placeholder="变量名" />
|
||||
</div>
|
||||
<div class="section" data-field="http.assign">
|
||||
<div class="sub-title">映射结果字段到变量</div>
|
||||
<KeyValueEditor v-model="node.config.assign" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-if="node.type === 'extract'">
|
||||
<div class="row" data-field="extract.selector">
|
||||
<label>选择器</label>
|
||||
<input v-model="node.config.selector" placeholder="CSS 选择器" />
|
||||
</div>
|
||||
<div class="row">
|
||||
<label>属性/文本</label>
|
||||
<input v-model="node.config.attr" placeholder="attr 名称 或 text/textContent" />
|
||||
</div>
|
||||
<div class="row">
|
||||
<label>自定义JS</label>
|
||||
<textarea v-model="node.config.js" placeholder="返回提取值的 JS 代码" rows="4"></textarea>
|
||||
</div>
|
||||
<div class="row" :class="{ invalid: !node.config.saveAs }" data-field="extract.saveAs">
|
||||
<label>保存为</label>
|
||||
<input v-model="node.config.saveAs" placeholder="变量名" />
|
||||
</div>
|
||||
<div v-if="extractErrors.length" class="errors">
|
||||
<div v-for="e in extractErrors" :key="e" class="error">⚠️ {{ e }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-if="node.type === 'openTab'">
|
||||
<div class="row" data-field="openTab.url">
|
||||
<label>URL</label>
|
||||
<input v-model="node.config.url" />
|
||||
</div>
|
||||
<div class="row">
|
||||
<label class="chk"><input type="checkbox" v-model="node.config.newWindow" />新窗口</label>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-if="node.type === 'switchTab'">
|
||||
<div class="row">
|
||||
<label>TabId</label>
|
||||
<input type="number" v-model.number="node.config.tabId" />
|
||||
</div>
|
||||
<div class="row">
|
||||
<label>URL包含</label>
|
||||
<input v-model="node.config.urlContains" />
|
||||
</div>
|
||||
<div class="row">
|
||||
<label>标题包含</label>
|
||||
<input v-model="node.config.titleContains" />
|
||||
</div>
|
||||
<div v-if="switchTabError" class="errors"
|
||||
><div class="error">⚠️ 需填写 tabId 或 URL/标题包含</div></div
|
||||
>
|
||||
</template>
|
||||
|
||||
<template v-if="node.type === 'closeTab'">
|
||||
<div class="row">
|
||||
<label>URL</label>
|
||||
<input v-model="node.config.url" placeholder="可留空以关闭当前标签页" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-if="node.type === 'wait'">
|
||||
<div class="sub-title">等待条件</div>
|
||||
<div class="row">
|
||||
<label>JSON</label>
|
||||
<textarea v-model="waitJson" rows="5"></textarea>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-if="node.type === 'assert'">
|
||||
<div class="sub-title">断言</div>
|
||||
<div class="row">
|
||||
<label>JSON</label>
|
||||
<textarea v-model="assertJson" rows="5"></textarea>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-if="node.type === 'navigate'">
|
||||
<div class="row" data-field="navigate.url">
|
||||
<label>URL</label>
|
||||
<input v-model="node.config.url" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-if="node.type === 'script'">
|
||||
<div class="row">
|
||||
<label>world</label>
|
||||
<select v-model="node.config.world">
|
||||
<option value="ISOLATED">ISOLATED</option>
|
||||
<option value="MAIN">MAIN</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="row" data-field="script.code">
|
||||
<label>代码</label>
|
||||
<textarea v-model="node.config.code" rows="6"></textarea>
|
||||
</div>
|
||||
<div class="row" data-field="script.saveAs">
|
||||
<label>保存为</label>
|
||||
<input v-model="node.config.saveAs" placeholder="变量名" />
|
||||
</div>
|
||||
<div class="row" data-field="script.assign">
|
||||
<label>映射(JSON)</label>
|
||||
<textarea
|
||||
v-model="scriptAssignJson"
|
||||
rows="4"
|
||||
placeholder='{"var":"path.in.result"}'
|
||||
></textarea>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div v-else class="empty">选择一个节点以编辑属性</div>
|
||||
<div v-if="node" class="errors" style="margin-top: 8px">
|
||||
<div v-for="e in nodeErrors" :key="e" class="error">⚠️ {{ e }}</div>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, watch } from 'vue';
|
||||
import type { NodeBase } from '@/entrypoints/background/record-replay/types';
|
||||
import { validateNode } from '../model/validation';
|
||||
import KeyValueEditor from './KeyValueEditor.vue';
|
||||
|
||||
const props = defineProps<{ node: NodeBase | null; highlightField?: string | null }>();
|
||||
|
||||
const waitJson = computed({
|
||||
get() {
|
||||
const n = props.node;
|
||||
if (!n || n.type !== 'wait') return '';
|
||||
try {
|
||||
return JSON.stringify(n.config?.condition || {}, null, 2);
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
},
|
||||
set(v: string) {
|
||||
const n = props.node;
|
||||
if (!n || n.type !== 'wait') return;
|
||||
try {
|
||||
n.config = { ...(n.config || {}), condition: JSON.parse(v || '{}') };
|
||||
} catch {}
|
||||
},
|
||||
});
|
||||
|
||||
const assertJson = computed({
|
||||
get() {
|
||||
const n = props.node;
|
||||
if (!n || n.type !== 'assert') return '';
|
||||
try {
|
||||
return JSON.stringify(n.config?.assert || {}, null, 2);
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
},
|
||||
set(v: string) {
|
||||
const n = props.node;
|
||||
if (!n || n.type !== 'assert') return;
|
||||
try {
|
||||
n.config = { ...(n.config || {}), assert: JSON.parse(v || '{}') };
|
||||
} catch {}
|
||||
},
|
||||
});
|
||||
|
||||
const nodeErrors = computed(() => (props.node ? validateNode(props.node) : []));
|
||||
const extractErrors = computed(() => {
|
||||
const n = props.node;
|
||||
if (!n || n.type !== 'extract') return [] as string[];
|
||||
const errs: string[] = [];
|
||||
if (!n.config?.saveAs) errs.push('需填写保存变量名');
|
||||
if (!n.config?.selector && !n.config?.js) errs.push('需提供 selector 或 js');
|
||||
return errs;
|
||||
});
|
||||
const switchTabError = computed(() => {
|
||||
const n = props.node;
|
||||
if (!n || n.type !== 'switchTab') return false;
|
||||
return !(n.config?.tabId || n.config?.urlContains || n.config?.titleContains);
|
||||
});
|
||||
|
||||
function swapCand(arr: any[], i: number, j: number) {
|
||||
if (j < 0 || j >= arr.length) return;
|
||||
const t = arr[i];
|
||||
arr[i] = arr[j];
|
||||
arr[j] = t;
|
||||
}
|
||||
|
||||
// http json helpers
|
||||
const headersJson = computed({
|
||||
get() {
|
||||
const n = props.node;
|
||||
if (!n || n.type !== 'http') return '';
|
||||
try {
|
||||
return JSON.stringify(n.config?.headers || {}, null, 2);
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
},
|
||||
set(v: string) {
|
||||
const n = props.node;
|
||||
if (!n || n.type !== 'http') return;
|
||||
try {
|
||||
n.config.headers = JSON.parse(v || '{}');
|
||||
} catch {}
|
||||
},
|
||||
});
|
||||
const bodyJson = computed({
|
||||
get() {
|
||||
const n = props.node;
|
||||
if (!n || n.type !== 'http') return '';
|
||||
try {
|
||||
return JSON.stringify(n.config?.body ?? null, null, 2);
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
},
|
||||
set(v: string) {
|
||||
const n = props.node;
|
||||
if (!n || n.type !== 'http') return;
|
||||
try {
|
||||
n.config.body = v ? JSON.parse(v) : null;
|
||||
} catch {}
|
||||
},
|
||||
});
|
||||
|
||||
// script assign json helper
|
||||
const scriptAssignJson = computed({
|
||||
get() {
|
||||
const n = props.node;
|
||||
if (!n || n.type !== 'script') return '';
|
||||
try {
|
||||
return JSON.stringify(n.config?.assign || {}, null, 2);
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
},
|
||||
set(v: string) {
|
||||
const n = props.node;
|
||||
if (!n || n.type !== 'script') return;
|
||||
try {
|
||||
n.config.assign = JSON.parse(v || '{}');
|
||||
} catch {}
|
||||
},
|
||||
});
|
||||
// 高亮并滚动到指定字段
|
||||
watch(
|
||||
() => props.highlightField,
|
||||
(field) => {
|
||||
if (!field) return;
|
||||
try {
|
||||
const root = (document?.querySelector?.('.panel') as HTMLElement) || null;
|
||||
const esc =
|
||||
(globalThis as any).CSS && typeof (globalThis as any).CSS.escape === 'function'
|
||||
? (globalThis as any).CSS.escape(field)
|
||||
: String(field).replace(/["\\]/g, '\\$&');
|
||||
const el = (root || document).querySelector(`[data-field="${esc}"]`) as HTMLElement | null;
|
||||
if (el && el.scrollIntoView) el.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
||||
if (el) {
|
||||
el.classList.add('hl');
|
||||
setTimeout(() => el.classList.remove('hl'), 1200);
|
||||
}
|
||||
} catch {}
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.panel {
|
||||
border-left: 1px solid #e5e7eb;
|
||||
padding: 10px;
|
||||
overflow: auto;
|
||||
}
|
||||
.panel-title {
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.sub-title {
|
||||
font-weight: 600;
|
||||
margin: 6px 0;
|
||||
}
|
||||
.row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
margin: 6px 0;
|
||||
}
|
||||
.row > label {
|
||||
width: 72px;
|
||||
color: #374151;
|
||||
}
|
||||
.row > input,
|
||||
.row > textarea,
|
||||
.row > select {
|
||||
flex: 1;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 6px;
|
||||
padding: 6px;
|
||||
}
|
||||
.row.invalid > input,
|
||||
.row.invalid > textarea,
|
||||
.row.invalid > select {
|
||||
border-color: #ef4444;
|
||||
background: #fef2f2;
|
||||
}
|
||||
.cands {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.cand {
|
||||
display: grid;
|
||||
grid-template-columns: 120px 1fr auto auto auto;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
.mini {
|
||||
font-size: 12px;
|
||||
padding: 4px 8px;
|
||||
border: 1px solid #d1d5db;
|
||||
background: #fff;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.mini.danger {
|
||||
background: #fee2e2;
|
||||
border-color: #fecaca;
|
||||
}
|
||||
.empty {
|
||||
color: #6b7280;
|
||||
padding: 12px;
|
||||
}
|
||||
.errors {
|
||||
color: #ef4444;
|
||||
font-size: 12px;
|
||||
}
|
||||
.errors .error {
|
||||
margin: 2px 0;
|
||||
}
|
||||
.panel :where([data-field].hl) {
|
||||
outline: 2px solid #f59e0b;
|
||||
background: #fffbeb;
|
||||
transition: outline-color 0.3s ease;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,70 @@
|
||||
<template>
|
||||
<aside class="sidebar">
|
||||
<div class="palette">
|
||||
<div class="palette-title">节点库</div>
|
||||
<div class="palette-list">
|
||||
<button v-for="t in paletteTypes" :key="t" class="node-btn" @click="$emit('addNode', t)">
|
||||
{{ t }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="meta">
|
||||
<div class="row">
|
||||
<label>名称</label>
|
||||
<input v-model="flow.name" />
|
||||
</div>
|
||||
<div class="row">
|
||||
<label>描述</label>
|
||||
<input v-model="flow.description" />
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { Flow as FlowV2, NodeBase } from '@/entrypoints/background/record-replay/types';
|
||||
|
||||
defineProps<{ flow: FlowV2; paletteTypes: NodeBase['type'][] }>();
|
||||
defineEmits<{ (e: 'addNode', t: NodeBase['type']): void }>();
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.sidebar {
|
||||
border-right: 1px solid #e5e7eb;
|
||||
padding: 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.palette-title {
|
||||
font-weight: 600;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.palette-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
.node-btn {
|
||||
border: 1px dashed #d1d5db;
|
||||
background: #f9fafb;
|
||||
border-radius: 6px;
|
||||
padding: 6px 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.meta .row {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
.meta .row > label {
|
||||
width: 72px;
|
||||
color: #374151;
|
||||
}
|
||||
.meta input {
|
||||
flex: 1;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 6px;
|
||||
padding: 6px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,182 @@
|
||||
import type {
|
||||
Flow as FlowV2,
|
||||
NodeBase,
|
||||
Edge as EdgeV2,
|
||||
} from '@/entrypoints/background/record-replay/types';
|
||||
|
||||
export function newId(prefix: string) {
|
||||
return `${prefix}_${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
export type NodeType = NodeBase['type'];
|
||||
|
||||
export function defaultConfigFor(t: NodeType): any {
|
||||
if (t === 'click' || t === 'fill')
|
||||
return { target: { candidates: [] }, value: t === 'fill' ? '' : undefined };
|
||||
if (t === 'navigate') return { url: '' };
|
||||
if (t === 'wait') return { condition: { text: '', appear: true } };
|
||||
if (t === 'assert') return { assert: { exists: '' } };
|
||||
if (t === 'key') return { keys: '' };
|
||||
if (t === 'delay') return { ms: 1000 };
|
||||
if (t === 'http') return { method: 'GET', url: '', headers: {}, body: null, saveAs: '' };
|
||||
if (t === 'extract') return { selector: '', attr: 'text', js: '', saveAs: '' };
|
||||
if (t === 'openTab') return { url: '', newWindow: false };
|
||||
if (t === 'switchTab') return { tabId: null, urlContains: '', titleContains: '' };
|
||||
if (t === 'closeTab') return { tabIds: [], url: '' };
|
||||
if (t === 'script') return { world: 'ISOLATED', code: '', saveAs: '', assign: {} };
|
||||
return {};
|
||||
}
|
||||
|
||||
export function stepsToNodes(steps: any[]): NodeBase[] {
|
||||
const arr: NodeBase[] = [];
|
||||
steps.forEach((s, i) => {
|
||||
const id = s.id || newId(String(s.type || 'step'));
|
||||
const node: NodeBase = {
|
||||
id,
|
||||
type: (s.type || 'script') as NodeType,
|
||||
name: '',
|
||||
disabled: false,
|
||||
ui: { x: 200, y: 120 + i * 120 },
|
||||
config: mapStepToConfig(s),
|
||||
};
|
||||
arr.push(node);
|
||||
});
|
||||
return arr;
|
||||
}
|
||||
|
||||
export function mapStepToConfig(s: any) {
|
||||
const t = s.type;
|
||||
if (t === 'click' || t === 'dblclick')
|
||||
return { target: s.target || { candidates: [] }, after: s.after, before: s.before };
|
||||
if (t === 'fill') return { target: s.target || { candidates: [] }, value: s.value || '' };
|
||||
if (t === 'wait') return { condition: s.condition || { text: '', appear: true } };
|
||||
if (t === 'assert') return { assert: s.assert || { exists: '' }, failStrategy: s.failStrategy };
|
||||
if (t === 'navigate') return { url: s.url || '' };
|
||||
if (t === 'script') return { world: s.world || 'ISOLATED', code: s.code || '' };
|
||||
return { ...s };
|
||||
}
|
||||
|
||||
export function mapConfigToStep(n: NodeBase) {
|
||||
const base = { id: n.id, type: n.type } as any;
|
||||
const c = n.config || {};
|
||||
if (n.type === 'click' || n.type === 'dblclick')
|
||||
return { ...base, target: c.target || { candidates: [] }, after: c.after, before: c.before };
|
||||
if (n.type === 'fill')
|
||||
return { ...base, target: c.target || { candidates: [] }, value: c.value || '' };
|
||||
if (n.type === 'key') return { ...base, keys: c.keys || '' };
|
||||
if (n.type === 'wait') return { ...base, condition: c.condition || { text: '', appear: true } };
|
||||
if (n.type === 'assert')
|
||||
return { ...base, assert: c.assert || { exists: '' }, failStrategy: c.failStrategy };
|
||||
if (n.type === 'navigate') return { ...base, url: c.url || '' };
|
||||
if (n.type === 'delay')
|
||||
return {
|
||||
...base,
|
||||
type: 'wait',
|
||||
timeoutMs: Math.max(0, Number(c.ms ?? 1000)),
|
||||
condition: { navigation: true },
|
||||
};
|
||||
if (n.type === 'http')
|
||||
return {
|
||||
...base,
|
||||
type: 'http',
|
||||
method: c.method || 'GET',
|
||||
url: c.url || '',
|
||||
headers: c.headers || {},
|
||||
body: c.body,
|
||||
saveAs: c.saveAs || '',
|
||||
} as any;
|
||||
if (n.type === 'extract')
|
||||
return {
|
||||
...base,
|
||||
type: 'extract',
|
||||
selector: c.selector || '',
|
||||
attr: c.attr || 'text',
|
||||
js: c.js || '',
|
||||
saveAs: c.saveAs || '',
|
||||
} as any;
|
||||
if (n.type === 'openTab')
|
||||
return { ...base, type: 'openTab', url: c.url || '', newWindow: !!c.newWindow } as any;
|
||||
if (n.type === 'switchTab')
|
||||
return {
|
||||
...base,
|
||||
type: 'switchTab',
|
||||
tabId: c.tabId || undefined,
|
||||
urlContains: c.urlContains || '',
|
||||
titleContains: c.titleContains || '',
|
||||
} as any;
|
||||
if (n.type === 'closeTab')
|
||||
return {
|
||||
...base,
|
||||
type: 'closeTab',
|
||||
tabIds: Array.isArray(c.tabIds) ? c.tabIds : undefined,
|
||||
url: c.url || '',
|
||||
} as any;
|
||||
if (n.type === 'script')
|
||||
return {
|
||||
...base,
|
||||
world: c.world || 'ISOLATED',
|
||||
code: c.code || '',
|
||||
when: c.when,
|
||||
saveAs: c.saveAs || '',
|
||||
assign: c.assign || {},
|
||||
} as any;
|
||||
return { ...base };
|
||||
}
|
||||
|
||||
export function topoOrder(nodes: NodeBase[], edges: EdgeV2[]): NodeBase[] {
|
||||
const id2n = new Map(nodes.map((n) => [n.id, n] as const));
|
||||
const indeg = new Map<string, number>(nodes.map((n) => [n.id, 0] as const));
|
||||
for (const e of edges)
|
||||
if (!e.label || e.label === 'default') indeg.set(e.to, (indeg.get(e.to) || 0) + 1);
|
||||
const q: string[] = nodes.filter((n) => (indeg.get(n.id) || 0) === 0).map((n) => n.id);
|
||||
const out: NodeBase[] = [];
|
||||
const nexts = new Map<string, string[]>(nodes.map((n) => [n.id, [] as string[]] as const));
|
||||
for (const e of edges) if (!e.label || e.label === 'default') nexts.get(e.from)!.push(e.to);
|
||||
while (q.length) {
|
||||
const id = q.shift()!;
|
||||
const n = id2n.get(id);
|
||||
if (!n) continue;
|
||||
out.push(n);
|
||||
for (const v of nexts.get(id)!) {
|
||||
indeg.set(v, (indeg.get(v) || 0) - 1);
|
||||
if ((indeg.get(v) || 0) === 0) q.push(v);
|
||||
}
|
||||
}
|
||||
if (out.length === nodes.length) return out;
|
||||
return nodes.slice();
|
||||
}
|
||||
|
||||
export function nodesToSteps(nodes: NodeBase[], edges: EdgeV2[]): any[] {
|
||||
const order = edges.length ? topoOrder(nodes, edges) : nodes.slice();
|
||||
return order.map((n) => mapConfigToStep(n));
|
||||
}
|
||||
|
||||
export function autoChainEdges(nodes: NodeBase[]): EdgeV2[] {
|
||||
const arr: EdgeV2[] = [];
|
||||
for (let i = 0; i < nodes.length - 1; i++)
|
||||
arr.push({ id: newId('e'), from: nodes[i].id, to: nodes[i + 1].id, label: 'default' });
|
||||
return arr;
|
||||
}
|
||||
|
||||
export function summarizeNode(n?: NodeBase | null): string {
|
||||
if (!n) return '';
|
||||
if (n.type === 'click' || n.type === 'fill')
|
||||
return n.config?.target?.candidates?.[0]?.value || '未配置选择器';
|
||||
if (n.type === 'navigate') return n.config?.url || '';
|
||||
if (n.type === 'key') return n.config?.keys || '';
|
||||
if (n.type === 'delay') return `${Number(n.config?.ms || 0)}ms`;
|
||||
if (n.type === 'http') return `${n.config?.method || 'GET'} ${n.config?.url || ''}`;
|
||||
if (n.type === 'extract') return `${n.config?.selector || ''} -> ${n.config?.saveAs || ''}`;
|
||||
if (n.type === 'openTab') return `open ${n.config?.url || ''}`;
|
||||
if (n.type === 'switchTab')
|
||||
return `switch ${n.config?.tabId || n.config?.urlContains || n.config?.titleContains || ''}`;
|
||||
if (n.type === 'closeTab') return `close ${n.config?.url || ''}`;
|
||||
if (n.type === 'wait') return JSON.stringify(n.config?.condition || {});
|
||||
if (n.type === 'assert') return JSON.stringify(n.config?.assert || {});
|
||||
if (n.type === 'script') return (n.config?.code || '').slice(0, 30);
|
||||
return '';
|
||||
}
|
||||
|
||||
export function cloneFlow(flow: FlowV2): FlowV2 {
|
||||
return JSON.parse(JSON.stringify(flow));
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import type { NodeBase } from '@/entrypoints/background/record-replay/types';
|
||||
|
||||
export function validateNode(n: NodeBase): string[] {
|
||||
const errs: string[] = [];
|
||||
if (n.disabled) return errs; // 忽略禁用节点
|
||||
const c: any = n.config || {};
|
||||
|
||||
switch (n.type) {
|
||||
case 'click':
|
||||
case 'dblclick':
|
||||
case 'fill': {
|
||||
const hasCandidate = !!c?.target?.candidates?.length;
|
||||
if (!hasCandidate) errs.push('缺少目标选择器候选');
|
||||
if (n.type === 'fill' && (!('value' in c) || c.value === undefined)) errs.push('缺少输入值');
|
||||
break;
|
||||
}
|
||||
case 'wait': {
|
||||
if (!c?.condition) errs.push('缺少等待条件');
|
||||
break;
|
||||
}
|
||||
case 'assert': {
|
||||
if (!c?.assert) errs.push('缺少断言条件');
|
||||
break;
|
||||
}
|
||||
case 'navigate': {
|
||||
if (!c?.url) errs.push('缺少 URL');
|
||||
break;
|
||||
}
|
||||
case 'http': {
|
||||
if (!c?.url) errs.push('HTTP: 缺少 URL');
|
||||
if (c?.assign && typeof c.assign === 'object') {
|
||||
const pathRe = /^[A-Za-z0-9_]+(?:\.[A-Za-z0-9_]+|\[\d+\])*$/;
|
||||
for (const v of Object.values(c.assign)) {
|
||||
const s = String(v);
|
||||
if (!pathRe.test(s)) errs.push(`Assign: 路径非法 ${s}`);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'extract': {
|
||||
if (!c?.saveAs) errs.push('Extract: 需填写保存变量名');
|
||||
if (!c?.selector && !c?.js) errs.push('Extract: 需提供 selector 或 js');
|
||||
break;
|
||||
}
|
||||
case 'switchTab': {
|
||||
if (!c?.tabId && !c?.urlContains && !c?.titleContains)
|
||||
errs.push('SwitchTab: 需提供 tabId 或 URL/标题包含');
|
||||
break;
|
||||
}
|
||||
case 'closeTab': {
|
||||
// 允许空(关闭当前标签页),不强制
|
||||
break;
|
||||
}
|
||||
case 'script': {
|
||||
// 若配置了 saveAs/assign,应提供 code
|
||||
const hasAssign = c?.assign && Object.keys(c.assign).length > 0;
|
||||
if ((c?.saveAs || hasAssign) && !String(c?.code || '').trim())
|
||||
errs.push('Script: 配置了保存/映射但缺少代码');
|
||||
if (hasAssign) {
|
||||
const pathRe = /^[A-Za-z0-9_]+(?:\.[A-Za-z0-9_]+|\[\d+\])*$/;
|
||||
for (const v of Object.values(c.assign || {})) {
|
||||
const s = String(v);
|
||||
if (!pathRe.test(s)) errs.push(`Assign: 路径非法 ${s}`);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
return errs;
|
||||
}
|
||||
|
||||
export function validateFlow(nodes: NodeBase[]): {
|
||||
totalErrors: number;
|
||||
nodeErrors: Record<string, string[]>;
|
||||
} {
|
||||
const nodeErrors: Record<string, string[]> = {};
|
||||
let totalErrors = 0;
|
||||
for (const n of nodes) {
|
||||
const e = validateNode(n);
|
||||
if (e.length) {
|
||||
nodeErrors[n.id] = e;
|
||||
totalErrors += e.length;
|
||||
}
|
||||
}
|
||||
return { totalErrors, nodeErrors };
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
import { reactive, ref } from 'vue';
|
||||
import type {
|
||||
Flow as FlowV2,
|
||||
NodeBase,
|
||||
Edge as EdgeV2,
|
||||
} from '@/entrypoints/background/record-replay/types';
|
||||
import {
|
||||
autoChainEdges,
|
||||
cloneFlow,
|
||||
defaultConfigFor,
|
||||
newId,
|
||||
nodesToSteps,
|
||||
stepsToNodes,
|
||||
summarizeNode,
|
||||
topoOrder,
|
||||
} from '../model/transforms';
|
||||
|
||||
export function useBuilderStore(initial?: FlowV2 | null) {
|
||||
const flowLocal = reactive<FlowV2>({ id: '', name: '', version: 1, steps: [], variables: [] });
|
||||
const nodes = reactive<NodeBase[]>([]);
|
||||
const edges = reactive<EdgeV2[]>([]);
|
||||
const activeNodeId = ref<string | null>(null);
|
||||
const pendingFrom = ref<string | null>(null);
|
||||
const paletteTypes = [
|
||||
'click',
|
||||
'fill',
|
||||
'key',
|
||||
'wait',
|
||||
'assert',
|
||||
'navigate',
|
||||
'script',
|
||||
'delay',
|
||||
'http',
|
||||
'extract',
|
||||
'openTab',
|
||||
'switchTab',
|
||||
'closeTab',
|
||||
] as NodeBase['type'][];
|
||||
|
||||
// --- history (undo/redo) ---
|
||||
type Snapshot = {
|
||||
flow: Pick<FlowV2, 'name' | 'description'>;
|
||||
nodes: NodeBase[];
|
||||
edges: EdgeV2[];
|
||||
};
|
||||
const HISTORY_MAX = 50;
|
||||
const past: Snapshot[] = [];
|
||||
const future: Snapshot[] = [];
|
||||
function takeSnapshot(): Snapshot {
|
||||
return {
|
||||
flow: { name: flowLocal.name, description: flowLocal.description } as any,
|
||||
nodes: JSON.parse(JSON.stringify(nodes)),
|
||||
edges: JSON.parse(JSON.stringify(edges)),
|
||||
};
|
||||
}
|
||||
function applySnapshot(s: Snapshot) {
|
||||
flowLocal.name = (s.flow as any).name || '';
|
||||
(flowLocal as any).description = (s.flow as any).description || '';
|
||||
nodes.splice(0, nodes.length, ...JSON.parse(JSON.stringify(s.nodes)));
|
||||
edges.splice(0, edges.length, ...JSON.parse(JSON.stringify(s.edges)));
|
||||
}
|
||||
function recordChange() {
|
||||
past.push(takeSnapshot());
|
||||
// clear redo stack on new change
|
||||
future.length = 0;
|
||||
if (past.length > HISTORY_MAX) past.splice(0, past.length - HISTORY_MAX);
|
||||
}
|
||||
function undo() {
|
||||
if (past.length === 0) return;
|
||||
const current = takeSnapshot();
|
||||
const prev = past.pop()!;
|
||||
future.push(current);
|
||||
applySnapshot(prev);
|
||||
}
|
||||
function redo() {
|
||||
if (future.length === 0) return;
|
||||
const current = takeSnapshot();
|
||||
const next = future.pop()!;
|
||||
past.push(current);
|
||||
applySnapshot(next);
|
||||
}
|
||||
|
||||
function layoutIfNeeded() {
|
||||
const startX = 120,
|
||||
startY = 80,
|
||||
gapY = 120;
|
||||
nodes.forEach((n, i) => {
|
||||
if (!n.ui || isNaN(n.ui.x) || isNaN(n.ui.y)) n.ui = { x: startX, y: startY + i * gapY };
|
||||
});
|
||||
}
|
||||
|
||||
function initFromFlow(flow: FlowV2) {
|
||||
const deep = cloneFlow(flow);
|
||||
Object.assign(flowLocal, deep);
|
||||
nodes.splice(
|
||||
0,
|
||||
nodes.length,
|
||||
...(Array.isArray(deep.nodes) && deep.nodes.length
|
||||
? deep.nodes
|
||||
: stepsToNodes(deep.steps || [])),
|
||||
);
|
||||
edges.splice(
|
||||
0,
|
||||
edges.length,
|
||||
...(Array.isArray(deep.edges) && deep.edges.length ? deep.edges : autoChainEdges(nodes)),
|
||||
);
|
||||
layoutIfNeeded();
|
||||
activeNodeId.value = nodes[0]?.id || null;
|
||||
// reset history
|
||||
past.length = 0;
|
||||
future.length = 0;
|
||||
past.push(takeSnapshot());
|
||||
}
|
||||
|
||||
function selectNode(id: string) {
|
||||
if (pendingFrom.value && pendingFrom.value !== id) {
|
||||
onConnect(pendingFrom.value, id);
|
||||
pendingFrom.value = null;
|
||||
}
|
||||
activeNodeId.value = id;
|
||||
}
|
||||
|
||||
function addNode(t: NodeBase['type']) {
|
||||
const id = newId(t);
|
||||
const n: NodeBase = {
|
||||
id,
|
||||
type: t,
|
||||
name: '',
|
||||
disabled: false,
|
||||
config: defaultConfigFor(t),
|
||||
ui: { x: 200 + nodes.length * 24, y: 120 + nodes.length * 96 },
|
||||
};
|
||||
nodes.push(n);
|
||||
if (nodes.length > 1) {
|
||||
const prev = nodes[nodes.length - 2];
|
||||
edges.push({ id: newId('e'), from: prev.id, to: id, label: 'default' });
|
||||
}
|
||||
activeNodeId.value = id;
|
||||
recordChange();
|
||||
}
|
||||
|
||||
function duplicateNode(id: string) {
|
||||
const src = nodes.find((n) => n.id === id);
|
||||
if (!src) return;
|
||||
const cp: NodeBase = JSON.parse(JSON.stringify(src));
|
||||
cp.id = newId(src.type);
|
||||
cp.name = src.name ? `${src.name} Copy` : '';
|
||||
const baseX = cp.ui && typeof cp.ui.x === 'number' ? cp.ui.x : 200;
|
||||
const baseY = cp.ui && typeof cp.ui.y === 'number' ? cp.ui.y : 120;
|
||||
cp.ui = { x: baseX + 40, y: baseY + 40 };
|
||||
nodes.push(cp);
|
||||
activeNodeId.value = cp.id;
|
||||
recordChange();
|
||||
}
|
||||
|
||||
function removeNode(id: string) {
|
||||
const idx = nodes.findIndex((n) => n.id === id);
|
||||
if (idx < 0) return;
|
||||
nodes.splice(idx, 1);
|
||||
for (let i = edges.length - 1; i >= 0; i--) {
|
||||
const e = edges[i];
|
||||
if (e.from === id || e.to === id) edges.splice(i, 1);
|
||||
}
|
||||
activeNodeId.value = nodes[Math.min(idx, nodes.length - 1)]?.id || null;
|
||||
recordChange();
|
||||
}
|
||||
|
||||
function setNodePosition(id: string, x: number, y: number) {
|
||||
const n = nodes.find((n) => n.id === id);
|
||||
if (!n) return;
|
||||
n.ui = { x: Math.round(x), y: Math.round(y) };
|
||||
// 不计入历史栈,避免频繁记录;由用户触发操作(连接/新增/删除等)记录。
|
||||
}
|
||||
|
||||
function connectFrom(id: string) {
|
||||
pendingFrom.value = id;
|
||||
}
|
||||
|
||||
function onConnect(sourceId: string, targetId: string) {
|
||||
// 单一默认出边:删除同源 default 出边
|
||||
for (let i = edges.length - 1; i >= 0; i--) {
|
||||
const e = edges[i];
|
||||
if (e.from === sourceId && (!e.label || e.label === 'default')) edges.splice(i, 1);
|
||||
}
|
||||
edges.push({ id: newId('e'), from: sourceId, to: targetId, label: 'default' });
|
||||
recordChange();
|
||||
}
|
||||
|
||||
function importFromSteps() {
|
||||
const arr = stepsToNodes(flowLocal.steps || []);
|
||||
nodes.splice(0, nodes.length, ...arr);
|
||||
edges.splice(0, edges.length, ...autoChainEdges(arr));
|
||||
layoutIfNeeded();
|
||||
recordChange();
|
||||
}
|
||||
|
||||
function exportSteps() {
|
||||
return nodesToSteps(nodes, edges);
|
||||
}
|
||||
|
||||
function summarize(id?: string) {
|
||||
const n = nodes.find((x) => x.id === id);
|
||||
return summarizeNode(n || null);
|
||||
}
|
||||
|
||||
// 自动排版:根据拓扑顺序纵向排列,列宽 300、行高 120;若存在分叉,简单按顺序换行
|
||||
function layoutAuto() {
|
||||
const order = topoOrder(nodes, edges);
|
||||
const startX = 120,
|
||||
startY = 80,
|
||||
stepY = 120,
|
||||
stepX = 300,
|
||||
maxPerCol = Math.max(6, Math.ceil(order.length / 3));
|
||||
let col = 0,
|
||||
row = 0;
|
||||
for (const n of order) {
|
||||
n.ui = { x: startX + col * stepX, y: startY + row * stepY } as any;
|
||||
row++;
|
||||
if (row >= maxPerCol) {
|
||||
row = 0;
|
||||
col++;
|
||||
}
|
||||
}
|
||||
recordChange();
|
||||
}
|
||||
|
||||
if (initial) initFromFlow(initial);
|
||||
|
||||
return {
|
||||
flowLocal,
|
||||
nodes,
|
||||
edges,
|
||||
activeNodeId,
|
||||
pendingFrom,
|
||||
paletteTypes,
|
||||
undo,
|
||||
redo,
|
||||
initFromFlow,
|
||||
selectNode,
|
||||
addNode,
|
||||
duplicateNode,
|
||||
removeNode,
|
||||
setNodePosition,
|
||||
connectFrom,
|
||||
onConnect,
|
||||
importFromSteps,
|
||||
exportSteps,
|
||||
summarize,
|
||||
layoutAuto,
|
||||
};
|
||||
}
|
||||
@@ -537,23 +537,61 @@
|
||||
const sel = String(request.selector || '').trim();
|
||||
let el = null;
|
||||
if (useText && textQuery) {
|
||||
const all = Array.from(document.querySelectorAll('body *'));
|
||||
for (const node of all) {
|
||||
const normalize = (s) =>
|
||||
String(s || '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
const query = normalize(textQuery);
|
||||
const bigrams = (s) => {
|
||||
const arr = [];
|
||||
for (let i = 0; i < s.length - 1; i++) arr.push(s.slice(i, i + 2));
|
||||
return arr;
|
||||
};
|
||||
const dice = (a, b) => {
|
||||
if (!a || !b) return 0;
|
||||
const A = bigrams(a);
|
||||
const B = bigrams(b);
|
||||
if (A.length === 0 || B.length === 0) return 0;
|
||||
let inter = 0;
|
||||
const map = new Map();
|
||||
for (const t of A) map.set(t, (map.get(t) || 0) + 1);
|
||||
for (const t of B) {
|
||||
const c = map.get(t) || 0;
|
||||
if (c > 0) {
|
||||
inter++;
|
||||
map.set(t, c - 1);
|
||||
}
|
||||
}
|
||||
return (2 * inter) / (A.length + B.length);
|
||||
};
|
||||
let best = { el: null, score: 0 };
|
||||
const walker = document.createTreeWalker(
|
||||
document.body || document.documentElement,
|
||||
NodeFilter.SHOW_ELEMENT,
|
||||
);
|
||||
let visited = 0;
|
||||
while (walker.nextNode()) {
|
||||
const node = /** @type {Element} */ (walker.currentNode);
|
||||
try {
|
||||
const cs = window.getComputedStyle(node);
|
||||
if (cs.display === 'none' || cs.visibility === 'hidden' || cs.opacity === '0')
|
||||
continue;
|
||||
const rect = /** @type {HTMLElement} */ (node).getBoundingClientRect();
|
||||
if (rect.width <= 0 || rect.height <= 0) continue;
|
||||
const txt = (node.textContent || '').trim();
|
||||
if (txt && txt.includes(textQuery)) {
|
||||
const txt = normalize(node.textContent || '');
|
||||
if (!txt) continue;
|
||||
// quick path: substring contains
|
||||
if (txt.includes(query)) {
|
||||
el = node;
|
||||
break;
|
||||
}
|
||||
} catch (_) {
|
||||
/* ignore */
|
||||
}
|
||||
const sc = dice(txt, query);
|
||||
if (sc > best.score) best = { el: node, score: sc };
|
||||
} catch {}
|
||||
if (++visited > 5000) break;
|
||||
}
|
||||
if (!el && best.el && best.score >= 0.6) el = best.el;
|
||||
} else {
|
||||
if (!sel) {
|
||||
sendResponse({ success: false, error: 'selector is required' });
|
||||
@@ -625,21 +663,136 @@
|
||||
}
|
||||
if (request && request.action === 'collectVariables') {
|
||||
try {
|
||||
const vars = Array.isArray(request.variables) ? request.variables : [];
|
||||
const values = {};
|
||||
for (const v of vars) {
|
||||
const key = String(v && v.key ? v.key : '');
|
||||
if (!key) continue;
|
||||
const label = v.label || key;
|
||||
const def = v.default || '';
|
||||
const promptText = `请输入参数 ${label} (${key})`;
|
||||
// Note: prompt in page context; in some sites may be blocked by CSP
|
||||
let val = window.prompt(promptText, def);
|
||||
if (typeof val !== 'string') val = def;
|
||||
values[key] = val;
|
||||
let vars = Array.isArray(request.variables) ? request.variables : [];
|
||||
if ((!vars || vars.length === 0) && request.payload) {
|
||||
try {
|
||||
const p = JSON.parse(String(request.payload || '{}'));
|
||||
if (Array.isArray(p.variables)) vars = p.variables;
|
||||
} catch {}
|
||||
}
|
||||
sendResponse({ success: true, values });
|
||||
return true;
|
||||
const useOverlay = request.useOverlay !== false; // default true
|
||||
const values = {};
|
||||
if (!useOverlay) {
|
||||
for (const v of vars) {
|
||||
const key = String(v && v.key ? v.key : '');
|
||||
if (!key) continue;
|
||||
const label = v.label || key;
|
||||
const def = v.default || '';
|
||||
const promptText = `请输入参数 ${label} (${key})`;
|
||||
let val = window.prompt(promptText, def);
|
||||
if (typeof val !== 'string') val = def;
|
||||
values[key] = val;
|
||||
}
|
||||
sendResponse({ success: true, values });
|
||||
return true;
|
||||
}
|
||||
// Build overlay form
|
||||
const hostId = '__rr_var_overlay__';
|
||||
let host = document.getElementById(hostId);
|
||||
if (host) host.remove();
|
||||
host = document.createElement('div');
|
||||
host.id = hostId;
|
||||
Object.assign(host.style, {
|
||||
position: 'fixed',
|
||||
inset: '0',
|
||||
background: 'rgba(0,0,0,0.35)',
|
||||
zIndex: 2147483646,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
});
|
||||
const panel = document.createElement('div');
|
||||
Object.assign(panel.style, {
|
||||
background: '#fff',
|
||||
borderRadius: '8px',
|
||||
width: 'min(520px, 96vw)',
|
||||
maxHeight: '80vh',
|
||||
overflow: 'auto',
|
||||
boxShadow: '0 8px 24px rgba(0,0,0,0.2)',
|
||||
padding: '16px',
|
||||
fontFamily: 'system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif',
|
||||
});
|
||||
const title = document.createElement('div');
|
||||
title.textContent = '请输入回放参数';
|
||||
Object.assign(title.style, { fontSize: '16px', fontWeight: '600', marginBottom: '12px' });
|
||||
const form = document.createElement('form');
|
||||
for (const v of vars) {
|
||||
const row = document.createElement('div');
|
||||
Object.assign(row.style, { marginBottom: '10px' });
|
||||
const label = document.createElement('label');
|
||||
label.textContent = `${v.label || v.key}${v.sensitive ? ' (敏感)' : ''}`;
|
||||
Object.assign(label.style, {
|
||||
display: 'block',
|
||||
marginBottom: '6px',
|
||||
fontWeight: '500',
|
||||
});
|
||||
const input = document.createElement('input');
|
||||
input.type = v.sensitive ? 'password' : 'text';
|
||||
input.name = String(v.key);
|
||||
input.value = String(v.default || '');
|
||||
Object.assign(input.style, {
|
||||
width: '100%',
|
||||
boxSizing: 'border-box',
|
||||
padding: '8px 10px',
|
||||
border: '1px solid #d0d7de',
|
||||
borderRadius: '6px',
|
||||
outline: 'none',
|
||||
});
|
||||
row.appendChild(label);
|
||||
row.appendChild(input);
|
||||
form.appendChild(row);
|
||||
}
|
||||
const actions = document.createElement('div');
|
||||
Object.assign(actions.style, { display: 'flex', gap: '8px', marginTop: '12px' });
|
||||
const ok = document.createElement('button');
|
||||
ok.type = 'submit';
|
||||
ok.textContent = '确定';
|
||||
Object.assign(ok.style, {
|
||||
background: '#0969da',
|
||||
color: '#fff',
|
||||
border: 'none',
|
||||
padding: '8px 16px',
|
||||
borderRadius: '6px',
|
||||
cursor: 'pointer',
|
||||
});
|
||||
const cancel = document.createElement('button');
|
||||
cancel.type = 'button';
|
||||
cancel.textContent = '取消';
|
||||
Object.assign(cancel.style, {
|
||||
background: '#f3f4f6',
|
||||
color: '#111',
|
||||
border: '1px solid #d0d7de',
|
||||
padding: '8px 16px',
|
||||
borderRadius: '6px',
|
||||
cursor: 'pointer',
|
||||
});
|
||||
actions.appendChild(ok);
|
||||
actions.appendChild(cancel);
|
||||
panel.appendChild(title);
|
||||
panel.appendChild(form);
|
||||
panel.appendChild(actions);
|
||||
host.appendChild(panel);
|
||||
document.documentElement.appendChild(host);
|
||||
|
||||
const cleanup = () => {
|
||||
try {
|
||||
host.remove();
|
||||
} catch {}
|
||||
};
|
||||
cancel.onclick = () => {
|
||||
cleanup();
|
||||
sendResponse({ success: false, cancelled: true });
|
||||
};
|
||||
form.onsubmit = (e) => {
|
||||
e.preventDefault();
|
||||
for (const v of vars) {
|
||||
const el = form.querySelector(`input[name="${CSS.escape(String(v.key))}"]`);
|
||||
if (el) values[v.key] = /** @type {HTMLInputElement} */ (el).value;
|
||||
}
|
||||
cleanup();
|
||||
sendResponse({ success: true, values });
|
||||
};
|
||||
return true; // async
|
||||
} catch (e) {
|
||||
sendResponse({ success: false, error: String(e && e.message ? e.message : e) });
|
||||
return true;
|
||||
|
||||
@@ -11,6 +11,9 @@
|
||||
const sampledDrag = [];
|
||||
|
||||
let isRecording = false;
|
||||
let isPaused = false;
|
||||
let hideInputValues = false;
|
||||
let highlightBox = null;
|
||||
let pendingFlow = {
|
||||
id: `flow_${Date.now()}`,
|
||||
name: '未命名录制',
|
||||
@@ -104,7 +107,7 @@
|
||||
}
|
||||
|
||||
function onClick(e) {
|
||||
if (!isRecording) return;
|
||||
if (!isRecording || isPaused) return;
|
||||
const el = e.target instanceof Element ? e.target : null;
|
||||
if (!el) return;
|
||||
const target = buildTarget(el);
|
||||
@@ -112,14 +115,15 @@
|
||||
}
|
||||
|
||||
function onInput(e) {
|
||||
if (!isRecording) return;
|
||||
if (!isRecording || isPaused) return;
|
||||
const el =
|
||||
e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement
|
||||
? e.target
|
||||
: null;
|
||||
if (!el) return;
|
||||
const target = buildTarget(el);
|
||||
const isSensitive = SENSITIVE_INPUT_TYPES.has((el.getAttribute('type') || '').toLowerCase());
|
||||
const isSensitive =
|
||||
hideInputValues || SENSITIVE_INPUT_TYPES.has((el.getAttribute('type') || '').toLowerCase());
|
||||
let value = el.value || '';
|
||||
if (isSensitive) {
|
||||
const varKey = el.name ? el.name : `var_${Math.random().toString(36).slice(2, 6)}`;
|
||||
@@ -130,7 +134,7 @@
|
||||
}
|
||||
|
||||
function onKeydown(e) {
|
||||
if (!isRecording) return;
|
||||
if (!isRecording || isPaused) return;
|
||||
// modifier+key or Enter/Backspace etc
|
||||
const mods = [];
|
||||
if (e.ctrlKey) mods.push('ctrl');
|
||||
@@ -144,22 +148,11 @@
|
||||
pushStep({ type: 'key', keys, screenshotOnFail: false });
|
||||
}
|
||||
|
||||
function onKeyup(e) {
|
||||
if (!isRecording) return;
|
||||
const mods = [];
|
||||
if (e.ctrlKey) mods.push('ctrl');
|
||||
if (e.metaKey) mods.push('cmd');
|
||||
if (e.altKey) mods.push('alt');
|
||||
if (e.shiftKey) mods.push('shift');
|
||||
let keyToken = e.key || '';
|
||||
keyToken = keyToken.length === 1 ? keyToken.toLowerCase() : keyToken.toLowerCase();
|
||||
const keys = mods.length ? `${mods.join('+')}+${keyToken}` : keyToken;
|
||||
pushStep({ type: 'key', keys, screenshotOnFail: false });
|
||||
}
|
||||
// keyup 不再记录,避免重复噪声
|
||||
|
||||
// Composition IME events (record markers for analysis; playback is no-op via script step)
|
||||
function onCompositionStart() {
|
||||
if (!isRecording) return;
|
||||
if (!isRecording || isPaused) return;
|
||||
pushStep({
|
||||
type: 'script',
|
||||
world: 'ISOLATED',
|
||||
@@ -169,7 +162,7 @@
|
||||
});
|
||||
}
|
||||
function onCompositionEnd() {
|
||||
if (!isRecording) return;
|
||||
if (!isRecording || isPaused) return;
|
||||
pushStep({
|
||||
type: 'script',
|
||||
world: 'ISOLATED',
|
||||
@@ -181,7 +174,7 @@
|
||||
|
||||
let lastScrollAt = 0;
|
||||
function onScroll(e) {
|
||||
if (!isRecording) return;
|
||||
if (!isRecording || isPaused) return;
|
||||
const nowTs = now();
|
||||
if (nowTs - lastScrollAt < THROTTLE_SCROLL_MS) return;
|
||||
lastScrollAt = nowTs;
|
||||
@@ -227,7 +220,7 @@
|
||||
document.addEventListener('change', onInput, true);
|
||||
document.addEventListener('input', onInput, true);
|
||||
document.addEventListener('keydown', onKeydown, true);
|
||||
document.addEventListener('keyup', onKeyup, true);
|
||||
// document.addEventListener('keyup', onKeyup, true);
|
||||
document.addEventListener('compositionstart', onCompositionStart, true);
|
||||
document.addEventListener('compositionend', onCompositionEnd, true);
|
||||
window.addEventListener('scroll', onScroll, { passive: true });
|
||||
@@ -241,7 +234,7 @@
|
||||
document.removeEventListener('change', onInput, true);
|
||||
document.removeEventListener('input', onInput, true);
|
||||
document.removeEventListener('keydown', onKeydown, true);
|
||||
document.removeEventListener('keyup', onKeyup, true);
|
||||
// document.removeEventListener('keyup', onKeyup, true);
|
||||
document.removeEventListener('compositionstart', onCompositionStart, true);
|
||||
document.removeEventListener('compositionend', onCompositionEnd, true);
|
||||
window.removeEventListener('scroll', onScroll, { passive: true });
|
||||
@@ -269,7 +262,9 @@
|
||||
function start(flowMeta) {
|
||||
reset(flowMeta || {});
|
||||
isRecording = true;
|
||||
isPaused = false;
|
||||
attach();
|
||||
ensureOverlay();
|
||||
chrome.runtime.sendMessage({
|
||||
type: 'rr_recorder_event',
|
||||
payload: { kind: 'start', flow: pendingFlow },
|
||||
@@ -279,6 +274,7 @@
|
||||
function stop() {
|
||||
isRecording = false;
|
||||
detach();
|
||||
removeOverlay();
|
||||
chrome.runtime.sendMessage({
|
||||
type: 'rr_recorder_event',
|
||||
payload: { kind: 'stop', flow: pendingFlow },
|
||||
@@ -286,6 +282,101 @@
|
||||
return pendingFlow;
|
||||
}
|
||||
|
||||
function pause() {
|
||||
isPaused = true;
|
||||
updateOverlayStatus();
|
||||
}
|
||||
|
||||
function resume() {
|
||||
isRecording = true;
|
||||
isPaused = false;
|
||||
attach();
|
||||
ensureOverlay();
|
||||
updateOverlayStatus();
|
||||
}
|
||||
|
||||
function ensureOverlay() {
|
||||
let root = document.getElementById('__rr_rec_overlay');
|
||||
if (root) return;
|
||||
root = document.createElement('div');
|
||||
root.id = '__rr_rec_overlay';
|
||||
Object.assign(root.style, {
|
||||
position: 'fixed',
|
||||
top: '10px',
|
||||
right: '10px',
|
||||
zIndex: 2147483646,
|
||||
fontFamily: 'system-ui,-apple-system,Segoe UI,Roboto,Arial',
|
||||
});
|
||||
root.innerHTML = `
|
||||
<div id="__rr_rec_panel" style="background: rgba(220,38,38,0.95); color: #fff; padding:8px 10px; border-radius:8px; display:flex; align-items:center; gap:8px; box-shadow:0 4px 16px rgba(0,0,0,0.2);">
|
||||
<span id="__rr_badge" style="font-weight:600;">录制中</span>
|
||||
<label style="display:inline-flex; align-items:center; gap:4px; font-size:12px;">
|
||||
<input id="__rr_hide_values" type="checkbox" style="vertical-align:middle;" />隐藏输入值
|
||||
</label>
|
||||
<button id="__rr_pause" style="background:#fff; color:#111; border:none; border-radius:6px; padding:4px 8px; cursor:pointer;">暂停</button>
|
||||
<button id="__rr_stop" style="background:#111; color:#fff; border:none; border-radius:6px; padding:4px 8px; cursor:pointer;">停止</button>
|
||||
</div>
|
||||
`;
|
||||
document.documentElement.appendChild(root);
|
||||
const btnPause = root.querySelector('#__rr_pause');
|
||||
const btnStop = root.querySelector('#__rr_stop');
|
||||
const hideChk = root.querySelector('#__rr_hide_values');
|
||||
hideChk.checked = hideInputValues;
|
||||
hideChk.addEventListener('change', () => (hideInputValues = hideChk.checked));
|
||||
btnPause.addEventListener('click', () => {
|
||||
if (!isPaused) pause();
|
||||
else resume();
|
||||
});
|
||||
btnStop.addEventListener('click', () => {
|
||||
stop();
|
||||
});
|
||||
updateOverlayStatus();
|
||||
// element highlight box
|
||||
highlightBox = document.createElement('div');
|
||||
Object.assign(highlightBox.style, {
|
||||
position: 'fixed',
|
||||
border: '2px solid rgba(59,130,246,0.9)',
|
||||
borderRadius: '4px',
|
||||
background: 'rgba(59,130,246,0.15)',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 2147483645,
|
||||
});
|
||||
document.documentElement.appendChild(highlightBox);
|
||||
document.addEventListener('mousemove', onHoverMove, true);
|
||||
}
|
||||
|
||||
function removeOverlay() {
|
||||
try {
|
||||
const root = document.getElementById('__rr_rec_overlay');
|
||||
if (root) root.remove();
|
||||
if (highlightBox) highlightBox.remove();
|
||||
document.removeEventListener('mousemove', onHoverMove, true);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function updateOverlayStatus() {
|
||||
const badge = document.getElementById('__rr_badge');
|
||||
const pauseBtn = document.getElementById('__rr_pause');
|
||||
if (badge) badge.textContent = isPaused ? '已暂停' : '录制中';
|
||||
if (pauseBtn) pauseBtn.textContent = isPaused ? '继续' : '暂停';
|
||||
}
|
||||
|
||||
function onHoverMove(e) {
|
||||
if (!highlightBox || !isRecording || isPaused) return;
|
||||
const el = e.target instanceof Element ? e.target : null;
|
||||
if (!el) return;
|
||||
try {
|
||||
const r = el.getBoundingClientRect();
|
||||
Object.assign(highlightBox.style, {
|
||||
left: `${Math.round(r.left)}px`,
|
||||
top: `${Math.round(r.top)}px`,
|
||||
width: `${Math.round(Math.max(0, r.width))}px`,
|
||||
height: `${Math.round(Math.max(0, r.height))}px`,
|
||||
display: r.width > 0 && r.height > 0 ? 'block' : 'none',
|
||||
});
|
||||
} catch {}
|
||||
}
|
||||
|
||||
chrome.runtime.onMessage.addListener((request, _sender, sendResponse) => {
|
||||
try {
|
||||
if (request && request.action === 'rr_recorder_control') {
|
||||
@@ -294,10 +385,12 @@
|
||||
start(request.meta || {});
|
||||
sendResponse({ success: true });
|
||||
return true;
|
||||
} else if (cmd === 'pause') {
|
||||
pause();
|
||||
sendResponse({ success: true });
|
||||
return true;
|
||||
} else if (cmd === 'resume') {
|
||||
// Attach without resetting flow or sending start event
|
||||
isRecording = true;
|
||||
attach();
|
||||
resume();
|
||||
sendResponse({ success: true });
|
||||
return true;
|
||||
} else if (cmd === 'stop') {
|
||||
|
||||
@@ -145,6 +145,58 @@
|
||||
});
|
||||
}
|
||||
|
||||
function waitForSelector({ selector, visible = true, timeout = 5000 }) {
|
||||
return new Promise((resolve) => {
|
||||
const start = Date.now();
|
||||
let resolved = false;
|
||||
|
||||
const isMatch = () => {
|
||||
try {
|
||||
const el = document.querySelector(selector);
|
||||
if (!el) return null;
|
||||
if (!visible) return el;
|
||||
return isVisible(el) ? el : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const done = (result) => {
|
||||
if (resolved) return;
|
||||
resolved = true;
|
||||
obs && obs.disconnect();
|
||||
clearTimeout(timer);
|
||||
resolve(result);
|
||||
};
|
||||
|
||||
const check = () => {
|
||||
const el = isMatch();
|
||||
if (el) {
|
||||
const ref = ensureRefForElement(el);
|
||||
const center = centerOf(el);
|
||||
done({ success: true, matched: { ref, center }, tookMs: Date.now() - start });
|
||||
}
|
||||
};
|
||||
|
||||
const obs = new MutationObserver(check);
|
||||
try {
|
||||
obs.observe(document.documentElement || document.body, {
|
||||
subtree: true,
|
||||
childList: true,
|
||||
characterData: true,
|
||||
attributes: true,
|
||||
});
|
||||
} catch {}
|
||||
|
||||
// initial check
|
||||
check();
|
||||
const timer = setTimeout(
|
||||
() => done({ success: false, reason: 'timeout', tookMs: Date.now() - start }),
|
||||
Math.max(0, timeout),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
chrome.runtime.onMessage.addListener((request, _sender, sendResponse) => {
|
||||
try {
|
||||
if (request && request.action === 'wait_helper_ping') {
|
||||
@@ -162,6 +214,17 @@
|
||||
waitFor({ text, appear, timeout }).then((res) => sendResponse(res));
|
||||
return true; // async
|
||||
}
|
||||
if (request && request.action === 'waitForSelector') {
|
||||
const selector = String(request.selector || '').trim();
|
||||
const visible = request.visible !== false; // default true
|
||||
const timeout = Number(request.timeout || 5000);
|
||||
if (!selector) {
|
||||
sendResponse({ success: false, error: 'selector is required' });
|
||||
return true;
|
||||
}
|
||||
waitForSelector({ selector, visible, timeout }).then((res) => sendResponse(res));
|
||||
return true; // async
|
||||
}
|
||||
} catch (e) {
|
||||
sendResponse({ success: false, error: String(e && e.message ? e.message : e) });
|
||||
return true;
|
||||
|
||||
@@ -21,6 +21,10 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.11.0",
|
||||
"@vue-flow/background": "^1.3.2",
|
||||
"@vue-flow/controls": "^1.1.3",
|
||||
"@vue-flow/core": "^1.47.0",
|
||||
"@vue-flow/minimap": "^1.5.4",
|
||||
"@xenova/transformers": "^2.17.2",
|
||||
"chrome-mcp-shared": "workspace:*",
|
||||
"date-fns": "^4.1.0",
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
# 模块 C — 编排画布(Builder)技术设计(builder.design.md)
|
||||
|
||||
版本:v1.0(基于 builder.prd.md)
|
||||
目标读者:前端架构/扩展工程/MCP 工具维护者
|
||||
|
||||
## 1. 架构概览
|
||||
|
||||
- 编辑端(Popup 内)
|
||||
- 画布 UI:Vue + 画布库(建议 VueFlow),包含节点库、画布、迷你地图、属性面板、搜索/对齐、撤销/重做、自动保存。
|
||||
- 状态管理:本地 store(可用组合式 API/Pinia),维护 `nodes/edges/variables/meta` 与 UI 状态。
|
||||
- 序列化:与 `FlowV2` 对齐,保存至 `chrome.storage.local`,并确保与 `Flow` 线性模式互转。
|
||||
- 执行端(Background)
|
||||
- Runner 增强:在存在 `nodes/edges` 时按 DAG 模式执行;缺省时沿用 steps[] 线性执行。
|
||||
- Node Registry:节点注册表(type→validate/run 映射),各节点内部复用 MCP 工具(chrome\_\*)。
|
||||
- 消息/发布
|
||||
- 导入/导出/发布工具:沿用现有 message types 与 native host 动态工具注册流程。
|
||||
|
||||
## 2. 数据模型
|
||||
|
||||
### 2.1 Flow V2 接口(与 design.md 保持一致)
|
||||
|
||||
```ts
|
||||
export type NodeType =
|
||||
| 'click'
|
||||
| 'fill'
|
||||
| 'key'
|
||||
| 'wait'
|
||||
| 'assert'
|
||||
| 'script'
|
||||
| 'navigate'
|
||||
| 'openTab'
|
||||
| 'switchTab'
|
||||
| 'closeTab'
|
||||
| 'http'
|
||||
| 'extract'
|
||||
| 'delay';
|
||||
|
||||
export interface NodeBase {
|
||||
id: string;
|
||||
type: NodeType;
|
||||
name?: string;
|
||||
disabled?: boolean;
|
||||
config?: any; // 每个节点的专有配置(见 2.2)
|
||||
ui?: { x: number; y: number };
|
||||
}
|
||||
export interface Edge {
|
||||
id: string;
|
||||
from: string;
|
||||
to: string;
|
||||
label?: 'default' | 'true' | 'false' | 'onError'; // M1 仅 default
|
||||
}
|
||||
export interface FlowV2 extends Flow {
|
||||
// 兼容 V1
|
||||
nodes?: NodeBase[];
|
||||
edges?: Edge[];
|
||||
subflows?: Record<string, { nodes: NodeBase[]; edges: Edge[] }>;
|
||||
}
|
||||
```
|
||||
|
||||
### 2.2 节点配置(config)约定
|
||||
|
||||
- 通用:`timeoutMs?`、`retry? { count; intervalMs; backoff? }`、`screenshotOnFail?: boolean`、`saveAs?: string`(extract/http/script 可用)。
|
||||
- click/fill:`target: TargetLocator`(含 ref/candidates),fill 另有 `value: string`(支持 `{var}`)。
|
||||
- key:`keys: string`(e.g. `Backspace Enter` / `cmd+a`)。
|
||||
- wait:`condition: { text | selector | navigation | networkIdle }`。
|
||||
- assert:`assert: { exists | visible | textPresent | attribute{ selector; name; equals? | matches? } }`。
|
||||
- script:`world?: 'MAIN'|'ISOLATED'`,`code: string`,`assign?: Record<string,string>`(把返回对象字段映射到 vars)。
|
||||
- http:`method; url; headers?; body?; assign?: Record<string,string>`(JSONPath → vars)。
|
||||
- extract:`selector/attr/text/regex/js`(至少一种),`saveAs: string`。
|
||||
- navigate/openTab/switchTab/closeTab:与现有浏览器工具参数一致(tabTarget/startUrl/refresh 保持在运行选项层)。
|
||||
|
||||
## 3. 画布 UI 设计
|
||||
|
||||
- 组件构成
|
||||
- 左侧:节点库(基础动作分组)、搜索。
|
||||
- 中间:画布区(VueFlow),支持缩放/平移/网格吸附/多选/框选/快捷键(Del/⌘C/⌘V/⌘Z/⌘Shift+Z)。
|
||||
- 右侧:属性面板(分组:基本、目标/选择器、等待/断言、变量/映射、重试/超时、备注)。
|
||||
- 底部:日志区(运行时使用,可隐藏)。
|
||||
- 交互细节
|
||||
- 新建:拖入节点自动放置,连接时高亮可落点;禁止自环;断开连线回收端点。
|
||||
- 校验:必填项红框/提示;不合法连线阻止;保存前校验。
|
||||
- 兼容:从 steps[] 打开时自动串联节点;保存时可选择“覆盖 steps[]”(线性模式)或“仅保存 nodes/edges”。
|
||||
- 性能与体验
|
||||
- 大图优化:虚拟化/分层渲染;节点模板缓存;平移/缩放节流;100~300 节点流畅。
|
||||
|
||||
## 4. Runner(DAG 模式)
|
||||
|
||||
### 4.1 执行语义(M1)
|
||||
|
||||
- 当 `nodes/edges` 存在且不为空:按拓扑排序获得可执行序列;仅处理 label=default 的边。
|
||||
- 逐节点执行:
|
||||
1. 变量展开:把 config 中字符串字段里的 `{var}` 替换为 ctx.vars 的值;
|
||||
2. validate:节点注册表校验 config;
|
||||
3. run:映射到 MCP 工具(见 5),拿到结果;
|
||||
4. saveAs/assign:把产出写入 ctx.vars(或 ctx.outputs[节点ID])。
|
||||
5. 日志:推送节点级 RunLogEntry;失败按 `failStrategy`/`retry` 处理。
|
||||
- 退出条件:到达尾部或遇到不可恢复错误。
|
||||
|
||||
### 4.2 伪代码
|
||||
|
||||
```ts
|
||||
async function runDag(flow: FlowV2, options): Promise<RunResult> {
|
||||
const ctx = { vars: resolveVars(flow.variables, options.args), outputs: {}, runId };
|
||||
const order = topoSort(flow.nodes, flow.edges); // label=default only
|
||||
for (const nodeId of order) {
|
||||
const node = getNode(nodeId);
|
||||
if (node.disabled) continue;
|
||||
const runtime = registry[node.type];
|
||||
const conf = expandTemplates(node.config, ctx.vars);
|
||||
try {
|
||||
runtime.validate(conf);
|
||||
const out = await runtime.run({ tabId, ctx, logger }, conf);
|
||||
if (conf.saveAs) ctx.vars[conf.saveAs] = out?.value ?? out;
|
||||
if (conf.assign) applyAssign(ctx.vars, out, conf.assign); // JSONPath 支持留到 M2
|
||||
logSuccess(nodeId);
|
||||
} catch (e) {
|
||||
const retryOk = await maybeRetry(runtime, conf, e);
|
||||
if (!retryOk) handleFail(nodeId, e); // stop/continue
|
||||
}
|
||||
}
|
||||
return summarize(ctx);
|
||||
}
|
||||
```
|
||||
|
||||
## 5. 节点注册表与工具映射
|
||||
|
||||
- 注册表结构
|
||||
|
||||
```ts
|
||||
export interface NodeRuntime<T = any> {
|
||||
validate(config: T): { ok: boolean; errors?: string[] };
|
||||
run(ctx: NodeContext, config: T): Promise<any>;
|
||||
}
|
||||
export interface NodeContext {
|
||||
tabId: number;
|
||||
vars: Record<string, any>;
|
||||
outputs: Record<string, any>;
|
||||
runId: string;
|
||||
logger: (e: RunLogEntry) => void;
|
||||
}
|
||||
```
|
||||
|
||||
- 工具映射(与现有一致):
|
||||
- click → `chrome_click_element`(双击用 `chrome_computer.double_click`)。
|
||||
- fill → `chrome_fill_or_select`;key → `chrome_keyboard`。
|
||||
- wait/assert → `wait-helper` + `chrome_read_page`;navigate/open/switch/close → `chrome_navigate`/窗口工具。
|
||||
- script → `chrome_inject_script`(MAIN/ISOLATED)。
|
||||
- http → `chrome_network_request`(已有则复用)。
|
||||
- extract → `chrome_read_page` + 内容脚本聚合。
|
||||
|
||||
## 6. 存储/导入/导出/发布
|
||||
|
||||
- 存储:沿用 `chrome.storage.local`,key 不变(rr_flows);在 Flow 实体上新增 `nodes/edges` 字段。
|
||||
- 导入导出:与线性一致,JSON 中可同时含 steps/nodes/edges;导入时做版本迁移。
|
||||
- 发布为 MCP 动态工具:工具名 `flow.<slug>`;inputSchema 基于 variables + 运行选项生成;调用时走通用 `record_replay_flow_run`。
|
||||
|
||||
## 7. 校验与错误处理
|
||||
|
||||
- 编辑期校验:必填/格式/选择器空值;运行期校验:validate + 容错(默认 stop)。
|
||||
- 失败截图:统一由 runner 在 catch 路径触发 screenshot 工具;日志中标出节点 ID 与错误原因。
|
||||
- 选择器回退:沿用 selector-engine 策略;fallback 用信息写入日志,并在编辑器弹出更新提示。
|
||||
|
||||
## 8. 安全与隐私
|
||||
|
||||
- 敏感变量:不落盘,不导出;运行时仅从 args 注入;Overlay 表单/提示敏感字段。
|
||||
- 注入世界:ISOLATED 为默认;MAIN 仅在用户明确选择时使用。
|
||||
- 权限最小化:不新增超出现有扩展范围的权限。
|
||||
|
||||
## 9. 性能策略
|
||||
|
||||
- 编辑端:虚拟化/节流;避免频繁全图重绘;自动保存去抖(≥500ms)。
|
||||
- 执行端:节点超时/重试/退避;M2 后引入并发与限流(全局/域级)。
|
||||
|
||||
## 10. 迁移与兼容
|
||||
|
||||
- 打开旧 Flow(仅 steps[])→ 自动生成链式 DAG(nodes/edges)并允许切换“线性/画布”视图;
|
||||
- 只要 nodes/edges 存在,优先 DAG 执行;否则走 steps[];导出时兼容两者。
|
||||
|
||||
## 11. 开发分期(落地建议)
|
||||
|
||||
- M1(2~3 周):
|
||||
- 选型并接入 VueFlow;完成画布基础、节点库、连线、属性面板、撤销/重做、自动保存;
|
||||
- 支持基础节点与 DAG 串行执行;线性兼容;导入导出/发布;
|
||||
- 日志/失败截图在节点上联动高亮(可先在列表展示)。
|
||||
- M2:
|
||||
- If/Else/While/ForEach;OnError 分支;从节点开始调试;open/switch/close 完善;分组/折叠。
|
||||
- M3:
|
||||
- 并发/限流;表达式/JSONPath 映射器;数据集/凭据;高级调试。
|
||||
|
||||
## 12. 开放问题
|
||||
|
||||
- JSONPath 与表达式语言的边界与安全沙箱如何定义?
|
||||
- 节点产出统一结构与 assign/saveAs 的歧义如何避免?
|
||||
- 画布超大规模(500+ 节点)时的严重退化处理策略?
|
||||
- 团队协作与冲突解决是否需要引入(ID 锁/合并策略)?
|
||||
|
||||
---
|
||||
|
||||
说明:本设计围绕“高度复用现有 MCP 工具、最小化新增复杂度”的原则,画布仅作为编排与可视化层;执行统一收口到背景 Runner 与工具层,便于稳定落地与运维。
|
||||
+146
@@ -0,0 +1,146 @@
|
||||
# 模块 C — 编排画布(Builder)PRD v1.0
|
||||
|
||||
版本:v1.0(参考 Automa,兼容现有 Record & Replay)
|
||||
状态:Ready for Design
|
||||
负责人:产品/前端架构
|
||||
|
||||
## 0. 现状与定位(与录制回放的关系)
|
||||
|
||||
- 现状:已具备“录制 → 线性步骤(steps[])→ 回放/发布”的闭环;回放统一复用 MCP 工具(chrome\_\*),并支持变量、失败截图、网络片段等。
|
||||
- 定位:本编排画布是“录制回放模块的可视化编辑层”。
|
||||
- 录制完成的工作流可“导入画布”进行二次编排与参数化;
|
||||
- 也支持在画布里“从零拖拽节点”新建工作流;
|
||||
- 保存后仍与已有回放/发布/定时/导入导出机制完全打通(同一存储与执行通道)。
|
||||
- 目标用户路径(高频):录制草稿 → 打开画布调整/补空 → 保存 → 回放验证 → 发布为 MCP 动态工具/配置定时 → 运营。
|
||||
|
||||
## 1. 背景与目标
|
||||
|
||||
- 背景:现有线性 steps[] 能覆盖大多数串行场景,但难以表达条件、循环、并发、多标签切换等复杂流程;编辑体验也不利于可视化理解与协作。
|
||||
- 目标:提供“节点 + 连线”的可视化编排画布(DAG),在保持与线性模式完全兼容的前提下,一步到位地支撑复杂业务流程的创建、调试与运行。
|
||||
- 价值:
|
||||
- 降低复杂自动化的心智负担(所见即所得)。
|
||||
- 增强流程表达力(条件/循环/分支/子流程/并发)。
|
||||
- 与 MCP 工具层打通,形成可沉淀、可分享、可重放的企业级资产。
|
||||
|
||||
## 2. 范围与非范围
|
||||
|
||||
- 范围(MVP/M1):
|
||||
- 画布编辑:节点拖拽、连线、选择、移动、缩放、对齐、撤销/重做、自动保存。
|
||||
- 节点类型(基础动作):click/fill/key/wait/assert/navigate/script/openTab/switchTab/closeTab/delay/extract/http。
|
||||
- 属性面板:每个节点的配置编辑(选择器候选、变量/占位符、等待/断言、超时/重试、保存为变量等)。
|
||||
- 变量系统:全局 variables 与节点产出保存(saveAs/assign),字符串字段支持占位符 `{var}`。
|
||||
- 执行:无条件边时按拓扑串行执行;步骤失败截图与日志;OnError 可选(stop/continue/retry)。
|
||||
- 兼容:线性 steps[] 自动映射为链式 DAG;DAG 缺省时沿用线性模式。
|
||||
- 导入/导出/发布为 MCP 动态工具(沿用现有机制,Schema 由变量推导)。
|
||||
- 来源与入口:
|
||||
- 来源:① 录制得到的 steps 一键转化为 DAG(链式);② 画布新建(拖拽节点);③ JSON 导入;
|
||||
- 入口:Popup 的“录制与回放”列表进入“编辑”;录制完成弹出“前往画布编辑”。
|
||||
- 非范围(M1 之外):
|
||||
- 高级控制流:If/Else、While/Until、ForEach(并发度控制)、OnError 分支(M2)。
|
||||
- 数据集(Dataset)/可视化 JSON 映射器/凭据库/表达式引擎(M2/M3)。
|
||||
- 团队协作、多用户并发编辑与权限(后续版本)。
|
||||
|
||||
## 3. 用户与关键场景
|
||||
|
||||
- 用户:运营/测试/开发/数据标注人员。
|
||||
- 关键场景:
|
||||
1. 登录并进入后台 → 根据条件决定分支 → 下载报告(click/fill/wait/assert/navigate/http)。
|
||||
2. 批量表单填充 → 提交失败重试 → 记录成功项(foreach/delay/assert/saveAs)。
|
||||
3. 爬取分页数据 → 提取字段到变量/数据集 → 导出 JSON/CSV(extract/http/脚本处理)。
|
||||
|
||||
## 4. 端到端流程(闭环)
|
||||
|
||||
- 从录制到编排:
|
||||
|
||||
1. 开始录制(Popup)→ 页面内浮层反馈与事件捕获 → 停止录制;
|
||||
2. 生成 Flow 草稿(steps[])→ 提示“前往画布编辑”;
|
||||
3. 画布自动将 steps 链式映射为 nodes/edges → 在属性面板完善:选择器候选优先级、等待/断言、变量占位、saveAs/assign;
|
||||
4. 保存 Flow(同一存储键),可导出 JSON。
|
||||
|
||||
- 从零拖拽到回放:
|
||||
|
||||
1. 画布中新建 → 从节点库拖入基础节点,连线构建链路;
|
||||
2. 在属性面板配置选择器/变量/等待/断言/脚本等 → 保存;
|
||||
3. 回放验证(可选择 “当前/新标签”、“起始 URL”)→ 查看日志与失败截图 → 调整后再次保存。
|
||||
|
||||
- 发布与定时:
|
||||
1. Flow 发布为 MCP 动态工具(flow.<slug>),输入 Schema 由 variables + 运行选项生成;
|
||||
2. 可配置定时执行(interval/daily/once),执行结果写入运行记录;
|
||||
3. 运行失败时截图与错误节点高亮,支持导出/导入迁移。
|
||||
|
||||
## 5. 功能需求(FR)
|
||||
|
||||
- 画布交互(FR-BLD-001~010)
|
||||
|
||||
- FR-BLD-001 画布基础:缩放/平移、对齐网格、吸附对齐、迷你地图。
|
||||
- FR-BLD-002 节点库:从侧边栏拖入节点、复制/粘贴、批量选择、删除。
|
||||
- FR-BLD-003 连线:连接/断开、自动修复、避免自环;边标签(默认/true/false/onError 预留)。
|
||||
- FR-BLD-004 属性面板:点选节点后右侧编辑配置;校验并提示错误。
|
||||
- FR-BLD-005 撤销/重做/自动保存;版本与恢复点(简版)。
|
||||
- FR-BLD-006 搜索与定位:按节点名/类型/变量引用查找并高亮。
|
||||
- FR-BLD-007 从节点开始调试(M2);单步/断点(M3)。
|
||||
- FR-BLD-008 兼容线性:steps↔DAG 双向转换(线性自动生成链式图)。
|
||||
- FR-BLD-009 运行入口:整流回放/从选中开始回放;日志与失败截图联动高亮。
|
||||
- FR-BLD-010 导入导出:JSON(带 nodes/edges/variables/meta)。
|
||||
|
||||
- 节点与属性(FR-BLD-011~030)
|
||||
|
||||
- FR-BLD-011 click/fill/key/wait/assert/navigate/script/delay:与线性动作参数一致。
|
||||
- FR-BLD-012 openTab/switchTab/closeTab:多标签编排基础动作。
|
||||
- FR-BLD-013 http(GET/POST/...):可保存响应字段到变量(M1 可选若已有工具)。
|
||||
- FR-BLD-014 extract:按 selector/属性/文本/正则/JS 抽取,saveAs 到变量。
|
||||
- FR-BLD-015 重试策略:count/intervalMs/backoff。
|
||||
- FR-BLD-016 超时/失败策略:stop/continue/retry;失败截图开关。
|
||||
- FR-BLD-017 选择器候选与优先级:ref/css/attr/aria/text/xpath;回退记录与提示。
|
||||
- FR-BLD-018 变量系统:全局 variables;字符串字段支持 `{var}`。脚本/HTTP 支持 assign。
|
||||
- FR-BLD-019 运行选项:tabTarget/startUrl/refresh/captureNetwork/returnLogs/timeoutMs。
|
||||
- FR-BLD-020 节点命名/备注/标签;节点分组与折叠(M2)。
|
||||
|
||||
- 执行与日志(FR-BLD-031~040)
|
||||
- FR-BLD-031 DAG 执行:无条件边时顺序拓扑执行;失败按策略处理。
|
||||
- FR-BLD-032 日志:节点级耗时/状态/错误信息;失败截图;网络片段(可选)。
|
||||
- FR-BLD-033 上下文:vars/outputs;saveAs/assign 写入;回放后返回 summary/outputs/logs。
|
||||
- FR-BLD-034 绑定校验:不匹配时拒绝或需 startUrl;与当前绑定规则一致。
|
||||
|
||||
## 6. 数据模型(导出/存储)
|
||||
|
||||
- 兼容 Flow V1:`steps[]` 不变。
|
||||
- 新增 Flow V2:
|
||||
- `nodes: NodeBase[]`、`edges: Edge[]`、`subflows?: Record<string,{nodes;edges}>`。
|
||||
- NodeBase:`{ id,type,name?,disabled?,config?,ui? }`;Edge:`{ id,from,to,label? }`(label 预留 default/true/false/onError)。
|
||||
- 变量:与现有 `variables[]` 统一;字符串字段支持 `{var}`;脚本/HTTP 支持 assign 映射到 vars。
|
||||
|
||||
## 7. 验收标准
|
||||
|
||||
- 线性流程自动转 DAG,回放结果与线性一致(10 次成功率 ≥ 95%)。
|
||||
- 画布可稳定编辑 200+ 节点;撤销/重做可靠;保存/导出/导入无损。
|
||||
- 失败报告包含失败截图与错误节点高亮;用户 1 分钟内定位问题。
|
||||
- 节点回退/选择器提示清晰,编辑器可直接调整优先级并保存。
|
||||
|
||||
## 8. 性能与非功能需求(NFR)
|
||||
|
||||
- 画布交互:常见 200 节点保持流畅(60fps 优先级次之,确保不卡顿)。
|
||||
- 执行性能:单节点默认等待 ≤ 10s;并发/循环在 M2 引入;全局并发上限与限流可配置。
|
||||
- 稳定性:10 步基准用例回放成功率 ≥ 95%。
|
||||
- 安全与隐私:敏感变量不落盘;导出不含敏感值;CSP 兼容;权限最小化。
|
||||
|
||||
## 9. 里程碑
|
||||
|
||||
- M1(画布基础 + 串行 DAG)
|
||||
- 画布/节点库/连线/属性面板/撤销重做/自动保存;基础节点(click/fill/key/wait/assert/navigate/script/delay/extract/http\*);
|
||||
- DAG 执行(无条件边,串行拓扑);线性兼容;日志/截图/变量;导入导出/发布。
|
||||
- M2(控制流与多标签)
|
||||
- If/Else/While/ForEach;OnError 分支;openTab/switchTab/closeTab 完善;从节点开始调试;节点分组/折叠;数据集/凭据(可选)。
|
||||
- M3(并发与限流)
|
||||
- foreach 并发、全局并发上限/域级限流;表达式/JSONPath 映射器;高级调试与监控。
|
||||
|
||||
---
|
||||
|
||||
注:节点与工具的映射严格复用现有 MCP 工具(chrome\_\*),减少新增复杂度并保证稳定性。
|
||||
|
||||
## 10. 成功指标(KPI)
|
||||
|
||||
- 20 分钟内:从录制到画布编排再到首次成功回放(≥ 80% 用户)。
|
||||
- 稳定性:基准 10 步流程回放成功率 ≥ 95%,并在页面小改动(文案/结构微调)下仍 ≥ 90%。
|
||||
- 工具化:发布为 MCP 工具后,十次调用成功率 ≥ 95%,参数缺失可被 Schema 友好提示。
|
||||
- 编辑效率:200+ 节点画布交互不卡顿(≥ 30 fps);撤销/重做成功率 100%。
|
||||
Generated
+186
@@ -59,6 +59,18 @@ importers:
|
||||
'@modelcontextprotocol/sdk':
|
||||
specifier: ^1.11.0
|
||||
version: 1.12.1
|
||||
'@vue-flow/background':
|
||||
specifier: ^1.3.2
|
||||
version: 1.3.2(@vue-flow/core@1.47.0(vue@3.5.16(typescript@5.8.3)))(vue@3.5.16(typescript@5.8.3))
|
||||
'@vue-flow/controls':
|
||||
specifier: ^1.1.3
|
||||
version: 1.1.3(@vue-flow/core@1.47.0(vue@3.5.16(typescript@5.8.3)))(vue@3.5.16(typescript@5.8.3))
|
||||
'@vue-flow/core':
|
||||
specifier: ^1.47.0
|
||||
version: 1.47.0(vue@3.5.16(typescript@5.8.3))
|
||||
'@vue-flow/minimap':
|
||||
specifier: ^1.5.4
|
||||
version: 1.5.4(@vue-flow/core@1.47.0(vue@3.5.16(typescript@5.8.3)))(vue@3.5.16(typescript@5.8.3))
|
||||
'@xenova/transformers':
|
||||
specifier: ^2.17.2
|
||||
version: 2.17.2
|
||||
@@ -1083,6 +1095,9 @@ packages:
|
||||
'@types/supertest@6.0.3':
|
||||
resolution: {integrity: sha512-8WzXq62EXFhJ7QsH3Ocb/iKQ/Ty9ZVWnVzoTKc9tyyFRRF3a74Tk2+TLFgaFFw364Ere+npzHKEJ6ga2LzIL7w==}
|
||||
|
||||
'@types/web-bluetooth@0.0.20':
|
||||
resolution: {integrity: sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==}
|
||||
|
||||
'@types/yargs-parser@21.0.3':
|
||||
resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==}
|
||||
|
||||
@@ -1167,6 +1182,29 @@ packages:
|
||||
'@volar/typescript@2.4.14':
|
||||
resolution: {integrity: sha512-p8Z6f/bZM3/HyCdRNFZOEEzts51uV8WHeN8Tnfnm2EBv6FDB2TQLzfVx7aJvnl8ofKAOnS64B2O8bImBFaauRw==}
|
||||
|
||||
'@vue-flow/background@1.3.2':
|
||||
resolution: {integrity: sha512-eJPhDcLj1wEo45bBoqTXw1uhl0yK2RaQGnEINqvvBsAFKh/camHJd5NPmOdS1w+M9lggc9igUewxaEd3iCQX2w==}
|
||||
peerDependencies:
|
||||
'@vue-flow/core': ^1.23.0
|
||||
vue: ^3.3.0
|
||||
|
||||
'@vue-flow/controls@1.1.3':
|
||||
resolution: {integrity: sha512-XCf+G+jCvaWURdFlZmOjifZGw3XMhN5hHlfMGkWh9xot+9nH9gdTZtn+ldIJKtarg3B21iyHU8JjKDhYcB6JMw==}
|
||||
peerDependencies:
|
||||
'@vue-flow/core': ^1.23.0
|
||||
vue: ^3.3.0
|
||||
|
||||
'@vue-flow/core@1.47.0':
|
||||
resolution: {integrity: sha512-w+qrm/xjQP5NUeKUOMIbQvpOeivTbGZtY2lGffK5kHiN3ZLyEazhESc8OeIV9NZkK2T5DIeyX/nhHxCC45HLiw==}
|
||||
peerDependencies:
|
||||
vue: ^3.3.0
|
||||
|
||||
'@vue-flow/minimap@1.5.4':
|
||||
resolution: {integrity: sha512-l4C+XTAXnRxsRpUdN7cAVFBennC1sVRzq4bDSpVK+ag7tdMczAnhFYGgbLkUw3v3sY6gokyWwMl8CDonp8eB2g==}
|
||||
peerDependencies:
|
||||
'@vue-flow/core': ^1.23.0
|
||||
vue: ^3.3.0
|
||||
|
||||
'@vue/compiler-core@3.5.16':
|
||||
resolution: {integrity: sha512-AOQS2eaQOaaZQoL1u+2rCJIKDruNXVBZSiUD3chnUrsoX5ZTQMaCvXlWNIfxBJuU15r1o7+mpo5223KVtIhAgQ==}
|
||||
|
||||
@@ -1207,6 +1245,15 @@ packages:
|
||||
'@vue/shared@3.5.16':
|
||||
resolution: {integrity: sha512-c/0fWy3Jw6Z8L9FmTyYfkpM5zklnqqa9+a6dz3DvONRKW2NEbh46BP0FHuLFSWi2TnQEtp91Z6zOWNrU6QiyPg==}
|
||||
|
||||
'@vueuse/core@10.11.1':
|
||||
resolution: {integrity: sha512-guoy26JQktXPcz+0n3GukWIy/JDNKti9v6VEMu6kV2sYBsWuGiTU8OWdg+ADfUbHg3/3DlqySDe7JmdHrktiww==}
|
||||
|
||||
'@vueuse/metadata@10.11.1':
|
||||
resolution: {integrity: sha512-IGa5FXd003Ug1qAZmyE8wF3sJ81xGLSqTqtQ6jaVfkeZ4i5kS2mwQF61yhVqojRnenVew5PldLyRgvdl4YYuSw==}
|
||||
|
||||
'@vueuse/shared@10.11.1':
|
||||
resolution: {integrity: sha512-LHpC8711VFZlDaYUXEBbFBCQ7GS3dVU9mjOhhMhXP6txTV4EhYQg/KGnQuvt/sPAtoUKq7VVUnL6mVtFoL42sA==}
|
||||
|
||||
'@webext-core/fake-browser@1.3.2':
|
||||
resolution: {integrity: sha512-jFyPWWz+VkHAC9DRIiIPOyu6X/KlC8dYqSKweHz6tsDb86QawtVgZSpYcM+GOQBlZc5DHFo92jJ7cIq4uBnU0A==}
|
||||
|
||||
@@ -1796,6 +1843,44 @@ packages:
|
||||
csstype@3.1.3:
|
||||
resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==}
|
||||
|
||||
d3-color@3.1.0:
|
||||
resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-dispatch@3.0.1:
|
||||
resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-drag@3.0.0:
|
||||
resolution: {integrity: sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-ease@3.0.1:
|
||||
resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-interpolate@3.0.1:
|
||||
resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-selection@3.0.0:
|
||||
resolution: {integrity: sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-timer@3.0.1:
|
||||
resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-transition@3.0.1:
|
||||
resolution: {integrity: sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==}
|
||||
engines: {node: '>=12'}
|
||||
peerDependencies:
|
||||
d3-selection: 2 - 3
|
||||
|
||||
d3-zoom@3.0.0:
|
||||
resolution: {integrity: sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
dargs@8.1.0:
|
||||
resolution: {integrity: sha512-wAV9QHOsNbwnWdNW2FYvE1P56wtgSbM+3SZcdGiWQILwVjACCXDCI3Ai8QlCjMDB8YK5zySiXZYBiwGmNY3lnw==}
|
||||
engines: {node: '>=12'}
|
||||
@@ -4062,6 +4147,7 @@ packages:
|
||||
supertest@7.1.1:
|
||||
resolution: {integrity: sha512-aI59HBTlG9e2wTjxGJV+DygfNLgnWbGdZxiA/sgrnNNikIW8lbDvCtF6RnhZoJ82nU7qv7ZLjrvWqCEm52fAmw==}
|
||||
engines: {node: '>=14.18.0'}
|
||||
deprecated: Please upgrade to supertest v7.1.3+, see release notes at https://github.com/forwardemail/supertest/releases/tag/v7.1.3 - maintenance is supported by Forward Email @ https://forwardemail.net
|
||||
|
||||
supports-color@5.5.0:
|
||||
resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==}
|
||||
@@ -4406,6 +4492,17 @@ packages:
|
||||
vscode-uri@3.1.0:
|
||||
resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==}
|
||||
|
||||
vue-demi@0.14.10:
|
||||
resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==}
|
||||
engines: {node: '>=12'}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
'@vue/composition-api': ^1.0.0-rc.1
|
||||
vue: ^3.0.0-0 || ^2.6.0
|
||||
peerDependenciesMeta:
|
||||
'@vue/composition-api':
|
||||
optional: true
|
||||
|
||||
vue-eslint-parser@10.1.3:
|
||||
resolution: {integrity: sha512-dbCBnd2e02dYWsXoqX5yKUZlOt+ExIpq7hmHKPb5ZqKcjf++Eo0hMseFTZMLKThrUk61m+Uv6A2YSBve6ZvuDQ==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
@@ -5545,6 +5642,8 @@ snapshots:
|
||||
'@types/methods': 1.1.4
|
||||
'@types/superagent': 8.1.9
|
||||
|
||||
'@types/web-bluetooth@0.0.20': {}
|
||||
|
||||
'@types/yargs-parser@21.0.3': {}
|
||||
|
||||
'@types/yargs@17.0.33':
|
||||
@@ -5665,6 +5764,34 @@ snapshots:
|
||||
path-browserify: 1.0.1
|
||||
vscode-uri: 3.1.0
|
||||
|
||||
'@vue-flow/background@1.3.2(@vue-flow/core@1.47.0(vue@3.5.16(typescript@5.8.3)))(vue@3.5.16(typescript@5.8.3))':
|
||||
dependencies:
|
||||
'@vue-flow/core': 1.47.0(vue@3.5.16(typescript@5.8.3))
|
||||
vue: 3.5.16(typescript@5.8.3)
|
||||
|
||||
'@vue-flow/controls@1.1.3(@vue-flow/core@1.47.0(vue@3.5.16(typescript@5.8.3)))(vue@3.5.16(typescript@5.8.3))':
|
||||
dependencies:
|
||||
'@vue-flow/core': 1.47.0(vue@3.5.16(typescript@5.8.3))
|
||||
vue: 3.5.16(typescript@5.8.3)
|
||||
|
||||
'@vue-flow/core@1.47.0(vue@3.5.16(typescript@5.8.3))':
|
||||
dependencies:
|
||||
'@vueuse/core': 10.11.1(vue@3.5.16(typescript@5.8.3))
|
||||
d3-drag: 3.0.0
|
||||
d3-interpolate: 3.0.1
|
||||
d3-selection: 3.0.0
|
||||
d3-zoom: 3.0.0
|
||||
vue: 3.5.16(typescript@5.8.3)
|
||||
transitivePeerDependencies:
|
||||
- '@vue/composition-api'
|
||||
|
||||
'@vue-flow/minimap@1.5.4(@vue-flow/core@1.47.0(vue@3.5.16(typescript@5.8.3)))(vue@3.5.16(typescript@5.8.3))':
|
||||
dependencies:
|
||||
'@vue-flow/core': 1.47.0(vue@3.5.16(typescript@5.8.3))
|
||||
d3-selection: 3.0.0
|
||||
d3-zoom: 3.0.0
|
||||
vue: 3.5.16(typescript@5.8.3)
|
||||
|
||||
'@vue/compiler-core@3.5.16':
|
||||
dependencies:
|
||||
'@babel/parser': 7.27.5
|
||||
@@ -5737,6 +5864,25 @@ snapshots:
|
||||
|
||||
'@vue/shared@3.5.16': {}
|
||||
|
||||
'@vueuse/core@10.11.1(vue@3.5.16(typescript@5.8.3))':
|
||||
dependencies:
|
||||
'@types/web-bluetooth': 0.0.20
|
||||
'@vueuse/metadata': 10.11.1
|
||||
'@vueuse/shared': 10.11.1(vue@3.5.16(typescript@5.8.3))
|
||||
vue-demi: 0.14.10(vue@3.5.16(typescript@5.8.3))
|
||||
transitivePeerDependencies:
|
||||
- '@vue/composition-api'
|
||||
- vue
|
||||
|
||||
'@vueuse/metadata@10.11.1': {}
|
||||
|
||||
'@vueuse/shared@10.11.1(vue@3.5.16(typescript@5.8.3))':
|
||||
dependencies:
|
||||
vue-demi: 0.14.10(vue@3.5.16(typescript@5.8.3))
|
||||
transitivePeerDependencies:
|
||||
- '@vue/composition-api'
|
||||
- vue
|
||||
|
||||
'@webext-core/fake-browser@1.3.2':
|
||||
dependencies:
|
||||
lodash.merge: 4.6.2
|
||||
@@ -6372,6 +6518,42 @@ snapshots:
|
||||
|
||||
csstype@3.1.3: {}
|
||||
|
||||
d3-color@3.1.0: {}
|
||||
|
||||
d3-dispatch@3.0.1: {}
|
||||
|
||||
d3-drag@3.0.0:
|
||||
dependencies:
|
||||
d3-dispatch: 3.0.1
|
||||
d3-selection: 3.0.0
|
||||
|
||||
d3-ease@3.0.1: {}
|
||||
|
||||
d3-interpolate@3.0.1:
|
||||
dependencies:
|
||||
d3-color: 3.1.0
|
||||
|
||||
d3-selection@3.0.0: {}
|
||||
|
||||
d3-timer@3.0.1: {}
|
||||
|
||||
d3-transition@3.0.1(d3-selection@3.0.0):
|
||||
dependencies:
|
||||
d3-color: 3.1.0
|
||||
d3-dispatch: 3.0.1
|
||||
d3-ease: 3.0.1
|
||||
d3-interpolate: 3.0.1
|
||||
d3-selection: 3.0.0
|
||||
d3-timer: 3.0.1
|
||||
|
||||
d3-zoom@3.0.0:
|
||||
dependencies:
|
||||
d3-dispatch: 3.0.1
|
||||
d3-drag: 3.0.0
|
||||
d3-interpolate: 3.0.1
|
||||
d3-selection: 3.0.0
|
||||
d3-transition: 3.0.1(d3-selection@3.0.0)
|
||||
|
||||
dargs@8.1.0: {}
|
||||
|
||||
date-fns@4.1.0: {}
|
||||
@@ -9260,6 +9442,10 @@ snapshots:
|
||||
|
||||
vscode-uri@3.1.0: {}
|
||||
|
||||
vue-demi@0.14.10(vue@3.5.16(typescript@5.8.3)):
|
||||
dependencies:
|
||||
vue: 3.5.16(typescript@5.8.3)
|
||||
|
||||
vue-eslint-parser@10.1.3(eslint@9.28.0(jiti@2.4.2)):
|
||||
dependencies:
|
||||
debug: 4.4.1(supports-color@5.5.0)
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
## 录制回放 · 编排画布(Builder)落地进度
|
||||
|
||||
更新时间:2025-10-10
|
||||
|
||||
### 已完成(本次)
|
||||
|
||||
- 数据模型:在 `record-replay/types.ts` 扩展 `Flow`,新增可选 `nodes`/`edges`(Flow V2 结构),兼容线性 `steps[]`。
|
||||
- 画布编辑器(M1 骨架):新增 `popup/components/BuilderEditor.vue`,使用 VueFlow 渲染 DAG。
|
||||
- 节点库:click/fill/key/wait/assert/navigate/script/delay。
|
||||
- 画布:缩放/平移(VueFlow 内置)、网格吸附、拖拽、连线(默认单一出边)。
|
||||
- 属性面板:按节点类型编辑(选择器候选、fill 值、wait/assert/navigate/script)。
|
||||
- 互转:支持 steps→nodes(链式)与 nodes→steps(按 default 边拓扑),保存时同步覆盖 `steps[]` 以保证可立即回放。
|
||||
- 集成入口:在 Popup 的“录制与回放”列表加入“画布编辑”按钮;保存沿用 `RR_SAVE_FLOW`。
|
||||
- Runner 增强:`flow-runner.ts` 在检测到 `nodes/edges` 时,运行期进行 DAG→steps 线性化(按 default 边拓扑),复用现有线性执行与日志/截图机制。
|
||||
- Builder 小增强:
|
||||
- 节点类型补充 key/delay 的默认配置、属性面板与摘要展示。
|
||||
- 快捷键:Delete/Backspace 删除选中;Cmd/Ctrl+D 复制;Cmd/Ctrl+S 保存。
|
||||
- 自动保存:节点/连线/名称变化 800ms 去抖自动保存,状态提示(保存中/已保存)。
|
||||
- 搜索定位:顶栏输入命中节点名或首个选择器,回车自动聚焦到节点并选中。
|
||||
- 新增节点类型与执行:
|
||||
- http(method/url/headers/body/saveAs),Runner 调用 NETWORK_REQUEST 并可保存 JSON 响应到变量。
|
||||
- extract(selector/attr/js/saveAs),Runner 在页面执行提取并保存变量。
|
||||
- openTab/switchTab/closeTab,Runner 分别创建新标签/切换标签/关闭标签(支持按 url/title 匹配)。
|
||||
- script 支持 saveAs/assign:执行返回值支持保存到变量;assign 支持点路径(a.b[0].c)映射多个变量。
|
||||
- 校验与提示:
|
||||
- 节点级校验(http/extract/switchTab/script 等必填/组合约束)与 UI 提示(字段红框+错误列表)。
|
||||
- 顶栏显示错误计数,便于定位问题。
|
||||
- 映射编辑器:KeyValueEditor 组件,用于 script/http 的 assign 键值映射编辑。
|
||||
- 从选中节点回放:Builder 顶栏支持从当前选中节点启动回放(传入 startNodeId)。
|
||||
- 错误列表面板:可展开全局错误列表,点击条目定位并聚焦到对应节点。
|
||||
- 快捷键补充:⌘/Ctrl+Z 撤销;⌘/Ctrl+Shift+Z 重做。
|
||||
- 自动排版与视图:一键自动排版(简单拓扑布局),自适应视图(fit view)。
|
||||
- 导出:Builder 顶栏直接导出 Flow JSON(保持与后台导出一致)。
|
||||
- 历史栈优化:限制最多 50 个快照,避免内存无限增长。
|
||||
- 字段级高亮:点击错误项可高亮并滚动到对应属性字段(PropertyPanel)。
|
||||
|
||||
### 目录结构与模块拆分
|
||||
|
||||
- 遵循组件聚合原则,将编辑器完整聚合在一个目录下:`popup/components/builder/`
|
||||
- `model/transforms.ts`:DAG/steps 互转、ID 生成、默认配置、拓扑排序、摘要。
|
||||
- `store/useBuilderStore.ts`:编辑器状态与操作(选择/新增/删除/连线/布局/导入导出)。
|
||||
- `components/{Canvas,Sidebar,PropertyPanel}.vue`:画布/节点库/属性面板子组件。
|
||||
|
||||
### 待安装依赖
|
||||
|
||||
- 画布基于 VueFlow:需要安装
|
||||
- `@vue-flow/core`
|
||||
- `@vue-flow/controls`
|
||||
- `@vue-flow/minimap`
|
||||
|
||||
### 下一步规划(短期)
|
||||
|
||||
1. DAG 执行路径进行更细粒度控制(后续支持 true/false/onError 边),并补齐 http/extract/openTab/switchTab/closeTab 映射。
|
||||
2. 画布体验升级:撤销/重做历史上限与压缩策略、MiniMap/Controls 配置、节点模板与样式。
|
||||
3. 属性面板补齐更多节点(http/extract/openTab/switchTab/closeTab),并完善字段校验/提示。
|
||||
4. 自动保存(去抖 ≥500ms)与版本快照;画布搜索与定位。
|
||||
|
||||
### 里程碑对齐(builder.prd.md)
|
||||
|
||||
- M1:画布基础 + 串行 DAG(当前已具备可用骨架,待 Runner DAG)
|
||||
- M2:控制流(If/Else/While/ForEach)、OnError 分支、多标签完善(后续迭代)
|
||||
- M3:并发与限流(后续迭代)
|
||||
|
||||
### 影响范围与兼容性
|
||||
|
||||
- 不破坏原有线性流程与回放;保存时同步 `steps[]`,可立即回放。
|
||||
- 类型扩展仅新增可选字段,旧数据不受影响;导入导出将兼容两种结构。
|
||||
Reference in New Issue
Block a user