assistant/apps/admin/src/components/LearningActionForm.tsx

205 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<ActionValues>();
const values = Form.useWatch('fields', form) as ExtractionField[] | undefined;
return <Form.List name="fields" rules={[{ validator: async (_, fields: ExtractionField[] | undefined) => {
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 }) => <div className="extraction-field" key={key}>
<div className="extraction-field-heading"><Typography.Text strong>字段 {name + 1}</Typography.Text><Button size="small" type="text" danger disabled={fields.length <= 1} onClick={() => remove(name)}>移除</Button></div>
<Form.Item {...rest} name={[name, 'name']} label="字段名称" rules={[{ required: true, message: '填写输出字段名' }, { pattern: identifier, message: '使用字母、数字或下划线,字母开头,最多 64 字符' }, { validator: async (_, value) => { if (['constructor', 'prototype'].includes(value)) throw new Error('请使用普通业务字段名称'); } }]}><Input placeholder="例如 text 或 author_url" /></Form.Item>
<Form.Item {...rest} name={[name, 'selector']} label="行内定位" extra="留空表示当前行;填写时只在该行内部查找。"><Input placeholder="例如 .comment-content" maxLength={500} /></Form.Item>
<Form.Item {...rest} name={[name, 'source']} label="取值来源" rules={[{ required: true }]}><Select onChange={source => { if (source === 'content') form.setFieldsValue({ fields: (form.getFieldValue('fields') || []).map((field: ExtractionField, index: number) => index === name ? { ...field, required: true, max_media: field.max_media ?? 4 } : field) }); }} options={[{ value: 'text', label: '页面文字' }, { value: 'href', label: '链接地址' }, { value: 'attribute', label: '只读属性' }, { value: 'content', label: '文字与媒体内容' }]} /></Form.Item>
{values?.[name]?.source === 'attribute' && <Form.Item {...rest} name={[name, 'attribute']} label="属性名称" rules={[{ required: true, message: '填写属性名称' }]}><Input placeholder="例如 data-id" maxLength={64} /></Form.Item>}
{values?.[name]?.source === 'content' && <div data-testid="content-field-options">
<Form.Item {...rest} name={[name, 'text_selector']} label="内容根内文字定位" extra="在行内定位所选内容根中读取文字;没有可读文字时仍须有符合范围的媒体。" rules={[{ required: true, whitespace: true, message: '填写文字相对定位' }]}><Input maxLength={1000} placeholder="例如 .comment-text" /></Form.Item>
<Form.Item {...rest} name={[name, 'media_selector']} label="内容根内媒体定位" rules={[{ required: true, whitespace: true, message: '填写图片或视频相对定位' }]}><Input maxLength={1000} placeholder="例如 img.comment-image, video.comment-video" /></Form.Item>
<Form.Item {...rest} name={[name, 'max_media']} label="每项最多记录媒体数" rules={[{ required: true }]}><InputNumber min={1} max={4} precision={0} /></Form.Item>
<Alert type="info" showIcon title="显式区分文字、媒体与混合内容" description="媒体只记录实际可见类型和定位,内容尚未解释,不执行 OCR。此字段必须有内容;旧文字必填候选保持原规则,需另存修订候选。" />
</div>}
<Form.Item {...rest} name={[name, 'required']} valuePropName="checked"><Checkbox disabled={values?.[name]?.source === 'content'}>每项必须包含该字段</Checkbox></Form.Item>
</div>)}
<Form.ErrorList errors={errors} />
<Button block disabled={fields.length >= 12} onClick={() => add({ name: '', source: 'text', required: true })}>增加提取字段</Button>
</>}</Form.List>;
}
export function LearningActionForm({ selected, latestElements, parameters, busy, ready, onExecute }: {
selected?: BrowserElement;
latestElements: BrowserElement[];
parameters: Record<string, unknown>;
busy: boolean;
ready: boolean;
onExecute: (action: RecordedStep) => Promise<boolean>;
}) {
const [form] = Form.useForm<ActionValues>();
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<ActionValues> 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,
}}>
<Form.Item name="kind" label="操作类型" rules={[{ required: true }]}><Select aria-label="操作类型" options={kinds.map(value => ({ value, label: operationName(value) }))} /></Form.Item>
<Form.Item name="selector" label={kind === 'extract' ? containerRows || useBatch || allowEmpty ? '读取容器定位' : '结果行定位' : kind === 'scroll' ? '滚动容器定位' : '页面定位'} extra={kind === 'scroll' ? useUntilTarget ? '使用最新观察中的唯一普通可滚动容器;动态目标查找不能使用 body。' : '填写 body 滚动页面,或使用观察到的滚动容器。' : kind === 'wait' && waitState === 'hidden' ? '可以等待本次观察中已消失的元素。' : '使用最新观察中的根元素定位,执行前会重新核对;字段内部可配置相对定位。'} rules={[{ required: true, whitespace: true, message: '填写页面定位' }]}><Input.TextArea aria-label="选中的页面定位" rows={2} maxLength={1000} placeholder={kind === 'scroll' && !useUntilTarget ? 'body' : '选择或填写本次操作的定位'} /></Form.Item>
{kind === 'fill' && <>
<Form.Item name="parameter" label="绑定任务参数"><Select allowClear placeholder="选择参数,或填写固定值" options={parameterOptions} /></Form.Item>
{!parameter && <Form.Item name="value" label="本次输入内容" rules={[{ required: true, whitespace: true, message: '填写普通搜索或查询内容' }]}><Input autoComplete="off" maxLength={2000} placeholder="普通搜索或查询内容" /></Form.Item>}
</>}
{kind === 'press' && <Alert type="info" showIcon message="在搜索框提交已有内容" description="固定按一次 Enter,仅支持已观察到的普通搜索输入框或文本域。请先填写搜索内容并核对页面;失败后先查看新证据。" />}
{(kind === 'read' || kind === 'extract') && <Form.Item name="output_name" label="输出名称" rules={[{ required: true }, { pattern: identifier, message: '使用字母、数字或下划线,字母开头,最多 64 字符' }]}><Input placeholder="例如 results 或 comments" maxLength={64} /></Form.Item>}
{kind === 'scroll' && <>
<Form.Item name="direction" label="方向"><Select options={[{ value: 'down', label: '向下' }, { value: 'up', label: '向上' }]} /></Form.Item>
<div className="action-form-grid"><Form.Item name="pixels" label="每次像素" rules={[{ required: true }]}><InputNumber min={1} max={1600} precision={0} /></Form.Item><Form.Item name="max_scrolls" label="最多次数" rules={[{ required: true }]}><InputNumber min={1} max={10} precision={0} /></Form.Item></div>
<Form.Item name="use_until_target" valuePropName="checked"><Checkbox onChange={event => { if (event.target.checked) form.setFieldValue('until_selector', ''); }}>按动态目标停止滚动</Checkbox></Form.Item>
{!useUntilTarget && <Form.Item name="until_selector" label="滚动停止条件(容器内 CSS)" extra="可选。在当前滚动容器内部寻找目标,完整可见即停止,仍受最多次数限制。"><Input maxLength={1000} placeholder="例如 .comments-container" autoComplete="off" /></Form.Item>}
{useUntilTarget && <div className="dynamic-target-fields" data-testid="scroll-until-target-options">
<Form.Item name="use_viewport_scroll" valuePropName="checked" extra="用于整页滚动的页面。上方定位仍须是已观察的普通容器,目标仅在该容器内查找。"><Checkbox>滚动整页寻找容器内目标</Checkbox></Form.Item>
<Form.Item name="until_target_selector" label="容器内目标集合" extra="在已观察容器内匹配目标的 CSS;八步阅读链路需与后续点击定位完全一致。" rules={[{ required: true, whitespace: true, message: '填写容器内目标集合定位' }]}><Input maxLength={1000} placeholder="例如 #global a.title" autoComplete="off" /></Form.Item>
<Form.Item name="until_target_field" label="滚动目标匹配依据"><Select options={[{ value: 'text', label: '完整文字' }, { value: 'href', label: '链接地址' }, { value: 'attribute', label: '只读属性' }]} /></Form.Item>
{untilTargetField === 'attribute' && <Form.Item name="until_target_attribute" label="滚动目标匹配属性" rules={[{ required: true, whitespace: true, message: '填写属性名称' }]}><Input maxLength={64} placeholder="例如 data-id" /></Form.Item>}
<Form.Item name="until_target_parameter" label="绑定滚动目标参数"><Select allowClear options={parameterOptions} placeholder="选择参数,或填写固定目标值" /></Form.Item>
{untilTargetParameter && <Typography.Paragraph className="form-note" type="secondary">本次停止目标:<code>{String(parameters[untilTargetParameter] ?? '')}</code></Typography.Paragraph>}
{!untilTargetParameter && <Form.Item name="until_target_value" label="滚动目标精确值" rules={[{ required: true, whitespace: true, message: '填写目标的精确值' }]}><Input maxLength={2000} autoComplete="off" /></Form.Item>}
<Form.Item name="seek_timeout_ms" label="动态查找最长毫秒" rules={[{ required: true }]}><InputNumber min={1} max={15000} precision={0} /></Form.Item>
<Alert type="info" showIcon title="限定次数、距离与时间查找" description="目标必须唯一且完整进入视口才会完成;找不到、重复或预算耗尽会停止。此模式使用已观察的普通滚动容器,不能用 body。" />
</div>}
<Typography.Paragraph type="secondary" className="form-note">限定范围内滚动;到达边界或无法继续移动即停止。</Typography.Paragraph>
</>}
{kind === 'wait' && <>
<Form.Item name="state" label="等待条件"><Select options={[{ value: 'visible', label: '元素可见' }, { value: 'hidden', label: '元素消失' }, { value: 'text', label: '元素包含文字' }]} /></Form.Item>
{waitState === 'visible' && <Form.Item name="min_count" label="至少可见数量" extra="可选,1–100。填写后按当前视口内可见集合数量等待;留空仍要求唯一对象。"><InputNumber min={1} max={100} precision={0} placeholder="留空使用唯一目标" /></Form.Item>}
{scopedWait && <Form.Item name="wait_scope" label="等待范围(普通容器)" preserve={false} extra="可选,从最新观察中选择唯一普通容器,目标仅在其内部等待出现;留空使用原等待方式。" rules={[{ validator: async (_, value) => { if (value && !waitScopeOptions.some(option => option.value === value)) throw new Error('请选择最新观察中的唯一普通容器'); } }]}><Select aria-label="等待范围(普通容器)" allowClear showSearch optionFilterProp="label" options={waitScopeOptions} placeholder="可选,不自动选择范围" notFoundContent="当前观察没有可选普通容器,请先采集页面" /></Form.Item>}
{waitState === 'text' && <Form.Item name="text" label="预期文字" rules={[{ required: true, whitespace: true, message: '填写等待出现的文字' }]}><Input maxLength={500} /></Form.Item>}
<Form.Item name="timeout_ms" label="最长等待毫秒" rules={[{ required: true }]}><InputNumber min={1} max={15000} precision={0} /></Form.Item>
</>}
{kind === 'extract' && <>
<Form.Item name="row_selector" label="容器内结果行(CSS,可选)" extra="填写后,页面定位须为最新观察中的唯一普通容器,结果行只在该容器内查找,不再使用动态对象匹配。" rules={[{ required: Boolean(useBatch || allowEmpty), whitespace: true, message: '多批或空态读取须填写容器内结果行定位' }]}><Input maxLength={1000} placeholder="例如 .comment-item" autoComplete="off" /></Form.Item>
<Form.Item name="limit" label="最多提取项数" rules={[{ required: true }]}><InputNumber min={1} max={100} precision={0} /></Form.Item>
<Form.Item name="complete_only" valuePropName="checked" extra="跳过被视口或滚动容器边缘裁剪的字段行;真实缺失字段仍会停止。"><Checkbox disabled={busy || Boolean(useBatch)}>只读完整可见行</Checkbox></Form.Item>
<ExtractionFields />
<Form.Item name="use_batch" valuePropName="checked"><Checkbox onChange={event => { if (event.target.checked) form.setFieldsValue({ complete_only: true, allow_empty: false, use_target: false }); }}>有限多批读取并去重</Checkbox></Form.Item>
{useBatch && <div className="dynamic-target-fields" data-testid="extract-batch-options">
<Form.Item name="batch_scroll_selector" label="批次滚动容器定位" extra="使用最新观察中的唯一可滚动容器完整定位,须为读取容器本身或其内部容器。" rules={[{ required: true, whitespace: true, message: '填写已观察的滚动容器定位' }]}><Input maxLength={1000} autoComplete="off" /></Form.Item>
<div className="action-form-grid"><Form.Item name="batch_pixels" label="每批滚动像素" rules={[{ required: true }]}><InputNumber min={1} max={1600} precision={0} /></Form.Item><Form.Item name="batch_max_batches" label="最多读取批数" extra="包含首批,最多 5 批。" rules={[{ required: true }]}><InputNumber min={1} max={5} precision={0} /></Form.Item></div>
<Form.Item name="batch_dedupe_field" label="跨批去重字段" extra="从必填的 id 或 data-* 属性字段中选择;每批保留实际证据。" rules={[{ required: true, message: '选择稳定标识字段' }, { validator: async (_, value) => { if (value && !dedupeOptions.some(option => option.value === value)) throw new Error('去重字段须为必填的 id 或 data-* 属性字段'); } }]}><Select options={dedupeOptions} placeholder="先配置必填属性字段" /></Form.Item>
</div>}
<Form.Item name="allow_empty" valuePropName="checked"><Checkbox onChange={event => { if (event.target.checked) form.setFieldsValue({ use_batch: false, use_target: false }); }}>允许明确空态结果</Checkbox></Form.Item>
{allowEmpty && !useBatch && <div className="dynamic-target-fields" data-testid="extract-empty-options">
<Form.Item name="empty_state_selector" label="容器内空态定位" rules={[{ required: true, whitespace: true, message: '填写容器内可见空态定位' }]}><Input maxLength={1000} placeholder="例如 .empty-state" autoComplete="off" /></Form.Item>
<Form.Item name="empty_state_text" label="空态精确文字" extra="填写观察到的完整可见文字,按归一化后的文字精确核对;零匹配本身不算空态。" rules={[{ required: true, whitespace: true, message: '填写页面明确显示的空态文字' }]}><Input.TextArea rows={2} maxLength={2000} /></Form.Item>
</div>}
<Typography.Paragraph type="secondary" className="form-note">只提取当前视口内可见的行;输出包含 items、数量、来源和覆盖范围,超过上限会标注截断。</Typography.Paragraph>
</>}
{dynamic && <>
<Form.Item name="use_target" valuePropName="checked" className="action-target-toggle"><Checkbox>按当前对象精确匹配</Checkbox></Form.Item>
{useTarget && <div className="dynamic-target-fields">
<Form.Item name="target_field" label="匹配依据"><Select options={[{ value: 'text', label: '完整文字' }, { value: 'href', label: '链接地址' }, { value: 'attribute', label: '只读属性' }]} /></Form.Item>
{targetField === 'attribute' && <Form.Item name="target_attribute" label="匹配属性" rules={[{ required: true, whitespace: true, message: '填写属性名称' }]}><Input placeholder="例如 data-id" maxLength={64} /></Form.Item>}
<Form.Item name="target_parameter" label="绑定对象参数" extra="选择后,录制使用当前任务样本值,试跑使用本次传入值。"><Select allowClear placeholder="可选;更换对象时使用参数" options={parameterOptions} /></Form.Item>
{targetParameter && <Typography.Paragraph className="form-note" type="secondary">本次精确值:<code>{String(parameters[targetParameter] ?? '')}</code></Typography.Paragraph>}
{!targetParameter && <Form.Item name="target_value" label="本次对象的精确值" rules={[{ required: true, whitespace: true, message: '填写当前页面对象的精确值' }]}><Input autoComplete="off" maxLength={2000} /></Form.Item>}
<Alert type="info" showIcon message="仅在范围内精确命中唯一对象后继续;多匹配或未命中会停止。" />
</div>}
</>}
<Button type="primary" block htmlType="submit" disabled={!ready || busy} loading={busy}>执行并记录步骤</Button>
<Typography.Paragraph className="form-note" type="secondary">首条只读链路。登录资料和发送、关注等动作不进入此流程;条件等待依据页面状态,未承诺平台账号效果。</Typography.Paragraph>
</Form>;
}