From cf02ce9266678fef1582152abb239e7fb90722c8 Mon Sep 17 00:00:00 2001 From: Script Generator Date: Mon, 29 Jun 2026 19:51:37 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E5=B0=8F=E5=AE=9D=E9=A2=84=E8=AD=A6):=20?= =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E9=A2=84=E8=AD=A6=E9=A1=B5=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/app/xiaobao-warning/page.tsx | 222 ++++++++++++++++++ apps/web/components/layout/Sidebar.tsx | 3 +- .../xiaobao-warning/XiaobaoWarningCard.tsx | 95 ++++++++ .../xiaobao-warning/XiaobaoWarningDrawer.tsx | 153 ++++++++++++ apps/web/lib/xiaobao-warning-view.test.ts | 47 ++++ apps/web/lib/xiaobao-warning-view.ts | 20 ++ 6 files changed, 539 insertions(+), 1 deletion(-) create mode 100644 apps/web/app/xiaobao-warning/page.tsx create mode 100644 apps/web/components/xiaobao-warning/XiaobaoWarningCard.tsx create mode 100644 apps/web/components/xiaobao-warning/XiaobaoWarningDrawer.tsx create mode 100644 apps/web/lib/xiaobao-warning-view.test.ts create mode 100644 apps/web/lib/xiaobao-warning-view.ts diff --git a/apps/web/app/xiaobao-warning/page.tsx b/apps/web/app/xiaobao-warning/page.tsx new file mode 100644 index 0000000..44f1e04 --- /dev/null +++ b/apps/web/app/xiaobao-warning/page.tsx @@ -0,0 +1,222 @@ +'use client'; + +import { useEffect, useMemo, useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { Layers, ShieldCheck, 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 { flattenVersions } from '@/lib/derive'; +import { calcXiaobaoVersionRisk, type XiaobaoVersionRisk } from '@/lib/xiaobao-risk'; +import { buildVersionDailyEvidence, buildXiaobaoWorkItems } from '@/lib/xiaobao-risk-evidence'; +import { filterXiaobaoWarningVersions } from '@/lib/xiaobao-warning-view'; + +type RiskFilter = 'all' | 'attention' | 'high'; + +export default function XiaobaoWarningPage() { + return ( + + + + ); +} + +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 [selectedRiskId, setSelectedRiskId] = useState(null); + const [filter, setFilter] = useState('all'); + + useEffect(() => { fetchOverview(); }, [fetchOverview]); + useEffect(() => { fetchPlans(); }, [fetchPlans]); + useEffect(() => { fetchRequirements(); }, [fetchRequirements]); + useEffect(() => { fetchTasks(); }, [fetchTasks]); + useEffect(() => { fetchTestCases(); }, [fetchTestCases]); + useEffect(() => { fetchBugs(); }, [fetchBugs]); + useEffect(() => { fetchActivities(); }, [fetchActivities]); + useEffect(() => { fetchWorklogs(); }, [fetchWorklogs]); + + 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: [], + }); + }).sort((a, b) => b.riskScore - a.riskScore), [ + activities, + bugs, + devTasks, + requirements, + testCases, + visibleVersions, + workItems, + worklogs, + ]); + + const filteredRisks = useMemo(() => risks.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, risks]); + + const selectedRisk = selectedRiskId ? risks.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) + : 0; + + return ( +
+
+
+
+ +
+
+

小宝预警

+

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

+
+
+
+ {canManage && ( + + + 管理权限 + + )} + {filteredRisks.length} / {risks.length} 个版本 +
+
+ +
+
+ + + + +
+ +
+
+ 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 }) { + return ( +
+

{label}

+

{value}

+
+ ); +} + +function FilterButton({ active, onClick, children }: { active: boolean; onClick: () => void; children: React.ReactNode }) { + return ( + + ); +} diff --git a/apps/web/components/layout/Sidebar.tsx b/apps/web/components/layout/Sidebar.tsx index 9568337..1185be4 100644 --- a/apps/web/components/layout/Sidebar.tsx +++ b/apps/web/components/layout/Sidebar.tsx @@ -1,7 +1,7 @@ 'use client'; import { usePathname, useRouter } from 'next/navigation'; -import { Inbox, Package, FolderKanban, Tag, Users, LayoutGrid, Search, Lightbulb, Clock, Shield, Settings, Sparkles } from 'lucide-react'; +import { Inbox, Package, FolderKanban, Tag, Users, LayoutGrid, Search, Lightbulb, Clock, Shield, Settings, Sparkles, TriangleAlert } from 'lucide-react'; import { useHasPermission } from '@/components/auth/Guard'; import { useAuthStore } from '@/stores/useAuthStore'; import { useMemberStore } from '@/stores/useMemberStore'; @@ -10,6 +10,7 @@ const NAV_GROUPS = [ { label: '工作区', items: [ + { label: '小宝预警', path: '/xiaobao-warning', icon: TriangleAlert, permission: 'xiaobao.warning:view' }, { label: '与我相关', path: '/workspace', icon: Inbox, permission: null as string | null }, { label: '产品', path: '/products', icon: Package, permission: 'product:view' }, { label: '项目', path: '/projects', icon: FolderKanban, permission: 'project:view' }, diff --git a/apps/web/components/xiaobao-warning/XiaobaoWarningCard.tsx b/apps/web/components/xiaobao-warning/XiaobaoWarningCard.tsx new file mode 100644 index 0000000..5e1608b --- /dev/null +++ b/apps/web/components/xiaobao-warning/XiaobaoWarningCard.tsx @@ -0,0 +1,95 @@ +'use client'; + +import { CalendarClock, ChevronRight, Sparkles, TrendingDown, TrendingUp, TriangleAlert } from 'lucide-react'; +import type { XiaobaoRiskLevel, XiaobaoVersionRisk } from '@/lib/xiaobao-risk'; +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: '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', +}; + +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; + + return ( + + ); +} + +function Metric({ 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}

+
+ ); +} diff --git a/apps/web/components/xiaobao-warning/XiaobaoWarningDrawer.tsx b/apps/web/components/xiaobao-warning/XiaobaoWarningDrawer.tsx new file mode 100644 index 0000000..8258110 --- /dev/null +++ b/apps/web/components/xiaobao-warning/XiaobaoWarningDrawer.tsx @@ -0,0 +1,153 @@ +'use client'; + +import { CalendarClock, Sparkles, X } from 'lucide-react'; +import type { XiaobaoVersionRisk } from '@/lib/xiaobao-risk'; +import { formatDateTime } from '@/lib/format'; + +export function XiaobaoWarningDrawer({ + risk, + onClose, + onNavigate, +}: { + risk: XiaobaoVersionRisk; + onClose: () => void; + onNavigate: () => void; +}) { + const evidence = risk.dailyEvidence; + const evidenceItems = [ + ...(evidence?.todayDeliveries ?? []), + ...(evidence?.todayProgress ?? []), + ...(evidence?.todayRisks ?? []), + ...(evidence?.progressNotes ?? []), + ]; + + return ( +
+
+
+ {risk.productName ?? '-'} / {risk.projectName ?? '-'} / {risk.versionName} +
+
+
+

小宝预警详情

+

规则预警会保留事实证据,AI 解读自动补充

+
+ +
+ +
+
+ + + + 0 ? `${risk.delayDays}天` : '0天'} /> +
+ +
+
+

+ + 期望发版: {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}

+
+ ))} +
+ +
+ {risk.aiInsight ? ( +
+
+

+ + {risk.aiInsight.summary} +

+

{risk.aiInsight.forecast}

+
+ {risk.aiInsight.suggestedActions.map((action) => ( +
+ {action} +
+ ))} +
+ ) : ( + + )} +
+ + +
+
+
+ ); +} + +function Summary({ label, value }: { label: string; value: string }) { + return ( +
+

{label}

+

{value}

+
+ ); +} + +function Section({ title, children }: { title: string; children: React.ReactNode }) { + return ( +
+

{title}

+
{children}
+
+ ); +} + +function Empty({ text }: { text: string }) { + return ( +
+ {text} +
+ ); +} diff --git a/apps/web/lib/xiaobao-warning-view.test.ts b/apps/web/lib/xiaobao-warning-view.test.ts new file mode 100644 index 0000000..c8c7939 --- /dev/null +++ b/apps/web/lib/xiaobao-warning-view.test.ts @@ -0,0 +1,47 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import type { VersionWithContext } from './derive'; +import { filterXiaobaoWarningVersions } from './xiaobao-warning-view'; + +function version(patch: Partial = {}): VersionWithContext { + return { + id: 'ver-1', + name: 'V1.0', + status: 'developing', + releaseDate: null, + createdAt: '2026-06-29T00:00:00.000Z', + productId: 'prod-1', + productName: 'FTB', + projectId: 'proj-1', + projectName: 'Project', + expectedReleaseDate: '2026-07-05T10:00:00.000Z', + members: [{ name: 'Alice', role: 'frontend' }], + ...patch, + }; +} + +test('filterXiaobaoWarningVersions lets managers see every unfinished version', () => { + const result = filterXiaobaoWarningVersions( + [ + version({ id: 'ver-1', status: 'developing', members: [{ name: 'Alice', role: 'frontend' }] }), + version({ id: 'ver-2', status: 'planned', members: [{ name: 'Bob', role: 'testing' }] }), + version({ id: 'ver-3', status: 'released', members: [{ name: 'Alice', role: 'frontend' }] }), + ], + { canManage: true, userName: 'Alice' }, + ); + + assert.deepEqual(result.map((item) => item.id), ['ver-1', 'ver-2']); +}); + +test('filterXiaobaoWarningVersions limits non-managers to versions where they are a member', () => { + const result = filterXiaobaoWarningVersions( + [ + version({ id: 'ver-1', status: 'developing', members: [{ name: 'Alice', role: 'frontend' }] }), + version({ id: 'ver-2', status: 'developing', members: [{ name: 'Bob', role: 'testing' }] }), + version({ id: 'ver-3', status: 'closed', members: [{ name: 'Alice', role: 'frontend' }] }), + ], + { canManage: false, userName: 'Alice' }, + ); + + assert.deepEqual(result.map((item) => item.id), ['ver-1']); +}); diff --git a/apps/web/lib/xiaobao-warning-view.ts b/apps/web/lib/xiaobao-warning-view.ts new file mode 100644 index 0000000..d88ac94 --- /dev/null +++ b/apps/web/lib/xiaobao-warning-view.ts @@ -0,0 +1,20 @@ +import type { VersionWithContext } from './derive'; + +const UNFINISHED_VERSION_STATUSES = new Set(['planned', 'developing', 'paused']); + +export interface XiaobaoWarningVersionFilter { + canManage: boolean; + userName?: string; +} + +export function filterXiaobaoWarningVersions( + versions: VersionWithContext[], + filter: XiaobaoWarningVersionFilter, +): VersionWithContext[] { + return versions.filter((version) => { + if (!UNFINISHED_VERSION_STATUSES.has(version.status)) return false; + if (filter.canManage) return true; + if (!filter.userName) return false; + return (version.members ?? []).some((member) => member.name === filter.userName); + }); +}