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:
parent
df754ce8b8
commit
db7964a461
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue