From 85ec416bc2e586ef060ab55609c4a65a40b8d8cf Mon Sep 17 00:00:00 2001
From: Script Generator
Date: Tue, 30 Jun 2026 10:23:01 +0800
Subject: [PATCH] =?UTF-8?q?feat(=E5=B0=8F=E5=AE=9D=E9=A2=84=E8=AD=A6):=20?=
=?UTF-8?q?=E8=B0=83=E6=95=B4=E9=A3=8E=E9=99=A9=E5=88=97=E8=A1=A8=E5=B8=83?=
=?UTF-8?q?=E5=B1=80=E5=92=8C=E5=AF=BC=E8=88=AA=E5=BE=BD=E6=A0=87?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
apps/web/app/xiaobao-warning/page.tsx | 485 ++++++++++++------
apps/web/components/layout/Sidebar.tsx | 31 +-
.../xiaobao-warning/XiaobaoWarningCard.tsx | 69 +--
apps/web/hooks/useXiaobaoWarningRisks.ts | 119 +++++
apps/web/lib/xiaobao-risk.ts | 6 +
apps/web/lib/xiaobao-warning-view.test.ts | 84 ++-
apps/web/lib/xiaobao-warning-view.ts | 28 +
7 files changed, 617 insertions(+), 205 deletions(-)
create mode 100644 apps/web/hooks/useXiaobaoWarningRisks.ts
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 (