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

93 lines
12 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, 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>;
}