关键改动: - 增加需求排序和版本只读状态规则及测试 - 完善版本概览阶段耗时、项目页和工作台展示 - 优化小宝预警请求节流、建议状态和风险过滤 Co-Authored-By: Codex GPT-5 <codex@openai.com>
540 lines
22 KiB
TypeScript
540 lines
22 KiB
TypeScript
'use client';
|
||
|
||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||
import { useRouter } from 'next/navigation';
|
||
import { CalendarClock, Loader2, ShieldCheck, Sparkles, TriangleAlert } from 'lucide-react';
|
||
import { RouteGuard, useHasPermission } from '@/components/auth/Guard';
|
||
import { XiaobaoWarningCard } from '@/components/xiaobao-warning/XiaobaoWarningCard';
|
||
import { useXiaobaoWarningRisks } from '@/hooks/useXiaobaoWarningRisks';
|
||
import { useAuthStore } from '@/stores/useAuthStore';
|
||
import { useXiaobaoWarningReadStore } from '@/stores/useXiaobaoWarningReadStore';
|
||
import type { XiaobaoRiskLevel, XiaobaoVersionRisk } from '@/lib/xiaobao-risk';
|
||
import { buildRiskInsightSignature, findPreviousRiskSnapshot, requestRiskInsight, shouldRequestRiskInsightWithRequestGate } from '@/lib/xiaobao-risk-ai';
|
||
import { buildRiskSignature, findLatestDailySnapshot, shouldSaveRiskSnapshot } from '@/lib/xiaobao-risk-trend';
|
||
import { attachXiaobaoRiskSuggestion, buildXiaobaoRiskInsightPendingKey } from '@/lib/xiaobao-risk-suggestion';
|
||
import {
|
||
filterXiaobaoRiskWarnings,
|
||
formatRemainingWork,
|
||
isXiaobaoWarningUpdated,
|
||
sanitizeRiskInsight,
|
||
} from '@/lib/xiaobao-warning-view';
|
||
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: '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() {
|
||
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 {
|
||
risks,
|
||
snapshots,
|
||
insights,
|
||
pendingInsightKeys,
|
||
insightRequestAttempts,
|
||
riskDataLoaded,
|
||
saveSnapshot,
|
||
saveInsight,
|
||
beginInsightUpdate,
|
||
finishInsightUpdate,
|
||
today,
|
||
} = useXiaobaoWarningRisks({ loadRiskCache: true });
|
||
const { readStates, readStateLoaded, fetchReadStates, markRiskRead } = useXiaobaoWarningReadStore();
|
||
const [selectedRiskId, setSelectedRiskId] = useState<string | null>(null);
|
||
const [selectedProductId, setSelectedProductId] = useState('');
|
||
const [selectedProjectId, setSelectedProjectId] = useState('');
|
||
const savedSnapshotKeysRef = useRef(new Set<string>());
|
||
const requestedInsightKeysRef = useRef(new Set<string>());
|
||
|
||
useEffect(() => {
|
||
if (user?.id) fetchReadStates();
|
||
}, [fetchReadStates, user?.id]);
|
||
|
||
useEffect(() => {
|
||
risks.forEach((risk) => {
|
||
const snapshot = { ...risk.currentSnapshot, createdAt: new Date().toISOString() };
|
||
const previousToday = findLatestDailySnapshot(snapshots, snapshot.versionId, snapshot.date);
|
||
if (!shouldSaveRiskSnapshot(snapshot, previousToday)) return;
|
||
const key = `${snapshot.versionId}:${snapshot.date}:${buildRiskSignature(snapshot)}`;
|
||
if (savedSnapshotKeysRef.current.has(key)) return;
|
||
savedSnapshotKeysRef.current.add(key);
|
||
saveSnapshot(snapshot).catch(() => {
|
||
savedSnapshotKeysRef.current.delete(key);
|
||
});
|
||
});
|
||
}, [risks, saveSnapshot, snapshots]);
|
||
|
||
useEffect(() => {
|
||
risks.forEach((risk) => {
|
||
const previous = findPreviousRiskSnapshot(snapshots, risk.versionId, today);
|
||
const key = buildXiaobaoRiskInsightPendingKey(risk);
|
||
if (!shouldRequestRiskInsightWithRequestGate({
|
||
riskCacheLoaded: riskDataLoaded,
|
||
cache: insights,
|
||
current: risk,
|
||
previous,
|
||
lastRequestedAt: insightRequestAttempts[key],
|
||
})) return;
|
||
const signature = buildRiskInsightSignature(risk);
|
||
if (pendingInsightKeys.includes(key)) return;
|
||
if (requestedInsightKeysRef.current.has(key)) return;
|
||
requestedInsightKeysRef.current.add(key);
|
||
beginInsightUpdate(key);
|
||
requestRiskInsight(risk).then((response) => {
|
||
if (!response.ok) return;
|
||
return saveInsight({
|
||
versionId: risk.versionId,
|
||
riskSignature: signature,
|
||
insight: sanitizeRiskInsight(response.result),
|
||
generatedAt: new Date().toISOString(),
|
||
providerInfo: { model: response.meta.model },
|
||
}).catch(() => {});
|
||
}).catch(() => {}).finally(() => {
|
||
finishInsightUpdate(key);
|
||
});
|
||
});
|
||
}, [
|
||
beginInsightUpdate,
|
||
finishInsightUpdate,
|
||
insights,
|
||
insightRequestAttempts,
|
||
pendingInsightKeys,
|
||
riskDataLoaded,
|
||
risks,
|
||
saveInsight,
|
||
snapshots,
|
||
today,
|
||
]);
|
||
|
||
const risksWithInsight = useMemo(() => risks.map((risk) => attachXiaobaoRiskSuggestion(risk, {
|
||
insights,
|
||
pendingInsightKeys,
|
||
})), [insights, pendingInsightKeys, risks]);
|
||
|
||
const warningRisks = useMemo(() => filterXiaobaoRiskWarnings(risksWithInsight), [risksWithInsight]);
|
||
const productOptions = useMemo(() => buildProductOptions(warningRisks), [warningRisks]);
|
||
const projectOptions = useMemo(
|
||
() => buildProjectOptions(warningRisks, selectedProductId),
|
||
[selectedProductId, warningRisks],
|
||
);
|
||
|
||
useEffect(() => {
|
||
if (selectedProductId && !productOptions.some((option) => option.id === selectedProductId)) {
|
||
setSelectedProductId('');
|
||
setSelectedProjectId('');
|
||
}
|
||
}, [productOptions, selectedProductId]);
|
||
|
||
useEffect(() => {
|
||
if (selectedProjectId && !projectOptions.some((option) => option.id === selectedProjectId)) {
|
||
setSelectedProjectId('');
|
||
}
|
||
}, [projectOptions, selectedProjectId]);
|
||
|
||
const filteredRisks = useMemo(() => filterXiaobaoRiskWarnings(risksWithInsight, {
|
||
productId: selectedProductId || undefined,
|
||
projectId: selectedProjectId || undefined,
|
||
}), [risksWithInsight, selectedProductId, selectedProjectId]);
|
||
|
||
const updatedRiskIds = useMemo(() => {
|
||
if (!user?.id || !readStateLoaded) return new Set<string>();
|
||
return new Set(
|
||
filteredRisks
|
||
.filter((risk) => isXiaobaoWarningUpdated(risk, readStates, user.id))
|
||
.map((risk) => risk.versionId),
|
||
);
|
||
}, [filteredRisks, readStateLoaded, readStates, user?.id]);
|
||
|
||
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;
|
||
|
||
const selectRisk = (risk: XiaobaoVersionRisk) => {
|
||
setSelectedRiskId(risk.versionId);
|
||
if (!user?.id) return;
|
||
markRiskRead(user.id, risk).catch(() => {});
|
||
};
|
||
|
||
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-3 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>
|
||
)}
|
||
<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>
|
||
</header>
|
||
|
||
<main className="grid min-h-0 flex-1 grid-cols-1 overflow-hidden lg:grid-cols-[390px_minmax(0,1fr)]">
|
||
<aside className="flex min-h-0 flex-col border-b border-[var(--line)] bg-[var(--bg-card)] lg:border-b-0 lg:border-r">
|
||
<div className="shrink-0 border-b border-[var(--line)] p-4">
|
||
<div className="grid grid-cols-2 gap-2">
|
||
<SelectFilter
|
||
label="产品"
|
||
value={selectedProductId}
|
||
allLabel="全部产品"
|
||
options={productOptions}
|
||
onChange={(value) => {
|
||
setSelectedProductId(value);
|
||
setSelectedProjectId('');
|
||
}}
|
||
/>
|
||
<SelectFilter
|
||
label="项目"
|
||
value={selectedProjectId}
|
||
allLabel="全部项目"
|
||
options={projectOptions}
|
||
onChange={setSelectedProjectId}
|
||
/>
|
||
</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}
|
||
updated={updatedRiskIds.has(risk.versionId)}
|
||
onClick={() => selectRisk(risk)}
|
||
/>
|
||
))}
|
||
</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>
|
||
</main>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function XiaobaoWarningDetailPanel({ risk, onNavigate }: { risk: XiaobaoVersionRisk; onNavigate: () => void }) {
|
||
const [detailTab, setDetailTab] = useState<'reasons' | 'silent' | 'evidence'>('reasons');
|
||
const evidence = risk.dailyEvidence;
|
||
const evidenceItems = [
|
||
...(evidence?.todayDeliveries ?? []),
|
||
...(evidence?.todayProgress ?? []),
|
||
...(evidence?.todayCreations ?? []),
|
||
...(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.aiInsightUpdating && <InsightUpdatingNotice source={risk.aiInsightSource} />}
|
||
{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>
|
||
|
||
<Section title="发版预测">
|
||
<div className="rounded-lg border border-[var(--line)] bg-[var(--bg-card)] p-4 text-[13px] leading-6 text-[var(--ink-soft)]">
|
||
<div className="grid gap-3 md:grid-cols-3">
|
||
<p className="flex items-center gap-1.5">
|
||
<CalendarClock className="h-4 w-4 text-[var(--ink-muted)]" />
|
||
期望发版:{formatDateTime(risk.expectedReleaseDate)}
|
||
</p>
|
||
<p>预测可发:{formatDateTime(risk.forecastReleaseDate)}</p>
|
||
<p>预计延期:{risk.delayDays > 0 ? `${risk.delayDays}天` : '0天'}</p>
|
||
</div>
|
||
<p className="mt-3 border-t border-[var(--line)] pt-3">{risk.trend.summary}</p>
|
||
</div>
|
||
</Section>
|
||
|
||
<Section title="风险证据">
|
||
<div className="rounded-lg border border-[var(--line)] bg-[var(--bg-card)]">
|
||
<div className="flex flex-wrap gap-1 border-b border-[var(--line)] bg-[var(--bg-subtle)] p-2">
|
||
<TabButton active={detailTab === 'reasons'} onClick={() => setDetailTab('reasons')}>
|
||
风险原因 {risk.reasons.length}
|
||
</TabButton>
|
||
<TabButton active={detailTab === 'silent'} onClick={() => setDetailTab('silent')}>
|
||
静默风险 {risk.silentRisks.length}
|
||
</TabButton>
|
||
<TabButton active={detailTab === 'evidence'} onClick={() => setDetailTab('evidence')}>
|
||
日报与活动证据 {evidenceItems.length}
|
||
</TabButton>
|
||
</div>
|
||
<div className="min-h-[220px] p-3">
|
||
{detailTab === 'reasons' && (
|
||
<div className="space-y-2">
|
||
{risk.reasons.length === 0 ? <Empty text="暂无风险原因" /> : risk.reasons.map((reason) => (
|
||
<div key={reason.key} className="rounded-lg border border-[var(--line)] bg-[var(--bg)] 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>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{detailTab === 'silent' && (
|
||
<div className="space-y-2">
|
||
{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>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{detailTab === 'evidence' && (
|
||
<div className="space-y-2">
|
||
{evidenceItems.length === 0 ? <Empty text="暂无近期日报或活动证据" /> : evidenceItems.map((item) => (
|
||
<div key={item.id} className="rounded-lg border border-[var(--line)] bg-[var(--bg)] 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>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</Section>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function InsightUpdatingNotice({ source }: { source?: XiaobaoVersionRisk['aiInsightSource'] }) {
|
||
const detail = source === 'previous_ai'
|
||
? '当前先显示上一条建议,生成完成后会自动替换。'
|
||
: '当前先显示规则建议,生成完成后会自动替换。';
|
||
return (
|
||
<div className="flex items-start gap-2 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-[12px] leading-5 text-amber-800">
|
||
<Loader2 className="mt-0.5 h-3.5 w-3.5 shrink-0 animate-spin" />
|
||
<span>新的小宝建议更新中。{detail}</span>
|
||
</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 (
|
||
<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 ${toneClass}`}>{value}</p>
|
||
</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 TabButton({ active, onClick, children }: { active: boolean; onClick: () => void; children: React.ReactNode }) {
|
||
return (
|
||
<button
|
||
type="button"
|
||
onClick={onClick}
|
||
className={`h-8 rounded-md px-3 text-[12px] transition-colors ${
|
||
active
|
||
? 'bg-[var(--bg-card)] text-[var(--accent)] shadow-sm'
|
||
: 'text-[var(--ink-soft)] hover:bg-[var(--bg-card)] hover:text-[var(--ink)]'
|
||
}`}
|
||
>
|
||
{children}
|
||
</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));
|
||
}
|