feat(平台): 补齐服务端持久化和AI拆解契约
This commit is contained in:
@@ -8,7 +8,8 @@ async function checkApi(): Promise<boolean> {
|
||||
if (probePromise) return probePromise;
|
||||
probePromise = (async () => {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/products`, { method: 'HEAD', signal: AbortSignal.timeout(300) });
|
||||
// 探测一个不依赖数据库的端点(/config/ai 总是返回 200,只要 NestJS 起来了)
|
||||
const res = await fetch(`${API_BASE}/config/ai`, { method: 'GET', signal: AbortSignal.timeout(1500) });
|
||||
apiAvailable = res.ok;
|
||||
} catch {
|
||||
apiAvailable = false;
|
||||
@@ -37,7 +38,29 @@ export const api = {
|
||||
get: <T>(path: string) => request<T>(path),
|
||||
post: <T>(path: string, data: unknown) =>
|
||||
request<T>(path, { method: 'POST', body: JSON.stringify(data) }),
|
||||
put: <T>(path: string, data: unknown) =>
|
||||
request<T>(path, { method: 'PUT', body: JSON.stringify(data) }),
|
||||
patch: <T>(path: string, data: unknown) =>
|
||||
request<T>(path, { method: 'PATCH', body: JSON.stringify(data) }),
|
||||
delete: <T>(path: string) => request<T>(path, { method: 'DELETE' }),
|
||||
postRaw: async <T>(path: string, data: unknown, timeoutMs = 120000): Promise<T> => {
|
||||
// 调用 AI 类长耗时接口时使用,跳过 checkApi 短路(确保走真实请求)
|
||||
const controller = new AbortController();
|
||||
const tid = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}${path}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
throw new Error(err.message || `请求失败: ${res.status}`);
|
||||
}
|
||||
return res.json();
|
||||
} finally {
|
||||
clearTimeout(tid);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
12
apps/web/lib/product-overview-persistence.test.ts
Normal file
12
apps/web/lib/product-overview-persistence.test.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { shouldPersistRemoteOverview } from './product-overview-persistence';
|
||||
|
||||
test('does not persist an empty remote overview', () => {
|
||||
assert.equal(shouldPersistRemoteOverview([]), false);
|
||||
});
|
||||
|
||||
test('persists a non-empty remote overview', () => {
|
||||
assert.equal(shouldPersistRemoteOverview([{ id: 'product-1' }]), true);
|
||||
});
|
||||
3
apps/web/lib/product-overview-persistence.ts
Normal file
3
apps/web/lib/product-overview-persistence.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export function shouldPersistRemoteOverview(overview: unknown): boolean {
|
||||
return Array.isArray(overview) && overview.length > 0;
|
||||
}
|
||||
49
apps/web/lib/requirement-selector.test.ts
Normal file
49
apps/web/lib/requirement-selector.test.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { getProjectAdoptedRequirementCandidates, mergeSelectedRequirementOptions } from './requirement-selector';
|
||||
import type { Requirement } from './requirement';
|
||||
|
||||
const base = {
|
||||
description: '',
|
||||
productId: 'product-1',
|
||||
sourceType: 'internal',
|
||||
sourceTarget: '',
|
||||
platforms: [] as string[],
|
||||
typeId: '',
|
||||
priority: 'P2',
|
||||
effort: 'M',
|
||||
creator: 'tester',
|
||||
createdAt: '2026-06-25',
|
||||
};
|
||||
|
||||
function req(id: string, projectId: string, status: Requirement['status']): Requirement {
|
||||
return {
|
||||
...base,
|
||||
id,
|
||||
code: id.toUpperCase(),
|
||||
title: `Requirement ${id}`,
|
||||
projectId,
|
||||
status,
|
||||
} as Requirement;
|
||||
}
|
||||
|
||||
test('returns only adopted requirements from current project', () => {
|
||||
const result = getProjectAdoptedRequirementCandidates([
|
||||
req('r1', 'project-1', 'adopted'),
|
||||
req('r2', 'project-1', 'pending_review'),
|
||||
req('r3', 'project-2', 'adopted'),
|
||||
], 'project-1');
|
||||
|
||||
assert.deepEqual(result.map((r: Requirement) => r.id), ['r1']);
|
||||
});
|
||||
|
||||
test('keeps historical selected requirements as non-candidate options', () => {
|
||||
const options = mergeSelectedRequirementOptions(
|
||||
[req('r1', 'project-1', 'adopted')],
|
||||
[req('r1', 'project-1', 'adopted'), req('r2', 'project-1', 'developing')],
|
||||
['r1', 'r2'],
|
||||
);
|
||||
|
||||
assert.equal(options.find((o: { id: string; isHistorical?: boolean }) => o.id === 'r2')?.isHistorical, true);
|
||||
});
|
||||
42
apps/web/lib/requirement-selector.ts
Normal file
42
apps/web/lib/requirement-selector.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import type { Requirement } from './requirement';
|
||||
|
||||
export interface RequirementOption {
|
||||
id: string;
|
||||
code: string;
|
||||
title: string;
|
||||
productOwner?: string;
|
||||
status: Requirement['status'];
|
||||
isHistorical?: boolean;
|
||||
}
|
||||
|
||||
export function toRequirementOption(requirement: Requirement, isHistorical = false): RequirementOption {
|
||||
return {
|
||||
id: requirement.id,
|
||||
code: requirement.code,
|
||||
title: requirement.title,
|
||||
productOwner: requirement.productOwner,
|
||||
status: requirement.status,
|
||||
isHistorical,
|
||||
};
|
||||
}
|
||||
|
||||
export function getProjectAdoptedRequirementCandidates(requirements: Requirement[], projectId: string): Requirement[] {
|
||||
return requirements
|
||||
.filter((r) => r.projectId === projectId && r.status === 'adopted')
|
||||
.sort((a, b) => a.code.localeCompare(b.code, 'zh-CN'));
|
||||
}
|
||||
|
||||
export function mergeSelectedRequirementOptions(
|
||||
candidates: Requirement[],
|
||||
allRequirements: Requirement[],
|
||||
selectedIds: string[] = [],
|
||||
): RequirementOption[] {
|
||||
const candidateOptions = candidates.map((r) => toRequirementOption(r));
|
||||
const candidateIds = new Set(candidateOptions.map((r) => r.id));
|
||||
const historical = selectedIds
|
||||
.filter((id) => !candidateIds.has(id))
|
||||
.map((id) => allRequirements.find((r) => r.id === id))
|
||||
.filter((r): r is Requirement => Boolean(r))
|
||||
.map((r) => toRequirementOption(r, true));
|
||||
return [...candidateOptions, ...historical];
|
||||
}
|
||||
27
apps/web/lib/server-data.ts
Normal file
27
apps/web/lib/server-data.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { api } from './api';
|
||||
|
||||
export type ServerDataKey =
|
||||
| 'products-overview'
|
||||
| 'requirements'
|
||||
| 'version-plans'
|
||||
| 'dev-tasks'
|
||||
| 'test-cases'
|
||||
| 'bugs'
|
||||
| 'members'
|
||||
| 'task-categories'
|
||||
| 'task-worklogs'
|
||||
| 'overtime';
|
||||
|
||||
interface ServerDataResponse<T> {
|
||||
key: ServerDataKey;
|
||||
value: T | null;
|
||||
}
|
||||
|
||||
export async function loadServerData<T>(key: ServerDataKey): Promise<T | null> {
|
||||
const res = await api.get<ServerDataResponse<T>>(`/data/${key}`);
|
||||
return res.value;
|
||||
}
|
||||
|
||||
export async function saveServerData<T>(key: ServerDataKey, value: T): Promise<void> {
|
||||
await api.put(`/data/${key}`, { value });
|
||||
}
|
||||
38
apps/web/lib/task-category.test.ts
Normal file
38
apps/web/lib/task-category.test.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import {
|
||||
DEFAULT_TEST_CATEGORY_ID,
|
||||
PRESET_CATEGORIES,
|
||||
findCategoryByCode,
|
||||
getDefaultCategoryByGroup,
|
||||
normalizeTaskCategories,
|
||||
resolveCategoryIdFromCode,
|
||||
} from './task-category';
|
||||
|
||||
test('preset categories include stable codes and testing group', () => {
|
||||
assert.ok(PRESET_CATEGORIES.some((c) => c.code === 'test_functional' && c.group === 'testing'));
|
||||
assert.ok(PRESET_CATEGORIES.every((c) => c.code.length > 0));
|
||||
});
|
||||
|
||||
test('normalizes legacy categories without code', () => {
|
||||
const normalized = normalizeTaskCategories([
|
||||
{ id: 'cat-1', name: '前端开发', group: 'development', sortOrder: 1, isSystem: true },
|
||||
]);
|
||||
|
||||
assert.equal(normalized[0].code, 'frontend_development');
|
||||
});
|
||||
|
||||
test('resolves category id from stable code', () => {
|
||||
const id = resolveCategoryIdFromCode(PRESET_CATEGORIES, 'test_functional', 'testing');
|
||||
assert.equal(id, DEFAULT_TEST_CATEGORY_ID);
|
||||
});
|
||||
|
||||
test('falls back to default group category for unknown code', () => {
|
||||
const category = getDefaultCategoryByGroup(PRESET_CATEGORIES, 'testing');
|
||||
assert.equal(resolveCategoryIdFromCode(PRESET_CATEGORIES, 'unknown_code', 'testing'), category.id);
|
||||
});
|
||||
|
||||
test('finds category by code', () => {
|
||||
assert.equal(findCategoryByCode(PRESET_CATEGORIES, 'backend_api')?.name, '后端接口');
|
||||
});
|
||||
@@ -1,7 +1,10 @@
|
||||
export type CategoryGroup = 'development' | 'implementation' | 'other';
|
||||
export type CategoryGroup = 'development' | 'testing' | 'implementation' | 'other';
|
||||
|
||||
export const DEFAULT_TEST_CATEGORY_ID = 'cat-test-functional';
|
||||
|
||||
export interface TaskCategory {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
group: CategoryGroup;
|
||||
color?: string;
|
||||
@@ -11,19 +14,68 @@ export interface TaskCategory {
|
||||
|
||||
export const CATEGORY_GROUP_LABEL: Record<CategoryGroup, string> = {
|
||||
development: '开发',
|
||||
testing: '测试',
|
||||
implementation: '实施',
|
||||
other: '其他',
|
||||
};
|
||||
|
||||
export const PRESET_CATEGORIES: TaskCategory[] = [
|
||||
{ id: 'cat-1', name: '前端开发', group: 'development', color: '#3b82f6', sortOrder: 1, isSystem: true },
|
||||
{ id: 'cat-2', name: '后端开发', group: 'development', color: '#6366f1', sortOrder: 2, isSystem: true },
|
||||
{ id: 'cat-3', name: '数据库设计', group: 'development', color: '#8b5cf6', sortOrder: 3, isSystem: true },
|
||||
{ id: 'cat-4', name: '接口联调', group: 'development', color: '#0ea5e9', sortOrder: 4, isSystem: true },
|
||||
{ id: 'cat-5', name: '数据处理', group: 'implementation', color: '#f59e0b', sortOrder: 5, isSystem: true },
|
||||
{ id: 'cat-6', name: '实施支持', group: 'implementation', color: '#10b981', sortOrder: 6, isSystem: true },
|
||||
{ id: 'cat-1', code: 'frontend_development', name: '前端开发', group: 'development', color: '#3b82f6', sortOrder: 1, isSystem: true },
|
||||
{ id: 'cat-frontend-interaction', code: 'frontend_interaction', name: '前端交互', group: 'development', color: '#0ea5e9', sortOrder: 2, isSystem: true },
|
||||
{ id: 'cat-2', code: 'backend_development', name: '后端开发', group: 'development', color: '#6366f1', sortOrder: 3, isSystem: true },
|
||||
{ id: 'cat-backend-api', code: 'backend_api', name: '后端接口', group: 'development', color: '#2563eb', sortOrder: 4, isSystem: true },
|
||||
{ id: 'cat-3', code: 'database_schema', name: '数据库设计', group: 'development', color: '#8b5cf6', sortOrder: 5, isSystem: true },
|
||||
{ id: 'cat-4', code: 'api_integration', name: '接口联调', group: 'development', color: '#06b6d4', sortOrder: 6, isSystem: true },
|
||||
{ id: DEFAULT_TEST_CATEGORY_ID, code: 'test_functional', name: '功能测试', group: 'testing', color: '#22c55e', sortOrder: 20, isSystem: true },
|
||||
{ id: 'cat-test-api', code: 'test_api', name: '接口测试', group: 'testing', color: '#14b8a6', sortOrder: 21, isSystem: true },
|
||||
{ id: 'cat-test-exception', code: 'test_exception', name: '异常场景测试', group: 'testing', color: '#f97316', sortOrder: 22, isSystem: true },
|
||||
{ id: 'cat-test-compatibility', code: 'test_compatibility', name: '兼容性测试', group: 'testing', color: '#a855f7', sortOrder: 23, isSystem: true },
|
||||
{ id: 'cat-5', code: 'data_processing', name: '数据处理', group: 'implementation', color: '#f59e0b', sortOrder: 40, isSystem: true },
|
||||
{ id: 'cat-6', code: 'implementation_support', name: '实施支持', group: 'implementation', color: '#10b981', sortOrder: 41, isSystem: true },
|
||||
{ id: 'cat-other-doc', code: 'documentation', name: '文档', group: 'other', color: '#64748b', sortOrder: 60, isSystem: true },
|
||||
];
|
||||
|
||||
const LEGACY_CODE_BY_ID: Record<string, string> = {
|
||||
'cat-1': 'frontend_development',
|
||||
'cat-2': 'backend_development',
|
||||
'cat-3': 'database_schema',
|
||||
'cat-4': 'api_integration',
|
||||
'cat-5': 'data_processing',
|
||||
'cat-6': 'implementation_support',
|
||||
};
|
||||
|
||||
function slugifyCategoryName(name: string, index: number): string {
|
||||
return (
|
||||
name
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9\u4e00-\u9fa5]+/g, '_')
|
||||
.replace(/^_+|_+$/g, '') || `category_${index + 1}`
|
||||
);
|
||||
}
|
||||
|
||||
export function normalizeTaskCategory(category: Partial<TaskCategory> | undefined, index = 0): TaskCategory {
|
||||
const preset = PRESET_CATEGORIES.find((c) => c.id === category?.id);
|
||||
const name = category?.name || preset?.name || `分类 ${index + 1}`;
|
||||
return {
|
||||
id: category?.id || preset?.id || `cat-${index + 1}`,
|
||||
code: category?.code || LEGACY_CODE_BY_ID[category?.id ?? ''] || preset?.code || slugifyCategoryName(name, index),
|
||||
name,
|
||||
group: category?.group || preset?.group || 'other',
|
||||
color: category?.color ?? preset?.color,
|
||||
sortOrder: typeof category?.sortOrder === 'number' ? category.sortOrder : preset?.sortOrder ?? index + 1,
|
||||
isSystem: typeof category?.isSystem === 'boolean' ? category.isSystem : Boolean(preset?.isSystem),
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeTaskCategories(categories: Partial<TaskCategory>[] = []): TaskCategory[] {
|
||||
const merged: Partial<TaskCategory>[] = Array.isArray(categories) ? [...categories] : [];
|
||||
for (const preset of PRESET_CATEGORIES) {
|
||||
if (!merged.some((c) => c.id === preset.id)) merged.push(preset);
|
||||
}
|
||||
return merged.map((category, index) => normalizeTaskCategory(category, index));
|
||||
}
|
||||
|
||||
export function getCategoryById(categories: TaskCategory[], id: string): TaskCategory | undefined {
|
||||
return categories.find((c) => c.id === id);
|
||||
}
|
||||
@@ -31,3 +83,16 @@ export function getCategoryById(categories: TaskCategory[], id: string): TaskCat
|
||||
export function getCategoriesByGroup(categories: TaskCategory[], group: CategoryGroup): TaskCategory[] {
|
||||
return categories.filter((c) => c.group === group).sort((a, b) => a.sortOrder - b.sortOrder);
|
||||
}
|
||||
|
||||
export function findCategoryByCode(categories: TaskCategory[], code?: string): TaskCategory | undefined {
|
||||
if (!code) return undefined;
|
||||
return categories.find((c) => c.code === code);
|
||||
}
|
||||
|
||||
export function getDefaultCategoryByGroup(categories: TaskCategory[], group: CategoryGroup): TaskCategory {
|
||||
return getCategoriesByGroup(categories, group)[0] ?? categories[0] ?? PRESET_CATEGORIES[0];
|
||||
}
|
||||
|
||||
export function resolveCategoryIdFromCode(categories: TaskCategory[], code: string | undefined, fallbackGroup: CategoryGroup): string {
|
||||
return findCategoryByCode(categories, code)?.id ?? getDefaultCategoryByGroup(categories, fallbackGroup).id;
|
||||
}
|
||||
|
||||
85
apps/web/lib/version-plan-workflow.test.ts
Normal file
85
apps/web/lib/version-plan-workflow.test.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { getPlanCompletionState, hasPlanResult } from './version-plan-workflow';
|
||||
import type { VersionPlan } from './version-plan';
|
||||
|
||||
function plan(patch: Partial<VersionPlan>): VersionPlan {
|
||||
return {
|
||||
id: 'plan-1',
|
||||
versionId: 'version-1',
|
||||
type: 'product',
|
||||
title: '产品方案',
|
||||
owner: 'PM',
|
||||
startTime: '2026-06-25T09:00',
|
||||
endTime: '2026-06-25T18:00',
|
||||
status: 'in_progress',
|
||||
linkedRequirementIds: ['r1'],
|
||||
completedRequirementIds: [],
|
||||
createdAt: '2026-06-25',
|
||||
addedBy: 'PM',
|
||||
...patch,
|
||||
};
|
||||
}
|
||||
|
||||
test('does not allow research result submission when subtasks are incomplete', () => {
|
||||
const state = getPlanCompletionState(plan({
|
||||
type: 'research',
|
||||
tasks: [{ id: 'task-1', title: '调研方向', status: 'pending' }],
|
||||
linkedRequirementIds: [],
|
||||
}));
|
||||
assert.equal(state.canSubmitResult, false);
|
||||
assert.ok(state.missingReasons.includes('子任务未全部完成'));
|
||||
});
|
||||
|
||||
test('requires product requirement coverage when linked requirements exist', () => {
|
||||
const state = getPlanCompletionState(plan({}));
|
||||
assert.equal(state.canSubmitResult, false);
|
||||
assert.ok(state.missingReasons.includes('关联需求未全部覆盖'));
|
||||
});
|
||||
|
||||
test('allows product result submission after coverage is complete without task checklist', () => {
|
||||
const state = getPlanCompletionState(plan({
|
||||
completedRequirementIds: ['r1'],
|
||||
}));
|
||||
assert.equal(state.canSubmitResult, true);
|
||||
assert.equal(state.canComplete, false);
|
||||
});
|
||||
|
||||
test('allows completion only after result exists', () => {
|
||||
const state = getPlanCompletionState(plan({
|
||||
completedRequirementIds: ['r1'],
|
||||
resultType: 'link',
|
||||
resultTitle: '原型',
|
||||
resultUrl: 'https://example.com/prototype',
|
||||
}));
|
||||
assert.equal(state.canComplete, true);
|
||||
});
|
||||
|
||||
test('ui plan does not require task checklist', () => {
|
||||
const state = getPlanCompletionState(plan({
|
||||
type: 'ui',
|
||||
completedRequirementIds: ['r1'],
|
||||
}));
|
||||
assert.equal(state.canSubmitResult, true);
|
||||
});
|
||||
|
||||
test('research requires tasks and result but not requirement coverage', () => {
|
||||
const state = getPlanCompletionState(plan({
|
||||
type: 'research',
|
||||
tasks: [{ id: 'task-1', title: '调研', status: 'completed' }],
|
||||
linkedRequirementIds: ['r1'],
|
||||
completedRequirementIds: [],
|
||||
resultType: 'file',
|
||||
resultTitle: '调研报告',
|
||||
resultFileName: 'report.pdf',
|
||||
resultFileData: 'data:application/pdf;base64,abc',
|
||||
}));
|
||||
assert.equal(state.canComplete, true);
|
||||
});
|
||||
|
||||
test('detects link and file result payloads', () => {
|
||||
assert.equal(hasPlanResult({ resultType: 'link', resultTitle: '原型', resultUrl: 'https://example.com' }), true);
|
||||
assert.equal(hasPlanResult({ resultType: 'file', resultTitle: '文件', resultFileName: 'a.pdf', resultFileData: 'data:pdf' }), true);
|
||||
assert.equal(hasPlanResult({ resultType: 'link', resultTitle: '原型' }), false);
|
||||
});
|
||||
76
apps/web/lib/version-plan-workflow.ts
Normal file
76
apps/web/lib/version-plan-workflow.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import type { VersionPlan } from './version-plan';
|
||||
|
||||
export interface PlanResultPayload {
|
||||
resultType?: 'link' | 'file';
|
||||
resultTitle?: string;
|
||||
resultUrl?: string;
|
||||
resultFileName?: string;
|
||||
resultFileData?: string;
|
||||
}
|
||||
|
||||
export interface PlanCompletionState {
|
||||
checklistTotal: number;
|
||||
checklistCompleted: number;
|
||||
requirementTotal: number;
|
||||
requirementCompleted: number;
|
||||
hasResult: boolean;
|
||||
canSubmitResult: boolean;
|
||||
canComplete: boolean;
|
||||
missingReasons: string[];
|
||||
}
|
||||
|
||||
export function hasPlanResult(plan: PlanResultPayload): boolean {
|
||||
const hasTitle = Boolean(plan.resultTitle?.trim());
|
||||
if (!hasTitle || !plan.resultType) return false;
|
||||
if (plan.resultType === 'link') return Boolean(plan.resultUrl?.trim());
|
||||
return Boolean(plan.resultFileData || plan.resultFileName);
|
||||
}
|
||||
|
||||
function requiresRequirementCoverage(plan: VersionPlan): boolean {
|
||||
return plan.type === 'product' || plan.type === 'ui';
|
||||
}
|
||||
|
||||
function requiresChecklist(plan: VersionPlan): boolean {
|
||||
return plan.type === 'research';
|
||||
}
|
||||
|
||||
export function getPlanCompletionState(plan: VersionPlan): PlanCompletionState {
|
||||
const tasks = plan.tasks ?? [];
|
||||
const checklistTotal = tasks.length;
|
||||
const checklistCompleted = tasks.filter((task) => task.status === 'completed').length;
|
||||
|
||||
const linked = plan.linkedRequirementIds ?? [];
|
||||
const completed = new Set(plan.completedRequirementIds ?? []);
|
||||
const requirementTotal = linked.length;
|
||||
const requirementCompleted = linked.filter((id) => completed.has(id)).length;
|
||||
|
||||
const missingReasons: string[] = [];
|
||||
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('关联需求未全部覆盖');
|
||||
}
|
||||
|
||||
const canSubmitResult = missingReasons.length === 0;
|
||||
const hasResult = hasPlanResult(plan);
|
||||
if (canSubmitResult && !hasResult) missingReasons.push('尚未提交成果');
|
||||
|
||||
return {
|
||||
checklistTotal,
|
||||
checklistCompleted,
|
||||
requirementTotal,
|
||||
requirementCompleted,
|
||||
hasResult,
|
||||
canSubmitResult,
|
||||
canComplete: canSubmitResult && hasResult,
|
||||
missingReasons,
|
||||
};
|
||||
}
|
||||
|
||||
export function canTogglePlanChecklist(plan: VersionPlan, now: Date = new Date()): boolean {
|
||||
return plan.status === 'in_progress' || (plan.status === 'pending' && Boolean(plan.startTime) && new Date(plan.startTime) <= now);
|
||||
}
|
||||
|
||||
export function canEditPlanRequirementCoverage(plan: VersionPlan, now: Date = new Date()): boolean {
|
||||
return canTogglePlanChecklist(plan, now);
|
||||
}
|
||||
@@ -19,6 +19,7 @@ export interface VersionPlan {
|
||||
completedRequirementIds?: string[];
|
||||
linkedRequirementIds?: string[];
|
||||
resultType?: 'link' | 'file';
|
||||
resultTitle?: string;
|
||||
resultUrl?: string;
|
||||
resultFileName?: string;
|
||||
resultFileData?: string;
|
||||
@@ -28,6 +29,10 @@ export interface VersionPlan {
|
||||
createdAt: string;
|
||||
completedAt?: string;
|
||||
addedBy: string;
|
||||
aiDecomposeStatus?: 'idle' | 'in_progress' | 'completed' | 'error';
|
||||
aiDecomposeAt?: string;
|
||||
aiDecomposeBy?: string;
|
||||
aiDecomposeError?: string;
|
||||
}
|
||||
|
||||
export type PlanType = VersionPlan['type'];
|
||||
|
||||
Reference in New Issue
Block a user