assistant/renderer/components/SceneCards.tsx

325 lines
42 KiB
TypeScript
Raw Permalink 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 { useRef, useState, type ReactNode } from 'react';
import { ArrowRight, Check, ChevronDown, ClipboardList, Download, FileText, Link, Pencil, Search, ShieldCheck } from 'lucide-react';
import type { BusinessOutcomeEvent, BusinessOutcomeValue, CandidateBadges, CandidateGroup, Clarification, DeliveryStatus, EvidenceBadge, ExportFile, GoalProgress, ModelUsage, PhaseState, ProspectLead, RecoveryRoute, Task } from '../types';
import { displayTime, statusLabel } from '../taskView';
import { ConversationParticles } from './ConversationParticles';
import '../scenario-cards.css';
export function safeSourceUrl(value: unknown): string | null {
if (typeof value !== 'string') return null;
try { const url = new URL(value); return ['https:', 'http:'].includes(url.protocol) ? url.href : null; } catch { return null; }
}
function text(value: unknown): string { return typeof value === 'string' ? value : ''; }
function records(value: unknown): Record<string, unknown>[] {
return Array.isArray(value) ? value.filter((row): row is Record<string, unknown> => Boolean(row && typeof row === 'object')) : [];
}
function profilePersonKey(value: unknown): string | null {
if (typeof value !== 'string') return null;
try {
const url = new URL(value);
if (url.protocol !== 'https:' || !['www.xiaohongshu.com', 'xiaohongshu.com'].includes(url.hostname) || url.username || url.password || (url.port && url.port !== '443')) return null;
const match = /^\/user\/profile\/([a-f0-9]{24})\/?$/i.exec(url.pathname);
return match ? `xhs:${match[1].toLowerCase()}` : null;
} catch { return null; }
}
export function ClarificationCard({ card, busy, onSubmit, onUseConversation }: {
card: Clarification; busy: boolean;
onSubmit: (answers: Record<string, string>) => Promise<boolean>;
onUseConversation: () => void;
}) {
const [answers, setAnswers] = useState<Record<string, string>>(() => Object.fromEntries(card.fields.map(field => [field.id, field.prefill || ''])));
const [errors, setErrors] = useState<Record<string, string>>({});
const [sending, setSending] = useState(false);
const [failed, setFailed] = useState(false);
const composing = useRef(false);
async function submit(): Promise<void> {
if (busy || sending) return;
const nextErrors: Record<string, string> = {};
for (const field of card.fields) {
const value = answers[field.id]?.trim() || '';
if (field.required && !value) nextErrors[field.id] = '请补充这一项,或在对话里说明。';
else if (field.input_kind === 'number' && value && (!/^[1-9]\d*$/.test(value) || !Number.isSafeInteger(Number(value)) || Number(value) < (field.min ?? 1))) nextErrors[field.id] = '请输入大于或等于1的整数,或在对话中说明本次范围。';
}
setErrors(nextErrors); setFailed(false);
if (Object.keys(nextErrors).length) return;
setSending(true);
try { setFailed(!(await onSubmit(Object.fromEntries(card.fields.map(field => [field.id, answers[field.id]?.trim() || '']))))); }
catch { setFailed(true); }
finally { setSending(false); }
}
return <section className="scene-card clarification-card" data-testid="clarification-card" data-card-id={card.id}>
<header className="scene-card-heading"><span className="scene-kind"><ClipboardList size={13} />补充一点信息</span><span className="scene-status waiting">等你补充</span></header>
<h3>补齐后,接着这个任务继续</h3>
<p className="scene-description">已有条件会沿用。你可以填写这里,也可以直接在下方回复。</p>
<form noValidate onCompositionStart={() => { composing.current = true; }} onCompositionEnd={() => { composing.current = false; }} onKeyDown={event => { if (event.key === 'Enter' && (composing.current || event.nativeEvent.isComposing)) event.preventDefault(); }} onSubmit={event => { event.preventDefault(); if (!composing.current) void submit(); }}>
<fieldset disabled={busy || sending} className="clarification-fields">{card.fields.map(field => {
const id = `clarification-${card.id}-${field.id}`;
return <div className="clarification-field" key={field.id}>
<label htmlFor={id}>{field.label}{!field.required && <small>选填</small>}</label>
{field.input_kind === 'textarea' ? <textarea id={id} rows={3} maxLength={2000} value={answers[field.id] || ''} required={field.required} aria-invalid={Boolean(errors[field.id])} aria-describedby={errors[field.id] ? `${id}-error` : undefined} onChange={event => { setAnswers(current => ({ ...current, [field.id]: event.target.value })); setErrors(current => ({ ...current, [field.id]: '' })); setFailed(false); }} /> : <input id={id} type={field.input_kind === 'number' ? 'number' : 'text'} inputMode={field.input_kind === 'number' ? 'numeric' : undefined} min={field.min} step={field.step} maxLength={field.input_kind === 'number' ? undefined : 2000} value={answers[field.id] || ''} required={field.required} aria-invalid={Boolean(errors[field.id])} aria-describedby={errors[field.id] ? `${id}-error` : undefined} onChange={event => { setAnswers(current => ({ ...current, [field.id]: event.target.value })); setErrors(current => ({ ...current, [field.id]: '' })); setFailed(false); }} />}
{field.key === 'target_candidates' && <p className="scene-footnote">按可区分身份、去重后的候选人数计算。待核候选不等于已确认客户,阅读数量按实际需要推进。</p>}
{errors[field.id] && <p id={`${id}-error`} className="field-error" role="alert">{errors[field.id]}</p>}
</div>;
})}</fieldset>
{failed && <p className="field-error" role="alert">尚未提交成功,填写内容已保留。请查看提示后再试。</p>}
<footer className="scene-card-actions"><button type="button" className="text-action" onClick={onUseConversation} disabled={busy || sending}>在对话里补充</button><button type="submit" className="primary-button small" disabled={busy || sending}>{sending ? '正在接续任务…' : '补充并继续'}<ArrowRight size={13} /></button></footer>
</form>
</section>;
}
export function ClarificationHistory({ task }: { task: Task }) {
const rows = records(task.artifacts.clarification_history);
if (!rows.length) return null;
return <details className="clarification-history" data-testid="clarification-history"><summary>已处理的补充信息<span>{rows.length}</span></summary>{rows.map((row, index) => {
const fields = records(row.fields);
const answers = row.answers && typeof row.answers === 'object' ? row.answers as Record<string, unknown> : {};
const status = row.status === 'answered' ? '已补充' : row.status === 'responded_in_chat' ? '已在对话中回应' : '目标已更新';
return <article key={text(row.id) || `${index}-${text(row.submitted_at)}`}><header><strong>{status}</strong><time>{displayTime(text(row.submitted_at))}</time></header>{fields.map(field => <p key={text(field.id)}><span>{text(field.label)}</span>{text(answers[text(field.id)]) && <small>{text(answers[text(field.id)])}</small>}</p>)}{text(row.response_text) && <p className="clarification-history-response">{text(row.response_text)}</p>}<small>此卡已留档,后续补充沿当前对话继续。</small></article>;
})}</details>;
}
export function GoalProgressCard({ progress }: { progress: GoalProgress }) {
const numbered = progress.scope === 'candidate_count' && progress.target !== null;
return <section className="goal-progress-card" data-testid="goal-progress" data-reached={progress.reached}>
<div><span className="scene-kind">{numbered ? '本次候选目标' : progress.scope === 'route_pass' ? '本次检索范围' : '候选人数待确认'}</span>
{numbered ? <strong><span>{progress.current}</span><small> / {progress.target} 位</small></strong> : <strong>{progress.scope === 'route_pass' ? progress.scope_note || '按当前路线先做一轮' : `已整理 ${progress.current} 位满足本次条件的候选`}</strong>}
</div>
<p>{progress.assessment_current === false ? '已有结果来自旧标准,不计入当前目标;原始名单和文件保留为历史版本。' : numbered ? progress.reached ? '已达到本次人数要求。各项证据和字段状态见名单。' : `还差 ${progress.remaining ?? Math.max(0, (progress.target || 0) - progress.current)} 位;已有成果会持续保留。` : '按本次明确范围记录进展,实际覆盖和未完成部分分别保留。'}</p>
{numbered && <progress max={progress.target!} value={Math.min(progress.current, progress.target!)} aria-label="候选人数进度" />}
<div className="goal-progress-meta"><span>{progress.assessment_current === false ? '保留旧标准资料' : '按本次条件去重计数'} · {progress.counts.signals} 条需求信号</span><span>{progress.counts.verified_accounts} 位小红书号已核对</span>{progress.counts.unresolved_sources > 0 && <span>{progress.counts.unresolved_sources} 条身份待核来源,暂不计人数</span>}</div>
<small>候选人数不代表已确认客户或成交。</small>
</section>;
}
export function DeliveryStatusCard({ delivery }: { delivery?: DeliveryStatus }) {
if (!delivery) return null;
if (delivery.preflight) {
const preflight = delivery.preflight;
return <section className={`delivery-status-card ${delivery.status}`} data-testid="delivery-status" data-delivery-status={delivery.status} data-delivery-kind="preflight">
<div className="delivery-status-meta"><span className="scene-kind">本次只读预检</span><span className={`scene-status ${delivery.status === 'in_progress' ? 'running' : preflight.completed ? 'completed' : delivery.status}`}>{delivery.status === 'in_progress' ? '正在预检' : preflight.completed ? '预检已完成' : preflight.inspected_notes > 0 ? '阶段结果' : '尚待确认'}</span></div>
<h3>{delivery.headline}</h3>
<p className="scene-description">{preflight.detail}</p>
<div className="delivery-facts"><span>{preflight.visited_known ? `${preflight.visited_notes} 篇已记录访问` : '实际访问数量未记录'}</span><span>{preflight.recorded ? `${preflight.inspected_notes} 篇可确认预检` : '尚无独立预检记录'}</span>{preflight.recorded && <><span>{preflight.checked_count} 条当前可见回复</span><span>{preflight.eligible_count} 条可继续取证</span></>}</div>
{preflight.notes.length > 0 && <details className="delivery-conditions"><summary>逐篇预检与未读范围</summary><ul>{preflight.notes.map(note => <li key={note.ordinal}><span className={`condition-dot ${note.inspected ? 'met' : 'missing'}`} /><span>笔记 {note.ordinal}:{note.inspected ? `已预检当前可见讨论,检查 ${note.checked_count} 条回复,可继续取证 ${note.eligible_count} 条` : note.status === 'not_in_view' ? '评论尚未进入视口,未计入已预检' : '尚未确认有效预检结果'}{note.has_unexpanded_replies === true ? ';仍有折叠回复未展开' : note.has_unexpanded_replies === null ? ';折叠状态尚未确认' : ';本次未看到待展开入口'}</span></li>)}</ul></details>}
{preflight.recorded && <details className="delivery-conditions"><summary>可见回复的预检分类</summary><ul><li><span>具备继续取证依据</span><small>{preflight.classification_counts.unique_dom_deep_reply}</small></li><li><span>需进一步核对身份</span><small>{preflight.classification_counts.needs_native_identity}</small></li><li><span>直接父关系不唯一</span><small>{preflight.classification_counts.ambiguous}</small></li><li><span>可见依据不足</span><small>{preflight.classification_counts.unknown}</small></li></ul></details>}
<p className="scene-footnote">本轮按要求仅预检可见讨论,未保存原话或生成候选名单。零合格样本不代表没有评论;折叠及未加载部分仍未知。</p>
</section>;
}
const current = delivery.fulfillment.assessment_current;
const conditions = delivery.conditions.filter(condition => condition.required);
return <section className={`delivery-status-card ${delivery.status}`} data-testid="delivery-status" data-delivery-status={delivery.status}>
<div className="delivery-status-meta"><span className="scene-kind">{current ? '本次交付' : '历史结果'}</span><span className={`scene-status ${delivery.fulfillment.complete ? 'completed' : delivery.status}`}>{delivery.fulfillment.complete ? '已完成要求' : delivery.status === 'in_progress' ? '正在继续' : delivery.status === 'partial' ? '部分交付' : '实际结果'}</span></div>
<h3>{delivery.headline}</h3>
{delivery.fulfillment.target !== null && <p className="delivery-fulfillment">满足本次必要条件 <strong>{delivery.fulfillment.current}</strong> / {delivery.fulfillment.target} 位{delivery.fulfillment.remaining !== null && delivery.fulfillment.remaining > 0 && <span> · 剩余 {delivery.fulfillment.remaining} 位</span>}</p>}
{!current && <p className="usage-warning" data-testid="delivery-history">这份名单对应旧标准,保留供追溯,不计入当前目标。按新标准分析后会生成新的结果版本。</p>}
<div className="delivery-facts"><span>{delivery.counts.eligible} 位满足本次条件</span>{delivery.counts.supplemental > 0 && <span>{delivery.counts.supplemental} 位补充来源</span>}{delivery.counts.unresolved > 0 && <span>{delivery.counts.unresolved} 组无法区分身份</span>}<span>{delivery.artifacts.file_available ? '名单文件可用' : delivery.artifacts.file_required ? '要求的文件尚未生成' : delivery.artifacts.list_available ? '名单可直接查看' : '尚无可交付名单'}</span></div>
{conditions.length > 0 && <details className="delivery-conditions"><summary>本次必要条件与完成量</summary><ul>{conditions.map(condition => <li key={condition.key}><span className={`condition-dot ${condition.met ? 'met' : 'missing'}`} /><span>{condition.label}</span>{typeof condition.current === 'number' && <small>{condition.current}{typeof condition.target === 'number' ? ` / ${condition.target}` : ''}</small>}<span className="condition-result">{condition.met ? '已满足' : '尚未满足'}</span></li>)}</ul></details>}
{(delivery.runtime.stop_detail || typeof delivery.runtime.raw_browser_success === 'boolean') && <details className="delivery-runtime"><summary>保留的原始执行结论</summary>{delivery.runtime.stop_detail && <p>{delivery.runtime.stop_detail}</p>}{typeof delivery.runtime.raw_browser_success === 'boolean' && <p>网页采集器原始结论:{delivery.runtime.raw_browser_success ? '报告成功' : '报告未完成'}。实际目标完成量与可用产物分别按上方证据判断。</p>}</details>}
<p className="scene-footnote">人数按身份去重并遵循本次必要条件。可选字段缺失会逐项说明,不阻止名单交付。</p>
</section>;
}
function tokens(value: number | null | undefined): string {
return typeof value === 'number' && Number.isFinite(value) ? value.toLocaleString('zh-CN') : '未知';
}
const PHASE_NAMES: Record<string, string> = {
intent: '目标理解', dialogue: '目标理解', browser: '网页执行', compression: '上下文整理', compaction: '上下文整理',
browser_compaction: '上下文整理', analysis: '来源分析', analysis_incremental: '增量分析', review: '资料复核', saved_review: '资料复核',
};
export function UsageCard({ usage }: { usage?: ModelUsage }) {
if (!usage) return null;
return <details className="usage-card" data-testid="usage-card"><summary><span>模型用量记录</span><small>{usage.requests > 0 ? `${usage.requests} 次已记录调用` : usage.legacy_incomplete ? '历史用量不完整' : '尚无模型调用'}</small><ChevronDown size={12} /></summary>
<p className="scene-description">仅汇总已记录的供应商返回用量,不是费用账单,也不会据此限制任务。</p>
<dl className="usage-totals"><div><dt>已知输入 token</dt><dd>{tokens(usage.input_tokens)}</dd></div><div><dt>已知输出 token</dt><dd>{tokens(usage.output_tokens)}</dd></div><div><dt>已知总 token</dt><dd>{tokens(usage.total_tokens)}</dd></div></dl>
<p className="usage-cache">输入缓存命中:{tokens(usage.cache_hit_tokens)} · 未命中:{tokens(usage.cache_miss_tokens)}</p>
{usage.legacy_incomplete && <p className="usage-warning">记录启用前的调用未完整保存,历史总用量未知,不能用这里的数字还原整项任务费用。</p>}
{usage.ledger_error && <p className="usage-warning">部分用量记录暂时无法保存或读取,当前统计可能不完整,不能当作完整账单。</p>}
{(usage.unknown_usage_requests > 0 || usage.incomplete_usage_requests > 0) && <p className="usage-warning">{usage.unknown_usage_requests > 0 ? `${usage.unknown_usage_requests} 次调用未返回可核对的用量。` : ''}{usage.incomplete_usage_requests > 0 ? `${usage.incomplete_usage_requests} 次调用仅返回部分用量字段。` : ''}汇总只包含已知数值。</p>}
{(usage.parse_failed_requests > 0 || usage.failed_requests > 0 || usage.pending_requests > 0) && <p className="scene-footnote">{usage.parse_failed_requests > 0 && <>输出校验未通过 {usage.parse_failed_requests} 次,已返回的用量仍计入。 </>}{usage.failed_requests > 0 && <>请求失败 {usage.failed_requests} 次。 </>}{usage.pending_requests > 0 && <>进行中 {usage.pending_requests} 次。</>}</p>}
{usage.phases.length > 0 && <div className="usage-table-wrap"><table className="usage-table"><caption className="sr-only">按阶段记录的模型调用用量</caption><thead><tr><th scope="col">阶段</th><th scope="col">调用</th><th scope="col">已知输入</th><th scope="col">已知输出</th></tr></thead><tbody>{usage.phases.map(phase => <tr key={phase.phase}><td>{PHASE_NAMES[phase.phase] || phase.phase}</td><td>{phase.requests}</td><td>{tokens(phase.input_tokens)}</td><td>{tokens(phase.output_tokens)}</td></tr>)}</tbody></table></div>}
{usage.recorded_since && <p className="scene-footnote">开始记录:{displayTime(usage.recorded_since)}</p>}
{usage.models && usage.models.length > 0 && <p className="scene-footnote">记录中的模型:{usage.models.join('、')}</p>}
</details>;
}
export function PhaseCard({ task, phase, reducedMotion, showActivity, children }: {
task: Task; phase?: PhaseState; reducedMotion: boolean; showActivity: boolean; children?: ReactNode;
}) {
if (!phase) return <>{children}</>;
if (task.ui?.clarification) return null;
const counts = phase.counts;
const hasSavedProgress = Object.values(counts).some(value => value > 0) || phase.events.length > 0;
return <section className="phase-card" data-testid="phase-card">
<header className="scene-card-heading"><span className="scene-kind">小红书 · 当前任务</span><span className={`scene-status ${task.status}`}>{statusLabel(task)}</span></header>
<div className="phase-current"><div><h3>{phase.title}</h3>{phase.detail && <p className="scene-description">{phase.detail}</p>}</div>{showActivity && <ConversationParticles active reducedMotion={reducedMotion} compact />}</div>
{task.ui?.goal_progress && (!task.inputs.intent || task.inputs.intent === 'discover' || task.ui.goal_progress.target !== null) && <GoalProgressCard progress={task.ui.goal_progress} />}
{phase.stages.length > 0 && <details className="phase-plan"><summary>本次计划<ChevronDown size={12} /></summary><ol>{phase.stages.map((stage, index) => <li key={`${index}-${stage}`}><span>{index + 1}</span>{stage}</li>)}</ol></details>}
{hasSavedProgress && <><dl className="phase-counts"><div><dt>笔记来源</dt><dd>{counts.notes}</dd></div><div><dt>评论来源</dt><dd>{counts.comments}</dd></div><div><dt>回复来源</dt><dd>{counts.replies}</dd></div><div><dt>核对主页记录</dt><dd>{counts.profiles}</dd></div><div><dt>需求信号</dt><dd>{counts.leads}</dd></div></dl><p className="scene-footnote">以上为已保存记录,不代表候选人数或完整阅读了对应页面。</p></>}
{(phase.coverage.observed_keywords.length > 0 || phase.coverage.pending_keywords.length > 0) && <details className="phase-coverage"><summary>查看实际检索覆盖</summary>{phase.coverage.observed_keywords.length > 0 && <p><strong>已观察检索页:</strong>{phase.coverage.observed_keywords.join('、')}</p>}{phase.coverage.pending_keywords.length > 0 && <p><strong>检索页待核对:</strong>{phase.coverage.pending_keywords.join('、')}</p>}</details>}
{phase.events.length > 0 && <details className="phase-events"><summary>最近执行记录<span>{phase.events.length}</span></summary><ol>{phase.events.slice(-5).map(event => <li key={event.id}><span className="phase-event-dot" /><div><strong>{event.title}</strong>{event.detail && <p>{event.detail}</p>}{event.at && <time>{displayTime(event.at)}</time>}</div></li>)}</ol></details>}
{children}
</section>;
}
export function DeliveryFilesCard({ files, busy, onOpen }: { files: ExportFile[]; busy: boolean; onOpen: (file: ExportFile) => Promise<void> }) {
if (!files.length) return null;
return <section className="delivery-files" data-testid="delivery-files"><div className="scene-card-heading"><span className="scene-kind"><FileText size={13} />已生成的名单文件</span><span>{files.length} 份</span></div>{files.slice().reverse().map(file => <article className="delivery-file" key={file.id}><span className="delivery-file-icon"><FileText size={18} /></span><div><strong>{file.filename}</strong><p>{file.format.toUpperCase()} · {file.count} 条 · {displayTime(file.generated_at)}</p><small className={file.is_current ? 'file-current' : ''}>{file.available === false ? '文件不可用,请重新导出当前名单' : file.is_current ? '对应当前结果' : '历史结果版本'}</small></div><button type="button" className="secondary-button small" disabled={busy || file.available === false} onClick={() => { void onOpen(file); }} aria-label={`打开文件 ${file.filename}`}><Download size={13} /><span>打开文件</span></button></article>)}</section>;
}
export function RecoveryCard({ routes, busy, active, onChoose }: { routes: RecoveryRoute[]; busy: boolean; active: boolean; onChoose: (route: RecoveryRoute, force?: boolean) => Promise<void> }) {
if (!routes.length) return null;
return <section className="scene-card recovery-card" data-testid="recovery-card"><header className="scene-card-heading"><span className="scene-kind">接下来可以</span></header><div className="recovery-routes">{routes.map((route, index) => <article key={route.id || `${index}-${route.title}`}><div><strong>{route.title}</strong>{(route.reason || route.description) && <p>{route.reason || route.description}</p>}{route.deliverable && <small>将得到:{route.deliverable}</small>}</div>{route.action && route.id ? <button type="button" className="text-action" disabled={busy || active || route.enabled === false} title={route.disabled_reason} onClick={() => { void onChoose(route); }}>选择这条路线<ArrowRight size={13} /></button> : <span className="scene-footnote">路线尚未接入</span>}{route.action === 'review_saved' && route.id && <div className="review-repeat-option"><small>资料和条件相同时优先复用已有判断。</small><button type="button" className="text-action" disabled={busy || active || route.enabled === false} title="重新分析会新增模型调用和用量" onClick={() => { void onChoose(route, true); }}>重新分析(新增模型用量)</button></div>}{route.enabled === false && route.disabled_reason && <p className="scene-footnote">{route.disabled_reason}</p>}</article>)}</div></section>;
}
const BADGE_DIMENSIONS: { key: keyof CandidateBadges; label: string }[] = [
{ key: 'demand', label: '需求表达' }, { key: 'identity', label: '身份关联' },
{ key: 'region', label: '地区依据' }, { key: 'account', label: '账号字段' }, { key: 'outreach', label: '跟进进展' },
{ key: 'business', label: '用户反馈' },
];
const BUSINESS_OUTCOMES: Record<BusinessOutcomeValue, string> = { replied: '已回复', need_confirmed: '已确认需求', cooperated: '已合作', no_reply: '暂未回复', not_relevant: '不相关或不匹配' };
function BusinessOutcomeForm({ sourceId, busy, history, onRecord }: {
sourceId: string; busy: boolean; history: BusinessOutcomeEvent[];
onRecord: (sourceId: string, outcome: BusinessOutcomeValue, note?: string) => Promise<void>;
}) {
const [open, setOpen] = useState(false);
const [outcome, setOutcome] = useState<BusinessOutcomeValue | ''>('');
const [note, setNote] = useState('');
const [sending, setSending] = useState(false);
const [saved, setSaved] = useState(false);
const [failed, setFailed] = useState(false);
const composing = useRef(false);
const submitting = useRef(false);
async function submit(): Promise<void> {
if (!outcome || busy || submitting.current || composing.current) return;
submitting.current = true; setSending(true); setFailed(false); setSaved(false);
try {
await onRecord(sourceId, outcome, note.trim() || undefined);
setSaved(true); setOpen(false); setOutcome(''); setNote('');
} catch { setFailed(true); }
finally { submitting.current = false; setSending(false); }
}
return <section className="business-outcome" data-testid="business-outcome" data-source-id={sourceId}>
<div className="business-outcome-heading"><button type="button" className="text-action" disabled={busy || sending} aria-expanded={open} onClick={() => { setOpen(value => !value); setSaved(false); }}>记录后续结果<small>选填</small><ChevronDown size={12} /></button>{saved && <span className="business-outcome-saved" role="status"><Check size={12} />已保存用户反馈</span>}</div>
{open && <form className="business-outcome-form" onCompositionStart={() => { composing.current = true; }} onCompositionEnd={() => { composing.current = false; }} onKeyDown={event => { if (event.key === 'Enter' && (composing.current || event.nativeEvent.isComposing)) event.preventDefault(); }} onSubmit={event => { event.preventDefault(); void submit(); }}>
<p className="scene-footnote">这是你的后续反馈,不是页面自动核验;不会增加方法的技术成功次数,也不会自动执行关注或发送。</p>
<fieldset disabled={busy || sending}><label htmlFor={`business-outcome-${sourceId}`}>实际后续结果<select id={`business-outcome-${sourceId}`} value={outcome} required onChange={event => { setOutcome(event.target.value as BusinessOutcomeValue | ''); setFailed(false); }}><option value="" disabled>请选择要记录的结果</option>{(Object.keys(BUSINESS_OUTCOMES) as BusinessOutcomeValue[]).map(value => <option key={value} value={value}>{BUSINESS_OUTCOMES[value]}</option>)}</select></label><label htmlFor={`business-outcome-note-${sourceId}`}>补充说明<small>选填</small><textarea id={`business-outcome-note-${sourceId}`} rows={2} maxLength={1000} value={note} placeholder="补充实际时间、沟通情况或不匹配原因" onChange={event => { setNote(event.target.value); setFailed(false); }} /></label></fieldset>
{failed && <p className="field-error" role="alert">尚未确认保存成功,填写内容已保留。请查看提示后再操作。</p>}
<div className="business-outcome-actions"><button type="button" className="text-action" disabled={sending} onClick={() => setOpen(false)}>收起</button><button type="submit" className="secondary-button small" disabled={busy || sending || !outcome}>{sending ? '正在保存…' : '保存后续结果'}</button></div>
</form>}
{history.length > 0 && <details className="business-outcome-history"><summary>已记录的后续反馈<span>{history.length}</span></summary><ol>{history.slice().reverse().map(event => <li key={event.id}><div><strong>{BUSINESS_OUTCOMES[event.outcome] || event.outcome}</strong><span>用户反馈</span><time>{displayTime(event.at)}</time></div>{event.note && <p>{event.note}</p>}</li>)}</ol><p className="scene-footnote">反馈保留原记录,不将后续合作自动归因于某条搜索或操作方法。</p></details>}
</section>;
}
function badgeTone(status: string): string {
if (['explicit', 'matched', 'verified', 'resolved', 'known', 'available', 'obtained', 'confirmed', 'yes', 'sent', 'followed', 'replied', 'source_link', 'profile_verified'].includes(status)) return 'supported';
if (['mismatch', 'mismatched', 'conflicting', 'contradicted', 'failed', 'no', 'send_failed', 'not_seeking'].includes(status)) return 'gap';
return 'neutral';
}
function Badge({ badge, dimension }: { badge: EvidenceBadge; dimension: string }) {
return <span className={`evidence-badge ${badgeTone(badge.status)}`} data-dimension={dimension} data-status={badge.status} title={badge.detail}>{badge.label}</span>;
}
function historicalBadges(group: CandidateGroup, evidence: ProspectLead[], account: string, regionRequired: boolean): CandidateBadges {
const explicit = evidence.some(lead => lead.seeking_service === 'yes');
const noDemand = evidence.length > 0 && evidence.every(lead => lead.seeking_service === 'no');
const regionMatched = evidence.some(lead => lead.region_match === 'yes' && Boolean(lead.region_quote));
const regionMismatch = evidence.some(lead => lead.region_match === 'no' && Boolean(lead.region_quote));
return {
demand: { status: explicit ? 'explicit' : noDemand ? 'no' : 'unknown', label: explicit ? '本人明确表达需求' : noDemand ? '未表达寻找服务' : '需求语境不足', detail: '依据已保存的逐来源分析字段;历史缺失字段保持未知。' },
identity: { status: group.identity_status, label: group.identity_status === 'unresolved' ? '无法区分身份' : '来源身份可区分' },
account: { status: account ? 'obtained' : 'unknown', label: account ? '已取得小红书号' : '账号号未获取' },
region: { status: regionMatched ? 'matched' : regionMismatch ? 'mismatch' : regionRequired ? 'unknown' : 'not_required', label: regionMatched ? '需求原话支持地区' : regionMismatch ? '地区不匹配' : regionRequired ? '地区依据不足' : '本次未限定地区' },
outreach: { status: 'unknown', label: '未记录跟进结果' },
};
}
export function LeadResults({ task, leads, busy, onCorrect, onBusinessOutcome }: {
task: Task; leads: ProspectLead[]; busy: boolean; onCorrect: (sourceId: string, text: string) => Promise<void>;
onBusinessOutcome: (sourceId: string, outcome: BusinessOutcomeValue, note?: string) => Promise<void>;
}) {
const [query, setQuery] = useState('');
const [filter, setFilter] = useState('all');
const [editing, setEditing] = useState<string | null>(null);
const [correction, setCorrection] = useState('');
const [expanded, setExpanded] = useState<string | null>(null);
const composing = useRef(false);
const profiles = records(task.artifacts.profiles);
const sources = records(task.artifacts.sources);
const byId = new Map(leads.map(lead => [lead.source_id || lead.id, lead]));
const progress = task.ui?.goal_progress;
const delivery = task.ui?.delivery_status;
const groups: CandidateGroup[] = progress?.groups || leads.map((lead, index) => ({
person_key: `unresolved-${lead.source_id || lead.id || index}`,
source_ids: [lead.source_id || lead.id || ''].filter(Boolean), representative: lead, evidence: [lead],
identity_status: 'unresolved' as const, account_verified: false,
}));
const rows = groups.map((group, index) => {
const evidence = (group.evidence.length ? group.evidence : [group.representative]).map(lead => byId.get(lead.source_id || lead.id) || lead);
const lead = byId.get(group.representative.source_id || group.representative.id) || group.representative;
const sourceId = lead.source_id || lead.id;
const source = (sourceId ? sources.find(item => item.source_id === sourceId) : undefined) || {};
const matchingProfiles = profiles.filter(item => group.source_ids.includes(text(item.source_id)) && item.evidence === 'matched_source_author_link_and_visible_profile_header' && Boolean(item.observed_at) && profilePersonKey(item.profile_url) === group.person_key);
const profile = matchingProfiles.find(item => text(item.xiaohongshu_id).trim()) || matchingProfiles[0] || {};
const account = group.account_verified ? text(profile.xiaohongshu_id) : '';
const author = text(source.author) || lead.author || text(profile.nickname) || `来源身份 ${index + 1}`;
const profileUrl = safeSourceUrl(profile.profile_url || source.author_profile_url);
const person = delivery?.people.find(item => item.person_key === group.person_key);
const badges = { ...(group.badges || historicalBadges(group, evidence, account, Boolean(task.inputs.region))), ...person?.badges };
const missing = group.missing || person?.missing || [];
const kind = group.group || person?.group || (group.identity_status === 'unresolved' ? 'unresolved' : undefined);
return { group, evidence, lead, badges, missing, kind, key: group.person_key, author, account, profileUrl,
unresolved: group.identity_status === 'unresolved', corrected: evidence.some(item => Boolean(item.correction)) };
});
const search = query.trim().toLocaleLowerCase();
const visible = rows.filter(row => (!search || `${row.author} ${row.account} ${row.evidence.map(item => `${item.quote} ${item.reason}`).join(' ')}`.toLocaleLowerCase().includes(search))
&& (filter !== 'account' || !row.account) && (filter !== 'unresolved' || row.unresolved)
&& (filter !== 'eligible' || row.kind === 'eligible') && (filter !== 'supplemental' || row.kind === 'supplemental')
&& (filter !== 'demand' || ['unknown', 'insufficient', 'context_insufficient', 'unclear'].includes(row.badges.demand.status))
&& (filter !== 'region' || Boolean(task.inputs.region) && ['unknown', 'insufficient', 'mismatch', 'mismatched', 'conflicting', 'no', 'context_insufficient'].includes(row.badges.region.status))
&& (filter !== 'corrected' || row.corrected));
if (delivery?.preflight) return <p className="scene-footnote" data-testid="preflight-no-candidate-list">本轮按要求只做可见回复关系预检。预检结论与未读范围见上方;后续取证另按你的要求进行。</p>;
if (!leads.length) return <div className="empty-state compact"><Search size={25} /><h3>{delivery?.status === 'no_matches' ? '本次范围内未发现符合条件的候选' : delivery?.status === 'no_evidence' ? '本阶段未取得可判断的资料' : '尚无候选名单'}</h3><p>{delivery?.status === 'no_matches' ? '筛选结论仅对应实际读取的范围,覆盖记录已保留。' : '未读取或未保存到有效来源,不能据此判断没有相关需求。已有进度和未完成范围会保留。'}</p></div>;
return <section className="lead-results" data-testid="lead-results">
{progress?.assessment_current === false && <p className="usage-warning" data-testid="assessment-stale">以下为旧标准结果,不计入当前目标。名单、证据和原版本文件继续保留。</p>}
<div className="lead-checklist">
{delivery ? <><span><ShieldCheck size={14} /><strong>{delivery.counts.eligible}</strong> 位满足本次条件</span>{delivery.counts.supplemental > 0 && <span><strong>{delivery.counts.supplemental}</strong> 位补充来源</span>}<span><strong>{delivery.counts.signals}</strong> 条原话</span><span><strong>{delivery.counts.unresolved}</strong> 组无法区分身份</span></> : progress ? <><span><ShieldCheck size={14} /><strong>{progress.counts.candidates}</strong> 位{progress.assessment_current === false ? '旧标准候选' : '可区分身份的候选'}</span><span><strong>{progress.counts.signals}</strong> 条需求信号</span><span><strong>{progress.counts.unresolved_sources}</strong> 条身份未知来源</span></> : <span>{leads.length} 条需求信号,身份归属尚未建立</span>}
<small>同一身份合并展示,同名不自动合并。补充来源与身份未知来源分别保留,不凑目标人数。</small>
</div>
<div className="lead-filters"><label><Search size={13} /><input type="search" aria-label="筛选名单" placeholder="搜索昵称、账号或需求原话" value={query} onChange={event => setQuery(event.target.value)} /></label><select aria-label="字段状态筛选" value={filter} onChange={event => setFilter(event.target.value)}><option value="all">全部记录</option><option value="eligible">满足本次条件</option><option value="supplemental">补充来源</option><option value="account">账号号未获取</option><option value="demand">需求语境不足</option><option value="region">地区缺项或不匹配</option><option value="unresolved">身份未知,不计人数</option><option value="corrected">已纠正</option></select><span>显示 {visible.length} / {rows.length} 组</span></div>
{visible.length ? <div className="lead-table-wrap"><table className="lead-table"><caption className="sr-only">按身份合并的候选名单、补充来源和字段证据状态</caption><thead><tr><th scope="col">候选与身份</th><th scope="col">来源原话与匹配依据</th><th scope="col">需求与字段</th></tr></thead><tbody>{visible.map(row => <tr key={row.key} data-person-key={row.key} data-identity-status={row.group.identity_status} data-candidate-group={row.kind}>
<td><strong>{row.author}</strong><small>{row.account ? `小红书号:${row.account}` : '账号号未获取'}</small>{row.kind === 'supplemental' && <small className="identity-unresolved">补充来源,不计本次达标</small>}{row.unresolved && <small className="identity-unresolved">无法区分身份,不计人数</small>}</td>
<td><span className="source-badge">{row.lead.source_type === 'reply' ? '回复' : row.lead.source_type === 'comment' ? '评论' : '笔记'}</span><p>{row.lead.quote}</p>{row.lead.reason && <small className="lead-match-reason">{row.lead.reason}</small>}{row.evidence.length > 1 && <small>共 {row.evidence.length} 条原话</small>}</td>
<td><div className="candidate-badges">{BADGE_DIMENSIONS.map(({ key: dimension }) => { const badge = row.badges[dimension]; return badge ? <Badge key={dimension} dimension={dimension} badge={badge} /> : null; })}</div><button type="button" className="text-action" aria-expanded={expanded === row.key} aria-label={`查看${row.author}的依据`} onClick={() => setExpanded(expanded === row.key ? null : row.key)}>{expanded === row.key ? '收起依据' : '查看依据'}<ChevronDown size={12} /></button></td>
</tr>)}</tbody></table></div> : <p className="scene-description lead-no-match">没有匹配记录,调整筛选即可查看已有名单。</p>}
{visible.filter(row => row.key === expanded).map(row => <section className="candidate-evidence-group" key={row.key} data-testid="candidate-evidence">
<header><strong>{row.author}</strong><span>{row.evidence.length} 条来源原话</span>{row.unresolved && <small>无法区分身份,不计人数</small>}</header>
<dl className="candidate-field-details">{BADGE_DIMENSIONS.map(({ key, label }) => { const badge = row.badges[key]; return badge ? <div key={key}><dt>{label}</dt><dd><Badge badge={badge} dimension={key} />{badge.detail && <p>{badge.detail}</p>}{badge.evidence?.filter(item => item.quote).map((item, index) => <blockquote key={`${item.source_id || key}-${index}`}>{item.quote}</blockquote>)}</dd></div> : null; })}</dl>
{row.missing.length > 0 && <p className="candidate-required-missing">本次必要条件缺项:{row.missing.map(item => typeof item === 'string' ? item : item.label).join(';')}</p>}
{row.profileUrl && <a className="text-action" href={row.profileUrl} target="_blank" rel="noreferrer">查看主页入口<ArrowRight size={12} /></a>}
{row.evidence.map((lead, index) => {
const sid = lead.source_id || lead.id;
const source = (sid ? sources.find(item => item.source_id === sid) : undefined) || {};
const url = safeSourceUrl(source.source_url || lead.source_url);
const key = sid || `${row.key}-${index}`;
return <article className="prospect-lead evidence-lead" key={key} data-testid="lead-card">
<div className="lead-heading"><span className="source-badge">{lead.source_type === 'reply' ? '回复' : lead.source_type === 'comment' ? '评论' : '笔记'}</span><strong>{text(source.author) || lead.author || row.author}</strong></div>
<blockquote>{lead.quote}</blockquote><p className="lead-reason">{lead.reason}</p>
{text(source.ip_location) && <p className="scene-footnote">页面 IP 属地:{text(source.ip_location)},不作为服务地点。</p>}
{lead.correction && <p className="lead-correction"><Check size={12} />你的纠正:{typeof lead.correction === 'string' ? lead.correction : lead.correction.text}</p>}
<div className="lead-footer"><span>{url ? <a href={url} target="_blank" rel="noreferrer"><Link size={13} />查看来源</a> : <span className="muted">未保存来源链接</span>}<time>{displayTime(text(source.observed_at) || lead.observed_at)}</time></span>{sid && <button type="button" className="text-action" disabled={busy} onClick={() => { setEditing(editing === key ? null : key); setCorrection(''); }}><Pencil size={12} />纠正这条</button>}</div>
{editing === key && sid && <form className="lead-correction-form" onCompositionStart={() => { composing.current = true; }} onCompositionEnd={() => { composing.current = false; }} onKeyDown={event => { if (event.key === 'Enter' && (composing.current || event.nativeEvent.isComposing)) event.preventDefault(); }} onSubmit={event => { event.preventDefault(); if (!composing.current && correction.trim()) void onCorrect(sid, correction.trim()).then(() => { setEditing(null); setCorrection(''); }).catch(() => undefined); }}><textarea rows={2} maxLength={1000} aria-label="纠正线索或补充账号信息" placeholder="补充账号号、地区依据,或说明这条判断哪里不适用" value={correction} onChange={event => setCorrection(event.target.value)} /><div><button type="button" className="text-action" onClick={() => setEditing(null)}>取消</button><button type="submit" className="secondary-button small" disabled={busy || !correction.trim()}>保存纠正</button></div></form>}
{sid && <BusinessOutcomeForm sourceId={sid} busy={busy} history={(task.artifacts.business_outcomes || []).filter(event => event.source_id === sid && event.source === 'user')} onRecord={onBusinessOutcome} />}
</article>;
})}
</section>)}
</section>;
}