feat(小宝预警): 增加预警页面

This commit is contained in:
Script Generator
2026-06-29 19:51:37 +08:00
parent 94a038494a
commit cf02ce9266
6 changed files with 539 additions and 1 deletions

View 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>
);
}

View File

@@ -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' },

View File

@@ -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<XiaobaoRiskLevel, string> = {
on_track: '按期',
attention: '关注',
at_risk: '有风险',
likely_delayed: '大概率延期',
blocked: '阻塞',
};
const RISK_LEVEL_STYLE: Record<XiaobaoRiskLevel, string> = {
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 (
<button
type="button"
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)]"
>
<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)]">
<TriangleAlert className="h-4 w-4" strokeWidth={1.8} />
</div>
<div className="min-w-0 flex-1">
<div className="flex items-start gap-2">
<div className="min-w-0 flex-1">
<h3 className="truncate text-[14px] font-semibold text-[var(--ink)]">{risk.versionName}</h3>
<p className="mt-0.5 truncate text-[11px] text-[var(--ink-muted)]">
{risk.productName ?? '-'} / {risk.projectName ?? '-'}
</p>
</div>
<span className={`shrink-0 rounded-full border px-2 py-0.5 text-[11px] font-medium ${RISK_LEVEL_STYLE[risk.riskLevel]}`}>
{RISK_LEVEL_LABEL[risk.riskLevel]}
</span>
</div>
<div className="mt-3 grid grid-cols-4 gap-2">
<Metric label="风险分" value={String(risk.riskScore)} tone={risk.riskScore >= 70 ? 'danger' : risk.riskScore >= 45 ? 'warn' : 'ok'} />
<Metric label="延期" value={risk.delayDays > 0 ? `${risk.delayDays}` : '0天'} tone={risk.delayDays > 0 ? 'danger' : 'ok'} />
<Metric label="剩余" value={`${risk.remainingWorkHours}h`} tone={risk.remainingWorkHours > 0 ? 'warn' : 'ok'} />
<Metric label="置信" value={`${risk.confidence}%`} tone={risk.confidence < 50 ? 'danger' : risk.confidence < 75 ? 'warn' : 'ok'} />
</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>
<ChevronRight className="mt-2 h-4 w-4 shrink-0 text-[var(--ink-muted)]" />
</div>
</button>
);
}
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 (
<div className="rounded-lg border border-[var(--line)] bg-[var(--bg)] px-2 py-2">
<p className="text-[10px] text-[var(--ink-muted)]">{label}</p>
<p className={`mt-0.5 text-[13px] font-semibold tabular-nums ${toneClass}`}>{value}</p>
</div>
);
}

View File

@@ -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 (
<div className="fixed inset-0 z-50 flex justify-end bg-black/40">
<div className="flex h-full w-full max-w-xl flex-col border-l border-[var(--line)] bg-[var(--bg)] shadow-2xl">
<div className="border-b border-[var(--line)] bg-[var(--bg-subtle)] px-5 py-2 text-[11px] text-[var(--ink-muted)]">
{risk.productName ?? '-'} / {risk.projectName ?? '-'} / {risk.versionName}
</div>
<header className="flex h-14 shrink-0 items-center gap-3 border-b border-[var(--line)] bg-[var(--bg-card)] px-5">
<div className="min-w-0 flex-1">
<h2 className="truncate text-[15px] font-semibold text-[var(--ink)]"></h2>
<p className="text-[11px] text-[var(--ink-muted)]">AI </p>
</div>
<button
type="button"
onClick={onClose}
className="flex h-8 w-8 items-center justify-center rounded-lg text-[var(--ink-muted)] hover:bg-[var(--bg-subtle)] hover:text-[var(--ink)]"
title="关闭"
>
<X className="h-4 w-4" />
</button>
</header>
<div className="flex-1 overflow-y-auto px-5 py-5">
<div className="mb-5 grid grid-cols-2 gap-3">
<Summary label="风险分" value={`${risk.riskScore}`} />
<Summary label="置信度" value={`${risk.confidence}%`} />
<Summary label="剩余工作量" value={`${risk.remainingWorkHours}h`} />
<Summary label="预计延期" value={risk.delayDays > 0 ? `${risk.delayDays}` : '0天'} />
</div>
<Section title="发版预测">
<div className="rounded-lg border border-[var(--line)] bg-[var(--bg-card)] p-3 text-[12px] leading-5 text-[var(--ink-soft)]">
<p className="flex items-center gap-1.5">
<CalendarClock className="h-3.5 w-3.5 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>
<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>
<Section title="小宝建议">
{risk.aiInsight ? (
<div className="space-y-2">
<div className="rounded-lg border border-[var(--line)] bg-[var(--bg-card)] p-3">
<p className="flex items-start gap-1.5 text-[12px] font-medium text-[var(--ink)]">
<Sparkles className="mt-0.5 h-3.5 w-3.5 shrink-0 text-[var(--accent)]" />
<span>{risk.aiInsight.summary}</span>
</p>
<p className="mt-2 text-[12px] leading-5 text-[var(--ink-soft)]">{risk.aiInsight.forecast}</p>
</div>
{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>
) : (
<Empty text="规则预警已生成AI 解读会在触发条件满足时自动补充" />
)}
</Section>
<button
type="button"
onClick={onNavigate}
className="mt-1 flex h-9 w-full items-center justify-center rounded-lg bg-[var(--accent)] text-[13px] font-medium text-white hover:bg-[var(--accent-hover)]"
>
</button>
</div>
</div>
</div>
);
}
function Summary({ 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-[18px] font-semibold tabular-nums text-[var(--ink)]">{value}</p>
</div>
);
}
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<section className="mb-5">
<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>
);
}

View File

@@ -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> = {}): 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']);
});

View File

@@ -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);
});
}