Files
ftb-project-management/apps/web/app/xiaobao-warning/page.tsx
Script Generator e5ef9ee28b fix(小宝预警): 稳定风险证据与建议更新
关键改动:

- 优化风险证据的日报工时与静默风险计算

- 稳定 AI 触发签名,避免证据细节变化造成重复请求

- 调整预警建议更新状态与相关测试

Co-Authored-By: Codex GPT-5 <codex@openai.com>
2026-07-01 11:26:16 +08:00

541 lines
22 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, Loader2, 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 { useAuthStore } from '@/stores/useAuthStore';
import { useXiaobaoWarningReadStore } from '@/stores/useXiaobaoWarningReadStore';
import type { XiaobaoRiskLevel, XiaobaoVersionRisk } from '@/lib/xiaobao-risk';
import { buildRiskInsightSignature, findPreviousRiskSnapshot, requestRiskInsight, shouldRequestRiskInsightWithRequestGate } from '@/lib/xiaobao-risk-ai';
import { buildRiskSignature, findLatestDailySnapshot, shouldSaveRiskSnapshot } from '@/lib/xiaobao-risk-trend';
import { attachXiaobaoRiskSuggestion, buildXiaobaoRiskInsightPendingKey } from '@/lib/xiaobao-risk-suggestion';
import {
filterXiaobaoRiskWarnings,
formatRemainingWork,
isXiaobaoWarningUpdated,
sanitizeRiskInsight,
} from '@/lib/xiaobao-warning-view';
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: '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 user = useAuthStore((s) => s.user);
const {
risks,
snapshots,
insights,
pendingInsightKeys,
insightRequestAttempts,
riskDataLoaded,
saveSnapshot,
saveInsight,
beginInsightUpdate,
finishInsightUpdate,
today,
} = useXiaobaoWarningRisks({ loadRiskCache: true });
const { readStates, readStateLoaded, fetchReadStates, markRiskRead } = useXiaobaoWarningReadStore();
const [selectedRiskId, setSelectedRiskId] = useState<string | null>(null);
const [selectedProductId, setSelectedProductId] = useState('');
const [selectedProjectId, setSelectedProjectId] = useState('');
const savedSnapshotKeysRef = useRef(new Set<string>());
const requestedInsightKeysRef = useRef(new Set<string>());
useEffect(() => {
if (user?.id) fetchReadStates();
}, [fetchReadStates, user?.id]);
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);
const key = buildXiaobaoRiskInsightPendingKey(risk);
if (!shouldRequestRiskInsightWithRequestGate({
riskCacheLoaded: riskDataLoaded,
cache: insights,
current: risk,
previous,
lastRequestedAt: insightRequestAttempts[key],
})) return;
const signature = buildRiskInsightSignature(risk);
if (pendingInsightKeys.includes(key)) return;
if (requestedInsightKeysRef.current.has(key)) return;
requestedInsightKeysRef.current.add(key);
beginInsightUpdate(key);
requestRiskInsight(risk).then((response) => {
if (!response.ok) return;
return saveInsight({
versionId: risk.versionId,
riskSignature: signature,
insight: sanitizeRiskInsight(response.result),
generatedAt: new Date().toISOString(),
providerInfo: { model: response.meta.model },
}).catch(() => {});
}).catch(() => {}).finally(() => {
finishInsightUpdate(key);
});
});
}, [
beginInsightUpdate,
finishInsightUpdate,
insights,
insightRequestAttempts,
pendingInsightKeys,
riskDataLoaded,
risks,
saveInsight,
snapshots,
today,
]);
const risksWithInsight = useMemo(() => risks.map((risk) => attachXiaobaoRiskSuggestion(risk, {
insights,
pendingInsightKeys,
pendingInsightAttempts: insightRequestAttempts,
})), [insightRequestAttempts, insights, pendingInsightKeys, 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, {
productId: selectedProductId || undefined,
projectId: selectedProjectId || undefined,
}), [risksWithInsight, selectedProductId, selectedProjectId]);
const updatedRiskIds = useMemo(() => {
if (!user?.id || !readStateLoaded) return new Set<string>();
return new Set(
filteredRisks
.filter((risk) => isXiaobaoWarningUpdated(risk, readStates, user.id))
.map((risk) => risk.versionId),
);
}, [filteredRisks, readStateLoaded, readStates, user?.id]);
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;
const selectRisk = (risk: XiaobaoVersionRisk) => {
setSelectedRiskId(risk.versionId);
if (!user?.id) return;
markRiskRead(user.id, risk).catch(() => {});
};
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>
<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}
updated={updatedRiskIds.has(risk.versionId)}
onClick={() => selectRisk(risk)}
/>
))}
</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 [detailTab, setDetailTab] = useState<'reasons' | 'silent' | 'evidence'>('reasons');
const evidence = risk.dailyEvidence;
const evidenceItems = [
...(evidence?.todayDeliveries ?? []),
...(evidence?.todayProgress ?? []),
...(evidence?.todayCreations ?? []),
...(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.aiInsightUpdating && <InsightUpdatingNotice source={risk.aiInsightSource} />}
{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>
<Section title="发版预测">
<div className="rounded-lg border border-[var(--line)] bg-[var(--bg-card)] p-4 text-[13px] leading-6 text-[var(--ink-soft)]">
<div className="grid gap-3 md:grid-cols-3">
<p className="flex items-center gap-1.5">
<CalendarClock className="h-4 w-4 text-[var(--ink-muted)]" />
{formatDateTime(risk.expectedReleaseDate)}
</p>
<p>{formatDateTime(risk.forecastReleaseDate)}</p>
<p>{risk.delayDays > 0 ? `${risk.delayDays}` : '0天'}</p>
</div>
<p className="mt-3 border-t border-[var(--line)] pt-3">{risk.trend.summary}</p>
</div>
</Section>
<Section title="风险证据">
<div className="rounded-lg border border-[var(--line)] bg-[var(--bg-card)]">
<div className="flex flex-wrap gap-1 border-b border-[var(--line)] bg-[var(--bg-subtle)] p-2">
<TabButton active={detailTab === 'reasons'} onClick={() => setDetailTab('reasons')}>
{risk.reasons.length}
</TabButton>
<TabButton active={detailTab === 'silent'} onClick={() => setDetailTab('silent')}>
{risk.silentRisks.length}
</TabButton>
<TabButton active={detailTab === 'evidence'} onClick={() => setDetailTab('evidence')}>
{evidenceItems.length}
</TabButton>
</div>
<div className="min-h-[220px] p-3">
{detailTab === 'reasons' && (
<div className="space-y-2">
{risk.reasons.length === 0 ? <Empty text="暂无风险原因" /> : risk.reasons.map((reason) => (
<div key={reason.key} className="rounded-lg border border-[var(--line)] bg-[var(--bg)] 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>
))}
</div>
)}
{detailTab === 'silent' && (
<div className="space-y-2">
{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>
))}
</div>
)}
{detailTab === 'evidence' && (
<div className="space-y-2">
{evidenceItems.length === 0 ? <Empty text="暂无近期日报或活动证据" /> : evidenceItems.map((item) => (
<div key={item.id} className="rounded-lg border border-[var(--line)] bg-[var(--bg)] 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>
))}
</div>
)}
</div>
</div>
</Section>
</div>
);
}
function InsightUpdatingNotice({ source }: { source?: XiaobaoVersionRisk['aiInsightSource'] }) {
const detail = source === 'previous_ai'
? '当前先显示上一条建议,生成完成后会自动替换。'
: '当前先显示规则建议,生成完成后会自动替换。';
return (
<div className="flex items-start gap-2 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-[12px] leading-5 text-amber-800">
<Loader2 className="mt-0.5 h-3.5 w-3.5 shrink-0 animate-spin" />
<span>{detail}</span>
</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 TabButton({ active, onClick, children }: { active: boolean; onClick: () => void; children: React.ReactNode }) {
return (
<button
type="button"
onClick={onClick}
className={`h-8 rounded-md px-3 text-[12px] transition-colors ${
active
? 'bg-[var(--bg-card)] text-[var(--accent)] shadow-sm'
: 'text-[var(--ink-soft)] hover:bg-[var(--bg-card)] 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));
}