Files
ftb-project-management/apps/web/app/xiaobao-warning/page.tsx
2026-06-30 10:23:01 +08:00

449 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useRouter } from 'next/navigation';
import { CalendarClock, ShieldCheck, Sparkles, TriangleAlert } from 'lucide-react';
import { RouteGuard, useHasPermission } from '@/components/auth/Guard';
import { XiaobaoWarningCard } from '@/components/xiaobao-warning/XiaobaoWarningCard';
import { useXiaobaoWarningRisks } from '@/hooks/useXiaobaoWarningRisks';
import type { XiaobaoRiskLevel, XiaobaoVersionRisk } from '@/lib/xiaobao-risk';
import { buildRiskInsightSignature, findPreviousRiskSnapshot, getReusableInsight, requestRiskInsight, shouldRequestRiskInsightWithCacheGate } from '@/lib/xiaobao-risk-ai';
import { buildRiskSignature, findLatestDailySnapshot, shouldSaveRiskSnapshot } from '@/lib/xiaobao-risk-trend';
import { filterXiaobaoRiskWarnings, formatRemainingWork, sanitizeRiskInsight, type XiaobaoWarningRiskFilter } from '@/lib/xiaobao-warning-view';
import { formatDateTime } from '@/lib/format';
const HIGH_RISK_LEVELS = new Set<XiaobaoRiskLevel>(['at_risk', 'likely_delayed', 'blocked']);
const RISK_LEVEL_LABEL: Record<XiaobaoRiskLevel, string> = {
on_track: '按期',
attention: '关注',
at_risk: '有风险',
likely_delayed: '大概率延期',
blocked: '阻塞',
};
const RISK_LEVEL_STYLE: Record<XiaobaoRiskLevel, string> = {
on_track: 'border-emerald-200 bg-emerald-50 text-emerald-700',
attention: 'border-amber-200 bg-amber-50 text-amber-700',
at_risk: 'border-orange-200 bg-orange-50 text-orange-700',
likely_delayed: 'border-red-200 bg-red-50 text-red-700',
blocked: 'border-zinc-900 bg-zinc-900 text-white',
};
type FilterOption = { id: string; label: string };
export default function XiaobaoWarningPage() {
return (
<RouteGuard permission="xiaobao.warning:view">
<XiaobaoWarningContent />
</RouteGuard>
);
}
function XiaobaoWarningContent() {
const router = useRouter();
const canManage = useHasPermission('xiaobao.warning:manage');
const { risks, snapshots, insights, riskDataLoaded, saveSnapshot, saveInsight, today } = useXiaobaoWarningRisks({ loadRiskCache: true });
const [selectedRiskId, setSelectedRiskId] = useState<string | null>(null);
const [riskFilter, setRiskFilter] = useState<XiaobaoWarningRiskFilter>('all');
const [selectedProductId, setSelectedProductId] = useState('');
const [selectedProjectId, setSelectedProjectId] = useState('');
const savedSnapshotKeysRef = useRef(new Set<string>());
const requestedInsightKeysRef = useRef(new Set<string>());
useEffect(() => {
risks.forEach((risk) => {
const snapshot = { ...risk.currentSnapshot, createdAt: new Date().toISOString() };
const previousToday = findLatestDailySnapshot(snapshots, snapshot.versionId, snapshot.date);
if (!shouldSaveRiskSnapshot(snapshot, previousToday)) return;
const key = `${snapshot.versionId}:${snapshot.date}:${buildRiskSignature(snapshot)}`;
if (savedSnapshotKeysRef.current.has(key)) return;
savedSnapshotKeysRef.current.add(key);
saveSnapshot(snapshot).catch(() => {
savedSnapshotKeysRef.current.delete(key);
});
});
}, [risks, saveSnapshot, snapshots]);
useEffect(() => {
risks.forEach((risk) => {
const previous = findPreviousRiskSnapshot(snapshots, risk.versionId, today);
if (!shouldRequestRiskInsightWithCacheGate(riskDataLoaded, insights, risk, previous)) return;
const signature = buildRiskInsightSignature(risk);
const key = `${risk.versionId}:${signature}`;
if (requestedInsightKeysRef.current.has(key)) return;
requestedInsightKeysRef.current.add(key);
requestRiskInsight(risk).then((response) => {
if (!response.ok) return;
saveInsight({
versionId: risk.versionId,
riskSignature: signature,
insight: sanitizeRiskInsight(response.result),
generatedAt: new Date().toISOString(),
providerInfo: { model: response.meta.model },
}).catch(() => {});
}).catch(() => {});
});
}, [insights, riskDataLoaded, risks, saveInsight, snapshots, today]);
const risksWithInsight = useMemo(() => risks.map((risk) => {
const cached = getReusableInsight(insights, risk);
return cached ? { ...risk, aiInsight: sanitizeRiskInsight(cached.insight) } : risk;
}), [insights, risks]);
const warningRisks = useMemo(() => filterXiaobaoRiskWarnings(risksWithInsight), [risksWithInsight]);
const productOptions = useMemo(() => buildProductOptions(warningRisks), [warningRisks]);
const projectOptions = useMemo(
() => buildProjectOptions(warningRisks, selectedProductId),
[selectedProductId, warningRisks],
);
useEffect(() => {
if (selectedProductId && !productOptions.some((option) => option.id === selectedProductId)) {
setSelectedProductId('');
setSelectedProjectId('');
}
}, [productOptions, selectedProductId]);
useEffect(() => {
if (selectedProjectId && !projectOptions.some((option) => option.id === selectedProjectId)) {
setSelectedProjectId('');
}
}, [projectOptions, selectedProjectId]);
const filteredRisks = useMemo(() => filterXiaobaoRiskWarnings(risksWithInsight, {
riskFilter,
productId: selectedProductId || undefined,
projectId: selectedProjectId || undefined,
}), [riskFilter, risksWithInsight, selectedProductId, selectedProjectId]);
useEffect(() => {
if (filteredRisks.length === 0) {
setSelectedRiskId(null);
return;
}
if (!selectedRiskId || !filteredRisks.some((risk) => risk.versionId === selectedRiskId)) {
setSelectedRiskId(filteredRisks[0].versionId);
}
}, [filteredRisks, selectedRiskId]);
const selectedRisk = selectedRiskId
? filteredRisks.find((risk) => risk.versionId === selectedRiskId) ?? null
: null;
const avgConfidence = filteredRisks.length > 0
? Math.round(filteredRisks.reduce((sum, risk) => sum + risk.confidence, 0) / filteredRisks.length)
: 0;
return (
<div className="flex h-full flex-col bg-[var(--bg)]">
<header className="flex h-14 shrink-0 items-center justify-between border-b border-[var(--line)] bg-[var(--bg-card)] px-5">
<div className="flex min-w-0 items-center gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-[var(--accent-soft)] text-[var(--accent)]">
<TriangleAlert className="h-4 w-4" />
</div>
<div className="min-w-0">
<h1 className="truncate text-[15px] font-semibold text-[var(--ink)]"></h1>
<p className="truncate text-[11px] text-[var(--ink-muted)]">
{canManage ? '管理视角:全部未结束版本' : '个人视角:我参与的未结束版本'}
</p>
</div>
</div>
<div className="flex items-center gap-3 text-[11px] text-[var(--ink-muted)]">
{canManage && (
<span className="inline-flex items-center gap-1 rounded-full bg-[var(--bg-subtle)] px-2 py-1">
<ShieldCheck className="h-3.5 w-3.5" />
</span>
)}
<div className="rounded-lg border border-[var(--line)] bg-[var(--bg-subtle)] px-3 py-1.5 text-right">
<p></p>
<p className="text-[15px] font-semibold tabular-nums text-[var(--ink)]">{avgConfidence}%</p>
</div>
</div>
</header>
<main className="grid min-h-0 flex-1 grid-cols-1 overflow-hidden lg:grid-cols-[390px_minmax(0,1fr)]">
<aside className="flex min-h-0 flex-col border-b border-[var(--line)] bg-[var(--bg-card)] lg:border-b-0 lg:border-r">
<div className="shrink-0 border-b border-[var(--line)] p-4">
<div className="grid grid-cols-2 gap-2">
<SelectFilter
label="产品"
value={selectedProductId}
allLabel="全部产品"
options={productOptions}
onChange={(value) => {
setSelectedProductId(value);
setSelectedProjectId('');
}}
/>
<SelectFilter
label="项目"
value={selectedProjectId}
allLabel="全部项目"
options={projectOptions}
onChange={setSelectedProjectId}
/>
</div>
<div className="mt-3 inline-flex rounded-lg border border-[var(--line)] bg-[var(--bg)] p-1">
<FilterButton active={riskFilter === 'all'} onClick={() => setRiskFilter('all')}></FilterButton>
<FilterButton active={riskFilter === 'attention'} onClick={() => setRiskFilter('attention')}></FilterButton>
<FilterButton active={riskFilter === 'high'} onClick={() => setRiskFilter('high')}></FilterButton>
</div>
<p className="mt-3 text-[11px] text-[var(--ink-muted)]">
{filteredRisks.length}
</p>
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-3">
{filteredRisks.length === 0 ? (
<div className="rounded-lg border border-dashed border-[var(--line)] bg-[var(--bg)] p-8 text-center">
<p className="text-[13px] font-medium text-[var(--ink-soft)]"></p>
<p className="mt-1 text-[12px] text-[var(--ink-muted)]"></p>
</div>
) : (
<div className="space-y-2">
{filteredRisks.map((risk) => (
<XiaobaoWarningCard
key={risk.versionId}
active={risk.versionId === selectedRiskId}
risk={risk}
onClick={() => setSelectedRiskId(risk.versionId)}
/>
))}
</div>
)}
</div>
</aside>
<section className="min-h-0 overflow-y-auto bg-[var(--bg)] p-5">
{selectedRisk ? (
<XiaobaoWarningDetailPanel
risk={selectedRisk}
onNavigate={() => router.push(`/versions/${selectedRisk.versionId}`)}
/>
) : (
<div className="flex h-full items-center justify-center rounded-lg border border-dashed border-[var(--line)] bg-[var(--bg-card)] text-[13px] text-[var(--ink-muted)]">
</div>
)}
</section>
</main>
</div>
);
}
function XiaobaoWarningDetailPanel({ risk, onNavigate }: { risk: XiaobaoVersionRisk; onNavigate: () => void }) {
const evidence = risk.dailyEvidence;
const evidenceItems = [
...(evidence?.todayDeliveries ?? []),
...(evidence?.todayProgress ?? []),
...(evidence?.todayRisks ?? []),
...(evidence?.progressNotes ?? []),
];
return (
<div className="mx-auto flex w-full max-w-5xl flex-col gap-5">
<div className="flex flex-wrap items-start justify-between gap-3 border-b border-[var(--line)] pb-4">
<div className="min-w-0">
<p className="text-[12px] text-[var(--ink-muted)]">{risk.productName ?? '-'} / {risk.projectName ?? '-'}</p>
<div className="mt-1 flex flex-wrap items-center gap-2">
<h2 className="text-[20px] font-semibold text-[var(--ink)]">{risk.versionName}</h2>
<RiskLevelBadge level={risk.riskLevel} />
</div>
</div>
<button
type="button"
onClick={onNavigate}
className="h-9 rounded-lg bg-[var(--accent)] px-4 text-[13px] font-medium text-white hover:bg-[var(--accent-hover)]"
>
</button>
</div>
<div className="grid gap-3 md:grid-cols-4">
<DetailMetric label="风险分" value={String(risk.riskScore)} tone={risk.riskScore >= 75 ? 'danger' : risk.riskScore >= 55 ? 'warn' : 'ok'} />
<DetailMetric label="置信" value={`${risk.confidence}%`} tone={risk.confidence < 50 ? 'danger' : risk.confidence < 75 ? 'warn' : 'ok'} />
<DetailMetric label="剩余工作量" value={formatRemainingWork(risk.remainingWorkHours)} tone={risk.remainingWorkHours > 0 ? 'warn' : 'ok'} />
<DetailMetric label="预计延期" value={risk.delayDays > 0 ? `${risk.delayDays}` : '0天'} tone={risk.delayDays > 0 ? 'danger' : 'ok'} />
</div>
<Section title="小宝建议">
{risk.aiInsight ? (
<div className="space-y-3">
<div className="rounded-lg border border-[var(--line)] bg-[var(--bg-card)] p-4">
<p className="flex items-start gap-2 text-[13px] font-medium leading-6 text-[var(--ink)]">
<Sparkles className="mt-1 h-4 w-4 shrink-0 text-[var(--accent)]" />
<span>{risk.aiInsight.summary}</span>
</p>
<p className="mt-3 text-[13px] leading-6 text-[var(--ink-soft)]">{risk.aiInsight.forecast}</p>
{risk.aiInsight.recommendedReleaseWindow && (
<p className="mt-3 text-[13px] leading-6 text-[var(--accent)]">{risk.aiInsight.recommendedReleaseWindow}</p>
)}
</div>
<div className="grid gap-2 md:grid-cols-2">
{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>
</div>
) : (
<Empty text="规则预警已生成AI 解读会在触发条件满足时自动补充。" />
)}
</Section>
<div className="grid gap-5 xl:grid-cols-2">
<Section title="发版预测">
<div className="rounded-lg border border-[var(--line)] bg-[var(--bg-card)] p-4 text-[13px] leading-6 text-[var(--ink-soft)]">
<p className="flex items-center gap-1.5">
<CalendarClock className="h-4 w-4 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>
</div>
<div className="grid gap-5 xl:grid-cols-2">
<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>
</div>
</div>
);
}
function RiskLevelBadge({ level }: { level: XiaobaoRiskLevel }) {
return (
<span className={`inline-flex shrink-0 rounded-full border px-2 py-0.5 text-[11px] font-medium ${RISK_LEVEL_STYLE[level]}`}>
{RISK_LEVEL_LABEL[level]}
</span>
);
}
function DetailMetric({ 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-card)] p-3">
<p className="text-[11px] text-[var(--ink-muted)]">{label}</p>
<p className={`mt-1 text-[18px] font-semibold tabular-nums ${toneClass}`}>{value}</p>
</div>
);
}
function SelectFilter({
label,
value,
allLabel,
options,
onChange,
}: {
label: string;
value: string;
allLabel: string;
options: FilterOption[];
onChange: (value: string) => void;
}) {
return (
<label className="block">
<span className="mb-1 block text-[11px] font-medium text-[var(--ink-muted)]">{label}</span>
<select
value={value}
onChange={(event) => onChange(event.target.value)}
className="h-8 w-full rounded-lg border border-[var(--line)] bg-[var(--bg)] px-2 text-[12px] text-[var(--ink)] outline-none focus:border-[var(--accent)] focus:ring-2 focus:ring-[var(--accent-ring)]"
>
<option value="">{allLabel}</option>
{options.map((option) => (
<option key={option.id} value={option.id}>{option.label}</option>
))}
</select>
</label>
);
}
function FilterButton({ active, onClick, children }: { active: boolean; onClick: () => void; children: React.ReactNode }) {
return (
<button
type="button"
onClick={onClick}
className={`h-7 rounded-md px-3 text-[12px] transition-colors ${
active
? 'bg-[var(--accent)] text-white'
: 'text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)] hover:text-[var(--ink)]'
}`}
>
{children}
</button>
);
}
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<section>
<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>
);
}
function buildProductOptions(risks: XiaobaoVersionRisk[]): FilterOption[] {
return uniqueOptions(risks.map((risk) => ({
id: risk.productId ?? '',
label: risk.productName ?? '未关联产品',
})));
}
function buildProjectOptions(risks: XiaobaoVersionRisk[], productId: string): FilterOption[] {
const scoped = productId ? risks.filter((risk) => risk.productId === productId) : risks;
return uniqueOptions(scoped.map((risk) => ({
id: risk.projectId ?? '',
label: risk.projectName ?? '未关联项目',
})));
}
function uniqueOptions(options: FilterOption[]): FilterOption[] {
const map = new Map<string, string>();
options.forEach((option) => {
if (!option.id || map.has(option.id)) return;
map.set(option.id, option.label);
});
return Array.from(map, ([id, label]) => ({ id, label })).sort((a, b) => a.label.localeCompare(b.label));
}