From b365ce65a333beead85134ba0bbbeec1ae7df720 Mon Sep 17 00:00:00 2001 From: Script Generator Date: Tue, 30 Jun 2026 14:55:13 +0800 Subject: [PATCH] =?UTF-8?q?fix(=E5=B0=8F=E5=AE=9D=E9=A2=84=E8=AD=A6):=20?= =?UTF-8?q?=E4=BF=9D=E8=AF=81=E5=BB=BA=E8=AE=AE=E4=B8=8D=E4=B8=BA=E7=A9=BA?= =?UTF-8?q?=E5=B9=B6=E6=98=BE=E7=A4=BA=E6=9B=B4=E6=96=B0=E4=B8=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/app/xiaobao-warning/page.tsx | 61 +++++++-- .../xiaobao-warning/XiaobaoWarningDrawer.tsx | 25 +++- apps/web/hooks/useXiaobaoWarningRisks.ts | 15 +- apps/web/lib/xiaobao-risk-suggestion.test.ts | 127 +++++++++++++++++ apps/web/lib/xiaobao-risk-suggestion.ts | 128 ++++++++++++++++++ apps/web/lib/xiaobao-risk.ts | 2 + apps/web/stores/useXiaobaoRiskStore.ts | 15 ++ 7 files changed, 356 insertions(+), 17 deletions(-) create mode 100644 apps/web/lib/xiaobao-risk-suggestion.test.ts create mode 100644 apps/web/lib/xiaobao-risk-suggestion.ts diff --git a/apps/web/app/xiaobao-warning/page.tsx b/apps/web/app/xiaobao-warning/page.tsx index 3c64c37..7092bd4 100644 --- a/apps/web/app/xiaobao-warning/page.tsx +++ b/apps/web/app/xiaobao-warning/page.tsx @@ -2,13 +2,14 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import { useRouter } from 'next/navigation'; -import { CalendarClock, ShieldCheck, Sparkles, TriangleAlert } from 'lucide-react'; +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 type { XiaobaoRiskLevel, XiaobaoVersionRisk } from '@/lib/xiaobao-risk'; -import { buildRiskInsightSignature, findPreviousRiskSnapshot, getReusableInsight, requestRiskInsight, shouldRequestRiskInsightWithCacheGate } from '@/lib/xiaobao-risk-ai'; +import { buildRiskInsightSignature, findPreviousRiskSnapshot, requestRiskInsight, shouldRequestRiskInsightWithCacheGate } 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, sanitizeRiskInsight } from '@/lib/xiaobao-warning-view'; import { formatDateTime } from '@/lib/format'; @@ -41,7 +42,18 @@ export default function XiaobaoWarningPage() { function XiaobaoWarningContent() { const router = useRouter(); const canManage = useHasPermission('xiaobao.warning:manage'); - const { risks, snapshots, insights, riskDataLoaded, saveSnapshot, saveInsight, today } = useXiaobaoWarningRisks({ loadRiskCache: true }); + const { + risks, + snapshots, + insights, + pendingInsightKeys, + riskDataLoaded, + saveSnapshot, + saveInsight, + beginInsightUpdate, + finishInsightUpdate, + today, + } = useXiaobaoWarningRisks({ loadRiskCache: true }); const [selectedRiskId, setSelectedRiskId] = useState(null); const [selectedProductId, setSelectedProductId] = useState(''); const [selectedProjectId, setSelectedProjectId] = useState(''); @@ -67,26 +79,40 @@ function XiaobaoWarningContent() { const previous = findPreviousRiskSnapshot(snapshots, risk.versionId, today); if (!shouldRequestRiskInsightWithCacheGate(riskDataLoaded, insights, risk, previous)) return; const signature = buildRiskInsightSignature(risk); - const key = `${risk.versionId}:${signature}`; + const key = buildXiaobaoRiskInsightPendingKey(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; - saveInsight({ + return saveInsight({ versionId: risk.versionId, riskSignature: signature, insight: sanitizeRiskInsight(response.result), generatedAt: new Date().toISOString(), providerInfo: { model: response.meta.model }, }).catch(() => {}); - }).catch(() => {}); + }).catch(() => {}).finally(() => { + finishInsightUpdate(key); + }); }); - }, [insights, riskDataLoaded, risks, saveInsight, snapshots, today]); + }, [ + beginInsightUpdate, + finishInsightUpdate, + insights, + pendingInsightKeys, + riskDataLoaded, + risks, + saveInsight, + snapshots, + today, + ]); - const risksWithInsight = useMemo(() => risks.map((risk) => { - const cached = getReusableInsight(insights, risk); - return cached ? { ...risk, aiInsight: sanitizeRiskInsight(cached.insight) } : risk; - }), [insights, risks]); + 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]); @@ -261,6 +287,7 @@ function XiaobaoWarningDetailPanel({ risk, onNavigate }: { risk: XiaobaoVersionR
+ {risk.aiInsightUpdating && } {risk.aiInsight ? (
@@ -357,6 +384,18 @@ function XiaobaoWarningDetailPanel({ risk, onNavigate }: { risk: XiaobaoVersionR ); } +function InsightUpdatingNotice({ source }: { source?: XiaobaoVersionRisk['aiInsightSource'] }) { + const detail = source === 'previous_ai' + ? '当前先显示上一条建议,生成完成后会自动替换。' + : '当前先显示规则建议,生成完成后会自动替换。'; + return ( +
+ + 新的小宝建议更新中。{detail} +
+ ); +} + function RiskLevelBadge({ level }: { level: XiaobaoRiskLevel }) { return ( diff --git a/apps/web/components/xiaobao-warning/XiaobaoWarningDrawer.tsx b/apps/web/components/xiaobao-warning/XiaobaoWarningDrawer.tsx index 60cc185..ce05317 100644 --- a/apps/web/components/xiaobao-warning/XiaobaoWarningDrawer.tsx +++ b/apps/web/components/xiaobao-warning/XiaobaoWarningDrawer.tsx @@ -1,9 +1,10 @@ 'use client'; -import { CalendarClock, Sparkles, X } from 'lucide-react'; +import { CalendarClock, Loader2, Sparkles, X } from 'lucide-react'; import type { XiaobaoVersionRisk } from '@/lib/xiaobao-risk'; import { formatDateTime } from '@/lib/format'; import { formatRemainingWork } from '@/lib/xiaobao-warning-view'; +import { buildRuleBasedRiskInsight } from '@/lib/xiaobao-risk-suggestion'; export function XiaobaoWarningDrawer({ risk, @@ -22,6 +23,7 @@ export function XiaobaoWarningDrawer({ ...(evidence?.todayRisks ?? []), ...(evidence?.progressNotes ?? []), ]; + const aiInsight = risk.aiInsight ?? buildRuleBasedRiskInsight(risk); return (
@@ -95,16 +97,17 @@ export function XiaobaoWarningDrawer({
- {risk.aiInsight ? ( + {risk.aiInsightUpdating && } + {aiInsight ? (

- {risk.aiInsight.summary} + {aiInsight.summary}

-

{risk.aiInsight.forecast}

+

{aiInsight.forecast}

- {risk.aiInsight.suggestedActions.map((action) => ( + {aiInsight.suggestedActions.map((action) => (
{action}
@@ -146,6 +149,18 @@ function Section({ title, children }: { title: string; children: React.ReactNode ); } +function InsightUpdatingNotice({ source }: { source?: XiaobaoVersionRisk['aiInsightSource'] }) { + const detail = source === 'previous_ai' + ? '当前先显示上一条建议,生成完成后会自动替换。' + : '当前先显示规则建议,生成完成后会自动替换。'; + return ( +
+ + 新的小宝建议更新中。{detail} +
+ ); +} + function Empty({ text }: { text: string }) { return (
diff --git a/apps/web/hooks/useXiaobaoWarningRisks.ts b/apps/web/hooks/useXiaobaoWarningRisks.ts index 7400f1c..e1c282a 100644 --- a/apps/web/hooks/useXiaobaoWarningRisks.ts +++ b/apps/web/hooks/useXiaobaoWarningRisks.ts @@ -28,7 +28,17 @@ export function useXiaobaoWarningRisks({ loadRiskCache = false }: { loadRiskCach const { bugs, fetchBugs } = useBugStore(); const { activities, fetchActivities } = useWorkActivityStore(); const { worklogs, fetchWorklogs } = useTaskWorklogStore(); - const { snapshots, insights, riskDataLoaded, fetchRiskData, saveSnapshot, saveInsight } = useXiaobaoRiskStore(); + const { + snapshots, + insights, + pendingInsightKeys, + riskDataLoaded, + fetchRiskData, + saveSnapshot, + saveInsight, + beginInsightUpdate, + finishInsightUpdate, + } = useXiaobaoRiskStore(); const [calculationNow] = useState(() => new Date()); const today = useMemo(() => new Date().toISOString().slice(0, 10), []); @@ -111,9 +121,12 @@ export function useXiaobaoWarningRisks({ loadRiskCache = false }: { loadRiskCach risks, snapshots, insights, + pendingInsightKeys, riskDataLoaded, saveSnapshot, saveInsight, + beginInsightUpdate, + finishInsightUpdate, today, }; } diff --git a/apps/web/lib/xiaobao-risk-suggestion.test.ts b/apps/web/lib/xiaobao-risk-suggestion.test.ts new file mode 100644 index 0000000..f5b3fbe --- /dev/null +++ b/apps/web/lib/xiaobao-risk-suggestion.test.ts @@ -0,0 +1,127 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { buildRiskInsightSignature } from './xiaobao-risk-ai'; +import type { XiaobaoRiskInsightCacheItem } from './xiaobao-risk-cache'; +import type { XiaobaoVersionRisk } from './xiaobao-risk'; +import { attachXiaobaoRiskSuggestion } from './xiaobao-risk-suggestion'; + +function risk(patch: Partial = {}): 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', + forecastReleaseDate: '2026-07-05T10:00:00.000Z', + delayDays: 0, + remainingWorkHours: 8, + reasons: [ + { + key: 'remaining_work', + title: '剩余工作量', + detail: '预计还剩 8h 工作量。', + severity: 'warning', + }, + ], + silentRisks: [], + signals: { + unfinishedCount: 1, + openBugCount: 0, + criticalBugCount: 0, + failedTestCount: 0, + blockedCount: 0, + silentRiskCount: 0, + daysToExpectedRelease: 2, + }, + currentSnapshot: { + versionId: 'ver-1', + date: '2026-06-30', + riskScore: 68, + riskLevel: 'attention', + forecastReleaseDate: '2026-07-05T10:00:00.000Z', + 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: '风险保持稳定。', pattern: 'stable' }, + ...patch, + }; +} + +function insight(itemRisk: XiaobaoVersionRisk, patch: Partial = {}): XiaobaoRiskInsightCacheItem { + return { + versionId: itemRisk.versionId, + riskSignature: buildRiskInsightSignature(itemRisk), + insight: { + summary: '旧的小宝建议', + why: ['仍有工作未完成'], + forecast: '当前建议继续观察。', + suggestedActions: ['确认剩余任务负责人和完成时间。'], + ownerHints: [], + generatedAt: '2026-06-30T09:00:00.000Z', + }, + generatedAt: '2026-06-30T09:00:00.000Z', + ...patch, + }; +} + +test('attachXiaobaoRiskSuggestion provides a rule suggestion when AI cache is empty', () => { + const result = attachXiaobaoRiskSuggestion(risk(), { + insights: [], + pendingInsightKeys: [], + }); + + assert.equal(result.aiInsightSource, 'rule'); + assert.equal(result.aiInsightUpdating, false); + assert.ok(result.aiInsight?.summary); + assert.ok(result.aiInsight?.forecast); + assert.ok((result.aiInsight?.suggestedActions.length ?? 0) > 0); +}); + +test('attachXiaobaoRiskSuggestion keeps previous AI suggestion while a new one is updating', () => { + const previous = risk({ riskScore: 52, riskLevel: 'attention' }); + const current = risk({ riskScore: 82, riskLevel: 'at_risk', delayDays: 1 }); + const pendingKey = `${current.versionId}:${buildRiskInsightSignature(current)}`; + + const result = attachXiaobaoRiskSuggestion(current, { + insights: [insight(previous)], + pendingInsightKeys: [pendingKey], + }); + + assert.equal(result.aiInsightSource, 'previous_ai'); + assert.equal(result.aiInsightUpdating, true); + assert.equal(result.aiInsight?.summary, '旧的小宝建议'); +}); + +test('attachXiaobaoRiskSuggestion uses the current AI suggestion when the signature matches', () => { + const current = risk({ riskScore: 82, riskLevel: 'at_risk', delayDays: 1 }); + + const result = attachXiaobaoRiskSuggestion(current, { + insights: [insight(current, { + insight: { + summary: '新的小宝建议', + why: ['风险已升高'], + forecast: '预计延期 1 天。', + suggestedActions: ['先处理阻塞和失败用例。'], + ownerHints: [], + generatedAt: '2026-06-30T10:00:00.000Z', + }, + generatedAt: '2026-06-30T10:00:00.000Z', + })], + pendingInsightKeys: [], + }); + + assert.equal(result.aiInsightSource, 'current_ai'); + assert.equal(result.aiInsightUpdating, false); + assert.equal(result.aiInsight?.summary, '新的小宝建议'); +}); diff --git a/apps/web/lib/xiaobao-risk-suggestion.ts b/apps/web/lib/xiaobao-risk-suggestion.ts new file mode 100644 index 0000000..fbb72b3 --- /dev/null +++ b/apps/web/lib/xiaobao-risk-suggestion.ts @@ -0,0 +1,128 @@ +import { buildRiskInsightSignature, findLatestRiskInsightForVersion, getReusableInsight } from './xiaobao-risk-ai'; +import type { XiaobaoRiskInsight, XiaobaoRiskInsightCacheItem } from './xiaobao-risk-cache'; +import type { RiskReason, XiaobaoVersionRisk } from './xiaobao-risk'; +import { sanitizeRiskInsight } from './xiaobao-warning-view'; + +interface AttachXiaobaoRiskSuggestionInput { + insights: XiaobaoRiskInsightCacheItem[]; + pendingInsightKeys: string[]; + now?: Date; +} + +const RISK_LEVEL_LABEL: Record = { + on_track: '按期', + attention: '需关注', + at_risk: '有风险', + likely_delayed: '大概率延期', + blocked: '阻塞', +}; + +export function buildXiaobaoRiskInsightPendingKey(risk: XiaobaoVersionRisk): string { + return `${risk.versionId}:${buildRiskInsightSignature(risk)}`; +} + +export function attachXiaobaoRiskSuggestion( + risk: XiaobaoVersionRisk, + input: AttachXiaobaoRiskSuggestionInput, +): XiaobaoVersionRisk { + const pendingKey = buildXiaobaoRiskInsightPendingKey(risk); + const isUpdating = input.pendingInsightKeys.includes(pendingKey); + const reusable = getReusableInsight(input.insights, risk); + if (reusable) { + return { + ...risk, + aiInsight: sanitizeRiskInsight(reusable.insight), + aiInsightSource: 'current_ai', + aiInsightUpdating: false, + }; + } + + const latest = findLatestRiskInsightForVersion(input.insights, risk.versionId); + if (latest) { + return { + ...risk, + aiInsight: sanitizeRiskInsight(latest.insight), + aiInsightSource: 'previous_ai', + aiInsightUpdating: isUpdating, + }; + } + + return { + ...risk, + aiInsight: buildRuleBasedRiskInsight(risk, input.now), + aiInsightSource: 'rule', + aiInsightUpdating: isUpdating, + }; +} + +export function buildRuleBasedRiskInsight(risk: XiaobaoVersionRisk, now = new Date()): XiaobaoRiskInsight { + const firstReason = risk.reasons[0]; + const why = risk.reasons.length > 0 + ? risk.reasons.slice(0, 4).map((reason) => reason.detail || reason.title) + : [risk.trend.summary || '当前版本存在规则层识别到的发布风险。']; + const forecastDate = formatDate(risk.forecastReleaseDate); + const expectedDate = formatDate(risk.expectedReleaseDate); + const forecast = risk.delayDays > 0 + ? `按当前剩余工作量和风险信号,预计较期望发版日期${expectedDate ? `(${expectedDate})` : ''}延期约 ${risk.delayDays} 天,可发窗口约为 ${forecastDate || '重新评估后确认'}。` + : `按当前规则预测,版本仍有风险需要收敛;预计可发窗口为 ${forecastDate || expectedDate || '待补齐计划后确认'}。`; + + return sanitizeRiskInsight({ + summary: `${risk.versionName} 当前风险分 ${risk.riskScore},状态为${RISK_LEVEL_LABEL[risk.riskLevel]}。${firstReason?.title ? `主要关注:${firstReason.title}。` : ''}`, + why, + forecast, + recommendedReleaseWindow: buildReleaseWindow(risk), + suggestedActions: buildRuleActions(risk), + ownerHints: buildOwnerHints(risk), + generatedAt: now.toISOString(), + }); +} + +function buildReleaseWindow(risk: XiaobaoVersionRisk): string | undefined { + const forecastDate = formatDate(risk.forecastReleaseDate); + if (!forecastDate) return undefined; + if (risk.delayDays > 0) return `建议优先以 ${forecastDate} 作为新的候选发版窗口,并在关键风险关闭后再确认。`; + return `建议继续以 ${forecastDate} 附近作为候选发版窗口,但需要先完成当前风险项收敛。`; +} + +function buildRuleActions(risk: XiaobaoVersionRisk): string[] { + const actions = risk.reasons.flatMap(reasonToActions); + const unique = Array.from(new Set(actions)); + if (unique.length > 0) return unique.slice(0, 4); + return ['确认剩余任务、测试失败、Bug 和阻塞项的负责人及预计完成时间。']; +} + +function reasonToActions(reason: RiskReason): string[] { + switch (reason.key) { + case 'critical_bug': + return ['优先收敛 P1 或致命 Bug,明确修复人、复测人和关闭时间。']; + case 'failed_test': + return ['安排测试负责人复测失败用例,并同步失败原因和修复进展。']; + case 'blocked_work': + return ['先解除阻塞项,无法当天解除的需要明确替代方案或延期影响。']; + case 'forecast_delay': + case 'remaining_work': + return ['按剩余工作量重排今天到发版日前的完成顺序,先处理影响发版的主链路事项。']; + case 'silent_risk': + return ['要求未更新的负责人补充今日日报或活动证据,避免静默风险继续扩大。']; + default: + return reason.detail ? [`跟进:${reason.detail}`] : []; + } +} + +function buildOwnerHints(risk: XiaobaoVersionRisk): string[] { + const hints: string[] = []; + if (risk.signals.unfinishedCount > 0) hints.push(`仍有 ${risk.signals.unfinishedCount} 个未完成事项,需要逐项确认负责人。`); + if (risk.signals.openBugCount > 0) hints.push(`仍有 ${risk.signals.openBugCount} 个未关闭 Bug,需要确认修复和复测节奏。`); + if (risk.signals.silentRiskCount > 0) hints.push(`存在 ${risk.signals.silentRiskCount} 个静默风险,需要负责人补充进展。`); + return hints; +} + +function formatDate(value: string | null | undefined): string | undefined { + if (!value) return undefined; + const raw = value.trim(); + if (!raw) return undefined; + if (/^\d{4}-\d{2}-\d{2}/.test(raw)) return raw.slice(0, 10); + const time = new Date(raw).getTime(); + if (!Number.isFinite(time)) return undefined; + return new Date(time).toISOString().slice(0, 10); +} diff --git a/apps/web/lib/xiaobao-risk.ts b/apps/web/lib/xiaobao-risk.ts index 7f96555..8e8f289 100644 --- a/apps/web/lib/xiaobao-risk.ts +++ b/apps/web/lib/xiaobao-risk.ts @@ -70,6 +70,8 @@ export interface XiaobaoVersionRisk { signals: XiaobaoRiskSignals; currentSnapshot: XiaobaoRiskSnapshot; aiInsight?: XiaobaoRiskInsight; + aiInsightUpdating?: boolean; + aiInsightSource?: 'current_ai' | 'previous_ai' | 'rule'; trend: { direction: 'up' | 'down' | 'flat' | 'unknown'; delta: number; diff --git a/apps/web/stores/useXiaobaoRiskStore.ts b/apps/web/stores/useXiaobaoRiskStore.ts index 48d3d54..0c15872 100644 --- a/apps/web/stores/useXiaobaoRiskStore.ts +++ b/apps/web/stores/useXiaobaoRiskStore.ts @@ -13,11 +13,14 @@ import type { XiaobaoRiskSnapshot } from '@/lib/xiaobao-risk-trend'; interface XiaobaoRiskState { snapshots: XiaobaoRiskSnapshot[]; insights: XiaobaoRiskInsightCacheItem[]; + pendingInsightKeys: string[]; riskDataLoaded: boolean; error?: string; fetchRiskData: () => Promise; saveSnapshot: (item: XiaobaoRiskSnapshot) => Promise; saveInsight: (item: XiaobaoRiskInsightCacheItem) => Promise; + beginInsightUpdate: (key: string) => void; + finishInsightUpdate: (key: string) => void; } async function loadSnapshots(): Promise { @@ -42,6 +45,7 @@ let insightSaveQueue: Promise = Promise.resolve(); export const useXiaobaoRiskStore = create((set, get) => ({ snapshots: [], insights: [], + pendingInsightKeys: [], riskDataLoaded: false, error: undefined, @@ -91,4 +95,15 @@ export const useXiaobaoRiskStore = create((set, get) => ({ throw error; } }, + + beginInsightUpdate: (key) => { + if (!key) return; + if (get().pendingInsightKeys.includes(key)) return; + set({ pendingInsightKeys: [...get().pendingInsightKeys, key] }); + }, + + finishInsightUpdate: (key) => { + if (!key) return; + set({ pendingInsightKeys: get().pendingInsightKeys.filter((item) => item !== key) }); + }, }));