feat(chat): P1 — 评估结果可视化卡片,Assessment Expert 输出渲染为结构化报告

## 新建 AssessmentResultCard 组件

- 渲染 Assessment Expert 返回的结构化评估数据
- 综合适合度分数(顶部大字展示,颜色编码)
- 6 个移民类别评估卡片(QMAS/GEP/IANG/TTPS/CIES/TECHTAS)
  - 分数条形图(CSS 实现,无需 chart 库)
  - 颜色梯度:绿色(90+) → 蓝色(70+) → 黄色(50+) → 橙色(30+) → 红色
  - 推荐类别高亮(primary 边框 + Award 图标)
  - 优势(highlights)、风险(concerns)、缺失信息(missingInfo) 分组展示
  - 子类别标签(如 A类、综合计分制)
- 排序:推荐类别优先,其次按分数降序
- 底部建议区块

## ToolCallResult 集成

- 识别 invoke_assessment_expert 工具结果
- 自动 JSON.parse(assessment expert 返回 JSON 字符串)
- 存在 assessments 数组时渲染 AssessmentResultCard

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
hailin 2026-02-07 01:20:12 -08:00
parent df754ce8b8
commit db7964a461
2 changed files with 192 additions and 0 deletions

View File

@ -0,0 +1,178 @@
import { clsx } from 'clsx';
import { CheckCircle, XCircle, AlertTriangle, Info, Award, TrendingUp } from 'lucide-react';
interface CategoryAssessment {
category: string;
categoryName: string;
eligible: boolean;
score: number;
confidence: number;
highlights: string[];
concerns: string[];
missingInfo?: string[];
subClass?: string;
}
interface AssessmentData {
assessments: CategoryAssessment[];
overallRecommendation: string;
topRecommended: string[];
suitabilityScore: number;
summary: string;
}
function ScoreBar({ score, label }: { score: number; label?: string }) {
const getColor = (s: number) => {
if (s >= 90) return 'bg-green-500';
if (s >= 70) return 'bg-blue-500';
if (s >= 50) return 'bg-yellow-500';
if (s >= 30) return 'bg-orange-500';
return 'bg-red-500';
};
const getLabel = (s: number) => {
if (s >= 90) return '高度适合';
if (s >= 70) return '比较适合';
if (s >= 50) return '有条件适合';
if (s >= 30) return '适合度低';
return '不适合';
};
return (
<div className="flex items-center gap-2">
<div className="flex-1 h-2 bg-secondary-100 rounded-full overflow-hidden">
<div
className={clsx('h-full rounded-full transition-all', getColor(score))}
style={{ width: `${score}%` }}
/>
</div>
<span className="text-xs font-medium text-secondary-600 whitespace-nowrap w-16 text-right">
{label || getLabel(score)} {score}
</span>
</div>
);
}
function CategoryCard({ assessment, isRecommended }: { assessment: CategoryAssessment; isRecommended: boolean }) {
return (
<div className={clsx(
'p-3 rounded-lg border transition-all',
isRecommended ? 'border-primary-300 bg-primary-50/50' : 'border-secondary-200 bg-white',
)}>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
{isRecommended && <Award className="w-4 h-4 text-primary-500" />}
<span className="font-medium text-sm">{assessment.categoryName}</span>
<span className="text-xs text-secondary-400">{assessment.category}</span>
{assessment.subClass && (
<span className="text-xs px-1.5 py-0.5 bg-secondary-100 rounded">{assessment.subClass}</span>
)}
</div>
<div className="flex items-center gap-1">
{assessment.eligible ? (
<CheckCircle className="w-4 h-4 text-green-500" />
) : (
<XCircle className="w-4 h-4 text-red-400" />
)}
<span className={clsx('text-xs', assessment.eligible ? 'text-green-600' : 'text-red-500')}>
{assessment.eligible ? '符合条件' : '不符合'}
</span>
</div>
</div>
<ScoreBar score={assessment.score} />
{assessment.highlights.length > 0 && (
<div className="mt-2">
{assessment.highlights.map((h, i) => (
<div key={i} className="flex items-start gap-1.5 text-xs text-green-700 mt-1">
<CheckCircle className="w-3 h-3 mt-0.5 flex-shrink-0" />
<span>{h}</span>
</div>
))}
</div>
)}
{assessment.concerns.length > 0 && (
<div className="mt-1">
{assessment.concerns.map((c, i) => (
<div key={i} className="flex items-start gap-1.5 text-xs text-orange-600 mt-1">
<AlertTriangle className="w-3 h-3 mt-0.5 flex-shrink-0" />
<span>{c}</span>
</div>
))}
</div>
)}
{assessment.missingInfo && assessment.missingInfo.length > 0 && (
<div className="mt-1">
{assessment.missingInfo.map((m, i) => (
<div key={i} className="flex items-start gap-1.5 text-xs text-secondary-500 mt-1">
<Info className="w-3 h-3 mt-0.5 flex-shrink-0" />
<span>{m}</span>
</div>
))}
</div>
)}
</div>
);
}
export function AssessmentResultCard({ data }: { data: AssessmentData }) {
const topSet = new Set(data.topRecommended);
// Sort: recommended first, then by score
const sorted = [...data.assessments].sort((a, b) => {
const aRec = topSet.has(a.category) ? 1 : 0;
const bRec = topSet.has(b.category) ? 1 : 0;
if (aRec !== bRec) return bRec - aRec;
return b.score - a.score;
});
return (
<div className="mt-3 p-4 bg-white rounded-lg border border-secondary-200">
{/* Header with overall score */}
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<TrendingUp className="w-5 h-5 text-primary-500" />
<span className="font-semibold text-sm"></span>
</div>
<div className="flex items-center gap-1.5">
<span className="text-xs text-secondary-500"></span>
<span className={clsx(
'text-lg font-bold',
data.suitabilityScore >= 70 ? 'text-green-600' :
data.suitabilityScore >= 50 ? 'text-yellow-600' : 'text-red-500',
)}>
{data.suitabilityScore}
</span>
</div>
</div>
{/* Summary */}
{data.summary && (
<p className="text-xs text-secondary-600 mb-3 leading-relaxed">{data.summary}</p>
)}
{/* Category assessments */}
<div className="space-y-2">
{sorted.map((assessment) => (
<CategoryCard
key={assessment.category}
assessment={assessment}
isRecommended={topSet.has(assessment.category)}
/>
))}
</div>
{/* Overall recommendation */}
{data.overallRecommendation && (
<div className="mt-3 p-2 bg-primary-50 rounded-lg">
<p className="text-xs text-primary-800 leading-relaxed">
<strong></strong>{data.overallRecommendation}
</p>
</div>
)}
</div>
);
}

View File

@ -3,6 +3,7 @@ import { User, Bot, Image, FileText, Download, ExternalLink, CheckCircle, Clock,
import ReactMarkdown from 'react-markdown';
import { QRCodeSVG } from 'qrcode.react';
import { FileAttachment } from '../stores/chatStore';
import { AssessmentResultCard } from './AssessmentResultCard';
interface Message {
id: string;
@ -383,5 +384,18 @@ function ToolCallResult({
);
}
if (toolCall.name === 'invoke_assessment_expert') {
// Assessment expert returns a JSON string; parse it
try {
const raw = toolCall.result;
const data = typeof raw === 'string' ? JSON.parse(raw) : raw;
if (data?.assessments && Array.isArray(data.assessments)) {
return <AssessmentResultCard data={data} />;
}
} catch {
// Not parseable — fall through to null
}
}
return null;
}