merge: 合并小宝预警到 master

# Conflicts:
#	apps/web/lib/xiaobao-risk-trend.test.ts
#	apps/web/lib/xiaobao-risk.ts
#	docs/decisions.md
This commit is contained in:
Script Generator
2026-06-30 10:45:31 +08:00
27 changed files with 2901 additions and 27 deletions

View File

@@ -1,15 +1,26 @@
'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 { useXiaobaoWarningRisks } from '@/hooks/useXiaobaoWarningRisks';
import { useAuthStore } from '@/stores/useAuthStore';
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 = [
{
label: '工作区',
items: [
{ 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: '/products', icon: Package, permission: 'product:view' },
{ label: '项目', path: '/projects', icon: FolderKanban, permission: 'project:view' },
@@ -103,7 +114,7 @@ function UserBlock() {
function NavGroup({ label, items, isActive, onNavigate }: {
label: string;
items: { label: string; path: string; icon: any; permission: string | null }[];
items: NavItemConfig[];
isActive: (p: string) => boolean;
onNavigate: (p: string) => void;
}) {
@@ -120,7 +131,7 @@ function NavGroup({ label, items, isActive, onNavigate }: {
}
function NavItem({ item, active, onNavigate }: {
item: { label: string; path: string; icon: any; permission: string | null };
item: NavItemConfig;
active: boolean;
onNavigate: (p: string) => void;
}) {
@@ -137,7 +148,20 @@ function NavItem({ item, active, onNavigate }: {
}`}
>
<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>
);
}
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>
);
}

View File

@@ -0,0 +1,77 @@
'use client';
import { ChevronRight, TriangleAlert } from 'lucide-react';
import type { XiaobaoRiskLevel, XiaobaoVersionRisk } from '@/lib/xiaobao-risk';
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',
};
export function XiaobaoWarningCard({
risk,
active = false,
onClick,
}: {
risk: XiaobaoVersionRisk;
active?: boolean;
onClick: () => void;
}) {
return (
<button
type="button"
onClick={onClick}
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 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} />
</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-[13px] 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-2 gap-2">
<Metric label="风险分" value={String(risk.riskScore)} tone={risk.riskScore >= 75 ? 'danger' : risk.riskScore >= 55 ? 'warn' : 'ok'} />
<Metric label="置信" value={`${risk.confidence}%`} tone={risk.confidence < 50 ? 'danger' : risk.confidence < 75 ? 'warn' : 'ok'} />
</div>
</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-card)] 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,154 @@
'use client';
import { CalendarClock, Sparkles, X } from 'lucide-react';
import type { XiaobaoVersionRisk } from '@/lib/xiaobao-risk';
import { formatDateTime } from '@/lib/format';
import { formatRemainingWork } from '@/lib/xiaobao-warning-view';
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={formatRemainingWork(risk.remainingWorkHours)} />
<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>
);
}