feat(版本): 完善研发计划与预警已读

关键改动:

- 增加版本表单、发布校验和调研方向进度规则

- 扩展小宝预警已读状态、风险签名和今日证据

- 补充组长权限、加班查看范围、活动记录与相关测试

Co-Authored-By: Codex GPT-5 <codex@openai.com>
This commit is contained in:
Script Generator
2026-06-30 16:48:58 +08:00
parent 04520bd8c5
commit 3d3d56697a
41 changed files with 1701 additions and 121 deletions

View File

@@ -13,6 +13,7 @@ export const WORK_ACTIVITY_ACTION_LABEL: Record<WorkActivityAction, string> = {
version_plan_started: '开始计划',
version_plan_completed: '完成计划',
version_plan_requirement_progress: '需求进度',
version_plan_research_direction_progress: '调研方向',
dev_task_created: '新建开发任务',
dev_task_started: '开始开发',
dev_task_self_testing: '进入自测',

View File

@@ -0,0 +1,97 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
filterOvertimeRecordsForViewer,
type OvertimeRecord,
} from './overtime';
import type { Department, Member, RoleItem } from './members';
import type { AuthUser } from './auth-user';
const departments: Department[] = [
{ id: 'dept-tech', name: 'Tech', order: 1, createdAt: '2024-01-01' },
{ id: 'dept-front', name: 'Frontend', parentId: 'dept-tech', order: 1, createdAt: '2024-01-01' },
{ id: 'dept-back', name: 'Backend', parentId: 'dept-tech', order: 2, createdAt: '2024-01-01' },
{ id: 'dept-product', name: 'Product', order: 2, createdAt: '2024-01-01' },
];
const members: Member[] = [
member('m-lead', 'Lead', 'dept-tech', 'role-lead'),
member('m-front', 'Frontend Alice', 'dept-front', 'role-dev'),
member('m-back', 'Backend Bob', 'dept-back', 'role-dev'),
member('m-pm', 'PM Carol', 'dept-product', 'role-pm'),
];
const records: OvertimeRecord[] = [
record('ot-front', 'Frontend Alice'),
record('ot-back', 'Backend Bob'),
record('ot-pm', 'PM Carol'),
];
test('filterOvertimeRecordsForViewer limits non-admin viewers to their department tree', () => {
const visible = filterOvertimeRecordsForViewer(records, {
viewer: toAuthUser(members[0]),
viewerRole: role('role-lead', ['overtime:view']),
members,
departments,
});
assert.deepEqual(visible.map((item) => item.id), ['ot-front', 'ot-back']);
});
test('filterOvertimeRecordsForViewer lets wildcard admin view every department', () => {
const visible = filterOvertimeRecordsForViewer(records, {
viewer: toAuthUser(members[0]),
viewerRole: role('role-admin', ['*']),
members,
departments,
});
assert.deepEqual(visible.map((item) => item.id), ['ot-front', 'ot-back', 'ot-pm']);
});
function member(id: string, name: string, departmentId: string, roleId: string): Member {
return {
id,
name,
departmentId,
roleId,
phone: '',
email: '',
password: '',
createdAt: '2024-01-01',
};
}
function record(id: string, person: string): OvertimeRecord {
return {
id,
person,
projectId: 'project-1',
startTime: '2026-06-01T19:00',
endTime: '2026-06-01T21:00',
duration: 2,
reasonId: 'reason-1',
createdAt: '2026-06-01',
};
}
function role(id: string, permissions: string[]): RoleItem {
return {
id,
name: id,
createdAt: '2024-01-01',
permissions,
};
}
function toAuthUser(member: Member): AuthUser {
return {
id: member.id,
name: member.name,
roleId: member.roleId,
departmentId: member.departmentId,
phone: member.phone,
email: member.email,
};
}

View File

@@ -1,3 +1,8 @@
import type { AuthUser } from './auth-user';
import type { Department, Member, RoleItem } from './members';
import { isMemberReference } from './member-system';
import { hasPermission } from './permissions';
export interface OvertimeRecord {
id: string;
projectId: string;
@@ -12,6 +17,13 @@ export interface OvertimeRecord {
createdAt: string;
}
interface OvertimeVisibilityContext {
viewer: AuthUser | null | undefined;
viewerRole: RoleItem | undefined;
members: Member[];
departments: Department[];
}
export type OvertimeReason =
| 'requirement_change'
| 'requirement_add'
@@ -105,3 +117,46 @@ export function calcDuration(start: string, end: string): number {
return Math.round(total * 10) / 10;
}
function collectDepartmentTreeIds(departments: Department[], departmentId: string): Set<string> {
const ids = new Set<string>([departmentId]);
let changed = true;
while (changed) {
changed = false;
for (const department of departments) {
if (department.parentId && ids.has(department.parentId) && !ids.has(department.id)) {
ids.add(department.id);
changed = true;
}
}
}
return ids;
}
function resolveRecordMember(
record: OvertimeRecord,
members: Member[],
viewer: AuthUser,
): Pick<Member, 'id' | 'name' | 'departmentId'> | null {
const member = members.find((item) => isMemberReference(record.person, item));
if (member) return member;
if (isMemberReference(record.person, viewer)) {
return { id: viewer.id, name: viewer.name, departmentId: viewer.departmentId };
}
return null;
}
export function filterOvertimeRecordsForViewer(
records: OvertimeRecord[],
context: OvertimeVisibilityContext,
): OvertimeRecord[] {
const { viewer, viewerRole, members, departments } = context;
if (!viewer || !viewerRole || !hasPermission(viewerRole, 'overtime:view')) return [];
if (viewerRole.permissions.includes('*')) return records;
const visibleDepartmentIds = collectDepartmentTreeIds(departments, viewer.departmentId);
return records.filter((record) => {
const member = resolveRecordMember(record, members, viewer);
return !!member && visibleDepartmentIds.has(member.departmentId);
});
}

View File

@@ -105,8 +105,26 @@ export const DEFAULT_ROLE_PERMISSIONS: Record<string, string[]> = {
'xiaobao.warning:view',
'overtime:view', 'overtime:create',
],
'role-lead': [
...VIEW_ONLY_BASE,
'version.research:view', 'version.product_plan:view', 'version.ui_plan:view',
'version.devtask:view', 'version.devtask:manage',
'version.testcase:view', 'version.testcase:manage',
'version.bug:view', 'version.bug:create', 'version.bug:edit', 'version.bug:delete',
'xiaobao.warning:view',
'overtime:view', 'overtime:create', 'overtime:export',
],
};
export const DEFAULT_ROLE_PRESETS: RoleItem[] = [
{ id: 'role-admin', name: '超级管理员', description: '拥有系统全部权限', createdAt: '2024-01-01', isSystem: true, permissions: DEFAULT_ROLE_PERMISSIONS['role-admin'] },
{ id: 'role-pm', name: '产品经理', description: '管理产品和需求', createdAt: '2024-01-01', permissions: DEFAULT_ROLE_PERMISSIONS['role-pm'] },
{ id: 'role-lead', name: '组长', description: '管理本组任务和本部门加班记录', createdAt: '2024-01-01', permissions: DEFAULT_ROLE_PERMISSIONS['role-lead'] },
{ id: 'role-dev', name: '开发工程师', description: '负责开发任务', createdAt: '2024-01-01', permissions: DEFAULT_ROLE_PERMISSIONS['role-dev'] },
{ id: 'role-test', name: '测试工程师', description: '负责测试任务', createdAt: '2024-01-01', permissions: DEFAULT_ROLE_PERMISSIONS['role-test'] },
{ id: 'role-design', name: '设计师', description: '负责UI/UX设计', createdAt: '2024-01-01', permissions: DEFAULT_ROLE_PERMISSIONS['role-design'] },
];
export function hasPermission(role: RoleItem | undefined, permission: string): boolean {
if (!role) return false;
if (role.permissions.includes('*')) return true;

View File

@@ -1,8 +1,38 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { RoleItem } from './members';
import { DEFAULT_ROLE_PERMISSIONS } from './permissions';
import { mergePresetRolePermissions } from './role-permission-migration';
test('default group leader role has business permissions without admin modules', () => {
const permissions = DEFAULT_ROLE_PERMISSIONS['role-lead'];
assert.ok(permissions);
assert.ok(permissions.includes('overtime:view'));
assert.ok(!permissions.includes('*'));
assert.equal(permissions.some((p) => p.startsWith('member:')), false);
assert.equal(permissions.some((p) => p.startsWith('role:')), false);
});
test('mergePresetRolePermissions adds the built-in group leader role to old role data', () => {
const roles: RoleItem[] = [
{
id: 'role-pm',
name: 'Product Manager',
createdAt: '2024-01-01',
permissions: ['product:view'],
},
];
const result = mergePresetRolePermissions(roles);
const lead = result.roles.find((role) => role.id === 'role-lead');
assert.equal(result.changed, true);
assert.ok(lead);
assert.equal(lead.name, '组长');
assert.deepEqual(lead.permissions, DEFAULT_ROLE_PERMISSIONS['role-lead']);
});
test('mergePresetRolePermissions adds new preset permissions to existing product manager role', () => {
const roles: RoleItem[] = [
{

View File

@@ -1,14 +1,18 @@
import type { RoleItem } from './members';
import { DEFAULT_ROLE_PERMISSIONS, DEFAULT_ROLE_PRESETS } from './permissions';
const XIAOBAO_WARNING_ROLE_PERMISSIONS: Record<string, string[]> = {
'role-pm': ['xiaobao.warning:view', 'xiaobao.warning:manage'],
'role-dev': ['xiaobao.warning:view'],
'role-test': ['xiaobao.warning:view'],
'role-design': ['xiaobao.warning:view'],
'role-lead': ['xiaobao.warning:view'],
};
export function mergePresetRolePermissions(roles: RoleItem[]): { roles: RoleItem[]; changed: boolean } {
let changed = false;
const presetRoleIds = new Set(Object.keys(DEFAULT_ROLE_PERMISSIONS));
const shouldEnsureNewPresets = roles.some((role) => presetRoleIds.has(role.id));
const next = roles.map((role) => {
const xiaobaoPermissions = XIAOBAO_WARNING_ROLE_PERMISSIONS[role.id];
if (!xiaobaoPermissions || role.permissions.includes('*')) return role;
@@ -17,5 +21,14 @@ export function mergePresetRolePermissions(roles: RoleItem[]): { roles: RoleItem
changed = true;
return { ...role, permissions: merged };
});
if (shouldEnsureNewPresets && !next.some((role) => role.id === 'role-lead')) {
const groupLeader = DEFAULT_ROLE_PRESETS.find((role) => role.id === 'role-lead');
if (groupLeader) {
next.splice(Math.min(2, next.length), 0, groupLeader);
changed = true;
}
}
return { roles: next, changed };
}

View File

@@ -13,6 +13,7 @@ export type ServerDataKey =
| 'work-activities'
| 'xiaobao-risk-insights'
| 'xiaobao-risk-snapshots'
| 'xiaobao-warning-views'
| 'overtime';
interface ServerDataResponse<T> {

View File

@@ -0,0 +1,70 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
VERSION_DEVELOPMENT_TYPE_OPTIONS,
canSubmitNewVersionForm,
getRecommendedExpectedReleaseDate,
getVersionDevelopmentTypeOption,
} from './version-form';
test('canSubmitNewVersionForm requires expected release date', () => {
assert.equal(canSubmitNewVersionForm({
productId: 'prod-1',
projectId: 'proj-1',
versionNumber: '1.0',
developmentType: 'agile',
expectedReleaseDate: '',
submitting: false,
}), false);
assert.equal(canSubmitNewVersionForm({
productId: 'prod-1',
projectId: 'proj-1',
versionNumber: '1.0',
developmentType: 'agile',
expectedReleaseDate: '2026-07-15',
submitting: false,
}), true);
});
test('canSubmitNewVersionForm requires project development type', () => {
assert.equal(canSubmitNewVersionForm({
productId: 'prod-1',
projectId: 'proj-1',
versionNumber: '1.0',
developmentType: '',
expectedReleaseDate: '2026-07-15',
submitting: false,
}), false);
});
test('canSubmitNewVersionForm blocks duplicate submits', () => {
assert.equal(canSubmitNewVersionForm({
productId: 'prod-1',
projectId: 'proj-1',
versionNumber: '1.0',
developmentType: 'agile',
expectedReleaseDate: '2026-07-15',
submitting: true,
}), false);
});
test('version development type estimates include product design development and testing', () => {
const agile = getVersionDevelopmentTypeOption('agile');
assert.deepEqual(agile?.stages.map((stage) => stage.label), ['产品设计', '开发', '测试']);
assert.equal(agile?.totalWorkDays, 13);
assert.ok(VERSION_DEVELOPMENT_TYPE_OPTIONS.some((option) => option.key === 'waterfall'));
});
test('getRecommendedExpectedReleaseDate skips non-workdays from the stage total', () => {
assert.equal(getRecommendedExpectedReleaseDate('hotfix', new Date('2026-06-30T09:00:00')), '2026-07-02');
assert.equal(getRecommendedExpectedReleaseDate('agile', new Date('2026-06-30T09:00:00')), '2026-07-16');
});
test('getRecommendedExpectedReleaseDate excludes product design when it is already completed', () => {
assert.equal(
getRecommendedExpectedReleaseDate('agile', new Date('2026-06-30T09:00:00'), { productDesignCompleted: true }),
'2026-07-14',
);
});

View File

@@ -0,0 +1,115 @@
import { isChinaWorkday } from './china-workday-calendar';
import { formatLocalDate } from './format';
export type VersionDevelopmentType = 'agile' | 'standard' | 'waterfall' | 'hotfix';
export interface VersionDevelopmentStageEstimate {
key: 'product_design' | 'development' | 'testing';
label: '产品设计' | '开发' | '测试';
workDays: number;
}
export interface VersionDevelopmentTypeOption {
key: VersionDevelopmentType;
label: string;
description: string;
stages: VersionDevelopmentStageEstimate[];
totalWorkDays: number;
}
export interface NewVersionFormState {
productId: string;
projectId: string;
versionNumber: string;
developmentType: VersionDevelopmentType | '';
expectedReleaseDate: string;
submitting: boolean;
}
export const VERSION_DEVELOPMENT_TYPE_OPTIONS: VersionDevelopmentTypeOption[] = [
makeDevelopmentTypeOption('agile', '敏捷迭代', '适合常规双周迭代', [
{ key: 'product_design', label: '产品设计', workDays: 2 },
{ key: 'development', label: '开发', workDays: 8 },
{ key: 'testing', label: '测试', workDays: 3 },
]),
makeDevelopmentTypeOption('standard', '常规项目', '适合需求较完整的普通版本', [
{ key: 'product_design', label: '产品设计', workDays: 3 },
{ key: 'development', label: '开发', workDays: 12 },
{ key: 'testing', label: '测试', workDays: 4 },
]),
makeDevelopmentTypeOption('waterfall', '瀑布项目', '适合方案先行的大范围交付', [
{ key: 'product_design', label: '产品设计', workDays: 5 },
{ key: 'development', label: '开发', workDays: 20 },
{ key: 'testing', label: '测试', workDays: 7 },
]),
makeDevelopmentTypeOption('hotfix', '紧急修复', '适合线上缺陷或很小范围改动', [
{ key: 'product_design', label: '产品设计', workDays: 0 },
{ key: 'development', label: '开发', workDays: 2 },
{ key: 'testing', label: '测试', workDays: 1 },
]),
];
export function canSubmitNewVersionForm(state: NewVersionFormState): boolean {
return Boolean(
state.productId &&
state.projectId &&
state.versionNumber &&
state.developmentType &&
state.expectedReleaseDate &&
!state.submitting,
);
}
export function getVersionDevelopmentTypeOption(type: VersionDevelopmentType): VersionDevelopmentTypeOption | undefined {
return VERSION_DEVELOPMENT_TYPE_OPTIONS.find((option) => option.key === type);
}
export function getRecommendedExpectedReleaseDate(
type: VersionDevelopmentType,
baseDate: Date = new Date(),
options: { productDesignCompleted?: boolean } = {},
): string {
const developmentType = getVersionDevelopmentTypeOption(type);
if (!developmentType) return '';
const totalWorkDays = getRecommendedWorkDays(developmentType, options);
return addChinaWorkDays(baseDate, totalWorkDays);
}
export function getRecommendedWorkDays(
option: VersionDevelopmentTypeOption,
options: { productDesignCompleted?: boolean } = {},
): number {
return option.stages.reduce((sum, stage) => {
if (stage.key === 'product_design' && options.productDesignCompleted) return sum;
return sum + stage.workDays;
}, 0);
}
function makeDevelopmentTypeOption(
key: VersionDevelopmentType,
label: string,
description: string,
stages: VersionDevelopmentStageEstimate[],
): VersionDevelopmentTypeOption {
return {
key,
label,
description,
stages,
totalWorkDays: stages.reduce((sum, stage) => sum + stage.workDays, 0),
};
}
function addChinaWorkDays(baseDate: Date, workDays: number): string {
const cursor = new Date(baseDate);
cursor.setHours(0, 0, 0, 0);
let remaining = Math.max(1, Math.round(workDays));
while (remaining > 0) {
if (isChinaWorkday(cursor)) remaining -= 1;
if (remaining <= 0) break;
cursor.setDate(cursor.getDate() + 1);
}
return formatLocalDate(cursor);
}

View File

@@ -0,0 +1,82 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as versionPlan from './version-plan';
import type { VersionPlan } from './version-plan';
function plan(patch: Partial<VersionPlan>): VersionPlan {
return {
id: 'plan-1',
versionId: 'version-1',
type: 'research',
title: '调研方案',
owner: 'PM',
startTime: '2026-06-30T09:00',
endTime: '2026-06-30T18:00',
status: 'in_progress',
createdAt: '2026-06-30',
addedBy: 'PM',
...patch,
};
}
test('summarizes research direction progress from direction task status', () => {
const getResearchDirectionProgressSummary = (versionPlan as any).getResearchDirectionProgressSummary as undefined | ((item: VersionPlan) => {
total: number;
completed: number;
partial: number;
notStarted: number;
percent: number;
});
assert.equal(typeof getResearchDirectionProgressSummary, 'function');
const summary = getResearchDirectionProgressSummary!(plan({
tasks: [
{ id: 'task-1', title: '竞品分析', status: 'completed' },
{ id: 'task-2', title: '用户访谈', status: 'in_progress' },
{ id: 'task-3', title: '数据调研', status: 'pending' },
],
}));
assert.deepEqual(summary, {
total: 3,
completed: 1,
partial: 1,
notStarted: 1,
percent: 33,
});
});
test('updates research direction progress and creates a direction log', () => {
const updateResearchDirectionProgress = (versionPlan as any).updateResearchDirectionProgress as undefined | ((item: VersionPlan, input: {
taskId: string;
status: string;
completedContent?: string;
remainingContent?: string;
updatedBy: string;
updatedAt: string;
}) => any);
assert.equal(typeof updateResearchDirectionProgress, 'function');
const next = updateResearchDirectionProgress!(plan({
tasks: [
{ id: 'task-1', title: '竞品分析', status: 'pending' },
{ id: 'task-2', title: '用户访谈', status: 'pending' },
],
}), {
taskId: 'task-1',
status: 'partial',
completedContent: '完成竞品登录流程对比',
remainingContent: '补充支付流程差异',
updatedBy: 'PM',
updatedAt: '2026-06-30T10:00:00.000Z',
});
assert.equal(next.tasks?.[0]?.status, 'in_progress');
assert.equal(next.tasks?.[0]?.completedContent, '完成竞品登录流程对比');
assert.equal(next.tasks?.[0]?.remainingContent, '补充支付流程差异');
assert.equal(next.logs?.[0]?.type, 'research_direction_progress');
assert.equal(next.logs?.[0]?.directionTaskId, 'task-1');
assert.equal(next.logs?.[0]?.directionTitle, '竞品分析');
assert.equal(next.logs?.[0]?.coverageStatus, 'partial');
});

View File

@@ -22,14 +22,15 @@ function plan(patch: Partial<VersionPlan>): VersionPlan {
};
}
test('does not allow research result submission when subtasks are incomplete', () => {
test('research requires direction completion instead of linked requirement coverage', () => {
const state = getPlanCompletionState(plan({
type: 'research',
tasks: [{ id: 'task-1', title: '调研方向', status: 'pending' }],
linkedRequirementIds: [],
linkedRequirementIds: ['r1'],
}));
assert.equal(state.canSubmitResult, false);
assert.ok(state.missingReasons.includes('子任务未全部完成'));
assert.ok(state.missingReasons.includes('调研方向未全部完成'));
assert.equal(state.missingReasons.includes('关联需求未全部覆盖'), false);
});
test('requires product requirement coverage when linked requirements exist', () => {
@@ -99,7 +100,7 @@ test('ui plan does not require task checklist', () => {
assert.equal(state.canSubmitResult, true);
});
test('research requires tasks and result but not requirement coverage', () => {
test('research requires completed directions and result but not linked requirement coverage', () => {
const state = getPlanCompletionState(plan({
type: 'research',
tasks: [{ id: 'task-1', title: '调研', status: 'completed' }],

View File

@@ -73,8 +73,8 @@ export function getPlanCompletionState(plan: VersionPlan): PlanCompletionState {
const requirementCompleted = requirementSummary.completed;
const missingReasons: string[] = [];
if (requiresChecklist(plan) && checklistTotal === 0) missingReasons.push('缺少子任务');
if (requiresChecklist(plan) && checklistTotal > 0 && checklistCompleted < checklistTotal) missingReasons.push('子任务未全部完成');
if (requiresChecklist(plan) && checklistTotal === 0) missingReasons.push('缺少调研方向');
if (requiresChecklist(plan) && checklistTotal > 0 && checklistCompleted < checklistTotal) missingReasons.push('调研方向未全部完成');
if (requiresRequirementCoverage(plan) && requirementTotal > 0 && requirementCompleted < requirementTotal) {
missingReasons.push('关联需求未全部覆盖');
}

View File

@@ -193,3 +193,16 @@ test('hides coverage record action after a requirement is completed', () => {
assert.equal(canOpenRequirementCoverageRecord!('completed', true), false);
assert.equal(canOpenRequirementCoverageRecord!('partial', false), false);
});
test('keeps research direction presets visible while disabling selected presets', () => {
const getResearchDirectionPresetOptions = (versionPlan as any).getResearchDirectionPresetOptions as undefined | ((
tasks: Array<{ title: string }>,
) => Array<{ title: string; disabled: boolean }>);
assert.equal(typeof getResearchDirectionPresetOptions, 'function');
const options = getResearchDirectionPresetOptions!([{ title: '竞品分析' }]);
assert.ok(options.length >= 6);
assert.equal(options.find((option) => option.title === '竞品分析')?.disabled, true);
assert.equal(options.find((option) => option.title === '用户访谈')?.disabled, false);
});

View File

@@ -4,7 +4,7 @@ export type PlanTaskStatus = 'pending' | 'in_progress' | 'completed';
export type ProductPlanKind = 'design' | 'review';
export type ProductPlanReviewResult = 'passed' | 'failed';
export type RequirementCoverageStatus = 'not_started' | 'partial' | 'completed';
export type VersionPlanLogType = 'requirement_progress' | 'ai_decompose' | 'system';
export type VersionPlanLogType = 'requirement_progress' | 'research_direction_progress' | 'ai_decompose' | 'system';
export type AiDecomposeLogStatus = 'started' | 'completed' | 'error';
export type ProductPlanReviewFailureType =
| 'requirement_mismatch'
@@ -45,6 +45,30 @@ export interface PlanTask {
id: string;
title: string;
status: PlanTaskStatus;
completedContent?: string;
remainingContent?: string;
updatedAt?: string;
updatedBy?: string;
}
export const RESEARCH_DIRECTION_PRESETS = [
'\u7ade\u54c1\u5206\u6790',
'\u7528\u6237\u8bbf\u8c08',
'\u6570\u636e\u8c03\u7814',
'\u6280\u672f\u53ef\u884c\u6027\u5206\u6790',
'\u5e02\u573a\u8c03\u7814',
'\u9700\u6c42\u5206\u6790',
];
export function getResearchDirectionPresetOptions(tasks: Array<Pick<PlanTask, 'title'>>): Array<{
title: string;
disabled: boolean;
}> {
const selectedTitles = new Set(tasks.map((task) => task.title.trim()).filter(Boolean));
return RESEARCH_DIRECTION_PRESETS.map((title) => ({
title,
disabled: selectedTitles.has(title),
}));
}
export interface VersionPlanRequirementCoverage {
@@ -69,6 +93,8 @@ export interface VersionPlanLog {
coverageStatus?: RequirementCoverageStatus;
completedContent?: string;
remainingContent?: string;
directionTaskId?: string;
directionTitle?: string;
aiTarget?: AgentDecomposeTarget;
aiStatus?: AiDecomposeLogStatus;
}
@@ -84,6 +110,15 @@ export interface RequirementCoverageUpdateInput {
requirementTitle?: string;
}
export interface ResearchDirectionProgressUpdateInput {
taskId: string;
status: Extract<RequirementCoverageStatus, 'partial' | 'completed'>;
completedContent?: string;
remainingContent?: string;
updatedAt?: string;
updatedBy: string;
}
export type PlanLogDraft = Omit<VersionPlanLog, 'id' | 'createdAt'> & {
id?: string;
createdAt?: string;
@@ -100,6 +135,33 @@ export const REQUIREMENT_COVERAGE_LABEL: Record<RequirementCoverageStatus, strin
completed: '完全完成',
};
export function getResearchDirectionStatus(task: Pick<PlanTask, 'status'>): RequirementCoverageStatus {
if (task.status === 'completed') return 'completed';
if (task.status === 'in_progress') return 'partial';
return 'not_started';
}
export function getResearchDirectionProgressSummary(plan: VersionPlan): {
total: number;
completed: number;
partial: number;
notStarted: number;
percent: number;
} {
const tasks = plan.tasks ?? [];
const total = tasks.length;
const completed = tasks.filter((task) => getResearchDirectionStatus(task) === 'completed').length;
const partial = tasks.filter((task) => getResearchDirectionStatus(task) === 'partial').length;
const notStarted = Math.max(total - completed - partial, 0);
return {
total,
completed,
partial,
notStarted,
percent: total === 0 ? 0 : Math.round((completed / total) * 100),
};
}
export interface VersionPlan {
id: string;
versionId: string;
@@ -275,6 +337,55 @@ export function updateRequirementCoverage(
};
}
export function updateResearchDirectionProgress(
plan: VersionPlan,
input: ResearchDirectionProgressUpdateInput,
): Pick<VersionPlan, 'tasks' | 'logs'> {
const tasks = plan.tasks ?? [];
const target = tasks.find((task) => task.id === input.taskId);
if (!target) {
return {
tasks: plan.tasks,
logs: plan.logs,
};
}
const updatedAt = input.updatedAt ?? new Date().toISOString();
const nextStatus: PlanTaskStatus = input.status === 'completed' ? 'completed' : 'in_progress';
const completedContent = input.status === 'partial' ? input.completedContent?.trim() || undefined : undefined;
const remainingContent = input.status === 'partial' ? input.remainingContent?.trim() || undefined : undefined;
const nextTasks = tasks.map((task) => task.id === input.taskId
? {
...task,
status: nextStatus,
completedContent,
remainingContent,
updatedAt,
updatedBy: input.updatedBy,
}
: task);
const detail = [
completedContent ? `已完成:${completedContent}` : '',
remainingContent ? `剩余:${remainingContent}` : '',
].filter(Boolean).join('\n');
return {
tasks: nextTasks,
logs: appendPlanLog(plan, {
type: 'research_direction_progress',
createdAt: updatedAt,
actor: input.updatedBy,
title: `${target.title}更新为${REQUIREMENT_COVERAGE_LABEL[input.status]}`,
detail: detail || undefined,
directionTaskId: target.id,
directionTitle: target.title,
coverageStatus: input.status,
completedContent,
remainingContent,
}),
};
}
function getPlanCreatedAtTime(plan: VersionPlan): number {
const time = new Date(plan.createdAt).getTime();
return Number.isFinite(time) ? time : 0;

View File

@@ -0,0 +1,19 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { canSubmitReleaseForm, getReleaseProgressWarning } from './version-release';
test('getReleaseProgressWarning warns when version progress is below 100 percent', () => {
assert.equal(
getReleaseProgressWarning(87),
'当前版本进度 87%,仍有未完成事项。确认发版会将版本标记为已发布。',
);
});
test('getReleaseProgressWarning returns empty when version progress is complete', () => {
assert.equal(getReleaseProgressWarning(100), '');
});
test('canSubmitReleaseForm requires release date', () => {
assert.equal(canSubmitReleaseForm(''), false);
assert.equal(canSubmitReleaseForm('2026-07-15'), true);
});

View File

@@ -0,0 +1,14 @@
function normalizeReleaseProgress(progress: number): number {
if (!Number.isFinite(progress)) return 0;
return Math.min(100, Math.max(0, Math.round(progress)));
}
export function getReleaseProgressWarning(progress: number): string {
const normalizedProgress = normalizeReleaseProgress(progress);
if (normalizedProgress >= 100) return '';
return `当前版本进度 ${normalizedProgress}%,仍有未完成事项。确认发版会将版本标记为已发布。`;
}
export function canSubmitReleaseForm(releaseDate: string): boolean {
return releaseDate.trim().length > 0;
}

View File

@@ -73,6 +73,32 @@ export function makeVersionPlanRequirementProgressActivity(plan: VersionPlan, lo
};
}
export function makeVersionPlanResearchDirectionProgressActivity(plan: VersionPlan, log: VersionPlanLog): WorkActivityDraft | undefined {
if (log.type !== 'research_direction_progress') return undefined;
if (plan.type !== 'research') return undefined;
const directionTitle = log.directionTitle || '调研方向';
const statusLabel = log.coverageStatus ? REQUIREMENT_COVERAGE_LABEL[log.coverageStatus] : '进度更新';
return {
actorId: log.actor,
sourceType: 'version_plan',
sourceId: plan.id,
action: 'version_plan_research_direction_progress',
category: 'progress',
title: plan.title,
summary: `推进调研方向:${directionTitle}${statusLabel}`,
occurredAt: log.createdAt,
metadata: {
directionTaskId: log.directionTaskId,
directionTitle: log.directionTitle,
coverageStatus: log.coverageStatus,
completedContent: log.completedContent,
remainingContent: log.remainingContent,
},
};
}
export function makeDevTaskCreatedActivity(task: DevTask, actorId: string): WorkActivityDraft {
return {
actorId,

View File

@@ -0,0 +1,49 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import type { VersionPlan } from './version-plan';
import * as workActivityFactory from './work-activity-factory';
function plan(patch: Partial<VersionPlan> = {}): VersionPlan {
return {
id: 'plan-1',
versionId: 'version-1',
type: 'research',
title: '支付链路调研',
owner: 'PM',
startTime: '2026-06-30T09:00',
endTime: '2026-06-30T18:00',
status: 'in_progress',
createdAt: '2026-06-30',
addedBy: 'PM',
...patch,
};
}
test('makeVersionPlanResearchDirectionProgressActivity records direction progress details', () => {
const makeVersionPlanResearchDirectionProgressActivity = (workActivityFactory as any).makeVersionPlanResearchDirectionProgressActivity as undefined | Function;
assert.equal(typeof makeVersionPlanResearchDirectionProgressActivity, 'function');
const activity = makeVersionPlanResearchDirectionProgressActivity!(plan(), {
id: 'log-1',
type: 'research_direction_progress',
createdAt: '2026-06-30T10:00:00.000Z',
actor: 'PM',
title: '竞品分析更新为部分完成',
directionTaskId: 'task-1',
directionTitle: '竞品分析',
coverageStatus: 'partial',
completedContent: '完成登录流程对比',
remainingContent: '补充支付流程差异',
});
assert.equal(activity?.action, 'version_plan_research_direction_progress');
assert.equal(activity?.category, 'progress');
assert.equal(activity?.sourceType, 'version_plan');
assert.equal(activity?.sourceId, 'plan-1');
assert.equal(activity?.occurredAt, '2026-06-30T10:00:00.000Z');
assert.equal(activity?.summary, '推进调研方向:竞品分析(部分完成)');
assert.equal(activity?.metadata?.directionTaskId, 'task-1');
assert.equal(activity?.metadata?.completedContent, '完成登录流程对比');
assert.equal(activity?.metadata?.remainingContent, '补充支付流程差异');
});

View File

@@ -7,6 +7,7 @@ export type WorkActivityAction =
| 'version_plan_started'
| 'version_plan_completed'
| 'version_plan_requirement_progress'
| 'version_plan_research_direction_progress'
| 'dev_task_created'
| 'dev_task_started'
| 'dev_task_self_testing'

View File

@@ -279,6 +279,89 @@ test('getWorkspaceDailyReport counts actual hours from today activity sources',
assert.equal(report.totalCount, 2);
});
test('getWorkspaceDailyReport does not double count overlapping activity time ranges', () => {
const report = getWorkspaceDailyReport({
activities: [
{
id: 'act-overlap-a',
actorId: 'Alice',
date: '2026-06-26',
occurredAt: '2026-06-26T02:10:00.000Z',
sourceType: 'dev_task',
sourceId: 'task-overlap-a',
action: 'dev_task_started',
category: 'progress',
title: 'Overlap A',
summary: 'Started development: Overlap A',
},
{
id: 'act-overlap-b',
actorId: 'Alice',
date: '2026-06-26',
occurredAt: '2026-06-26T02:20:00.000Z',
sourceType: 'dev_task',
sourceId: 'task-overlap-b',
action: 'dev_task_started',
category: 'progress',
title: 'Overlap B',
summary: 'Started development: Overlap B',
},
],
worklogs: [],
workItems: [
{
id: 'task-overlap-a',
type: 'devTask',
title: 'Overlap A',
status: 'submitted',
completed: true,
productName: 'FTB',
projectName: 'Project Management',
versionName: 'V1.0',
versionId: 'version-1',
extra: {
actualStartAt: '2026-06-26T02:00:00.000Z',
actualEndAt: '2026-06-26T04:00:00.000Z',
},
raw: {
id: 'task-overlap-a',
actualStartAt: '2026-06-26T02:00:00.000Z',
actualEndAt: '2026-06-26T04:00:00.000Z',
status: 'submitted',
updatedAt: '2026-06-26T04:00:00.000Z',
} as any,
},
{
id: 'task-overlap-b',
type: 'devTask',
title: 'Overlap B',
status: 'submitted',
completed: true,
productName: 'FTB',
projectName: 'Project Management',
versionName: 'V1.0',
versionId: 'version-1',
extra: {
actualStartAt: '2026-06-26T02:00:00.000Z',
actualEndAt: '2026-06-26T04:00:00.000Z',
},
raw: {
id: 'task-overlap-b',
actualStartAt: '2026-06-26T02:00:00.000Z',
actualEndAt: '2026-06-26T04:00:00.000Z',
status: 'submitted',
updatedAt: '2026-06-26T04:00:00.000Z',
} as any,
},
],
userId: 'Alice',
date: '2026-06-26',
});
assert.equal(report.totalHours, 2);
assert.equal(report.totalCount, 2);
});
test('getWorkspaceDailyReport rebuilds activity evidence from persisted work item timestamps', () => {
const report = getWorkspaceDailyReport({
activities: [],

View File

@@ -120,12 +120,13 @@ export function getWorkspaceDailyReport({
...items.map((item) => item.taskId),
]);
const worklogTaskIds = new Set(items.map((item) => item.taskId));
const activityHours = Array.from(new Set(reportActivities.map((activity) => activity.sourceId)))
.filter((sourceId) => !worklogTaskIds.has(sourceId))
.reduce((sum, sourceId) => {
const item = workItemMap.get(sourceId);
return sum + calcWorkItemHoursForDate(item, date, now);
}, 0);
const activityHours = calcActivityHoursForDate(
Array.from(new Set(reportActivities.map((activity) => activity.sourceId))),
workItemMap,
worklogTaskIds,
date,
now,
);
const needsProgressItems = workItems
.filter((item) => shouldRequireProgress(item, date, touchedSourceIds))
@@ -297,25 +298,64 @@ function getTestCaseTerminalFallback(
return undefined;
}
function calcWorkItemHoursForDate(item: WorkItem | undefined, date: string, now: Date): number {
function calcActivityHoursForDate(
sourceIds: string[],
workItemMap: Map<string, WorkItem>,
excludedSourceIds: Set<string>,
date: string,
now: Date,
): number {
const intervals = sourceIds
.filter((sourceId) => !excludedSourceIds.has(sourceId))
.map((sourceId) => getWorkItemIntervalForDate(workItemMap.get(sourceId), date, now))
.filter(isTimeInterval);
return calcMergedIntervalHours(intervals);
}
function getWorkItemIntervalForDate(item: WorkItem | undefined, date: string, now: Date): { start: number; end: number } | undefined {
const interval = getActualInterval(item, now);
if (!interval) return 0;
if (!interval) return undefined;
const day = getLocalDateBounds(date);
if (!day) return 0;
if (!day) return undefined;
const start = new Date(interval.start).getTime();
const end = new Date(interval.end).getTime();
if (!Number.isFinite(start) || !Number.isFinite(end) || end <= start) return 0;
if (!Number.isFinite(start) || !Number.isFinite(end) || end <= start) return undefined;
const overlapStart = Math.max(start, day.start.getTime());
const overlapEnd = Math.min(end, day.end.getTime());
if (overlapEnd <= overlapStart) return 0;
if (overlapEnd <= overlapStart) return undefined;
return calcActualElapsedHours(
new Date(overlapStart).toISOString(),
new Date(overlapEnd).toISOString(),
);
return { start: overlapStart, end: overlapEnd };
}
function isTimeInterval(interval: { start: number; end: number } | undefined): interval is { start: number; end: number } {
return Boolean(interval);
}
function calcMergedIntervalHours(intervals: Array<{ start: number; end: number }>): number {
if (intervals.length === 0) return 0;
const sorted = [...intervals].sort((a, b) => a.start - b.start);
const merged: Array<{ start: number; end: number }> = [];
let current = { ...sorted[0] };
for (const interval of sorted.slice(1)) {
if (interval.start <= current.end) {
current.end = Math.max(current.end, interval.end);
} else {
merged.push(current);
current = { ...interval };
}
}
merged.push(current);
return merged.reduce((sum, interval) => sum + calcActualElapsedHours(
new Date(interval.start).toISOString(),
new Date(interval.end).toISOString(),
), 0);
}
function getActualInterval(item: WorkItem | undefined, now: Date): { start: string; end: string } | undefined {

View File

@@ -374,6 +374,7 @@ test('getReusableInsight keeps showing cached insight when only refresh-volatile
dailyEvidence: {
...risk().dailyEvidence!,
recentActivityCount: 12,
todayActualHours: 1.5,
lastActivityAt: '2026-06-29T03:37:15.903Z',
todayProgress: [
{ id: 'ev-2', title: 'Progress', summary: 'Fixed login issue.', occurredAt: '2026-06-29T03:37:15.903Z' },
@@ -390,6 +391,7 @@ test('getReusableInsight keeps showing cached insight when only refresh-volatile
dailyEvidence: {
...risk().dailyEvidence!,
recentActivityCount: 13,
todayActualHours: 3,
lastActivityAt: '2026-06-29T06:21:38.597Z',
todayProgress: [
{ id: 'ev-2', title: 'Progress', summary: 'Fixed login issue.', occurredAt: '2026-06-29T06:21:38.597Z' },

View File

@@ -144,6 +144,14 @@ export function buildRiskInsightSignature(risk: XiaobaoVersionRisk): string {
});
}
export function buildRiskInsightDisplaySignature(risk: XiaobaoVersionRisk): string {
return normalizeRiskInsightDisplaySignature(buildRiskInsightSignature(risk)) ?? buildRiskInsightSignature(risk);
}
export function normalizeRiskInsightDisplaySignature(signature: string): string | undefined {
return buildDisplayCompatibilityKey(signature);
}
export function getReusableInsight(
cache: XiaobaoRiskInsightCacheItem[],
risk: XiaobaoVersionRisk,
@@ -365,7 +373,6 @@ function buildDisplayCompatibilityKey(signature: string): string | undefined {
progressNotes: normalizeCompatibilityEvidence(dailyEvidence.progressNotes),
needsProgressItems: normalizeCompatibilityEvidence(dailyEvidence.needsProgressItems),
totalActivityCount: normalizeInteger(dailyEvidence.totalActivityCount),
todayActualHours: normalizeNumber(dailyEvidence.todayActualHours),
},
});
} catch {
@@ -436,10 +443,6 @@ function normalizeInteger(value: unknown): number | null {
return typeof value === 'number' && Number.isFinite(value) ? Math.round(value) : null;
}
function normalizeNumber(value: unknown): number | null {
return typeof value === 'number' && Number.isFinite(value) ? Math.round(value * 10) / 10 : null;
}
function getTime(value: string): number {
const time = new Date(value).getTime();
return Number.isFinite(time) ? time : Number.NaN;

View File

@@ -48,6 +48,20 @@ test('buildRiskSignature changes when critical bug count changes without open bu
assert.notEqual(base, changed);
});
test('buildRiskSignature ignores volatile same-day forecast time shifts', () => {
const base = snapshot({
forecastReleaseDate: '2026-07-15T06:30:00.000Z',
createdAt: '2026-06-30T08:00:00.000Z',
});
const changed = snapshot({
forecastReleaseDate: '2026-07-15T09:46:04.706Z',
createdAt: '2026-06-30T08:16:04.720Z',
});
assert.equal(buildRiskSignature(changed), buildRiskSignature(base));
assert.equal(shouldSaveRiskSnapshot(changed, base, new Date('2026-06-30T08:16:04.720Z')), false);
});
test('findLatestDailySnapshot returns latest same-day snapshot for a version', () => {
const latest = findLatestDailySnapshot([
snapshot({ versionId: 'ver-1', date: '2026-06-29', riskScore: 40, createdAt: '2026-06-29T09:00:00.000Z' }),

View File

@@ -83,7 +83,7 @@ export function buildRiskSignature(snapshot: XiaobaoRiskSnapshot): string {
snapshot.date,
clampScore(snapshot.riskScore),
snapshot.riskLevel,
snapshot.forecastReleaseDate ?? '',
normalizeForecastDate(snapshot.forecastReleaseDate),
snapshot.openBugCount,
snapshot.criticalBugCount ?? 0,
snapshot.failedTestCount,
@@ -136,6 +136,16 @@ function hasForecastShiftedByOneDay(current?: string, previous?: string): boolea
return Number.isFinite(delta) && delta >= ONE_DAY_MS;
}
function normalizeForecastDate(value?: string): string {
if (!value) return '';
const raw = value.trim();
if (!raw) return '';
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 raw;
return new Date(time).toISOString().slice(0, 10);
}
function getSnapshotTime(snapshot: XiaobaoRiskSnapshot): number {
const date = new Date(snapshot.createdAt || snapshot.date).getTime();
return Number.isFinite(date) ? date : 0;

View File

@@ -8,6 +8,10 @@ import {
formatRemainingWork,
getRiskScoreTone,
getXiaobaoWarningRiskCount,
getXiaobaoWarningUnreadUpdateCount,
getXiaobaoWarningUpdateSignature,
isXiaobaoWarningUpdated,
markXiaobaoWarningRead,
sanitizeRiskInsight,
} from './xiaobao-warning-view';
@@ -157,3 +161,105 @@ test('sanitizeRiskInsight filters invalid page refresh suggested actions', () =>
assert.deepEqual(result.suggestedActions, ['优先处理 3 个 P1 Bug并同步测试负责人复测']);
});
test('xiaobao warning update state is unread until the current user reads the same risk signature', () => {
const baseRisk = risk();
assert.equal(isXiaobaoWarningUpdated(baseRisk, [], 'user-1'), true);
const readStates = markXiaobaoWarningRead([], 'user-1', baseRisk, '2026-06-30T11:00:00.000Z');
assert.equal(isXiaobaoWarningUpdated(baseRisk, readStates, 'user-1'), false);
assert.equal(isXiaobaoWarningUpdated(baseRisk, readStates, 'user-2'), true);
});
test('xiaobao warning update state becomes unread again when risk facts change', () => {
const baseRisk = risk();
const readStates = markXiaobaoWarningRead([], 'user-1', baseRisk, '2026-06-30T11:00:00.000Z');
const changedRisk = risk({
riskScore: 69,
signals: {
...baseRisk.signals,
failedTestCount: 1,
},
});
assert.notEqual(getXiaobaoWarningUpdateSignature(changedRisk), getXiaobaoWarningUpdateSignature(baseRisk));
assert.equal(isXiaobaoWarningUpdated(changedRisk, readStates, 'user-1'), true);
assert.equal(getXiaobaoWarningUnreadUpdateCount([baseRisk, changedRisk], readStates, 'user-1'), 1);
});
test('xiaobao warning update state waits until AI suggestion update is complete', () => {
const baseRisk = risk();
const readStates = markXiaobaoWarningRead([], 'user-1', baseRisk, '2026-06-30T11:00:00.000Z');
const changedRisk = risk({
riskScore: 72,
aiInsightUpdating: true,
signals: {
...baseRisk.signals,
failedTestCount: 1,
},
});
const completedRisk = { ...changedRisk, aiInsightUpdating: false };
assert.equal(isXiaobaoWarningUpdated(changedRisk, readStates, 'user-1'), false);
assert.equal(getXiaobaoWarningUnreadUpdateCount([changedRisk], readStates, 'user-1'), 0);
assert.deepEqual(markXiaobaoWarningRead(readStates, 'user-1', changedRisk, '2026-06-30T12:00:00.000Z'), readStates);
assert.equal(isXiaobaoWarningUpdated(completedRisk, readStates, 'user-1'), true);
});
test('xiaobao warning update state ignores refresh-volatile timing and hour drift', () => {
const baseRisk = risk({
forecastReleaseDate: '2026-07-05T10:00:00.000Z',
dailyEvidence: {
todayDeliveries: [],
todayProgress: [
{ id: 'ev-1', title: 'Progress', summary: '开始开发:核心流程', occurredAt: '2026-06-30T10:00:00.000Z' },
],
todayCreations: [],
todayRisks: [],
progressNotes: [],
needsProgressItems: [],
recentActivityCount: 2,
totalActivityCount: 1,
todayActualHours: 1,
lastActivityAt: '2026-06-30T10:00:00.000Z',
},
});
const readStates = markXiaobaoWarningRead([], 'user-1', baseRisk, '2026-06-30T11:00:00.000Z');
const refreshDriftRisk = risk({
forecastReleaseDate: '2026-07-05T11:30:00.000Z',
signals: {
...baseRisk.signals,
daysToExpectedRelease: 4.2,
},
dailyEvidence: {
...baseRisk.dailyEvidence!,
recentActivityCount: 3,
todayActualHours: 2.5,
lastActivityAt: '2026-06-30T11:30:00.000Z',
todayProgress: [
{ id: 'ev-1', title: 'Progress', summary: '开始开发:核心流程', occurredAt: '2026-06-30T11:30:00.000Z' },
],
},
});
assert.equal(isXiaobaoWarningUpdated(refreshDriftRisk, readStates, 'user-1'), false);
});
test('markXiaobaoWarningRead replaces only the matching user and version read marker', () => {
const baseRisk = risk();
const otherRisk = risk({ versionId: 'ver-2', versionName: 'V2.0' });
const initial = [
...markXiaobaoWarningRead([], 'user-1', baseRisk, '2026-06-30T10:00:00.000Z'),
...markXiaobaoWarningRead([], 'user-2', baseRisk, '2026-06-30T10:05:00.000Z'),
...markXiaobaoWarningRead([], 'user-1', otherRisk, '2026-06-30T10:10:00.000Z'),
];
const updated = markXiaobaoWarningRead(initial, 'user-1', baseRisk, '2026-06-30T11:00:00.000Z');
assert.equal(updated.filter((item) => item.userId === 'user-1' && item.versionId === 'ver-1').length, 1);
assert.equal(updated.find((item) => item.userId === 'user-1' && item.versionId === 'ver-1')?.readAt, '2026-06-30T11:00:00.000Z');
assert.equal(updated.some((item) => item.userId === 'user-2' && item.versionId === 'ver-1'), true);
assert.equal(updated.some((item) => item.userId === 'user-1' && item.versionId === 'ver-2'), true);
});

View File

@@ -1,6 +1,7 @@
import type { VersionWithContext } from './derive';
import type { XiaobaoRiskInsight } from './xiaobao-risk-cache';
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']);
@@ -28,6 +29,13 @@ export interface XiaobaoWarningRiskListFilter {
export type RiskScoreTone = 'danger' | 'warn' | 'ok';
export interface XiaobaoWarningReadState {
userId: string;
versionId: string;
signature: string;
readAt: string;
}
export function filterXiaobaoWarningVersions(
versions: VersionWithContext[],
filter: XiaobaoWarningVersionFilter,
@@ -58,6 +66,52 @@ export function getXiaobaoWarningRiskCount(risks: XiaobaoVersionRisk[]): number
return filterXiaobaoRiskWarnings(risks).length;
}
export function getXiaobaoWarningUpdateSignature(risk: XiaobaoVersionRisk): string {
return buildRiskInsightDisplaySignature(risk);
}
export function isXiaobaoWarningUpdated(
risk: XiaobaoVersionRisk,
readStates: XiaobaoWarningReadState[],
userId?: string,
): boolean {
if (!userId) return false;
if (risk.aiInsightUpdating) return false;
const currentSignature = getXiaobaoWarningUpdateSignature(risk);
const readState = readStates.find((item) => item.userId === userId && item.versionId === risk.versionId);
const readSignature = readState?.signature
? normalizeRiskInsightDisplaySignature(readState.signature) ?? readState.signature
: undefined;
return readSignature !== currentSignature;
}
export function getXiaobaoWarningUnreadUpdateCount(
risks: XiaobaoVersionRisk[],
readStates: XiaobaoWarningReadState[],
userId?: string,
): number {
return risks.filter((risk) => isXiaobaoWarningUpdated(risk, readStates, userId)).length;
}
export function markXiaobaoWarningRead(
readStates: XiaobaoWarningReadState[],
userId: string,
risk: XiaobaoVersionRisk,
readAt: string,
): XiaobaoWarningReadState[] {
if (risk.aiInsightUpdating) return readStates;
const item: XiaobaoWarningReadState = {
userId,
versionId: risk.versionId,
signature: getXiaobaoWarningUpdateSignature(risk),
readAt,
};
return [
item,
...readStates.filter((row) => row.userId !== userId || row.versionId !== risk.versionId),
];
}
export function getRiskScoreTone(score: number): RiskScoreTone {
if (score >= 75) return 'danger';
if (score >= 55) return 'warn';