205 lines
24 KiB
TypeScript
205 lines
24 KiB
TypeScript
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>;
|
||
}
|