feat(版本): 优化概览与只读状态
关键改动: - 增加需求排序和版本只读状态规则及测试 - 完善版本概览阶段耗时、项目页和工作台展示 - 优化小宝预警请求节流、建议状态和风险过滤 Co-Authored-By: Codex GPT-5 <codex@openai.com>
This commit is contained in:
@@ -23,7 +23,7 @@ export interface VersionLinks {
|
||||
interface ProductOverviewLike {
|
||||
id: string;
|
||||
name: string;
|
||||
projects: { id: string; name: string; description: string; createdAt: string }[];
|
||||
projects: { id: string; name: string; description: string; createdAt: string; status?: string }[];
|
||||
versions: {
|
||||
id: string; name: string; status?: string; releaseDate: string | null; createdAt: string;
|
||||
currentStage?: Stage; startDate?: string | null; expectedReleaseDate?: string | null;
|
||||
@@ -37,6 +37,7 @@ export interface ProjectWithContext {
|
||||
name: string;
|
||||
description: string;
|
||||
createdAt: string;
|
||||
status?: string;
|
||||
productId: string;
|
||||
productName: string;
|
||||
versions: VersionWithContext[];
|
||||
@@ -52,6 +53,7 @@ export interface VersionWithContext {
|
||||
productName: string;
|
||||
projectId: string;
|
||||
projectName: string;
|
||||
projectStatus?: string;
|
||||
currentStage?: Stage;
|
||||
startDate?: string | null;
|
||||
expectedReleaseDate?: string | null;
|
||||
@@ -74,6 +76,7 @@ export function flattenProjects(overview: ProductOverviewLike[]): ProjectWithCon
|
||||
productName: product.name,
|
||||
projectId: project.id,
|
||||
projectName: project.name,
|
||||
projectStatus: project.status,
|
||||
}));
|
||||
result.push({
|
||||
...project,
|
||||
@@ -99,6 +102,7 @@ export function flattenVersions(overview: ProductOverviewLike[]): VersionWithCon
|
||||
productId: product.id,
|
||||
productName: product.name,
|
||||
projectId: project?.id || '',
|
||||
projectStatus: project?.status,
|
||||
projectName: project?.name || '未关联',
|
||||
});
|
||||
}
|
||||
|
||||
63
apps/web/lib/requirement-sort.test.ts
Normal file
63
apps/web/lib/requirement-sort.test.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import type { Requirement } from './requirement';
|
||||
import { sortRequirementsByCreatedAt } from './requirement-sort';
|
||||
|
||||
const baseRequirement = {
|
||||
description: '',
|
||||
productId: 'product-1',
|
||||
projectId: 'project-1',
|
||||
sourceType: 'internal',
|
||||
sourceTarget: '',
|
||||
platforms: [] as string[],
|
||||
typeId: 'type-1',
|
||||
status: 'pending_review',
|
||||
priority: 'P2',
|
||||
effort: 'M',
|
||||
creator: 'tester',
|
||||
};
|
||||
|
||||
function req(id: string, code: string, createdAt: string): Requirement {
|
||||
return {
|
||||
...baseRequirement,
|
||||
id,
|
||||
code,
|
||||
title: code,
|
||||
createdAt,
|
||||
} as Requirement;
|
||||
}
|
||||
|
||||
test('sorts requirements by created time descending by default', () => {
|
||||
const requirements = [
|
||||
req('req-100', 'REQ-001', '2026-06-28T09:00:00.000Z'),
|
||||
req('req-300', 'REQ-003', '2026-06-29T09:00:00.000Z'),
|
||||
req('req-200', 'REQ-002', '2026-06-28T18:00:00.000Z'),
|
||||
];
|
||||
|
||||
assert.deepEqual(sortRequirementsByCreatedAt(requirements).map((item) => item.id), ['req-300', 'req-200', 'req-100']);
|
||||
});
|
||||
|
||||
test('uses id/code fallback when legacy requirements only have the same date', () => {
|
||||
const requirements = [
|
||||
req('req-1782301644000', 'REQ-010', '2026-06-24'),
|
||||
req('req-1782301644789', 'REQ-011', '2026-06-24'),
|
||||
req('req-old', 'REQ-009', '2026-06-24'),
|
||||
];
|
||||
|
||||
assert.deepEqual(sortRequirementsByCreatedAt(requirements).map((item) => item.id), [
|
||||
'req-1782301644789',
|
||||
'req-1782301644000',
|
||||
'req-old',
|
||||
]);
|
||||
});
|
||||
|
||||
test('can sort requirements by created time ascending', () => {
|
||||
const requirements = [
|
||||
req('req-300', 'REQ-003', '2026-06-29T09:00:00.000Z'),
|
||||
req('req-100', 'REQ-001', '2026-06-28T09:00:00.000Z'),
|
||||
req('req-200', 'REQ-002', '2026-06-28T18:00:00.000Z'),
|
||||
];
|
||||
|
||||
assert.deepEqual(sortRequirementsByCreatedAt(requirements, 'asc').map((item) => item.id), ['req-100', 'req-200', 'req-300']);
|
||||
});
|
||||
39
apps/web/lib/requirement-sort.ts
Normal file
39
apps/web/lib/requirement-sort.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import type { Requirement } from './requirement';
|
||||
|
||||
export type RequirementDateSort = 'desc' | 'asc';
|
||||
|
||||
export function sortRequirementsByCreatedAt(
|
||||
requirements: Requirement[],
|
||||
direction: RequirementDateSort = 'desc',
|
||||
): Requirement[] {
|
||||
const multiplier = direction === 'asc' ? 1 : -1;
|
||||
return [...requirements].sort((a, b) => compareRequirementCreatedAt(a, b) * multiplier);
|
||||
}
|
||||
|
||||
function compareRequirementCreatedAt(a: Requirement, b: Requirement): number {
|
||||
const createdAtDiff = toTimestamp(a.createdAt) - toTimestamp(b.createdAt);
|
||||
if (createdAtDiff !== 0) return createdAtDiff;
|
||||
|
||||
const idDiff = trailingNumber(a.id) - trailingNumber(b.id);
|
||||
if (idDiff !== 0) return idDiff;
|
||||
|
||||
const codeDiff = trailingNumber(a.code) - trailingNumber(b.code);
|
||||
if (codeDiff !== 0) return codeDiff;
|
||||
|
||||
const codeTextDiff = a.code.localeCompare(b.code);
|
||||
if (codeTextDiff !== 0) return codeTextDiff;
|
||||
|
||||
return a.id.localeCompare(b.id);
|
||||
}
|
||||
|
||||
function toTimestamp(value: string): number {
|
||||
const timestamp = new Date(value).getTime();
|
||||
return Number.isFinite(timestamp) ? timestamp : 0;
|
||||
}
|
||||
|
||||
function trailingNumber(value: string): number {
|
||||
const matches = value.match(/\d+/g);
|
||||
if (!matches) return 0;
|
||||
const numericValue = Number(matches[matches.length - 1]);
|
||||
return Number.isFinite(numericValue) ? numericValue : 0;
|
||||
}
|
||||
@@ -6,6 +6,10 @@ import {
|
||||
calcPersonalEffortRanking,
|
||||
calcStageEffortMetrics,
|
||||
calcVersionOverviewEffortTotals,
|
||||
buildVersionTimelineSummary,
|
||||
formatVersionOverviewDateTime,
|
||||
getVersionCardDefaultExpanded,
|
||||
mergeStageProgressWithEffort,
|
||||
} from './version-overview';
|
||||
import type { VersionPlan } from './version-plan';
|
||||
import type { DevTask } from './dev-task';
|
||||
@@ -131,6 +135,60 @@ test('calcStageEffortMetrics returns actual hours and AI estimates per stage', (
|
||||
assert.equal(metrics.bug.actualHours, 0.5);
|
||||
});
|
||||
|
||||
test('mergeStageProgressWithEffort keeps progress state and adds stage effort fields', () => {
|
||||
const metrics = calcStageEffortMetrics({
|
||||
plans: [],
|
||||
devTasks: [
|
||||
devTask({
|
||||
actualStartAt: '2026-06-24T09:00:00',
|
||||
actualEndAt: '2026-06-24T14:00:00',
|
||||
estimateHours: 4.5,
|
||||
aiEstimateHours: 3.25,
|
||||
}),
|
||||
],
|
||||
testCases: [
|
||||
testCase({
|
||||
startedAt: '2026-06-25T09:00:00',
|
||||
completedAt: '2026-06-25T11:00:00',
|
||||
estimateHours: 2.25,
|
||||
aiEstimateHours: 1.5,
|
||||
}),
|
||||
],
|
||||
bugs: [],
|
||||
});
|
||||
|
||||
const merged = mergeStageProgressWithEffort(
|
||||
{
|
||||
dev: { percent: 50, status: 'active' },
|
||||
testing: { percent: 100, status: 'done' },
|
||||
},
|
||||
metrics,
|
||||
);
|
||||
|
||||
assert.equal(merged.dev.percent, 50);
|
||||
assert.equal(merged.dev.status, 'active');
|
||||
assert.equal(merged.dev.actualHours, 4);
|
||||
assert.equal(merged.dev.estimateHours, 4.5);
|
||||
assert.equal(merged.dev.aiEstimateHours, 3.25);
|
||||
assert.equal(merged.dev.showEstimates, true);
|
||||
assert.equal(merged.testing.percent, 100);
|
||||
assert.equal(merged.testing.status, 'done');
|
||||
assert.equal(merged.testing.actualHours, 2);
|
||||
assert.equal(merged.testing.estimateHours, 2.25);
|
||||
assert.equal(merged.testing.aiEstimateHours, 1.5);
|
||||
assert.equal(merged.requirement.percent, 0);
|
||||
assert.equal(merged.requirement.status, 'idle');
|
||||
assert.equal(merged.requirement.actualHours, 0);
|
||||
});
|
||||
|
||||
test('getVersionCardDefaultExpanded expands only in-progress versions by default', () => {
|
||||
assert.equal(getVersionCardDefaultExpanded('developing'), true);
|
||||
assert.equal(getVersionCardDefaultExpanded('released'), false);
|
||||
assert.equal(getVersionCardDefaultExpanded('closed'), false);
|
||||
assert.equal(getVersionCardDefaultExpanded('paused'), false);
|
||||
assert.equal(getVersionCardDefaultExpanded('planned'), false);
|
||||
});
|
||||
|
||||
test('calcStageEffortMetrics uses updatedAt for terminal test cases missing completedAt', () => {
|
||||
const metrics = calcStageEffortMetrics({
|
||||
plans: [],
|
||||
@@ -188,6 +246,58 @@ test('calcVersionOverviewEffortTotals sums actual hours and overtime records sep
|
||||
assert.equal(totals.overtimeHours, 3.3);
|
||||
});
|
||||
|
||||
test('buildVersionTimelineSummary uses the same timing sources as version detail', () => {
|
||||
const summary = buildVersionTimelineSummary({
|
||||
status: 'closed',
|
||||
startDate: '2026-06-20',
|
||||
expectedReleaseDate: '2026-06-27',
|
||||
releaseDate: '2026-06-30T10:00:00',
|
||||
plans: [
|
||||
plan({
|
||||
type: 'research',
|
||||
actualStartAt: '2026-06-22T09:00:00',
|
||||
completedAt: '2026-06-22T18:00:00',
|
||||
}),
|
||||
],
|
||||
devTasks: [
|
||||
devTask({
|
||||
actualStartAt: '2026-06-23T09:00:00',
|
||||
actualEndAt: '2026-06-23T12:00:00',
|
||||
}),
|
||||
],
|
||||
testCases: [
|
||||
testCase({
|
||||
startedAt: '2026-06-24T09:00:00',
|
||||
completedAt: '2026-06-24T11:00:00',
|
||||
}),
|
||||
],
|
||||
bugs: [
|
||||
bug({
|
||||
status: 'closed',
|
||||
createdAt: '2026-06-25T10:00:00',
|
||||
resolvedAt: '2026-06-25T16:00:00',
|
||||
closedAt: undefined,
|
||||
updatedAt: '2026-06-25T17:00:00',
|
||||
}),
|
||||
],
|
||||
now: new Date('2026-06-26T18:00:00'),
|
||||
});
|
||||
|
||||
assert.equal(summary.actualStartIso, '2026-06-22T09:00:00');
|
||||
assert.equal(summary.expectedReleaseIso, '2026-06-27');
|
||||
assert.equal(summary.actualReleaseIso, '2026-06-30T10:00:00');
|
||||
assert.equal(summary.actualEndIso, '2026-06-25T16:00:00');
|
||||
assert.equal(summary.isTerminalVersion, true);
|
||||
assert.equal(summary.actualHours, 30);
|
||||
assert.equal(summary.overdueDays, 3);
|
||||
});
|
||||
|
||||
test('formatVersionOverviewDateTime matches the version detail display format', () => {
|
||||
assert.equal(formatVersionOverviewDateTime('2026-06-22T09:30:00'), '2026-06-22 09:30');
|
||||
assert.equal(formatVersionOverviewDateTime('2026-06-27'), '2026-06-27');
|
||||
assert.equal(formatVersionOverviewDateTime(null), '-');
|
||||
});
|
||||
|
||||
test('calcPersonalEffortRanking includes bug work and sorts by total hours', () => {
|
||||
const overtimeRecords: OvertimeRecord[] = [
|
||||
{
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import type { Stage } from './stage';
|
||||
import { STAGES, type Stage } from './stage';
|
||||
import type { VersionPlan } from './version-plan';
|
||||
import type { DevTask } from './dev-task';
|
||||
import type { TestCase } from './test-case';
|
||||
import type { Bug, BugSeverity } from './bug';
|
||||
import type { OvertimeRecord } from './overtime';
|
||||
import type { VersionStatus } from './version-status';
|
||||
import { getActualHours as getDevTaskActualHours } from './dev-task';
|
||||
import { getTestCaseActualHours } from './test-case';
|
||||
import { getBugActualHours } from './bug';
|
||||
import { calcActualElapsedHours } from './work-hours';
|
||||
import { formatDateTime } from './format';
|
||||
|
||||
export interface StageEffortMetric {
|
||||
actualHours: number;
|
||||
@@ -16,6 +18,13 @@ export interface StageEffortMetric {
|
||||
showEstimates?: boolean;
|
||||
}
|
||||
|
||||
export interface StageProgressState {
|
||||
percent: number;
|
||||
status: 'idle' | 'active' | 'done';
|
||||
}
|
||||
|
||||
export type StageProgressWithEffort = StageProgressState & StageEffortMetric;
|
||||
|
||||
export interface PersonalEffortItem {
|
||||
name: string;
|
||||
actualHours: number;
|
||||
@@ -37,6 +46,16 @@ export interface VersionOverviewEffortTotals {
|
||||
overtimeHours: number;
|
||||
}
|
||||
|
||||
export interface VersionTimelineSummary {
|
||||
actualStartIso: string | null;
|
||||
expectedReleaseIso: string | null;
|
||||
actualReleaseIso: string | null;
|
||||
actualEndIso: string | null;
|
||||
isTerminalVersion: boolean;
|
||||
actualHours: number;
|
||||
overdueDays: number;
|
||||
}
|
||||
|
||||
function roundHalf(hours: number): number {
|
||||
return Math.round(hours * 2) / 2;
|
||||
}
|
||||
@@ -117,6 +136,100 @@ export function calcStageEffortMetrics(input: {
|
||||
};
|
||||
}
|
||||
|
||||
export function mergeStageProgressWithEffort(
|
||||
progress: Partial<Record<Stage, StageProgressState>>,
|
||||
effortMetrics: Record<Stage, StageEffortMetric>,
|
||||
): Record<Stage, StageProgressWithEffort> {
|
||||
return STAGES.reduce((acc, stage) => {
|
||||
const state = progress[stage.key];
|
||||
acc[stage.key] = {
|
||||
percent: state?.percent ?? 0,
|
||||
status: state?.status ?? 'idle',
|
||||
...effortMetrics[stage.key],
|
||||
};
|
||||
return acc;
|
||||
}, {} as Record<Stage, StageProgressWithEffort>);
|
||||
}
|
||||
|
||||
export function getVersionCardDefaultExpanded(status: VersionStatus): boolean {
|
||||
return status === 'developing';
|
||||
}
|
||||
|
||||
export function formatVersionOverviewDateTime(value?: string | null): string {
|
||||
if (!value) return '-';
|
||||
return value.includes('T') ? formatDateTime(value) : value;
|
||||
}
|
||||
|
||||
export function buildVersionTimelineSummary(input: {
|
||||
status: VersionStatus;
|
||||
startDate?: string | null;
|
||||
expectedReleaseDate?: string | null;
|
||||
releaseDate?: string | null;
|
||||
plans: VersionPlan[];
|
||||
devTasks: DevTask[];
|
||||
testCases: TestCase[];
|
||||
bugs: Bug[];
|
||||
now?: Date;
|
||||
}): VersionTimelineSummary {
|
||||
const now = input.now ?? new Date();
|
||||
const startDates: string[] = [];
|
||||
|
||||
input.plans.forEach((plan) => {
|
||||
if (plan.actualStartAt) {
|
||||
startDates.push(plan.actualStartAt);
|
||||
} else if (plan.status === 'pending' && plan.startTime && new Date(plan.startTime) <= now) {
|
||||
startDates.push(plan.startTime);
|
||||
}
|
||||
});
|
||||
input.devTasks.forEach((task) => {
|
||||
if (task.actualStartAt) startDates.push(task.actualStartAt);
|
||||
});
|
||||
input.testCases.forEach((testCase) => {
|
||||
if (testCase.startedAt) startDates.push(testCase.startedAt);
|
||||
});
|
||||
|
||||
const actualStartIso = startDates.length > 0 ? startDates.sort()[0] : (input.startDate ?? null);
|
||||
|
||||
const endDates: string[] = [];
|
||||
input.plans.forEach((plan) => {
|
||||
if (plan.completedAt) endDates.push(plan.completedAt);
|
||||
});
|
||||
input.devTasks.forEach((task) => {
|
||||
if (task.actualEndAt) endDates.push(task.actualEndAt);
|
||||
});
|
||||
input.testCases.forEach((testCase) => {
|
||||
if (testCase.completedAt) endDates.push(testCase.completedAt);
|
||||
});
|
||||
input.bugs.forEach((bug) => {
|
||||
if (bug.closedAt) endDates.push(bug.closedAt);
|
||||
else if (bug.resolvedAt) endDates.push(bug.resolvedAt);
|
||||
else if ((bug.status === 'closed' || bug.status === 'rejected') && bug.updatedAt) endDates.push(bug.updatedAt);
|
||||
});
|
||||
|
||||
const actualEndIso = endDates.length > 0 ? endDates.sort().reverse()[0] : null;
|
||||
const isTerminalVersion = input.status === 'released' || input.status === 'closed';
|
||||
const actualHours = calcActualElapsedHours(actualStartIso, isTerminalVersion ? actualEndIso : now.toISOString());
|
||||
|
||||
let overdueDays = 0;
|
||||
if (input.expectedReleaseDate && input.releaseDate) {
|
||||
const endDate = new Date(input.releaseDate);
|
||||
const deadlineDate = new Date(input.expectedReleaseDate);
|
||||
endDate.setHours(0, 0, 0, 0);
|
||||
deadlineDate.setHours(0, 0, 0, 0);
|
||||
overdueDays = Math.floor((endDate.getTime() - deadlineDate.getTime()) / (1000 * 60 * 60 * 24));
|
||||
}
|
||||
|
||||
return {
|
||||
actualStartIso,
|
||||
expectedReleaseIso: input.expectedReleaseDate ?? null,
|
||||
actualReleaseIso: input.releaseDate ?? null,
|
||||
actualEndIso,
|
||||
isTerminalVersion,
|
||||
actualHours,
|
||||
overdueDays,
|
||||
};
|
||||
}
|
||||
|
||||
export function calcVersionOverviewEffortTotals(input: {
|
||||
plans: VersionPlan[];
|
||||
devTasks: DevTask[];
|
||||
|
||||
25
apps/web/lib/version-status.test.ts
Normal file
25
apps/web/lib/version-status.test.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { getVersionReadonlyNotice, isVersionReadonly } from './version-status';
|
||||
|
||||
test('isVersionReadonly locks released closed and paused versions', () => {
|
||||
assert.equal(isVersionReadonly('released'), true);
|
||||
assert.equal(isVersionReadonly('closed'), true);
|
||||
assert.equal(isVersionReadonly('paused'), true);
|
||||
});
|
||||
|
||||
test('isVersionReadonly allows planned and developing versions to be edited', () => {
|
||||
assert.equal(isVersionReadonly('planned'), false);
|
||||
assert.equal(isVersionReadonly('developing'), false);
|
||||
});
|
||||
|
||||
test('getVersionReadonlyNotice explains locked version task actions', () => {
|
||||
assert.equal(getVersionReadonlyNotice('released'), '已发布版本不可编辑或删除任务');
|
||||
assert.equal(getVersionReadonlyNotice('paused'), '已暂停版本不可编辑或删除任务');
|
||||
assert.equal(getVersionReadonlyNotice('closed'), '已关闭版本不可编辑或删除任务');
|
||||
});
|
||||
|
||||
test('getVersionReadonlyNotice stays empty for editable versions', () => {
|
||||
assert.equal(getVersionReadonlyNotice('developing'), null);
|
||||
assert.equal(getVersionReadonlyNotice('planned'), null);
|
||||
});
|
||||
@@ -1,5 +1,11 @@
|
||||
export type VersionStatus = 'developing' | 'planned' | 'released' | 'paused' | 'closed';
|
||||
|
||||
const READONLY_VERSION_STATUSES = new Set<VersionStatus>(['released', 'closed', 'paused']);
|
||||
|
||||
export function isVersionReadonly(status: VersionStatus): boolean {
|
||||
return READONLY_VERSION_STATUSES.has(status);
|
||||
}
|
||||
|
||||
export const VERSION_STATUS_LABEL: Record<VersionStatus, string> = {
|
||||
developing: '进行中',
|
||||
planned: '规划中',
|
||||
@@ -8,6 +14,11 @@ export const VERSION_STATUS_LABEL: Record<VersionStatus, string> = {
|
||||
closed: '已关闭',
|
||||
};
|
||||
|
||||
export function getVersionReadonlyNotice(status: VersionStatus): string | null {
|
||||
if (!isVersionReadonly(status)) return null;
|
||||
return `${VERSION_STATUS_LABEL[status]}版本不可编辑或删除任务`;
|
||||
}
|
||||
|
||||
export const VERSION_STATUS_DOT: Record<VersionStatus, string> = {
|
||||
developing: 'bg-blue-500',
|
||||
planned: 'bg-orange-500',
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
findPreviousRiskSnapshot,
|
||||
getReusableInsight,
|
||||
shouldRequestRiskInsight,
|
||||
shouldRequestRiskInsightWithRequestGate,
|
||||
shouldRequestRiskInsightWithCacheGate,
|
||||
shouldRequestRiskInsightWithCooldown,
|
||||
} from './xiaobao-risk-ai';
|
||||
@@ -283,6 +284,22 @@ test('shouldRequestRiskInsightWithCacheGate allows changed risk facts after cach
|
||||
);
|
||||
});
|
||||
|
||||
test('shouldRequestRiskInsightWithRequestGate skips repeat requests during request cooldown without cache', () => {
|
||||
const current = risk({ riskLevel: 'at_risk', riskScore: 82 });
|
||||
|
||||
assert.equal(
|
||||
shouldRequestRiskInsightWithRequestGate({
|
||||
riskCacheLoaded: true,
|
||||
cache: [],
|
||||
current,
|
||||
previous: snapshot({ riskScore: 45 }),
|
||||
lastRequestedAt: '2026-06-29T10:30:00.000Z',
|
||||
now: new Date('2026-06-29T11:00:00.000Z'),
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('buildRiskInsightSignature uses all open bugs, not only critical bugs', () => {
|
||||
const base = buildRiskInsightSignature(risk({
|
||||
signals: { ...risk().signals, openBugCount: 1, criticalBugCount: 0 },
|
||||
|
||||
@@ -9,7 +9,7 @@ type RiskInsightCurrent = Pick<
|
||||
'riskLevel' | 'riskScore' | 'confidence' | 'forecastReleaseDate' | 'signals' | 'trend'
|
||||
>;
|
||||
|
||||
type RiskInsightPrevious = Pick<
|
||||
export type RiskInsightPrevious = Pick<
|
||||
XiaobaoRiskSnapshot,
|
||||
| 'riskScore'
|
||||
| 'confidence'
|
||||
@@ -103,6 +103,35 @@ export function shouldRequestRiskInsightWithCacheGate(
|
||||
return shouldRequestRiskInsightWithCooldown(current, previous, latestInsight, now);
|
||||
}
|
||||
|
||||
export interface RiskInsightRequestGateInput {
|
||||
riskCacheLoaded: boolean;
|
||||
cache: XiaobaoRiskInsightCacheItem[];
|
||||
current: XiaobaoVersionRisk;
|
||||
previous?: RiskInsightPrevious;
|
||||
lastRequestedAt?: string;
|
||||
now?: Date;
|
||||
}
|
||||
|
||||
export function shouldRequestRiskInsightWithRequestGate(input: RiskInsightRequestGateInput): boolean {
|
||||
const now = input.now ?? new Date();
|
||||
if (isRiskInsightRequestCoolingDown(input.lastRequestedAt, now)) return false;
|
||||
return shouldRequestRiskInsightWithCacheGate(
|
||||
input.riskCacheLoaded,
|
||||
input.cache,
|
||||
input.current,
|
||||
input.previous,
|
||||
now,
|
||||
);
|
||||
}
|
||||
|
||||
export function isRiskInsightRequestCoolingDown(lastRequestedAt: string | undefined, now: Date = new Date()): boolean {
|
||||
if (!lastRequestedAt) return false;
|
||||
const requestedAt = getTime(lastRequestedAt);
|
||||
const nowTime = now.getTime();
|
||||
if (!Number.isFinite(requestedAt) || !Number.isFinite(nowTime)) return false;
|
||||
return nowTime - requestedAt < RISK_INSIGHT_COOLDOWN_MS;
|
||||
}
|
||||
|
||||
export function buildRiskInsightSignature(risk: XiaobaoVersionRisk): string {
|
||||
return JSON.stringify({
|
||||
versionId: risk.versionId,
|
||||
|
||||
@@ -3,7 +3,7 @@ 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';
|
||||
import { attachXiaobaoRiskSuggestion, buildXiaobaoRiskInsightPendingKey } from './xiaobao-risk-suggestion';
|
||||
|
||||
function risk(patch: Partial<XiaobaoVersionRisk> = {}): XiaobaoVersionRisk {
|
||||
return {
|
||||
@@ -91,7 +91,7 @@ test('attachXiaobaoRiskSuggestion provides a rule suggestion when AI cache is em
|
||||
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 pendingKey = buildXiaobaoRiskInsightPendingKey(current);
|
||||
|
||||
const result = attachXiaobaoRiskSuggestion(current, {
|
||||
insights: [insight(previous)],
|
||||
@@ -125,3 +125,50 @@ test('attachXiaobaoRiskSuggestion uses the current AI suggestion when the signat
|
||||
assert.equal(result.aiInsightUpdating, false);
|
||||
assert.equal(result.aiInsight?.summary, '新的小宝建议');
|
||||
});
|
||||
|
||||
test('buildXiaobaoRiskInsightPendingKey stays stable for refresh-only risk drift', () => {
|
||||
const before = risk({
|
||||
riskLevel: 'likely_delayed',
|
||||
riskScore: 100,
|
||||
confidence: 65,
|
||||
forecastReleaseDate: '2026-07-14T03:37:15.903Z',
|
||||
signals: { ...risk().signals, failedTestCount: 14, silentRiskCount: 58, daysToExpectedRelease: 1 },
|
||||
dailyEvidence: {
|
||||
todayDeliveries: [],
|
||||
todayProgress: [
|
||||
{ id: 'ev-2', title: 'Progress', summary: 'Fixed login issue.', occurredAt: '2026-06-29T03:37:15.903Z' },
|
||||
],
|
||||
todayCreations: [],
|
||||
todayRisks: [],
|
||||
progressNotes: [],
|
||||
needsProgressItems: [],
|
||||
recentActivityCount: 12,
|
||||
totalActivityCount: 6,
|
||||
todayActualHours: 1.5,
|
||||
lastActivityAt: '2026-06-29T03:37:15.903Z',
|
||||
},
|
||||
});
|
||||
const after = risk({
|
||||
riskLevel: 'likely_delayed',
|
||||
riskScore: 100,
|
||||
confidence: 65,
|
||||
forecastReleaseDate: '2026-07-15T06:21:38.597Z',
|
||||
signals: { ...risk().signals, failedTestCount: 14, silentRiskCount: 58, daysToExpectedRelease: 0 },
|
||||
dailyEvidence: {
|
||||
todayDeliveries: [],
|
||||
todayProgress: [
|
||||
{ id: 'ev-2', title: 'Progress', summary: 'Fixed login issue.', occurredAt: '2026-06-29T06:21:38.597Z' },
|
||||
],
|
||||
todayCreations: [],
|
||||
todayRisks: [],
|
||||
progressNotes: [],
|
||||
needsProgressItems: [],
|
||||
recentActivityCount: 13,
|
||||
totalActivityCount: 6,
|
||||
todayActualHours: 3,
|
||||
lastActivityAt: '2026-06-29T06:21:38.597Z',
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(buildXiaobaoRiskInsightPendingKey(before), buildXiaobaoRiskInsightPendingKey(after));
|
||||
});
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { buildRiskInsightSignature, findLatestRiskInsightForVersion, getReusableInsight } from './xiaobao-risk-ai';
|
||||
import {
|
||||
buildRiskInsightDisplaySignature,
|
||||
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';
|
||||
@@ -18,7 +23,7 @@ const RISK_LEVEL_LABEL: Record<XiaobaoVersionRisk['riskLevel'], string> = {
|
||||
};
|
||||
|
||||
export function buildXiaobaoRiskInsightPendingKey(risk: XiaobaoVersionRisk): string {
|
||||
return `${risk.versionId}:${buildRiskInsightSignature(risk)}`;
|
||||
return `${risk.versionId}:${buildRiskInsightDisplaySignature(risk)}`;
|
||||
}
|
||||
|
||||
export function attachXiaobaoRiskSuggestion(
|
||||
|
||||
@@ -88,6 +88,25 @@ test('filterXiaobaoWarningVersions lets managers see every unfinished version',
|
||||
assert.deepEqual(result.map((item) => item.id), ['ver-1', 'ver-2']);
|
||||
});
|
||||
|
||||
test('filterXiaobaoWarningVersions excludes paused released closed versions and paused projects', () => {
|
||||
const versions = [
|
||||
version({ id: 'ver-1', status: 'paused', members: [{ name: 'Alice', role: 'frontend' }] }),
|
||||
version({ id: 'ver-2', status: 'developing', members: [{ name: 'Alice', role: 'frontend' }] }),
|
||||
version({ id: 'ver-3', status: 'released', members: [{ name: 'Alice', role: 'frontend' }] }),
|
||||
version({ id: 'ver-4', status: 'closed', members: [{ name: 'Alice', role: 'frontend' }] }),
|
||||
version({ id: 'ver-5', status: 'developing', projectStatus: 'paused', members: [{ name: 'Alice', role: 'frontend' }] }),
|
||||
];
|
||||
|
||||
assert.deepEqual(
|
||||
filterXiaobaoWarningVersions(versions, { canManage: true, userName: 'Alice' }).map((item) => item.id),
|
||||
['ver-2'],
|
||||
);
|
||||
assert.deepEqual(
|
||||
filterXiaobaoWarningVersions(versions, { canManage: false, userName: 'Alice' }).map((item) => item.id),
|
||||
['ver-2'],
|
||||
);
|
||||
});
|
||||
|
||||
test('filterXiaobaoWarningVersions limits non-managers to versions where they are a member', () => {
|
||||
const result = filterXiaobaoWarningVersions(
|
||||
[
|
||||
|
||||
@@ -4,7 +4,8 @@ import type { XiaobaoVersionRisk } from './xiaobao-risk';
|
||||
import { buildRiskInsightDisplaySignature, normalizeRiskInsightDisplaySignature } from './xiaobao-risk-ai';
|
||||
import { WORK_HOURS } from './work-hours';
|
||||
|
||||
const UNFINISHED_VERSION_STATUSES = new Set(['planned', 'developing', 'paused']);
|
||||
const WARNING_VERSION_STATUSES = new Set(['planned', 'developing']);
|
||||
const PAUSED_PROJECT_STATUSES = new Set(['paused']);
|
||||
const HIGH_RISK_LEVELS = new Set<XiaobaoVersionRisk['riskLevel']>(['at_risk', 'likely_delayed', 'blocked']);
|
||||
const PAGE_REFRESH_ADVICE_PATTERNS = [
|
||||
/(刷新|重新加载|重载).*(页面|浏览器|小宝|预警)/i,
|
||||
@@ -41,7 +42,8 @@ export function filterXiaobaoWarningVersions(
|
||||
filter: XiaobaoWarningVersionFilter,
|
||||
): VersionWithContext[] {
|
||||
return versions.filter((version) => {
|
||||
if (!UNFINISHED_VERSION_STATUSES.has(version.status)) return false;
|
||||
if (!WARNING_VERSION_STATUSES.has(version.status)) return false;
|
||||
if (version.projectStatus && PAUSED_PROJECT_STATUSES.has(version.projectStatus)) return false;
|
||||
if (filter.canManage) return true;
|
||||
if (!filter.userName) return false;
|
||||
return (version.members ?? []).some((member) => member.name === filter.userName);
|
||||
|
||||
Reference in New Issue
Block a user