feat(小宝预警): 增加预警页面
This commit is contained in:
95
apps/web/components/xiaobao-warning/XiaobaoWarningCard.tsx
Normal file
95
apps/web/components/xiaobao-warning/XiaobaoWarningCard.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
'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';
|
||||
|
||||
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={`${risk.remainingWorkHours}h`} 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>
|
||||
);
|
||||
}
|
||||
153
apps/web/components/xiaobao-warning/XiaobaoWarningDrawer.tsx
Normal file
153
apps/web/components/xiaobao-warning/XiaobaoWarningDrawer.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
'use client';
|
||||
|
||||
import { CalendarClock, Sparkles, X } from 'lucide-react';
|
||||
import type { XiaobaoVersionRisk } from '@/lib/xiaobao-risk';
|
||||
import { formatDateTime } from '@/lib/format';
|
||||
|
||||
export function XiaobaoWarningDrawer({
|
||||
risk,
|
||||
onClose,
|
||||
onNavigate,
|
||||
}: {
|
||||
risk: XiaobaoVersionRisk;
|
||||
onClose: () => void;
|
||||
onNavigate: () => void;
|
||||
}) {
|
||||
const evidence = risk.dailyEvidence;
|
||||
const evidenceItems = [
|
||||
...(evidence?.todayDeliveries ?? []),
|
||||
...(evidence?.todayProgress ?? []),
|
||||
...(evidence?.todayRisks ?? []),
|
||||
...(evidence?.progressNotes ?? []),
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex justify-end bg-black/40">
|
||||
<div className="flex h-full w-full max-w-xl flex-col border-l border-[var(--line)] bg-[var(--bg)] shadow-2xl">
|
||||
<div className="border-b border-[var(--line)] bg-[var(--bg-subtle)] px-5 py-2 text-[11px] text-[var(--ink-muted)]">
|
||||
{risk.productName ?? '-'} / {risk.projectName ?? '-'} / {risk.versionName}
|
||||
</div>
|
||||
<header className="flex h-14 shrink-0 items-center gap-3 border-b border-[var(--line)] bg-[var(--bg-card)] px-5">
|
||||
<div className="min-w-0 flex-1">
|
||||
<h2 className="truncate text-[15px] font-semibold text-[var(--ink)]">小宝预警详情</h2>
|
||||
<p className="text-[11px] text-[var(--ink-muted)]">规则预警会保留事实证据,AI 解读自动补充</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-lg text-[var(--ink-muted)] hover:bg-[var(--bg-subtle)] hover:text-[var(--ink)]"
|
||||
title="关闭"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-5 py-5">
|
||||
<div className="mb-5 grid grid-cols-2 gap-3">
|
||||
<Summary label="风险分" value={`${risk.riskScore}`} />
|
||||
<Summary label="置信度" value={`${risk.confidence}%`} />
|
||||
<Summary label="剩余工作量" value={`${risk.remainingWorkHours}h`} />
|
||||
<Summary label="预计延期" value={risk.delayDays > 0 ? `${risk.delayDays}天` : '0天'} />
|
||||
</div>
|
||||
|
||||
<Section title="发版预测">
|
||||
<div className="rounded-lg border border-[var(--line)] bg-[var(--bg-card)] p-3 text-[12px] leading-5 text-[var(--ink-soft)]">
|
||||
<p className="flex items-center gap-1.5">
|
||||
<CalendarClock className="h-3.5 w-3.5 text-[var(--ink-muted)]" />
|
||||
期望发版: {formatDateTime(risk.expectedReleaseDate)}
|
||||
</p>
|
||||
<p className="mt-1">预测可发: {formatDateTime(risk.forecastReleaseDate)}</p>
|
||||
<p className="mt-1">{risk.trend.summary}</p>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="风险原因">
|
||||
{risk.reasons.length === 0 ? <Empty text="暂无风险原因" /> : risk.reasons.map((reason) => (
|
||||
<div key={reason.key} className="rounded-lg border border-[var(--line)] bg-[var(--bg-card)] p-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="text-[12px] font-medium text-[var(--ink)]">{reason.title}</p>
|
||||
<span className="rounded-full bg-[var(--bg-subtle)] px-2 py-0.5 text-[10px] text-[var(--ink-muted)]">{reason.severity}</span>
|
||||
</div>
|
||||
<p className="mt-1 text-[12px] leading-5 text-[var(--ink-soft)]">{reason.detail}</p>
|
||||
</div>
|
||||
))}
|
||||
</Section>
|
||||
|
||||
<Section title="日报与活动证据">
|
||||
{evidenceItems.length === 0 ? <Empty text="暂无近期日报或活动证据" /> : evidenceItems.map((item) => (
|
||||
<div key={item.id} className="rounded-lg border border-[var(--line)] bg-[var(--bg-card)] p-3">
|
||||
<p className="text-[12px] font-medium text-[var(--ink)]">{item.title}</p>
|
||||
<p className="mt-1 text-[12px] leading-5 text-[var(--ink-soft)]">{item.summary}</p>
|
||||
<p className="mt-1 text-[10px] text-[var(--ink-muted)]">{formatDateTime(item.occurredAt)}</p>
|
||||
</div>
|
||||
))}
|
||||
</Section>
|
||||
|
||||
<Section title="静默风险">
|
||||
{risk.silentRisks.length === 0 ? <Empty text="暂无静默风险" /> : risk.silentRisks.map((item, index) => (
|
||||
<div key={`${item.key}-${item.itemId ?? index}`} className="rounded-lg border border-amber-200 bg-amber-50 p-3">
|
||||
<p className="text-[12px] font-medium text-amber-900">{item.title}</p>
|
||||
<p className="mt-1 text-[12px] leading-5 text-amber-800">{item.detail}</p>
|
||||
</div>
|
||||
))}
|
||||
</Section>
|
||||
|
||||
<Section title="小宝建议">
|
||||
{risk.aiInsight ? (
|
||||
<div className="space-y-2">
|
||||
<div className="rounded-lg border border-[var(--line)] bg-[var(--bg-card)] p-3">
|
||||
<p className="flex items-start gap-1.5 text-[12px] font-medium text-[var(--ink)]">
|
||||
<Sparkles className="mt-0.5 h-3.5 w-3.5 shrink-0 text-[var(--accent)]" />
|
||||
<span>{risk.aiInsight.summary}</span>
|
||||
</p>
|
||||
<p className="mt-2 text-[12px] leading-5 text-[var(--ink-soft)]">{risk.aiInsight.forecast}</p>
|
||||
</div>
|
||||
{risk.aiInsight.suggestedActions.map((action) => (
|
||||
<div key={action} className="rounded-lg border border-[var(--line)] bg-[var(--bg-card)] p-3 text-[12px] leading-5 text-[var(--ink-soft)]">
|
||||
{action}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<Empty text="规则预警已生成,AI 解读会在触发条件满足时自动补充" />
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onNavigate}
|
||||
className="mt-1 flex h-9 w-full items-center justify-center rounded-lg bg-[var(--accent)] text-[13px] font-medium text-white hover:bg-[var(--accent-hover)]"
|
||||
>
|
||||
打开版本详情
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Summary({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="rounded-lg border border-[var(--line)] bg-[var(--bg-card)] p-3">
|
||||
<p className="text-[11px] text-[var(--ink-muted)]">{label}</p>
|
||||
<p className="mt-1 text-[18px] font-semibold tabular-nums text-[var(--ink)]">{value}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<section className="mb-5">
|
||||
<h3 className="mb-2 text-[12px] font-semibold text-[var(--ink)]">{title}</h3>
|
||||
<div className="space-y-2">{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function Empty({ text }: { text: string }) {
|
||||
return (
|
||||
<div className="rounded-lg border border-dashed border-[var(--line)] bg-[var(--bg-card)] p-3 text-[12px] text-[var(--ink-muted)]">
|
||||
{text}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user