refactor(data): 收口关系表运行时数据源
Some checks failed
Deploy Production / Build, push, deploy, verify (push) Has been cancelled

- 移除已迁移业务 AppData 运行时 fallback,改走领域 API 和关系表快读
- 补齐需求产品负责人、版本计划任务 JSON 和成员 username 回填迁移
- 统一治理字典入口,并补充 AI provider、数据源契约和领域服务测试

Co-Authored-By: Codex GPT-5 <codex@openai.com>
This commit is contained in:
2026-07-09 14:59:49 +08:00
parent 1e6fb0c7aa
commit 2e595c7e72
98 changed files with 2938 additions and 1441 deletions

View File

@@ -0,0 +1,9 @@
ALTER TABLE "requirements" ADD COLUMN "product_owner_id" TEXT;
ALTER TABLE "requirements"
ADD CONSTRAINT "requirements_product_owner_id_fkey"
FOREIGN KEY ("product_owner_id") REFERENCES "users"("id")
ON DELETE SET NULL ON UPDATE CASCADE;
CREATE INDEX "requirements_product_owner_created_at_idx"
ON "requirements"("product_owner_id", "created_at" DESC);

View File

@@ -0,0 +1,48 @@
WITH raw_members AS (
SELECT member
FROM app_data
CROSS JOIN LATERAL jsonb_array_elements(
CASE
WHEN jsonb_typeof(value -> 'members') = 'array' THEN value -> 'members'
ELSE '[]'::jsonb
END
) AS member
WHERE key = 'members'
),
members AS (
SELECT
NULLIF(member ->> 'id', '') AS id,
NULLIF(member ->> 'username', '') AS username,
NULLIF(member ->> 'departmentId', '') AS department_id,
NULLIF(member ->> 'roleId', '') AS role_id,
COALESCE(member ->> 'phone', '') AS phone,
COALESCE(member ->> 'password', '') AS password,
CASE
WHEN jsonb_typeof(member -> 'isSystem') = 'boolean' THEN (member ->> 'isSystem')::boolean
ELSE NULL
END AS is_system,
COUNT(*) OVER (PARTITION BY NULLIF(member ->> 'username', '')) AS username_count
FROM raw_members
)
UPDATE users AS u
SET
username = CASE
WHEN NULLIF(u.username, '') IS NULL
AND m.username IS NOT NULL
AND m.username_count = 1
AND NOT EXISTS (
SELECT 1
FROM users AS other_user
WHERE other_user.id <> u.id
AND other_user.username = m.username
)
THEN m.username
ELSE u.username
END,
department_id = COALESCE(m.department_id, u.department_id),
role_id = COALESCE(m.role_id, u.role_id, 'member'),
phone = COALESCE(m.phone, u.phone, ''),
password = COALESCE(m.password, u.password, ''),
is_system = COALESCE(m.is_system, u.is_system, false)
FROM members AS m
WHERE u.id = m.id;

View File

@@ -0,0 +1,2 @@
ALTER TABLE "version_plans"
ADD COLUMN IF NOT EXISTS "tasks" JSONB NOT NULL DEFAULT '[]';

View File

@@ -24,7 +24,8 @@ model User {
createdTasks Task[] @relation("TaskCreator")
assignedTasks Task[] @relation("TaskAssignee")
projectMembers ProjectMember[]
requirements Requirement[]
requirements Requirement[] @relation("RequirementCreator")
productOwnedRequirements Requirement[] @relation("RequirementProductOwner")
watchedTasks TaskWatcher[]
@@map("users")
@@ -116,11 +117,13 @@ model Requirement {
sourceTarget String? @map("source_target")
platform String?
creatorId String? @map("creator_id")
productOwnerId String? @map("product_owner_id")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
product Product @relation(fields: [productId], references: [id])
creator User? @relation(fields: [creatorId], references: [id])
creator User? @relation("RequirementCreator", fields: [creatorId], references: [id])
productOwner User? @relation("RequirementProductOwner", fields: [productOwnerId], references: [id])
@@id([id, productId])
@@unique([productId, code])
@@ -290,6 +293,7 @@ model VersionPlan {
actualStartAt DateTime? @map("actual_start_at")
completedAt DateTime? @map("completed_at")
resultUrl String? @map("result_url")
tasks Json @default("[]")
requirementCoverage Json @default("[]") @map("requirement_coverage")
logs Json @default("[]")
createdAt DateTime @default(now()) @map("created_at")

View File

@@ -0,0 +1,25 @@
export async function resolveUserReference(prisma: any, reference: string | null | undefined): Promise<string | null> {
const normalized = emptyToNull(reference);
if (!normalized) return null;
if (!prisma?.user?.findFirst) return null;
const user = await prisma.user.findFirst({
where: {
OR: [
{ id: normalized },
{ name: normalized },
{ username: normalized },
{ email: normalized },
],
},
select: { id: true },
});
return user?.id ?? null;
}
function emptyToNull(value: string | null | undefined): string | null {
if (value === null) return null;
if (value === undefined) return null;
const trimmed = value.trim();
return trimmed ? trimmed : null;
}

View File

@@ -271,6 +271,35 @@ describe('AiService', () => {
expect(result).toMatchObject({ ok: false, code: 'PARSE_ERROR' });
});
it('returns a readable timeout error when decomposition provider request is aborted', async () => {
const callTool = jest.fn().mockRejectedValue(new Error('signal is aborted without reason'));
const gateway = {
getActiveProvider: jest.fn().mockResolvedValue({
callTool,
}),
getActiveModel: jest.fn().mockResolvedValue('test-model'),
} as unknown as AiGatewayService;
const service = new AiService(gateway);
(service as any).fetchPrototype = jest.fn().mockResolvedValue('QY0001手机号登录');
const result = await service.decompose({
prototypeUrl: 'https://example.com/prototype',
requirements: [{ id: 'req-1', code: 'REQ001', title: '手机号登录' }],
members: [{ name: '张三', role: 'frontend' }],
versionId: 'version-1',
planId: 'plan-1',
target: 'dev_tasks',
});
expect(result).toMatchObject({
ok: false,
code: 'API_TIMEOUT',
error: expect.stringContaining('AI 服务调用超时'),
});
expect(callTool).toHaveBeenCalledWith(expect.objectContaining({ timeoutMs: expect.any(Number) }));
expect(result.ok ? '' : result.error).not.toContain('signal is aborted without reason');
});
it('retries once with shorter prototype context when Anthropic returns no tool_use', async () => {
const callTool = jest
.fn()

View File

@@ -26,6 +26,7 @@ import type {
} from '@ftb/shared';
const DECOMPOSE_CONTEXT_CHAR_STEPS = [1800, 1200, 800] as const;
const DECOMPOSE_AI_TIMEOUT_MS = 110000;
const PROTOTYPE_FETCH_TIMEOUT_MS = 15000;
const PROTOTYPE_FETCH_ATTEMPTS = 3;
const PROTOTYPE_FETCH_RETRY_DELAY_MS = 500;
@@ -97,6 +98,7 @@ export class AiService {
forceTool: true,
maxTokens: 16000,
model,
timeoutMs: DECOMPOSE_AI_TIMEOUT_MS,
});
break;
} catch (e: any) {
@@ -108,6 +110,15 @@ export class AiService {
continue;
}
if (this.isProviderTimeoutError(e)) {
this.logger.warn(`AI 拆解调用超时: ${e.message}`);
return {
ok: false,
error: `AI 服务调用超时:上游模型在${this.formatTimeout(DECOMPOSE_AI_TIMEOUT_MS)}内未返回拆解结果,请稍后重试,或在 AI 配置中切换更快/更稳定的提供商。`,
code: 'API_TIMEOUT',
};
}
this.logger.error(`AI 调用失败: ${e.message}`);
return {
ok: false,
@@ -267,6 +278,17 @@ export class AiService {
return message.includes('tool_call arguments') && message.includes('JSON');
}
private isProviderTimeoutError(error: any): boolean {
const name = String(error?.name || '');
const message = String(error?.message || error || '');
return /timeout|timed out|aborted|abort/i.test(`${name} ${message}`);
}
private formatTimeout(ms: number): string {
if (ms >= 60000) return `${Math.ceil(ms / 60000)} 分钟`;
return `${Math.ceil(ms / 1000)}`;
}
private async fetchPrototype(url: string): Promise<string> {
if (!url || !url.startsWith('http')) {
throw new Error('原型链接无效');

View File

@@ -103,4 +103,48 @@ describe('AnthropicProvider', () => {
}),
);
});
it('passes request timeout and disables SDK retries for tool calls', async () => {
mockCreate.mockResolvedValue({
stop_reason: 'tool_use',
model: 'claude-test',
content: [
{
type: 'tool_use',
id: 'toolu_test',
name: 'submit_decompose',
input: { ok: true },
},
],
usage: {
input_tokens: 12,
output_tokens: 34,
},
});
const provider = new AnthropicProvider('test-key', 'https://example.test');
await provider.callTool({
systemPrompt: '只通过工具返回',
userPrompt: '拆解这个原型',
tool: {
name: 'submit_decompose',
description: '提交拆解结果',
inputSchema: {
type: 'object',
properties: { ok: { type: 'boolean' } },
required: ['ok'],
},
},
forceTool: true,
maxTokens: 1000,
model: 'claude-test',
timeoutMs: 110000,
});
expect(mockCreate).toHaveBeenCalledWith(
expect.any(Object),
{ timeout: 110000, maxRetries: 0 },
);
});
});

View File

@@ -24,7 +24,7 @@ export class AnthropicProvider implements AiProvider {
} as any)
: { type: 'auto' as const };
const response = await this.client.messages.create({
const body = {
model: req.model,
max_tokens: req.maxTokens,
system: req.systemPrompt,
@@ -36,8 +36,11 @@ export class AnthropicProvider implements AiProvider {
},
],
tool_choice: toolChoice,
messages: [{ role: 'user', content: req.userPrompt }],
});
messages: [{ role: 'user' as const, content: req.userPrompt }],
};
const response = req.timeoutMs
? await this.client.messages.create(body, { timeout: req.timeoutMs, maxRetries: 0 })
: await this.client.messages.create(body);
const toolUse = response.content.find(
(c): c is Anthropic.ToolUseBlock => c.type === 'tool_use',

View File

@@ -0,0 +1,70 @@
import { OpenAIProvider } from './openai.provider';
const mockCreate = jest.fn();
jest.mock('openai', () => ({
__esModule: true,
default: jest.fn().mockImplementation(() => ({
chat: {
completions: {
create: mockCreate,
},
},
})),
}));
describe('OpenAIProvider', () => {
beforeEach(() => {
mockCreate.mockReset();
});
it('passes request timeout and disables SDK retries for tool calls', async () => {
mockCreate.mockResolvedValue({
model: 'openai-test',
choices: [
{
message: {
tool_calls: [
{
type: 'function',
function: {
name: 'submit_decompose',
arguments: JSON.stringify({ ok: true }),
},
},
],
},
},
],
usage: {
prompt_tokens: 12,
completion_tokens: 34,
},
});
const provider = new OpenAIProvider('test-key', 'https://example.test/v1');
await provider.callTool({
systemPrompt: '只通过工具返回',
userPrompt: '拆解这个原型',
tool: {
name: 'submit_decompose',
description: '提交拆解结果',
inputSchema: {
type: 'object',
properties: { ok: { type: 'boolean' } },
required: ['ok'],
},
},
forceTool: true,
maxTokens: 1000,
model: 'openai-test',
timeoutMs: 110000,
});
expect(mockCreate).toHaveBeenCalledWith(
expect.any(Object),
{ timeout: 110000, maxRetries: 0 },
);
});
});

View File

@@ -16,16 +16,16 @@ export class OpenAIProvider implements AiProvider {
}
async callTool(req: ToolCallRequest): Promise<ToolCallResponse> {
const response = await this.client.chat.completions.create({
const body = {
model: req.model,
max_tokens: req.maxTokens,
messages: [
{ role: 'system', content: req.systemPrompt },
{ role: 'user', content: req.userPrompt },
{ role: 'system' as const, content: req.systemPrompt },
{ role: 'user' as const, content: req.userPrompt },
],
tools: [
{
type: 'function',
type: 'function' as const,
function: {
name: req.tool.name,
description: req.tool.description,
@@ -34,9 +34,12 @@ export class OpenAIProvider implements AiProvider {
},
],
tool_choice: req.forceTool
? { type: 'function', function: { name: req.tool.name } }
: 'auto',
});
? { type: 'function' as const, function: { name: req.tool.name } }
: 'auto' as const,
};
const response = req.timeoutMs
? await this.client.chat.completions.create(body, { timeout: req.timeoutMs, maxRetries: 0 })
: await this.client.chat.completions.create(body);
const choice = response.choices[0];
if (!choice?.message?.tool_calls?.length) {

View File

@@ -17,6 +17,7 @@ export interface ToolCallRequest {
forceTool: boolean;
maxTokens: number;
model: string;
timeoutMs?: number;
}
export interface ToolCallResponse {

View File

@@ -27,6 +27,16 @@ describe('AppDataRetirementService', () => {
}
});
it('keeps members AppData writable for legacy department role and password-rule config', () => {
expect(APP_DATA_RETIREMENT_CONFIG.members.state).toBe('active');
expect(() => service.assertWritable('members')).not.toThrow();
});
it('keeps overtime AppData writable for legacy overtime reason config', () => {
expect(APP_DATA_RETIREMENT_CONFIG.overtime.state).toBe('active');
expect(() => service.assertWritable('overtime')).not.toThrow();
});
it('treats archived documents as read-only', () => {
try {
service.assertWritable('xiaobao-risk-snapshots');

View File

@@ -35,9 +35,9 @@ export const APP_DATA_RETIREMENT_CONFIG = {
replacement: '/api/v1/versions/:versionId/bugs',
},
members: {
state: 'write_frozen',
replacement: '/api/v1/members',
note: '成员身份已迁移;部门、角色和密码策略仍需 V2.7 配置表承接。',
state: 'active',
replacement: '/api/v1/members for member identities; legacy members AppData remains temporary storage for departments, roles, and password policy.',
note: '成员身份已迁移到领域 API;部门、角色和密码策略 V2.7 配置表承接前仍临时保留 AppData 写入。',
},
'task-categories': {
state: 'write_frozen',
@@ -67,9 +67,9 @@ export const APP_DATA_RETIREMENT_CONFIG = {
note: '个人已读状态等待企业协作/通知治理阶段承接。',
},
overtime: {
state: 'write_frozen',
replacement: '/api/v1/overtime',
note: '加班记录已迁移;加班原因配置仍需 V2.7 配置表承接。',
state: 'active',
replacement: '/api/v1/overtime for overtime records; legacy overtime AppData remains temporary storage for overtime reason config.',
note: '加班记录已迁移到领域 API;加班原因配置 V2.7 配置表承接前仍临时保留 AppData 写入。',
},
} satisfies Record<AppDataKey, AppDataRetirementEntry>;

View File

@@ -7,6 +7,9 @@ describe('BugService domain writes', () => {
record: jest.fn().mockResolvedValue({ id: 'activity-1' }),
};
const prisma = {
user: {
findFirst: createUserFindFirstMock(),
},
version: {
findUnique: jest.fn(),
},
@@ -26,6 +29,61 @@ describe('BugService domain writes', () => {
};
};
it('resolves assignee and reporter names to user ids before relation writes', async () => {
const { prisma, service } = makeService();
prisma.user.findFirst = createUserFindFirstMock({ : 'dev-1', : 'qa-1' });
prisma.version.findUnique.mockResolvedValue({ id: 'version-1', productId: 'product-1', projectId: 'project-1' });
prisma.bug.create.mockResolvedValue({ id: 'bug-1', versionId: 'version-1', title: '登录报错' });
await service.create('version-1', {
title: '登录报错',
assigneeId: '开发',
reportedBy: '测试',
} as any);
expect(prisma.user.findFirst).toHaveBeenCalledTimes(2);
expect(prisma.bug.create).toHaveBeenCalledWith({
data: expect.objectContaining({
assigneeId: 'dev-1',
reporterId: 'qa-1',
}),
});
});
it('resolves transfer assignee names before updating bug foreign keys', async () => {
const { prisma, workActivity, service } = makeService();
prisma.user.findFirst = createUserFindFirstMock({ : 'dev-2' });
prisma.bug.findFirst.mockResolvedValue({
id: 'bug-1',
versionId: 'version-1',
productId: 'product-1',
projectId: 'project-1',
status: 'open',
title: '登录报错',
assigneeId: 'dev-1',
reporterId: 'qa-1',
});
prisma.bug.update.mockResolvedValue({
id: 'bug-1',
versionId: 'version-1',
productId: 'product-1',
projectId: 'project-1',
title: '登录报错',
assigneeId: 'dev-2',
reporterId: 'qa-1',
});
await service.transfer('version-1', 'bug-1', '李四', 'qa-1');
expect(prisma.bug.update).toHaveBeenCalledWith({
where: { id_versionId: { id: 'bug-1', versionId: 'version-1' } },
data: { assigneeId: 'dev-2' },
});
expect(workActivity.record).toHaveBeenCalledWith(expect.objectContaining({
metadata: expect.objectContaining({ fromAssigneeId: 'dev-1', toAssigneeId: 'dev-2' }),
}));
});
it('creates bugs directly under a version partition', async () => {
const { prisma, workActivity, service } = makeService();
prisma.version.findUnique.mockResolvedValue({ id: 'version-1', productId: 'product-1', projectId: 'project-1' });
@@ -151,3 +209,13 @@ describe('BugService domain writes', () => {
expect(prisma.bug.update).not.toHaveBeenCalled();
});
});
function createUserFindFirstMock(mapping: Record<string, string> = {}) {
return jest.fn(({ where }: any) => {
const refs = (where?.OR ?? [])
.flatMap((condition: Record<string, string>) => Object.values(condition))
.filter(Boolean);
const ref = refs[0];
return Promise.resolve(ref ? { id: mapping[ref] ?? ref } : null);
});
}

View File

@@ -1,4 +1,5 @@
import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common';
import { resolveUserReference } from '../../common/user-reference';
import { WorkActivityService } from '../work-activity/work-activity.service';
import { CreateBugDto } from './dto/create-bug.dto';
import { UpdateBugDto } from './dto/update-bug.dto';
@@ -14,9 +15,10 @@ export class BugService {
async create(versionId: string, dto: CreateBugDto) {
const version = await this.ensureVersion(versionId);
const bugData = await this.toBugData(dto);
const item = await this.prisma.bug.create({
data: {
...this.toBugData(dto),
...bugData,
versionId,
productId: version.productId,
projectId: version.projectId,
@@ -43,7 +45,7 @@ export class BugService {
await this.ensureBugInVersion(versionId, id);
const item = await this.prisma.bug.update({
where: { id_versionId: { id, versionId } },
data: this.toBugData(dto),
data: await this.toBugData(dto),
});
await this.workActivity.markXiaobaoSummaryDirty(versionId);
return { item, activities: [] };
@@ -70,16 +72,17 @@ export class BugService {
async transfer(versionId: string, id: string, assigneeId: string, operator?: string) {
const current = await this.ensureBugInVersion(versionId, id);
const resolvedAssigneeId = await resolveUserReference(this.prisma, assigneeId);
const item = await this.prisma.bug.update({
where: { id_versionId: { id, versionId } },
data: { assigneeId },
data: { assigneeId: resolvedAssigneeId },
});
const activity = await this.recordBugActivity(
item,
'bug_transferred',
'progress',
`转交 Bug${item.title}${assigneeId}`,
{ fromAssigneeId: current.assigneeId, toAssigneeId: assigneeId, operator },
`转交 Bug${item.title}${resolvedAssigneeId ?? '-'}`,
{ fromAssigneeId: current.assigneeId, toAssigneeId: resolvedAssigneeId, operator },
);
return { item, activities: [activity] };
}
@@ -91,7 +94,7 @@ export class BugService {
return item;
}
private toBugData(dto: Partial<CreateBugDto>) {
private async toBugData(dto: Partial<CreateBugDto>) {
return {
...(dto.testCaseId !== undefined && { testCaseId: emptyToNull(dto.testCaseId) }),
...(dto.testCaseVersionId !== undefined && { testCaseVersionId: emptyToNull(dto.testCaseVersionId) }),
@@ -101,8 +104,10 @@ export class BugService {
...(dto.status !== undefined && { status: dto.status }),
...(dto.severity !== undefined && { severity: dto.severity ?? 'minor' }),
...(dto.priority !== undefined && { priority: parsePriority(dto.priority) ?? 0 }),
...(dto.assigneeId !== undefined && { assigneeId: emptyToNull(dto.assigneeId) }),
...(dto.reporterId !== undefined || dto.reportedBy !== undefined ? { reporterId: emptyToNull(dto.reporterId ?? dto.reportedBy) } : {}),
...(dto.assigneeId !== undefined && { assigneeId: await resolveUserReference(this.prisma, dto.assigneeId) }),
...(dto.reporterId !== undefined || dto.reportedBy !== undefined
? { reporterId: await resolveUserReference(this.prisma, dto.reporterId ?? dto.reportedBy) }
: {}),
...(dto.plannedFixAt !== undefined && { plannedFixAt: parseOptionalDate(dto.plannedFixAt) }),
...(dto.resolvedAt !== undefined && { resolvedAt: parseOptionalDate(dto.resolvedAt) }),
...(dto.closedAt !== undefined && { closedAt: parseOptionalDate(dto.closedAt) }),

View File

@@ -1,4 +1,5 @@
import { BadRequestException, ConflictException } from '@nestjs/common';
import { AppDataRetirementService } from '../app-data-retirement/app-data-retirement.service';
import { DataService } from './data.service';
describe('DataService', () => {
@@ -14,9 +15,7 @@ describe('DataService', () => {
const syncService = {
syncAfterAppDataPut: jest.fn(),
};
const retirementService = {
assertWritable: jest.fn(),
};
const retirementService = new AppDataRetirementService();
return {
prisma,
syncService,
@@ -58,36 +57,29 @@ describe('DataService', () => {
await expect(service.get('unknown-key')).rejects.toBeInstanceOf(BadRequestException);
});
it('upserts JSON values for allowed keys', async () => {
it('upserts JSON values for active config keys', async () => {
const { prisma, service, syncService, retirementService } = makeService();
const value = [{ id: 'p1', name: 'Product 1' }];
const value = { departments: [], members: [], roles: [], passwordRule: null };
const updatedAt = new Date('2026-07-02T08:01:00.000Z');
prisma.appData.upsert.mockResolvedValue({ key: 'products-overview', value, updatedAt });
const writableSpy = jest.spyOn(retirementService, 'assertWritable');
prisma.appData.upsert.mockResolvedValue({ key: 'members', value, updatedAt });
await expect(service.put('products-overview', value)).resolves.toEqual({
key: 'products-overview',
await expect(service.put('members', value)).resolves.toEqual({
key: 'members',
value,
version: updatedAt.toISOString(),
});
expect(prisma.appData.upsert).toHaveBeenCalledWith({
where: { key: 'products-overview' },
where: { key: 'members' },
update: { value },
create: { key: 'products-overview', value },
create: { key: 'members', value },
});
expect(retirementService.assertWritable).toHaveBeenCalledWith('products-overview');
expect(syncService.syncAfterAppDataPut).toHaveBeenCalledWith('products-overview');
expect(writableSpy).toHaveBeenCalledWith('members');
expect(syncService.syncAfterAppDataPut).toHaveBeenCalledWith('members');
});
it('rejects frozen AppData writes before touching storage or relation sync', async () => {
const { prisma, service, syncService, retirementService } = makeService();
retirementService.assertWritable.mockImplementation(() => {
throw new ConflictException({
code: 'APP_DATA_WRITE_FROZEN',
key: 'dev-tasks',
state: 'write_frozen',
replacement: '/api/v1/versions/:versionId/dev-tasks',
});
});
const { prisma, service, syncService } = makeService();
await expect(service.put('dev-tasks', [{ id: 'dt-1' }])).rejects.toMatchObject({
response: expect.objectContaining({
@@ -102,59 +94,62 @@ describe('DataService', () => {
expect(syncService.syncAfterAppDataPut).not.toHaveBeenCalled();
});
it('allows supporting business data keys migrated from browser storage', async () => {
it('allows only active config AppData keys after business key retirement', async () => {
const { prisma, service } = makeService();
const value: unknown[] = [];
const membersValue = { departments: [], members: [], roles: [], passwordRule: null };
const overtimeValue = { records: [], reasons: [] };
prisma.appData.upsert.mockImplementation(({ where }) =>
Promise.resolve({ key: where.key, value, updatedAt: new Date('2026-07-02T08:02:00.000Z') }),
Promise.resolve({
key: where.key,
value: where.key === 'members' ? membersValue : overtimeValue,
updatedAt: new Date('2026-07-02T08:02:00.000Z'),
}),
);
await expect(service.put('task-worklogs', value)).resolves.toMatchObject({ key: 'task-worklogs', value });
await expect(service.put('work-activities', value)).resolves.toMatchObject({ key: 'work-activities', value });
await expect(service.put('overtime', { records: [], reasons: [] })).resolves.toMatchObject({
key: 'overtime',
value,
});
await expect(service.put('members', membersValue)).resolves.toMatchObject({ key: 'members', value: membersValue });
await expect(service.put('overtime', overtimeValue)).resolves.toMatchObject({ key: 'overtime', value: overtimeValue });
await expect(service.put('task-worklogs', [])).rejects.toBeInstanceOf(ConflictException);
await expect(service.put('work-activities', [])).rejects.toBeInstanceOf(ConflictException);
});
it('updates only when the supplied version matches the stored row version', async () => {
const { prisma, service, syncService } = makeService();
const previousVersion = '2026-07-02T08:03:00.000Z';
const nextUpdatedAt = new Date('2026-07-02T08:04:00.000Z');
const nextValue = [{ id: 'p2', name: 'Product 2' }];
const nextValue = { departments: [], members: [], roles: [], passwordRule: null };
prisma.appData.updateMany.mockResolvedValue({ count: 1 });
prisma.appData.findUnique.mockResolvedValue({
key: 'products-overview',
key: 'members',
value: nextValue,
updatedAt: nextUpdatedAt,
});
await expect(service.put('products-overview', nextValue, previousVersion)).resolves.toEqual({
key: 'products-overview',
await expect(service.put('members', nextValue, previousVersion)).resolves.toEqual({
key: 'members',
value: nextValue,
version: nextUpdatedAt.toISOString(),
});
expect(prisma.appData.updateMany).toHaveBeenCalledWith({
where: {
key: 'products-overview',
key: 'members',
updatedAt: new Date(previousVersion),
},
data: { value: nextValue },
});
expect(syncService.syncAfterAppDataPut).toHaveBeenCalledWith('products-overview');
expect(syncService.syncAfterAppDataPut).toHaveBeenCalledWith('members');
});
it('rejects stale AppData versions without overwriting the current value', async () => {
const { prisma, service, syncService } = makeService();
prisma.appData.updateMany.mockResolvedValue({ count: 0 });
prisma.appData.findUnique.mockResolvedValue({
key: 'products-overview',
value: [{ id: 'current' }],
key: 'members',
value: { departments: [], members: [], roles: [], passwordRule: null },
updatedAt: new Date('2026-07-02T08:05:00.000Z'),
});
await expect(
service.put('products-overview', [{ id: 'stale' }], '2026-07-02T08:03:00.000Z'),
service.put('members', { departments: [], members: [], roles: [], passwordRule: null }, '2026-07-02T08:03:00.000Z'),
).rejects.toBeInstanceOf(ConflictException);
expect(prisma.appData.upsert).not.toHaveBeenCalled();
expect(syncService.syncAfterAppDataPut).not.toHaveBeenCalled();
@@ -162,32 +157,32 @@ describe('DataService', () => {
it('creates a missing row only when the client loaded a null version', async () => {
const { prisma, service, syncService } = makeService();
const value = [{ id: 'p1' }];
const value = { departments: [], members: [], roles: [], passwordRule: null };
const updatedAt = new Date('2026-07-02T08:06:00.000Z');
prisma.appData.create.mockResolvedValue({ key: 'products-overview', value, updatedAt });
prisma.appData.create.mockResolvedValue({ key: 'members', value, updatedAt });
await expect(service.put('products-overview', value, null)).resolves.toEqual({
key: 'products-overview',
await expect(service.put('members', value, null)).resolves.toEqual({
key: 'members',
value,
version: updatedAt.toISOString(),
});
expect(prisma.appData.create).toHaveBeenCalledWith({
data: { key: 'products-overview', value },
data: { key: 'members', value },
});
expect(prisma.appData.upsert).not.toHaveBeenCalled();
expect(syncService.syncAfterAppDataPut).toHaveBeenCalledWith('products-overview');
expect(syncService.syncAfterAppDataPut).toHaveBeenCalledWith('members');
});
it('rejects create-only writes when another client created the row first', async () => {
const { prisma, service, syncService } = makeService();
prisma.appData.create.mockRejectedValue({ code: 'P2002' });
prisma.appData.findUnique.mockResolvedValue({
key: 'products-overview',
value: [{ id: 'current' }],
key: 'members',
value: { departments: [], members: [], roles: [], passwordRule: null },
updatedAt: new Date('2026-07-02T08:07:00.000Z'),
});
await expect(service.put('products-overview', [{ id: 'new' }], null)).rejects.toBeInstanceOf(
await expect(service.put('members', { departments: [], members: [], roles: [], passwordRule: null }, null)).rejects.toBeInstanceOf(
ConflictException,
);
expect(syncService.syncAfterAppDataPut).not.toHaveBeenCalled();
@@ -196,24 +191,24 @@ describe('DataService', () => {
it('rejects invalid AppData versions', async () => {
const { service } = makeService();
await expect(service.put('products-overview', [], 'not-a-date')).rejects.toBeInstanceOf(
await expect(service.put('members', { departments: [], members: [], roles: [], passwordRule: null }, 'not-a-date')).rejects.toBeInstanceOf(
BadRequestException,
);
});
it('keeps the AppData response successful when relation sync fails', async () => {
const { prisma, service, syncService } = makeService();
const value = [{ id: 'p1' }];
const value = { departments: [], members: [], roles: [], passwordRule: null };
const updatedAt = new Date('2026-07-02T08:08:00.000Z');
const warnSpy = jest.spyOn((service as any).logger, 'warn').mockImplementation();
prisma.appData.upsert.mockResolvedValue({ key: 'products-overview', value, updatedAt });
prisma.appData.upsert.mockResolvedValue({ key: 'members', value, updatedAt });
syncService.syncAfterAppDataPut.mockRejectedValue(new Error('sync failed'));
await expect(service.put('products-overview', value)).resolves.toEqual({
key: 'products-overview',
await expect(service.put('members', value)).resolves.toEqual({
key: 'members',
value,
version: updatedAt.toISOString(),
});
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('products-overview'));
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('members'));
});
});

View File

@@ -7,6 +7,9 @@ describe('DevTaskService domain writes', () => {
record: jest.fn().mockResolvedValue({ id: 'activity-1' }),
};
const prisma = {
user: {
findFirst: createUserFindFirstMock(),
},
version: {
findUnique: jest.fn(),
},
@@ -26,6 +29,58 @@ describe('DevTaskService domain writes', () => {
};
};
it('resolves assignee and creator names to user ids before relation writes', async () => {
const { prisma, service } = makeService();
prisma.user.findFirst = createUserFindFirstMock({ : 'dev-1', : 'pm-1' });
prisma.version.findUnique.mockResolvedValue({ id: 'version-1', productId: 'product-1', projectId: 'project-1' });
prisma.devTask.create.mockResolvedValue({ id: 'task-1', versionId: 'version-1', title: '开发登录' });
await service.create('version-1', {
title: '开发登录',
assigneeId: '张三',
createdBy: '产品经理',
} as any);
expect(prisma.user.findFirst).toHaveBeenCalledTimes(2);
expect(prisma.devTask.create).toHaveBeenCalledWith({
data: expect.objectContaining({
assigneeId: 'dev-1',
creatorId: 'pm-1',
}),
});
});
it('resolves transfer assignee names before updating foreign keys', async () => {
const { prisma, workActivity, service } = makeService();
prisma.user.findFirst = createUserFindFirstMock({ : 'dev-2' });
prisma.devTask.findFirst.mockResolvedValue({
id: 'task-1',
versionId: 'version-1',
productId: 'product-1',
projectId: 'project-1',
title: '开发登录',
assigneeId: 'dev-1',
});
prisma.devTask.update.mockResolvedValue({
id: 'task-1',
versionId: 'version-1',
productId: 'product-1',
projectId: 'project-1',
title: '开发登录',
assigneeId: 'dev-2',
});
await service.transfer('version-1', 'task-1', '李四');
expect(prisma.devTask.update).toHaveBeenCalledWith({
where: { id_versionId: { id: 'task-1', versionId: 'version-1' } },
data: { assigneeId: 'dev-2' },
});
expect(workActivity.record).toHaveBeenCalledWith(expect.objectContaining({
metadata: expect.objectContaining({ fromAssigneeId: 'dev-1', toAssigneeId: 'dev-2' }),
}));
});
it('creates dev tasks directly under a version partition', async () => {
const { prisma, workActivity, service } = makeService();
prisma.version.findUnique.mockResolvedValue({ id: 'version-1', productId: 'product-1', projectId: 'project-1' });
@@ -151,3 +206,13 @@ describe('DevTaskService domain writes', () => {
expect(prisma.devTask.update).not.toHaveBeenCalled();
});
});
function createUserFindFirstMock(mapping: Record<string, string> = {}) {
return jest.fn(({ where }: any) => {
const refs = (where?.OR ?? [])
.flatMap((condition: Record<string, string>) => Object.values(condition))
.filter(Boolean);
const ref = refs[0];
return Promise.resolve(ref ? { id: mapping[ref] ?? ref } : null);
});
}

View File

@@ -1,4 +1,5 @@
import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common';
import { resolveUserReference } from '../../common/user-reference';
import { WorkActivityService } from '../work-activity/work-activity.service';
import { CreateDevTaskDto } from './dto/create-dev-task.dto';
import { UpdateDevTaskDto } from './dto/update-dev-task.dto';
@@ -17,9 +18,10 @@ export class DevTaskService {
if (!version.projectId) {
throw new BadRequestException('开发任务所属版本必须归属于项目');
}
const taskData = await this.toTaskData(dto);
const item = await this.prisma.devTask.create({
data: {
...this.toTaskData(dto),
...taskData,
versionId,
productId: version.productId,
projectId: version.projectId,
@@ -44,7 +46,7 @@ export class DevTaskService {
await this.ensureTaskInVersion(versionId, id);
const item = await this.prisma.devTask.update({
where: { id_versionId: { id, versionId } },
data: this.toTaskData(dto),
data: await this.toTaskData(dto),
});
await this.workActivity.markXiaobaoSummaryDirty(versionId);
return { item, activities: [] };
@@ -84,16 +86,17 @@ export class DevTaskService {
async transfer(versionId: string, id: string, assigneeId: string) {
const current = await this.ensureTaskInVersion(versionId, id);
const resolvedAssigneeId = await resolveUserReference(this.prisma, assigneeId);
const item = await this.prisma.devTask.update({
where: { id_versionId: { id, versionId } },
data: { assigneeId },
data: { assigneeId: resolvedAssigneeId },
});
const activity = await this.recordTaskActivity(
item,
'dev_task_transferred',
'progress',
`转派开发任务:${item.title}`,
{ fromAssigneeId: current.assigneeId, toAssigneeId: assigneeId },
{ fromAssigneeId: current.assigneeId, toAssigneeId: resolvedAssigneeId },
);
return { item, activities: [activity] };
}
@@ -105,7 +108,7 @@ export class DevTaskService {
return item;
}
private toTaskData(dto: Partial<CreateDevTaskDto>) {
private async toTaskData(dto: Partial<CreateDevTaskDto>) {
return {
...(dto.requirementId !== undefined && { requirementId: emptyToNull(dto.requirementId) }),
...(dto.requirementProductId !== undefined && { requirementProductId: emptyToNull(dto.requirementProductId) }),
@@ -115,8 +118,10 @@ export class DevTaskService {
...(dto.description !== undefined && { description: dto.description ?? '' }),
...(dto.status !== undefined && { status: dto.status }),
...(dto.priority !== undefined && { priority: parsePriority(dto.priority) ?? 0 }),
...(dto.assigneeId !== undefined && { assigneeId: emptyToNull(dto.assigneeId) }),
...(dto.creatorId !== undefined || dto.createdBy !== undefined ? { creatorId: emptyToNull(dto.creatorId ?? dto.createdBy) } : {}),
...(dto.assigneeId !== undefined && { assigneeId: await resolveUserReference(this.prisma, dto.assigneeId) }),
...(dto.creatorId !== undefined || dto.createdBy !== undefined
? { creatorId: await resolveUserReference(this.prisma, dto.creatorId ?? dto.createdBy) }
: {}),
...(dto.isBlocked !== undefined && { isBlocked: dto.isBlocked }),
...(dto.blockReason !== undefined && { blockReason: emptyToNull(dto.blockReason) }),
...(dto.expectedStartAt !== undefined && { expectedStartAt: parseOptionalDate(dto.expectedStartAt) }),

View File

@@ -20,6 +20,9 @@ describe('GovernanceService', () => {
update: jest.fn(),
upsert: jest.fn(),
},
appData: {
findUnique: jest.fn(),
},
};
const audit = { record: jest.fn() } as unknown as AuditService;
const rbac = {
@@ -80,6 +83,56 @@ describe('GovernanceService', () => {
}));
});
it('does not backfill requirement dictionaries from legacy requirement AppData during runtime list', async () => {
const { prisma, service } = makeService();
prisma.governanceDictionary.findMany.mockResolvedValue([]);
await expect(service.list('requirement_type')).resolves.toEqual([]);
expect(prisma.appData.findUnique).not.toHaveBeenCalled();
expect(prisma.governanceDictionary.upsert).not.toHaveBeenCalled();
});
it('seeds default task categories without reading legacy AppData on first governance list', async () => {
const { prisma, service } = makeService();
const seeded = [{ id: 'cat-1', name: '前端开发', group: 'development' }];
prisma.taskCategory.findMany
.mockResolvedValueOnce([])
.mockResolvedValueOnce(seeded);
prisma.taskCategory.create.mockResolvedValue(seeded[0]);
await expect(service.list('task_category')).resolves.toEqual(seeded);
expect(prisma.taskCategory.create).toHaveBeenCalledWith({
data: expect.objectContaining({
id: 'cat-1',
name: '前端开发',
group: 'development',
code: 'frontend_development',
isSystem: true,
}),
});
expect(prisma.appData.findUnique).not.toHaveBeenCalled();
});
it('seeds default development and testing task categories when relation table is empty', async () => {
const { prisma, service } = makeService();
prisma.taskCategory.findMany
.mockResolvedValueOnce([])
.mockResolvedValueOnce([{ id: 'cat-1', name: '前端开发', group: 'development' }]);
prisma.taskCategory.create.mockResolvedValue({ id: 'cat-1', name: '前端开发', group: 'development' });
await service.list('task_category');
expect(prisma.taskCategory.create).toHaveBeenCalledWith({
data: expect.objectContaining({ id: 'cat-1', name: '前端开发', group: 'development' }),
});
expect(prisma.taskCategory.create).toHaveBeenCalledWith({
data: expect.objectContaining({ id: 'cat-test-functional', name: '功能测试', group: 'testing' }),
});
expect(prisma.appData.findUnique).not.toHaveBeenCalled();
});
it('exports task categories and governance dictionaries together', async () => {
const { prisma, service } = makeService();
prisma.taskCategory.findMany.mockResolvedValue([{ id: 'cat-1', name: '前端' }]);

View File

@@ -33,17 +33,28 @@ export class GovernanceService {
private readonly rbacService: RbacService,
) {}
list(kind: GovernanceDictionaryKind) {
async list(kind: GovernanceDictionaryKind) {
assertDictionaryKind(kind);
if (kind === 'task_category') {
return this.prisma.taskCategory.findMany({ orderBy: [{ group: 'asc' }, { name: 'asc' }] });
const rows = await this.findTaskCategories();
if (rows.length > 0) return rows;
await this.seedDefaultTaskCategories();
return this.findTaskCategories();
}
return this.findGovernanceDictionaries(kind);
}
private findGovernanceDictionaries(kind: GovernanceDictionaryKind) {
return this.prisma.governanceDictionary.findMany({
where: { kind, deletedAt: null },
orderBy: { name: 'asc' },
});
}
private findTaskCategories() {
return this.prisma.taskCategory.findMany({ orderBy: [{ group: 'asc' }, { name: 'asc' }] });
}
async create(input: GovernanceDictionaryInput) {
assertDictionaryKind(input.kind);
const actorId = requireText(input.actorId, 'actorId');
@@ -212,6 +223,20 @@ export class GovernanceService {
requiredPermissions: ['governance:manage'],
});
}
private async seedDefaultTaskCategories() {
for (const item of DEFAULT_TASK_CATEGORIES) {
await this.prisma.taskCategory.create({
data: {
id: item.id,
name: item.name,
group: item.group,
code: item.code ?? null,
isSystem: item.isSystem,
},
});
}
}
}
function assertDictionaryKind(kind: string): asserts kind is GovernanceDictionaryKind {
@@ -225,3 +250,26 @@ function requireText(value: string | undefined | null, field: string): string {
if (!normalized) throw new BadRequestException(`${field} is required`);
return normalized;
}
const DEFAULT_TASK_CATEGORIES: Array<{ id: string; code: string; name: string; group: string; isSystem: boolean }> = [
{ id: 'cat-1', code: 'frontend_development', name: '前端开发', group: 'development', isSystem: true },
{ id: 'cat-frontend-interaction', code: 'frontend_interaction', name: '前端交互', group: 'development', isSystem: true },
{ id: 'cat-2', code: 'backend_development', name: '后端开发', group: 'development', isSystem: true },
{ id: 'cat-backend-api', code: 'backend_api', name: '后端接口', group: 'development', isSystem: true },
{ id: 'cat-3', code: 'database_schema', name: '数据库设计', group: 'development', isSystem: true },
{ id: 'cat-4', code: 'api_integration', name: '接口联调', group: 'development', isSystem: true },
{ id: 'cat-test-functional', code: 'test_functional', name: '功能测试', group: 'testing', isSystem: true },
{ id: 'cat-test-ui-interaction', code: 'test_ui_interaction', name: 'UI交互测试', group: 'testing', isSystem: true },
{ id: 'cat-test-form-validation', code: 'test_form_validation', name: '表单校验测试', group: 'testing', isSystem: true },
{ id: 'cat-test-api', code: 'test_api', name: '接口测试', group: 'testing', isSystem: true },
{ id: 'cat-test-data-consistency', code: 'test_data_consistency', name: '数据一致性测试', group: 'testing', isSystem: true },
{ id: 'cat-test-permission', code: 'test_permission', name: '权限测试', group: 'testing', isSystem: true },
{ id: 'cat-test-exception', code: 'test_exception', name: '异常场景测试', group: 'testing', isSystem: true },
{ id: 'cat-test-boundary', code: 'test_boundary', name: '边界值测试', group: 'testing', isSystem: true },
{ id: 'cat-test-state-flow', code: 'test_state_flow', name: '状态流转测试', group: 'testing', isSystem: true },
{ id: 'cat-test-compatibility', code: 'test_compatibility', name: '兼容性测试', group: 'testing', isSystem: true },
{ id: 'cat-test-regression', code: 'test_regression', name: '回归测试', group: 'testing', isSystem: true },
{ id: 'cat-5', code: 'data_processing', name: '数据处理', group: 'implementation', isSystem: true },
{ id: 'cat-6', code: 'implementation_support', name: '实施支持', group: 'implementation', isSystem: true },
{ id: 'cat-other-doc', code: 'documentation', name: '文档', group: 'other', isSystem: true },
];

View File

@@ -50,6 +50,7 @@ function buildAppData(): Record<string, any> {
status: 'adopted',
priority: 'P1',
creator: 'Product manager',
productOwner: 'pm',
createdAt: '2026-01-02T08:00:00.000Z',
updatedAt: '2026-01-02T09:00:00.000Z',
},
@@ -62,6 +63,11 @@ function buildAppData(): Record<string, any> {
name: 'Product manager',
username: 'pm',
email: '',
phone: '13000000001',
roleId: 'role-pm',
departmentId: 'dept-product',
password: 'Ftb12345',
isSystem: false,
createdAt: '2026-01-01T00:00:00.000Z',
},
{
@@ -287,7 +293,17 @@ describe('mapAppDataToV22Rows', () => {
}),
]);
expect(result.users).toEqual([
expect.objectContaining({ id: 'member-pm', email: 'pm@local.ftb', name: 'Product manager' }),
expect.objectContaining({
id: 'member-pm',
email: 'pm@local.ftb',
name: 'Product manager',
username: 'pm',
phone: '13000000001',
roleId: 'role-pm',
departmentId: 'dept-product',
password: 'Ftb12345',
isSystem: false,
}),
expect.objectContaining({ id: 'member-dev', email: 'dev@example.com', name: 'Developer' }),
expect.objectContaining({ id: 'member-test', email: 'test@local.ftb', name: 'Tester' }),
]);
@@ -302,6 +318,7 @@ describe('mapAppDataToV22Rows', () => {
priority: 1,
platform: 'Web,App',
creatorId: 'member-pm',
productOwnerId: 'member-pm',
}),
]);
expect(result.devTasks).toEqual([
@@ -442,4 +459,15 @@ describe('mapAppDataToV22Rows', () => {
reason: 'missing version partition context',
});
});
it('resolves legacy person references by member username', () => {
const appData = buildAppData();
appData.requirements.requirements[0].creator = 'pm';
appData['version-plans'][0].owner = 'pm';
const result = mapAppDataToV22Rows(appData);
expect(result.requirements[0]).toEqual(expect.objectContaining({ creatorId: 'member-pm' }));
expect(result.versionPlans[0]).toEqual(expect.objectContaining({ ownerId: 'member-pm' }));
});
});

View File

@@ -47,6 +47,12 @@ interface UserRow {
email: string;
name: string;
avatar?: string;
username?: string;
departmentId?: string;
roleId?: string;
phone?: string;
password?: string;
isSystem?: boolean;
createdAt?: string;
updatedAt?: string;
}
@@ -66,6 +72,7 @@ interface RequirementRow {
sourceTarget?: string;
platform?: string;
creatorId?: string;
productOwnerId?: string;
createdAt?: string;
updatedAt?: string;
}
@@ -94,6 +101,7 @@ interface VersionPlanRow {
actualStartAt?: string;
completedAt?: string;
resultUrl?: string;
tasks: unknown[];
requirementCoverage: unknown[];
logs: unknown[];
createdAt?: string;
@@ -383,6 +391,12 @@ export function mapAppDataToV22Rows(appData: Record<string, unknown>): V22Mapped
email: nextEmail(email),
name: stringField(member, 'name') ?? username,
avatar: stringField(member, 'avatar'),
username,
departmentId: stringField(member, 'departmentId'),
roleId: stringField(member, 'roleId') ?? 'member',
phone: stringField(member, 'phone') ?? '',
password: stringField(member, 'password') ?? '',
isSystem: booleanField(member, 'isSystem') ?? false,
createdAt: stringField(member, 'createdAt'),
updatedAt: stringField(member, 'updatedAt') ?? stringField(member, 'createdAt'),
});
@@ -418,6 +432,10 @@ export function mapAppDataToV22Rows(appData: Record<string, unknown>): V22Mapped
sourceTarget: stringField(requirement, 'sourceTarget'),
platform: platforms.join(',') || stringField(requirement, 'platform'),
creatorId: resolveUserId(stringField(requirement, 'creatorId') ?? stringField(requirement, 'creator'), userIdByReference),
productOwnerId: resolveUserId(
stringField(requirement, 'productOwnerId') ?? stringField(requirement, 'productOwner'),
userIdByReference,
),
createdAt: stringField(requirement, 'createdAt'),
updatedAt: stringField(requirement, 'updatedAt') ?? stringField(requirement, 'createdAt'),
};
@@ -460,6 +478,7 @@ export function mapAppDataToV22Rows(appData: Record<string, unknown>): V22Mapped
actualStartAt: stringField(plan, 'actualStartAt'),
completedAt: stringField(plan, 'completedAt'),
resultUrl: stringField(plan, 'resultUrl'),
tasks: unknownArray(plan.tasks),
requirementCoverage: unknownArray(plan.requirementCoverage),
logs: unknownArray(plan.logs),
createdAt: stringField(plan, 'createdAt'),
@@ -878,8 +897,9 @@ function uniqueEmailFactory() {
function buildUserIdByReference(users: UserRow[]): Map<string, string> {
const refs = new Map<string, string>();
for (const user of users) {
refs.set(user.id, user.id);
refs.set(user.name, user.id);
for (const ref of [user.id, user.name, user.username, user.email]) {
if (ref) refs.set(ref, user.id);
}
}
return refs;
}

View File

@@ -0,0 +1,46 @@
import { NotFoundException } from '@nestjs/common';
import { ProductService } from './product.service';
function buildPrismaMock() {
return {
product: {
findMany: jest.fn(),
findUnique: jest.fn(),
create: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
},
appData: {
findUnique: jest.fn(),
},
};
}
describe('ProductService relation source', () => {
it('does not fall back to products-overview AppData when relation products are empty', async () => {
const prisma = buildPrismaMock();
prisma.product.findMany.mockResolvedValue([]);
prisma.appData.findUnique.mockResolvedValue({
key: 'products-overview',
value: [{ id: 'legacy-product', name: '旧产品', projects: [], versions: [] }],
});
const service = new ProductService(prisma as any);
await expect(service.findAll()).resolves.toEqual([]);
await expect(service.findAllWithChildren()).resolves.toEqual([]);
expect(prisma.appData.findUnique).not.toHaveBeenCalled();
});
it('does not fall back to products-overview AppData when a relation product is missing', async () => {
const prisma = buildPrismaMock();
prisma.product.findUnique.mockResolvedValue(null);
prisma.appData.findUnique.mockResolvedValue({
key: 'products-overview',
value: [{ id: 'legacy-product', name: '旧产品', projects: [], versions: [] }],
});
const service = new ProductService(prisma as any);
await expect(service.findOne('legacy-product')).rejects.toBeInstanceOf(NotFoundException);
expect(prisma.appData.findUnique).not.toHaveBeenCalled();
});
});

View File

@@ -3,32 +3,6 @@ import { PrismaService } from '../../prisma/prisma.service';
import { CreateProductDto } from './dto/create-product.dto';
import { UpdateProductDto } from './dto/update-product.dto';
type ProductOverviewItem = {
id: string;
name: string;
description?: string;
createdAt?: string;
updatedAt?: string;
projects?: { id: string; name: string; description?: string; createdAt?: string }[];
versions?: {
id: string;
productId?: string;
projectId?: string | null;
name: string;
status?: string;
currentStage?: string | null;
startDate?: string | null;
expectedReleaseDate?: string | null;
releaseDate?: string | null;
members?: unknown[];
progress?: unknown[];
priority?: string | number | null;
links?: unknown;
createdAt?: string;
}[];
_count?: { requirements?: number; projects?: number; versions?: number };
};
@Injectable()
export class ProductService {
constructor(private prisma: PrismaService) {}
@@ -44,15 +18,7 @@ export class ProductService {
_count: { select: { requirements: true, projects: true, versions: true } },
},
});
if (products.length > 0) return products;
const overview = await this.getAppDataOverview();
if (!overview) return [];
return overview.map((product) => {
const normalized = this.normalizeOverviewProduct(product);
const { projects: _projects, versions: _versions, ...rest } = normalized;
return rest;
});
return products;
}
async findAllWithChildren() {
@@ -87,11 +53,7 @@ export class ProductService {
_count: { select: { requirements: true, projects: true, versions: true } },
},
});
if (products.length > 0) return products.map((product) => this.normalizeRelationProduct(product));
const overview = await this.getAppDataOverview();
if (!overview) return [];
return overview.map((product) => this.normalizeOverviewProduct(product));
return products.map((product) => this.normalizeRelationProduct(product));
}
async findOne(id: string) {
@@ -108,14 +70,7 @@ export class ProductService {
},
});
if (product) return product;
const overview = await this.getAppDataOverview();
const fallback = overview?.find((item) => item.id === id);
if (!fallback) throw new NotFoundException('产品不存在');
return {
...this.normalizeOverviewProduct(fallback),
requirements: [],
};
throw new NotFoundException('产品不存在');
}
async update(id: string, dto: UpdateProductDto) {
@@ -133,30 +88,6 @@ export class ProductService {
if (!exists) throw new NotFoundException('产品不存在');
}
private async getAppDataOverview(): Promise<ProductOverviewItem[] | null> {
const row = await this.prisma.appData.findUnique({
where: { key: 'products-overview' },
});
if (!Array.isArray(row?.value)) return null;
return row.value as unknown as ProductOverviewItem[];
}
private normalizeOverviewProduct(product: ProductOverviewItem) {
const projects = product.projects ?? [];
const versions = product.versions ?? [];
return {
...product,
description: product.description ?? '',
projects,
versions,
_count: {
requirements: product._count?.requirements ?? 0,
projects: projects.length,
versions: versions.length,
},
};
}
private normalizeRelationProduct(product: any) {
const projects = product.projects ?? [];
const versions = (product.versions ?? []).map((version: any) => ({

View File

@@ -56,4 +56,12 @@ export class CreateRequirementDto {
@IsString()
@IsOptional()
creatorId?: string;
@IsString()
@IsOptional()
productOwnerId?: string;
@IsString()
@IsOptional()
productOwner?: string;
}

View File

@@ -3,6 +3,9 @@ import { RequirementService } from './requirement.service';
describe('RequirementService with V2.2 composite requirement key', () => {
const makeService = () => {
const prisma = {
user: {
findFirst: createUserFindFirstMock(),
},
requirement: {
create: jest.fn(),
delete: jest.fn(),
@@ -18,6 +21,44 @@ describe('RequirementService with V2.2 composite requirement key', () => {
};
};
it('resolves creator names to user ids before requirement relation writes', async () => {
const { prisma, service } = makeService();
prisma.user.findFirst = createUserFindFirstMock({ : 'pm-1' });
prisma.requirement.create.mockResolvedValue({ id: 'req-1', productId: 'product-1', code: 'REQ-001' });
await service.create('product-1', {
code: 'REQ-001',
title: 'Payment',
creatorId: '产品经理',
});
expect(prisma.user.findFirst).toHaveBeenCalled();
expect(prisma.requirement.create).toHaveBeenCalledWith({
data: expect.objectContaining({
creatorId: 'pm-1',
}),
});
});
it('resolves product owner names to user ids before requirement relation writes', async () => {
const { prisma, service } = makeService();
prisma.user.findFirst = createUserFindFirstMock({ : 'pm-1' });
prisma.requirement.create.mockResolvedValue({ id: 'req-1', productId: 'product-1', code: 'REQ-001' });
await service.create('product-1', {
code: 'REQ-001',
title: 'Payment',
productOwner: '产品经理',
} as any);
expect(prisma.user.findFirst).toHaveBeenCalled();
expect(prisma.requirement.create).toHaveBeenCalledWith({
data: expect.objectContaining({
productOwnerId: 'pm-1',
}),
});
});
it('creates requirements with a partition-key-scoped business code', async () => {
const { prisma, service } = makeService();
prisma.requirement.create.mockResolvedValue({ id: 'req-1', productId: 'product-1', code: 'REQ-001' });
@@ -96,7 +137,10 @@ describe('RequirementService with V2.2 composite requirement key', () => {
{ title: { contains: 'login', mode: 'insensitive' } },
],
},
include: { creator: { select: { id: true, name: true } } },
include: {
creator: { select: { id: true, name: true } },
productOwner: { select: { id: true, name: true } },
},
orderBy: { createdAt: 'asc' },
take: 2,
cursor: { id_productId: { id: 'req-3', productId: 'product-1' } },
@@ -132,6 +176,24 @@ describe('RequirementService with V2.2 composite requirement key', () => {
});
});
it('updates requirement product owner by id plus product id', async () => {
const { prisma, service } = makeService();
prisma.user.findFirst = createUserFindFirstMock({ : 'pm-1' });
prisma.requirement.findFirst.mockResolvedValue({
id: 'req-1',
productId: 'product-1',
status: 'pending_review',
});
prisma.requirement.update.mockResolvedValue({ id: 'req-1', productId: 'product-1' });
await service.update('product-1', 'req-1', { productOwner: '产品经理' } as any);
expect(prisma.requirement.update).toHaveBeenCalledWith({
where: { id_productId: { id: 'req-1', productId: 'product-1' } },
data: { productOwnerId: 'pm-1' },
});
});
it('updates frontend pool fields by id plus product id', async () => {
const { prisma, service } = makeService();
prisma.requirement.findFirst.mockResolvedValue({
@@ -225,3 +287,13 @@ describe('RequirementService with V2.2 composite requirement key', () => {
expect(prisma.requirement.update).not.toHaveBeenCalled();
});
});
function createUserFindFirstMock(mapping: Record<string, string> = {}) {
return jest.fn(({ where }: any) => {
const refs = (where?.OR ?? [])
.flatMap((condition: Record<string, string>) => Object.values(condition))
.filter(Boolean);
const ref = refs[0];
return Promise.resolve(ref ? { id: mapping[ref] ?? ref } : null);
});
}

View File

@@ -1,5 +1,6 @@
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { resolveUserReference } from '../../common/user-reference';
import { RequirementStatus } from '@ftb/shared';
import { CreateRequirementDto } from './dto/create-requirement.dto';
import { UpdateRequirementDto } from './dto/update-requirement.dto';
@@ -15,6 +16,11 @@ const VALID_TRANSITIONS: Record<string, string[]> = {
[RequirementStatus.CLOSED]: [],
};
const REQUIREMENT_USER_INCLUDE = {
creator: { select: { id: true, name: true } },
productOwner: { select: { id: true, name: true } },
} as const;
export interface RequirementListQuery {
projectId?: string;
versionId?: string;
@@ -38,8 +44,8 @@ function createFallbackRequirementCode() {
export class RequirementService {
constructor(private prisma: PrismaService) {}
create(productId: string, dto: CreateRequirementDto) {
const data = this.toRequirementData(dto);
async create(productId: string, dto: CreateRequirementDto) {
const data = await this.toRequirementData(dto);
return this.prisma.requirement.create({
data: {
...data,
@@ -79,7 +85,7 @@ export class RequirementService {
}
: {}),
},
include: { creator: { select: { id: true, name: true } } },
include: REQUIREMENT_USER_INCLUDE,
orderBy: parseRequirementSort(query.sort),
take: limit + 1,
...(query.cursor
@@ -100,7 +106,7 @@ export class RequirementService {
async findOne(productId: string, id: string) {
const req = await this.prisma.requirement.findFirst({
where: { id, productId },
include: { creator: { select: { id: true, name: true } } },
include: REQUIREMENT_USER_INCLUDE,
});
if (!req) throw new NotFoundException('需求不存在');
return req;
@@ -110,7 +116,7 @@ export class RequirementService {
await this.findOne(productId, id);
return this.prisma.requirement.update({
where: { id_productId: { id, productId } },
data: this.toRequirementData(dto),
data: await this.toRequirementData(dto),
});
}
@@ -133,10 +139,11 @@ export class RequirementService {
return this.prisma.requirement.delete({ where: { id_productId: { id, productId } } });
}
private toRequirementData(dto: CreateRequirementDto | UpdateRequirementDto) {
private async toRequirementData(dto: CreateRequirementDto | UpdateRequirementDto) {
const type = dto.type ?? dto.typeId;
const platform = dto.platform ?? arrayToCsv(dto.platforms);
const priority = parsePriorityValue(dto.priority);
const productOwnerReference = dto.productOwnerId ?? dto.productOwner;
return {
...(dto.code !== undefined && { code: dto.code }),
@@ -148,7 +155,10 @@ export class RequirementService {
...(dto.sourceType !== undefined && { sourceType: emptyToNull(dto.sourceType) }),
...(dto.sourceTarget !== undefined && { sourceTarget: emptyToNull(dto.sourceTarget) }),
...(platform !== undefined && { platform: emptyToNull(platform) }),
...(dto.creatorId !== undefined && { creatorId: emptyToNull(dto.creatorId) }),
...(dto.creatorId !== undefined && { creatorId: await resolveUserReference(this.prisma, dto.creatorId) }),
...(dto.productOwnerId !== undefined || dto.productOwner !== undefined
? { productOwnerId: await resolveUserReference(this.prisma, productOwnerReference) }
: {}),
...(priority !== undefined && { priority }),
};
}

View File

@@ -7,6 +7,9 @@ describe('TestCaseService domain writes', () => {
record: jest.fn().mockResolvedValue({ id: 'activity-1' }),
};
const prisma = {
user: {
findFirst: createUserFindFirstMock(),
},
version: {
findUnique: jest.fn(),
},
@@ -27,6 +30,49 @@ describe('TestCaseService domain writes', () => {
};
};
it('resolves assignee and creator names to user ids before relation writes', async () => {
const { prisma, service } = makeService();
prisma.user.findFirst = createUserFindFirstMock({ : 'qa-1', : 'pm-1' });
prisma.version.findUnique.mockResolvedValue({ id: 'version-1', productId: 'product-1', projectId: 'project-1' });
prisma.testCase.create.mockResolvedValue({ id: 'tc-1', versionId: 'version-1', title: '登录冒烟' });
await service.create('version-1', {
title: '登录冒烟',
assigneeId: '测试',
createdBy: '产品经理',
} as any);
expect(prisma.user.findFirst).toHaveBeenCalledTimes(2);
expect(prisma.testCase.create).toHaveBeenCalledWith({
data: expect.objectContaining({
assigneeId: 'qa-1',
creatorId: 'pm-1',
}),
});
});
it('resolves names while batch creating copied or AI-generated test cases', async () => {
const { prisma, service } = makeService();
prisma.user.findFirst = createUserFindFirstMock({ : 'qa-1', AI: 'system-ai' });
prisma.version.findUnique.mockResolvedValue({ id: 'version-1', productId: 'product-1', projectId: 'project-1' });
prisma.testCase.createMany.mockResolvedValue({ count: 1 });
prisma.testCase.findMany.mockResolvedValue([{ id: 'tc-copy-1' }]);
await service.createMany('version-1', [
{ caseNo: 'TC-101', title: 'AI 用例', assigneeId: '测试', createdBy: 'AI' },
] as any);
expect(prisma.testCase.createMany).toHaveBeenCalledWith({
data: [
expect.objectContaining({
assigneeId: 'qa-1',
creatorId: 'system-ai',
}),
],
skipDuplicates: true,
});
});
it('creates test cases directly under a version partition', async () => {
const { prisma, workActivity, service } = makeService();
prisma.version.findUnique.mockResolvedValue({ id: 'version-1', productId: 'product-1', projectId: 'project-1' });
@@ -139,3 +185,13 @@ describe('TestCaseService domain writes', () => {
expect(prisma.testCase.update).not.toHaveBeenCalled();
});
});
function createUserFindFirstMock(mapping: Record<string, string> = {}) {
return jest.fn(({ where }: any) => {
const refs = (where?.OR ?? [])
.flatMap((condition: Record<string, string>) => Object.values(condition))
.filter(Boolean);
const ref = refs[0];
return Promise.resolve(ref ? { id: mapping[ref] ?? ref } : null);
});
}

View File

@@ -1,4 +1,5 @@
import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common';
import { resolveUserReference } from '../../common/user-reference';
import { WorkActivityService } from '../work-activity/work-activity.service';
import { CreateTestCaseDto } from './dto/create-test-case.dto';
import { UpdateTestCaseDto } from './dto/update-test-case.dto';
@@ -14,9 +15,10 @@ export class TestCaseService {
async create(versionId: string, dto: CreateTestCaseDto) {
const version = await this.ensureVersion(versionId);
const testCaseData = await this.toTestCaseData(dto);
const item = await this.prisma.testCase.create({
data: {
...this.toTestCaseData(dto),
...testCaseData,
versionId,
productId: version.productId,
projectId: version.projectId,
@@ -32,15 +34,15 @@ export class TestCaseService {
async createMany(versionId: string, dtos: CreateTestCaseDto[]) {
if (dtos.length === 0) return { items: [], activities: [] };
const version = await this.ensureVersion(versionId);
const rows = dtos.map((dto) => ({
...this.toTestCaseData(dto),
const rows = await Promise.all(dtos.map(async (dto) => ({
...(await this.toTestCaseData(dto)),
versionId,
productId: version.productId,
projectId: version.projectId,
code: dto.code?.trim() || dto.caseNo?.trim() || createFallbackCode('TC'),
title: dto.title,
status: dto.status ?? 'pending',
}));
})));
await this.prisma.testCase.createMany({ data: rows, skipDuplicates: true });
const items = await this.prisma.testCase.findMany({
where: { versionId, code: { in: rows.map((row) => row.code) } },
@@ -62,7 +64,7 @@ export class TestCaseService {
await this.ensureTestCaseInVersion(versionId, id);
const item = await this.prisma.testCase.update({
where: { id_versionId: { id, versionId } },
data: this.toTestCaseData(dto),
data: await this.toTestCaseData(dto),
});
await this.workActivity.markXiaobaoSummaryDirty(versionId);
return { item, activities: [] };
@@ -90,7 +92,7 @@ export class TestCaseService {
return item;
}
private toTestCaseData(dto: Partial<CreateTestCaseDto>) {
private async toTestCaseData(dto: Partial<CreateTestCaseDto>) {
return {
...(dto.requirementId !== undefined && { requirementId: emptyToNull(dto.requirementId) }),
...(dto.requirementProductId !== undefined && { requirementProductId: emptyToNull(dto.requirementProductId) }),
@@ -101,8 +103,10 @@ export class TestCaseService {
...(dto.status !== undefined && { status: dto.status }),
...(dto.roundNo !== undefined && { roundNo: normalizeRoundNo(dto.roundNo) }),
...(dto.priority !== undefined && { priority: parsePriority(dto.priority) ?? 0 }),
...(dto.assigneeId !== undefined && { assigneeId: emptyToNull(dto.assigneeId) }),
...(dto.creatorId !== undefined || dto.createdBy !== undefined ? { creatorId: emptyToNull(dto.creatorId ?? dto.createdBy) } : {}),
...(dto.assigneeId !== undefined && { assigneeId: await resolveUserReference(this.prisma, dto.assigneeId) }),
...(dto.creatorId !== undefined || dto.createdBy !== undefined
? { creatorId: await resolveUserReference(this.prisma, dto.creatorId ?? dto.createdBy) }
: {}),
...(dto.plannedTestAt !== undefined && { plannedTestAt: parseOptionalDate(dto.plannedTestAt) }),
...(dto.plannedEndAt !== undefined && { plannedEndAt: parseOptionalDate(dto.plannedEndAt) }),
...(dto.startedAt !== undefined && { startedAt: parseOptionalDate(dto.startedAt) }),

View File

@@ -50,7 +50,10 @@ describe('V22QueryService', () => {
expect(result.devTasks).toEqual([{ id: 'dev-1' }]);
expect(prisma.requirement.findMany).toHaveBeenCalledWith({
where: { versionId: 'version-1' },
include: { creator: { select: { id: true, name: true } } },
include: {
creator: { select: { id: true, name: true } },
productOwner: { select: { id: true, name: true } },
},
orderBy: { createdAt: 'desc' },
});
expect(prisma.devTask.findMany).toHaveBeenCalledWith({
@@ -95,7 +98,10 @@ describe('V22QueryService', () => {
{ title: { contains: 'login', mode: 'insensitive' } },
],
},
include: { creator: { select: { id: true, name: true } } },
include: {
creator: { select: { id: true, name: true } },
productOwner: { select: { id: true, name: true } },
},
orderBy: { createdAt: 'asc' },
take: 2,
cursor: { id_productId: { id: 'req-3', productId: 'product-1' } },
@@ -129,7 +135,13 @@ describe('V22QueryService', () => {
});
});
it('falls back to AppData workspace rows when relation tables are empty', async () => {
it('rejects missing workspace userId with a client error instead of a TypeError', async () => {
const service = new V22QueryService(buildPrismaMock() as any);
await expect(service.getWorkspaceData(undefined as any)).rejects.toBeInstanceOf(BadRequestException);
});
it('does not fall back to legacy AppData workspace rows when relation tables are empty', async () => {
const prisma = buildPrismaMock();
prisma.versionPlan.findMany.mockResolvedValue([]);
prisma.devTask.findMany.mockResolvedValue([]);
@@ -170,14 +182,8 @@ describe('V22QueryService', () => {
const result = await service.getWorkspaceData('m-8');
expect(result.versionPlans).toEqual([
expect.objectContaining({
id: 'plan-1',
ownerId: 'm-8',
title: '案例学习 V1.3 产品方案',
}),
]);
expect(prisma.appData.findMany).toHaveBeenCalled();
expect(result).toEqual({ versionPlans: [], devTasks: [], testCases: [], bugs: [] });
expect(prisma.appData.findMany).not.toHaveBeenCalled();
});
it('loads Xiaobao warning summaries either globally for managers or by user-owned version ids', async () => {

View File

@@ -1,7 +1,5 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { APP_DATA_KEYS } from '../data/data-keys';
import { mapAppDataToV22Rows } from '../migration/app-data-v22.mapper';
interface RequirementQuery {
productId?: string;
@@ -21,8 +19,16 @@ interface XiaobaoWarningQuery {
manager?: string;
}
type WorkspaceRows = {
versionPlans: unknown[];
devTasks: unknown[];
testCases: unknown[];
bugs: unknown[];
};
const REQUIREMENT_CREATOR_INCLUDE = {
creator: { select: { id: true, name: true } },
productOwner: { select: { id: true, name: true } },
} as const;
@Injectable()
@@ -105,8 +111,8 @@ export class V22QueryService {
};
}
async getWorkspaceData(userId: string): Promise<WorkspaceRows> {
const normalizedUserId = userId.trim();
async getWorkspaceData(userId?: string): Promise<WorkspaceRows> {
const normalizedUserId = userId?.trim();
if (!normalizedUserId) throw new BadRequestException('userId is required');
const [versionPlans, devTasks, testCases, bugs] = await Promise.all([
@@ -128,39 +134,9 @@ export class V22QueryService {
}),
]);
const relationResult = { versionPlans, devTasks, testCases, bugs };
if (hasWorkspaceRows(relationResult)) return relationResult;
const fallback = await this.getWorkspaceDataFromAppData(normalizedUserId);
if (hasWorkspaceRows(fallback)) return fallback;
return { versionPlans, devTasks, testCases, bugs };
}
private async getWorkspaceDataFromAppData(userId: string): Promise<WorkspaceRows> {
const rows = await this.prisma.appData.findMany({
where: { key: { in: [...APP_DATA_KEYS] } },
select: { key: true, value: true },
});
const snapshot = Object.fromEntries(rows.map((row) => [row.key, row.value]));
const mapped = mapAppDataToV22Rows(snapshot);
return {
versionPlans: mapped.versionPlans
.filter((plan) => plan.ownerId === userId && plan.status !== 'completed')
.sort(comparePlanRows),
devTasks: mapped.devTasks
.filter((task) => task.assigneeId === userId && task.status !== 'submitted')
.sort(comparePriorityThenUpdatedRows),
testCases: mapped.testCases
.filter((testCase) => testCase.assigneeId === userId && !['passed', 'failed', 'blocked'].includes(testCase.status))
.sort(comparePriorityThenUpdatedRows),
bugs: mapped.bugs
.filter((bug) => bug.assigneeId === userId && ['open', 'fixing', 'fixed', 'verifying'].includes(bug.status))
.sort(comparePriorityThenUpdatedRows),
};
}
async getXiaobaoWarnings(query: XiaobaoWarningQuery) {
if (query.manager === 'true') {
const rows = await this.prisma.xiaobaoRiskSummary.findMany({
@@ -234,49 +210,6 @@ export class V22QueryService {
}
}
type WorkspaceRows = {
versionPlans: unknown[];
devTasks: unknown[];
testCases: unknown[];
bugs: unknown[];
};
type PriorityUpdatedRow = {
priority?: number;
updatedAt?: string;
};
type PlanSortRow = {
expectedEndAt?: string;
updatedAt?: string;
};
function hasWorkspaceRows(rows: WorkspaceRows): boolean {
return rows.versionPlans.length > 0
|| rows.devTasks.length > 0
|| rows.testCases.length > 0
|| rows.bugs.length > 0;
}
function comparePlanRows(a: PlanSortRow, b: PlanSortRow): number {
const endDiff = dateSortValue(a.expectedEndAt, Number.POSITIVE_INFINITY)
- dateSortValue(b.expectedEndAt, Number.POSITIVE_INFINITY);
if (endDiff !== 0) return endDiff;
return dateSortValue(b.updatedAt, 0) - dateSortValue(a.updatedAt, 0);
}
function comparePriorityThenUpdatedRows(a: PriorityUpdatedRow, b: PriorityUpdatedRow): number {
const priorityDiff = (a.priority ?? 0) - (b.priority ?? 0);
if (priorityDiff !== 0) return priorityDiff;
return dateSortValue(b.updatedAt, 0) - dateSortValue(a.updatedAt, 0);
}
function dateSortValue(value: string | undefined, fallback: number): number {
if (!value) return fallback;
const time = new Date(value).getTime();
return Number.isFinite(time) ? time : fallback;
}
function parseLimit(raw?: string): number {
const parsed = raw ? Number(raw) : 50;
if (!Number.isFinite(parsed)) return 50;

View File

@@ -47,6 +47,9 @@ export class CreateVersionPlanDto {
@IsOptional()
resultUrl?: string;
@IsOptional()
tasks?: unknown[];
@IsArray()
@IsOptional()
linkedRequirementIds?: string[];

View File

@@ -5,8 +5,12 @@ describe('VersionPlanService domain writes', () => {
const makeService = () => {
const workActivity = {
record: jest.fn().mockResolvedValue({ id: 'activity-1' }),
markXiaobaoSummaryDirty: jest.fn().mockResolvedValue(undefined),
};
const prisma = {
user: {
findFirst: createUserFindFirstMock(),
},
version: {
findUnique: jest.fn(),
},
@@ -26,6 +30,58 @@ describe('VersionPlanService domain writes', () => {
};
};
it('resolves plan owner names to user ids before relation writes', async () => {
const { prisma, service } = makeService();
prisma.user.findFirst = createUserFindFirstMock({ : 'member-1' });
prisma.version.findUnique.mockResolvedValue({ id: 'version-1', productId: 'product-1', projectId: 'project-1' });
prisma.versionPlan.create.mockResolvedValue({ id: 'plan-1', versionId: 'version-1', title: '产品方案', ownerId: 'member-1' });
await service.create('version-1', {
type: 'product',
title: '产品方案',
owner: '张三',
} as any);
expect(prisma.user.findFirst).toHaveBeenCalled();
expect(prisma.versionPlan.create).toHaveBeenCalledWith({
data: expect.objectContaining({
ownerId: 'member-1',
}),
});
});
it('clears unresolved plan owner references instead of writing invalid foreign keys', async () => {
const { prisma, service } = makeService();
prisma.user.findFirst.mockResolvedValue(null);
prisma.versionPlan.findFirst.mockResolvedValue({
id: 'plan-1',
versionId: 'version-1',
productId: 'product-1',
projectId: 'project-1',
status: 'pending',
title: '产品方案',
ownerId: 'member-1',
});
prisma.versionPlan.update.mockResolvedValue({
id: 'plan-1',
versionId: 'version-1',
productId: 'product-1',
projectId: 'project-1',
status: 'pending',
title: '产品方案',
ownerId: null,
});
await service.update('version-1', 'plan-1', { owner: '不存在的成员' } as any);
expect(prisma.versionPlan.update).toHaveBeenCalledWith({
where: { id: 'plan-1' },
data: expect.objectContaining({
ownerId: null,
}),
});
});
it('creates version plans directly under a version partition', async () => {
const { prisma, workActivity, service } = makeService();
prisma.version.findUnique.mockResolvedValue({ id: 'version-1', productId: 'product-1', projectId: 'project-1' });
@@ -61,6 +117,64 @@ describe('VersionPlanService domain writes', () => {
}));
});
it('persists research direction tasks on create and update', async () => {
const { prisma, service } = makeService();
const tasks = [{ id: 'task-1', title: '竞品分析', status: 'pending' }];
prisma.version.findUnique.mockResolvedValue({ id: 'version-1', productId: 'product-1', projectId: 'project-1' });
prisma.versionPlan.create.mockResolvedValue({
id: 'plan-1',
versionId: 'version-1',
productId: 'product-1',
projectId: 'project-1',
status: 'pending',
title: '调研方案',
ownerId: 'member-1',
tasks,
});
prisma.versionPlan.findFirst.mockResolvedValue({
id: 'plan-1',
versionId: 'version-1',
productId: 'product-1',
projectId: 'project-1',
status: 'pending',
title: '调研方案',
ownerId: 'member-1',
tasks,
});
prisma.versionPlan.update.mockResolvedValue({
id: 'plan-1',
versionId: 'version-1',
productId: 'product-1',
projectId: 'project-1',
status: 'pending',
title: '调研方案',
ownerId: 'member-1',
tasks: [{ ...tasks[0], status: 'in_progress' }],
});
await service.create('version-1', {
type: 'research',
title: '调研方案',
owner: 'member-1',
tasks,
} as any);
await service.update('version-1', 'plan-1', {
tasks: [{ ...tasks[0], status: 'in_progress' }],
} as any);
expect(prisma.versionPlan.create).toHaveBeenCalledWith({
data: expect.objectContaining({
tasks,
}),
});
expect(prisma.versionPlan.update).toHaveBeenCalledWith({
where: { id: 'plan-1' },
data: expect.objectContaining({
tasks: [{ ...tasks[0], status: 'in_progress' }],
}),
});
});
it('updates plan status inside version scope and records activity evidence', async () => {
const { prisma, workActivity, service } = makeService();
prisma.versionPlan.findFirst.mockResolvedValue({
@@ -107,3 +221,13 @@ describe('VersionPlanService domain writes', () => {
expect(prisma.versionPlan.update).not.toHaveBeenCalled();
});
});
function createUserFindFirstMock(mapping: Record<string, string> = {}) {
return jest.fn(({ where }: any) => {
const refs = (where?.OR ?? [])
.flatMap((condition: Record<string, string>) => Object.values(condition))
.filter(Boolean);
const ref = refs[0];
return Promise.resolve(ref ? { id: mapping[ref] ?? ref } : null);
});
}

View File

@@ -1,4 +1,5 @@
import { Inject, Injectable, NotFoundException } from '@nestjs/common';
import { resolveUserReference } from '../../common/user-reference';
import { WorkActivityService } from '../work-activity/work-activity.service';
import { CreateVersionPlanDto } from './dto/create-version-plan.dto';
import { UpdateVersionPlanDto } from './dto/update-version-plan.dto';
@@ -14,9 +15,10 @@ export class VersionPlanService {
async create(versionId: string, dto: CreateVersionPlanDto) {
const version = await this.ensureVersion(versionId);
const planData = await this.toPlanData(dto);
const item = await this.prisma.versionPlan.create({
data: {
...this.toPlanData(dto),
...planData,
versionId,
productId: version.productId,
projectId: version.projectId,
@@ -38,7 +40,7 @@ export class VersionPlanService {
async update(versionId: string, id: string, dto: UpdateVersionPlanDto) {
const current = await this.ensurePlanInVersion(versionId, id);
const data = this.toPlanData(dto);
const data = await this.toPlanData(dto);
if (dto.status === 'in_progress' && !current.actualStartAt) {
data.actualStartAt = new Date();
}
@@ -69,12 +71,14 @@ export class VersionPlanService {
return item;
}
private toPlanData(dto: Partial<CreateVersionPlanDto>) {
private async toPlanData(dto: Partial<CreateVersionPlanDto>) {
return {
...(dto.type !== undefined && { type: dto.type }),
...(dto.title !== undefined && { title: dto.title }),
...(dto.status !== undefined && { status: dto.status }),
...(dto.owner !== undefined || dto.ownerId !== undefined ? { ownerId: emptyToNull(dto.ownerId ?? dto.owner) } : {}),
...(dto.owner !== undefined || dto.ownerId !== undefined
? { ownerId: await resolveUserReference(this.prisma, dto.ownerId ?? dto.owner) }
: {}),
...(dto.startTime !== undefined || dto.expectedStartAt !== undefined
? { expectedStartAt: parseOptionalDate(dto.expectedStartAt ?? dto.startTime) }
: {}),
@@ -84,6 +88,7 @@ export class VersionPlanService {
...(dto.actualStartAt !== undefined && { actualStartAt: parseOptionalDate(dto.actualStartAt) }),
...(dto.completedAt !== undefined && { completedAt: parseOptionalDate(dto.completedAt) }),
...(dto.resultUrl !== undefined && { resultUrl: emptyToNull(dto.resultUrl) }),
...(dto.tasks !== undefined && { tasks: toJsonInput(dto.tasks) }),
...(dto.requirementCoverage !== undefined || dto.linkedRequirementIds !== undefined
? { requirementCoverage: toJsonInput(dto.requirementCoverage ?? buildRequirementCoverage(dto.linkedRequirementIds)) }
: {}),

View File

@@ -25,13 +25,30 @@ const KIND_LABEL: Record<GovernanceKind, string> = {
requirement_source: '需求来源',
};
const TASK_CATEGORY_GROUPS = [
{ value: 'development', label: '开发' },
{ value: 'testing', label: '测试' },
{ value: 'implementation', label: '实施' },
{ value: 'other', label: '其他' },
];
const SOURCE_TYPE_GROUPS = [
{ value: 'customer', label: '客户' },
{ value: 'internal', label: '内部' },
{ value: 'operation', label: '运营' },
{ value: 'aftersale', label: '售后' },
{ value: 'market', label: '市场' },
{ value: 'competitor', label: '竞品' },
{ value: 'management', label: '管理层' },
];
function GovernancePageInner() {
const user = useAuthStore((s) => s.user);
const role = useMemberStore((s) => s.roles.find((item) => item.id === user?.roleId));
const [kind, setKind] = useState<GovernanceKind>('task_category');
const [items, setItems] = useState<GovernanceItem[]>([]);
const [name, setName] = useState('');
const [group, setGroup] = useState('other');
const [group, setGroup] = useState(defaultGroupForKind('task_category'));
const [exportText, setExportText] = useState('');
const actorId = user?.id ?? '';
const permissions = role?.permissions ?? [];
@@ -45,9 +62,13 @@ function GovernancePageInner() {
void reload().catch(() => setItems([]));
}, [kind]);
useEffect(() => {
setGroup(defaultGroupForKind(kind));
}, [kind]);
const create = async () => {
if (!actorId || !name.trim()) return;
await api.post('/governance/dictionaries', { actorId, permissions, kind, name: name.trim(), group });
await api.post('/governance/dictionaries', { actorId, permissions, kind, name: name.trim(), group: groupForPayload(kind, group) });
setName('');
await reload();
};
@@ -99,9 +120,15 @@ function GovernancePageInner() {
</div>
<section className="rounded-lg border border-[var(--line)] bg-[var(--bg-card)]">
<div className="grid grid-cols-[1fr_140px_auto] gap-2 border-b border-[var(--line)] p-3">
<div className={`grid gap-2 border-b border-[var(--line)] p-3 ${groupOptionsForKind(kind) ? 'grid-cols-[1fr_140px_auto]' : 'grid-cols-[1fr_auto]'}`}>
<input value={name} onChange={(event) => setName(event.target.value)} placeholder={`新增${KIND_LABEL[kind]}`} className="h-8 rounded-md border border-[var(--line)] bg-[var(--bg)] px-3 text-[12px] focus:border-[var(--accent)] focus:outline-none" />
<input value={group} onChange={(event) => setGroup(event.target.value)} placeholder="分组" className="h-8 rounded-md border border-[var(--line)] bg-[var(--bg)] px-3 text-[12px] focus:border-[var(--accent)] focus:outline-none" />
{groupOptionsForKind(kind) && (
<select value={group} onChange={(event) => setGroup(event.target.value)} className="h-8 rounded-md border border-[var(--line)] bg-[var(--bg)] px-3 text-[12px] focus:border-[var(--accent)] focus:outline-none">
{groupOptionsForKind(kind)?.map((option) => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
)}
<button onClick={create} disabled={!name.trim()} className="inline-flex h-8 items-center gap-1 rounded-md bg-[var(--accent)] px-3 text-[12px] font-medium text-white disabled:opacity-50">
<Plus className="h-3.5 w-3.5" />
</button>
@@ -143,6 +170,22 @@ function GovernancePageInner() {
);
}
function groupOptionsForKind(kind: GovernanceKind) {
if (kind === 'task_category') return TASK_CATEGORY_GROUPS;
if (kind === 'requirement_source') return SOURCE_TYPE_GROUPS;
return null;
}
function defaultGroupForKind(kind: GovernanceKind) {
if (kind === 'task_category') return 'development';
if (kind === 'requirement_source') return 'customer';
return '';
}
function groupForPayload(kind: GovernanceKind, group: string) {
return groupOptionsForKind(kind) ? group : undefined;
}
export default function GovernancePage() {
return (
<RouteGuard permission="governance:manage">

View File

@@ -47,7 +47,6 @@ function OvertimePageContent() {
const [showReasonDrawer, setShowReasonDrawer] = useState(false);
useEffect(() => { fetchOverview(); }, [fetchOverview]);
useEffect(() => { fetchRequirements(); }, [fetchRequirements]);
useEffect(() => { fetchRecords(); }, [fetchRecords]);
useEffect(() => { fetchMembers(); }, [fetchMembers]);
@@ -327,6 +326,7 @@ function OvertimePageContent() {
versions={allVersions}
reasons={reasons}
requirements={requirements.map((r) => ({ id: r.id, title: r.title, versionId: r.versionId }))}
onVersionChange={(version) => fetchRequirements({ productId: version.productId, versionId: version.id })}
onClose={() => setShowModal(false)}
onSubmit={(data) => {
createRecord(data as any);
@@ -408,13 +408,14 @@ function DepartmentButton({ active, label, count, hours, depth = 0, icon, onClic
);
}
function OvertimeModal({ defaultPerson, products, projects, versions, reasons, requirements, onClose, onSubmit }: {
function OvertimeModal({ defaultPerson, products, projects, versions, reasons, requirements, onVersionChange, onClose, onSubmit }: {
defaultPerson: string;
products: { id: string; name: string }[];
projects: { id: string; name: string; productId: string }[];
versions: { id: string; name: string; projectId?: string }[];
versions: { id: string; name: string; productId: string; projectId?: string }[];
reasons: { id: string; name: string }[];
requirements: { id: string; title: string; versionId?: string }[];
onVersionChange: (version: { id: string; productId: string }) => void;
onClose: () => void;
onSubmit: (data: any) => void;
}) {
@@ -469,7 +470,18 @@ function OvertimeModal({ defaultPerson, products, projects, versions, reasons, r
</div>
<div>
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1 block"> *</label>
<select value={versionId} onChange={(e) => setVersionId(e.target.value)} required className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[13px] focus:border-[var(--accent)] focus:outline-none">
<select
value={versionId}
onChange={(e) => {
const nextVersionId = e.target.value;
setVersionId(nextVersionId);
setRequirementId('');
const version = versions.find((item) => item.id === nextVersionId);
if (version) onVersionChange(version);
}}
required
className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[13px] focus:border-[var(--accent)] focus:outline-none"
>
<option value=""></option>
{filteredVersions.map((v) => <option key={v.id} value={v.id}>{v.name}</option>)}
</select>

View File

@@ -45,7 +45,7 @@ export default function ProductDetailPage() {
useEffect(() => {
fetchProduct(productId);
fetchRequirements();
fetchRequirements({ productId });
fetchRecords();
}, [productId, fetchProduct, fetchRequirements, fetchRecords]);

View File

@@ -332,14 +332,21 @@ export default function ProjectDetailPage() {
const [showAnalysisDrawer, setShowAnalysisDrawer] = useState(false);
useEffect(() => { fetchOverview(); }, [fetchOverview]);
useEffect(() => { fetchRequirements(); }, [fetchRequirements]);
useEffect(() => { fetchRecords(); }, [fetchRecords]);
useEffect(() => { fetchPlans(); }, [fetchPlans]);
useEffect(() => { fetchDevTasks(); }, [fetchDevTasks]);
useEffect(() => { fetchTestCases(); }, [fetchTestCases]);
const project = useMemo(() => getProjectDetail(overview, projectId), [overview, projectId]);
useEffect(() => {
if (!project) return;
void fetchRequirements({ productId: project.productId, projectId });
project.versions.forEach((version) => {
void fetchPlans({ versionId: version.id });
void fetchDevTasks({ versionId: version.id });
void fetchTestCases({ versionId: version.id });
void fetchBugs({ versionId: version.id });
});
}, [fetchBugs, fetchDevTasks, fetchPlans, fetchRequirements, fetchTestCases, project, projectId]);
const user = useAuthStore((s) => s.user);
const currentUserName = user?.name || '';
const { roles } = useMemberStore();

View File

@@ -35,15 +35,23 @@ function ProjectsPageContent() {
const [showForm, setShowForm] = useState(false);
const [editingProject, setEditingProject] = useState<ProjectWithContext | null>(null);
useEffect(() => { fetchOverview(); }, [fetchOverview]);
useEffect(() => { fetchRequirements(); }, [fetchRequirements]);
useEffect(() => { fetchPlans(); }, [fetchPlans]);
useEffect(() => { fetchDevTasks(); }, [fetchDevTasks]);
useEffect(() => { fetchTestCases(); }, [fetchTestCases]);
const allProjects = useMemo(() => flattenProjects(overview), [overview]);
const allVersions = useMemo(() => flattenVersions(overview), [overview]);
useEffect(() => { fetchOverview(); }, [fetchOverview]);
useEffect(() => {
overview.forEach((product) => {
void fetchRequirements({ productId: product.id });
});
}, [fetchRequirements, overview]);
useEffect(() => {
allVersions.forEach((version) => {
void fetchPlans({ versionId: version.id });
void fetchDevTasks({ versionId: version.id });
void fetchTestCases({ versionId: version.id });
});
}, [allVersions, fetchDevTasks, fetchPlans, fetchTestCases]);
const versionProgressMap = useMemo(
() => buildVersionProgressMap(allVersions, plans, requirements, devTasks, testCases),
[allVersions, plans, requirements, devTasks, testCases],

View File

@@ -1,11 +1,10 @@
'use client';
import { RouteGuard } from '@/components/auth/Guard';
import { useEffect, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import {
Search,
Plus,
Lightbulb,
ArrowUp,
ArrowDown,
ChevronDown,
@@ -42,7 +41,6 @@ import { getRequirementVersionSelectionPatch } from '@/lib/requirement-version-l
import { Pagination } from '@/components/Pagination';
import { RequirementModal } from '@/components/requirement/RequirementModal';
import { RequirementDetail } from '@/components/requirement/RequirementDetail';
import { DictDrawer, SourceDrawer } from '@/components/requirement/DictDrawer';
import { FilterSelect } from '@/components/FilterSelect';
import { buildV22RequirementQuery } from '@/lib/requirement-v22-query';
import { loadV22RequirementsPage } from '@/lib/v22-api';
@@ -199,9 +197,6 @@ function RequirementsPageContent() {
const {
requirements, fetchRequirements, createRequirement, updateRequirement, deleteRequirement,
sourceTargets, types, platforms,
addSourceTarget, updateSourceTarget, deleteSourceTarget,
addType, updateType, deleteType,
addPlatform, updatePlatform, deletePlatform,
loaded: requirementsLoaded,
} = useRequirementStore();
const { overview, fetchOverview } = useProductStore();
@@ -233,7 +228,6 @@ function RequirementsPageContent() {
const [showModal, setShowModal] = useState(false);
const [editingReq, setEditingReq] = useState<Requirement | null>(null);
const [viewingReq, setViewingReq] = useState<Requirement | null>(null);
const [drawerType, setDrawerType] = useState<null | 'source' | 'type' | 'platform'>(null);
const [rejectingReq, setRejectingReq] = useState<Requirement | null>(null);
const [rejectReason, setRejectReason] = useState('');
const [dateSort, setDateSort] = useState<RequirementDateSort>('desc');
@@ -274,6 +268,23 @@ function RequirementsPageContent() {
if (selectedScope.type === 'project') return allVersions.filter((v) => v.projectId === selectedScope.projectId);
return allVersions;
}, [allVersions, selectedScope]);
const hydrateRequirementStoreFallback = useCallback(async () => {
const pendingLoads: Array<Promise<void>> = [];
if (selectedScope.type === 'product') {
pendingLoads.push(fetchRequirements({ productId: selectedScope.productId }));
} else if (selectedScope.type === 'project') {
const project = allProjects.find((item) => item.id === selectedScope.projectId);
if (project) pendingLoads.push(fetchRequirements({ productId: project.productId, projectId: selectedScope.projectId }));
} else {
for (const product of overview) {
pendingLoads.push(fetchRequirements({ productId: product.id }));
}
}
for (const version of scopedVersions) {
pendingLoads.push(fetchDevTasks({ versionId: version.id }));
}
await Promise.all(pendingLoads);
}, [allProjects, fetchDevTasks, fetchRequirements, overview, scopedVersions, selectedScope]);
const filterResetKey = useMemo(() => JSON.stringify({
selectedScopeKey,
@@ -319,9 +330,8 @@ function RequirementsPageContent() {
useEffect(() => {
if (v22RequirementQuery && !v22RequirementsFailed) return;
fetchRequirements();
fetchDevTasks();
}, [fetchDevTasks, fetchRequirements, v22RequirementQuery, v22RequirementsFailed]);
void hydrateRequirementStoreFallback();
}, [hydrateRequirementStoreFallback, v22RequirementQuery, v22RequirementsFailed]);
useEffect(() => {
setV22RequirementsFailed(false);
@@ -354,8 +364,7 @@ function RequirementsPageContent() {
setV22Requirements([]);
setV22NextCursor(undefined);
setV22RequirementsFailed(true);
fetchRequirements();
fetchDevTasks();
void hydrateRequirementStoreFallback();
})
.finally(() => {
if (!cancelled) setV22RequirementsLoading(false);
@@ -364,7 +373,7 @@ function RequirementsPageContent() {
return () => {
cancelled = true;
};
}, [fetchDevTasks, fetchRequirements, page, v22RequirementQuery]);
}, [hydrateRequirementStoreFallback, page, v22RequirementQuery]);
const selectedScopeTitle = useMemo(() => {
if (selectedScope.type === 'product') {
@@ -472,7 +481,7 @@ function RequirementsPageContent() {
};
const handleCreate = async () => {
await fetchRequirements();
await hydrateRequirementStoreFallback();
setEditingReq(null);
setShowModal(true);
};
@@ -480,10 +489,6 @@ function RequirementsPageContent() {
const handleSelectScope = (scope: RequirementScopeSelection) => {
setAutoScopeSelected(true);
setSelectedScope(scope);
if (scope.type === 'all') {
fetchRequirements();
fetchDevTasks();
}
};
return (
@@ -552,13 +557,6 @@ function RequirementsPageContent() {
<p className="mt-0.5 text-[11px] text-[var(--ink-muted)]">{selectedScopeMeta}</p>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => { fetchRequirements(); setDrawerType('source'); }}
className="flex h-8 items-center gap-1.5 rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[13px] font-medium text-[var(--ink-soft)] hover:border-[var(--accent)] hover:text-[var(--accent)] transition-colors"
>
<Lightbulb className="h-3.5 w-3.5" strokeWidth={2} />
</button>
<button
onClick={handleCreate}
className="flex h-8 items-center gap-1.5 rounded-lg bg-[var(--accent)] px-3 text-[13px] font-medium text-white shadow-[var(--shadow-sm)] hover:bg-[var(--accent-hover)] transition-colors"
@@ -888,41 +886,6 @@ function RequirementsPageContent() {
setShowModal(false);
setEditingReq(null);
}}
onOpenDrawer={(type) => setDrawerType(type)}
/>
)}
{/* Drawer */}
{drawerType === 'source' && (
<SourceDrawer
open={true}
items={sourceTargets}
onClose={() => setDrawerType(null)}
onAdd={addSourceTarget}
onUpdate={updateSourceTarget}
onDelete={deleteSourceTarget}
/>
)}
{drawerType === 'type' && (
<DictDrawer
open={true}
title="需求类型管理"
items={types}
onClose={() => setDrawerType(null)}
onAdd={addType}
onUpdate={updateType}
onDelete={deleteType}
/>
)}
{drawerType === 'platform' && (
<DictDrawer
open={true}
title="支持端管理"
items={platforms}
onClose={() => setDrawerType(null)}
onAdd={addPlatform}
onUpdate={updatePlatform}
onDelete={deletePlatform}
/>
)}

View File

@@ -1,6 +1,6 @@
'use client';
import { useEffect, useMemo, useState, type FormEvent } from 'react';
import { useCallback, useEffect, useMemo, useState, type FormEvent } from 'react';
import { useParams, useRouter } from 'next/navigation';
import { AlertTriangle, Calendar, Check, ChevronLeft, Clock, FileText, Link2, Pencil, Search, Settings, Sparkles, UserPlus, X } from 'lucide-react';
import { useProductStore } from '@/stores/useProductStore';
@@ -142,6 +142,7 @@ export default function VersionDetailPage() {
() => allMembers.map((member) => ({
id: member.id,
name: member.name,
username: member.username,
departmentName: departments.find((department) => department.id === member.departmentId)?.name,
})),
[allMembers, departments],
@@ -163,6 +164,12 @@ export default function VersionDetailPage() {
const [showAnalysisDrawer, setShowAnalysisDrawer] = useState(false);
const [recommendationDataReady, setRecommendationDataReady] = useState(false);
const [v22Scope, setV22Scope] = useState<VersionDataScope | null>(null);
const version = useMemo(() => getVersionDetail(overview, versionId), [overview, versionId]);
const fetchScopedRequirements = useCallback((force = false) => {
if (!version) return Promise.resolve();
if (force) return fetchRequirements({ productId: version.productId, versionId, force: true });
return fetchRequirements({ productId: version.productId, versionId });
}, [fetchRequirements, version, versionId]);
useEffect(() => { fetchOverview(); }, [fetchOverview]);
useEffect(() => {
@@ -182,43 +189,44 @@ export default function VersionDetailPage() {
fetchRecords();
return;
}
if (!version) return;
if (activeTab === 'requirements') {
fetchRequirements();
fetchDevTasks();
fetchScopedRequirements();
fetchDevTasks({ versionId });
return;
}
if (activeTab === 'research' || activeTab === 'product' || activeTab === 'ui') {
fetchRequirements();
fetchPlans();
fetchScopedRequirements();
fetchPlans({ versionId });
return;
}
if (activeTab === 'tasks') {
fetchRequirements();
fetchDevTasks();
fetchScopedRequirements();
fetchDevTasks({ versionId });
return;
}
if (activeTab === 'testcases') {
fetchRequirements();
fetchDevTasks();
fetchTestCases();
fetchBugs();
fetchScopedRequirements();
fetchDevTasks({ versionId });
fetchTestCases({ versionId });
fetchBugs({ versionId });
return;
}
if (activeTab === 'bugs') {
fetchRequirements();
fetchTestCases();
fetchBugs();
fetchScopedRequirements();
fetchTestCases({ versionId });
fetchBugs({ versionId });
}
}, [activeTab, fetchBugs, fetchDevTasks, fetchPlans, fetchRecords, fetchRequirements, fetchTestCases]);
}, [activeTab, fetchBugs, fetchDevTasks, fetchPlans, fetchRecords, fetchScopedRequirements, fetchTestCases, version, versionId]);
useEffect(() => {
if (!showRecommendModal) return;
fetchRecords();
fetchRequirements();
fetchPlans();
fetchDevTasks();
fetchTestCases();
fetchBugs();
}, [fetchBugs, fetchDevTasks, fetchPlans, fetchRecords, fetchRequirements, fetchTestCases, showRecommendModal]);
fetchScopedRequirements(true);
fetchPlans({ versionId });
fetchDevTasks({ versionId });
fetchTestCases({ versionId });
fetchBugs({ versionId });
}, [fetchBugs, fetchDevTasks, fetchPlans, fetchRecords, fetchScopedRequirements, fetchTestCases, showRecommendModal, versionId]);
useEffect(() => {
let active = true;
setRecommendationDataReady(false);
@@ -228,8 +236,7 @@ export default function VersionDetailPage() {
return () => { active = false; };
}, [fetchMembers, fetchCategories]);
const version = useMemo(() => getVersionDetail(overview, versionId), [overview, versionId]);
const appDataVersionScope = useMemo(
const storeVersionScope = useMemo(
() => version
? buildVersionDataScope({
versionId: version.id,
@@ -250,19 +257,20 @@ export default function VersionDetailPage() {
const testCaseWriteReady = requirementsLoaded && devTasksLoaded && testCasesLoaded && bugsLoaded;
const bugWriteReady = requirementsLoaded && testCasesLoaded && bugsLoaded;
const loadAllVersionStores = () => {
if (!version) return;
fetchRecords();
fetchRequirements();
fetchPlans();
fetchDevTasks();
fetchTestCases();
fetchBugs();
fetchRequirements({ productId: version.productId, versionId, force: true });
fetchPlans({ versionId });
fetchDevTasks({ versionId });
fetchTestCases({ versionId });
fetchBugs({ versionId });
};
const versionScope = useMemo(
() => selectVersionDataScope({
appDataScope: appDataVersionScope,
v22Scope: versionWriteStoresReady ? null : v22Scope,
storeScope: storeVersionScope,
v22Scope,
}),
[appDataVersionScope, v22Scope, versionWriteStoresReady],
[storeVersionScope, v22Scope],
);
const releaseProgress = versionScope
? calcScopedVersionProgress(versionScope.plans, versionScope.devTasks, versionScope.testCases)
@@ -327,15 +335,12 @@ export default function VersionDetailPage() {
.some((permission) => canWriteVersionPermission(permission));
const scopedVersionData = versionScope!;
const appDataScopedVersionData = appDataVersionScope ?? scopedVersionData;
const displayRequirements = requirementsLoaded ? requirements : scopedVersionData.requirements;
const displayPlans = plansLoaded ? appDataScopedVersionData.plans : scopedVersionData.plans;
const displayDevTasks = devTasksLoaded ? appDataScopedVersionData.devTasks : scopedVersionData.devTasks;
const displayTestCases = testCasesLoaded ? appDataScopedVersionData.testCases : scopedVersionData.testCases;
const displayBugs = bugsLoaded ? appDataScopedVersionData.bugs : scopedVersionData.bugs;
const displayRequirementIds = requirementsLoaded
? appDataScopedVersionData.requirementIds
: scopedVersionData.requirementIds;
const displayRequirements = scopedVersionData.requirements;
const displayPlans = scopedVersionData.plans;
const displayDevTasks = scopedVersionData.devTasks;
const displayTestCases = scopedVersionData.testCases;
const displayBugs = scopedVersionData.bugs;
const displayRequirementIds = scopedVersionData.requirementIds;
const memberRecommendationGroups = showRecommendModal && recommendationDataReady ? (() => {
const currentSystemParticipation = new Map<string, number>();
overview.forEach((product) => {
@@ -1066,8 +1071,10 @@ export default function VersionDetailPage() {
version={version}
versionDeadline={version.expectedReleaseDate ?? undefined}
currentUserName={user?.name ?? ''}
currentUserReference={user?.username ?? user?.id ?? user?.name ?? ''}
planType={pt}
versionMembers={version.members ?? []}
allMembers={memberCandidates}
linkedRequirements={versionLinkedReqs}
allRequirements={displayRequirements}
readOnly={versionReadonly || !planWriteReady || !canWriteVersionPermission(PLAN_MANAGE_PERMISSION[pt])}

View File

@@ -94,16 +94,9 @@ function VersionsPageContent() {
useEffect(() => { fetchOverview(); }, [fetchOverview]);
const { requirements, fetchRequirements, updateRequirement } = useRequirementStore();
useEffect(() => { fetchRequirements(); }, [fetchRequirements]);
const { plans, fetchPlans } = useVersionPlanStore();
useEffect(() => { fetchPlans(); }, [fetchPlans]);
const { tasks: devTasks, fetchTasks: fetchDevTasks } = useDevTaskStore();
useEffect(() => { fetchDevTasks(); }, [fetchDevTasks]);
const { testCases, fetchTestCases } = useTestCaseStore();
useEffect(() => { fetchTestCases(); }, [fetchTestCases]);
const user = useAuthStore((s) => s.user);
const currentUserName = user?.name || '';
@@ -125,6 +118,14 @@ function VersionsPageContent() {
if (!v.members || v.members.length === 0) return true;
return v.members.some((m) => m.name === currentUserName);
}), [allVersionsRaw, currentUserName, isSuperAdmin]);
useEffect(() => {
for (const version of allVersions) {
void fetchRequirements({ productId: version.productId, versionId: version.id });
void fetchPlans({ versionId: version.id });
void fetchDevTasks({ versionId: version.id });
void fetchTestCases({ versionId: version.id });
}
}, [allVersions, fetchDevTasks, fetchPlans, fetchRequirements, fetchTestCases]);
const versionTree = useMemo(() => buildVersionScopeTree(allVersions, treeKeyword), [allVersions, treeKeyword]);
// Compute overall progress per version from actual data

View File

@@ -32,7 +32,7 @@ import { loadV22WorkspaceData, type V22WorkspaceData } from '@/lib/v22-api';
import { selectWorkspaceCollections } from '@/lib/workspace-v22-source';
type TabKey = 'all' | 'plan_research' | 'plan_product' | 'plan_ui' | 'devTask' | 'testCase' | 'bug';
type WorkspaceVersionContext = { id: string; name: string; productName: string; projectName: string; status: VersionStatus };
type WorkspaceVersionContext = { id: string; name: string; productId: string; productName: string; projectName: string; status: VersionStatus };
type TreeVersion = { id: string; name: string; status: VersionStatus; pendingCount: number };
type ProductTree = Map<string, { name: string; projects: Map<string, { name: string; versions: TreeVersion[] }> }>;
@@ -56,11 +56,11 @@ const PLAN_STATUS_LABEL: Record<string, string> = { pending: '未开始', in_pro
export default function WorkspacePage() {
const router = useRouter();
const { overview, fetchOverview } = useProductStore();
const { plans, fetchPlans, loaded: plansLoaded } = useVersionPlanStore();
const { requirements, fetchRequirements, loaded: requirementsLoaded } = useRequirementStore();
const { tasks: devTasks, fetchTasks, loaded: devTasksLoaded } = useDevTaskStore();
const { testCases, fetchTestCases, loaded: testCasesLoaded } = useTestCaseStore();
const { bugs, fetchBugs, loaded: bugsLoaded } = useBugStore();
const { plans, fetchPlans } = useVersionPlanStore();
const { requirements, fetchRequirements } = useRequirementStore();
const { tasks: devTasks, fetchTasks } = useDevTaskStore();
const { testCases, fetchTestCases } = useTestCaseStore();
const { bugs, fetchBugs } = useBugStore();
const { worklogs, fetchWorklogs } = useTaskWorklogStore();
const { activities, fetchActivities } = useWorkActivityStore();
const user = useAuthStore((s) => s.user);
@@ -81,6 +81,16 @@ export default function WorkspacePage() {
const userName = user?.name ?? '';
const workspaceUserKey = userId || userName;
const workspaceUserRefs = useMemo(() => [userName, userId].filter(Boolean), [userId, userName]);
const allVersions = useMemo(() => flattenVersions(overview), [overview]);
const hydrateWorkspaceStoreFallback = useCallback(() => {
for (const version of allVersions) {
void fetchRequirements({ productId: version.productId, versionId: version.id });
void fetchPlans({ versionId: version.id });
void fetchTasks({ versionId: version.id });
void fetchTestCases({ versionId: version.id });
void fetchBugs({ versionId: version.id });
}
}, [allVersions, fetchBugs, fetchPlans, fetchRequirements, fetchTasks, fetchTestCases]);
useEffect(() => {
if (!workspaceUserKey.trim()) {
@@ -112,26 +122,19 @@ export default function WorkspacePage() {
useEffect(() => {
if (!workspaceUserKey.trim() || !v22WorkspaceFailed) return;
void fetchPlans();
void fetchRequirements();
void fetchTasks();
void fetchTestCases();
void fetchBugs();
}, [
fetchBugs,
fetchPlans,
fetchRequirements,
fetchTasks,
fetchTestCases,
v22WorkspaceFailed,
workspaceUserKey,
]);
const allVersions = useMemo(() => flattenVersions(overview), [overview]);
hydrateWorkspaceStoreFallback();
}, [hydrateWorkspaceStoreFallback, v22WorkspaceFailed, workspaceUserKey]);
const versionMap = useMemo(() => {
const map = new Map<string, WorkspaceVersionContext>();
allVersions.forEach((v) => map.set(v.id, { id: v.id, name: v.name, productName: v.productName, projectName: v.projectName, status: v.status }));
allVersions.forEach((v) => map.set(v.id, {
id: v.id,
name: v.name,
productId: v.productId,
productName: v.productName,
projectName: v.projectName,
status: v.status,
}));
return map;
}, [allVersions]);
@@ -146,7 +149,7 @@ export default function WorkspacePage() {
v22Loaded: v22WorkspaceLoaded,
v22Failed: v22WorkspaceFailed,
v22Data: v22WorkspaceData,
appData: { plans, devTasks, testCases, bugs },
storeData: { plans, devTasks, testCases, bugs },
}),
[bugs, devTasks, plans, testCases, v22WorkspaceData, v22WorkspaceFailed, v22WorkspaceLoaded],
);
@@ -227,25 +230,24 @@ export default function WorkspacePage() {
const drawerReadOnly = drawerVersionStatus ? isVersionReadonly(drawerVersionStatus) : false;
const ensureWorkspaceDrawerStores = useCallback(async (item: WorkItem) => {
const pendingLoads: Array<Promise<void>> = [];
if (!requirementsLoaded) pendingLoads.push(fetchRequirements());
if ((item.type === 'plan_research' || item.type === 'plan_product' || item.type === 'plan_ui') && !plansLoaded) {
pendingLoads.push(fetchPlans());
const versionContext = versionMap.get(item.versionId);
if (versionContext) {
pendingLoads.push(fetchRequirements({ productId: versionContext.productId, versionId: item.versionId }));
}
if (item.type === 'devTask' && !devTasksLoaded) pendingLoads.push(fetchTasks());
if (item.type === 'testCase' && !testCasesLoaded) pendingLoads.push(fetchTestCases());
if (item.type === 'bug' && !bugsLoaded) pendingLoads.push(fetchBugs());
if (item.type === 'plan_research' || item.type === 'plan_product' || item.type === 'plan_ui') {
pendingLoads.push(fetchPlans({ versionId: item.versionId }));
}
if (item.type === 'devTask') pendingLoads.push(fetchTasks({ versionId: item.versionId }));
if (item.type === 'testCase') pendingLoads.push(fetchTestCases({ versionId: item.versionId }));
if (item.type === 'bug') pendingLoads.push(fetchBugs({ versionId: item.versionId }));
await Promise.all(pendingLoads);
}, [
bugsLoaded,
devTasksLoaded,
fetchBugs,
fetchPlans,
fetchRequirements,
fetchTasks,
fetchTestCases,
plansLoaded,
requirementsLoaded,
testCasesLoaded,
versionMap,
fetchBugs,
]);
const openWorkItemDrawer = useCallback(async (item: WorkItem) => {
await ensureWorkspaceDrawerStores(item);

View File

@@ -9,15 +9,12 @@ import { useXiaobaoWarningRisks } from '@/hooks/useXiaobaoWarningRisks';
import { useAuthStore } from '@/stores/useAuthStore';
import { useXiaobaoWarningReadStore } from '@/stores/useXiaobaoWarningReadStore';
import type { XiaobaoRiskLevel, XiaobaoVersionRisk } from '@/lib/xiaobao-risk';
import { buildRiskInsightSignature, findPreviousRiskSnapshot, requestRiskInsight, shouldRequestRiskInsightWithRequestGate } from '@/lib/xiaobao-risk-ai';
import { buildRiskSignature, findLatestDailySnapshot, shouldSaveRiskSnapshot } from '@/lib/xiaobao-risk-trend';
import { attachXiaobaoRiskSuggestion, buildXiaobaoRiskInsightPendingKey } from '@/lib/xiaobao-risk-suggestion';
import { attachXiaobaoRiskSuggestion } from '@/lib/xiaobao-risk-suggestion';
import {
filterXiaobaoRiskWarnings,
formatRemainingWork,
isXiaobaoWarningUpdated,
shouldSkipXiaobaoRiskInsightRequestForReadState,
sanitizeRiskInsight,
} from '@/lib/xiaobao-warning-view';
import { formatDateTime } from '@/lib/format';
@@ -57,19 +54,13 @@ function XiaobaoWarningContent() {
insights,
pendingInsightKeys,
insightRequestAttempts,
riskDataLoaded,
saveSnapshot,
saveInsight,
beginInsightUpdate,
finishInsightUpdate,
today,
} = useXiaobaoWarningRisks({ loadRiskCache: true });
const { readStates, readStateLoaded, fetchReadStates, markRiskRead } = useXiaobaoWarningReadStore();
const [selectedRiskId, setSelectedRiskId] = useState<string | null>(null);
const [selectedProductId, setSelectedProductId] = useState('');
const [selectedProjectId, setSelectedProjectId] = useState('');
const savedSnapshotKeysRef = useRef(new Set<string>());
const requestedInsightKeysRef = useRef(new Set<string>());
useEffect(() => {
if (user?.id) fetchReadStates();
@@ -89,52 +80,6 @@ function XiaobaoWarningContent() {
});
}, [risks, saveSnapshot, snapshots]);
useEffect(() => {
risks.forEach((risk) => {
if (shouldSkipXiaobaoRiskInsightRequestForReadState(risk, readStates, user?.id, readStateLoaded)) return;
const previous = findPreviousRiskSnapshot(snapshots, risk.versionId, today);
const key = buildXiaobaoRiskInsightPendingKey(risk);
if (!shouldRequestRiskInsightWithRequestGate({
riskCacheLoaded: riskDataLoaded,
cache: insights,
current: risk,
previous,
lastRequestedAt: insightRequestAttempts[key],
})) return;
const signature = buildRiskInsightSignature(risk);
if (pendingInsightKeys.includes(key)) return;
if (requestedInsightKeysRef.current.has(key)) return;
requestedInsightKeysRef.current.add(key);
beginInsightUpdate(key);
requestRiskInsight(risk).then((response) => {
if (!response.ok) return;
return saveInsight({
versionId: risk.versionId,
riskSignature: signature,
insight: sanitizeRiskInsight(response.result),
generatedAt: new Date().toISOString(),
providerInfo: { model: response.meta.model },
}).catch(() => {});
}).catch(() => {}).finally(() => {
finishInsightUpdate(key);
});
});
}, [
beginInsightUpdate,
finishInsightUpdate,
insights,
insightRequestAttempts,
pendingInsightKeys,
readStateLoaded,
readStates,
riskDataLoaded,
risks,
saveInsight,
snapshots,
today,
user?.id,
]);
const risksWithInsight = useMemo(() => risks.map((risk) => attachXiaobaoRiskSuggestion(risk, {
insights,
pendingInsightKeys,

View File

@@ -36,11 +36,11 @@ export function BugTab({ versionId, requirementIds, readOnly = false, versionBug
const members = useMemberStore((s) => s.members);
useEffect(() => {
if (!scopedVersionBugs) fetchBugs();
}, [fetchBugs, scopedVersionBugs]);
if (!scopedVersionBugs) fetchBugs({ versionId });
}, [fetchBugs, scopedVersionBugs, versionId]);
useEffect(() => {
if (!versionTestCases) fetchTestCases();
}, [fetchTestCases, versionTestCases]);
if (!versionTestCases) fetchTestCases({ versionId });
}, [fetchTestCases, versionId, versionTestCases]);
const fallbackVersionBugs = useMemo(
() => bugs.filter((b) => b.versionId === versionId),

View File

@@ -48,8 +48,8 @@ export function DevTaskTab({ versionId, requirementIds, versionDeadline, readOnl
const user = useAuthStore((s) => s.user);
useEffect(() => {
if (!scopedVersionTasks) fetchTasks();
}, [fetchTasks, scopedVersionTasks]);
if (!scopedVersionTasks) fetchTasks({ versionId });
}, [fetchTasks, scopedVersionTasks, versionId]);
useEffect(() => { fetchCategories(); }, [fetchCategories]);
const reqIdSet = useMemo(() => new Set(requirementIds), [requirementIds]);

View File

@@ -18,7 +18,6 @@ interface RequirementModalProps {
currentUserName: string;
onClose: () => void;
onSubmit: (data: any) => void;
onOpenDrawer: (type: 'source' | 'type' | 'platform') => void;
}
const PRIORITIES: Priority[] = ['P0', 'P1', 'P2', 'P3', 'P4'];
@@ -45,7 +44,6 @@ export function RequirementModal({
currentUserName,
onClose,
onSubmit,
onOpenDrawer,
}: RequirementModalProps) {
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
@@ -289,13 +287,6 @@ export function RequirementModal({
<label className="text-[12px] font-medium text-[var(--ink-soft)]">
{SOURCE_TARGET_LABEL[sourceType]}
</label>
<button
type="button"
onClick={() => onOpenDrawer('source')}
className="text-[11px] text-[var(--accent)] hover:underline"
>
</button>
</div>
<div className="relative">
<button
@@ -494,13 +485,6 @@ export function RequirementModal({
<div>
<div className="flex items-center justify-between mb-1.5">
<label className="text-[12px] font-medium text-[var(--ink-soft)]"></label>
<button
type="button"
onClick={() => onOpenDrawer('platform')}
className="text-[11px] text-[var(--accent)] hover:underline"
>
</button>
</div>
<div className="relative">
<button
@@ -538,13 +522,6 @@ export function RequirementModal({
<div>
<div className="flex items-center justify-between mb-1.5">
<label className="text-[12px] font-medium text-[var(--ink-soft)]"></label>
<button
type="button"
onClick={() => onOpenDrawer('type')}
className="text-[11px] text-[var(--accent)] hover:underline"
>
</button>
</div>
<div className="relative">
<button

View File

@@ -63,11 +63,11 @@ export function TestCaseTab({
const { categories, fetchCategories } = useTaskCategoryStore();
useEffect(() => {
if (!scopedVersionCases) fetchTestCases();
}, [fetchTestCases, scopedVersionCases]);
if (!scopedVersionCases) fetchTestCases({ versionId });
}, [fetchTestCases, scopedVersionCases, versionId]);
useEffect(() => {
if (!scopedVersionBugs) fetchBugs();
}, [fetchBugs, scopedVersionBugs]);
if (!scopedVersionBugs) fetchBugs({ versionId });
}, [fetchBugs, scopedVersionBugs, versionId]);
useEffect(() => { fetchCategories(); }, [fetchCategories]);
const reqIdSet = useMemo(() => new Set(requirementIds), [requirementIds]);

View File

@@ -1,6 +1,6 @@
'use client';
import { useEffect, useMemo, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { Plus, Pencil, Trash2, X, Check, ExternalLink, FileUp, Link2, Play, ArrowRightLeft } from 'lucide-react';
import type { ProductPlanKind, ProductPlanReviewFailureType, ProductPlanReviewResult, VersionPlan, PlanTask } from '@/lib/version-plan';
import {
@@ -28,6 +28,9 @@ import type { Requirement } from '@/lib/requirement';
import { mergeSelectedRequirementOptions } from '@/lib/requirement-selector';
import { canEditPlanRequirementCoverage, canTogglePlanChecklist, getPlanCompletionState } from '@/lib/version-plan-workflow';
import type { PlanResultPayload } from '@/lib/version-plan-workflow';
import { isMemberReference, resolveMemberDisplayName } from '@/lib/member-system';
type PlanMemberCandidate = { id?: string; role?: string; name: string; username?: string | null };
interface Props {
plans: VersionPlan[];
@@ -35,8 +38,10 @@ interface Props {
version?: VersionWithContext;
versionDeadline?: string;
currentUserName: string;
currentUserReference?: string;
planType: 'research' | 'product' | 'ui';
versionMembers: { role: string; name: string }[];
versionMembers: PlanMemberCandidate[];
allMembers?: PlanMemberCandidate[];
linkedRequirements?: Requirement[];
allRequirements?: Requirement[];
onCreate: (data: Omit<VersionPlan, 'id' | 'createdAt'>) => void;
@@ -72,7 +77,34 @@ function getFailureLabels(types?: ProductPlanReviewFailureType[]): string[] {
.filter(Boolean) as string[];
}
export function PlanTab({ plans, versionId, version, versionDeadline, currentUserName, planType, versionMembers, linkedRequirements, allRequirements, onCreate, onUpdate, onComplete, onDelete, readOnly = false }: Props) {
function buildPlanMemberCandidates(
versionMembers: PlanMemberCandidate[],
allMembers: PlanMemberCandidate[],
): PlanMemberCandidate[] {
const byName = new Map(allMembers.map((member) => [member.name, member]));
const seen = new Set<string>();
const out: PlanMemberCandidate[] = [];
const push = (member: PlanMemberCandidate) => {
const key = member.id || member.name;
if (!key || seen.has(key)) return;
seen.add(key);
out.push(member);
};
versionMembers.forEach((member) => {
const canonical = byName.get(member.name);
push({ ...member, id: member.id ?? canonical?.id, username: member.username ?? canonical?.username });
});
allMembers.forEach(push);
return out;
}
function getMemberReferenceValue(member: PlanMemberCandidate): string {
return member.username || member.id || member.name;
}
export function PlanTab({ plans, versionId, version, versionDeadline, currentUserName, currentUserReference, planType, versionMembers, allMembers, linkedRequirements, allRequirements, onCreate, onUpdate, onComplete, onDelete, readOnly = false }: Props) {
const [showCreateModal, setShowCreateModal] = useState(false);
const [editingPlan, setEditingPlan] = useState<VersionPlan | null>(null);
const [completingPlan, setCompletingPlan] = useState<VersionPlan | null>(null);
@@ -81,6 +113,10 @@ export function PlanTab({ plans, versionId, version, versionDeadline, currentUse
const typePlans = sortPlansNewestFirst(plans.filter((p) => p.versionId === versionId && p.type === planType));
const totalDuration = calcTotalDuration(typePlans);
const planMemberCandidates = useMemo(
() => buildPlanMemberCandidates(versionMembers, allMembers ?? []),
[versionMembers, allMembers],
);
return (
<div className="-m-5 h-[calc(100vh-98px)] min-h-[520px]">
@@ -113,6 +149,7 @@ export function PlanTab({ plans, versionId, version, versionDeadline, currentUse
const completionState = getPlanCompletionState(plan);
const canToggle = canTogglePlanChecklist(plan);
const canEditCoverage = canEditPlanRequirementCoverage(plan);
const ownerLabel = resolveMemberDisplayName(plan.owner, planMemberCandidates);
const requirementOptions = mergeSelectedRequirementOptions(linkedRequirements ?? [], allRequirements ?? [], plan.linkedRequirementIds ?? []);
// 耗时用实际时间戳计算
const dur = plan.status === 'completed' && plan.completedAt && plan.actualStartAt
@@ -150,7 +187,7 @@ export function PlanTab({ plans, versionId, version, versionDeadline, currentUse
<dl className="mt-3 grid grid-cols-1 gap-x-5 gap-y-2 border-y border-[var(--line)] py-3 text-[12px] sm:grid-cols-2 lg:grid-cols-4">
<div>
<dt className="text-[11px] text-[var(--ink-muted)]"></dt>
<dd className="mt-0.5 font-medium text-[var(--ink)]">{plan.owner}</dd>
<dd className="mt-0.5 font-medium text-[var(--ink)]">{ownerLabel}</dd>
</div>
<div className="sm:col-span-2">
<dt className="text-[11px] text-[var(--ink-muted)]"></dt>
@@ -276,7 +313,7 @@ export function PlanTab({ plans, versionId, version, versionDeadline, currentUse
<FilterSelect
value={transferTo || 'all'}
onChange={(value) => setTransferTo(value === 'all' ? '' : value)}
options={versionMembers.filter((m) => m.name !== plan.owner).map((m) => ({ value: m.name, label: m.name }))}
options={planMemberCandidates.filter((member) => !isMemberReference(plan.owner, member)).map((member) => ({ value: getMemberReferenceValue(member), label: member.name }))}
allLabel="选择参与人员"
className="flex-1"
/>
@@ -310,7 +347,7 @@ export function PlanTab({ plans, versionId, version, versionDeadline, currentUse
versionDeadline={versionDeadline}
version={version}
currentUserName={currentUserName}
versionMembers={versionMembers}
versionMembers={planMemberCandidates}
linkedRequirements={linkedRequirements}
allRequirements={allRequirements}
transferPlanId={transferPlanId}
@@ -333,6 +370,8 @@ export function PlanTab({ plans, versionId, version, versionDeadline, currentUse
versionId={versionId}
versionDeadline={versionDeadline}
currentUserName={currentUserName}
currentUserReference={currentUserReference}
memberCandidates={planMemberCandidates}
linkedRequirements={linkedRequirements}
allRequirements={allRequirements}
onClose={() => { setShowCreateModal(false); setEditingPlan(null); }}
@@ -412,7 +451,7 @@ function ProductUiPlanWorkspace({
versionDeadline?: string;
version?: VersionWithContext;
currentUserName: string;
versionMembers: { role: string; name: string }[];
versionMembers: PlanMemberCandidate[];
linkedRequirements?: Requirement[];
allRequirements?: Requirement[];
transferPlanId: string | null;
@@ -427,6 +466,7 @@ function ProductUiPlanWorkspace({
readOnly: boolean;
}) {
const [selectedPlanId, setSelectedPlanId] = useState<string | null>(typePlans[0]?.id ?? null);
const autoStartedPlanIds = useRef<Set<string>>(new Set());
const selectedPlan = typePlans.find((plan) => plan.id === selectedPlanId) ?? typePlans[0];
const selectedPlanIdOrFirst = selectedPlan?.id;
const allLogs = useMemo(() => getPlanLogsForPlans(typePlans), [typePlans]);
@@ -435,7 +475,8 @@ function ProductUiPlanWorkspace({
if (readOnly) return;
typePlans.forEach((plan) => {
const { autoStarted } = getPlanRuntime(plan);
if (autoStarted && !plan.actualStartAt) {
if (autoStarted && !plan.actualStartAt && !autoStartedPlanIds.current.has(plan.id)) {
autoStartedPlanIds.current.add(plan.id);
onUpdate(plan.id, { status: 'in_progress' });
}
});
@@ -470,6 +511,7 @@ function ProductUiPlanWorkspace({
<div className="min-h-0 flex-1 space-y-1 overflow-y-auto p-2">
{typePlans.map((plan) => {
const { effectiveStatus } = getPlanRuntime(plan);
const ownerLabel = resolveMemberDisplayName(plan.owner, versionMembers);
const summary = plan.type === 'research'
? getResearchDirectionProgressSummary(plan)
: getRequirementCoverageSummary(plan);
@@ -488,7 +530,7 @@ function ProductUiPlanWorkspace({
</span>
</div>
<div className="mt-2 flex items-center justify-between gap-2 text-[11px]">
<span className="truncate">{plan.owner}</span>
<span className="truncate">{ownerLabel}</span>
{plan.type === 'product' ? (
<span className="shrink-0">{PRODUCT_PLAN_KIND_LABEL[getProductPlanKind(plan)]}</span>
) : (
@@ -577,7 +619,7 @@ function ProductUiPlanDetail({
planType: VersionPlan['type'];
version?: VersionWithContext;
currentUserName: string;
versionMembers: { role: string; name: string }[];
versionMembers: PlanMemberCandidate[];
linkedRequirements?: Requirement[];
allRequirements?: Requirement[];
transferPlanId: string | null;
@@ -595,6 +637,7 @@ function ProductUiPlanDetail({
const durText = getPlanDurationText(plan, effectiveStatus, effectiveStartAt, now);
const completionState = getPlanCompletionState(plan);
const canEditCoverage = !readOnly && canEditPlanRequirementCoverage(plan);
const ownerLabel = resolveMemberDisplayName(plan.owner, versionMembers);
const requirementOptions = mergeSelectedRequirementOptions(linkedRequirements ?? [], allRequirements ?? [], plan.linkedRequirementIds ?? []);
const selectedRequirements = (plan.linkedRequirementIds ?? [])
.map((rid) => requirementOptions.find((requirement) => requirement.id === rid))
@@ -649,7 +692,7 @@ function ProductUiPlanDetail({
<dl className="mt-4 grid grid-cols-1 gap-3 text-[12px] sm:grid-cols-2 xl:grid-cols-4">
<div className="rounded-lg bg-[var(--bg-subtle)] px-3 py-2">
<dt className="text-[11px] text-[var(--ink-muted)]"></dt>
<dd className="mt-0.5 font-medium text-[var(--ink)]">{plan.owner}</dd>
<dd className="mt-0.5 font-medium text-[var(--ink)]">{ownerLabel}</dd>
</div>
<div className="rounded-lg bg-[var(--bg-subtle)] px-3 py-2 sm:col-span-2">
<dt className="text-[11px] text-[var(--ink-muted)]"></dt>
@@ -738,7 +781,7 @@ function ProductUiPlanDetail({
<FilterSelect
value={transferTo || 'all'}
onChange={(value) => onTransferToChange(value === 'all' ? '' : value)}
options={versionMembers.filter((member) => member.name !== plan.owner).map((member) => ({ value: member.name, label: member.name }))}
options={versionMembers.filter((member) => !isMemberReference(plan.owner, member)).map((member) => ({ value: getMemberReferenceValue(member), label: member.name }))}
allLabel="选择参与人员"
className="flex-1"
/>
@@ -757,12 +800,14 @@ function ProductUiPlanDetail({
);
}
function PlanFormModal({ initial, planType, versionId, versionDeadline, currentUserName, linkedRequirements, allRequirements, onClose, onSubmit }: {
function PlanFormModal({ initial, planType, versionId, versionDeadline, currentUserName, currentUserReference, memberCandidates, linkedRequirements, allRequirements, onClose, onSubmit }: {
initial: VersionPlan | null;
planType: 'research' | 'product' | 'ui';
versionId: string;
versionDeadline?: string;
currentUserName: string;
currentUserReference?: string;
memberCandidates: PlanMemberCandidate[];
linkedRequirements?: Requirement[];
allRequirements?: Requirement[];
onClose: () => void;
@@ -770,7 +815,8 @@ function PlanFormModal({ initial, planType, versionId, versionDeadline, currentU
}) {
const now = new Date().toISOString().slice(0, 16);
const [title, setTitle] = useState(initial?.title ?? '');
const [owner] = useState(initial?.owner ?? currentUserName);
const [owner] = useState(initial?.owner ?? currentUserReference ?? currentUserName);
const ownerLabel = resolveMemberDisplayName(owner, memberCandidates);
const [startTime, setStartTime] = useState(initial?.startTime?.slice(0, 16) ?? now);
const [endTime, setEndTime] = useState(initial?.endTime?.slice(0, 16) ?? '');
const [remark, setRemark] = useState(initial?.remark ?? '');
@@ -801,7 +847,7 @@ function PlanFormModal({ initial, planType, versionId, versionDeadline, currentU
versionId,
type: planType,
title: title.trim(),
owner: owner.trim() || currentUserName,
owner: owner.trim() || currentUserReference || currentUserName,
startTime,
endTime,
status: initial?.status ?? 'pending',
@@ -828,7 +874,7 @@ function PlanFormModal({ initial, planType, versionId, versionDeadline, currentU
</div>
<div>
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1 block"></label>
<input value={owner} readOnly className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-subtle)] px-3 text-[13px] text-[var(--ink-muted)] cursor-not-allowed" />
<input value={ownerLabel} readOnly className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-subtle)] px-3 text-[13px] text-[var(--ink-muted)] cursor-not-allowed" />
</div>
{planType === 'product' && (
<div>

View File

@@ -1,6 +1,6 @@
'use client';
import { useEffect, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useProductStore } from '@/stores/useProductStore';
import { useVersionPlanStore } from '@/stores/useVersionPlanStore';
import { useRequirementStore } from '@/stores/useRequirementStore';
@@ -31,6 +31,17 @@ export function useWorkspaceWorkItems({ autoFetch = true }: { autoFetch?: boolea
const userName = user?.name ?? '';
const workspaceUserKey = userId || userName;
const workspaceUserRefs = useMemo(() => [userName, userId].filter(Boolean), [userId, userName]);
const allVersions = useMemo(() => flattenVersions(overview), [overview]);
const hydrateWorkspaceStoreFallback = useCallback(() => {
for (const version of allVersions) {
void fetchRequirements({ productId: version.productId, versionId: version.id });
void fetchPlans({ versionId: version.id });
void fetchTasks({ versionId: version.id });
void fetchTestCases({ versionId: version.id });
void fetchBugs({ versionId: version.id });
}
}, [allVersions, fetchBugs, fetchPlans, fetchRequirements, fetchTasks, fetchTestCases]);
useEffect(() => {
if (!autoFetch || !workspaceUserKey.trim()) {
setV22WorkspaceData(null);
@@ -61,30 +72,21 @@ export function useWorkspaceWorkItems({ autoFetch = true }: { autoFetch?: boolea
useEffect(() => {
if (!autoFetch || !workspaceUserKey.trim() || !v22WorkspaceFailed) return;
void fetchPlans();
void fetchRequirements();
void fetchTasks();
void fetchTestCases();
void fetchBugs();
hydrateWorkspaceStoreFallback();
}, [
autoFetch,
fetchBugs,
fetchPlans,
fetchRequirements,
fetchTasks,
fetchTestCases,
hydrateWorkspaceStoreFallback,
v22WorkspaceFailed,
workspaceUserKey,
]);
const allVersions = useMemo(() => flattenVersions(overview), [overview]);
const versionMap = useMemo(() => {
const map = new Map<string, { id: string; name: string; productName: string; projectName: string }>();
const map = new Map<string, { id: string; name: string; productId: string; productName: string; projectName: string }>();
allVersions.forEach((version) => {
map.set(version.id, {
id: version.id,
name: version.name,
productId: version.productId,
productName: version.productName,
projectName: version.projectName,
});
@@ -105,7 +107,7 @@ export function useWorkspaceWorkItems({ autoFetch = true }: { autoFetch?: boolea
v22Loaded: v22WorkspaceLoaded,
v22Failed: v22WorkspaceFailed,
v22Data: v22WorkspaceData,
appData: { plans, devTasks, testCases, bugs },
storeData: { plans, devTasks, testCases, bugs },
}),
[bugs, devTasks, plans, testCases, v22WorkspaceData, v22WorkspaceFailed, v22WorkspaceLoaded],
);

View File

@@ -1,6 +1,6 @@
'use client';
import { useEffect, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useHasPermission } from '@/components/auth/Guard';
import { useAuthStore } from '@/stores/useAuthStore';
import { useBugStore } from '@/stores/useBugStore';
@@ -20,7 +20,7 @@ import {
collectV22XiaobaoLatestInsights,
filterV22XiaobaoSummariesForVisibleVersions,
mergeV22XiaobaoLatestInsights,
shouldLoadXiaobaoAppDataFallback,
shouldLoadXiaobaoStoreFallback,
type V22XiaobaoSummaryLoadState,
} from '@/lib/xiaobao-v22-summary';
import { calcXiaobaoVersionRisk } from '@/lib/xiaobao-risk';
@@ -55,12 +55,26 @@ export function useXiaobaoWarningRisks({ loadRiskCache = false }: { loadRiskCach
const [v22Summaries, setV22Summaries] = useState<V22XiaobaoWarningSummary[]>([]);
const today = useMemo(() => new Date().toISOString().slice(0, 10), []);
const v22UserId = user?.name || user?.id || '';
const shouldLoadFallback = shouldLoadXiaobaoAppDataFallback(v22SummaryState);
const shouldLoadFallback = shouldLoadXiaobaoStoreFallback(v22SummaryState);
const relationInsights = useMemo(() => collectV22XiaobaoLatestInsights(v22Summaries), [v22Summaries]);
const mergedInsights = useMemo(
() => mergeV22XiaobaoLatestInsights(relationInsights, insights),
[insights, relationInsights],
);
const allVersions = useMemo(() => flattenVersions(overview), [overview]);
const visibleVersions = useMemo(
() => filterXiaobaoWarningVersions(allVersions, { canManage, userName: user?.name }),
[allVersions, canManage, user?.name],
);
const hydrateXiaobaoStoreFallback = useCallback(() => {
for (const version of visibleVersions) {
void fetchRequirements({ productId: version.productId, versionId: version.id });
void fetchPlans({ versionId: version.id });
void fetchTasks({ versionId: version.id });
void fetchTestCases({ versionId: version.id });
void fetchBugs({ versionId: version.id });
}
}, [fetchBugs, fetchPlans, fetchRequirements, fetchTasks, fetchTestCases, visibleVersions]);
useEffect(() => { fetchOverview(); }, [fetchOverview]);
useEffect(() => {
@@ -89,23 +103,15 @@ export function useXiaobaoWarningRisks({ loadRiskCache = false }: { loadRiskCach
ignore = true;
};
}, [canManage, v22UserId]);
useEffect(() => { if (shouldLoadFallback) fetchPlans(); }, [fetchPlans, shouldLoadFallback]);
useEffect(() => { if (shouldLoadFallback) fetchRequirements(); }, [fetchRequirements, shouldLoadFallback]);
useEffect(() => { if (shouldLoadFallback) fetchTasks(); }, [fetchTasks, shouldLoadFallback]);
useEffect(() => { if (shouldLoadFallback) fetchTestCases(); }, [fetchTestCases, shouldLoadFallback]);
useEffect(() => { if (shouldLoadFallback) fetchBugs(); }, [fetchBugs, shouldLoadFallback]);
useEffect(() => {
if (shouldLoadFallback) hydrateXiaobaoStoreFallback();
}, [hydrateXiaobaoStoreFallback, shouldLoadFallback]);
useEffect(() => { if (shouldLoadFallback) fetchActivities(); }, [fetchActivities, shouldLoadFallback]);
useEffect(() => { if (shouldLoadFallback) fetchWorklogs(); }, [fetchWorklogs, shouldLoadFallback]);
useEffect(() => {
if (loadRiskCache) fetchRiskData();
}, [fetchRiskData, loadRiskCache]);
const allVersions = useMemo(() => flattenVersions(overview), [overview]);
const visibleVersions = useMemo(
() => filterXiaobaoWarningVersions(allVersions, { canManage, userName: user?.name }),
[allVersions, canManage, user?.name],
);
const visibleVersionScopeMap = useMemo(() => buildVersionDataScopeMap({
versionIds: visibleVersions.map((version) => version.id),
plans,

View File

@@ -112,3 +112,20 @@ test('API availability probe retries after a transient failure', async () => {
globalThis.fetch = originalFetch;
}
});
test('raw API requests map abort errors to a readable timeout message', async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = (async () => {
throw new Error('signal is aborted without reason');
}) as typeof fetch;
try {
await assert.rejects(
() => api.postRaw('/ai/decompose', {}, 1),
/请求超时/,
);
} finally {
globalThis.fetch = originalFetch;
}
});

View File

@@ -37,6 +37,17 @@ function getErrorMessage(body: unknown, status: number) {
return `Request failed: ${status}`;
}
function isAbortLikeError(error: unknown) {
const value = error as { name?: unknown; message?: unknown };
const text = `${typeof value?.name === 'string' ? value.name : ''} ${typeof value?.message === 'string' ? value.message : String(error ?? '')}`;
return /abort|aborted|timeout|timed out/i.test(text);
}
function formatTimeoutMs(timeoutMs: number) {
if (timeoutMs >= 60000) return `${Math.ceil(timeoutMs / 60000)} 分钟`;
return `${Math.ceil(timeoutMs / 1000)}`;
}
export class ApiRequestError extends Error {
constructor(
public readonly status: number,
@@ -110,6 +121,11 @@ export const api = {
throw new ApiRequestError(res.status, err);
}
return res.json();
} catch (e) {
if (isAbortLikeError(e)) {
throw new Error(`请求超时:服务在${formatTimeoutMs(timeoutMs)}内没有返回,请稍后重试。`);
}
throw e;
} finally {
clearTimeout(tid);
}

View File

@@ -8,7 +8,7 @@ import type { Requirement, RequirementStatus, SourceType } from './requirement';
import type { TaskCategory } from './task-category';
import type { TaskWorklog } from './task-worklog';
import type { TestCase, TestCaseStatus } from './test-case';
import type { VersionPlan, VersionPlanLog, VersionPlanRequirementCoverage } from './version-plan';
import type { PlanTask, VersionPlan, VersionPlanLog, VersionPlanRequirementCoverage } from './version-plan';
import type { WorkActivity, WorkActivityCategory, WorkActivityDraft, WorkActivitySourceType } from './work-activity';
export interface RootProject {
@@ -117,6 +117,9 @@ interface DomainRequirementRow {
creatorId?: string | null;
creatorName?: string | null;
creator?: { id?: string | null; name?: string | null } | null;
productOwnerId?: string | null;
productOwnerName?: string | null;
productOwner?: { id?: string | null; name?: string | null } | null;
createdAt?: string | Date | null;
}
@@ -168,6 +171,7 @@ interface DomainVersionPlanRow {
actualStartAt?: string | Date | null;
completedAt?: string | Date | null;
resultUrl?: string | null;
tasks?: unknown;
requirementCoverage?: unknown;
logs?: unknown;
createdAt?: string | Date | null;
@@ -594,6 +598,7 @@ function toRequirementPayload(data: Partial<Requirement>) {
...(data.sourceTarget !== undefined && { sourceTarget: data.sourceTarget }),
...(data.platforms !== undefined && { platform: data.platforms.join(',') }),
...(data.priority !== undefined && { priority: priorityToNumber(data.priority) }),
...(data.productOwner !== undefined && { productOwnerId: data.productOwner }),
};
}
@@ -613,6 +618,7 @@ function normalizeRequirement(row: DomainRequirementRow): Requirement {
status: toRequirementStatus(row.status),
priority: toPriority(row.priority),
effort: 'M',
productOwner: row.productOwner?.name ?? row.productOwnerName ?? row.productOwnerId ?? '',
creator: row.creator?.name ?? row.creatorName ?? row.creatorId ?? '',
createdAt: isoString(row.createdAt),
};
@@ -669,6 +675,7 @@ function toVersionPlanPayload(data: Partial<VersionPlan>) {
...(data.actualStartAt !== undefined && { actualStartAt: data.actualStartAt }),
...(data.completedAt !== undefined && { completedAt: data.completedAt }),
...(data.resultUrl !== undefined && { resultUrl: data.resultUrl }),
...(data.tasks !== undefined && { tasks: data.tasks }),
...(data.linkedRequirementIds !== undefined && { linkedRequirementIds: data.linkedRequirementIds }),
...(data.requirementCoverage !== undefined && { requirementCoverage: data.requirementCoverage }),
...(data.logs !== undefined && { logs: data.logs }),
@@ -686,7 +693,7 @@ function normalizeVersionPlan(row: DomainVersionPlanRow): VersionPlan {
startTime: isoString(row.expectedStartAt),
endTime: isoString(row.expectedEndAt),
status: toPlanStatus(row.status),
tasks: [],
tasks: asArray<PlanTask>(row.tasks),
completedRequirementIds: requirementCoverage
.filter((item) => item?.status === 'completed')
.map((item) => item.requirementId)

View File

@@ -0,0 +1,21 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import test from 'node:test';
test('governance settings is the unified dictionary entry for requirement and task types', () => {
const sidebar = readFileSync('components/layout/Sidebar.tsx', 'utf8');
const permissions = readFileSync('lib/permissions.ts', 'utf8');
const governancePage = readFileSync('app/admin/governance/page.tsx', 'utf8');
const appModule = readFileSync(join(process.cwd(), '../server/src/app.module.ts'), 'utf8');
assert.match(sidebar, /治理设置/);
assert.match(sidebar, /\/admin\/governance/);
assert.doesNotMatch(sidebar, /\/admin\/categories/);
assert.match(permissions, /governance:manage/);
assert.match(appModule, /GovernanceModule/);
assert.match(governancePage, /task_category/);
assert.match(governancePage, /requirement_type/);
assert.match(governancePage, /requirement_platform/);
assert.match(governancePage, /requirement_source/);
});

View File

@@ -8,7 +8,7 @@ test('member store save helper does not swallow AppData save failures', () => {
assert.match(
source,
/async function saveStored\(state: \{ departments: Department\[\]; members: Member\[\]; roles: RoleItem\[\]; passwordRule: PasswordRule \}\) \{\s+await saveServerData\('members', state\);\s+\}/,
/async function saveStored\(state: MemberConfigState\) \{\s+await saveServerData\('members', \{ departments: state\.departments, members: \[\], roles: state\.roles, passwordRule: state\.passwordRule \}\);\s+\}/,
);
});

View File

@@ -101,6 +101,7 @@ test('resolves legacy Chen Shi labels to the built-in admin display name', () =>
assert.equal(resolveMemberDisplayName('陈十', result.members), SYSTEM_ADMIN_MEMBER_NAME);
assert.equal(resolveMemberDisplayName(SYSTEM_ADMIN_MEMBER_ID, result.members), SYSTEM_ADMIN_MEMBER_NAME);
assert.equal(resolveMemberDisplayName('admin', result.members), SYSTEM_ADMIN_MEMBER_NAME);
assert.equal(resolveMemberDisplayName('张三', result.members), '张三');
});
@@ -111,5 +112,6 @@ test('matches legacy admin names as the built-in admin member', () => {
assert.equal(isMemberReference('陈十', admin), true);
assert.equal(isMemberReference(SYSTEM_ADMIN_MEMBER_ID, admin), true);
assert.equal(isMemberReference(SYSTEM_ADMIN_MEMBER_NAME, admin), true);
assert.equal(isMemberReference('admin', admin), true);
assert.equal(isMemberReference('张三', admin), false);
});

View File

@@ -78,9 +78,9 @@ export function isSystemAdminMember(member: Pick<Member, 'id'>): boolean {
return member.id === SYSTEM_ADMIN_MEMBER_ID;
}
export function resolveMemberDisplayName(ref: string | undefined | null, members: Member[]): string {
export function resolveMemberDisplayName(ref: string | undefined | null, members: Array<{ id?: string; name: string; username?: string | null }>): string {
if (!ref) return '-';
const member = members.find((m) => m.id === ref || m.name === ref);
const member = members.find((m) => m.id === ref || m.name === ref || m.username === ref);
if (member) return member.name;
if (LEGACY_SYSTEM_ADMIN_NAMES.includes(ref)) {
const admin = members.find((m) => m.id === SYSTEM_ADMIN_MEMBER_ID);
@@ -89,8 +89,8 @@ export function resolveMemberDisplayName(ref: string | undefined | null, members
return ref;
}
export function isMemberReference(ref: string | undefined | null, member: Pick<Member, 'id' | 'name'>): boolean {
export function isMemberReference(ref: string | undefined | null, member: { id?: string; name: string; username?: string | null }): boolean {
if (!ref) return false;
if (ref === member.id || ref === member.name) return true;
if (ref === member.id || ref === member.name || ref === member.username) return true;
return member.id === SYSTEM_ADMIN_MEMBER_ID && LEGACY_SYSTEM_ADMIN_NAMES.includes(ref);
}

View File

@@ -0,0 +1,89 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import test from 'node:test';
function source(path: string) {
return readFileSync(join(process.cwd(), path), 'utf8');
}
function assertNoBroadBusinessFetches(path: string, allowedPatterns: RegExp[] = []) {
const text = allowedPatterns.reduce(
(current, pattern) => current.replace(pattern, ''),
source(path),
);
for (const method of [
'fetchRequirements',
'fetchPlans',
'fetchTasks',
'fetchDevTasks',
'fetchTestCases',
'fetchBugs',
]) {
assert.doesNotMatch(
text,
new RegExp(`\\b${method}\\(\\s*\\)`),
`${path} must not call ${method}() without a relation scope`,
);
}
}
test('version detail runtime loads requirements and work data by relation scope', () => {
const text = source('app/versions/[id]/page.tsx');
assert.match(text, /fetchRequirements\(\{ productId: version\.productId, versionId \}\)/);
assert.match(text, /fetchRequirements\(\{ productId: version\.productId, versionId, force: true \}\)/);
assertNoBroadBusinessFetches('app/versions/[id]/page.tsx');
});
test('version list hydrates visible versions through scoped relation partitions', () => {
const text = source('app/versions/page.tsx');
assert.match(text, /fetchRequirements\(\{ productId: version\.productId, versionId: version\.id \}\)/);
assert.match(text, /fetchPlans\(\{ versionId: version\.id \}\)/);
assert.match(text, /fetchDevTasks\(\{ versionId: version\.id \}\)/);
assert.match(text, /fetchTestCases\(\{ versionId: version\.id \}\)/);
assertNoBroadBusinessFetches('app/versions/page.tsx');
});
test('workspace fallback and drawer hydration remain version scoped', () => {
const hook = source('hooks/useWorkspaceWorkItems.ts');
const page = source('app/workspace/page.tsx');
assert.match(hook, /hydrateWorkspaceStoreFallback/);
assert.match(page, /hydrateWorkspaceStoreFallback/);
assert.match(page, /fetchRequirements\(\{ productId: versionContext\.productId, versionId: item\.versionId \}\)/);
assertNoBroadBusinessFetches('hooks/useWorkspaceWorkItems.ts');
assertNoBroadBusinessFetches('app/workspace/page.tsx');
});
test('xiaobao fallback hydrates visible versions by relation scope only', () => {
const text = source('hooks/useXiaobaoWarningRisks.ts');
assert.match(text, /hydrateXiaobaoStoreFallback/);
assert.match(text, /fetchRequirements\(\{ productId: version\.productId, versionId: version\.id \}\)/);
assert.match(text, /fetchPlans\(\{ versionId: version\.id \}\)/);
assert.match(text, /fetchTasks\(\{ versionId: version\.id \}\)/);
assert.match(text, /fetchTestCases\(\{ versionId: version\.id \}\)/);
assert.match(text, /fetchBugs\(\{ versionId: version\.id \}\)/);
assertNoBroadBusinessFetches('hooks/useXiaobaoWarningRisks.ts');
});
test('overtime modal loads selected version requirements through relation scope', () => {
const text = source('app/overtime/page.tsx');
assert.match(text, /onVersionChange=\{\(version\) => fetchRequirements\(\{ productId: version\.productId, versionId: version\.id \}\)\}/);
assertNoBroadBusinessFetches('app/overtime/page.tsx');
});
test('requirement pool fallback hydrates the selected relation scope', () => {
const text = source('app/requirements/page.tsx');
assert.match(text, /hydrateRequirementStoreFallback/);
assert.match(text, /fetchRequirements\(\{ productId: selectedScope\.productId \}\)/);
assert.match(text, /fetchRequirements\(\{ productId: project\.productId, projectId: selectedScope\.projectId \}\)/);
assert.match(text, /fetchRequirements\(\{ productId: product\.id \}\)/);
assert.match(text, /fetchDevTasks\(\{ versionId: version\.id \}\)/);
assertNoBroadBusinessFetches('app/requirements/page.tsx');
});

View File

@@ -0,0 +1,66 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import test from 'node:test';
function source(path: string) {
return readFileSync(join(process.cwd(), path), 'utf8');
}
const migratedBusinessStores = [
['stores/useProductStore.ts', 'products-overview'],
['stores/useRequirementStore.ts', 'requirements'],
['stores/useVersionPlanStore.ts', 'version-plans'],
['stores/useDevTaskStore.ts', 'dev-tasks'],
['stores/useTestCaseStore.ts', 'test-cases'],
['stores/useBugStore.ts', 'bugs'],
['stores/useTaskCategoryStore.ts', 'task-categories'],
['stores/useTaskWorklogStore.ts', 'task-worklogs'],
['stores/useWorkActivityStore.ts', 'work-activities'],
] as const;
test('migrated business stores do not read or write AppData documents at runtime', () => {
for (const [file, key] of migratedBusinessStores) {
const text = source(file);
assert.doesNotMatch(text, new RegExp(`loadServerData<[^>]*>\\('${key}'`), `${file} must not read ${key}`);
assert.doesNotMatch(text, new RegExp(`loadServerData\\([^)]*'${key}'`), `${file} must not read ${key}`);
assert.doesNotMatch(text, new RegExp(`saveServerData\\('${key}'`), `${file} must not write ${key}`);
assert.doesNotMatch(text, /\.catch\(loadStored\)/, `${file} must not fall back to legacy AppData reads`);
}
});
test('only explicit legacy config stores keep AppData access', () => {
assert.match(source('stores/useMemberStore.ts'), /saveServerData\('members'/);
assert.match(source('stores/useMemberStore.ts'), /loadServerData<[^>]+>\('members'/);
assert.match(source('stores/useOvertimeStore.ts'), /loadServerData<[^>]+>\('overtime'/);
});
test('read-only Xiaobao AppData archive keys are not used by runtime stores', () => {
for (const file of ['stores/useXiaobaoRiskStore.ts', 'stores/useXiaobaoWarningReadStore.ts']) {
const text = source(file);
assert.doesNotMatch(text, /loadServerData<[^>]+>\('xiaobao-risk-snapshots'/, `${file} must not read Xiaobao snapshots AppData`);
assert.doesNotMatch(text, /loadServerData<[^>]+>\('xiaobao-risk-insights'/, `${file} must not read Xiaobao insights AppData`);
assert.doesNotMatch(text, /loadServerData<[^>]+>\('xiaobao-warning-views'/, `${file} must not read Xiaobao warning read-state AppData`);
assert.doesNotMatch(text, /saveServerData\('xiaobao-risk-snapshots'/, `${file} must not write Xiaobao snapshots AppData`);
assert.doesNotMatch(text, /saveServerData\('xiaobao-risk-insights'/, `${file} must not write Xiaobao insights AppData`);
assert.doesNotMatch(text, /saveServerData\('xiaobao-warning-views'/, `${file} must not write Xiaobao warning read-state AppData`);
}
});
test('auth reads member identities from the member domain API instead of members AppData', () => {
const text = source('stores/useAuthStore.ts');
assert.match(text, /listMembersDomain/);
assert.doesNotMatch(text, /loadServerData<[^>]+>\('members'/);
assert.doesNotMatch(text, /from '@\/lib\/server-data'/);
});
test('member store keeps members AppData as config only, not member identity fallback', () => {
const text = source('stores/useMemberStore.ts');
assert.doesNotMatch(text, /cached\.members/);
assert.doesNotMatch(text, /members: get\(\)\.members,/);
assert.match(text, /saveServerData\('members', \{ departments: state\.departments, members: \[\], roles: state\.roles, passwordRule: state\.passwordRule \}\)/);
});

View File

@@ -3,11 +3,10 @@ import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import test from 'node:test';
test('product store save helper does not swallow AppData save failures', () => {
test('product store no longer persists product tree through products-overview AppData', () => {
const source = readFileSync(join(process.cwd(), 'stores/useProductStore.ts'), 'utf8');
assert.match(
source,
/async function saveStoredOverview\(data: ProductOverview\[\]\) \{\s+await saveServerData\('products-overview', data\);\s+\}/,
);
assert.doesNotMatch(source, /loadServerData/);
assert.doesNotMatch(source, /saveServerData/);
assert.doesNotMatch(source, /products-overview/);
});

View File

@@ -5,6 +5,8 @@ import test from 'node:test';
const storeSource = () => readFileSync(join(process.cwd(), 'stores/useRequirementStore.ts'), 'utf8');
const pageSource = () => readFileSync(join(process.cwd(), 'app/requirements/page.tsx'), 'utf8');
const productDetailPage = () => readFileSync(join(process.cwd(), 'app/products/[id]/page.tsx'), 'utf8');
const projectDetailPage = () => readFileSync(join(process.cwd(), 'app/projects/[id]/page.tsx'), 'utf8');
function storeMethodBody(text: string, name: string) {
const start = text.indexOf(` ${name}:`);
@@ -52,11 +54,63 @@ test('requirement mutations use domain APIs instead of AppData requirements save
assert.doesNotMatch(deleteRequirement, /saveServerData\('requirements'/);
});
test('requirement dictionaries use governance dictionaries instead of AppData requirement saves', () => {
const text = storeSource();
const fetchRequirements = storeMethodBody(text, 'fetchRequirements');
const addType = storeMethodBody(text, 'addType');
const updateType = storeMethodBody(text, 'updateType');
const deleteType = storeMethodBody(text, 'deleteType');
const addSourceTarget = storeMethodBody(text, 'addSourceTarget');
const addPlatform = storeMethodBody(text, 'addPlatform');
assert.match(fetchRequirements, /loadRequirementGovernanceDictionaries/);
assert.match(addType, /createGovernanceDictionary\('requirement_type'/);
assert.match(updateType, /updateGovernanceDictionary\('requirement_type'/);
assert.match(deleteType, /deleteGovernanceDictionary\('requirement_type'/);
assert.match(addSourceTarget, /createGovernanceDictionary\('requirement_source'/);
assert.match(addPlatform, /createGovernanceDictionary\('requirement_platform'/);
for (const body of [addType, updateType, deleteType, addSourceTarget, addPlatform]) {
assert.doesNotMatch(body, /saveServerData\('requirements'/);
}
});
test('requirement pool no longer exposes local dictionary management drawers', () => {
const page = pageSource();
const modal = readFileSync(join(process.cwd(), 'components/requirement/RequirementModal.tsx'), 'utf8');
assert.doesNotMatch(page, /SourceDrawer|DictDrawer/);
assert.doesNotMatch(page, /setDrawerType/);
assert.doesNotMatch(modal, /onOpenDrawer/);
});
test('requirement pool uses server pagination without full AppData load for scoped list queries', () => {
const text = pageSource();
assert.match(text, /loadV22RequirementsPage\(v22RequirementQuery\)/);
assert.match(text, /if \(v22RequirementQuery && !v22RequirementsFailed\) return;\s+fetchRequirements\(\);/);
assert.match(text, /if \(v22RequirementQuery && !v22RequirementsFailed\) return;\s+void hydrateRequirementStoreFallback\(\);/);
assert.doesNotMatch(text, /fetchRequirements\(\);/);
assert.doesNotMatch(text, /fetchDevTasks\(\);/);
});
test('requirement store can hydrate scoped relation rows for product and project pages', () => {
const text = storeSource();
const fetchRequirements = storeMethodBody(text, 'fetchRequirements');
assert.match(text, /loadV22RequirementsPage/);
assert.match(fetchRequirements, /options\?\.productId/);
assert.match(fetchRequirements, /mergeRequirementsForScope/);
assert.doesNotMatch(fetchRequirements, /loadServerData<[^>]+>\('requirements'/);
});
test('product and project detail pages request scoped requirement relation rows', () => {
const productPage = productDetailPage();
const projectPage = projectDetailPage();
assert.match(productPage, /fetchRequirements\(\{ productId \}\)/);
assert.doesNotMatch(productPage, /fetchRequirements\(\);/);
assert.match(projectPage, /fetchRequirements\(\{ productId: project\.productId, projectId \}\)/);
assert.doesNotMatch(projectPage, /fetchRequirements\(\);/);
});
test('relation-backed requirement rows remain mutable without AppData identity', () => {

View File

@@ -5,6 +5,8 @@ import test from 'node:test';
const testCaseStore = () => readFileSync(join(process.cwd(), 'stores/useTestCaseStore.ts'), 'utf8');
const bugStore = () => readFileSync(join(process.cwd(), 'stores/useBugStore.ts'), 'utf8');
const testCaseTab = () => readFileSync(join(process.cwd(), 'components/test-case/TestCaseTab.tsx'), 'utf8');
const bugTab = () => readFileSync(join(process.cwd(), 'components/bug/BugTab.tsx'), 'utf8');
function storeMethodBody(text: string, name: string) {
const implementationStart = text.indexOf('export const');
@@ -30,8 +32,11 @@ function storeMethodBody(text: string, name: string) {
test('test case store uses domain APIs for version-scoped writes', () => {
const text = testCaseStore();
const fetchTestCases = storeMethodBody(text, 'fetchTestCases');
assert.match(text, /from '@\/lib\/domain-api'/);
assert.match(fetchTestCases, /listTestCasesByVersionId\(options\.versionId\)/);
assert.match(fetchTestCases, /!options\?\.versionId && !options\?\.force/);
assert.match(storeMethodBody(text, 'createTestCase'), /createTestCaseByVersionId\(tc\.versionId,/);
assert.match(storeMethodBody(text, 'createTestCases'), /createTestCasesByVersionId\(created\[0\]\.versionId,/);
assert.match(storeMethodBody(text, 'updateTestCase'), /updateTestCaseByVersionId\(versionId, id,/);
@@ -42,8 +47,11 @@ test('test case store uses domain APIs for version-scoped writes', () => {
test('bug store uses domain APIs for version-scoped writes', () => {
const text = bugStore();
const fetchBugs = storeMethodBody(text, 'fetchBugs');
assert.match(text, /from '@\/lib\/domain-api'/);
assert.match(fetchBugs, /listBugsByVersionId\(options\.versionId\)/);
assert.match(fetchBugs, /!options\?\.versionId && !options\?\.force/);
assert.match(storeMethodBody(text, 'createBug'), /createBugByVersionId\(bug\.versionId,/);
assert.match(storeMethodBody(text, 'updateBug'), /updateBugByVersionId\(versionId, id,/);
assert.match(storeMethodBody(text, 'changeStatus'), /updateBugStatusByVersionId\(bug\.versionId,/);
@@ -51,3 +59,32 @@ test('bug store uses domain APIs for version-scoped writes', () => {
assert.doesNotMatch(storeMethodBody(text, 'createBug'), /saveServerData\('bugs'/);
assert.doesNotMatch(storeMethodBody(text, 'updateBug'), /saveServerData\('bugs'/);
});
test('workspace test case and bug drawers load version-scoped relation data', () => {
const page = readFileSync(join(process.cwd(), 'app/workspace/page.tsx'), 'utf8');
assert.match(page, /fetchTestCases\(\{ versionId: item\.versionId \}\)/);
assert.match(page, /fetchBugs\(\{ versionId: item\.versionId \}\)/);
assert.doesNotMatch(page, /item\.type === 'testCase' && !testCasesLoaded/);
assert.doesNotMatch(page, /item\.type === 'bug' && !bugsLoaded/);
assert.doesNotMatch(page, /pendingLoads\.push\(fetchTestCases\(\)\);/);
assert.doesNotMatch(page, /pendingLoads\.push\(fetchBugs\(\)\);/);
});
test('test case tab fallback fetches current version relation partitions', () => {
const text = testCaseTab();
assert.match(text, /fetchTestCases\(\{ versionId \}\)/);
assert.match(text, /fetchBugs\(\{ versionId \}\)/);
assert.doesNotMatch(text, /if \(!scopedVersionCases\) fetchTestCases\(\);/);
assert.doesNotMatch(text, /if \(!scopedVersionBugs\) fetchBugs\(\);/);
});
test('bug tab fallback fetches current version relation partitions', () => {
const text = bugTab();
assert.match(text, /fetchBugs\(\{ versionId \}\)/);
assert.match(text, /fetchTestCases\(\{ versionId \}\)/);
assert.doesNotMatch(text, /if \(!scopedVersionBugs\) fetchBugs\(\);/);
assert.doesNotMatch(text, /if \(!versionTestCases\) fetchTestCases\(\);/);
});

View File

@@ -47,6 +47,9 @@ interface V22RequirementRow {
creatorId?: string | null;
creatorName?: string | null;
creator?: { id?: string | null; name?: string | null } | null;
productOwnerId?: string | null;
productOwnerName?: string | null;
productOwner?: { id?: string | null; name?: string | null } | null;
createdAt?: V22DateValue;
updatedAt?: V22DateValue;
}
@@ -63,6 +66,7 @@ interface V22VersionPlanRow {
actualStartAt?: V22DateValue;
completedAt?: V22DateValue;
resultUrl?: string | null;
tasks?: unknown;
requirementCoverage?: unknown;
logs?: unknown;
createdAt?: V22DateValue;
@@ -295,6 +299,7 @@ function mapRequirement(row: V22RequirementRow): Requirement {
status: toRequirementStatus(row.status),
priority: toPriority(row.priority),
effort: 'M',
productOwner: row.productOwner?.name ?? row.productOwnerName ?? row.productOwnerId ?? '',
creator: row.creator?.name ?? row.creatorName ?? row.creatorId ?? '',
createdAt: requiredIso(row.createdAt),
};
@@ -311,7 +316,7 @@ function mapVersionPlan(row: V22VersionPlanRow): VersionPlan {
startTime: requiredIso(row.expectedStartAt),
endTime: requiredIso(row.expectedEndAt),
status: toPlanStatus(row.status),
tasks: [],
tasks: asArray<PlanTask>(row.tasks),
completedRequirementIds: coverage
.filter((item) => item?.status === 'completed')
.map((item) => item.requirementId)

View File

@@ -36,6 +36,10 @@ test('member and dictionary stores use domain APIs as primary writes', () => {
assert.match(storeMethodBody(members, 'createMember'), /createMemberDomain\(member,/);
assert.match(storeMethodBody(members, 'updateMember'), /updateMemberDomain\(id,/);
assert.match(storeMethodBody(members, 'deleteMember'), /deleteMemberDomain\(id\)/);
assert.doesNotMatch(storeMethodBody(members, 'fetchMembers'), /saveStored\(/);
assert.doesNotMatch(storeMethodBody(members, 'createMember'), /saveStored\(/);
assert.doesNotMatch(storeMethodBody(members, 'updateMember'), /saveStored\(/);
assert.doesNotMatch(storeMethodBody(members, 'deleteMember'), /saveStored\(/);
assert.match(categories, /from '@\/lib\/domain-api'/);
assert.match(storeMethodBody(categories, 'addCategory'), /createTaskCategoryDomain\(item\)/);

View File

@@ -170,8 +170,8 @@ test('buildVersionDataScopeMap indexes many versions in one pass', () => {
assert.deepEqual(Object.keys(scopeMap).sort(), ['version-1', 'version-2', 'version-empty']);
});
test('selectVersionDataScope prefers non-empty V2.2 scope but keeps AppData overtime records', () => {
const appDataScope = buildVersionDataScope({
test('selectVersionDataScope prefers non-empty V2.2 scope but keeps store overtime records', () => {
const storeScope = buildVersionDataScope({
versionId: 'version-1',
requirements: [requirement('req-app', 'version-1')],
plans: [plan('plan-app', 'version-1', 'research')],
@@ -190,15 +190,61 @@ test('selectVersionDataScope prefers non-empty V2.2 scope but keeps AppData over
overtimeRecords: [],
});
const selected = selectVersionDataScope({ appDataScope, v22Scope });
const selected = selectVersionDataScope({ storeScope, v22Scope });
assert.equal(selected?.requirements[0].id, 'req-v22');
assert.equal(selected?.plans[0].id, 'plan-v22');
assert.deepEqual(selected?.overtimeRecords.map((item) => item.id), ['ot-app']);
});
test('selectVersionDataScope falls back to AppData when V2.2 scope is empty', () => {
const appDataScope = buildVersionDataScope({
test('selectVersionDataScope overlays local store changes onto V2.2 rows by id', () => {
const storeScope = buildVersionDataScope({
versionId: 'version-1',
requirements: [
{ ...requirement('req-v22', 'version-1'), title: '本地标题' },
{ ...requirement('req-local', 'version-1'), title: '本地新增' },
],
plans: [
{
...plan('plan-v22', 'version-1', 'product'),
status: 'in_progress',
requirementCoverage: [
{
requirementId: 'req-v22',
status: 'not_started',
currentWorkStartedAt: '2026-07-09T09:30:00.000Z',
updatedAt: '2026-07-09T09:30:00.000Z',
updatedBy: 'PM',
},
],
},
],
devTasks: [devTask('dev-v22', { versionId: 'version-1', status: 'in_progress' })],
testCases: [testCase('tc-v22', 'version-1')],
bugs: [bug('bug-v22', 'version-1')],
overtimeRecords: [],
});
const v22Scope = buildVersionDataScope({
versionId: 'version-1',
requirements: [requirement('req-v22', 'version-1')],
plans: [plan('plan-v22', 'version-1', 'product')],
devTasks: [devTask('dev-v22', { versionId: 'version-1', status: 'todo' })],
testCases: [testCase('tc-v22', 'version-1')],
bugs: [bug('bug-v22', 'version-1')],
overtimeRecords: [],
});
const selected = selectVersionDataScope({ storeScope, v22Scope });
assert.equal(selected?.requirements.find((item) => item.id === 'req-v22')?.title, '本地标题');
assert.equal(selected?.requirements.some((item) => item.id === 'req-local'), true);
assert.equal(selected?.plans[0].status, 'in_progress');
assert.equal(selected?.plans[0].requirementCoverage?.[0]?.currentWorkStartedAt, '2026-07-09T09:30:00.000Z');
assert.equal(selected?.devTasks[0].status, 'in_progress');
});
test('selectVersionDataScope falls back to store scope when V2.2 scope is empty', () => {
const storeScope = buildVersionDataScope({
versionId: 'version-1',
requirements: [requirement('req-app', 'version-1')],
plans: [],
@@ -215,7 +261,7 @@ test('selectVersionDataScope falls back to AppData when V2.2 scope is empty', ()
bugs: [],
});
const selected = selectVersionDataScope({ appDataScope, v22Scope: emptyV22Scope });
const selected = selectVersionDataScope({ storeScope, v22Scope: emptyV22Scope });
assert.equal(selected?.requirements[0].id, 'req-app');
});

View File

@@ -89,17 +89,27 @@ export function hasVersionDataScopeRows(scope: VersionDataScope | null | undefin
}
export function selectVersionDataScope(input: {
appDataScope: VersionDataScope | null;
storeScope: VersionDataScope | null;
v22Scope?: VersionDataScope | null;
}): VersionDataScope | null {
const { appDataScope, v22Scope } = input;
const { storeScope, v22Scope } = input;
if (hasVersionDataScopeRows(v22Scope)) {
const requirements = overlayRowsById(v22Scope.requirements, storeScope?.requirements ?? []);
const plans = overlayRowsById(v22Scope.plans, storeScope?.plans ?? []);
return {
...v22Scope,
overtimeRecords: appDataScope?.overtimeRecords ?? v22Scope.overtimeRecords,
requirements,
requirementIds: requirements.map((item) => item.id),
requirementIdSet: new Set(requirements.map((item) => item.id)),
plans,
plansByType: groupPlansByType(plans),
devTasks: overlayRowsById(v22Scope.devTasks, storeScope?.devTasks ?? []),
testCases: overlayRowsById(v22Scope.testCases, storeScope?.testCases ?? []),
bugs: overlayRowsById(v22Scope.bugs, storeScope?.bugs ?? []),
overtimeRecords: storeScope?.overtimeRecords ?? v22Scope.overtimeRecords,
};
}
return appDataScope;
return storeScope;
}
export function buildVersionDataScopeMap(input: VersionDataScopeMapInput): Record<string, VersionDataScope> {
@@ -167,3 +177,25 @@ function createEmptyScope(): VersionDataScope {
overtimeRecords: [],
};
}
function overlayRowsById<T extends { id: string }>(baseRows: T[], overlayRows: T[]): T[] {
if (baseRows.length === 0) return baseRows;
const overlayById = new Map(overlayRows.map((item) => [item.id, item]));
const seen = new Set<string>();
const merged = baseRows.map((item) => {
seen.add(item.id);
return overlayById.get(item.id) ?? item;
});
for (const item of overlayRows) {
if (!seen.has(item.id)) merged.push(item);
}
return merged;
}
function groupPlansByType(plans: VersionPlan[]): Record<VersionPlan['type'], VersionPlan[]> {
return {
research: plans.filter((plan) => plan.type === 'research'),
product: plans.filter((plan) => plan.type === 'product'),
ui: plans.filter((plan) => plan.type === 'ui'),
};
}

View File

@@ -8,6 +8,7 @@ export interface VersionMember {
export interface VersionMemberCandidate {
id: string;
name: string;
username?: string;
departmentName?: string;
}

View File

@@ -5,6 +5,11 @@ import test from 'node:test';
const planStore = () => readFileSync(join(process.cwd(), 'stores/useVersionPlanStore.ts'), 'utf8');
const taskStore = () => readFileSync(join(process.cwd(), 'stores/useDevTaskStore.ts'), 'utf8');
const versionDetailPage = () => readFileSync(join(process.cwd(), 'app/versions/[id]/page.tsx'), 'utf8');
const workspacePage = () => readFileSync(join(process.cwd(), 'app/workspace/page.tsx'), 'utf8');
const devTaskTab = () => readFileSync(join(process.cwd(), 'components/dev-task/DevTaskTab.tsx'), 'utf8');
const projectListPage = () => readFileSync(join(process.cwd(), 'app/projects/page.tsx'), 'utf8');
const projectDetailPage = () => readFileSync(join(process.cwd(), 'app/projects/[id]/page.tsx'), 'utf8');
function storeMethodBody(text: string, name: string) {
const implementationStart = text.indexOf('export const');
@@ -37,12 +42,52 @@ test('version plan store uses domain APIs for version-scoped writes', () => {
assert.match(storeMethodBody(text, 'completePlan'), /completeVersionPlanByVersionId\(versionId, id,/);
assert.doesNotMatch(storeMethodBody(text, 'createPlan'), /saveServerData\('version-plans'/);
assert.doesNotMatch(storeMethodBody(text, 'updatePlan'), /saveServerData\('version-plans'/);
assert.doesNotMatch(text, /saveServerData\('version-plans'/);
assert.doesNotMatch(text, /saveVersionPlansFallback/);
});
test('version plan domain API preserves research direction tasks', () => {
const text = readFileSync(join(process.cwd(), 'lib/domain-api.ts'), 'utf8');
assert.match(text, /data\.tasks !== undefined && \{ tasks: data\.tasks \}/);
assert.match(text, /tasks: asArray<PlanTask>\(row\.tasks\)/);
});
test('version plan reads for version detail use version-scoped relation data', () => {
const store = planStore();
const fetchPlans = storeMethodBody(store, 'fetchPlans');
const page = versionDetailPage();
assert.match(fetchPlans, /listVersionPlansByVersionId\(options\.versionId\)/);
assert.match(fetchPlans, /!options\?\.versionId && !options\?\.force/);
assert.match(fetchPlans, /mergeVersionPlansForVersion/);
assert.doesNotMatch(fetchPlans, /listVersionPlansByVersionId\(options\.versionId\)\.catch\(loadStored\)/);
assert.match(page, /fetchPlans\(\{ versionId \}\);/);
assert.match(page, /v22Scope,\s*\}\)/);
assert.doesNotMatch(page, /versionWriteStoresReady \? null : v22Scope/);
assert.match(page, /const displayRequirements = scopedVersionData\.requirements/);
assert.match(page, /const displayRequirementIds = scopedVersionData\.requirementIds/);
assert.match(page, /const displayPlans = scopedVersionData\.plans/);
assert.match(page, /const displayDevTasks = scopedVersionData\.devTasks/);
assert.match(page, /const displayTestCases = scopedVersionData\.testCases/);
assert.match(page, /const displayBugs = scopedVersionData\.bugs/);
});
test('workspace plan drawer loads version-scoped relation data', () => {
const page = workspacePage();
assert.match(page, /fetchPlans\(\{ versionId: item\.versionId \}\)/);
assert.doesNotMatch(page, /&& !plansLoaded\) \{\s*pendingLoads\.push\(fetchPlans\(\{ versionId: item\.versionId \}\)\);/);
assert.doesNotMatch(page, /pendingLoads\.push\(fetchPlans\(\)\);/);
});
test('dev task store uses domain APIs for version-scoped writes', () => {
const text = taskStore();
const fetchTasks = storeMethodBody(text, 'fetchTasks');
assert.match(text, /from '@\/lib\/domain-api'/);
assert.match(fetchTasks, /listDevTasksByVersionId\(options\.versionId\)/);
assert.match(fetchTasks, /!options\?\.versionId && !options\?\.force/);
assert.match(storeMethodBody(text, 'createTask'), /createDevTaskByVersionId\(task\.versionId,/);
assert.match(storeMethodBody(text, 'updateTask'), /updateDevTaskByVersionId\(versionId, id,/);
assert.match(storeMethodBody(text, 'changeStatus'), /updateDevTaskStatusByVersionId\(task\.versionId,/);
@@ -50,3 +95,39 @@ test('dev task store uses domain APIs for version-scoped writes', () => {
assert.doesNotMatch(storeMethodBody(text, 'createTask'), /saveServerData\('dev-tasks'/);
assert.doesNotMatch(storeMethodBody(text, 'updateTask'), /saveServerData\('dev-tasks'/);
});
test('workspace dev task drawer loads version-scoped relation data', () => {
const page = workspacePage();
assert.match(page, /fetchTasks\(\{ versionId: item\.versionId \}\)/);
assert.doesNotMatch(page, /item\.type === 'devTask' && !devTasksLoaded/);
assert.doesNotMatch(page, /pendingLoads\.push\(fetchTasks\(\)\);/);
});
test('dev task tab fallback fetches the current version relation partition', () => {
const text = devTaskTab();
assert.match(text, /fetchTasks\(\{ versionId \}\)/);
assert.doesNotMatch(text, /if \(!scopedVersionTasks\) fetchTasks\(\);/);
});
test('project pages hydrate version summaries through version-scoped relation partitions', () => {
const listPage = projectListPage();
const detailPage = projectDetailPage();
assert.match(listPage, /fetchPlans\(\{ versionId: version\.id \}\)/);
assert.match(listPage, /fetchDevTasks\(\{ versionId: version\.id \}\)/);
assert.match(listPage, /fetchTestCases\(\{ versionId: version\.id \}\)/);
assert.doesNotMatch(listPage, /fetchPlans\(\);/);
assert.doesNotMatch(listPage, /fetchDevTasks\(\);/);
assert.doesNotMatch(listPage, /fetchTestCases\(\);/);
assert.match(detailPage, /fetchPlans\(\{ versionId: version\.id \}\)/);
assert.match(detailPage, /fetchDevTasks\(\{ versionId: version\.id \}\)/);
assert.match(detailPage, /fetchTestCases\(\{ versionId: version\.id \}\)/);
assert.match(detailPage, /fetchBugs\(\{ versionId: version\.id \}\)/);
assert.doesNotMatch(detailPage, /fetchPlans\(\);/);
assert.doesNotMatch(detailPage, /fetchDevTasks\(\);/);
assert.doesNotMatch(detailPage, /fetchTestCases\(\);/);
assert.doesNotMatch(detailPage, /fetchBugs\(\);/);
});

View File

@@ -0,0 +1,50 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import test from 'node:test';
const rootFile = (path: string) => readFileSync(join(process.cwd(), path), 'utf8');
test('plan auto-start effect only persists the same pending plan once', () => {
const text = rootFile('components/version/PlanTab.tsx');
assert.match(text, /useRef/);
assert.match(text, /autoStartedPlanIds/);
assert.match(text, /!autoStartedPlanIds\.current\.has\(plan\.id\)/);
assert.match(text, /autoStartedPlanIds\.current\.add\(plan\.id\)/);
});
test('plan owner UI resolves stored member ids to display names and filters transfer choices by member reference', () => {
const text = rootFile('components/version/PlanTab.tsx');
assert.match(text, /resolveMemberDisplayName/);
assert.match(text, /isMemberReference/);
assert.match(text, /ownerLabel/);
assert.doesNotMatch(text, />\{plan\.owner\}<\/span>/);
assert.doesNotMatch(text, />\{plan\.owner\}<\/dd>/);
assert.doesNotMatch(text, /member\.name !== plan\.owner/);
});
test('version detail passes relation-backed member candidates to plan tabs for id display resolution', () => {
const text = rootFile('app/versions/[id]/page.tsx');
assert.match(text, /username: member\.username/);
assert.match(text, /currentUserReference=\{user\?\.username/);
assert.match(text, /allMembers=\{memberCandidates\}/);
});
test('plan create response merge preserves optimistic auto-start fields from the current local row', () => {
const text = rootFile('stores/useVersionPlanStore.ts');
assert.match(text, /mergeCreatedVersionPlan/);
assert.doesNotMatch(text, /\{\s*\.\.\.plan,\s*\.\.\.result\.item,\s*tasks: plan\.tasks\s*\}/);
});
test('requirement product owner is sent to the relation API instead of staying as a local-only field', () => {
const requirementStore = rootFile('stores/useRequirementStore.ts');
const domainApi = rootFile('lib/domain-api.ts');
assert.doesNotMatch(requirementStore, /delete domainPatch\.productOwner/);
assert.match(domainApi, /productOwnerId/);
assert.match(domainApi, /data\.productOwner/);
});

View File

@@ -36,6 +36,22 @@ test('sortPlansNewestFirst places newly created plans before older plans', () =>
assert.deepEqual(plans.map((item) => item.id), ['plan-100', 'manual-old', 'plan-300']);
});
test('calcTotalDuration follows overview actual-duration display for short plans', () => {
const calcTotalDuration = (versionPlan as any).calcTotalDuration as undefined | ((plans: VersionPlan[]) => string);
assert.equal(typeof calcTotalDuration, 'function');
assert.equal(calcTotalDuration!([
plan({
type: 'product',
startTime: '2026-06-22T09:00:00',
endTime: '2026-06-22T10:00:00',
status: 'completed',
actualStartAt: '2026-06-22T09:00:00',
completedAt: '2026-06-22T10:00:00',
}),
]), '1h0.04天)');
});
test('collects logs across plans with plan context and newest first', () => {
const getPlanLogsForPlans = (versionPlan as any).getPlanLogsForPlans as undefined | ((plans: VersionPlan[]) => Array<{
id: string;
@@ -210,6 +226,112 @@ test('starts requirement coverage work while preserving existing progress', () =
assert.equal(next.requirementCoverage?.[0]?.updatedBy, 'PM');
});
test('merges stale requirement coverage patches without dropping current sessions', () => {
const mergeVersionPlanPatch = (versionPlan as any).mergeVersionPlanPatch as undefined | ((
current: VersionPlan,
patch: Partial<VersionPlan>,
) => Partial<VersionPlan>);
assert.equal(typeof mergeVersionPlanPatch, 'function');
const current = plan({
requirementCoverage: [
{
requirementId: 'r1',
status: 'not_started',
currentWorkStartedAt: '2026-06-29T09:30:00.000Z',
updatedAt: '2026-06-29T09:30:00.000Z',
updatedBy: 'PM',
},
],
});
const merged = mergeVersionPlanPatch!(current, {
requirementCoverage: [
{
requirementId: 'r2',
status: 'not_started',
currentWorkStartedAt: '2026-06-29T10:00:00.000Z',
updatedAt: '2026-06-29T10:00:00.000Z',
updatedBy: 'PM',
},
],
});
assert.deepEqual(
merged.requirementCoverage?.map((item) => [item.requirementId, item.currentWorkStartedAt]),
[
['r2', '2026-06-29T10:00:00.000Z'],
['r1', '2026-06-29T09:30:00.000Z'],
],
);
});
test('merged requirement coverage patches preserve legacy completed ids outside the patch', () => {
const mergeVersionPlanPatch = (versionPlan as any).mergeVersionPlanPatch as undefined | ((
current: VersionPlan,
patch: Partial<VersionPlan>,
) => Partial<VersionPlan>);
assert.equal(typeof mergeVersionPlanPatch, 'function');
const merged = mergeVersionPlanPatch!(plan({
linkedRequirementIds: ['r1', 'r2'],
completedRequirementIds: ['r1'],
}), {
requirementCoverage: [
{
requirementId: 'r2',
status: 'not_started',
currentWorkStartedAt: '2026-06-29T10:00:00.000Z',
updatedAt: '2026-06-29T10:00:00.000Z',
updatedBy: 'PM',
},
],
});
assert.deepEqual(merged.completedRequirementIds?.sort(), ['r1']);
});
test('merges stale research direction patches without dropping current sessions', () => {
const mergeVersionPlanPatch = (versionPlan as any).mergeVersionPlanPatch as undefined | ((
current: VersionPlan,
patch: Partial<VersionPlan>,
) => Partial<VersionPlan>);
assert.equal(typeof mergeVersionPlanPatch, 'function');
const current = plan({
type: 'research',
tasks: [
{
id: 'task-1',
title: '竞品分析',
status: 'in_progress',
currentWorkStartedAt: '2026-06-29T09:30:00.000Z',
},
{ id: 'task-2', title: '用户访谈', status: 'pending' },
],
});
const merged = mergeVersionPlanPatch!(current, {
tasks: [
{ id: 'task-1', title: '竞品分析', status: 'pending' },
{
id: 'task-2',
title: '用户访谈',
status: 'in_progress',
currentWorkStartedAt: '2026-06-29T10:00:00.000Z',
},
],
});
assert.deepEqual(
merged.tasks?.map((item) => [item.id, item.status, item.currentWorkStartedAt]),
[
['task-1', 'in_progress', '2026-06-29T09:30:00.000Z'],
['task-2', 'in_progress', '2026-06-29T10:00:00.000Z'],
],
);
});
test('validates requirement coverage record drafts by started work session', () => {
const canSaveRequirementCoverageDraft = (versionPlan as any).canSaveRequirementCoverageDraft as undefined | ((
status: string,

View File

@@ -1,4 +1,5 @@
import type { AgentDecomposeTarget } from '@ftb/shared';
import { calcActualElapsedHours, formatActualDuration, type TimeInterval } from './work-hours';
export type PlanTaskStatus = 'pending' | 'in_progress' | 'completed';
export type ProductPlanKind = 'design' | 'review';
@@ -455,6 +456,125 @@ export function updateResearchDirectionProgress(
};
}
export function mergeVersionPlanPatch(current: VersionPlan, patch: Partial<VersionPlan>): Partial<VersionPlan> {
const nextPatch: Partial<VersionPlan> = { ...patch };
const changedRequirementIds = new Set(
(patch.logs ?? [])
.filter((log) => log.type === 'requirement_progress' && log.requirementId)
.map((log) => log.requirementId as string),
);
const changedTaskIds = new Set(
(patch.logs ?? [])
.filter((log) => log.type === 'research_direction_progress' && log.directionTaskId)
.map((log) => log.directionTaskId as string),
);
if (patch.requirementCoverage) {
nextPatch.requirementCoverage = mergeRequirementCoverage(
current.requirementCoverage ?? [],
patch.requirementCoverage,
changedRequirementIds,
);
nextPatch.completedRequirementIds = mergeCompletedRequirementIds(
current.completedRequirementIds ?? [],
nextPatch.requirementCoverage,
);
}
if (patch.tasks) {
nextPatch.tasks = mergePlanTasks(current.tasks ?? [], patch.tasks, changedTaskIds);
}
if (patch.logs) {
nextPatch.logs = mergePlanLogs(current.logs ?? [], patch.logs);
}
return nextPatch;
}
function mergeRequirementCoverage(
currentItems: VersionPlanRequirementCoverage[],
patchItems: VersionPlanRequirementCoverage[],
changedRequirementIds: Set<string>,
): VersionPlanRequirementCoverage[] {
const patchIds = new Set(patchItems.map((item) => item.requirementId));
return [
...patchItems.map((patchItem) => {
const currentItem = currentItems.find((item) => item.requirementId === patchItem.requirementId);
if (!currentItem || changedRequirementIds.has(patchItem.requirementId)) return patchItem;
if (isNewerThan(currentItem.updatedAt, patchItem.updatedAt)) return currentItem;
return {
...currentItem,
...patchItem,
currentWorkStartedAt: patchItem.currentWorkStartedAt ?? currentItem.currentWorkStartedAt,
};
}),
...currentItems.filter((item) => !patchIds.has(item.requirementId)),
];
}
function mergePlanTasks(
currentTasks: PlanTask[],
patchTasks: PlanTask[],
changedTaskIds: Set<string>,
): PlanTask[] {
const patchIds = new Set(patchTasks.map((item) => item.id));
return [
...patchTasks.map((patchTask) => {
const currentTask = currentTasks.find((item) => item.id === patchTask.id);
if (!currentTask || changedTaskIds.has(patchTask.id)) return patchTask;
if (isNewerThan(currentTask.updatedAt, patchTask.updatedAt)) return currentTask;
return {
...currentTask,
...patchTask,
status: maxPlanTaskStatus(currentTask.status, patchTask.status),
currentWorkStartedAt: patchTask.currentWorkStartedAt ?? currentTask.currentWorkStartedAt,
};
}),
...currentTasks.filter((item) => !patchIds.has(item.id)),
];
}
function mergePlanLogs(currentLogs: VersionPlanLog[], patchLogs: VersionPlanLog[]): VersionPlanLog[] {
const seen = new Set<string>();
const logs: VersionPlanLog[] = [];
for (const log of [...patchLogs, ...currentLogs]) {
if (seen.has(log.id)) continue;
seen.add(log.id);
logs.push(log);
}
return logs;
}
function mergeCompletedRequirementIds(
currentCompletedIds: string[],
coverage: VersionPlanRequirementCoverage[],
): string[] {
const completed = new Set(currentCompletedIds);
for (const item of coverage) {
if (item.status === 'completed') completed.add(item.requirementId);
else completed.delete(item.requirementId);
}
return Array.from(completed);
}
function isNewerThan(left?: string, right?: string): boolean {
if (!left || !right) return Boolean(left && !right);
const leftTime = new Date(left).getTime();
const rightTime = new Date(right).getTime();
if (!Number.isFinite(leftTime) || !Number.isFinite(rightTime)) return false;
return leftTime > rightTime;
}
function maxPlanTaskStatus(left: PlanTaskStatus, right: PlanTaskStatus): PlanTaskStatus {
const rank: Record<PlanTaskStatus, number> = {
pending: 0,
in_progress: 1,
completed: 2,
};
return rank[left] > rank[right] ? left : right;
}
function getPlanCreatedAtTime(plan: VersionPlan): number {
const time = new Date(plan.createdAt).getTime();
return Number.isFinite(time) ? time : 0;
@@ -505,43 +625,14 @@ export function formatDuration(days: number, hours: number): string {
}
export function calcTotalDuration(plans: VersionPlan[]): string {
const today = new Date().toISOString().slice(0, 10);
const activePlans = plans.filter((p) => p.status !== 'pending' || p.startTime <= today);
if (activePlans.length === 0) return '0天';
const intervals = activePlans.map((p) => {
const start = new Date(p.startTime).getTime();
const end = p.completedAt
? new Date(p.completedAt).getTime()
: p.startTime <= today
? new Date(today).getTime()
: new Date(p.startTime).getTime();
return { start, end };
}).filter((i) => i.end > i.start).sort((a, b) => a.start - b.start);
if (intervals.length === 0) return '0天';
let totalMs = 0;
let currentStart = intervals[0].start;
let currentEnd = intervals[0].end;
for (let i = 1; i < intervals.length; i++) {
if (intervals[i].start <= currentEnd) {
currentEnd = Math.max(currentEnd, intervals[i].end);
} else {
totalMs += currentEnd - currentStart;
currentStart = intervals[i].start;
currentEnd = intervals[i].end;
}
}
totalMs += currentEnd - currentStart;
const totalDays = Math.ceil(totalMs / (1000 * 60 * 60 * 24));
return `${totalDays}`;
const now = new Date().toISOString();
const totalHours = plans.reduce((sum, plan) => {
if (!plan.actualStartAt) return sum;
return sum + calcActualElapsedHours(plan.actualStartAt, plan.completedAt ?? now);
}, 0);
return formatActualDuration(Math.round(totalHours * 2) / 2);
}
import type { TimeInterval } from './work-hours';
/**
* 抽取每条已开始计划的 [actualStartAt, completedAt ?? now] 时间区间
* 用于双口径耗时统计calcTwoMetrics

View File

@@ -3,11 +3,11 @@ import test from 'node:test';
import { selectWorkspaceCollections } from './workspace-v22-source';
const appData = {
plans: [{ id: 'app-plan' }] as any[],
devTasks: [{ id: 'app-dev' }] as any[],
testCases: [{ id: 'app-test' }] as any[],
bugs: [{ id: 'app-bug' }] as any[],
const storeData = {
plans: [{ id: 'store-plan' }] as any[],
devTasks: [{ id: 'store-dev' }] as any[],
testCases: [{ id: 'store-test' }] as any[],
bugs: [{ id: 'store-bug' }] as any[],
};
test('selectWorkspaceCollections prefers successfully loaded V2.2 data even when it is empty', () => {
@@ -15,24 +15,63 @@ test('selectWorkspaceCollections prefers successfully loaded V2.2 data even when
v22Loaded: true,
v22Failed: false,
v22Data: { versionPlans: [], devTasks: [], testCases: [], bugs: [] },
appData,
storeData,
});
assert.deepEqual(selected, { versionPlans: [], devTasks: [], testCases: [], bugs: [] });
});
test('selectWorkspaceCollections falls back to AppData only when V2.2 is unavailable', () => {
test('selectWorkspaceCollections falls back to store data only when V2.2 is unavailable', () => {
const selected = selectWorkspaceCollections({
v22Loaded: false,
v22Failed: true,
v22Data: null,
appData,
storeData,
});
assert.deepEqual(selected, {
versionPlans: appData.plans,
devTasks: appData.devTasks,
testCases: appData.testCases,
bugs: appData.bugs,
versionPlans: storeData.plans,
devTasks: storeData.devTasks,
testCases: storeData.testCases,
bugs: storeData.bugs,
});
});
test('selectWorkspaceCollections overlays local store changes onto V2.2 rows by id', () => {
const selected = selectWorkspaceCollections({
v22Loaded: true,
v22Failed: false,
v22Data: {
versionPlans: [{ id: 'plan-1', status: 'pending' }] as any[],
devTasks: [{ id: 'dev-1', status: 'todo' }] as any[],
testCases: [{ id: 'tc-1', status: 'pending' }] as any[],
bugs: [{ id: 'bug-1', status: 'open' }] as any[],
},
storeData: {
plans: [
{
id: 'plan-1',
status: 'in_progress',
requirementCoverage: [
{
requirementId: 'req-1',
status: 'not_started',
currentWorkStartedAt: '2026-07-09T09:30:00.000Z',
updatedAt: '2026-07-09T09:30:00.000Z',
updatedBy: 'PM',
},
],
},
] as any[],
devTasks: [{ id: 'dev-1', status: 'in_progress' }] as any[],
testCases: [{ id: 'tc-1', status: 'running' }] as any[],
bugs: [{ id: 'bug-1', status: 'fixing' }] as any[],
},
});
assert.equal(selected.versionPlans[0].status, 'in_progress');
assert.equal(selected.versionPlans[0].requirementCoverage?.[0]?.currentWorkStartedAt, '2026-07-09T09:30:00.000Z');
assert.equal(selected.devTasks[0].status, 'in_progress');
assert.equal(selected.testCases[0].status, 'running');
assert.equal(selected.bugs[0].status, 'fixing');
});

View File

@@ -15,7 +15,7 @@ export interface SelectWorkspaceCollectionsInput {
v22Loaded: boolean;
v22Failed: boolean;
v22Data: V22WorkspaceData | null;
appData: {
storeData: {
plans: VersionPlan[];
devTasks: DevTask[];
testCases: TestCase[];
@@ -27,21 +27,46 @@ export function selectWorkspaceCollections({
v22Loaded,
v22Failed,
v22Data,
appData,
storeData,
}: SelectWorkspaceCollectionsInput): WorkspaceCollections {
if (v22Loaded && !v22Failed && v22Data) {
const hasV22Rows = v22Data.versionPlans.length > 0
|| v22Data.devTasks.length > 0
|| v22Data.testCases.length > 0
|| v22Data.bugs.length > 0;
if (!hasV22Rows) {
return {
versionPlans: v22Data.versionPlans,
devTasks: v22Data.devTasks,
testCases: v22Data.testCases,
bugs: v22Data.bugs,
};
}
return {
versionPlans: v22Data.versionPlans,
devTasks: v22Data.devTasks,
testCases: v22Data.testCases,
bugs: v22Data.bugs,
versionPlans: overlayRowsById(v22Data.versionPlans, storeData.plans),
devTasks: overlayRowsById(v22Data.devTasks, storeData.devTasks),
testCases: overlayRowsById(v22Data.testCases, storeData.testCases),
bugs: overlayRowsById(v22Data.bugs, storeData.bugs),
};
}
return {
versionPlans: appData.plans,
devTasks: appData.devTasks,
testCases: appData.testCases,
bugs: appData.bugs,
versionPlans: storeData.plans,
devTasks: storeData.devTasks,
testCases: storeData.testCases,
bugs: storeData.bugs,
};
}
function overlayRowsById<T extends { id: string }>(baseRows: T[], overlayRows: T[]): T[] {
const overlayById = new Map(overlayRows.map((item) => [item.id, item]));
const seen = new Set<string>();
const merged = baseRows.map((item) => {
seen.add(item.id);
return overlayById.get(item.id) ?? item;
});
for (const item of overlayRows) {
if (!seen.has(item.id)) merged.push(item);
}
return merged;
}

View File

@@ -8,7 +8,7 @@ import {
collectV22XiaobaoLatestInsights,
filterV22XiaobaoSummariesForVisibleVersions,
mergeV22XiaobaoLatestInsights,
shouldLoadXiaobaoAppDataFallback,
shouldLoadXiaobaoStoreFallback,
} from './xiaobao-v22-summary';
test('buildXiaobaoRisksFromV22Summaries preserves precomputed risk details with version context', () => {
@@ -144,12 +144,12 @@ test('buildXiaobaoRisksFromV22Summaries builds compatible defaults from sparse s
assert.equal(risks[0].trend.direction, 'up');
});
test('shouldLoadXiaobaoAppDataFallback waits for V2.2 summary before loading heavy AppData stores', () => {
assert.equal(shouldLoadXiaobaoAppDataFallback('idle'), false);
assert.equal(shouldLoadXiaobaoAppDataFallback('loading'), false);
assert.equal(shouldLoadXiaobaoAppDataFallback('ready'), false);
assert.equal(shouldLoadXiaobaoAppDataFallback('empty'), true);
assert.equal(shouldLoadXiaobaoAppDataFallback('failed'), true);
test('shouldLoadXiaobaoStoreFallback waits for V2.2 summary before loading relation-backed stores', () => {
assert.equal(shouldLoadXiaobaoStoreFallback('idle'), false);
assert.equal(shouldLoadXiaobaoStoreFallback('loading'), false);
assert.equal(shouldLoadXiaobaoStoreFallback('ready'), false);
assert.equal(shouldLoadXiaobaoStoreFallback('empty'), true);
assert.equal(shouldLoadXiaobaoStoreFallback('failed'), true);
});
test('filterV22XiaobaoSummariesForVisibleVersions keeps only visible version summaries', () => {

View File

@@ -25,7 +25,7 @@ const RISK_LEVELS = new Set<XiaobaoRiskLevel>(['on_track', 'attention', 'at_risk
const REASON_SEVERITIES = new Set<RiskReason['severity']>(['info', 'warning', 'danger']);
const SILENT_RISK_ITEM_TYPES = new Set<NonNullable<SilentRisk['itemType']>>(['dev_task', 'test_case', 'bug', 'version']);
export function shouldLoadXiaobaoAppDataFallback(state: V22XiaobaoSummaryLoadState): boolean {
export function shouldLoadXiaobaoStoreFallback(state: V22XiaobaoSummaryLoadState): boolean {
return state === 'empty' || state === 'failed';
}

View File

@@ -1,11 +1,11 @@
'use client';
import { create } from 'zustand';
import { loadServerData } from '@/lib/server-data';
import type { Member } from '@/lib/members';
import { ensureMemberUsernames } from '@/lib/members';
import { SYSTEM_ADMIN_MEMBER, ensureSystemAdminMember } from '@/lib/member-system';
import { findMemberByUsernameCredentials, resolveAuthUserFromMembers, toAuthUser, type AuthUser } from '@/lib/auth-user';
import { listMembersDomain } from '@/lib/domain-api';
import { useMemberStore } from './useMemberStore';
interface AuthState {
@@ -17,10 +17,6 @@ interface AuthState {
refreshUser: () => Promise<void>;
}
interface StoredMembersData {
members: Member[];
}
const SESSION_KEY = 'ftb_auth_session';
const PERSIST_KEY = 'ftb_auth_persist';
@@ -40,9 +36,9 @@ function clearAuthStorage() {
async function loadMembersForAuth(): Promise<Member[]> {
try {
const stored = await loadServerData<StoredMembersData>('members');
if (stored?.members?.length) {
const ensured = ensureSystemAdminMember(stored.members);
const domainMembers = await listMembersDomain();
if (domainMembers.length) {
const ensured = ensureSystemAdminMember(domainMembers);
return ensureMemberUsernames(ensured.members).members;
}
} catch {}

View File

@@ -12,10 +12,9 @@ import {
updateBugStatusByVersionId,
} from '@/lib/domain-api';
import { scheduleSaveWithOptimisticRollback } from '@/lib/optimistic-persistence';
import { loadServerData, saveServerData, SERVER_DATA_CACHE_MS } from '@/lib/server-data';
import { SERVER_DATA_CACHE_MS } from '@/lib/server-data';
import type { WorkActivity } from '@/lib/work-activity';
import {
makeBugCreatedActivity,
makeBugStatusActivity,
makeBugTransferredActivity,
} from '@/lib/work-activity-factory';
@@ -23,13 +22,6 @@ import { useWorkActivityStore } from './useWorkActivityStore';
let lastBugsFetchAt = 0;
async function loadStored(): Promise<Bug[] | null> {
try {
return await loadServerData<Bug[]>('bugs');
} catch {}
return null;
}
function makeLog(action: BugLog['action'], operator: string, from?: string, to?: string, remark?: string): BugLog {
return { id: `log-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`, action, fromValue: from, toValue: to, operator, remark, createdAt: new Date().toISOString() };
}
@@ -53,13 +45,18 @@ export const useBugStore = create<BugState>((set, get) => ({
loaded: false,
fetchBugs: async (options) => {
if (!options?.force && get().loaded && Date.now() - lastBugsFetchAt < SERVER_DATA_CACHE_MS) return;
if (!options?.versionId && !options?.force && get().loaded && Date.now() - lastBugsFetchAt < SERVER_DATA_CACHE_MS) return;
const cached = options?.versionId
? await listBugsByVersionId(options.versionId).catch(loadStored)
: await loadStored();
if (!options?.force && get().loaded && Date.now() - lastBugsFetchAt < SERVER_DATA_CACHE_MS) return;
? await listBugsByVersionId(options.versionId).catch(() => [])
: get().bugs;
if (!options?.versionId && !options?.force && get().loaded && Date.now() - lastBugsFetchAt < SERVER_DATA_CACHE_MS) return;
lastBugsFetchAt = Date.now();
set({ bugs: cached ?? [], loaded: true });
set({
bugs: options?.versionId
? mergeBugsForVersion(get().bugs, options.versionId, cached ?? [])
: cached ?? [],
loaded: true,
});
},
createBug: (data, operator) => {
@@ -79,17 +76,12 @@ export const useBugStore = create<BugState>((set, get) => ({
set({ bugs: updated, loaded: true });
scheduleSaveWithOptimisticRollback({
save: async () => {
try {
const result = await createBugByVersionId(bug.versionId, bug);
set({
bugs: get().bugs.map((item) => (item.id === bug.id ? { ...bug, ...result.item, logs: bug.logs } : item)),
loaded: true,
});
appendDomainActivities(result.activities);
} catch {
await saveBugsFallback(updated);
useWorkActivityStore.getState().addActivity(makeBugCreatedActivity(bug, operator));
}
const result = await createBugByVersionId(bug.versionId, bug);
set({
bugs: get().bugs.map((item) => (item.id === bug.id ? { ...bug, ...result.item, logs: bug.logs } : item)),
loaded: true,
});
appendDomainActivities(result.activities);
},
expected: updated,
getCurrent: () => get().bugs,
@@ -106,14 +98,10 @@ export const useBugStore = create<BugState>((set, get) => ({
set({ bugs: updated, loaded: true });
scheduleSaveWithOptimisticRollback({
save: async () => {
try {
const versionId = previous.find((bug) => bug.id === id)?.versionId;
if (!versionId) throw new Error('missing versionId');
const result = await updateBugByVersionId(versionId, id, data);
appendDomainActivities(result.activities);
} catch {
await saveBugsFallback(updated);
}
const versionId = previous.find((bug) => bug.id === id)?.versionId;
if (!versionId) throw new Error('missing versionId');
const result = await updateBugByVersionId(versionId, id, data);
appendDomainActivities(result.activities);
},
expected: updated,
getCurrent: () => get().bugs,
@@ -127,13 +115,9 @@ export const useBugStore = create<BugState>((set, get) => ({
set({ bugs: updated, loaded: true });
scheduleSaveWithOptimisticRollback({
save: async () => {
try {
const versionId = previous.find((bug) => bug.id === id)?.versionId;
if (!versionId) throw new Error('missing versionId');
await deleteBugByVersionId(versionId, id);
} catch {
await saveBugsFallback(updated);
}
const versionId = previous.find((bug) => bug.id === id)?.versionId;
if (!versionId) throw new Error('missing versionId');
await deleteBugByVersionId(versionId, id);
},
expected: updated,
getCurrent: () => get().bugs,
@@ -157,17 +141,12 @@ export const useBugStore = create<BugState>((set, get) => ({
const activity = makeBugStatusActivity(bug, bug.status, to, operator);
scheduleSaveWithOptimisticRollback({
save: async () => {
try {
const result = await updateBugStatusByVersionId(bug.versionId, id, to, {
operator,
resolution: extra?.resolution,
});
appendDomainActivities(result.activities);
if (result.activities.length === 0 && activity) useWorkActivityStore.getState().addActivity(activity);
} catch {
await saveBugsFallback(updated);
if (activity) useWorkActivityStore.getState().addActivity(activity);
}
const result = await updateBugStatusByVersionId(bug.versionId, id, to, {
operator,
resolution: extra?.resolution,
});
appendDomainActivities(result.activities);
if (result.activities.length === 0 && activity) useWorkActivityStore.getState().addActivity(activity);
},
expected: updated,
getCurrent: () => get().bugs,
@@ -189,14 +168,9 @@ export const useBugStore = create<BugState>((set, get) => ({
set({ bugs: updated, loaded: true });
scheduleSaveWithOptimisticRollback({
save: async () => {
try {
const result = await transferBugByVersionId(bug.versionId, id, newAssigneeId, operator);
appendDomainActivities(result.activities);
if (result.activities.length === 0) useWorkActivityStore.getState().addActivity(activity);
} catch {
await saveBugsFallback(updated);
useWorkActivityStore.getState().addActivity(activity);
}
const result = await transferBugByVersionId(bug.versionId, id, newAssigneeId, operator);
appendDomainActivities(result.activities);
if (result.activities.length === 0) useWorkActivityStore.getState().addActivity(activity);
},
expected: updated,
getCurrent: () => get().bugs,
@@ -218,8 +192,11 @@ export const useBugStore = create<BugState>((set, get) => ({
},
}));
async function saveBugsFallback(bugs: Bug[]) {
await saveServerData('bugs', bugs);
function mergeBugsForVersion(current: Bug[], versionId: string, scopedBugs: Bug[]) {
return [
...current.filter((bug) => bug.versionId !== versionId),
...scopedBugs,
];
}
function appendDomainActivities(activities: WorkActivity[]) {

View File

@@ -1,7 +1,7 @@
'use client';
import { create } from 'zustand';
import type { DevTask, DevTaskStatus } from '@/lib/dev-task';
import { generateTaskNo, isLegacyTask } from '@/lib/dev-task';
import { generateTaskNo } from '@/lib/dev-task';
import { applyDevTaskTransition, normalizeDevTaskOnCreate } from '@/lib/dev-task-workflow';
import {
createDevTaskByVersionId,
@@ -11,9 +11,9 @@ import {
updateDevTaskByVersionId,
updateDevTaskStatusByVersionId,
} from '@/lib/domain-api';
import { createEntityId, dedupeEntityIds } from '@/lib/entity-id';
import { createEntityId } from '@/lib/entity-id';
import { scheduleSaveWithOptimisticRollback } from '@/lib/optimistic-persistence';
import { loadServerData, saveServerData, SERVER_DATA_CACHE_MS } from '@/lib/server-data';
import { SERVER_DATA_CACHE_MS } from '@/lib/server-data';
import type { WorkActivity } from '@/lib/work-activity';
import {
makeDevTaskBlockedActivity,
@@ -23,27 +23,8 @@ import {
} from '@/lib/work-activity-factory';
import { useWorkActivityStore } from './useWorkActivityStore';
function saveStored(items: DevTask[]) {
saveServerData('dev-tasks', items).catch(() => {});
}
let lastTasksFetchAt = 0;
async function loadStored(): Promise<DevTask[] | null> {
try {
const parsed = await loadServerData<DevTask[]>('dev-tasks');
if (parsed) {
if (Array.isArray(parsed) && parsed.some(isLegacyTask)) {
return null;
}
const result = dedupeEntityIds(parsed, 'task');
if (result.changed) saveStored(result.items);
return result.items;
}
} catch {}
return null;
}
interface DevTaskState {
tasks: DevTask[];
loaded: boolean;
@@ -63,13 +44,18 @@ export const useDevTaskStore = create<DevTaskState>((set, get) => ({
loaded: false,
fetchTasks: async (options) => {
if (!options?.force && get().loaded && Date.now() - lastTasksFetchAt < SERVER_DATA_CACHE_MS) return;
if (!options?.versionId && !options?.force && get().loaded && Date.now() - lastTasksFetchAt < SERVER_DATA_CACHE_MS) return;
const cached = options?.versionId
? await listDevTasksByVersionId(options.versionId).catch(loadStored)
: await loadStored();
if (!options?.force && get().loaded && Date.now() - lastTasksFetchAt < SERVER_DATA_CACHE_MS) return;
? await listDevTasksByVersionId(options.versionId).catch(() => [])
: get().tasks;
if (!options?.versionId && !options?.force && get().loaded && Date.now() - lastTasksFetchAt < SERVER_DATA_CACHE_MS) return;
lastTasksFetchAt = Date.now();
set({ tasks: cached ?? [], loaded: true });
set({
tasks: options?.versionId
? mergeDevTasksForVersion(get().tasks, options.versionId, cached ?? [])
: cached ?? [],
loaded: true,
});
},
createTask: (data) => {
@@ -87,18 +73,13 @@ export const useDevTaskStore = create<DevTaskState>((set, get) => ({
set({ tasks: updated, loaded: true });
scheduleSaveWithOptimisticRollback({
save: async () => {
try {
if (!task.versionId) throw new Error('missing versionId');
const result = await createDevTaskByVersionId(task.versionId, task);
set({
tasks: get().tasks.map((item) => (item.id === task.id ? { ...task, ...result.item } : item)),
loaded: true,
});
appendDomainActivities(result.activities);
} catch {
await saveDevTasksFallback(updated);
useWorkActivityStore.getState().addActivity(makeDevTaskCreatedActivity(task, task.createdBy || task.assigneeId));
}
if (!task.versionId) throw new Error('missing versionId');
const result = await createDevTaskByVersionId(task.versionId, task);
set({
tasks: get().tasks.map((item) => (item.id === task.id ? { ...task, ...result.item } : item)),
loaded: true,
});
appendDomainActivities(result.activities);
},
expected: updated,
getCurrent: () => get().tasks,
@@ -115,14 +96,10 @@ export const useDevTaskStore = create<DevTaskState>((set, get) => ({
set({ tasks: updated, loaded: true });
scheduleSaveWithOptimisticRollback({
save: async () => {
try {
const versionId = previous.find((task) => task.id === id)?.versionId;
if (!versionId) throw new Error('missing versionId');
const result = await updateDevTaskByVersionId(versionId, id, data);
appendDomainActivities(result.activities);
} catch {
await saveDevTasksFallback(updated);
}
const versionId = previous.find((task) => task.id === id)?.versionId;
if (!versionId) throw new Error('missing versionId');
const result = await updateDevTaskByVersionId(versionId, id, data);
appendDomainActivities(result.activities);
},
expected: updated,
getCurrent: () => get().tasks,
@@ -136,13 +113,9 @@ export const useDevTaskStore = create<DevTaskState>((set, get) => ({
set({ tasks: updated, loaded: true });
scheduleSaveWithOptimisticRollback({
save: async () => {
try {
const versionId = previous.find((task) => task.id === id)?.versionId;
if (!versionId) throw new Error('missing versionId');
await deleteDevTaskByVersionId(versionId, id);
} catch {
await saveDevTasksFallback(updated);
}
const versionId = previous.find((task) => task.id === id)?.versionId;
if (!versionId) throw new Error('missing versionId');
await deleteDevTaskByVersionId(versionId, id);
},
expected: updated,
getCurrent: () => get().tasks,
@@ -166,15 +139,10 @@ export const useDevTaskStore = create<DevTaskState>((set, get) => ({
const activity = makeDevTaskStatusActivity(task, task.status, to, task.assigneeId);
scheduleSaveWithOptimisticRollback({
save: async () => {
try {
if (!task.versionId) throw new Error('missing versionId');
const result = await updateDevTaskStatusByVersionId(task.versionId, id, to);
appendDomainActivities(result.activities);
if (result.activities.length === 0 && activity) useWorkActivityStore.getState().addActivity(activity);
} catch {
await saveDevTasksFallback(updated);
if (activity) useWorkActivityStore.getState().addActivity(activity);
}
if (!task.versionId) throw new Error('missing versionId');
const result = await updateDevTaskStatusByVersionId(task.versionId, id, to);
appendDomainActivities(result.activities);
if (result.activities.length === 0 && activity) useWorkActivityStore.getState().addActivity(activity);
},
expected: updated,
getCurrent: () => get().tasks,
@@ -201,15 +169,10 @@ export const useDevTaskStore = create<DevTaskState>((set, get) => ({
: makeDevTaskUnblockedActivity(task, task.assigneeId);
scheduleSaveWithOptimisticRollback({
save: async () => {
try {
if (!task.versionId) throw new Error('missing versionId');
const result = await setDevTaskBlockedByVersionId(task.versionId, id, blocked, reason);
appendDomainActivities(result.activities);
if (result.activities.length === 0) useWorkActivityStore.getState().addActivity(activity);
} catch {
await saveDevTasksFallback(updated);
useWorkActivityStore.getState().addActivity(activity);
}
if (!task.versionId) throw new Error('missing versionId');
const result = await setDevTaskBlockedByVersionId(task.versionId, id, blocked, reason);
appendDomainActivities(result.activities);
if (result.activities.length === 0) useWorkActivityStore.getState().addActivity(activity);
},
expected: updated,
getCurrent: () => get().tasks,
@@ -231,8 +194,11 @@ export const useDevTaskStore = create<DevTaskState>((set, get) => ({
},
}));
async function saveDevTasksFallback(tasks: DevTask[]) {
await saveServerData('dev-tasks', tasks);
function mergeDevTasksForVersion(current: DevTask[], versionId: string, scopedTasks: DevTask[]) {
return [
...current.filter((task) => task.versionId !== versionId),
...scopedTasks,
];
}
function appendDomainActivities(activities: WorkActivity[]) {

View File

@@ -18,7 +18,7 @@ import {
import { scheduleSaveWithOptimisticRollback } from '@/lib/optimistic-persistence';
import { DEFAULT_ROLE_PERMISSIONS, DEFAULT_ROLE_PRESETS } from '@/lib/permissions';
import { mergePresetRolePermissions } from '@/lib/role-permission-migration';
import { loadServerData, saveServerData, SERVER_DATA_CACHE_MS } from '@/lib/server-data';
import { loadServerData, saveServerData, SERVER_DATA_CACHE_MS, SERVER_DATA_SAVE_ERROR_EVENT } from '@/lib/server-data';
const PRESET_DEPARTMENTS: Department[] = [
{ id: 'dept-1', name: '产品部', order: 1, createdAt: '2024-01-01' },
@@ -37,6 +37,19 @@ const MOCK_MEMBERS: Member[] = [
];
let lastMembersFetchAt = 0;
type StoredMemberConfig = {
departments?: Department[];
members?: Member[];
roles?: RoleItem[];
passwordRule?: PasswordRule;
};
type MemberConfigState = {
departments: Department[];
roles: RoleItem[];
passwordRule: PasswordRule;
};
interface MemberState {
departments: Department[];
members: Member[];
@@ -57,13 +70,13 @@ interface MemberState {
deleteRole: (id: string) => void;
}
async function saveStored(state: { departments: Department[]; members: Member[]; roles: RoleItem[]; passwordRule: PasswordRule }) {
await saveServerData('members', state);
async function saveStored(state: MemberConfigState) {
await saveServerData('members', { departments: state.departments, members: [], roles: state.roles, passwordRule: state.passwordRule });
}
async function loadStored(): Promise<{ departments: Department[]; members: Member[]; roles: RoleItem[]; passwordRule: PasswordRule } | null> {
async function loadStored(): Promise<StoredMemberConfig | null> {
try {
return await loadServerData<{ departments: Department[]; members: Member[]; roles: RoleItem[]; passwordRule: PasswordRule }>('members');
return await loadServerData<StoredMemberConfig>('members');
} catch {}
return null;
}
@@ -86,7 +99,8 @@ export const useMemberStore = create<MemberState>((set, get) => ({
lastMembersFetchAt = Date.now();
if (cached) {
// 迁移:旧 RoleItem 没 permissions 字段,补默认值
const migratedRoles = cached.roles.map((r) => {
const configuredRoles = Array.isArray(cached.roles) ? cached.roles : PRESET_ROLES;
const migratedRoles = configuredRoles.map((r) => {
if (!('permissions' in r) || !Array.isArray((r as any).permissions)) {
return { ...r, permissions: DEFAULT_ROLE_PERMISSIONS[r.id] ?? [] };
}
@@ -98,23 +112,21 @@ export const useMemberStore = create<MemberState>((set, get) => ({
const roles = rolesWithNewDefaults.some((r) => r.id === SYSTEM_ADMIN_ROLE_ID)
? rolesWithNewDefaults.map((r) => r.id === SYSTEM_ADMIN_ROLE_ID ? { ...adminRole, ...r, isSystem: true, permissions: DEFAULT_ROLE_PERMISSIONS[SYSTEM_ADMIN_ROLE_ID] } : r)
: [adminRole, ...rolesWithNewDefaults];
const rolesChanged =
permissionMerged.changed ||
roles.length !== rolesWithNewDefaults.length ||
roles.some((role, index) => JSON.stringify(role) !== JSON.stringify(rolesWithNewDefaults[index]));
const sourceMembers = domainMembers && domainMembers.length > 0 ? domainMembers : cached.members;
const sourceMembers = domainMembers && domainMembers.length > 0 ? domainMembers : get().members;
const ensured = ensureSystemAdminMember(sourceMembers);
const usernameEnsured = ensureMemberUsernames(ensured.members);
const next = {
departments: cached.departments,
departments: Array.isArray(cached.departments) ? cached.departments : PRESET_DEPARTMENTS,
members: usernameEnsured.members,
roles,
passwordRule: cached.passwordRule || DEFAULT_PASSWORD_RULE,
};
set({ ...next, loaded: true });
if (ensured.changed || usernameEnsured.changed || rolesChanged) void saveStored(next).catch(() => {});
} else {
set({ members: domainMembers && domainMembers.length > 0 ? domainMembers : get().members, loaded: true });
const sourceMembers = domainMembers && domainMembers.length > 0 ? domainMembers : get().members;
const ensured = ensureSystemAdminMember(sourceMembers);
const usernameEnsured = ensureMemberUsernames(ensured.members);
set({ members: usernameEnsured.members, loaded: true });
}
},
@@ -122,7 +134,7 @@ export const useMemberStore = create<MemberState>((set, get) => ({
const previous = get().passwordRule;
set({ passwordRule: rule });
scheduleSaveWithOptimisticRollback({
save: () => saveStored({ departments: get().departments, members: get().members, roles: get().roles, passwordRule: rule }),
save: () => saveStored({ departments: get().departments, roles: get().roles, passwordRule: rule }),
expected: rule,
getCurrent: () => get().passwordRule,
rollback: () => set({ passwordRule: previous }),
@@ -135,7 +147,7 @@ export const useMemberStore = create<MemberState>((set, get) => ({
const departments = [...previous, dept];
set({ departments });
scheduleSaveWithOptimisticRollback({
save: () => saveStored({ departments, members: get().members, roles: get().roles, passwordRule: get().passwordRule }),
save: () => saveStored({ departments, roles: get().roles, passwordRule: get().passwordRule }),
expected: departments,
getCurrent: () => get().departments,
rollback: () => set({ departments: previous }),
@@ -146,7 +158,7 @@ export const useMemberStore = create<MemberState>((set, get) => ({
const departments = previous.map((d) => d.id === id ? { ...d, ...data } : d);
set({ departments });
scheduleSaveWithOptimisticRollback({
save: () => saveStored({ departments, members: get().members, roles: get().roles, passwordRule: get().passwordRule }),
save: () => saveStored({ departments, roles: get().roles, passwordRule: get().passwordRule }),
expected: departments,
getCurrent: () => get().departments,
rollback: () => set({ departments: previous }),
@@ -157,7 +169,7 @@ export const useMemberStore = create<MemberState>((set, get) => ({
const departments = previous.filter((d) => d.id !== id && d.parentId !== id);
set({ departments });
scheduleSaveWithOptimisticRollback({
save: () => saveStored({ departments, members: get().members, roles: get().roles, passwordRule: get().passwordRule }),
save: () => saveStored({ departments, roles: get().roles, passwordRule: get().passwordRule }),
expected: departments,
getCurrent: () => get().departments,
rollback: () => set({ departments: previous }),
@@ -182,8 +194,9 @@ export const useMemberStore = create<MemberState>((set, get) => ({
set({
members: get().members.map((item) => (item.id === member.id ? { ...member, ...created } : item)),
});
} catch {
await saveStored({ departments: get().departments, members, roles: get().roles, passwordRule: get().passwordRule });
} catch (error) {
notifyMemberSaveFailure(error);
throw error;
}
},
expected: members,
@@ -200,12 +213,9 @@ export const useMemberStore = create<MemberState>((set, get) => ({
try {
await updateMemberDomain(id, safeData);
} catch (error) {
try {
await saveStored({ departments: get().departments, members: ensured.members, roles: get().roles, passwordRule: get().passwordRule });
} catch {
set({ members: previousMembers });
throw error;
}
set({ members: previousMembers });
notifyMemberSaveFailure(error);
throw error;
}
},
deleteMember: (id) => {
@@ -217,8 +227,9 @@ export const useMemberStore = create<MemberState>((set, get) => ({
save: async () => {
try {
await deleteMemberDomain(id);
} catch {
await saveStored({ departments: get().departments, members, roles: get().roles, passwordRule: get().passwordRule });
} catch (error) {
notifyMemberSaveFailure(error);
throw error;
}
},
expected: members,
@@ -238,7 +249,7 @@ export const useMemberStore = create<MemberState>((set, get) => ({
const roles = [...previous, role];
set({ roles });
scheduleSaveWithOptimisticRollback({
save: () => saveStored({ departments: get().departments, members: get().members, roles, passwordRule: get().passwordRule }),
save: () => saveStored({ departments: get().departments, roles, passwordRule: get().passwordRule }),
expected: roles,
getCurrent: () => get().roles,
rollback: () => set({ roles: previous }),
@@ -257,7 +268,7 @@ export const useMemberStore = create<MemberState>((set, get) => ({
});
set({ roles });
scheduleSaveWithOptimisticRollback({
save: () => saveStored({ departments: get().departments, members: get().members, roles, passwordRule: get().passwordRule }),
save: () => saveStored({ departments: get().departments, roles, passwordRule: get().passwordRule }),
expected: roles,
getCurrent: () => get().roles,
rollback: () => set({ roles: previous }),
@@ -269,10 +280,22 @@ export const useMemberStore = create<MemberState>((set, get) => ({
const roles = previous.filter((r) => r.id !== id);
set({ roles });
scheduleSaveWithOptimisticRollback({
save: () => saveStored({ departments: get().departments, members: get().members, roles, passwordRule: get().passwordRule }),
save: () => saveStored({ departments: get().departments, roles, passwordRule: get().passwordRule }),
expected: roles,
getCurrent: () => get().roles,
rollback: () => set({ roles: previous }),
});
},
}));
function notifyMemberSaveFailure(error: unknown) {
if (typeof window === 'undefined') return;
window.dispatchEvent(
new CustomEvent(SERVER_DATA_SAVE_ERROR_EVENT, {
detail: {
key: 'members',
message: error instanceof Error && error.message ? error.message : '成员/账号保存失败,请检查 API 服务',
},
}),
);
}

View File

@@ -43,9 +43,10 @@ interface OvertimeState {
deleteReason: (id: string) => void;
}
async function loadStored(): Promise<{ records: OvertimeRecord[]; reasons: DictItem[] } | null> {
async function loadStoredReasons(): Promise<DictItem[] | null> {
try {
return await loadServerData<{ records: OvertimeRecord[]; reasons: DictItem[] }>('overtime');
const stored = await loadServerData<{ records?: OvertimeRecord[]; reasons?: DictItem[] }>('overtime');
return Array.isArray(stored?.reasons) ? stored.reasons : null;
} catch {}
return null;
}
@@ -58,15 +59,15 @@ export const useOvertimeStore = create<OvertimeState>((set, get) => ({
fetchRecords: async (options) => {
if (!options?.force && get().loaded && Date.now() - lastOvertimeFetchAt < SERVER_DATA_CACHE_MS) return;
const [cached, domainRecords] = await Promise.all([
loadStored(),
const [storedReasons, domainRecords] = await Promise.all([
loadStoredReasons(),
listOvertimeRecordsDomain().catch(() => null),
]);
if (!options?.force && get().loaded && Date.now() - lastOvertimeFetchAt < SERVER_DATA_CACHE_MS) return;
lastOvertimeFetchAt = Date.now();
set({
records: domainRecords && domainRecords.length > 0 ? domainRecords : cached?.records ?? MOCK_RECORDS,
reasons: cached?.reasons ?? PRESET_REASONS,
records: domainRecords ?? MOCK_RECORDS,
reasons: storedReasons ?? PRESET_REASONS,
loaded: true,
});
},
@@ -84,15 +85,11 @@ export const useOvertimeStore = create<OvertimeState>((set, get) => ({
set({ records, loaded: true });
scheduleSaveWithOptimisticRollback({
save: async () => {
try {
const created = await createOvertimeRecordDomain(record);
set({
records: get().records.map((item) => (item.id === record.id ? { ...record, ...created } : item)),
loaded: true,
});
} catch {
await saveOvertimeFallback(records, get().reasons);
}
const created = await createOvertimeRecordDomain(record);
set({
records: get().records.map((item) => (item.id === record.id ? { ...record, ...created } : item)),
loaded: true,
});
},
expected: records,
getCurrent: () => get().records,
@@ -113,11 +110,7 @@ export const useOvertimeStore = create<OvertimeState>((set, get) => ({
set({ records, loaded: true });
scheduleSaveWithOptimisticRollback({
save: async () => {
try {
await updateOvertimeRecordDomain(id, records.find((record) => record.id === id) ?? {});
} catch {
await saveOvertimeFallback(records, get().reasons);
}
await updateOvertimeRecordDomain(id, records.find((record) => record.id === id) ?? {});
},
expected: records,
getCurrent: () => get().records,
@@ -131,11 +124,7 @@ export const useOvertimeStore = create<OvertimeState>((set, get) => ({
set({ records, loaded: true });
scheduleSaveWithOptimisticRollback({
save: async () => {
try {
await deleteOvertimeRecordDomain(id);
} catch {
await saveOvertimeFallback(records, get().reasons);
}
await deleteOvertimeRecordDomain(id);
},
expected: records,
getCurrent: () => get().records,
@@ -149,7 +138,7 @@ export const useOvertimeStore = create<OvertimeState>((set, get) => ({
const reasons = [...previous, item];
set({ reasons, loaded: true });
scheduleSaveWithOptimisticRollback({
save: () => saveServerData('overtime', { records: get().records, reasons }),
save: () => saveOvertimeReasons(reasons),
expected: reasons,
getCurrent: () => get().reasons,
rollback: () => set({ reasons: previous, loaded: true }),
@@ -161,7 +150,7 @@ export const useOvertimeStore = create<OvertimeState>((set, get) => ({
const reasons = previous.map((r) => r.id === id ? { ...r, name } : r);
set({ reasons, loaded: true });
scheduleSaveWithOptimisticRollback({
save: () => saveServerData('overtime', { records: get().records, reasons }),
save: () => saveOvertimeReasons(reasons),
expected: reasons,
getCurrent: () => get().reasons,
rollback: () => set({ reasons: previous, loaded: true }),
@@ -173,7 +162,7 @@ export const useOvertimeStore = create<OvertimeState>((set, get) => ({
const reasons = previous.filter((r) => r.id !== id);
set({ reasons, loaded: true });
scheduleSaveWithOptimisticRollback({
save: () => saveServerData('overtime', { records: get().records, reasons }),
save: () => saveOvertimeReasons(reasons),
expected: reasons,
getCurrent: () => get().reasons,
rollback: () => set({ reasons: previous, loaded: true }),
@@ -181,6 +170,6 @@ export const useOvertimeStore = create<OvertimeState>((set, get) => ({
},
}));
async function saveOvertimeFallback(records: OvertimeRecord[], reasons: DictItem[]) {
await saveServerData('overtime', { records, reasons });
async function saveOvertimeReasons(reasons: DictItem[]) {
await saveServerData('overtime', { records: [], reasons });
}

View File

@@ -15,7 +15,7 @@ import {
updateVersionByProductId,
} from '@/lib/domain-api';
import { saveWithOptimisticRollback } from '@/lib/optimistic-persistence';
import { loadServerData, saveServerData, SERVER_DATA_CACHE_MS } from '@/lib/server-data';
import { SERVER_DATA_CACHE_MS } from '@/lib/server-data';
import type { Stage, Role } from '@/lib/stage';
import type { Priority, VersionLinks } from '@/lib/derive';
@@ -99,11 +99,7 @@ export const useProductStore = create<ProductState>((set, get) => ({
const products = await api.get<ProductWithCount[]>('/products');
set({ products, loading: false });
} catch {
const overview = await loadStoredOverview();
const products = overview
? overview.map(({ projects: _projects, versions: _versions, ...product }) => product)
: [];
set({ products, error: null, loading: false });
set({ products: [], error: '产品加载失败', loading: false });
}
},
@@ -115,9 +111,8 @@ export const useProductStore = create<ProductState>((set, get) => ({
const overview = await api.get<ProductOverview[]>('/products/overview');
set({ overview, loading: false, overviewLoaded: true });
} catch {
const cached = await loadStoredOverview();
if (get().overviewLoaded && Date.now() - lastOverviewFetchAt < SERVER_DATA_CACHE_MS) return;
set({ overview: cached ?? MOCK_OVERVIEW, error: null, loading: false, overviewLoaded: true });
set({ overview: get().overviewLoaded ? get().overview : MOCK_OVERVIEW, error: '产品概览加载失败', loading: false, overviewLoaded: true });
}
},
@@ -127,8 +122,7 @@ export const useProductStore = create<ProductState>((set, get) => ({
const product = await api.get<Product>(`/products/${id}`);
set({ currentProduct: product, loading: false });
} catch {
const overview = await loadStoredOverview();
const found = overview?.find((p) => p.id === id) ?? get().overview.find((p) => p.id === id);
const found = get().overview.find((p) => p.id === id);
if (found) {
set({ currentProduct: found as any, loading: false });
} else {
@@ -153,16 +147,12 @@ export const useProductStore = create<ProductState>((set, get) => ({
set({ overview: updated, overviewLoaded: true });
await saveWithOptimisticRollback({
save: async () => {
try {
const created = await createProductRoot(data);
set({
overview: get().overview.map((product) => (product.id === newProduct.id ? created as ProductOverview : product)),
products: [created, ...get().products] as ProductWithCount[],
overviewLoaded: true,
});
} catch {
await saveRootOverviewFallback(updated);
}
const created = await createProductRoot(data);
set({
overview: get().overview.map((product) => (product.id === newProduct.id ? created as ProductOverview : product)),
products: [created, ...get().products] as ProductWithCount[],
overviewLoaded: true,
});
},
expected: updated,
getCurrent: () => get().overview,
@@ -184,11 +174,7 @@ export const useProductStore = create<ProductState>((set, get) => ({
});
await saveWithOptimisticRollback({
save: async () => {
try {
await updateProductRoot(id, data);
} catch {
await saveRootOverviewFallback(updated);
}
await updateProductRoot(id, data);
},
expected: updated,
getCurrent: () => get().overview,
@@ -213,11 +199,7 @@ export const useProductStore = create<ProductState>((set, get) => ({
});
await saveWithOptimisticRollback({
save: async () => {
try {
await deleteProductRoot(id);
} catch {
await saveRootOverviewFallback(updated);
}
await deleteProductRoot(id);
},
expected: updated,
getCurrent: () => get().overview,
@@ -236,7 +218,9 @@ export const useProductStore = create<ProductState>((set, get) => ({
const reordered = ids.map((id) => map.get(id)).filter(Boolean) as ProductOverview[];
set({ overview: reordered, overviewLoaded: true });
await saveWithOptimisticRollback({
save: () => saveStoredOverview(reordered),
save: async () => {
throw new Error('Product reorder domain API is not available');
},
expected: reordered,
getCurrent: () => get().overview,
rollback: () => set({ overview: previousOverview, overviewLoaded: true }),
@@ -265,7 +249,9 @@ export const useProductStore = create<ProductState>((set, get) => ({
.map((p) => (p.id === targetId ? migratedTarget : p));
set({ overview: updated, overviewLoaded: true });
await saveWithOptimisticRollback({
save: () => saveStoredOverview(updated),
save: async () => {
throw new Error('Product merge domain API is not available');
},
expected: updated,
getCurrent: () => get().overview,
rollback: () => set({ overview: previousOverview, overviewLoaded: true }),
@@ -291,22 +277,18 @@ export const useProductStore = create<ProductState>((set, get) => ({
set({ overview: updated, overviewLoaded: true });
await saveWithOptimisticRollback({
save: async () => {
try {
const created = await createProjectByProductId(productId, data);
set({
overview: get().overview.map((p) => {
if (p.id !== productId) return p;
return {
...p,
projects: p.projects.map((project) => (project.id === newProject.id ? created : project)),
_count: { ...p._count, projects: p.projects.length } as any,
};
}),
overviewLoaded: true,
});
} catch {
await saveRootOverviewFallback(updated);
}
const created = await createProjectByProductId(productId, data);
set({
overview: get().overview.map((p) => {
if (p.id !== productId) return p;
return {
...p,
projects: p.projects.map((project) => (project.id === newProject.id ? created : project)),
_count: { ...p._count, projects: p.projects.length } as any,
};
}),
overviewLoaded: true,
});
},
expected: updated,
getCurrent: () => get().overview,
@@ -351,11 +333,7 @@ export const useProductStore = create<ProductState>((set, get) => ({
set({ overview: updated, overviewLoaded: true });
await saveWithOptimisticRollback({
save: async () => {
try {
await updateProjectByProductId(productId, projectId, data);
} catch {
await saveRootOverviewFallback(updated);
}
await updateProjectByProductId(productId, projectId, data);
},
expected: updated,
getCurrent: () => get().overview,
@@ -384,22 +362,18 @@ export const useProductStore = create<ProductState>((set, get) => ({
set({ overview: updated, overviewLoaded: true });
await saveWithOptimisticRollback({
save: async () => {
try {
const created = await createVersionByProductId(productId, data);
set({
overview: get().overview.map((p) => {
if (p.id !== productId) return p;
return {
...p,
versions: p.versions.map((version) => (version.id === newVersion.id ? created as VersionItem : version)),
_count: { ...p._count, versions: p.versions.length } as any,
};
}),
overviewLoaded: true,
});
} catch {
await saveRootOverviewFallback(updated);
}
const created = await createVersionByProductId(productId, data);
set({
overview: get().overview.map((p) => {
if (p.id !== productId) return p;
return {
...p,
versions: p.versions.map((version) => (version.id === newVersion.id ? created as VersionItem : version)),
_count: { ...p._count, versions: p.versions.length } as any,
};
}),
overviewLoaded: true,
});
},
expected: updated,
getCurrent: () => get().overview,
@@ -421,11 +395,7 @@ export const useProductStore = create<ProductState>((set, get) => ({
set({ overview: updated, overviewLoaded: true });
await saveWithOptimisticRollback({
save: async () => {
try {
await updateVersionByProductId(productId, versionId, data);
} catch {
await saveRootOverviewFallback(updated);
}
await updateVersionByProductId(productId, versionId, data);
},
expected: updated,
getCurrent: () => get().overview,
@@ -446,11 +416,7 @@ export const useProductStore = create<ProductState>((set, get) => ({
set({ overview: updated, overviewLoaded: true });
await saveWithOptimisticRollback({
save: async () => {
try {
await deleteVersionByProductId(productId, versionId);
} catch {
await saveRootOverviewFallback(updated);
}
await deleteVersionByProductId(productId, versionId);
},
expected: updated,
getCurrent: () => get().overview,
@@ -471,11 +437,7 @@ export const useProductStore = create<ProductState>((set, get) => ({
set({ overview: updated, overviewLoaded: true });
await saveWithOptimisticRollback({
save: async () => {
try {
await deleteProjectByProductId(productId, projectId);
} catch {
await saveRootOverviewFallback(updated);
}
await deleteProjectByProductId(productId, projectId);
},
expected: updated,
getCurrent: () => get().overview,
@@ -483,18 +445,3 @@ export const useProductStore = create<ProductState>((set, get) => ({
});
},
}));
async function saveStoredOverview(data: ProductOverview[]) {
await saveServerData('products-overview', data);
}
async function saveRootOverviewFallback(data: ProductOverview[]) {
await saveStoredOverview(data);
}
async function loadStoredOverview(): Promise<ProductOverview[] | null> {
try {
return await loadServerData<ProductOverview[]>('products-overview');
} catch {}
return null;
}

View File

@@ -9,8 +9,12 @@ import {
updateRequirementByProductId,
updateRequirementStatusByProductId,
} from '@/lib/domain-api';
import { api } from '@/lib/api';
import { scheduleSaveWithOptimisticRollback } from '@/lib/optimistic-persistence';
import { loadServerData, saveServerData, SERVER_DATA_CACHE_MS } from '@/lib/server-data';
import { SERVER_DATA_CACHE_MS } from '@/lib/server-data';
import { loadV22RequirementsPage, type V22RequirementsQuery } from '@/lib/v22-api';
import { useAuthStore } from './useAuthStore';
import { useMemberStore } from './useMemberStore';
interface RequirementState {
requirements: Requirement[];
@@ -19,7 +23,7 @@ interface RequirementState {
platforms: DictItem[];
loading: boolean;
loaded: boolean;
fetchRequirements: (options?: { force?: boolean }) => Promise<void>;
fetchRequirements: (options?: { force?: boolean; productId?: string; projectId?: string; versionId?: string }) => Promise<void>;
createRequirement: (data: Omit<Requirement, 'id' | 'code' | 'createdAt'>) => void;
updateRequirement: (id: string, data: Partial<Requirement>) => void;
deleteRequirement: (id: string, productId?: string) => void;
@@ -62,11 +66,13 @@ const PRESET_PLATFORMS: DictItem[] = [
const MOCK_REQUIREMENTS: Requirement[] = [];
let lastRequirementsFetchAt = 0;
async function loadStored(): Promise<{ requirements: Requirement[]; sourceTargets: SourceTarget[]; types: DictItem[]; platforms: DictItem[] } | null> {
try {
return await loadServerData<{ requirements: Requirement[]; sourceTargets: SourceTarget[]; types: DictItem[]; platforms: DictItem[] }>('requirements');
} catch {}
return null;
type RequirementGovernanceKind = 'requirement_type' | 'requirement_platform' | 'requirement_source';
interface GovernanceDictionaryRow {
id: string;
name: string;
group?: string | null;
createdAt?: string;
}
// --- Store ---
@@ -81,19 +87,27 @@ export const useRequirementStore = create<RequirementState>((set, get) => {
loaded: false,
fetchRequirements: async (options) => {
if (!options?.force && get().loaded && Date.now() - lastRequirementsFetchAt < SERVER_DATA_CACHE_MS) return;
const cached = await loadStored();
if (!options?.force && get().loaded && Date.now() - lastRequirementsFetchAt < SERVER_DATA_CACHE_MS) return;
const hasRelationScope = Boolean(options?.productId);
if (!hasRelationScope && !options?.force && get().loaded && Date.now() - lastRequirementsFetchAt < SERVER_DATA_CACHE_MS) return;
const [governed, scopedRequirements] = await Promise.all([
loadRequirementGovernanceDictionaries().catch(() => null),
hasRelationScope ? loadScopedRequirementsFromRelation(options).catch(() => null) : Promise.resolve(null),
]);
if (!hasRelationScope && !options?.force && get().loaded && Date.now() - lastRequirementsFetchAt < SERVER_DATA_CACHE_MS) return;
lastRequirementsFetchAt = Date.now();
if (cached) {
set({ requirements: cached.requirements, sourceTargets: cached.sourceTargets, types: cached.types, platforms: cached.platforms, loaded: true });
} else {
set({ loaded: true });
}
set({
requirements: scopedRequirements
? mergeRequirementsForScope(get().requirements, options, scopedRequirements)
: get().requirements,
sourceTargets: governed?.sourceTargets ?? PRESET_SOURCE_TARGETS,
types: governed?.types ?? PRESET_TYPES,
platforms: governed?.platforms ?? PRESET_PLATFORMS,
loaded: true,
});
},
createRequirement: (data) => {
const { requirements, sourceTargets, types, platforms } = get();
const { requirements } = get();
const code = `REQ-${String(requirements.length + 1).padStart(3, '0')}`;
const newReq: Requirement = {
...data,
@@ -106,17 +120,13 @@ export const useRequirementStore = create<RequirementState>((set, get) => {
set({ requirements: updated, loaded: true });
scheduleSaveWithOptimisticRollback({
save: async () => {
try {
const created = await createRequirementByProductId(newReq.productId, newReq);
set({
requirements: get().requirements.map((item) => (
item.id === newReq.id ? { ...created, creator: newReq.creator || created.creator } : item
)),
loaded: true,
});
} catch {
await saveRequirementsFallback({ requirements: updated, sourceTargets, types, platforms });
}
const created = await createRequirementByProductId(newReq.productId, newReq);
set({
requirements: get().requirements.map((item) => (
item.id === newReq.id ? { ...created, creator: newReq.creator || created.creator } : item
)),
loaded: true,
});
},
expected: updated,
getCurrent: () => get().requirements,
@@ -125,29 +135,24 @@ export const useRequirementStore = create<RequirementState>((set, get) => {
},
updateRequirement: (id, data) => {
const { requirements, sourceTargets, types, platforms } = get();
const { requirements } = get();
const current = requirements.find((r) => r.id === id);
const productId = data.productId ?? current?.productId;
const updated = requirements.map((r) => (r.id === id ? { ...r, ...data } : r));
set({ requirements: updated, loaded: true });
scheduleSaveWithOptimisticRollback({
save: async () => {
try {
if (!productId) throw new Error('missing requirement productId');
const domainPatch = { ...data };
const status = domainPatch.status;
delete domainPatch.status;
delete domainPatch.productId;
delete domainPatch.productOwner;
delete domainPatch.addedToVersionBy;
if (hasDomainPatch(domainPatch)) {
await updateRequirementByProductId(productId, id, domainPatch);
}
if (status) {
await updateRequirementStatusByProductId(productId, id, status);
}
} catch {
await saveRequirementsFallback({ requirements: updated, sourceTargets, types, platforms });
if (!productId) throw new Error('missing requirement productId');
const domainPatch = { ...data };
const status = domainPatch.status;
delete domainPatch.status;
delete domainPatch.productId;
delete domainPatch.addedToVersionBy;
if (hasDomainPatch(domainPatch)) {
await updateRequirementByProductId(productId, id, domainPatch);
}
if (status) {
await updateRequirementStatusByProductId(productId, id, status);
}
},
expected: updated,
@@ -157,18 +162,14 @@ export const useRequirementStore = create<RequirementState>((set, get) => {
},
deleteRequirement: (id, scopedProductId) => {
const { requirements, sourceTargets, types, platforms } = get();
const { requirements } = get();
const productId = scopedProductId ?? requirements.find((r) => r.id === id)?.productId;
const updated = requirements.filter((r) => r.id !== id);
set({ requirements: updated, loaded: true });
scheduleSaveWithOptimisticRollback({
save: async () => {
try {
if (!productId) throw new Error('missing requirement productId');
await deleteRequirementByProductId(productId, id);
} catch {
await saveRequirementsFallback({ requirements: updated, sourceTargets, types, platforms });
}
if (!productId) throw new Error('missing requirement productId');
await deleteRequirementByProductId(productId, id);
},
expected: updated,
getCurrent: () => get().requirements,
@@ -178,12 +179,20 @@ export const useRequirementStore = create<RequirementState>((set, get) => {
// --- SourceTarget dict ---
addSourceTarget: (name, sourceType) => {
const { requirements, sourceTargets, types, platforms } = get();
const item: SourceTarget = { id: `st-${Date.now()}`, name, sourceType, createdAt: new Date().toISOString().slice(0, 10) };
const { sourceTargets } = get();
const updated = [...sourceTargets, item];
set({ sourceTargets: updated, loaded: true });
scheduleSaveWithOptimisticRollback({
save: () => saveServerData('requirements', { requirements, sourceTargets: updated, types, platforms }),
save: async () => {
const created = await createGovernanceDictionary('requirement_source', name, sourceType);
set({
sourceTargets: get().sourceTargets.map((target) => (
target.id === item.id ? governanceRowToSourceTarget(created, sourceType) : target
)),
loaded: true,
});
},
expected: updated,
getCurrent: () => get().sourceTargets,
rollback: () => set({ sourceTargets, loaded: true }),
@@ -191,11 +200,14 @@ export const useRequirementStore = create<RequirementState>((set, get) => {
},
updateSourceTarget: (id, name) => {
const { requirements, sourceTargets, types, platforms } = get();
const { sourceTargets } = get();
const current = sourceTargets.find((target) => target.id === id);
const updated = sourceTargets.map((s) => (s.id === id ? { ...s, name } : s));
set({ sourceTargets: updated, loaded: true });
scheduleSaveWithOptimisticRollback({
save: () => saveServerData('requirements', { requirements, sourceTargets: updated, types, platforms }),
save: async () => {
await updateGovernanceDictionary('requirement_source', id, name, current?.sourceType ?? 'customer');
},
expected: updated,
getCurrent: () => get().sourceTargets,
rollback: () => set({ sourceTargets, loaded: true }),
@@ -203,11 +215,13 @@ export const useRequirementStore = create<RequirementState>((set, get) => {
},
deleteSourceTarget: (id) => {
const { requirements, sourceTargets, types, platforms } = get();
const { sourceTargets } = get();
const updated = sourceTargets.filter((s) => s.id !== id);
set({ sourceTargets: updated, loaded: true });
scheduleSaveWithOptimisticRollback({
save: () => saveServerData('requirements', { requirements, sourceTargets: updated, types, platforms }),
save: async () => {
await deleteGovernanceDictionary('requirement_source', id);
},
expected: updated,
getCurrent: () => get().sourceTargets,
rollback: () => set({ sourceTargets, loaded: true }),
@@ -216,12 +230,18 @@ export const useRequirementStore = create<RequirementState>((set, get) => {
// --- Type dict ---
addType: (name) => {
const { requirements, sourceTargets, types, platforms } = get();
const item: DictItem = { id: `type-${Date.now()}`, name, createdAt: new Date().toISOString().slice(0, 10) };
const { types } = get();
const updated = [...types, item];
set({ types: updated, loaded: true });
scheduleSaveWithOptimisticRollback({
save: () => saveServerData('requirements', { requirements, sourceTargets, types: updated, platforms }),
save: async () => {
const created = await createGovernanceDictionary('requirement_type', name);
set({
types: get().types.map((type) => (type.id === item.id ? governanceRowToDictItem(created) : type)),
loaded: true,
});
},
expected: updated,
getCurrent: () => get().types,
rollback: () => set({ types, loaded: true }),
@@ -229,11 +249,13 @@ export const useRequirementStore = create<RequirementState>((set, get) => {
},
updateType: (id, name) => {
const { requirements, sourceTargets, types, platforms } = get();
const { types } = get();
const updated = types.map((t) => (t.id === id ? { ...t, name } : t));
set({ types: updated, loaded: true });
scheduleSaveWithOptimisticRollback({
save: () => saveServerData('requirements', { requirements, sourceTargets, types: updated, platforms }),
save: async () => {
await updateGovernanceDictionary('requirement_type', id, name);
},
expected: updated,
getCurrent: () => get().types,
rollback: () => set({ types, loaded: true }),
@@ -241,11 +263,13 @@ export const useRequirementStore = create<RequirementState>((set, get) => {
},
deleteType: (id) => {
const { requirements, sourceTargets, types, platforms } = get();
const { types } = get();
const updated = types.filter((t) => t.id !== id);
set({ types: updated, loaded: true });
scheduleSaveWithOptimisticRollback({
save: () => saveServerData('requirements', { requirements, sourceTargets, types: updated, platforms }),
save: async () => {
await deleteGovernanceDictionary('requirement_type', id);
},
expected: updated,
getCurrent: () => get().types,
rollback: () => set({ types, loaded: true }),
@@ -254,12 +278,18 @@ export const useRequirementStore = create<RequirementState>((set, get) => {
// --- Platform dict ---
addPlatform: (name) => {
const { requirements, sourceTargets, types, platforms } = get();
const item: DictItem = { id: `platform-${Date.now()}`, name, createdAt: new Date().toISOString().slice(0, 10) };
const { platforms } = get();
const updated = [...platforms, item];
set({ platforms: updated, loaded: true });
scheduleSaveWithOptimisticRollback({
save: () => saveServerData('requirements', { requirements, sourceTargets, types, platforms: updated }),
save: async () => {
const created = await createGovernanceDictionary('requirement_platform', name);
set({
platforms: get().platforms.map((platform) => (platform.id === item.id ? governanceRowToDictItem(created) : platform)),
loaded: true,
});
},
expected: updated,
getCurrent: () => get().platforms,
rollback: () => set({ platforms, loaded: true }),
@@ -267,11 +297,13 @@ export const useRequirementStore = create<RequirementState>((set, get) => {
},
updatePlatform: (id, name) => {
const { requirements, sourceTargets, types, platforms } = get();
const { platforms } = get();
const updated = platforms.map((p) => (p.id === id ? { ...p, name } : p));
set({ platforms: updated, loaded: true });
scheduleSaveWithOptimisticRollback({
save: () => saveServerData('requirements', { requirements, sourceTargets, types, platforms: updated }),
save: async () => {
await updateGovernanceDictionary('requirement_platform', id, name);
},
expected: updated,
getCurrent: () => get().platforms,
rollback: () => set({ platforms, loaded: true }),
@@ -279,11 +311,13 @@ export const useRequirementStore = create<RequirementState>((set, get) => {
},
deletePlatform: (id) => {
const { requirements, sourceTargets, types, platforms } = get();
const { platforms } = get();
const updated = platforms.filter((p) => p.id !== id);
set({ platforms: updated, loaded: true });
scheduleSaveWithOptimisticRollback({
save: () => saveServerData('requirements', { requirements, sourceTargets, types, platforms: updated }),
save: async () => {
await deleteGovernanceDictionary('requirement_platform', id);
},
expected: updated,
getCurrent: () => get().platforms,
rollback: () => set({ platforms, loaded: true }),
@@ -292,15 +326,114 @@ export const useRequirementStore = create<RequirementState>((set, get) => {
};
});
async function saveRequirementsFallback(data: {
requirements: Requirement[];
sourceTargets: SourceTarget[];
types: DictItem[];
platforms: DictItem[];
}) {
await saveServerData('requirements', data);
}
function hasDomainPatch(data: Partial<Requirement>) {
return Object.values(data).some((value) => value !== undefined);
}
async function loadScopedRequirementsFromRelation(
options?: { productId?: string; projectId?: string; versionId?: string },
): Promise<Requirement[]> {
if (!options?.productId) return [];
const items: Requirement[] = [];
let cursor: string | undefined;
do {
const query: V22RequirementsQuery = {
productId: options.productId,
projectId: options.projectId,
versionId: options.versionId,
sort: 'created_at_desc',
limit: 500,
cursor,
};
const page = await loadV22RequirementsPage(query);
items.push(...page.items);
cursor = page.nextCursor;
} while (cursor);
return items;
}
function mergeRequirementsForScope(
current: Requirement[],
scope: { productId?: string; projectId?: string; versionId?: string } | undefined,
scopedRequirements: Requirement[],
) {
if (!scope?.productId) return current;
return [
...current.filter((requirement) => !isRequirementInScope(requirement, scope)),
...scopedRequirements,
];
}
function isRequirementInScope(
requirement: Requirement,
scope: { productId?: string; projectId?: string; versionId?: string },
) {
if (scope.versionId) return requirement.versionId === scope.versionId;
if (scope.projectId) return requirement.productId === scope.productId && requirement.projectId === scope.projectId;
return requirement.productId === scope.productId;
}
async function loadRequirementGovernanceDictionaries() {
const [types, platforms, sourceTargets] = await Promise.all([
listGovernanceDictionary('requirement_type'),
listGovernanceDictionary('requirement_platform'),
listGovernanceDictionary('requirement_source'),
]);
return {
types: types.length > 0 ? types.map(governanceRowToDictItem) : PRESET_TYPES,
platforms: platforms.length > 0 ? platforms.map(governanceRowToDictItem) : PRESET_PLATFORMS,
sourceTargets: sourceTargets.length > 0 ? sourceTargets.map((row) => governanceRowToSourceTarget(row)) : PRESET_SOURCE_TARGETS,
};
}
function listGovernanceDictionary(kind: RequirementGovernanceKind) {
return api.get<GovernanceDictionaryRow[]>(`/governance/dictionaries?kind=${kind}`);
}
function createGovernanceDictionary(kind: RequirementGovernanceKind, name: string, group?: string) {
const { actorId, permissions } = getGovernanceActorContext();
return api.post<GovernanceDictionaryRow>('/governance/dictionaries', { actorId, permissions, kind, name, group });
}
function updateGovernanceDictionary(kind: RequirementGovernanceKind, id: string, name: string, group?: string) {
const { actorId, permissions } = getGovernanceActorContext();
return api.patch<GovernanceDictionaryRow>(`/governance/dictionaries/${kind}/${id}`, { actorId, permissions, kind, name, group });
}
function deleteGovernanceDictionary(kind: RequirementGovernanceKind, id: string) {
const { actorId, permissions } = getGovernanceActorContext();
return api.deleteWithBody<GovernanceDictionaryRow>(`/governance/dictionaries/${kind}/${id}`, { actorId, permissions });
}
function getGovernanceActorContext() {
const user = useAuthStore.getState().user;
const role = useMemberStore.getState().roles.find((item) => item.id === user?.roleId);
return { actorId: user?.id ?? '', permissions: role?.permissions ?? [] };
}
function governanceRowToDictItem(row: GovernanceDictionaryRow): DictItem {
return {
id: row.id,
name: row.name,
createdAt: normalizeGovernanceCreatedAt(row.createdAt),
};
}
function governanceRowToSourceTarget(row: GovernanceDictionaryRow, fallbackSourceType?: SourceType): SourceTarget {
return {
id: row.id,
name: row.name,
sourceType: normalizeGovernanceSourceType(row.group, fallbackSourceType),
createdAt: normalizeGovernanceCreatedAt(row.createdAt),
};
}
function normalizeGovernanceCreatedAt(value?: string) {
return value ? value.slice(0, 10) : new Date().toISOString().slice(0, 10);
}
function normalizeGovernanceSourceType(value?: string | null, fallback: SourceType = 'customer'): SourceType {
return ['customer', 'internal', 'operation', 'aftersale', 'market', 'competitor', 'management'].includes(value ?? '')
? value as SourceType
: fallback;
}

View File

@@ -9,17 +9,10 @@ import {
updateTaskCategoryDomain,
} from '@/lib/domain-api';
import { scheduleSaveWithOptimisticRollback } from '@/lib/optimistic-persistence';
import { loadServerData, saveServerData, SERVER_DATA_CACHE_MS } from '@/lib/server-data';
import { SERVER_DATA_CACHE_MS } from '@/lib/server-data';
let lastCategoriesFetchAt = 0;
async function loadStored(): Promise<TaskCategory[] | null> {
try {
return await loadServerData<TaskCategory[]>('task-categories');
} catch {}
return null;
}
interface TaskCategoryState {
categories: TaskCategory[];
loaded: boolean;
@@ -36,7 +29,7 @@ export const useTaskCategoryStore = create<TaskCategoryState>((set, get) => ({
fetchCategories: async (options) => {
if (!options?.force && get().loaded && Date.now() - lastCategoriesFetchAt < SERVER_DATA_CACHE_MS) return;
const cached = await listTaskCategoriesDomain().catch(loadStored);
const cached = await listTaskCategoriesDomain().catch(() => null);
if (!options?.force && get().loaded && Date.now() - lastCategoriesFetchAt < SERVER_DATA_CACHE_MS) return;
lastCategoriesFetchAt = Date.now();
set({ categories: cached ? normalizeTaskCategories(cached) : PRESET_CATEGORIES, loaded: true });
@@ -57,15 +50,11 @@ export const useTaskCategoryStore = create<TaskCategoryState>((set, get) => ({
set({ categories: updated, loaded: true });
scheduleSaveWithOptimisticRollback({
save: async () => {
try {
const created = await createTaskCategoryDomain(item);
set({
categories: get().categories.map((category) => (category.id === item.id ? { ...item, ...created } : category)),
loaded: true,
});
} catch {
await saveTaskCategoriesFallback(updated);
}
const created = await createTaskCategoryDomain(item);
set({
categories: get().categories.map((category) => (category.id === item.id ? { ...item, ...created } : category)),
loaded: true,
});
},
expected: updated,
getCurrent: () => get().categories,
@@ -80,11 +69,7 @@ export const useTaskCategoryStore = create<TaskCategoryState>((set, get) => ({
set({ categories: result.categories, loaded: true });
scheduleSaveWithOptimisticRollback({
save: async () => {
try {
await createTaskCategoryDomain(result.category);
} catch {
await saveTaskCategoriesFallback(result.categories);
}
await createTaskCategoryDomain(result.category);
},
expected: result.categories,
getCurrent: () => get().categories,
@@ -100,11 +85,7 @@ export const useTaskCategoryStore = create<TaskCategoryState>((set, get) => ({
set({ categories: updated, loaded: true });
scheduleSaveWithOptimisticRollback({
save: async () => {
try {
await updateTaskCategoryDomain(id, data);
} catch {
await saveTaskCategoriesFallback(updated);
}
await updateTaskCategoryDomain(id, data);
},
expected: updated,
getCurrent: () => get().categories,
@@ -120,11 +101,7 @@ export const useTaskCategoryStore = create<TaskCategoryState>((set, get) => ({
set({ categories: updated, loaded: true });
scheduleSaveWithOptimisticRollback({
save: async () => {
try {
await deleteTaskCategoryDomain(id);
} catch {
await saveTaskCategoriesFallback(updated);
}
await deleteTaskCategoryDomain(id);
},
expected: updated,
getCurrent: () => get().categories,
@@ -133,7 +110,3 @@ export const useTaskCategoryStore = create<TaskCategoryState>((set, get) => ({
return true;
},
}));
async function saveTaskCategoriesFallback(categories: TaskCategory[]) {
await saveServerData('task-categories', categories);
}

View File

@@ -7,17 +7,10 @@ import {
listTaskWorklogsDomain,
} from '@/lib/domain-api';
import { scheduleSaveWithOptimisticRollback } from '@/lib/optimistic-persistence';
import { loadServerData, saveServerData, SERVER_DATA_CACHE_MS } from '@/lib/server-data';
import { SERVER_DATA_CACHE_MS } from '@/lib/server-data';
let lastWorklogsFetchAt = 0;
async function loadStored(): Promise<TaskWorklog[] | null> {
try {
return await loadServerData<TaskWorklog[]>('task-worklogs');
} catch {}
return null;
}
interface TaskWorklogState {
worklogs: TaskWorklog[];
loaded: boolean;
@@ -33,7 +26,7 @@ export const useTaskWorklogStore = create<TaskWorklogState>((set, get) => ({
fetchWorklogs: async (options) => {
if (!options?.force && get().loaded && Date.now() - lastWorklogsFetchAt < SERVER_DATA_CACHE_MS) return;
const cached = await listTaskWorklogsDomain().catch(loadStored);
const cached = await listTaskWorklogsDomain().catch(() => []);
if (!options?.force && get().loaded && Date.now() - lastWorklogsFetchAt < SERVER_DATA_CACHE_MS) return;
lastWorklogsFetchAt = Date.now();
set({ worklogs: cached ?? [], loaded: true });
@@ -50,15 +43,11 @@ export const useTaskWorklogStore = create<TaskWorklogState>((set, get) => ({
set({ worklogs: updated, loaded: true });
scheduleSaveWithOptimisticRollback({
save: async () => {
try {
const created = await createTaskWorklogDomain(item);
set({
worklogs: get().worklogs.map((worklog) => (worklog.id === item.id ? { ...item, ...created } : worklog)),
loaded: true,
});
} catch {
await saveTaskWorklogsFallback(updated);
}
const created = await createTaskWorklogDomain(item);
set({
worklogs: get().worklogs.map((worklog) => (worklog.id === item.id ? { ...item, ...created } : worklog)),
loaded: true,
});
},
expected: updated,
getCurrent: () => get().worklogs,
@@ -72,11 +61,7 @@ export const useTaskWorklogStore = create<TaskWorklogState>((set, get) => ({
set({ worklogs: updated, loaded: true });
scheduleSaveWithOptimisticRollback({
save: async () => {
try {
await deleteTaskWorklogDomain(id);
} catch {
await saveTaskWorklogsFallback(updated);
}
await deleteTaskWorklogDomain(id);
},
expected: updated,
getCurrent: () => get().worklogs,
@@ -90,7 +75,3 @@ export const useTaskWorklogStore = create<TaskWorklogState>((set, get) => ({
.reduce((sum, w) => sum + w.hours, 0);
},
}));
async function saveTaskWorklogsFallback(worklogs: TaskWorklog[]) {
await saveServerData('task-worklogs', worklogs);
}

View File

@@ -11,29 +11,17 @@ import {
updateTestCaseByVersionId,
updateTestCaseStatusByVersionId,
} from '@/lib/domain-api';
import { createEntityId, dedupeEntityIds } from '@/lib/entity-id';
import { createEntityId } from '@/lib/entity-id';
import { scheduleSaveWithOptimisticRollback } from '@/lib/optimistic-persistence';
import { loadServerData, saveServerData, SERVER_DATA_CACHE_MS } from '@/lib/server-data';
import { SERVER_DATA_CACHE_MS } from '@/lib/server-data';
import type { WorkActivity } from '@/lib/work-activity';
import {
makeTestCaseCreatedActivity,
makeTestCaseStatusActivity,
} from '@/lib/work-activity-factory';
import { useWorkActivityStore } from './useWorkActivityStore';
function saveStored(items: TestCase[]) {
saveServerData('test-cases', items).catch(() => {});
}
let lastTestCasesFetchAt = 0;
async function loadStored(): Promise<TestCase[] | null> {
try {
return await loadServerData<TestCase[]>('test-cases');
} catch {}
return null;
}
interface TestCaseState {
testCases: TestCase[];
loaded: boolean;
@@ -53,19 +41,19 @@ export const useTestCaseStore = create<TestCaseState>((set, get) => ({
loaded: false,
fetchTestCases: async (options) => {
if (!options?.force && get().loaded && Date.now() - lastTestCasesFetchAt < SERVER_DATA_CACHE_MS) return;
if (!options?.versionId && !options?.force && get().loaded && Date.now() - lastTestCasesFetchAt < SERVER_DATA_CACHE_MS) return;
const cached = options?.versionId
? await listTestCasesByVersionId(options.versionId).catch(loadStored)
: await loadStored();
if (!options?.force && get().loaded && Date.now() - lastTestCasesFetchAt < SERVER_DATA_CACHE_MS) return;
? await listTestCasesByVersionId(options.versionId).catch(() => [])
: get().testCases;
if (!options?.versionId && !options?.force && get().loaded && Date.now() - lastTestCasesFetchAt < SERVER_DATA_CACHE_MS) return;
lastTestCasesFetchAt = Date.now();
if (cached) {
const result = dedupeEntityIds(normalizeTestCases(cached), 'tc');
if (result.changed) saveStored(result.items);
set({ testCases: result.items, loaded: true });
} else {
set({ loaded: true });
}
const normalized = normalizeTestCases(cached ?? []);
set({
testCases: options?.versionId
? mergeTestCasesForVersion(get().testCases, options.versionId, normalized)
: normalized,
loaded: true,
});
},
createTestCase: (data) => {
@@ -83,17 +71,12 @@ export const useTestCaseStore = create<TestCaseState>((set, get) => ({
set({ testCases: updated, loaded: true });
scheduleSaveWithOptimisticRollback({
save: async () => {
try {
const result = await createTestCaseByVersionId(tc.versionId, tc);
set({
testCases: get().testCases.map((item) => (item.id === tc.id ? { ...tc, ...result.item } : item)),
loaded: true,
});
appendDomainActivities(result.activities);
} catch {
await saveTestCasesFallback(updated);
useWorkActivityStore.getState().addActivity(makeTestCaseCreatedActivity(tc, tc.createdBy));
}
const result = await createTestCaseByVersionId(tc.versionId, tc);
set({
testCases: get().testCases.map((item) => (item.id === tc.id ? { ...tc, ...result.item } : item)),
loaded: true,
});
appendDomainActivities(result.activities);
},
expected: updated,
getCurrent: () => get().testCases,
@@ -123,25 +106,18 @@ export const useTestCaseStore = create<TestCaseState>((set, get) => ({
set({ testCases: list, loaded: true });
scheduleSaveWithOptimisticRollback({
save: async () => {
try {
if (!created[0]?.versionId) throw new Error('missing versionId');
const result = await createTestCasesByVersionId(created[0].versionId, created);
const byCaseNo = new Map(result.items.map((item) => [item.caseNo, item]));
set({
testCases: get().testCases.map((item) => {
if (!created.some((tc) => tc.id === item.id)) return item;
const replacement = byCaseNo.get(item.caseNo);
return replacement ? { ...item, ...replacement } : item;
}),
loaded: true,
});
appendDomainActivities(result.activities);
} catch {
await saveTestCasesFallback(list);
created.forEach((tc) => {
useWorkActivityStore.getState().addActivity(makeTestCaseCreatedActivity(tc, tc.createdBy));
});
}
if (!created[0]?.versionId) throw new Error('missing versionId');
const result = await createTestCasesByVersionId(created[0].versionId, created);
const byCaseNo = new Map(result.items.map((item) => [item.caseNo, item]));
set({
testCases: get().testCases.map((item) => {
if (!created.some((tc) => tc.id === item.id)) return item;
const replacement = byCaseNo.get(item.caseNo);
return replacement ? { ...item, ...replacement } : item;
}),
loaded: true,
});
appendDomainActivities(result.activities);
},
expected: list,
getCurrent: () => get().testCases,
@@ -158,14 +134,10 @@ export const useTestCaseStore = create<TestCaseState>((set, get) => ({
set({ testCases: updated, loaded: true });
scheduleSaveWithOptimisticRollback({
save: async () => {
try {
const versionId = previous.find((testCase) => testCase.id === id)?.versionId;
if (!versionId) throw new Error('missing versionId');
const result = await updateTestCaseByVersionId(versionId, id, data);
appendDomainActivities(result.activities);
} catch {
await saveTestCasesFallback(updated);
}
const versionId = previous.find((testCase) => testCase.id === id)?.versionId;
if (!versionId) throw new Error('missing versionId');
const result = await updateTestCaseByVersionId(versionId, id, data);
appendDomainActivities(result.activities);
},
expected: updated,
getCurrent: () => get().testCases,
@@ -179,13 +151,9 @@ export const useTestCaseStore = create<TestCaseState>((set, get) => ({
set({ testCases: updated, loaded: true });
scheduleSaveWithOptimisticRollback({
save: async () => {
try {
const versionId = previous.find((testCase) => testCase.id === id)?.versionId;
if (!versionId) throw new Error('missing versionId');
await deleteTestCaseByVersionId(versionId, id);
} catch {
await saveTestCasesFallback(updated);
}
const versionId = previous.find((testCase) => testCase.id === id)?.versionId;
if (!versionId) throw new Error('missing versionId');
await deleteTestCaseByVersionId(versionId, id);
},
expected: updated,
getCurrent: () => get().testCases,
@@ -211,14 +179,9 @@ export const useTestCaseStore = create<TestCaseState>((set, get) => ({
const activity = makeTestCaseStatusActivity(tc, tc.status, to, actorId);
scheduleSaveWithOptimisticRollback({
save: async () => {
try {
const result = await updateTestCaseStatusByVersionId(tc.versionId, id, to);
appendDomainActivities(result.activities);
if (result.activities.length === 0 && activity) useWorkActivityStore.getState().addActivity(activity);
} catch {
await saveTestCasesFallback(updated);
if (activity) useWorkActivityStore.getState().addActivity(activity);
}
const result = await updateTestCaseStatusByVersionId(tc.versionId, id, to);
appendDomainActivities(result.activities);
if (result.activities.length === 0 && activity) useWorkActivityStore.getState().addActivity(activity);
},
expected: updated,
getCurrent: () => get().testCases,
@@ -240,8 +203,11 @@ export const useTestCaseStore = create<TestCaseState>((set, get) => ({
},
}));
async function saveTestCasesFallback(testCases: TestCase[]) {
await saveServerData('test-cases', testCases);
function mergeTestCasesForVersion(current: TestCase[], versionId: string, scopedTestCases: TestCase[]) {
return [
...current.filter((testCase) => testCase.versionId !== versionId),
...scopedTestCases,
];
}
function appendDomainActivities(activities: WorkActivity[]) {

View File

@@ -1,6 +1,7 @@
'use client';
import { create } from 'zustand';
import type { VersionPlan, PlanType } from '@/lib/version-plan';
import type { VersionPlan } from '@/lib/version-plan';
import { mergeVersionPlanPatch } from '@/lib/version-plan';
import {
completeVersionPlanByVersionId,
createVersionPlanByVersionId,
@@ -9,12 +10,11 @@ import {
updateVersionPlanByVersionId,
} from '@/lib/domain-api';
import { scheduleSaveWithOptimisticRollback } from '@/lib/optimistic-persistence';
import { loadServerData, saveServerData, SERVER_DATA_CACHE_MS } from '@/lib/server-data';
import { SERVER_DATA_CACHE_MS, SERVER_DATA_SAVE_ERROR_EVENT } from '@/lib/server-data';
import { getPlanCompletionState } from '@/lib/version-plan-workflow';
import type { PlanResultPayload } from '@/lib/version-plan-workflow';
import {
makeVersionPlanCompletedActivity,
makeVersionPlanCreatedActivity,
makeVersionPlanRequirementProgressActivity,
makeVersionPlanResearchDirectionProgressActivity,
makeVersionPlanStartedActivity,
@@ -26,13 +26,6 @@ import { useWorkActivityStore } from './useWorkActivityStore';
const MOCK_PLANS: VersionPlan[] = [];
let lastPlansFetchAt = 0;
async function loadStored(): Promise<VersionPlan[] | null> {
try {
return await loadServerData<VersionPlan[]>('version-plans');
} catch {}
return null;
}
interface VersionPlanState {
plans: VersionPlan[];
loaded: boolean;
@@ -48,18 +41,31 @@ export const useVersionPlanStore = create<VersionPlanState>((set, get) => ({
loaded: false,
fetchPlans: async (options) => {
if (!options?.force && get().loaded && Date.now() - lastPlansFetchAt < SERVER_DATA_CACHE_MS) return;
if (!options?.versionId && !options?.force && get().loaded && Date.now() - lastPlansFetchAt < SERVER_DATA_CACHE_MS) return;
const cached = options?.versionId
? await listVersionPlansByVersionId(options.versionId).catch(loadStored)
: await loadStored();
if (!options?.force && get().loaded && Date.now() - lastPlansFetchAt < SERVER_DATA_CACHE_MS) return;
? await listVersionPlansByVersionId(options.versionId).catch(() => [])
: get().plans;
if (!options?.versionId && !options?.force && get().loaded && Date.now() - lastPlansFetchAt < SERVER_DATA_CACHE_MS) return;
lastPlansFetchAt = Date.now();
set({ plans: cached ?? MOCK_PLANS, loaded: true });
set({
plans: options?.versionId
? mergeVersionPlansForVersion(get().plans, options.versionId, cached ?? MOCK_PLANS)
: cached ?? MOCK_PLANS,
loaded: true,
});
},
createPlan: (data) => {
const previous = get().plans;
const plan: VersionPlan = { ...data, id: `plan-${Date.now()}`, createdAt: new Date().toISOString().slice(0, 10) };
const now = new Date().toISOString();
const autoStarted = shouldAutoStartCreatedPlan(data, now);
const plan: VersionPlan = {
...data,
status: autoStarted ? 'in_progress' : data.status,
actualStartAt: autoStarted ? data.actualStartAt ?? now : data.actualStartAt,
id: `plan-${Date.now()}`,
createdAt: now.slice(0, 10),
};
const plans = [...previous, plan];
set({ plans, loaded: true });
scheduleSaveWithOptimisticRollback({
@@ -68,14 +74,14 @@ export const useVersionPlanStore = create<VersionPlanState>((set, get) => ({
const result = await createVersionPlanByVersionId(plan.versionId, plan);
set({
plans: get().plans.map((item) => (
item.id === plan.id ? { ...plan, ...result.item, tasks: plan.tasks } : item
item.id === plan.id ? mergeCreatedVersionPlan(plan, item, result.item) : item
)),
loaded: true,
});
appendDomainActivities(result.activities);
} catch {
await saveVersionPlansFallback(plans);
useWorkActivityStore.getState().addActivity(makeVersionPlanCreatedActivity(plan, plan.addedBy || plan.owner));
} catch (error) {
notifyVersionPlanSaveFailure(error);
throw error;
}
},
expected: plans,
@@ -88,9 +94,10 @@ export const useVersionPlanStore = create<VersionPlanState>((set, get) => ({
const previous = get().plans;
const now = new Date().toISOString();
const activities: WorkActivityDraft[] = [];
let apiPatch: Partial<VersionPlan> | null = null;
const plans = previous.map((p) => {
if (p.id !== id) return p;
const patch = { ...data };
const patch = { ...mergeVersionPlanPatch(p, data) };
if (patch.status === 'in_progress' && !p.actualStartAt) {
(patch as any).actualStartAt = now;
}
@@ -112,6 +119,7 @@ export const useVersionPlanStore = create<VersionPlanState>((set, get) => ({
const directionActivity = makeVersionPlanResearchDirectionProgressActivity(next, log);
if (directionActivity) activities.push(directionActivity);
}
apiPatch = patch;
return next;
});
set({ plans, loaded: true });
@@ -120,14 +128,14 @@ export const useVersionPlanStore = create<VersionPlanState>((set, get) => ({
try {
const versionId = previous.find((plan) => plan.id === id)?.versionId;
if (!versionId) throw new Error('missing versionId');
const result = await updateVersionPlanByVersionId(versionId, id, data);
const result = await updateVersionPlanByVersionId(versionId, id, apiPatch ?? data);
appendDomainActivities(result.activities);
if (result.activities.length === 0) {
activities.forEach((activity) => useWorkActivityStore.getState().addActivity(activity));
}
} catch {
await saveVersionPlansFallback(plans);
activities.forEach((activity) => useWorkActivityStore.getState().addActivity(activity));
} catch (error) {
notifyVersionPlanSaveFailure(error);
throw error;
}
},
expected: plans,
@@ -159,8 +167,9 @@ export const useVersionPlanStore = create<VersionPlanState>((set, get) => ({
if (!versionId) throw new Error('missing versionId');
const apiResult = await completeVersionPlanByVersionId(versionId, id, result);
appendDomainActivities(apiResult.activities);
} catch {
await saveVersionPlansFallback(plans);
} catch (error) {
notifyVersionPlanSaveFailure(error);
throw error;
}
},
expected: plans,
@@ -180,8 +189,9 @@ export const useVersionPlanStore = create<VersionPlanState>((set, get) => ({
const versionId = previous.find((plan) => plan.id === id)?.versionId;
if (!versionId) throw new Error('missing versionId');
await deleteVersionPlanByVersionId(versionId, id);
} catch {
await saveVersionPlansFallback(plans);
} catch (error) {
notifyVersionPlanSaveFailure(error);
throw error;
}
},
expected: plans,
@@ -191,10 +201,6 @@ export const useVersionPlanStore = create<VersionPlanState>((set, get) => ({
},
}));
async function saveVersionPlansFallback(plans: VersionPlan[]) {
await saveServerData('version-plans', plans);
}
function appendDomainActivities(activities: WorkActivity[]) {
if (activities.length === 0) return;
useWorkActivityStore.setState((state) => ({
@@ -202,3 +208,42 @@ function appendDomainActivities(activities: WorkActivity[]) {
loaded: true,
}));
}
function mergeVersionPlansForVersion(current: VersionPlan[], versionId: string, scopedPlans: VersionPlan[]) {
return [
...current.filter((plan) => plan.versionId !== versionId),
...scopedPlans,
];
}
function shouldAutoStartCreatedPlan(data: Omit<VersionPlan, 'id' | 'createdAt'>, nowIso: string): boolean {
if (data.status !== 'pending' || !data.startTime || data.actualStartAt) return false;
return new Date(data.startTime).getTime() <= new Date(nowIso).getTime();
}
function mergeCreatedVersionPlan(initial: VersionPlan, current: VersionPlan, persisted: VersionPlan): VersionPlan {
const localChanged = current.status !== initial.status
|| current.actualStartAt !== initial.actualStartAt
|| current.completedAt !== initial.completedAt;
return {
...current,
...persisted,
status: localChanged ? current.status : persisted.status,
actualStartAt: current.actualStartAt ?? persisted.actualStartAt,
completedAt: current.completedAt ?? persisted.completedAt,
tasks: current.tasks ?? initial.tasks ?? persisted.tasks,
};
}
function notifyVersionPlanSaveFailure(error: unknown) {
if (typeof window === 'undefined') return;
window.dispatchEvent(
new CustomEvent(SERVER_DATA_SAVE_ERROR_EVENT, {
detail: {
key: 'version-plans',
message: error instanceof Error && error.message ? error.message : '版本计划保存失败,请检查 API 服务',
},
}),
);
}

View File

@@ -1,7 +1,7 @@
'use client';
import { create } from 'zustand';
import { formatLocalDate } from '@/lib/format';
import { mergeWorkActivities, type WorkActivity, type WorkActivityDraft } from '@/lib/work-activity';
import { type WorkActivity, type WorkActivityDraft } from '@/lib/work-activity';
import { makeProgressNoteActivity, type ProgressNoteActivityInput } from '@/lib/work-activity-factory';
import {
createWorkActivityDomain,
@@ -9,7 +9,7 @@ import {
listWorkActivitiesDomain,
} from '@/lib/domain-api';
import { scheduleSaveWithOptimisticRollback } from '@/lib/optimistic-persistence';
import { loadServerData, saveServerData, SERVER_DATA_CACHE_MS } from '@/lib/server-data';
import { SERVER_DATA_CACHE_MS } from '@/lib/server-data';
interface WorkActivityState {
activities: WorkActivity[];
@@ -22,32 +22,6 @@ interface WorkActivityState {
let lastActivitiesFetchAt = 0;
async function saveStored(
items: WorkActivity[],
setActivities?: (items: WorkActivity[]) => void,
options: { mergeRemote?: boolean } = {},
) {
try {
if (options.mergeRemote === false) {
await saveServerData('work-activities', items);
return;
}
const remote = await loadServerData<WorkActivity[]>('work-activities', { force: true });
const merged = mergeWorkActivities(Array.isArray(remote) ? remote : [], items);
setActivities?.(merged);
await saveServerData('work-activities', merged);
} catch {
saveServerData('work-activities', items).catch(() => {});
}
}
async function loadStored(): Promise<WorkActivity[] | null> {
try {
return await loadServerData<WorkActivity[]>('work-activities');
} catch {}
return null;
}
function createActivityId(): string {
return `act-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
}
@@ -58,7 +32,7 @@ export const useWorkActivityStore = create<WorkActivityState>((set, get) => ({
fetchActivities: async (options) => {
if (!options?.force && get().loaded && Date.now() - lastActivitiesFetchAt < SERVER_DATA_CACHE_MS) return;
const cached = await listWorkActivitiesDomain().catch(loadStored);
const cached = await listWorkActivitiesDomain().catch(() => []);
if (!options?.force && get().loaded && Date.now() - lastActivitiesFetchAt < SERVER_DATA_CACHE_MS) return;
lastActivitiesFetchAt = Date.now();
set({ activities: cached ?? [], loaded: true });
@@ -77,15 +51,11 @@ export const useWorkActivityStore = create<WorkActivityState>((set, get) => ({
set({ activities: updated, loaded: true });
scheduleSaveWithOptimisticRollback({
save: async () => {
try {
const created = await createWorkActivityDomain(item);
set({
activities: get().activities.map((activity) => (activity.id === item.id ? { ...item, ...created } : activity)),
loaded: true,
});
} catch {
await saveStored(updated, (activities) => set({ activities, loaded: true }));
}
const created = await createWorkActivityDomain(item);
set({
activities: get().activities.map((activity) => (activity.id === item.id ? { ...item, ...created } : activity)),
loaded: true,
});
},
expected: updated,
getCurrent: () => get().activities,
@@ -104,11 +74,7 @@ export const useWorkActivityStore = create<WorkActivityState>((set, get) => ({
set({ activities: updated, loaded: true });
scheduleSaveWithOptimisticRollback({
save: async () => {
try {
await deleteWorkActivityDomain(id);
} catch {
await saveStored(updated, (activities) => set({ activities, loaded: true }), { mergeRemote: false });
}
await deleteWorkActivityDomain(id);
},
expected: updated,
getCurrent: () => get().activities,

View File

@@ -1,11 +1,6 @@
'use client';
import { create } from 'zustand';
import { loadServerData, saveServerData, SERVER_DATA_CACHE_MS } from '@/lib/server-data';
import { api } from '@/lib/api';
import { useMemberStore } from './useMemberStore';
import {
mergeDailySnapshotCacheForSave,
mergeInsightCacheForSave,
upsertDailySnapshot,
upsertInsight,
type XiaobaoRiskInsightCacheItem,
@@ -26,24 +21,7 @@ interface XiaobaoRiskState {
finishInsightUpdate: (key: string) => void;
}
async function loadSnapshots(): Promise<XiaobaoRiskSnapshot[] | null> {
try {
const rows = await loadServerData<XiaobaoRiskSnapshot[]>('xiaobao-risk-snapshots');
return Array.isArray(rows) ? rows : [];
} catch {}
return null;
}
async function loadInsights(): Promise<XiaobaoRiskInsightCacheItem[] | null> {
try {
const rows = await loadServerData<XiaobaoRiskInsightCacheItem[]>('xiaobao-risk-insights');
return Array.isArray(rows) ? rows : [];
} catch {}
return null;
}
let snapshotSaveQueue: Promise<void> = Promise.resolve();
let insightSaveQueue: Promise<void> = Promise.resolve();
const RISK_DATA_CACHE_MS = 30_000;
let lastRiskDataFetchAt = 0;
export const useXiaobaoRiskStore = create<XiaobaoRiskState>((set, get) => ({
@@ -55,54 +33,22 @@ export const useXiaobaoRiskStore = create<XiaobaoRiskState>((set, get) => ({
error: undefined,
fetchRiskData: async () => {
if (get().riskDataLoaded && Date.now() - lastRiskDataFetchAt < SERVER_DATA_CACHE_MS) return;
set({ riskDataLoaded: false });
const [snapshots, insights] = await Promise.all([loadSnapshots(), loadInsights()]);
if (get().riskDataLoaded && Date.now() - lastRiskDataFetchAt < SERVER_DATA_CACHE_MS) return;
if (get().riskDataLoaded && Date.now() - lastRiskDataFetchAt < RISK_DATA_CACHE_MS) return;
lastRiskDataFetchAt = Date.now();
set({
...(snapshots ? { snapshots } : {}),
...(insights ? { insights } : {}),
riskDataLoaded: snapshots !== null && insights !== null,
error: snapshots === null || insights === null ? '小宝预警缓存加载失败' : undefined,
riskDataLoaded: true,
error: undefined,
});
},
saveSnapshot: async (item) => {
const optimistic = upsertDailySnapshot(get().snapshots, item);
set({ snapshots: optimistic, error: undefined });
const task = snapshotSaveQueue.then(async () => {
const remote = await loadServerData<XiaobaoRiskSnapshot[]>('xiaobao-risk-snapshots', { force: true });
const snapshots = mergeDailySnapshotCacheForSave(get().snapshots, Array.isArray(remote) ? remote : [], item);
set({ snapshots, error: undefined });
await saveServerData('xiaobao-risk-snapshots', snapshots);
void notifyRiskManagers(item).catch(() => {});
});
snapshotSaveQueue = task.catch(() => undefined);
try {
await task;
} catch (error) {
set({ error: '小宝预警快照保存失败' });
throw error;
}
},
saveInsight: async (item) => {
const optimistic = upsertInsight(get().insights, item);
set({ insights: optimistic, error: undefined });
const task = insightSaveQueue.then(async () => {
const remote = await loadServerData<XiaobaoRiskInsightCacheItem[]>('xiaobao-risk-insights', { force: true });
const insights = mergeInsightCacheForSave(get().insights, Array.isArray(remote) ? remote : [], item);
set({ insights, error: undefined });
await saveServerData('xiaobao-risk-insights', insights);
});
insightSaveQueue = task.catch(() => undefined);
try {
await task;
} catch (error) {
set({ error: '小宝预警解读保存失败' });
throw error;
}
},
beginInsightUpdate: (key) => {
@@ -122,32 +68,3 @@ export const useXiaobaoRiskStore = create<XiaobaoRiskState>((set, get) => ({
set({ pendingInsightKeys: get().pendingInsightKeys.filter((item) => item !== key) });
},
}));
async function notifyRiskManagers(item: XiaobaoRiskSnapshot) {
if (!['at_risk', 'likely_delayed', 'blocked'].includes(item.riskLevel)) return;
const memberStore = useMemberStore.getState();
if (!memberStore.loaded) {
await memberStore.fetchMembers().catch(() => undefined);
}
const state = useMemberStore.getState();
const roleMap = new Map(state.roles.map((role) => [role.id, role]));
const recipients = state.members.filter((member) => {
const permissions = roleMap.get(member.roleId)?.permissions ?? [];
return permissions.includes('*') || permissions.includes('xiaobao.warning:manage');
});
await Promise.all(recipients.map((member) => api.post('/notifications', {
recipientId: member.id,
actorId: 'xiaobao',
type: 'risk_alert',
title: '小宝预警更新',
body: `版本 ${item.versionId} 当前风险 ${item.riskLevel},风险分 ${item.riskScore}`,
resourceType: 'version',
resourceId: item.versionId,
versionId: item.versionId,
metadata: {
riskLevel: item.riskLevel,
riskScore: item.riskScore,
riskSignature: `${item.versionId}:${item.date}:${item.riskScore}:${item.riskLevel}`,
},
}).catch(() => undefined)));
}

View File

@@ -6,7 +6,6 @@ import {
markXiaobaoWarningRead,
type XiaobaoWarningReadState,
} from '@/lib/xiaobao-warning-view';
import { loadServerData, saveServerData, SERVER_DATA_CACHE_MS } from '@/lib/server-data';
interface XiaobaoWarningReadStoreState {
readStates: XiaobaoWarningReadState[];
@@ -16,15 +15,23 @@ interface XiaobaoWarningReadStoreState {
markRiskRead: (userId: string, risk: XiaobaoVersionRisk, readAt?: string) => Promise<void>;
}
let readStateSaveQueue: Promise<void> = Promise.resolve();
const READ_STATE_CACHE_MS = 30_000;
const READ_STATE_STORAGE_KEY = 'ftb_xiaobao_warning_views';
let lastReadStatesFetchAt = 0;
async function loadReadStates(): Promise<XiaobaoWarningReadState[] | null> {
function loadReadStates(): XiaobaoWarningReadState[] {
try {
const rows = await loadServerData<XiaobaoWarningReadState[]>('xiaobao-warning-views');
return normalizeReadStates(rows);
if (typeof window === 'undefined') return [];
return normalizeReadStates(JSON.parse(window.localStorage.getItem(READ_STATE_STORAGE_KEY) || '[]'));
} catch {}
return [];
}
function saveReadStates(rows: XiaobaoWarningReadState[]) {
try {
if (typeof window === 'undefined') return;
window.localStorage.setItem(READ_STATE_STORAGE_KEY, JSON.stringify(rows));
} catch {}
return null;
}
function normalizeReadStates(rows: unknown): XiaobaoWarningReadState[] {
@@ -64,14 +71,14 @@ export const useXiaobaoWarningReadStore = create<XiaobaoWarningReadStoreState>((
error: undefined,
fetchReadStates: async () => {
if (get().readStateLoaded && Date.now() - lastReadStatesFetchAt < SERVER_DATA_CACHE_MS) return;
const rows = await loadReadStates();
if (get().readStateLoaded && Date.now() - lastReadStatesFetchAt < SERVER_DATA_CACHE_MS) return;
if (get().readStateLoaded && Date.now() - lastReadStatesFetchAt < READ_STATE_CACHE_MS) return;
const rows = loadReadStates();
if (get().readStateLoaded && Date.now() - lastReadStatesFetchAt < READ_STATE_CACHE_MS) return;
lastReadStatesFetchAt = Date.now();
set({
readStates: rows ? mergeReadStates(rows, get().readStates) : get().readStates,
readStateLoaded: rows !== null,
error: rows === null ? '小宝预警查看状态加载失败' : undefined,
readStates: mergeReadStates(rows, get().readStates),
readStateLoaded: true,
error: undefined,
});
},
@@ -79,20 +86,6 @@ export const useXiaobaoWarningReadStore = create<XiaobaoWarningReadStoreState>((
if (!userId) return;
const optimistic = markXiaobaoWarningRead(get().readStates, userId, risk, readAt);
set({ readStates: optimistic, error: undefined });
const task = readStateSaveQueue.then(async () => {
const remote = await loadServerData<XiaobaoWarningReadState[]>('xiaobao-warning-views', { force: true });
const merged = mergeReadStates(normalizeReadStates(remote), get().readStates);
set({ readStates: merged, readStateLoaded: true, error: undefined });
await saveServerData('xiaobao-warning-views', merged);
});
readStateSaveQueue = task.catch(() => undefined);
try {
await task;
} catch (error) {
set({ error: '小宝预警查看状态保存失败' });
throw error;
}
saveReadStates(optimistic);
},
}));