325 lines
42 KiB
TypeScript
325 lines
42 KiB
TypeScript
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>;
|
||
}
|