Files
ftb-project-management/apps/web/components/xiaobao-warning/XiaobaoWarningCard.tsx
2026-06-30 09:35:47 +08:00

97 lines
4.5 KiB
TypeScript

'use client';
import { CalendarClock, ChevronRight, Sparkles, TrendingDown, TrendingUp, TriangleAlert } from 'lucide-react';
import type { XiaobaoRiskLevel, XiaobaoVersionRisk } from '@/lib/xiaobao-risk';
import { formatDateTime } from '@/lib/format';
import { formatRemainingWork } from '@/lib/xiaobao-warning-view';
const RISK_LEVEL_LABEL: Record<XiaobaoRiskLevel, string> = {
on_track: '按期',
attention: '关注',
at_risk: '有风险',
likely_delayed: '大概率延期',
blocked: '阻塞',
};
const RISK_LEVEL_STYLE: Record<XiaobaoRiskLevel, string> = {
on_track: 'bg-emerald-50 text-emerald-700 border-emerald-200',
attention: 'bg-amber-50 text-amber-700 border-amber-200',
at_risk: 'bg-orange-50 text-orange-700 border-orange-200',
likely_delayed: 'bg-red-50 text-red-700 border-red-200',
blocked: 'bg-zinc-900 text-white border-zinc-900',
};
export function XiaobaoWarningCard({ risk, onClick }: { risk: XiaobaoVersionRisk; onClick: () => void }) {
const firstReason = risk.reasons[0];
const TrendIcon = risk.trend.direction === 'down' ? TrendingDown : risk.trend.direction === 'up' ? TrendingUp : TriangleAlert;
return (
<button
type="button"
onClick={onClick}
className="w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] p-4 text-left transition-colors hover:border-[var(--accent)] hover:bg-[var(--bg-subtle)]"
>
<div className="flex items-start gap-3">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-[var(--bg-subtle)] text-[var(--accent)]">
<TriangleAlert className="h-4 w-4" strokeWidth={1.8} />
</div>
<div className="min-w-0 flex-1">
<div className="flex items-start gap-2">
<div className="min-w-0 flex-1">
<h3 className="truncate text-[14px] font-semibold text-[var(--ink)]">{risk.versionName}</h3>
<p className="mt-0.5 truncate text-[11px] text-[var(--ink-muted)]">
{risk.productName ?? '-'} / {risk.projectName ?? '-'}
</p>
</div>
<span className={`shrink-0 rounded-full border px-2 py-0.5 text-[11px] font-medium ${RISK_LEVEL_STYLE[risk.riskLevel]}`}>
{RISK_LEVEL_LABEL[risk.riskLevel]}
</span>
</div>
<div className="mt-3 grid grid-cols-4 gap-2">
<Metric label="风险分" value={String(risk.riskScore)} tone={risk.riskScore >= 70 ? 'danger' : risk.riskScore >= 45 ? 'warn' : 'ok'} />
<Metric label="延期" value={risk.delayDays > 0 ? `${risk.delayDays}` : '0天'} tone={risk.delayDays > 0 ? 'danger' : 'ok'} />
<Metric label="剩余" value={formatRemainingWork(risk.remainingWorkHours)} tone={risk.remainingWorkHours > 0 ? 'warn' : 'ok'} />
<Metric label="置信" value={`${risk.confidence}%`} tone={risk.confidence < 50 ? 'danger' : risk.confidence < 75 ? 'warn' : 'ok'} />
</div>
<div className="mt-3 flex flex-wrap items-center gap-x-3 gap-y-1 text-[11px] text-[var(--ink-muted)]">
<span className="inline-flex items-center gap-1">
<CalendarClock className="h-3.5 w-3.5" />
{formatDateTime(risk.expectedReleaseDate)}
</span>
<span> {formatDateTime(risk.forecastReleaseDate)}</span>
<span className="inline-flex items-center gap-1">
<TrendIcon className="h-3.5 w-3.5" />
{risk.trend.summary}
</span>
</div>
{firstReason && (
<p className="mt-2 text-[12px] leading-5 text-[var(--ink-soft)]">
{firstReason.title}: {firstReason.detail}
</p>
)}
{risk.aiInsight?.summary && (
<p className="mt-2 flex items-start gap-1.5 text-[12px] leading-5 text-[var(--accent)]">
<Sparkles className="mt-0.5 h-3.5 w-3.5 shrink-0" />
<span>{risk.aiInsight.summary}</span>
</p>
)}
</div>
<ChevronRight className="mt-2 h-4 w-4 shrink-0 text-[var(--ink-muted)]" />
</div>
</button>
);
}
function Metric({ label, value, tone }: { label: string; value: string; tone: 'ok' | 'warn' | 'danger' }) {
const toneClass = tone === 'danger' ? 'text-red-600' : tone === 'warn' ? 'text-amber-600' : 'text-emerald-600';
return (
<div className="rounded-lg border border-[var(--line)] bg-[var(--bg)] px-2 py-2">
<p className="text-[10px] text-[var(--ink-muted)]">{label}</p>
<p className={`mt-0.5 text-[13px] font-semibold tabular-nums ${toneClass}`}>{value}</p>
</div>
);
}