feat(小宝预警): 调整风险列表布局和导航徽标
This commit is contained in:
@@ -2,28 +2,35 @@
|
|||||||
|
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
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 { RouteGuard, useHasPermission } from '@/components/auth/Guard';
|
||||||
import { XiaobaoWarningCard } from '@/components/xiaobao-warning/XiaobaoWarningCard';
|
import { XiaobaoWarningCard } from '@/components/xiaobao-warning/XiaobaoWarningCard';
|
||||||
import { XiaobaoWarningDrawer } from '@/components/xiaobao-warning/XiaobaoWarningDrawer';
|
import { useXiaobaoWarningRisks } from '@/hooks/useXiaobaoWarningRisks';
|
||||||
import { useAuthStore } from '@/stores/useAuthStore';
|
import type { XiaobaoRiskLevel, XiaobaoVersionRisk } from '@/lib/xiaobao-risk';
|
||||||
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 { buildRiskInsightSignature, findPreviousRiskSnapshot, getReusableInsight, requestRiskInsight, shouldRequestRiskInsightWithCacheGate } from '@/lib/xiaobao-risk-ai';
|
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 { 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<XiaobaoRiskLevel>(['at_risk', 'likely_delayed', 'blocked']);
|
||||||
|
|
||||||
|
const RISK_LEVEL_LABEL: Record<XiaobaoRiskLevel, string> = {
|
||||||
|
on_track: '按期',
|
||||||
|
attention: '关注',
|
||||||
|
at_risk: '有风险',
|
||||||
|
likely_delayed: '大概率延期',
|
||||||
|
blocked: '阻塞',
|
||||||
|
};
|
||||||
|
|
||||||
|
const RISK_LEVEL_STYLE: Record<XiaobaoRiskLevel, string> = {
|
||||||
|
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() {
|
export default function XiaobaoWarningPage() {
|
||||||
return (
|
return (
|
||||||
@@ -36,95 +43,13 @@ export default function XiaobaoWarningPage() {
|
|||||||
function XiaobaoWarningContent() {
|
function XiaobaoWarningContent() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const canManage = useHasPermission('xiaobao.warning:manage');
|
const canManage = useHasPermission('xiaobao.warning:manage');
|
||||||
const user = useAuthStore((s) => s.user);
|
const { risks, snapshots, insights, riskDataLoaded, saveSnapshot, saveInsight, today } = useXiaobaoWarningRisks({ loadRiskCache: true });
|
||||||
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 [selectedRiskId, setSelectedRiskId] = useState<string | null>(null);
|
const [selectedRiskId, setSelectedRiskId] = useState<string | null>(null);
|
||||||
const [filter, setFilter] = useState<RiskFilter>('all');
|
const [riskFilter, setRiskFilter] = useState<XiaobaoWarningRiskFilter>('all');
|
||||||
const [calculationNow] = useState(() => new Date());
|
const [selectedProductId, setSelectedProductId] = useState('');
|
||||||
|
const [selectedProjectId, setSelectedProjectId] = useState('');
|
||||||
const savedSnapshotKeysRef = useRef(new Set<string>());
|
const savedSnapshotKeysRef = useRef(new Set<string>());
|
||||||
const requestedInsightKeysRef = useRef(new Set<string>());
|
const requestedInsightKeysRef = useRef(new Set<string>());
|
||||||
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<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: 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(() => {
|
useEffect(() => {
|
||||||
risks.forEach((risk) => {
|
risks.forEach((risk) => {
|
||||||
@@ -166,17 +91,47 @@ function XiaobaoWarningContent() {
|
|||||||
return cached ? { ...risk, aiInsight: sanitizeRiskInsight(cached.insight) } : risk;
|
return cached ? { ...risk, aiInsight: sanitizeRiskInsight(cached.insight) } : risk;
|
||||||
}), [insights, risks]);
|
}), [insights, risks]);
|
||||||
|
|
||||||
const filteredRisks = useMemo(() => risksWithInsight.filter((risk) => {
|
const warningRisks = useMemo(() => filterXiaobaoRiskWarnings(risksWithInsight), [risksWithInsight]);
|
||||||
if (filter === 'attention') return risk.riskLevel !== 'on_track';
|
const productOptions = useMemo(() => buildProductOptions(warningRisks), [warningRisks]);
|
||||||
if (filter === 'high') return ['at_risk', 'likely_delayed', 'blocked'].includes(risk.riskLevel);
|
const projectOptions = useMemo(
|
||||||
return true;
|
() => buildProjectOptions(warningRisks, selectedProductId),
|
||||||
}), [filter, risksWithInsight]);
|
[selectedProductId, warningRisks],
|
||||||
|
);
|
||||||
|
|
||||||
const selectedRisk = selectedRiskId ? risksWithInsight.find((risk) => risk.versionId === selectedRiskId) ?? null : null;
|
useEffect(() => {
|
||||||
const highRiskCount = risks.filter((risk) => ['at_risk', 'likely_delayed', 'blocked'].includes(risk.riskLevel)).length;
|
if (selectedProductId && !productOptions.some((option) => option.id === selectedProductId)) {
|
||||||
const attentionCount = risks.filter((risk) => risk.riskLevel !== 'on_track').length;
|
setSelectedProductId('');
|
||||||
const avgConfidence = risks.length > 0
|
setSelectedProjectId('');
|
||||||
? Math.round(risks.reduce((sum, risk) => sum + risk.confidence, 0) / risks.length)
|
}
|
||||||
|
}, [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;
|
: 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -193,71 +148,248 @@ function XiaobaoWarningContent() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 text-[11px] text-[var(--ink-muted)]">
|
<div className="flex items-center gap-3 text-[11px] text-[var(--ink-muted)]">
|
||||||
{canManage && (
|
{canManage && (
|
||||||
<span className="inline-flex items-center gap-1 rounded-full bg-[var(--bg-subtle)] px-2 py-1">
|
<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" />
|
<ShieldCheck className="h-3.5 w-3.5" />
|
||||||
管理权限
|
管理权限
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<span>{filteredRisks.length} / {risks.length} 个版本</span>
|
<div className="rounded-lg border border-[var(--line)] bg-[var(--bg-subtle)] px-3 py-1.5 text-right">
|
||||||
|
<p>平均置信</p>
|
||||||
|
<p className="text-[15px] font-semibold tabular-nums text-[var(--ink)]">{avgConfidence}%</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main className="flex-1 overflow-y-auto p-5">
|
<main className="grid min-h-0 flex-1 grid-cols-1 overflow-hidden lg:grid-cols-[390px_minmax(0,1fr)]">
|
||||||
<section className="mb-4 grid gap-3 md:grid-cols-4">
|
<aside className="flex min-h-0 flex-col border-b border-[var(--line)] bg-[var(--bg-card)] lg:border-b-0 lg:border-r">
|
||||||
<SummaryTile label="可见版本" value={`${risks.length}`} />
|
<div className="shrink-0 border-b border-[var(--line)] p-4">
|
||||||
<SummaryTile label="需关注" value={`${attentionCount}`} />
|
<div className="grid grid-cols-2 gap-2">
|
||||||
<SummaryTile label="高风险" value={`${highRiskCount}`} />
|
<SelectFilter
|
||||||
<SummaryTile label="平均置信" value={`${avgConfidence}%`} />
|
label="产品"
|
||||||
|
value={selectedProductId}
|
||||||
|
allLabel="全部产品"
|
||||||
|
options={productOptions}
|
||||||
|
onChange={(value) => {
|
||||||
|
setSelectedProductId(value);
|
||||||
|
setSelectedProjectId('');
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<SelectFilter
|
||||||
|
label="项目"
|
||||||
|
value={selectedProjectId}
|
||||||
|
allLabel="全部项目"
|
||||||
|
options={projectOptions}
|
||||||
|
onChange={setSelectedProjectId}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 inline-flex rounded-lg border border-[var(--line)] bg-[var(--bg)] p-1">
|
||||||
|
<FilterButton active={riskFilter === 'all'} onClick={() => setRiskFilter('all')}>全部</FilterButton>
|
||||||
|
<FilterButton active={riskFilter === 'attention'} onClick={() => setRiskFilter('attention')}>需关注</FilterButton>
|
||||||
|
<FilterButton active={riskFilter === 'high'} onClick={() => setRiskFilter('high')}>高风险</FilterButton>
|
||||||
|
</div>
|
||||||
|
<p className="mt-3 text-[11px] text-[var(--ink-muted)]">
|
||||||
|
当前显示 {filteredRisks.length} 个风险版本,系统会自动隐藏无风险版本。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="min-h-0 flex-1 overflow-y-auto p-3">
|
||||||
|
{filteredRisks.length === 0 ? (
|
||||||
|
<div className="rounded-lg border border-dashed border-[var(--line)] bg-[var(--bg)] p-8 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="space-y-2">
|
||||||
|
{filteredRisks.map((risk) => (
|
||||||
|
<XiaobaoWarningCard
|
||||||
|
key={risk.versionId}
|
||||||
|
active={risk.versionId === selectedRiskId}
|
||||||
|
risk={risk}
|
||||||
|
onClick={() => setSelectedRiskId(risk.versionId)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<section className="min-h-0 overflow-y-auto bg-[var(--bg)] p-5">
|
||||||
|
{selectedRisk ? (
|
||||||
|
<XiaobaoWarningDetailPanel
|
||||||
|
risk={selectedRisk}
|
||||||
|
onNavigate={() => router.push(`/versions/${selectedRisk.versionId}`)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-full items-center justify-center rounded-lg border border-dashed border-[var(--line)] bg-[var(--bg-card)] text-[13px] text-[var(--ink-muted)]">
|
||||||
|
选择左侧风险版本查看详情
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</section>
|
</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>
|
</main>
|
||||||
|
|
||||||
{selectedRisk && (
|
|
||||||
<XiaobaoWarningDrawer
|
|
||||||
risk={selectedRisk}
|
|
||||||
onClose={() => setSelectedRiskId(null)}
|
|
||||||
onNavigate={() => router.push(`/versions/${selectedRisk.versionId}`)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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 (
|
||||||
|
<div className="mx-auto flex w-full max-w-5xl flex-col gap-5">
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-3 border-b border-[var(--line)] pb-4">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-[12px] text-[var(--ink-muted)]">{risk.productName ?? '-'} / {risk.projectName ?? '-'}</p>
|
||||||
|
<div className="mt-1 flex flex-wrap items-center gap-2">
|
||||||
|
<h2 className="text-[20px] font-semibold text-[var(--ink)]">{risk.versionName}</h2>
|
||||||
|
<RiskLevelBadge level={risk.riskLevel} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onNavigate}
|
||||||
|
className="h-9 rounded-lg bg-[var(--accent)] px-4 text-[13px] font-medium text-white hover:bg-[var(--accent-hover)]"
|
||||||
|
>
|
||||||
|
打开版本详情
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-3 md:grid-cols-4">
|
||||||
|
<DetailMetric label="风险分" value={String(risk.riskScore)} tone={risk.riskScore >= 75 ? 'danger' : risk.riskScore >= 55 ? 'warn' : 'ok'} />
|
||||||
|
<DetailMetric label="置信" value={`${risk.confidence}%`} tone={risk.confidence < 50 ? 'danger' : risk.confidence < 75 ? 'warn' : 'ok'} />
|
||||||
|
<DetailMetric label="剩余工作量" value={formatRemainingWork(risk.remainingWorkHours)} tone={risk.remainingWorkHours > 0 ? 'warn' : 'ok'} />
|
||||||
|
<DetailMetric label="预计延期" value={risk.delayDays > 0 ? `${risk.delayDays}天` : '0天'} tone={risk.delayDays > 0 ? 'danger' : 'ok'} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Section title="小宝建议">
|
||||||
|
{risk.aiInsight ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="rounded-lg border border-[var(--line)] bg-[var(--bg-card)] p-4">
|
||||||
|
<p className="flex items-start gap-2 text-[13px] font-medium leading-6 text-[var(--ink)]">
|
||||||
|
<Sparkles className="mt-1 h-4 w-4 shrink-0 text-[var(--accent)]" />
|
||||||
|
<span>{risk.aiInsight.summary}</span>
|
||||||
|
</p>
|
||||||
|
<p className="mt-3 text-[13px] leading-6 text-[var(--ink-soft)]">{risk.aiInsight.forecast}</p>
|
||||||
|
{risk.aiInsight.recommendedReleaseWindow && (
|
||||||
|
<p className="mt-3 text-[13px] leading-6 text-[var(--accent)]">{risk.aiInsight.recommendedReleaseWindow}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2 md:grid-cols-2">
|
||||||
|
{risk.aiInsight.suggestedActions.map((action) => (
|
||||||
|
<div key={action} className="rounded-lg border border-[var(--line)] bg-[var(--bg-card)] p-3 text-[12px] leading-5 text-[var(--ink-soft)]">
|
||||||
|
{action}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Empty text="规则预警已生成,AI 解读会在触发条件满足时自动补充。" />
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<div className="grid gap-5 xl:grid-cols-2">
|
||||||
|
<Section title="发版预测">
|
||||||
|
<div className="rounded-lg border border-[var(--line)] bg-[var(--bg-card)] p-4 text-[13px] leading-6 text-[var(--ink-soft)]">
|
||||||
|
<p className="flex items-center gap-1.5">
|
||||||
|
<CalendarClock className="h-4 w-4 text-[var(--ink-muted)]" />
|
||||||
|
期望发版:{formatDateTime(risk.expectedReleaseDate)}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1">预测可发:{formatDateTime(risk.forecastReleaseDate)}</p>
|
||||||
|
<p className="mt-1">{risk.trend.summary}</p>
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="风险原因">
|
||||||
|
{risk.reasons.length === 0 ? <Empty text="暂无风险原因" /> : risk.reasons.map((reason) => (
|
||||||
|
<div key={reason.key} className="rounded-lg border border-[var(--line)] bg-[var(--bg-card)] p-3">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<p className="text-[12px] font-medium text-[var(--ink)]">{reason.title}</p>
|
||||||
|
<span className="rounded-full bg-[var(--bg-subtle)] px-2 py-0.5 text-[10px] text-[var(--ink-muted)]">{reason.severity}</span>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-[12px] leading-5 text-[var(--ink-soft)]">{reason.detail}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</Section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-5 xl:grid-cols-2">
|
||||||
|
<Section title="日报与活动证据">
|
||||||
|
{evidenceItems.length === 0 ? <Empty text="暂无近期日报或活动证据" /> : evidenceItems.map((item) => (
|
||||||
|
<div key={item.id} className="rounded-lg border border-[var(--line)] bg-[var(--bg-card)] p-3">
|
||||||
|
<p className="text-[12px] font-medium text-[var(--ink)]">{item.title}</p>
|
||||||
|
<p className="mt-1 text-[12px] leading-5 text-[var(--ink-soft)]">{item.summary}</p>
|
||||||
|
<p className="mt-1 text-[10px] text-[var(--ink-muted)]">{formatDateTime(item.occurredAt)}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="静默风险">
|
||||||
|
{risk.silentRisks.length === 0 ? <Empty text="暂无静默风险" /> : risk.silentRisks.map((item, index) => (
|
||||||
|
<div key={`${item.key}-${item.itemId ?? index}`} className="rounded-lg border border-amber-200 bg-amber-50 p-3">
|
||||||
|
<p className="text-[12px] font-medium text-amber-900">{item.title}</p>
|
||||||
|
<p className="mt-1 text-[12px] leading-5 text-amber-800">{item.detail}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</Section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RiskLevelBadge({ level }: { level: XiaobaoRiskLevel }) {
|
||||||
|
return (
|
||||||
|
<span className={`inline-flex shrink-0 rounded-full border px-2 py-0.5 text-[11px] font-medium ${RISK_LEVEL_STYLE[level]}`}>
|
||||||
|
{RISK_LEVEL_LABEL[level]}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
return (
|
||||||
<div className="rounded-lg border border-[var(--line)] bg-[var(--bg-card)] p-3">
|
<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="text-[11px] text-[var(--ink-muted)]">{label}</p>
|
||||||
<p className="mt-1 text-[20px] font-semibold tabular-nums text-[var(--ink)]">{value}</p>
|
<p className={`mt-1 text-[18px] font-semibold tabular-nums ${toneClass}`}>{value}</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function SelectFilter({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
allLabel,
|
||||||
|
options,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
allLabel: string;
|
||||||
|
options: FilterOption[];
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-1 block text-[11px] font-medium text-[var(--ink-muted)]">{label}</span>
|
||||||
|
<select
|
||||||
|
value={value}
|
||||||
|
onChange={(event) => onChange(event.target.value)}
|
||||||
|
className="h-8 w-full rounded-lg border border-[var(--line)] bg-[var(--bg)] px-2 text-[12px] text-[var(--ink)] outline-none focus:border-[var(--accent)] focus:ring-2 focus:ring-[var(--accent-ring)]"
|
||||||
|
>
|
||||||
|
<option value="">{allLabel}</option>
|
||||||
|
{options.map((option) => (
|
||||||
|
<option key={option.id} value={option.id}>{option.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function FilterButton({ active, onClick, children }: { active: boolean; onClick: () => void; children: React.ReactNode }) {
|
function FilterButton({ active, onClick, children }: { active: boolean; onClick: () => void; children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
@@ -273,3 +405,44 @@ function FilterButton({ active, onClick, children }: { active: boolean; onClick:
|
|||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<section>
|
||||||
|
<h3 className="mb-2 text-[12px] font-semibold text-[var(--ink)]">{title}</h3>
|
||||||
|
<div className="space-y-2">{children}</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Empty({ text }: { text: string }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-dashed border-[var(--line)] bg-[var(--bg-card)] p-3 text-[12px] text-[var(--ink-muted)]">
|
||||||
|
{text}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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<string, string>();
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,14 +3,24 @@
|
|||||||
import { usePathname, useRouter } from 'next/navigation';
|
import { usePathname, useRouter } from 'next/navigation';
|
||||||
import { Inbox, Package, FolderKanban, Tag, Users, LayoutGrid, Search, Lightbulb, Clock, Shield, Settings, Sparkles, TriangleAlert } 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 { useHasPermission } from '@/components/auth/Guard';
|
||||||
|
import { useXiaobaoWarningRisks } from '@/hooks/useXiaobaoWarningRisks';
|
||||||
import { useAuthStore } from '@/stores/useAuthStore';
|
import { useAuthStore } from '@/stores/useAuthStore';
|
||||||
import { useMemberStore } from '@/stores/useMemberStore';
|
import { useMemberStore } from '@/stores/useMemberStore';
|
||||||
|
import { getXiaobaoWarningRiskCount } from '@/lib/xiaobao-warning-view';
|
||||||
|
|
||||||
|
type NavItemConfig = {
|
||||||
|
label: string;
|
||||||
|
path: string;
|
||||||
|
icon: any;
|
||||||
|
permission: string | null;
|
||||||
|
badge?: 'xiaobao-risk';
|
||||||
|
};
|
||||||
|
|
||||||
const NAV_GROUPS = [
|
const NAV_GROUPS = [
|
||||||
{
|
{
|
||||||
label: '工作区',
|
label: '工作区',
|
||||||
items: [
|
items: [
|
||||||
{ label: '小宝预警', path: '/xiaobao-warning', icon: TriangleAlert, permission: 'xiaobao.warning:view' },
|
{ label: '小宝预警', path: '/xiaobao-warning', icon: TriangleAlert, permission: 'xiaobao.warning:view', badge: 'xiaobao-risk' as const },
|
||||||
{ label: '与我相关', path: '/workspace', icon: Inbox, permission: null as string | null },
|
{ label: '与我相关', path: '/workspace', icon: Inbox, permission: null as string | null },
|
||||||
{ label: '产品', path: '/products', icon: Package, permission: 'product:view' },
|
{ label: '产品', path: '/products', icon: Package, permission: 'product:view' },
|
||||||
{ label: '项目', path: '/projects', icon: FolderKanban, permission: 'project:view' },
|
{ label: '项目', path: '/projects', icon: FolderKanban, permission: 'project:view' },
|
||||||
@@ -104,7 +114,7 @@ function UserBlock() {
|
|||||||
|
|
||||||
function NavGroup({ label, items, isActive, onNavigate }: {
|
function NavGroup({ label, items, isActive, onNavigate }: {
|
||||||
label: string;
|
label: string;
|
||||||
items: { label: string; path: string; icon: any; permission: string | null }[];
|
items: NavItemConfig[];
|
||||||
isActive: (p: string) => boolean;
|
isActive: (p: string) => boolean;
|
||||||
onNavigate: (p: string) => void;
|
onNavigate: (p: string) => void;
|
||||||
}) {
|
}) {
|
||||||
@@ -121,7 +131,7 @@ function NavGroup({ label, items, isActive, onNavigate }: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function NavItem({ item, active, onNavigate }: {
|
function NavItem({ item, active, onNavigate }: {
|
||||||
item: { label: string; path: string; icon: any; permission: string | null };
|
item: NavItemConfig;
|
||||||
active: boolean;
|
active: boolean;
|
||||||
onNavigate: (p: string) => void;
|
onNavigate: (p: string) => void;
|
||||||
}) {
|
}) {
|
||||||
@@ -138,7 +148,20 @@ function NavItem({ item, active, onNavigate }: {
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Icon className="h-4 w-4 shrink-0" strokeWidth={1.75} />
|
<Icon className="h-4 w-4 shrink-0" strokeWidth={1.75} />
|
||||||
<span className="truncate">{item.label}</span>
|
<span className="min-w-0 flex-1 truncate">{item.label}</span>
|
||||||
|
{item.badge === 'xiaobao-risk' && <XiaobaoRiskNavBadge />}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function XiaobaoRiskNavBadge() {
|
||||||
|
const { risks } = useXiaobaoWarningRisks();
|
||||||
|
const count = getXiaobaoWarningRiskCount(risks);
|
||||||
|
if (count <= 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className="ml-auto inline-flex h-5 min-w-5 shrink-0 items-center justify-center rounded-full bg-red-600 px-1.5 text-[10px] font-semibold leading-none text-white">
|
||||||
|
{count > 99 ? '99+' : count}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
'use client';
|
'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 type { XiaobaoRiskLevel, XiaobaoVersionRisk } from '@/lib/xiaobao-risk';
|
||||||
import { formatDateTime } from '@/lib/format';
|
|
||||||
import { formatRemainingWork } from '@/lib/xiaobao-warning-view';
|
|
||||||
|
|
||||||
const RISK_LEVEL_LABEL: Record<XiaobaoRiskLevel, string> = {
|
const RISK_LEVEL_LABEL: Record<XiaobaoRiskLevel, string> = {
|
||||||
on_track: '按期',
|
on_track: '按期',
|
||||||
@@ -14,31 +12,40 @@ const RISK_LEVEL_LABEL: Record<XiaobaoRiskLevel, string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const RISK_LEVEL_STYLE: Record<XiaobaoRiskLevel, string> = {
|
const RISK_LEVEL_STYLE: Record<XiaobaoRiskLevel, string> = {
|
||||||
on_track: 'bg-emerald-50 text-emerald-700 border-emerald-200',
|
on_track: 'border-emerald-200 bg-emerald-50 text-emerald-700',
|
||||||
attention: 'bg-amber-50 text-amber-700 border-amber-200',
|
attention: 'border-amber-200 bg-amber-50 text-amber-700',
|
||||||
at_risk: 'bg-orange-50 text-orange-700 border-orange-200',
|
at_risk: 'border-orange-200 bg-orange-50 text-orange-700',
|
||||||
likely_delayed: 'bg-red-50 text-red-700 border-red-200',
|
likely_delayed: 'border-red-200 bg-red-50 text-red-700',
|
||||||
blocked: 'bg-zinc-900 text-white border-zinc-900',
|
blocked: 'border-zinc-900 bg-zinc-900 text-white',
|
||||||
};
|
};
|
||||||
|
|
||||||
export function XiaobaoWarningCard({ risk, onClick }: { risk: XiaobaoVersionRisk; onClick: () => void }) {
|
export function XiaobaoWarningCard({
|
||||||
const firstReason = risk.reasons[0];
|
risk,
|
||||||
const TrendIcon = risk.trend.direction === 'down' ? TrendingDown : risk.trend.direction === 'up' ? TrendingUp : TriangleAlert;
|
active = false,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
risk: XiaobaoVersionRisk;
|
||||||
|
active?: boolean;
|
||||||
|
onClick: () => void;
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
className="w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] p-4 text-left transition-colors hover:border-[var(--accent)] hover:bg-[var(--bg-subtle)]"
|
className={`w-full rounded-lg border p-3 text-left transition-colors ${
|
||||||
|
active
|
||||||
|
? 'border-[var(--accent)] bg-[var(--accent-soft)]'
|
||||||
|
: 'border-[var(--line)] bg-[var(--bg)] hover:border-[var(--accent)] hover:bg-[var(--bg-subtle)]'
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-[var(--bg-subtle)] text-[var(--accent)]">
|
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-[var(--bg-subtle)] text-[var(--accent)]">
|
||||||
<TriangleAlert className="h-4 w-4" strokeWidth={1.8} />
|
<TriangleAlert className="h-4 w-4" strokeWidth={1.8} />
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex items-start gap-2">
|
<div className="flex items-start gap-2">
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<h3 className="truncate text-[14px] font-semibold text-[var(--ink)]">{risk.versionName}</h3>
|
<h3 className="truncate text-[13px] font-semibold text-[var(--ink)]">{risk.versionName}</h3>
|
||||||
<p className="mt-0.5 truncate text-[11px] text-[var(--ink-muted)]">
|
<p className="mt-0.5 truncate text-[11px] text-[var(--ink-muted)]">
|
||||||
{risk.productName ?? '-'} / {risk.projectName ?? '-'}
|
{risk.productName ?? '-'} / {risk.projectName ?? '-'}
|
||||||
</p>
|
</p>
|
||||||
@@ -48,36 +55,10 @@ export function XiaobaoWarningCard({ risk, onClick }: { risk: XiaobaoVersionRisk
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-3 grid grid-cols-4 gap-2">
|
<div className="mt-3 grid grid-cols-2 gap-2">
|
||||||
<Metric label="风险分" value={String(risk.riskScore)} tone={risk.riskScore >= 70 ? 'danger' : risk.riskScore >= 45 ? 'warn' : 'ok'} />
|
<Metric label="风险分" value={String(risk.riskScore)} tone={risk.riskScore >= 75 ? 'danger' : risk.riskScore >= 55 ? 'warn' : 'ok'} />
|
||||||
<Metric label="延期" value={risk.delayDays > 0 ? `${risk.delayDays}天` : '0天'} tone={risk.delayDays > 0 ? 'danger' : 'ok'} />
|
|
||||||
<Metric label="剩余" value={formatRemainingWork(risk.remainingWorkHours)} tone={risk.remainingWorkHours > 0 ? 'warn' : 'ok'} />
|
|
||||||
<Metric label="置信" value={`${risk.confidence}%`} tone={risk.confidence < 50 ? 'danger' : risk.confidence < 75 ? 'warn' : 'ok'} />
|
<Metric label="置信" value={`${risk.confidence}%`} tone={risk.confidence < 50 ? 'danger' : risk.confidence < 75 ? 'warn' : 'ok'} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-3 flex flex-wrap items-center gap-x-3 gap-y-1 text-[11px] text-[var(--ink-muted)]">
|
|
||||||
<span className="inline-flex items-center gap-1">
|
|
||||||
<CalendarClock className="h-3.5 w-3.5" />
|
|
||||||
期望 {formatDateTime(risk.expectedReleaseDate)}
|
|
||||||
</span>
|
|
||||||
<span>预测 {formatDateTime(risk.forecastReleaseDate)}</span>
|
|
||||||
<span className="inline-flex items-center gap-1">
|
|
||||||
<TrendIcon className="h-3.5 w-3.5" />
|
|
||||||
{risk.trend.summary}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{firstReason && (
|
|
||||||
<p className="mt-2 text-[12px] leading-5 text-[var(--ink-soft)]">
|
|
||||||
{firstReason.title}: {firstReason.detail}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{risk.aiInsight?.summary && (
|
|
||||||
<p className="mt-2 flex items-start gap-1.5 text-[12px] leading-5 text-[var(--accent)]">
|
|
||||||
<Sparkles className="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
|
||||||
<span>{risk.aiInsight.summary}</span>
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<ChevronRight className="mt-2 h-4 w-4 shrink-0 text-[var(--ink-muted)]" />
|
<ChevronRight className="mt-2 h-4 w-4 shrink-0 text-[var(--ink-muted)]" />
|
||||||
</div>
|
</div>
|
||||||
@@ -88,7 +69,7 @@ export function XiaobaoWarningCard({ risk, onClick }: { risk: XiaobaoVersionRisk
|
|||||||
function Metric({ label, value, tone }: { label: string; value: string; tone: 'ok' | 'warn' | 'danger' }) {
|
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';
|
const toneClass = tone === 'danger' ? 'text-red-600' : tone === 'warn' ? 'text-amber-600' : 'text-emerald-600';
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg border border-[var(--line)] bg-[var(--bg)] px-2 py-2">
|
<div className="rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-2 py-2">
|
||||||
<p className="text-[10px] text-[var(--ink-muted)]">{label}</p>
|
<p className="text-[10px] text-[var(--ink-muted)]">{label}</p>
|
||||||
<p className={`mt-0.5 text-[13px] font-semibold tabular-nums ${toneClass}`}>{value}</p>
|
<p className={`mt-0.5 text-[13px] font-semibold tabular-nums ${toneClass}`}>{value}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
119
apps/web/hooks/useXiaobaoWarningRisks.ts
Normal file
119
apps/web/hooks/useXiaobaoWarningRisks.ts
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { useHasPermission } from '@/components/auth/Guard';
|
||||||
|
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 } from '@/lib/xiaobao-risk';
|
||||||
|
import { buildVersionDailyEvidence, buildXiaobaoWorkItems } from '@/lib/xiaobao-risk-evidence';
|
||||||
|
import { filterXiaobaoWarningVersions } from '@/lib/xiaobao-warning-view';
|
||||||
|
|
||||||
|
export function useXiaobaoWarningRisks({ loadRiskCache = false }: { loadRiskCache?: boolean } = {}) {
|
||||||
|
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 [calculationNow] = useState(() => new Date());
|
||||||
|
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(() => {
|
||||||
|
if (loadRiskCache) fetchRiskData();
|
||||||
|
}, [fetchRiskData, loadRiskCache]);
|
||||||
|
|
||||||
|
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: 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,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
risks,
|
||||||
|
snapshots,
|
||||||
|
insights,
|
||||||
|
riskDataLoaded,
|
||||||
|
saveSnapshot,
|
||||||
|
saveInsight,
|
||||||
|
today,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -15,7 +15,9 @@ export interface XiaobaoVersionRef {
|
|||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
status?: string;
|
status?: string;
|
||||||
|
productId?: string;
|
||||||
productName?: string;
|
productName?: string;
|
||||||
|
projectId?: string;
|
||||||
projectName?: string;
|
projectName?: string;
|
||||||
expectedReleaseDate?: string | null;
|
expectedReleaseDate?: string | null;
|
||||||
members?: Array<{ id?: string; name: string; role?: string }>;
|
members?: Array<{ id?: string; name: string; role?: string }>;
|
||||||
@@ -50,7 +52,9 @@ export interface XiaobaoRiskSignals {
|
|||||||
export interface XiaobaoVersionRisk {
|
export interface XiaobaoVersionRisk {
|
||||||
versionId: string;
|
versionId: string;
|
||||||
versionName: string;
|
versionName: string;
|
||||||
|
productId?: string;
|
||||||
productName?: string;
|
productName?: string;
|
||||||
|
projectId?: string;
|
||||||
projectName?: string;
|
projectName?: string;
|
||||||
riskScore: number;
|
riskScore: number;
|
||||||
riskLevel: XiaobaoRiskLevel;
|
riskLevel: XiaobaoRiskLevel;
|
||||||
@@ -224,7 +228,9 @@ export function calcXiaobaoVersionRisk(input: CalcXiaobaoVersionRiskInput): Xiao
|
|||||||
return {
|
return {
|
||||||
versionId: input.version.id,
|
versionId: input.version.id,
|
||||||
versionName: input.version.name,
|
versionName: input.version.name,
|
||||||
|
productId: input.version.productId,
|
||||||
productName: input.version.productName,
|
productName: input.version.productName,
|
||||||
|
projectId: input.version.projectId,
|
||||||
projectName: input.version.projectName,
|
projectName: input.version.projectName,
|
||||||
riskScore,
|
riskScore,
|
||||||
riskLevel,
|
riskLevel,
|
||||||
|
|||||||
@@ -1,7 +1,14 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import type { VersionWithContext } from './derive';
|
import type { VersionWithContext } from './derive';
|
||||||
import { filterXiaobaoWarningVersions, formatRemainingWork, sanitizeRiskInsight } from './xiaobao-warning-view';
|
import type { XiaobaoVersionRisk } from './xiaobao-risk';
|
||||||
|
import {
|
||||||
|
filterXiaobaoRiskWarnings,
|
||||||
|
filterXiaobaoWarningVersions,
|
||||||
|
formatRemainingWork,
|
||||||
|
getXiaobaoWarningRiskCount,
|
||||||
|
sanitizeRiskInsight,
|
||||||
|
} from './xiaobao-warning-view';
|
||||||
|
|
||||||
function version(patch: Partial<VersionWithContext> = {}): VersionWithContext {
|
function version(patch: Partial<VersionWithContext> = {}): VersionWithContext {
|
||||||
return {
|
return {
|
||||||
@@ -20,6 +27,49 @@ function version(patch: Partial<VersionWithContext> = {}): VersionWithContext {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function risk(patch: Partial<XiaobaoVersionRisk> = {}): XiaobaoVersionRisk {
|
||||||
|
return {
|
||||||
|
versionId: 'ver-1',
|
||||||
|
versionName: 'V1.0',
|
||||||
|
productId: 'prod-1',
|
||||||
|
productName: 'Product A',
|
||||||
|
projectId: 'proj-1',
|
||||||
|
projectName: 'Project A',
|
||||||
|
riskScore: 68,
|
||||||
|
riskLevel: 'attention',
|
||||||
|
expectedReleaseDate: '2026-07-05',
|
||||||
|
confidence: 80,
|
||||||
|
confidenceLevel: 'high',
|
||||||
|
delayDays: 0,
|
||||||
|
remainingWorkHours: 8,
|
||||||
|
reasons: [],
|
||||||
|
silentRisks: [],
|
||||||
|
signals: {
|
||||||
|
unfinishedCount: 1,
|
||||||
|
openBugCount: 0,
|
||||||
|
criticalBugCount: 0,
|
||||||
|
failedTestCount: 0,
|
||||||
|
blockedCount: 0,
|
||||||
|
silentRiskCount: 0,
|
||||||
|
},
|
||||||
|
currentSnapshot: {
|
||||||
|
versionId: 'ver-1',
|
||||||
|
date: '2026-06-30',
|
||||||
|
riskScore: 68,
|
||||||
|
riskLevel: 'attention',
|
||||||
|
openBugCount: 0,
|
||||||
|
criticalBugCount: 0,
|
||||||
|
failedTestCount: 0,
|
||||||
|
blockedCount: 0,
|
||||||
|
silentRiskCount: 0,
|
||||||
|
confidence: 80,
|
||||||
|
createdAt: '2026-06-30T10:00:00.000Z',
|
||||||
|
},
|
||||||
|
trend: { direction: 'flat', delta: 0, summary: 'Risk is stable.', pattern: 'stable' },
|
||||||
|
...patch,
|
||||||
|
} as XiaobaoVersionRisk;
|
||||||
|
}
|
||||||
|
|
||||||
test('filterXiaobaoWarningVersions lets managers see every unfinished version', () => {
|
test('filterXiaobaoWarningVersions lets managers see every unfinished version', () => {
|
||||||
const result = filterXiaobaoWarningVersions(
|
const result = filterXiaobaoWarningVersions(
|
||||||
[
|
[
|
||||||
@@ -46,6 +96,38 @@ test('filterXiaobaoWarningVersions limits non-managers to versions where they ar
|
|||||||
assert.deepEqual(result.map((item) => item.id), ['ver-1']);
|
assert.deepEqual(result.map((item) => item.id), ['ver-1']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('filterXiaobaoRiskWarnings hides on_track risks and badge count follows visible risks', () => {
|
||||||
|
const risks = [
|
||||||
|
risk({ versionId: 'ver-1', riskLevel: 'on_track', riskScore: 10 }),
|
||||||
|
risk({ versionId: 'ver-2', riskLevel: 'attention', riskScore: 38 }),
|
||||||
|
risk({ versionId: 'ver-3', riskLevel: 'blocked', riskScore: 100 }),
|
||||||
|
];
|
||||||
|
|
||||||
|
assert.deepEqual(filterXiaobaoRiskWarnings(risks).map((item) => item.versionId), ['ver-2', 'ver-3']);
|
||||||
|
assert.equal(getXiaobaoWarningRiskCount(risks), 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('filterXiaobaoRiskWarnings filters by product project and risk tier', () => {
|
||||||
|
const risks = [
|
||||||
|
risk({ versionId: 'ver-1', productId: 'prod-1', projectId: 'proj-1', riskLevel: 'attention', riskScore: 38 }),
|
||||||
|
risk({ versionId: 'ver-2', productId: 'prod-1', projectId: 'proj-2', riskLevel: 'likely_delayed', riskScore: 88 }),
|
||||||
|
risk({ versionId: 'ver-3', productId: 'prod-2', projectId: 'proj-3', riskLevel: 'blocked', riskScore: 100 }),
|
||||||
|
];
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
filterXiaobaoRiskWarnings(risks, { productId: 'prod-1' }).map((item) => item.versionId),
|
||||||
|
['ver-1', 'ver-2'],
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
filterXiaobaoRiskWarnings(risks, { productId: 'prod-1', projectId: 'proj-2' }).map((item) => item.versionId),
|
||||||
|
['ver-2'],
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
filterXiaobaoRiskWarnings(risks, { riskFilter: 'high' }).map((item) => item.versionId),
|
||||||
|
['ver-2', 'ver-3'],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test('formatRemainingWork keeps hours and adds work-day conversion', () => {
|
test('formatRemainingWork keeps hours and adds work-day conversion', () => {
|
||||||
assert.equal(formatRemainingWork(0), '0h / 0天');
|
assert.equal(formatRemainingWork(0), '0h / 0天');
|
||||||
assert.equal(formatRemainingWork(8), '8h / 1天');
|
assert.equal(formatRemainingWork(8), '8h / 1天');
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import type { VersionWithContext } from './derive';
|
import type { VersionWithContext } from './derive';
|
||||||
import type { XiaobaoRiskInsight } from './xiaobao-risk-cache';
|
import type { XiaobaoRiskInsight } from './xiaobao-risk-cache';
|
||||||
|
import type { XiaobaoVersionRisk } from './xiaobao-risk';
|
||||||
import { WORK_HOURS } from './work-hours';
|
import { WORK_HOURS } from './work-hours';
|
||||||
|
|
||||||
const UNFINISHED_VERSION_STATUSES = new Set(['planned', 'developing', 'paused']);
|
const UNFINISHED_VERSION_STATUSES = new Set(['planned', 'developing', 'paused']);
|
||||||
|
const HIGH_RISK_LEVELS = new Set<XiaobaoVersionRisk['riskLevel']>(['at_risk', 'likely_delayed', 'blocked']);
|
||||||
const PAGE_REFRESH_ADVICE_PATTERNS = [
|
const PAGE_REFRESH_ADVICE_PATTERNS = [
|
||||||
/(刷新|重新加载|重载).*(页面|浏览器|小宝|预警)/i,
|
/(刷新|重新加载|重载).*(页面|浏览器|小宝|预警)/i,
|
||||||
/(页面|浏览器|小宝|预警).*(刷新|重新加载|重载)/i,
|
/(页面|浏览器|小宝|预警).*(刷新|重新加载|重载)/i,
|
||||||
@@ -16,6 +18,14 @@ export interface XiaobaoWarningVersionFilter {
|
|||||||
userName?: string;
|
userName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type XiaobaoWarningRiskFilter = 'all' | 'attention' | 'high';
|
||||||
|
|
||||||
|
export interface XiaobaoWarningRiskListFilter {
|
||||||
|
riskFilter?: XiaobaoWarningRiskFilter;
|
||||||
|
productId?: string;
|
||||||
|
projectId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export function filterXiaobaoWarningVersions(
|
export function filterXiaobaoWarningVersions(
|
||||||
versions: VersionWithContext[],
|
versions: VersionWithContext[],
|
||||||
filter: XiaobaoWarningVersionFilter,
|
filter: XiaobaoWarningVersionFilter,
|
||||||
@@ -28,6 +38,24 @@ export function filterXiaobaoWarningVersions(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function filterXiaobaoRiskWarnings(
|
||||||
|
risks: XiaobaoVersionRisk[],
|
||||||
|
filter: XiaobaoWarningRiskListFilter = {},
|
||||||
|
): XiaobaoVersionRisk[] {
|
||||||
|
return risks.filter((risk) => {
|
||||||
|
if (risk.riskLevel === 'on_track') return false;
|
||||||
|
if (filter.productId && risk.productId !== filter.productId) return false;
|
||||||
|
if (filter.projectId && risk.projectId !== filter.projectId) return false;
|
||||||
|
if (filter.riskFilter === 'attention') return true;
|
||||||
|
if (filter.riskFilter === 'high') return HIGH_RISK_LEVELS.has(risk.riskLevel);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getXiaobaoWarningRiskCount(risks: XiaobaoVersionRisk[]): number {
|
||||||
|
return filterXiaobaoRiskWarnings(risks).length;
|
||||||
|
}
|
||||||
|
|
||||||
export function formatRemainingWork(hours: number): string {
|
export function formatRemainingWork(hours: number): string {
|
||||||
const safeHours = Number.isFinite(hours) && hours > 0 ? hours : 0;
|
const safeHours = Number.isFinite(hours) && hours > 0 ? hours : 0;
|
||||||
const days = safeHours / WORK_HOURS.hoursPerDay;
|
const days = safeHours / WORK_HOURS.hoursPerDay;
|
||||||
|
|||||||
Reference in New Issue
Block a user