diff --git a/apps/web/app/xiaobao-warning/page.tsx b/apps/web/app/xiaobao-warning/page.tsx index d6f4f64..bee87f3 100644 --- a/apps/web/app/xiaobao-warning/page.tsx +++ b/apps/web/app/xiaobao-warning/page.tsx @@ -2,28 +2,35 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import { useRouter } from 'next/navigation'; -import { Layers, ShieldCheck, TriangleAlert } from 'lucide-react'; +import { CalendarClock, ShieldCheck, Sparkles, TriangleAlert } from 'lucide-react'; import { RouteGuard, useHasPermission } from '@/components/auth/Guard'; import { XiaobaoWarningCard } from '@/components/xiaobao-warning/XiaobaoWarningCard'; -import { XiaobaoWarningDrawer } from '@/components/xiaobao-warning/XiaobaoWarningDrawer'; -import { useAuthStore } from '@/stores/useAuthStore'; -import { useBugStore } from '@/stores/useBugStore'; -import { useDevTaskStore } from '@/stores/useDevTaskStore'; -import { useProductStore } from '@/stores/useProductStore'; -import { useRequirementStore } from '@/stores/useRequirementStore'; -import { useTaskWorklogStore } from '@/stores/useTaskWorklogStore'; -import { useTestCaseStore } from '@/stores/useTestCaseStore'; -import { useVersionPlanStore } from '@/stores/useVersionPlanStore'; -import { useWorkActivityStore } from '@/stores/useWorkActivityStore'; -import { useXiaobaoRiskStore } from '@/stores/useXiaobaoRiskStore'; -import { flattenVersions } from '@/lib/derive'; -import { calcXiaobaoVersionRisk, type XiaobaoVersionRisk } from '@/lib/xiaobao-risk'; +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 { buildVersionDailyEvidence, buildXiaobaoWorkItems } from '@/lib/xiaobao-risk-evidence'; import { buildRiskSignature, findLatestDailySnapshot, shouldSaveRiskSnapshot } from '@/lib/xiaobao-risk-trend'; -import { filterXiaobaoWarningVersions, sanitizeRiskInsight } from '@/lib/xiaobao-warning-view'; +import { filterXiaobaoRiskWarnings, formatRemainingWork, sanitizeRiskInsight, type XiaobaoWarningRiskFilter } from '@/lib/xiaobao-warning-view'; +import { formatDateTime } from '@/lib/format'; -type RiskFilter = 'all' | 'attention' | 'high'; +const HIGH_RISK_LEVELS = new Set(['at_risk', 'likely_delayed', 'blocked']); + +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 ( @@ -36,95 +43,13 @@ export default function XiaobaoWarningPage() { function XiaobaoWarningContent() { const router = useRouter(); const canManage = useHasPermission('xiaobao.warning:manage'); - const user = useAuthStore((s) => s.user); - const { overview, fetchOverview } = useProductStore(); - const { plans, fetchPlans } = useVersionPlanStore(); - const { requirements, fetchRequirements } = useRequirementStore(); - const { tasks: devTasks, fetchTasks } = useDevTaskStore(); - const { testCases, fetchTestCases } = useTestCaseStore(); - const { bugs, fetchBugs } = useBugStore(); - const { activities, fetchActivities } = useWorkActivityStore(); - const { worklogs, fetchWorklogs } = useTaskWorklogStore(); - const { snapshots, insights, riskDataLoaded, fetchRiskData, saveSnapshot, saveInsight } = useXiaobaoRiskStore(); + const { risks, snapshots, insights, riskDataLoaded, saveSnapshot, saveInsight, today } = useXiaobaoWarningRisks({ loadRiskCache: true }); const [selectedRiskId, setSelectedRiskId] = useState(null); - const [filter, setFilter] = useState('all'); - const [calculationNow] = useState(() => new Date()); + const [riskFilter, setRiskFilter] = useState('all'); + const [selectedProductId, setSelectedProductId] = useState(''); + const [selectedProjectId, setSelectedProjectId] = useState(''); const savedSnapshotKeysRef = useRef(new Set()); const requestedInsightKeysRef = useRef(new Set()); - const today = useMemo(() => new Date().toISOString().slice(0, 10), []); - - useEffect(() => { fetchOverview(); }, [fetchOverview]); - useEffect(() => { fetchPlans(); }, [fetchPlans]); - useEffect(() => { fetchRequirements(); }, [fetchRequirements]); - useEffect(() => { fetchTasks(); }, [fetchTasks]); - useEffect(() => { fetchTestCases(); }, [fetchTestCases]); - useEffect(() => { fetchBugs(); }, [fetchBugs]); - useEffect(() => { fetchActivities(); }, [fetchActivities]); - useEffect(() => { fetchWorklogs(); }, [fetchWorklogs]); - useEffect(() => { fetchRiskData(); }, [fetchRiskData]); - - const allVersions = useMemo(() => flattenVersions(overview), [overview]); - const visibleVersions = useMemo( - () => filterXiaobaoWarningVersions(allVersions, { canManage, userName: user?.name }), - [allVersions, canManage, user?.name], - ); - - const requirementVersionMap = useMemo(() => { - const map = new Map(); - requirements.forEach((requirement) => { - if (requirement.versionId) map.set(requirement.id, requirement.versionId); - }); - return map; - }, [requirements]); - - const workItems = useMemo(() => buildXiaobaoWorkItems({ - plans, - devTasks, - testCases, - bugs, - versions: allVersions.map((version) => ({ - id: version.id, - name: version.name, - productName: version.productName, - projectName: version.projectName, - })), - requirementVersionMap, - }), [allVersions, bugs, devTasks, plans, requirementVersionMap, testCases]); - - const risks = useMemo(() => visibleVersions.map((version) => { - const versionRequirements = requirements.filter((requirement) => requirement.versionId === version.id); - const requirementIds = new Set(versionRequirements.map((requirement) => requirement.id)); - const dailyEvidence = buildVersionDailyEvidence({ - versionId: version.id, - workItems, - activities, - worklogs, - }); - - return calcXiaobaoVersionRisk({ - version, - devTasks: devTasks.filter((task) => requirementIds.has(task.requirementId)), - testCases: testCases.filter((testCase) => testCase.versionId === version.id), - bugs: bugs.filter((bug) => bug.versionId === version.id), - dailyEvidence, - recentActivityCount: dailyEvidence.recentActivityCount, - lastActivityAt: dailyEvidence.lastActivityAt, - snapshots: snapshots.filter((snapshot) => snapshot.versionId === version.id && snapshot.date < today), - now: calculationNow, - }); - }).sort((a, b) => b.riskScore - a.riskScore), [ - activities, - bugs, - devTasks, - requirements, - testCases, - visibleVersions, - workItems, - worklogs, - snapshots, - today, - calculationNow, - ]); useEffect(() => { risks.forEach((risk) => { @@ -166,17 +91,47 @@ function XiaobaoWarningContent() { return cached ? { ...risk, aiInsight: sanitizeRiskInsight(cached.insight) } : risk; }), [insights, risks]); - const filteredRisks = useMemo(() => risksWithInsight.filter((risk) => { - if (filter === 'attention') return risk.riskLevel !== 'on_track'; - if (filter === 'high') return ['at_risk', 'likely_delayed', 'blocked'].includes(risk.riskLevel); - return true; - }), [filter, risksWithInsight]); + const warningRisks = useMemo(() => filterXiaobaoRiskWarnings(risksWithInsight), [risksWithInsight]); + const productOptions = useMemo(() => buildProductOptions(warningRisks), [warningRisks]); + const projectOptions = useMemo( + () => buildProjectOptions(warningRisks, selectedProductId), + [selectedProductId, warningRisks], + ); - const selectedRisk = selectedRiskId ? risksWithInsight.find((risk) => risk.versionId === selectedRiskId) ?? null : null; - const highRiskCount = risks.filter((risk) => ['at_risk', 'likely_delayed', 'blocked'].includes(risk.riskLevel)).length; - const attentionCount = risks.filter((risk) => risk.riskLevel !== 'on_track').length; - const avgConfidence = risks.length > 0 - ? Math.round(risks.reduce((sum, risk) => sum + risk.confidence, 0) / risks.length) + 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 ( @@ -193,71 +148,248 @@ function XiaobaoWarningContent() {

-
+
{canManage && ( 管理权限 )} - {filteredRisks.length} / {risks.length} 个版本 +
+

平均置信

+

{avgConfidence}%

+
-
-
- - - - +
+ + +
+ {selectedRisk ? ( + router.push(`/versions/${selectedRisk.versionId}`)} + /> + ) : ( +
+ 选择左侧风险版本查看详情 +
+ )}
- -
-
- setFilter('all')}>全部 - setFilter('attention')}>需关注 - setFilter('high')}>高风险 -
-
- - 截止日期按版本期望发版日期计算 -
-
- - {filteredRisks.length === 0 ? ( -
-

暂无小宝预警

-

当前筛选范围内没有需要展示的未结束版本

-
- ) : ( -
- {filteredRisks.map((risk) => ( - setSelectedRiskId(risk.versionId)} /> - ))} -
- )}
- - {selectedRisk && ( - setSelectedRiskId(null)} - onNavigate={() => router.push(`/versions/${selectedRisk.versionId}`)} - /> - )}
); } -function SummaryTile({ label, value }: { label: string; value: string }) { +function XiaobaoWarningDetailPanel({ risk, onNavigate }: { risk: XiaobaoVersionRisk; onNavigate: () => void }) { + const evidence = risk.dailyEvidence; + const evidenceItems = [ + ...(evidence?.todayDeliveries ?? []), + ...(evidence?.todayProgress ?? []), + ...(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.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.trend.summary}

+
+
+ +
+ {risk.reasons.length === 0 ? : risk.reasons.map((reason) => ( +
+
+

{reason.title}

+ {reason.severity} +
+

{reason.detail}

+
+ ))} +
+
+ +
+
+ {evidenceItems.length === 0 ? : evidenceItems.map((item) => ( +
+

{item.title}

+

{item.summary}

+

{formatDateTime(item.occurredAt)}

+
+ ))} +
+ +
+ {risk.silentRisks.length === 0 ? : risk.silentRisks.map((item, index) => ( +
+

{item.title}

+

{item.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}

+

{value}

); } +function SelectFilter({ + label, + value, + allLabel, + options, + onChange, +}: { + label: string; + value: string; + allLabel: string; + options: FilterOption[]; + onChange: (value: string) => void; +}) { + return ( + + ); +} + function FilterButton({ active, onClick, children }: { active: boolean; onClick: () => void; children: React.ReactNode }) { return ( ); } + +function XiaobaoRiskNavBadge() { + const { risks } = useXiaobaoWarningRisks(); + const count = getXiaobaoWarningRiskCount(risks); + if (count <= 0) return null; + + return ( + + {count > 99 ? '99+' : count} + + ); +} diff --git a/apps/web/components/xiaobao-warning/XiaobaoWarningCard.tsx b/apps/web/components/xiaobao-warning/XiaobaoWarningCard.tsx index 0434581..6828c09 100644 --- a/apps/web/components/xiaobao-warning/XiaobaoWarningCard.tsx +++ b/apps/web/components/xiaobao-warning/XiaobaoWarningCard.tsx @@ -1,9 +1,7 @@ 'use client'; -import { CalendarClock, ChevronRight, Sparkles, TrendingDown, TrendingUp, TriangleAlert } from 'lucide-react'; +import { ChevronRight, 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 = { on_track: '按期', @@ -14,31 +12,40 @@ const RISK_LEVEL_LABEL: Record = { }; const RISK_LEVEL_STYLE: Record = { - 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', + 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', }; -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; - +export function XiaobaoWarningCard({ + risk, + active = false, + onClick, +}: { + risk: XiaobaoVersionRisk; + active?: boolean; + onClick: () => void; +}) { return (