93 lines
12 KiB
TypeScript
93 lines
12 KiB
TypeScript
import { Alert, Button, Card, Descriptions, Drawer, Form, Input, Select, Space, Table, Tag, Typography } from 'antd';
|
||
import { useEffect, useRef, useState } from 'react';
|
||
import { api, errorText } from '../api';
|
||
import type { BusinessBrief, BusinessConditions, PageResult } from '../types';
|
||
import { formatTime, LoadError, useResource } from './common';
|
||
|
||
export const briefFieldLabels: Record<keyof BusinessConditions, string> = { platform: '目标平台', service: '商品或服务', target_customer: '目标客户', search_terms: '搜索词', region: '服务地区', region_scope: '地区范围', exclude_service_promotions: '排除服务推广' };
|
||
const briefStatusLabels: Record<BusinessBrief['status'], string> = { draft: '待人工确认', needs_clarification: '需要补充条件', unsupported: '超出当前支持范围', failed: '解析未完成', confirmed: '条件已确认' };
|
||
export function RegionScope({ conditions }: { conditions: Pick<BusinessConditions, 'region' | 'region_scope'> }) { return <>{conditions.region_scope === 'specified' ? conditions.region : conditions.region_scope === 'unrestricted' ? '不限地区(已明确选择)' : '未设置,不推断全国'}</>; }
|
||
export function BriefConditions({ brief }: { brief: BusinessBrief }) { return <Descriptions size="small" column={1} data-testid="business-brief-conditions" items={[
|
||
{ key: 'service', label: '商品或服务', children: brief.conditions.service || '待补充' },
|
||
{ key: 'target', label: '目标客户', children: brief.conditions.target_customer || '待补充' },
|
||
{ key: 'platform', label: '平台', children: brief.conditions.platform === 'xiaohongshu' ? '小红书' : '待补充' },
|
||
{ key: 'terms', label: '搜索词', children: brief.conditions.search_terms.join('、') || '待补充' },
|
||
{ key: 'region', label: '服务地区', children: <RegionScope conditions={brief.conditions} /> },
|
||
{ key: 'exclude', label: '服务推广', children: brief.conditions.exclude_service_promotions === null ? '待确认' : brief.conditions.exclude_service_promotions ? '匹配时排除服务推广信号' : '不额外排除,仍独立判断自身需求' },
|
||
]} />; }
|
||
|
||
export function BusinessBriefPanel({ onUse }: { onUse: (brief: BusinessBrief) => void }) {
|
||
const records = useResource<PageResult<BusinessBrief>>('/business/briefs');
|
||
const [open, setOpen] = useState(false);
|
||
const [selected, setSelected] = useState<BusinessBrief>();
|
||
const [requestText, setRequestText] = useState('');
|
||
const [error, setError] = useState('');
|
||
const [busy, setBusy] = useState(false);
|
||
const [confirmForm] = Form.useForm<BusinessConditions>();
|
||
const active = useRef(false), mounted = useRef(true);
|
||
useEffect(() => { mounted.current = true; return () => { mounted.current = false; }; }, []);
|
||
const scope = Form.useWatch('region_scope', confirmForm);
|
||
function show(brief?: BusinessBrief) {
|
||
setError(''); setSelected(brief);
|
||
confirmForm.resetFields();
|
||
if (brief) { setRequestText(brief.request_text); confirmForm.setFieldsValue(brief.conditions); }
|
||
setOpen(true);
|
||
}
|
||
async function parse() {
|
||
if (active.current || !requestText.trim()) return;
|
||
active.current = true; setBusy(true); setError('');
|
||
try { const result = await api.post<BusinessBrief>('/business/briefs', { request_text: requestText.trim() }); if (!mounted.current) return; setSelected(result); confirmForm.resetFields(); confirmForm.setFieldsValue(result.conditions); await records.reload(); }
|
||
catch (failure) { if (mounted.current) { setError(errorText(failure)); await records.reload(); } }
|
||
finally { active.current = false; if (mounted.current) setBusy(false); }
|
||
}
|
||
async function confirm(values: BusinessConditions) {
|
||
if (active.current || !selected || !['draft', 'needs_clarification'].includes(selected.status)) return;
|
||
active.current = true; setBusy(true); setError('');
|
||
try {
|
||
const result = await api.post<BusinessBrief>(`/business/briefs/${selected.id}/confirm`, { ...values, search_terms: values.search_terms.map(value => value.trim()), region: values.region_scope === 'specified' ? values.region?.trim() || null : null });
|
||
if (!mounted.current) return; setSelected(result); await records.reload();
|
||
} catch (failure) { if (mounted.current) setError(errorText(failure)); }
|
||
finally { active.current = false; if (mounted.current) setBusy(false); }
|
||
}
|
||
const canConfirm = selected && ['draft', 'needs_clarification'].includes(selected.status);
|
||
return <Card className="surface-card" title="商家需求 · 业务验证" data-testid="business-brief-panel" extra={<Button type="primary" data-testid="business-brief-open" onClick={() => { setRequestText(''); show(); }}>输入商家需求</Button>}>
|
||
<Typography.Paragraph>先说明你提供什么服务、要找谁和服务地区,解析后由你确认条件,再用于搜索和已读内容分析。</Typography.Paragraph>
|
||
<Typography.Paragraph type="secondary">当前支持小红书装修服务样例;解析不会打开平台或执行关注、私信和评论。确认业务条件不等于已核实客户身份。</Typography.Paragraph>
|
||
<LoadError error={records.error} reload={records.reload} />
|
||
<Table<BusinessBrief> data-testid="business-brief-history" size="small" rowKey="id" loading={records.loading} dataSource={records.data?.items || []} pagination={{ pageSize: 3, showSizeChanger: false }} scroll={{ x: 620 }} locale={{ emptyText: '尚无商家需求,点击右上角输入。' }} columns={[
|
||
{ title: '时间', key: 'created_at', render: (_, row) => formatTime(row.created_at) },
|
||
{ title: '需求', key: 'request', render: (_, row) => <Typography.Paragraph ellipsis={{ rows: 2 }}>{row.request_text}</Typography.Paragraph> },
|
||
{ title: '状态', key: 'status', render: (_, row) => <Tag>{briefStatusLabels[row.status]}</Tag> },
|
||
{ title: '操作', key: 'view', render: (_, row) => <Space wrap><Button type="link" onClick={() => show(row)}>查看条件</Button>{row.status === 'confirmed' && <Button data-testid="business-brief-use" onClick={() => onUse(row)}>用于本次初筛</Button>}</Space> },
|
||
]} />
|
||
<Drawer title="商家需求:解析与确认" data-testid="business-brief-drawer" open={open} onClose={() => { if (!busy) setOpen(false); }} closable={{ 'aria-label': '关闭商家需求' }} size="min(800px, 100vw)" destroyOnHidden styles={{ body: { padding: 20, overflowWrap: 'anywhere' } }}>
|
||
<div className="detail-stack" style={{ gridTemplateColumns: 'minmax(0, 1fr)', minWidth: 0 }}>
|
||
<Typography.Paragraph type="secondary">例如:我是做装修服务的,想在小红书找准备装修的业主,服务杭州,排除装修公司推广。</Typography.Paragraph>
|
||
<Input.TextArea aria-label="商家自然语言需求" data-testid="business-request-text" rows={3} maxLength={2000} showCount value={requestText} disabled={busy} onChange={event => setRequestText(event.target.value)} placeholder="说明商品或服务、目标客户、平台和地区;没有提供的条件会留待确认。" />
|
||
<Button type="primary" data-testid="business-brief-parse" loading={busy} disabled={busy || !requestText.trim()} onClick={() => void parse()}>解析需求(调用模型)</Button>
|
||
{error && <Alert type="error" title="本次请求未完成" description={error} />}
|
||
{selected && <section data-testid="business-brief-result" data-brief-id={selected.id} data-status={selected.status}>
|
||
<Space wrap><Tag color={selected.status === 'confirmed' ? 'success' : 'default'}>{briefStatusLabels[selected.status]}</Tag><Typography.Text type="secondary">{formatTime(selected.created_at)}</Typography.Text></Space>
|
||
<Typography.Paragraph>本次原始需求:{selected.request_text}</Typography.Paragraph>
|
||
{selected.missing_fields.length > 0 && <Alert type="warning" data-testid="business-brief-missing" title="以下条件仍需补充或明确选择" description={selected.missing_fields.map(field => briefFieldLabels[field as keyof BusinessConditions] || field).join('、')} />}
|
||
{selected.status === 'failed' && <Alert type="warning" title="解析未完成,不能确认或用于执行" description="请核对配置或重新明确输入后再解析,失败历史保留;不会自动重试。" />}
|
||
{selected.status === 'unsupported' && <Alert type="warning" data-testid="business-brief-unsupported" title="超出当前小红书装修样例范围" description="这条记录不能直接确认或执行;请提交受支持的新需求,不会替你转换成其他业务。" />}
|
||
{selected.uncertainties.length > 0 && <ul>{selected.uncertainties.map((value, index) => <li key={index}>{value}</li>)}</ul>}
|
||
{selected.kind === 'parse' && <section data-testid="business-brief-evidence"><h4>解析字段与原文依据</h4>{Object.entries(selected.field_evidence).map(([field, quotes]) => <Typography.Paragraph key={field}>{briefFieldLabels[field as keyof BusinessConditions] || field}:{quotes.length ? quotes.map(quote => `「${quote}」`).join(';') : '原文未提供,不能自动补全'}</Typography.Paragraph>)}</section>}
|
||
{canConfirm && <Form<BusinessConditions> form={confirmForm} layout="vertical" disabled={busy} onFinish={values => void confirm(values)} data-testid="business-brief-confirm-form">
|
||
<h4>人工核对并确认条件</h4><Typography.Paragraph type="secondary">确认会追加一条条件记录,保留原解析;所有确认字段记为维护者确认,不冒充模型原文依据。</Typography.Paragraph>
|
||
<div className="form-grid"><Form.Item name="platform" label="目标平台" rules={[{ required: true }]}><Select aria-label="确认目标平台" options={[{ value: 'xiaohongshu', label: '小红书' }]} /></Form.Item><Form.Item name="service" label="商品或服务" rules={[{ required: true }]}><Select aria-label="确认商品或服务" options={[{ value: '装修服务', label: '装修服务' }]} /></Form.Item></div>
|
||
<Form.Item name="target_customer" label="目标客户" rules={[{ required: true }]}><Select aria-label="确认目标客户" options={[{ value: '有装修需求的业主', label: '有装修需求的业主' }]} /></Form.Item>
|
||
<Form.Item name="search_terms" label="搜索词(1–3 个)" rules={[{ required: true }, { validator: (_, values: string[]) => values?.length >= 1 && values.length <= 3 && values.every(value => value.trim().length > 0 && value.trim().length <= 100) ? Promise.resolve() : Promise.reject(new Error('填写 1–3 个搜索词,每个最多 100 字')) }]}><Select mode="tags" aria-label="确认搜索词" tokenSeparators={[',', ',']} /></Form.Item>
|
||
<Form.Item name="region_scope" label="地区范围" rules={[{ required: true }]}><Select aria-label="确认地区范围" options={[{ value: 'unspecified', label: '未设置(不推断全国)' }, { value: 'specified', label: '指定服务地区' }, { value: 'unrestricted', label: '不限地区(明确选择)' }]} /></Form.Item>
|
||
{scope === 'specified' && <Form.Item name="region" label="服务地区" rules={[{ required: true, whitespace: true }]}><Input aria-label="确认服务地区" maxLength={120} /></Form.Item>}
|
||
<Form.Item name="exclude_service_promotions" label="是否排除服务推广信号" rules={[{ required: true }]}><Select aria-label="确认推广排除条件" options={[{ value: true, label: '排除服务推广信号' }, { value: false, label: '不额外排除,仍独立判断自身需求' }]} /></Form.Item>
|
||
<Button type="primary" htmlType="submit" data-testid="business-brief-confirm" loading={busy}>确认并保存条件</Button>
|
||
</Form>}
|
||
{selected.status === 'confirmed' && <><BriefConditions brief={selected} /><Typography.Paragraph type="secondary">条件由维护者确认;内容发布者或评论者身份仍待核实。当前搜索只使用第一个词,不自动并行执行其他词。</Typography.Paragraph><Button type="primary" data-testid="business-brief-confirmed-use" onClick={() => { onUse(selected); setOpen(false); }}>用于本次初筛</Button></>}
|
||
</section>}
|
||
</div>
|
||
</Drawer>
|
||
</Card>;
|
||
}
|