'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 = { on_track: '按期', attention: '关注', at_risk: '有风险', likely_delayed: '大概率延期', blocked: '阻塞', }; const RISK_LEVEL_STYLE: Record = { 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 ( ); } 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(null); const [selectedProductId, setSelectedProductId] = useState(''); const [selectedProjectId, setSelectedProjectId] = useState(''); const savedSnapshotKeysRef = useRef(new Set()); const requestedInsightKeysRef = useRef(new Set()); 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(); 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 (

小宝预警

{canManage ? '管理视角:全部未结束版本' : '个人视角:我参与的未结束版本'}

{canManage && ( 管理权限 )}

平均置信

{avgConfidence}%

{selectedRisk ? ( router.push(`/versions/${selectedRisk.versionId}`)} /> ) : (
选择左侧风险版本查看详情
)}
); } 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 (

{risk.productName ?? '-'} / {risk.projectName ?? '-'}

{risk.versionName}

= 75 ? 'danger' : risk.riskScore >= 55 ? 'warn' : 'ok'} /> 0 ? 'warn' : 'ok'} /> 0 ? `${risk.delayDays}天` : '0天'} tone={risk.delayDays > 0 ? 'danger' : 'ok'} />
{risk.aiInsightUpdating && } {risk.aiInsight ? (

{risk.aiInsight.summary}

{risk.aiInsight.forecast}

{risk.aiInsight.recommendedReleaseWindow && (

{risk.aiInsight.recommendedReleaseWindow}

)}
{risk.aiInsight.suggestedActions.map((action) => (
{action}
))}
) : ( )}

期望发版:{formatDateTime(risk.expectedReleaseDate)}

预测可发:{formatDateTime(risk.forecastReleaseDate)}

预计延期:{risk.delayDays > 0 ? `${risk.delayDays}天` : '0天'}

{risk.trend.summary}

setDetailTab('reasons')}> 风险原因 {risk.reasons.length} setDetailTab('silent')}> 静默风险 {risk.silentRisks.length} setDetailTab('evidence')}> 日报与活动证据 {evidenceItems.length}
{detailTab === 'reasons' && (
{risk.reasons.length === 0 ? : risk.reasons.map((reason) => (

{reason.title}

{reason.severity}

{reason.detail}

))}
)} {detailTab === 'silent' && (
{risk.silentRisks.length === 0 ? : risk.silentRisks.map((item, index) => (

{item.title}

{item.detail}

))}
)} {detailTab === 'evidence' && (
{evidenceItems.length === 0 ? : evidenceItems.map((item) => (

{item.title}

{item.summary}

{formatDateTime(item.occurredAt)}

))}
)}
); } function InsightUpdatingNotice({ source }: { source?: XiaobaoVersionRisk['aiInsightSource'] }) { const detail = source === 'previous_ai' ? '当前先显示上一条建议,生成完成后会自动替换。' : '当前先显示规则建议,生成完成后会自动替换。'; return (
新的小宝建议更新中。{detail}
); } function RiskLevelBadge({ level }: { level: XiaobaoRiskLevel }) { return ( {RISK_LEVEL_LABEL[level]} ); } 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 (

{label}

{value}

); } function SelectFilter({ label, value, allLabel, options, onChange, }: { label: string; value: string; allLabel: string; options: FilterOption[]; onChange: (value: string) => void; }) { return ( ); } function TabButton({ active, onClick, children }: { active: boolean; onClick: () => void; children: React.ReactNode }) { return ( ); } function Section({ title, children }: { title: string; children: React.ReactNode }) { return (

{title}

{children}
); } function Empty({ text }: { text: string }) { return (
{text}
); } 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(); 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)); }