import { Alert, Button, Checkbox, Form, Input, InputNumber, Select, Typography } from 'antd'; import { useEffect } from 'react'; import { operationName } from '../learning-view-model'; import type { BrowserElement, DynamicTarget, ExtractionField, RecordedStep } from '../types'; const kinds: RecordedStep['kind'][] = ['fill', 'press', 'click', 'read', 'scroll', 'wait', 'extract']; const identifier = /^[A-Za-z][A-Za-z0-9_]{0,63}$/; interface ActionValues extends RecordedStep { use_target?: boolean; target_field?: DynamicTarget['field']; target_attribute?: string; target_value?: string; target_parameter?: string; use_batch?: boolean; batch_scroll_selector?: string; batch_pixels?: number; batch_max_batches?: number; batch_dedupe_field?: string; empty_state_selector?: string; empty_state_text?: string; use_until_target?: boolean; use_viewport_scroll?: boolean; until_target_selector?: string; until_target_field?: DynamicTarget['field']; until_target_attribute?: string; until_target_value?: string; until_target_parameter?: string; seek_timeout_ms?: number; } function guessKind(element: BrowserElement): RecordedStep['kind'] { const type = `${element.kind || ''} ${element.tag || ''}`.toLowerCase(); if (/input|textarea|fill/.test(type)) return 'fill'; if (/button|link|click|\ba\b/.test(type)) return 'click'; return 'read'; } function ExtractionFields() { const form = Form.useFormInstance(); const values = Form.useWatch('fields', form) as ExtractionField[] | undefined; return { if (!fields?.length || fields.length > 12) throw new Error('请配置 1–12 个字段'); if (new Set(fields.map(field => field.name)).size !== fields.length) throw new Error('字段名称不能重复'); } }]}>{(fields, { add, remove }, { errors }) => <> {fields.map(({ key, name, ...rest }) =>
字段 {name + 1}
{ if (['constructor', 'prototype'].includes(value)) throw new Error('请使用普通业务字段名称'); } }]}> } {values?.[name]?.source === 'content' &&
} 每项必须包含该字段
)} }
; } export function LearningActionForm({ selected, latestElements, parameters, busy, ready, onExecute }: { selected?: BrowserElement; latestElements: BrowserElement[]; parameters: Record; busy: boolean; ready: boolean; onExecute: (action: RecordedStep) => Promise; }) { const [form] = Form.useForm(); const kind = Form.useWatch('kind', form) || 'read'; const parameter = Form.useWatch('parameter', form); const waitState = Form.useWatch('state', form); const minCount = Form.useWatch('min_count', form); const waitScope = Form.useWatch('wait_scope', form); const useTarget = Form.useWatch('use_target', form); const targetField = Form.useWatch('target_field', form); const targetParameter = Form.useWatch('target_parameter', form); const rowSelector = Form.useWatch('row_selector', form); const useBatch = Form.useWatch('use_batch', form); const allowEmpty = Form.useWatch('allow_empty', form); const useUntilTarget = Form.useWatch('use_until_target', form); const untilTargetField = Form.useWatch('until_target_field', form); const untilTargetParameter = Form.useWatch('until_target_parameter', form); const fields = Form.useWatch('fields', form) as ExtractionField[] | undefined; const containerRows = Boolean(rowSelector?.trim()); const dynamic = ['click', 'read', 'extract'].includes(kind) && !(kind === 'extract' && (containerRows || useBatch || allowEmpty)); const dedupeOptions = (fields || []).filter(field => field.required && field.source === 'attribute' && /^(?:id|data-[A-Za-z0-9_.:-]+)$/.test(field.attribute?.trim() || '') && identifier.test(field.name?.trim() || '')).map(field => ({ value: field.name.trim(), label: `${field.name.trim()} (${field.attribute?.trim()})` })); const parameterOptions = Object.entries(parameters).map(([value, sample]) => ({ value, label: `${value} = ${String(sample)}` })); const scopedWait = kind === 'wait' && waitState === 'visible' && typeof minCount === 'number'; const waitScopeOptions = latestElements.filter(element => element.kind === 'read' && element.unique === true && !element.sensitive && element.tag && !['body', 'html', 'form'].includes(element.tag.toLowerCase())).map(element => ({ value: element.selector, label: `${element.selector}${element.label ? ` · ${element.label}` : ''}` })); useEffect(() => { if (selected) form.setFieldsValue({ selector: selected.selector, kind: guessKind(selected), parameter: undefined, value: '', use_target: false, use_viewport_scroll: false, wait_scope: undefined }); }, [selected?.selector, selected?.kind, selected?.tag, form]); useEffect(() => { if (waitScope && (!scopedWait || !waitScopeOptions.some(option => option.value === waitScope))) form.setFieldValue('wait_scope', undefined); }, [scopedWait, waitScope, latestElements, form]); async function submit(values: ActionValues) { const action: RecordedStep = { kind: values.kind, selector: values.selector.trim() }; if (values.kind === 'press') action.key = 'Enter'; if (values.kind === 'fill') { if (values.parameter) action.parameter = values.parameter; else action.value = values.value || ''; } if (values.kind === 'read' || values.kind === 'extract') action.output_name = values.output_name?.trim(); if (values.kind === 'scroll') Object.assign(action, { direction: values.direction, pixels: values.pixels, max_scrolls: values.max_scrolls, ...(values.use_until_target ? { until_target: { selector: values.until_target_selector?.trim(), field: values.until_target_field || 'text', ...(values.until_target_parameter ? { parameter: values.until_target_parameter } : { value: values.until_target_value?.trim() }), ...(values.until_target_field === 'attribute' ? { attribute: values.until_target_attribute?.trim() } : {}), }, timeout_ms: values.seek_timeout_ms, ...(values.use_viewport_scroll ? { scroll_scope: 'viewport' } : {}) } : values.until_selector?.trim() ? { until_selector: values.until_selector.trim() } : {}), }); if (values.kind === 'wait') Object.assign(action, { state: values.state, timeout_ms: values.timeout_ms, ...(values.state === 'text' ? { text: values.text } : {}), ...(values.state === 'visible' && typeof values.min_count === 'number' ? { min_count: values.min_count, ...(values.wait_scope?.trim() ? { wait_scope: values.wait_scope.trim() } : {}) } : {}) }); if (values.kind === 'extract') Object.assign(action, { limit: values.limit, ...(values.complete_only || values.use_batch ? { complete_only: true } : {}), ...(values.row_selector?.trim() ? { row_selector: values.row_selector.trim() } : {}), ...(values.use_batch ? { batch: { scroll_selector: values.batch_scroll_selector?.trim(), pixels: values.batch_pixels, max_batches: values.batch_max_batches, dedupe_field: values.batch_dedupe_field } } : {}), ...(values.allow_empty && !values.use_batch ? { allow_empty: true, empty_state: { selector: values.empty_state_selector?.trim(), text: values.empty_state_text?.trim() } } : {}), fields: (values.fields || []).map(field => ({ name: field.name.trim(), source: field.source, required: field.source === 'content' || Boolean(field.required), ...(field.selector?.trim() ? { selector: field.selector.trim() } : {}), ...(field.source === 'attribute' ? { attribute: field.attribute?.trim() } : {}), ...(field.source === 'content' ? { text_selector: field.text_selector?.trim(), media_selector: field.media_selector?.trim(), max_media: field.max_media ?? 4 } : {}) })), }); if (dynamic && values.use_target) action.target = { field: values.target_field || 'text', ...(values.target_parameter ? { parameter: values.target_parameter } : { value: values.target_value?.trim() }), ...(values.target_field === 'attribute' ? { attribute: values.target_attribute?.trim() } : {}), }; if (await onExecute(action)) form.setFieldValue('selector', ''); } return form={form} layout="vertical" disabled={busy} onFinish={values => void submit(values)} data-testid="learning-action-form" initialValues={{ kind: 'read', selector: '', value: '', output_name: 'results', direction: 'down', pixels: 600, max_scrolls: 1, state: 'visible', timeout_ms: 5000, limit: 20, complete_only: false, until_selector: '', fields: [{ name: 'text', source: 'text', required: true }], use_target: false, target_field: 'text', row_selector: '', use_batch: false, batch_scroll_selector: '', batch_pixels: 400, batch_max_batches: 3, allow_empty: false, empty_state_selector: '', empty_state_text: '', use_until_target: false, use_viewport_scroll: false, until_target_field: 'text', until_target_selector: '', seek_timeout_ms: 5000, }}> {!parameter && } } {kind === 'press' && } {(kind === 'read' || kind === 'extract') && } {kind === 'scroll' && <> } {useUntilTarget &&
滚动整页寻找容器内目标 } }
} 限定范围内滚动;到达边界或无法继续移动即停止。 } {kind === 'wait' && <> } {waitState === 'text' && } } {kind === 'extract' && <> 只读完整可见行 { if (event.target.checked) form.setFieldsValue({ complete_only: true, allow_empty: false, use_target: false }); }}>有限多批读取并去重 {useBatch &&
{ if (value && !dedupeOptions.some(option => option.value === value)) throw new Error('去重字段须为必填的 id 或 data-* 属性字段'); } }]}>
} 只提取当前视口内可见的行;输出包含 items、数量、来源和覆盖范围,超过上限会标注截断。 } {dynamic && <> 按当前对象精确匹配 {useTarget &&
} }
} } 首条只读链路。登录资料和发送、关注等动作不进入此流程;条件等待依据页面状态,未承诺平台账号效果。 ; }