feat(小宝预警): 增加预警页面
This commit is contained in:
222
apps/web/app/xiaobao-warning/page.tsx
Normal file
222
apps/web/app/xiaobao-warning/page.tsx
Normal file
@@ -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 (
|
||||
<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 { 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<string | null>(null);
|
||||
const [filter, setFilter] = useState<RiskFilter>('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<string, string>();
|
||||
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 (
|
||||
<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-2 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>
|
||||
)}
|
||||
<span>{filteredRisks.length} / {risks.length} 个版本</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="flex-1 overflow-y-auto p-5">
|
||||
<section className="mb-4 grid gap-3 md:grid-cols-4">
|
||||
<SummaryTile label="可见版本" value={`${risks.length}`} />
|
||||
<SummaryTile label="需关注" value={`${attentionCount}`} />
|
||||
<SummaryTile label="高风险" value={`${highRiskCount}`} />
|
||||
<SummaryTile label="平均置信" value={`${avgConfidence}%`} />
|
||||
</section>
|
||||
|
||||
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="inline-flex rounded-lg border border-[var(--line)] bg-[var(--bg-card)] p-1">
|
||||
<FilterButton active={filter === 'all'} onClick={() => setFilter('all')}>全部</FilterButton>
|
||||
<FilterButton active={filter === 'attention'} onClick={() => setFilter('attention')}>需关注</FilterButton>
|
||||
<FilterButton active={filter === 'high'} onClick={() => setFilter('high')}>高风险</FilterButton>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 text-[11px] text-[var(--ink-muted)]">
|
||||
<Layers className="h-3.5 w-3.5" />
|
||||
截止日期按版本期望发版日期计算
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{filteredRisks.length === 0 ? (
|
||||
<div className="rounded-lg border border-dashed border-[var(--line)] bg-[var(--bg-card)] p-12 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="grid gap-3 xl:grid-cols-2">
|
||||
{filteredRisks.map((risk) => (
|
||||
<XiaobaoWarningCard key={risk.versionId} risk={risk} onClick={() => setSelectedRiskId(risk.versionId)} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
|
||||
{selectedRisk && (
|
||||
<XiaobaoWarningDrawer
|
||||
risk={selectedRisk}
|
||||
onClose={() => setSelectedRiskId(null)}
|
||||
onNavigate={() => router.push(`/versions/${selectedRisk.versionId}`)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SummaryTile({ label, value }: { label: string; value: string }) {
|
||||
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-[20px] font-semibold tabular-nums text-[var(--ink)]">{value}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FilterButton({ active, onClick, children }: { active: boolean; onClick: () => void; children: React.ReactNode }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={`h-7 rounded-md px-3 text-[12px] transition-colors ${
|
||||
active
|
||||
? 'bg-[var(--accent)] text-white'
|
||||
: 'text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)] hover:text-[var(--ink)]'
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user