feat(版本): 优化概览与只读状态

关键改动:

- 增加需求排序和版本只读状态规则及测试

- 完善版本概览阶段耗时、项目页和工作台展示

- 优化小宝预警请求节流、建议状态和风险过滤

Co-Authored-By: Codex GPT-5 <codex@openai.com>
This commit is contained in:
Script Generator
2026-06-30 18:18:18 +08:00
parent 3d3d56697a
commit eef3c8f000
32 changed files with 1072 additions and 289 deletions

View File

@@ -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 || '未关联',
});
}

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

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

View File

@@ -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[] = [
{

View File

@@ -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[];

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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(

View File

@@ -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(
[

View File

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