Compare commits
1 Commits
1e6fb0c7aa
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2e595c7e72 |
10
.superpowers/sdd/progress.md
Normal file
10
.superpowers/sdd/progress.md
Normal file
@@ -0,0 +1,10 @@
|
||||
Business Analysis Agent implementation progress
|
||||
|
||||
Task 1: complete (commit b6b7ebf, shared contracts)
|
||||
Task 2: complete (thread 019f41cd-e0f5-7e21-b8b3-f5d1c5d398c2, source commit 773505b, integrated commit 04db000)
|
||||
Task 3: complete (thread 019f41cd-e105-7073-9fb6-867f3b37364e, source commit a6ef8e3, integrated commit 889a34c)
|
||||
Task 6: complete (thread 019f41cd-e1a4-7b01-aa8a-a583820507ab, source commit f1958de, integrated commit 89ee444)
|
||||
Task 4: complete (thread 019f41dd-baa5-78d3-8fd6-b82da2896c12, source commit 82fe7b4, integrated commit 7cd18da)
|
||||
Task 5: complete (thread 019f41f2-5597-70b3-a40a-e284e147988f, source commits 11a1009+a83c6f2, integrated commits 56c8f59+c724446, review approved)
|
||||
Task 7: complete (thread 019f4211-0692-7c32-bfe0-87513154b7e5, source commit 5cb2e57, integrated commit 6d29990, review approved)
|
||||
Task 8: complete (thread 019f4482-1902-76a0-ae84-0cbbefa68127, source commit 8f125fb, integrated commit 1e6fb0c, review approved)
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE "version_plans"
|
||||
ADD COLUMN IF NOT EXISTS "tasks" JSONB NOT NULL DEFAULT '[]';
|
||||
@@ -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")
|
||||
|
||||
25
apps/server/src/common/user-reference.ts
Normal file
25
apps/server/src/common/user-reference.ts
Normal 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;
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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('原型链接无效');
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
|
||||
70
apps/server/src/modules/ai/providers/openai.provider.spec.ts
Normal file
70
apps/server/src/modules/ai/providers/openai.provider.spec.ts
Normal 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 },
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
@@ -17,6 +17,7 @@ export interface ToolCallRequest {
|
||||
forceTool: boolean;
|
||||
maxTokens: number;
|
||||
model: string;
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
export interface ToolCallResponse {
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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>;
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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) }),
|
||||
|
||||
@@ -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'));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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) }),
|
||||
|
||||
@@ -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: '前端' }]);
|
||||
|
||||
@@ -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 },
|
||||
];
|
||||
|
||||
@@ -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' }));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
46
apps/server/src/modules/product/product.service.spec.ts
Normal file
46
apps/server/src/modules/product/product.service.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -56,4 +56,12 @@ export class CreateRequirementDto {
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
creatorId?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
productOwnerId?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
productOwner?: string;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 }),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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) }),
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -47,6 +47,9 @@ export class CreateVersionPlanDto {
|
||||
@IsOptional()
|
||||
resultUrl?: string;
|
||||
|
||||
@IsOptional()
|
||||
tasks?: unknown[];
|
||||
|
||||
@IsArray()
|
||||
@IsOptional()
|
||||
linkedRequirementIds?: string[];
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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)) }
|
||||
: {}),
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -45,7 +45,7 @@ export default function ProductDetailPage() {
|
||||
|
||||
useEffect(() => {
|
||||
fetchProduct(productId);
|
||||
fetchRequirements();
|
||||
fetchRequirements({ productId });
|
||||
fetchRecords();
|
||||
}, [productId, fetchProduct, fetchRequirements, fetchRecords]);
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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])}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
21
apps/web/lib/governance-source.test.ts
Normal file
21
apps/web/lib/governance-source.test.ts
Normal 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/);
|
||||
});
|
||||
@@ -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+\}/,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
89
apps/web/lib/no-broad-business-fetch-source.test.ts
Normal file
89
apps/web/lib/no-broad-business-fetch-source.test.ts
Normal 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');
|
||||
});
|
||||
66
apps/web/lib/no-business-appdata-runtime-source.test.ts
Normal file
66
apps/web/lib/no-business-appdata-runtime-source.test.ts
Normal 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 \}\)/);
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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\(\);/);
|
||||
});
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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\)/);
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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'),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ export interface VersionMember {
|
||||
export interface VersionMemberCandidate {
|
||||
id: string;
|
||||
name: string;
|
||||
username?: string;
|
||||
departmentName?: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -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\(\);/);
|
||||
});
|
||||
|
||||
50
apps/web/lib/version-plan-owner-source.test.ts
Normal file
50
apps/web/lib/version-plan-owner-source.test.ts
Normal 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/);
|
||||
});
|
||||
@@ -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',
|
||||
}),
|
||||
]), '1h(0.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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -15,7 +15,7 @@ export interface SelectWorkspaceCollectionsInput {
|
||||
v22Loaded: boolean;
|
||||
v22Failed: boolean;
|
||||
v22Data: V22WorkspaceData | null;
|
||||
appData: {
|
||||
storeData: {
|
||||
plans: VersionPlan[];
|
||||
devTasks: DevTask[];
|
||||
testCases: TestCase[];
|
||||
@@ -27,9 +27,14 @@ 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,
|
||||
@@ -37,11 +42,31 @@ export function selectWorkspaceCollections({
|
||||
bugs: v22Data.bugs,
|
||||
};
|
||||
}
|
||||
return {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
},
|
||||
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);
|
||||
}
|
||||
},
|
||||
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);
|
||||
}
|
||||
},
|
||||
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);
|
||||
}
|
||||
},
|
||||
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);
|
||||
}
|
||||
},
|
||||
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[]) {
|
||||
|
||||
@@ -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,7 +73,6 @@ 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({
|
||||
@@ -95,10 +80,6 @@ export const useDevTaskStore = create<DevTaskState>((set, get) => ({
|
||||
loaded: true,
|
||||
});
|
||||
appendDomainActivities(result.activities);
|
||||
} catch {
|
||||
await saveDevTasksFallback(updated);
|
||||
useWorkActivityStore.getState().addActivity(makeDevTaskCreatedActivity(task, task.createdBy || task.assigneeId));
|
||||
}
|
||||
},
|
||||
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);
|
||||
}
|
||||
},
|
||||
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);
|
||||
}
|
||||
},
|
||||
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);
|
||||
}
|
||||
},
|
||||
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);
|
||||
}
|
||||
},
|
||||
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[]) {
|
||||
|
||||
@@ -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,13 +213,10 @@ 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 });
|
||||
notifyMemberSaveFailure(error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
},
|
||||
deleteMember: (id) => {
|
||||
if (!canDeleteSystemMember(id)) return;
|
||||
@@ -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 服务',
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
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);
|
||||
}
|
||||
},
|
||||
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);
|
||||
}
|
||||
},
|
||||
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 });
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
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);
|
||||
}
|
||||
},
|
||||
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);
|
||||
}
|
||||
},
|
||||
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,7 +277,6 @@ 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) => {
|
||||
@@ -304,9 +289,6 @@ export const useProductStore = create<ProductState>((set, get) => ({
|
||||
}),
|
||||
overviewLoaded: true,
|
||||
});
|
||||
} catch {
|
||||
await saveRootOverviewFallback(updated);
|
||||
}
|
||||
},
|
||||
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);
|
||||
}
|
||||
},
|
||||
expected: updated,
|
||||
getCurrent: () => get().overview,
|
||||
@@ -384,7 +362,6 @@ 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) => {
|
||||
@@ -397,9 +374,6 @@ export const useProductStore = create<ProductState>((set, get) => ({
|
||||
}),
|
||||
overviewLoaded: true,
|
||||
});
|
||||
} catch {
|
||||
await saveRootOverviewFallback(updated);
|
||||
}
|
||||
},
|
||||
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);
|
||||
}
|
||||
},
|
||||
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);
|
||||
}
|
||||
},
|
||||
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);
|
||||
}
|
||||
},
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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,7 +120,6 @@ 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) => (
|
||||
@@ -114,9 +127,6 @@ export const useRequirementStore = create<RequirementState>((set, get) => {
|
||||
)),
|
||||
loaded: true,
|
||||
});
|
||||
} catch {
|
||||
await saveRequirementsFallback({ requirements: updated, sourceTargets, types, platforms });
|
||||
}
|
||||
},
|
||||
expected: updated,
|
||||
getCurrent: () => get().requirements,
|
||||
@@ -125,20 +135,18 @@ 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);
|
||||
@@ -146,9 +154,6 @@ export const useRequirementStore = create<RequirementState>((set, get) => {
|
||||
if (status) {
|
||||
await updateRequirementStatusByProductId(productId, id, status);
|
||||
}
|
||||
} catch {
|
||||
await saveRequirementsFallback({ requirements: updated, sourceTargets, types, platforms });
|
||||
}
|
||||
},
|
||||
expected: updated,
|
||||
getCurrent: () => get().requirements,
|
||||
@@ -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 });
|
||||
}
|
||||
},
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
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);
|
||||
}
|
||||
},
|
||||
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);
|
||||
}
|
||||
},
|
||||
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);
|
||||
}
|
||||
},
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
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);
|
||||
}
|
||||
},
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
},
|
||||
expected: updated,
|
||||
getCurrent: () => get().testCases,
|
||||
@@ -123,7 +106,6 @@ 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]));
|
||||
@@ -136,12 +118,6 @@ export const useTestCaseStore = create<TestCaseState>((set, get) => ({
|
||||
loaded: true,
|
||||
});
|
||||
appendDomainActivities(result.activities);
|
||||
} catch {
|
||||
await saveTestCasesFallback(list);
|
||||
created.forEach((tc) => {
|
||||
useWorkActivityStore.getState().addActivity(makeTestCaseCreatedActivity(tc, tc.createdBy));
|
||||
});
|
||||
}
|
||||
},
|
||||
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);
|
||||
}
|
||||
},
|
||||
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);
|
||||
}
|
||||
},
|
||||
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);
|
||||
}
|
||||
},
|
||||
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[]) {
|
||||
|
||||
@@ -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 服务',
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 }));
|
||||
}
|
||||
},
|
||||
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 });
|
||||
}
|
||||
},
|
||||
expected: updated,
|
||||
getCurrent: () => get().activities,
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -136,7 +136,7 @@
|
||||
|
||||
**输出**:`summary`、`why[]`、`forecast`、`recommendedReleaseWindow`、`suggestedActions[]`、`ownerHints[]`。
|
||||
|
||||
**权限**:读规则结果和压缩证据;写 `xiaobao_risk_insights` 关系表缓存(历史兼容名 `xiaobao-risk-insights`)。不修改 Version、Requirement、DevTask、TestCase、Bug、Member。
|
||||
**权限**:读规则结果和压缩证据;写 `xiaobao_risk_insights` 关系表缓存。历史 AppData 兼容名 `xiaobao-risk-insights` 仅用于迁移/归档,不作为运行时写入目标。不修改 Version、Requirement、DevTask、TestCase、Bug、Member。
|
||||
|
||||
**失败回退**:AI 不可用时保留规则预警,前端显示“规则预警已生成,AI 解读会在触发条件满足时自动补充”。AI 失败不影响快照保存和规则风险展示。
|
||||
|
||||
@@ -285,14 +285,16 @@ AI 草案在 DevTask / TestCase 列表中的视觉区分:
|
||||
- 用户在详情抽屉里编辑保存任意字段后,徽章和左边线自动消失
|
||||
- 无需求ID分组不额外显示“原型发现”等标记,只在分组标题中展示 `无需求ID · {requirementName}`
|
||||
|
||||
## Business Analysis Agent(规划)
|
||||
## Business Analysis Agent(第一版已落地)
|
||||
|
||||
Business Analysis Agent 是只读业务数据分析 Agent,不复用 Prototype Decompose Agent 的草案写入契约,也不复用 Risk Watch Agent 的单版本风险解释契约。
|
||||
|
||||
第一版已接入 `/api/v1/ai/analysis`、`/wenfan-xiaobao` 和产品/项目/版本详情页上下文入口。当前支持 Template Strategy 和 Deterministic Rule Composition;AI Planning 只保留为受控计划建议边界,生成的 `AnalysisPlan` 必须经过 Analysis Plan Processor 校验和规范化后才能执行。图表契约为 Unified ChartSpec,前端通过 ECharts Renderer 渲染。
|
||||
|
||||
入口:
|
||||
|
||||
- `/wenfan-xiaobao`:业务数据分析对话。
|
||||
- 产品、项目、版本详情页:带当前上下文的智能分析入口。
|
||||
- `/wenfan-xiaobao`:业务数据分析对话,优先尝试业务分析,AI 分析不可用时保留内置帮助 fallback。
|
||||
- 产品、项目、版本详情页:带当前上下文的智能分析入口和只读分析 Drawer。
|
||||
|
||||
核心链路:
|
||||
|
||||
@@ -316,5 +318,6 @@ Question + Context
|
||||
- 输出 ChartSpec 是平台统一契约,不是 ECharts option;前端第一版用 ECharts Renderer。
|
||||
- Metric Catalog 记录 metric version;公式或业务口径变化必须升版本。
|
||||
- 输出必须包含 Insight Card、图表、固定结构报告、可点击 Evidence 和只读 Follow-up。
|
||||
- 详情页入口只传 `surface` 和对应上下文 ID;后端基于当前用户和上下文再次收窄范围,不能只信任前端。
|
||||
|
||||
视觉规范见 `docs/superpowers/specs/2026-07-08-business-analysis-agent-design.md` 的 AI Analysis Design System。
|
||||
|
||||
@@ -33,7 +33,7 @@ Requirement Version TestCase
|
||||
| 前端 | Next.js 14 (App Router) | TypeScript + 客户端组件为主 |
|
||||
| UI | Tailwind CSS + Shadcn/ui | 紧凑信息密度、现代风格 |
|
||||
| 状态 | Zustand | 每个领域一个 store |
|
||||
| 持久化 | PostgreSQL 关系表领域主写 + AppData 兼容兜底(V2.4/V2.5) | 高增长领域直接写关系表;AppData 进入禁写、核对、归档退场阶段 |
|
||||
| 持久化 | PostgreSQL 关系表领域主写 + AppData 归档/配置例外 | 高增长和核心业务运行时走关系表;AppData 仅保留迁移归档与少量低频配置 |
|
||||
| 后端 | NestJS + Prisma + PostgreSQL(V2.5) | Product/Project/Version/Requirement/VersionPlan/DevTask/TestCase/Bug/Member/TaskCategory/TaskWorklog/Overtime/WorkActivity 均有领域 CRUD,V2.5 收口 RBAC/审计/一致性 |
|
||||
| AI | Anthropic SDK(V3 远景) | 健康度/风险预警/排期建议 |
|
||||
|
||||
@@ -114,12 +114,12 @@ DevTask 没有"已完成"状态,"已提测"就是终态——开发交付完
|
||||
|
||||
## 数据持久化
|
||||
|
||||
**当前 V2.5 分层:** 领域 CRUD 主写 + AppData 禁写/核对/归档退场
|
||||
**当前 V2.5+ 分层:** 领域 CRUD 主写 + AppData 归档/配置例外
|
||||
|
||||
领域主写层已经覆盖主要业务实体:
|
||||
- 根数据:Product、Project、Version 直接写领域 API,`products-overview` 只作为兼容读取/兜底。
|
||||
- 根数据:Product、Project、Version 直接读写领域 API;正常业务运行时不再读取 `products-overview`,该文档只用于迁移、归档导出和人工核对。
|
||||
- 需求池:Requirement 直接按 `productId` 分区键写 `requirements`,列表/search/filter/sort 使用服务端分页。
|
||||
- 版本详情:VersionPlan、DevTask、TestCase、Bug 直接按 `versionId` 分区键写关系表,并继续标脏 Xiaobao 摘要和写入工作活动证据。
|
||||
- 版本详情:VersionPlan、DevTask、TestCase、Bug 直接按 `versionId` 分区键读写关系表,并继续标脏 Xiaobao 摘要和写入工作活动证据。
|
||||
- 字典/成员/证据:Member 写 `users` 的成员身份字段;TaskCategory 写 `task_categories`;TaskWorklog、OvertimeRecord、WorkActivity 保持追加/证据型关系表写入。
|
||||
|
||||
V2.5 控制面新增三类横切能力:
|
||||
@@ -129,22 +129,22 @@ V2.5 控制面新增三类横切能力:
|
||||
- 一致性:`GET /api/v1/consistency` 与 `pnpm consistency:v25` 检查 counts、分区键、孤儿引用和审计覆盖。历史数据没有审计事件时只报 warning,不阻断关系表主源运行。
|
||||
|
||||
兼容层仍保留通用服务端文档表 `app_data`:
|
||||
- 后端:`apps/server/src/modules/data/` 提供 `GET/PUT /api/v1/data/:key`;V2.5 起所有业务 key 都由 `AppDataRetirementService` 标记为 `write_frozen` 或 `read_only_archive`,冻结写入返回 `409 APP_DATA_WRITE_FROZEN` 并给出替代领域 API。
|
||||
- 后端:`apps/server/src/modules/data/` 提供 `GET/PUT /api/v1/data/:key` 给迁移、归档、配置例外使用;已迁移业务 key 由 `AppDataRetirementService` 标记为 `write_frozen` 或 `read_only_archive`,冻结写入返回 `409 APP_DATA_WRITE_FROZEN` 并给出替代领域 API。
|
||||
- 数据库:Prisma `AppData` 模型,表名 `app_data`,`key` 为主键,`value` 为 JSONB
|
||||
- 一致性:`GET` 返回 `updatedAt` 派生的 `version`;前端保存时带上最近读取的 `version`,后端用 `key + updatedAt` 原子更新,版本不匹配返回 `409 APP_DATA_CONFLICT`
|
||||
- 前端:各 Zustand store 保持现有 UI 数据形状,优先调用 `apps/web/lib/domain-api.ts`;`apps/web/lib/server-data.ts` 只保留兼容读取和冻结写入错误处理,不再作为业务保存 fallback。
|
||||
- 仍留在 AppData 历史形状中的内容:部门、角色、密码规则、加班原因等尚未拆出独立 RBAC/配置表的低频配置。`members` 与 `overtime` AppData key 已禁写;这些配置的独立表/API 归 V2.7 管理治理阶段承接。
|
||||
- 前端:各 Zustand store 保持现有 UI 数据形状,正常业务读写优先调用 `apps/web/lib/domain-api.ts` 和 V2.2 快读 API;`apps/web/lib/server-data.ts` 不再作为业务运行时 fallback。
|
||||
- 仍留在 AppData 历史形状中的内容:部门、角色、密码规则、加班原因等尚未拆出独立 RBAC/配置表的低频配置。成员身份已由 `/api/v1/members` 主写关系表,`members` AppData 只保存配置字段,写入时不再夹带成员身份数组;加班记录已由 `/api/v1/overtime` 主写关系表,`overtime` AppData 只保存原因配置。
|
||||
- 浏览器仅保留登录会话(`ftb_auth_session` / `ftb_auth_persist`),不再作为业务数据主存储
|
||||
|
||||
关系表层包含 V2.2-V2.4 能力:
|
||||
- V2.2:高增长业务表使用分区表,并提供版本详情、需求池、工作台和小宝预警的快读 API。
|
||||
- V2.3:AppData 保存成功后触发关系表同步,让快读路径保持新鲜;同步失败只记日志,不阻塞用户保存。
|
||||
- V2.4:领域 CRUD 成为主写入路径,AppData 写桥保留给历史数据和回滚兜底。不要恢复业务 localStorage 缓存,避免线上部署后出现多端数据分叉。
|
||||
- V2.4:领域 CRUD 成为主写入路径,AppData 写桥只服务历史迁移窗口。当前运行时不要恢复业务 AppData 或 localStorage fallback,避免线上部署后出现多端数据分叉。
|
||||
|
||||
**目标主源(新方向):** 业务主数据必须落到 PostgreSQL 领域关系表。
|
||||
- `app_data` 不再作为长期事实源,只保留迁移、回填、兼容读取和故障排查价值。
|
||||
- `app_data` 不再作为长期事实源,只保留迁移、回填、归档导出、低频配置例外和故障排查价值。
|
||||
- 新增业务模块不得新增 AppData key 作为主存储;必须先设计关系表、Prisma model 和领域 CRUD API。
|
||||
- 现有 AppData key 需要逐步完成一次性迁移、双读校验、关系表写入切换和 JSON fallback 移除。
|
||||
- 现有 AppData key 需要逐步完成一次性迁移、双读校验、关系表写入切换和运行时 JSON fallback 移除。
|
||||
- 前端 store 可以继续保留 Zustand 状态形状,但持久化入口要从 `saveServerData(key)` 迁到领域 API。
|
||||
- `app_data` 删除前必须有备份/导出和数据量核对,不能直接丢弃历史 JSON。
|
||||
|
||||
@@ -281,7 +281,7 @@ The rule surface stays in pure frontend engines:
|
||||
|
||||
Managers with `xiaobao.warning:manage` can see all unfinished versions. Non-managers with `xiaobao.warning:view` can only see unfinished versions where the current user is in `version.members`.
|
||||
|
||||
AI explains rule results only. It writes interpretation cache to `xiaobao-risk-insights` / `xiaobao_risk_insights` and never mutates Version, Requirement, DevTask, TestCase, Bug, or Member data. Risk snapshots are saved to `xiaobao-risk-snapshots` when the page is opened.
|
||||
AI explains rule results only. It writes interpretation cache to the relation table `xiaobao_risk_insights` and never mutates Version, Requirement, DevTask, TestCase, Bug, or Member data. Historical AppData keys `xiaobao-risk-insights` and `xiaobao-risk-snapshots` are read-only archive inputs for migration/export, not normal frontend runtime cache.
|
||||
|
||||
V2.6 moves the current risk summary refresh to the server:
|
||||
|
||||
@@ -290,9 +290,9 @@ V2.6 moves the current risk summary refresh to the server:
|
||||
- Domain writes that produce work activity already mark the affected version dirty; V2.6 also enqueues a deduped refresh job. Plain update/delete paths for version plans, dev tasks, test cases, and bugs explicitly mark the version dirty as well.
|
||||
- `XiaobaoAiService` evaluates the refreshed summary and enqueues `xiaobao.ai.interpret` when policy allows. The worker reloads the latest summary, skips stale signatures, calls the existing `AiService.interpretRisk()` prompt path, and writes only `xiaobao_risk_insights`.
|
||||
- AI interpretation cache uses the summary `riskSignature`, exact cache reuse, a six-hour cooldown, and risk-level escalation bypass. Until the server has full daily trend snapshots, `attention` summaries trigger server-side AI only when release is within one day and unfinished work remains.
|
||||
- Frontend `/xiaobao-warning` consumes V2.2 summary reads first. The V2.2 response includes the latest generated relation-table AI insight when available, and the frontend prefers that insight over legacy AppData insight cache. It only falls back to AppData risk calculation when summaries are empty or unavailable.
|
||||
- Frontend `/xiaobao-warning` consumes V2.2 summary reads first. The V2.2 response includes the latest generated relation-table AI insight when available, and the frontend prefers that relation insight. If summaries are empty or unavailable, the page asks the relation-backed stores/domain APIs for scoped data instead of reloading heavy business AppData documents.
|
||||
|
||||
Per-user warning read state is saved to `xiaobao-warning-views`. The read marker stores `userId + versionId + risk signature`, so the sidebar can turn the Xiaobao badge blue when any visible risk has a completed unread update, then return to the red risk-count badge after the user opens every updated warning. AI interpretation that is still generating only shows the "updating" notice and must not produce the blue update badge yet.
|
||||
Per-user warning read state no longer writes `xiaobao-warning-views` AppData at runtime. Until a dedicated per-user read-state API lands, the frontend keeps this as browser-local UI state keyed by `userId + versionId + risk signature`, so the sidebar can turn the Xiaobao badge blue when any visible risk has a completed unread update, then return to the red risk-count badge after the user opens every updated warning. AI interpretation that is still generating only shows the "updating" notice and must not produce the blue update badge yet.
|
||||
## V2.2 Partitioned Domain Data Layer (2026-07-03)
|
||||
|
||||
V2.2 starts the move from AppData JSON documents to relation tables for high-volume domains. The first database foundation is partitioned from the start so future growth does not require a disruptive rewrite of primary keys, unique constraints, and foreign-key references.
|
||||
@@ -346,11 +346,11 @@ The server also has lightweight observability for this phase: a global API timin
|
||||
Current source-of-truth boundary:
|
||||
|
||||
- Product, Project, Version, Requirement, VersionPlan, DevTask, TestCase, Bug, Member, TaskCategory, TaskWorklog, OvertimeRecord, and WorkActivity now have public domain CRUD/write APIs.
|
||||
- Frontend stores use domain APIs as the primary mutation path. AppData reads remain for archive/fallback inspection, but business AppData writes are frozen server-side and return `APP_DATA_WRITE_FROZEN`.
|
||||
- `products-overview` is no longer the product/project/version tree source of truth; it remains a compatibility document for fallback reads and rollback.
|
||||
- V2.2 read APIs and V2.3 relation sync remain compatibility infrastructure for fast reads, historical AppData imports, and rollback. They are no longer the main proof of data freshness for domains that now write relation tables directly.
|
||||
- Frontend stores use domain APIs as the runtime mutation path. Migrated business stores do not call `loadServerData` / `saveServerData` for `products-overview`, `requirements`, `version-plans`, `dev-tasks`, `test-cases`, `bugs`, `task-categories`, `task-worklogs`, or `work-activities`.
|
||||
- `products-overview` is no longer the product/project/version tree source of truth and is not used for normal fallback reads. It remains only as a historical AppData document for migration rehearsal, archive export, and manual incident inspection.
|
||||
- V2.2 read APIs and V2.3 relation sync remain compatibility infrastructure for fast reads and historical AppData imports. Runtime data freshness for migrated domains is proven by relation-table domain writes and source-contract tests, not by AppData sync.
|
||||
- `packages/shared` status contracts have been aligned with the current workflow statuses before the V2.4 write switch.
|
||||
- V2.5 boundary: audit/RBAC/consistency are active on domain writes. Xiaobao risk snapshots/insights and warning read-state AppData keys are read-only archives pending V2.6 relation writer/backgrounding and V2.7 per-user read-state API.
|
||||
- V2.5+ boundary: audit/RBAC/consistency are active on domain writes. AppData active exceptions are limited to `members` configuration (departments, roles, password rule) and `overtime` reason configuration; member identity and overtime records stay relation-backed. Xiaobao risk snapshots/insights and warning read-state AppData keys are historical archive inputs only and are not used by normal frontend runtime.
|
||||
|
||||
## V2.7 Enterprise Collaboration And Governance Layer (2026-07-08)
|
||||
|
||||
@@ -360,7 +360,8 @@ V2.7 adds enterprise collaboration capabilities on top of the relational source-
|
||||
- `comments`: polymorphic comments for `dev_task / test_case / bug / requirement / version_plan`, with mention metadata and soft deletion.
|
||||
- `project_members`: project-level Owner/Admin/Member/Viewer governance, now exposed through server-enforced APIs.
|
||||
- `audit_logs`: append-only governance and collaboration audit events.
|
||||
- `governance_dictionaries`: centralized requirement type/platform/source dictionaries; task categories continue to use `task_categories`.
|
||||
- `governance_dictionaries`: centralized requirement type/platform/source dictionaries; the Requirement Pool consumes these dictionaries and no longer exposes local dictionary management drawers.
|
||||
- Task categories continue to use `task_categories`, but the visible admin entry is `/admin/governance` so development and testing task types are managed together with requirement dictionaries.
|
||||
|
||||
V2.7 uses stable server adapters for collaboration and governance modules:
|
||||
|
||||
|
||||
@@ -442,7 +442,7 @@
|
||||
- AI 解读自动触发,不提供人工“AI 解读”按钮。缓存签名必须覆盖趋势、原因、静默风险、日报/活动证据、风险信号和置信度,避免复用过期解读。
|
||||
- 快照保存需要节流:同版本同日普通变化 10 分钟内不重复保存;风险等级变化、风险分变化达到阈值、关键 Bug/失败用例/阻塞/静默风险变化或预测日期明显变化时立即保存。
|
||||
- AI 解读需要 cooldown:同版本最近 6 小时内已有解读时不重复请求;如果风险等级升级,则允许绕过 cooldown。
|
||||
- AI 只写入 `xiaobao-risk-insights` 缓存,不修改 Version、DevTask、TestCase、Bug、Requirement 或 Member。
|
||||
- AI 只写入 `xiaobao_risk_insights` 关系表缓存,不修改 Version、DevTask、TestCase、Bug、Requirement 或 Member;历史 `xiaobao-risk-insights` AppData 只保留归档/迁移价值。
|
||||
|
||||
**理由**:规则结果可测试、可追溯、可复盘;AI 文案提升可读性,但不能替代系统事实判断。趋势、静默风险和置信度能弥补“当前风险等级”过于静态的问题。
|
||||
|
||||
@@ -569,14 +569,15 @@
|
||||
- 需求池列表/search/filter/sort 走服务端分页,避免加载全量 AppData 文档。
|
||||
- 版本详情实体按 `versionId` 分区键写入;Requirement 按 `productId` 分区键写入;追加型证据表保留追加语义,不从 AppData 快照反向删除历史。
|
||||
- `/workspace` 和 `/xiaobao-warning` 继续走关系表聚合/快读。领域写成功后通过工作活动和小宝 dirty 标记维持证据链。
|
||||
- AppData 保留为兼容读取、失败回退、历史迁移和少量配置承载,不再作为已迁移领域的事实源。
|
||||
- 成员迁移采用保守边界:`users` 存成员身份字段;部门、角色、密码规则暂不在本阶段发明完整 RBAC 表,仍作为 AppData 兼容配置。
|
||||
- AppData 在 V2.4 迁移窗口保留为兼容读取、失败回退、历史迁移和少量配置承载,不再作为已迁移领域的事实源。
|
||||
- 当前 V2.5+ 收口后,正常业务 UI 不再把 AppData 作为失败回退;已迁移业务 store 运行时不读写 `products-overview`、`requirements`、`version-plans`、`dev-tasks`、`test-cases`、`bugs`、`task-categories`、`task-worklogs` 和 `work-activities`。
|
||||
- 成员迁移采用保守边界:`users` 存成员身份字段;部门、角色、密码规则暂不在本阶段发明完整 RBAC 表,仍作为 AppData 配置。`members` AppData 不再作为成员身份来源,也不再写入当前成员数组。
|
||||
- 加班原因同样暂留 AppData 配置;加班记录本身写 `overtime_records`。
|
||||
|
||||
**理由**:
|
||||
- 领域 CRUD 直接写关系表后,读写路径对齐,分页、筛选、聚合和风险预警不再依赖 AppData 同步是否及时。
|
||||
- 分区键进入每次领域写入,能维持 V2.2 分区表设计的查询边界。
|
||||
- AppData fallback 让迁移可回滚、可兼容旧数据,但不再制造长期双事实源。
|
||||
- 迁移期 AppData fallback 让迁移可回滚、可兼容旧数据;当前收口后必须移除正常业务运行时 fallback,避免继续制造双事实源。
|
||||
- RBAC/配置表会影响权限模型和管理流程,单独成阶段更安全;V2.4.5 只收口当前高频业务写入,避免为了“全收口”临时设计不稳的权限 schema。
|
||||
|
||||
## 45. V2 后端关系化采用八阶段交付链路
|
||||
@@ -614,13 +615,14 @@
|
||||
**问题**:V2.4 已经把主要领域写入迁到关系表,但 AppData 里仍保存历史 JSON、旧部署 fallback 和少量尚未领域化的配置形状。如果直接删除 `app_data` 或移除 `/data/:key`,会失去回滚、迁移核对和历史排查依据;如果继续允许写入,又会把双事实源问题拖进 V2.6。
|
||||
|
||||
**决策**:
|
||||
- 每个 AppData key 明确进入 `write_frozen` 或 `read_only_archive`,由 `AppDataRetirementService` 集中配置替代 API 和说明。
|
||||
- 每个 AppData key 由 `AppDataRetirementService` 集中声明退场状态、替代 API 和说明;已迁移业务 key 进入 `write_frozen` 或 `read_only_archive`,`members` 仅作为部门、角色、密码规则的临时配置文档保持 `active`,`overtime` 仅作为加班原因配置文档保持 `active`。
|
||||
- 正常业务前端不得从 AppData 读取已迁移业务 key;成员身份读取走 `/api/v1/members`,加班记录读取走 `/api/v1/overtime`。
|
||||
- 冻结 key 的 `PUT /api/v1/data/:key` 返回 `409 APP_DATA_WRITE_FROZEN`;`GET` 继续可用,用于历史读取、归档导出和人工核对。
|
||||
- Product/Project/Version/Requirement/VersionPlan/DevTask/TestCase/Bug/Member/TaskCategory/TaskWorklog/Overtime/WorkActivity 等领域 mutation 全部使用 `@ProtectedMutation()`,同一个装饰器组合权限、资源作用域和审计写入。
|
||||
- `audit_events` 采用 append-only 模型,按 `created_at` 分区;敏感字段由 AuditService 脱敏,查询接口需要 `audit:view`。
|
||||
- 一致性校验同时提供 `GET /api/v1/consistency` 和 `pnpm consistency:v25`,检查 counts、分区键、孤儿引用和审计覆盖。历史数据缺少审计事件只作为 warning,不把迁移前事实误判为当前写路径错误。
|
||||
- 当前 server auth context 先用 `x-ftb-user-*` 头作为稳定 adapter,前端从现有登录会话补齐这些头;正式 JWT/NextAuth 服务端验证留给后续认证治理阶段。
|
||||
- Xiaobao risk snapshots/insights 的 AppData key 进入 `read_only_archive`,关系表写入和后台化归 V2.6;`xiaobao-warning-views` 读状态 API 归 V2.7。
|
||||
- Xiaobao risk snapshots/insights 的 AppData key 进入 `read_only_archive`,正常前端运行时不得读写;关系表写入和后台化归 V2.6。`xiaobao-warning-views` 同样只保留归档价值,当前前端已读状态在专用 API 落地前仅作为浏览器本地 UI 状态。
|
||||
|
||||
**理由**:冻结写入能立即切断新的双主源风险,同时保留旧 JSON 的审计和回滚价值。把权限和审计合并到领域 mutation 装饰器,可以确保后续新增写接口默认带服务端 guard 和 audit event。审计覆盖对历史数据只告警,避免为了“补齐历史审计”伪造事件。auth header adapter 给 V2.5 一个可测试的服务端权限边界,但不把它包装成最终安全方案,后续 JWT/企业 RBAC 可以替换 adapter 而不改领域 controller 合同。
|
||||
|
||||
@@ -683,15 +685,14 @@
|
||||
|
||||
## 52. V2.7 协作治理先落稳定适配器,不硬编码临时权限
|
||||
|
||||
**问题**:V2.7 需要通知、评论、项目成员治理、管理驾驶舱和治理字典。如果各 V2.7 模块直接写临时权限判断和审计插入,就会绕开 V2.5 已落地的服务端权限、审计和资源作用域边界,后续认证治理也会再次返工。
|
||||
**问题**:V2.7 需要通知、评论、项目成员治理和管理驾驶舱。如果各 V2.7 模块直接写临时权限判断和审计插入,就会绕开 V2.5 已落地的服务端权限、审计和资源作用域边界,后续认证治理也会再次返工。
|
||||
|
||||
**决策**:
|
||||
- 新增 `RbacService` 作为项目角色与全局权限断言适配器,Owner/Admin/Member/Viewer 的层级判断和 `management:view` / `governance:manage` 等全局权限入口集中在此处。
|
||||
- 新增 `RbacService` 作为项目角色与全局权限断言适配器,Owner/Admin/Member/Viewer 的层级判断和 `management:view` 等全局权限入口集中在此处。
|
||||
- 新增 `AuditService` 作为审计写入适配器,业务模块只提交 `actorId/action/resource/before/after`。
|
||||
- 通知事件类型固定为 `assignment / mention / risk_alert / overdue_item`,跨模块通过这些稳定语义发通知。
|
||||
- 通用评论使用 `entityType + entityId + entityVersionId` 的多态引用,不给每个业务表单独建评论表。
|
||||
- 管理驾驶舱只读关系表和 `xiaobao_risk_summaries`,不回读 AppData。
|
||||
- 治理字典使用软删除或使用中禁止硬删,变更必须写审计。
|
||||
|
||||
**理由**:适配器把协作治理模块的权限和审计接入点收束在一层,既能复用 V2.5 的服务端控制面,也给后续 JWT/NextAuth 和企业级角色体系留下替换点。稳定事件名和多态评论引用能避免后续模块继续扩散 ad-hoc 字段。
|
||||
|
||||
@@ -716,3 +717,17 @@
|
||||
- 权限红线:Analysis Agent 只能查询当前用户已有权限的数据,自然语言不能扩大范围;无权限时拒绝或返回授权范围内的空结果。
|
||||
|
||||
**理由**:Semantic Layer 和 Metric Catalog 能把自然语言、业务口径和数据库字段解耦;metric version 和 result snapshot 能支撑历史分析复现;Analysis Plan Processor 保证 AI proposal 不直接变成系统执行;统一 ChartSpec 和 MetricResult 让 ECharts 只是当前 renderer,而不是长期数据契约。这样第一版可以靠固定模板稳定交付,后续又能通过确定性组合和受控 AI Planning 扩展能力。
|
||||
|
||||
## 54. 任务类型与需求池字典统一进入治理设置
|
||||
|
||||
**问题**:需求池类型/来源/支持端、开发任务类型和测试用例任务类型都属于可复用业务字典。如果分别在需求池、版本详情和治理设置维护,会出现多个入口、口径不一致和使用中 ID 难以追踪的问题。
|
||||
|
||||
**决策**:
|
||||
- 恢复 `/admin/governance` 作为统一治理设置入口,由 `governance:manage` 控制。
|
||||
- 治理设置统一维护四类字典:`task_category`、`requirement_type`、`requirement_platform`、`requirement_source`。
|
||||
- 开发任务类型和测试用例任务类型继续落在 `task_categories`,但通过治理设置入口统一维护。
|
||||
- 需求池类型、来源和支持端优先读写 `/api/v1/governance/dictionaries`,不再通过需求池本地管理抽屉维护。
|
||||
- 后端治理列表运行时不再从旧 `requirements` AppData 回填字典。需求池字典以 `governance_dictionaries` 为准;任务类型为空时只种内置默认 `task_categories`,历史 AppData 回填只能作为显式迁移工具执行。
|
||||
- Business Analysis Agent 可以读取需求类型/来源作为分析维度,但不负责修改字典。
|
||||
|
||||
**理由**:这些字典会被需求池、版本执行任务、测试用例和 AI 分析共同消费,放在治理设置能形成单一维护入口。运行时不再读取 AppData 回填字典,可以避免部署后旧 JSON 把关系表配置反向污染;历史兼容由迁移工具承担,而不是页面访问时隐式发生。
|
||||
|
||||
@@ -4,13 +4,13 @@
|
||||
|
||||
V2.8 已在既有生产 CI/CD 基线上补齐运维闭环:备份恢复演练、发布 smoke test、监控告警、日志检索、迁移回滚 runbook、AppData 退场 runbook、小宝后台化 runbook 和生产 readiness 证据清单。本阶段不重新设计业务流程,专注把生产发布和故障处置做成可验证、可复盘、可回滚的标准流程。
|
||||
|
||||
V2.4 已将高增长和核心业务领域从“AppData 主写 + 关系表同步副本”推进到“领域 CRUD 主写关系表 + AppData 兼容/迁移兜底”。V2.5 已收口后端权限、审计、AppData 禁写和一致性核对。V2.6 已完成大数据性能增强、小宝风险后台化、AI 解读队列和运行时 Ops 看板。V2.7 已补齐企业协作和治理能力,且不新增 AppData 主存储。
|
||||
V2.4 已将高增长和核心业务领域从“AppData 主写 + 关系表同步副本”推进到“领域 CRUD 主写关系表”。V2.5 已收口后端权限、审计、AppData 禁写和一致性核对,并移除正常业务运行时 AppData fallback。V2.6 已完成大数据性能增强、小宝风险后台化、AI 解读队列和运行时 Ops 看板。V2.7 已补齐企业协作和治理能力,且不新增 AppData 主存储。
|
||||
|
||||
### V2.5-V2.8 完成范围
|
||||
|
||||
1. **RBAC 收口**:领域 mutation API 已接入服务端权限校验、资源作用域和当前用户上下文。
|
||||
2. **审计事件**:领域 mutation 通过 `audit_events` 写 append-only audit event,支持后台查询和敏感字段脱敏。
|
||||
3. **AppData 禁写**:业务 AppData key 已进入 `write_frozen` 或 `read_only_archive`,读仍可用,写返回明确替代领域 API。
|
||||
3. **AppData 禁写**:业务 AppData key 已进入 `write_frozen` 或 `read_only_archive`,正常业务 UI 不再读取这些 key;`GET` 仅用于迁移、归档和人工核对,写返回明确替代领域 API。
|
||||
4. **导出归档**:已提供 AppData archive export/verify 脚本,包含 checksum、key list 和应用版本元数据。
|
||||
5. **一致性校验**:已提供 counts、partition key、orphan refs、audit coverage 的本地脚本和后台页面。
|
||||
6. **管理端可视化**:已补 `/admin/audit` 与 `/admin/consistency`,并由 `audit:view` / `consistency:view` 控制。
|
||||
@@ -22,7 +22,7 @@ V2.4 已将高增长和核心业务领域从“AppData 主写 + 关系表同步
|
||||
12. **通用评论**:DevTask/TestCase/Bug/Requirement/VersionPlan 已接入统一评论面板,支持 `@成员名`、显式成员选择、删除和审计。
|
||||
13. **项目成员治理**:已补项目成员 Owner/Admin/Member/Viewer 服务端治理,禁止移除最后 Owner,角色变更写审计。
|
||||
14. **管理驾驶舱**:已补只读关系表和 summary 的管理概览,聚合活跃版本、逾期、阻塞、风险和成员负载。
|
||||
15. **治理设置**:已补 task category、requirement type/platform/source 等治理字典能力,使用中的字典不可硬删,支持导入导出。
|
||||
15. **治理设置**:`/admin/governance` 统一维护任务类型、需求类型、支持端与需求来源;需求池消费治理字典,不再本地维护这些字典。
|
||||
16. **备份恢复自动化**:已补 PostgreSQL dump、`server_data` volume 备份、fresh DB restore dry-run 和显式覆盖确认。
|
||||
17. **发布 smoke test**:已补部署后 runtime version、前端根页、产品页、产品 API、V2.2 读路径和 AI 配置校验。
|
||||
18. **监控告警基线**:已补 Prometheus/Grafana/Loki/Promtail 可选 profile,覆盖慢 API、慢 Prisma、任务失败、小宝摘要 stale、磁盘压力和 DB 可用性。
|
||||
@@ -47,12 +47,12 @@ V2.4 已将高增长和核心业务领域从“AppData 主写 + 关系表同步
|
||||
### 当前状态快照(2026-07-08)
|
||||
|
||||
- 项目已经不是早期骨架。前端业务功能已覆盖产品、项目、版本详情、需求池、工作台、成员/角色/任务类型、加班、小宝预警和 AI 配置等主要管理端路由。
|
||||
- 版本详情已有需求、调研、产品方案、UI、开发任务、测试用例、Bug、概览等核心 Tab;渲染重的路径优先接入关系表快读,并保留 AppData fallback。
|
||||
- 版本详情已有需求、调研、产品方案、UI、开发任务、测试用例、Bug、概览等核心 Tab;渲染重的路径优先接入关系表快读,快读不可用时改走关系表领域 store,不再回读业务 AppData 文档。
|
||||
- 后端已落地 Product、Project、Version、Requirement、VersionPlan、DevTask、TestCase、Bug、Member、TaskCategory、TaskWorklog、Overtime、WorkActivity 领域 CRUD/write API。
|
||||
- Prisma schema 已包含 Product、Project、Version、Requirement、VersionPlan、DevTask、TestCase、Bug、WorkActivity、TaskWorklog、Overtime、Xiaobao、AiLog、AppData、Notification、Comment、ProjectMember、GovernanceDictionary 等关系模型;高增长表的分区 migration 已落地。
|
||||
- 主写入源已经切到领域 API:前端 store 优先调用 `apps/web/lib/domain-api.ts`,AppData 只保留兼容读取、历史核对和少量旧配置形状。
|
||||
- 主写入源已经切到领域 API:前端 store 调用 `apps/web/lib/domain-api.ts`,AppData 只保留历史核对、归档导出和少量旧配置形状。
|
||||
- V2.5 后端服务端权限、审计和一致性控制面已启用:领域 mutation 使用 `@ProtectedMutation()`,审计写 `audit_events`,后台查询需要 `audit:view` / `consistency:view`。
|
||||
- 所有业务 AppData key 已明确冻结或只读归档;`PUT /api/v1/data/:key` 对这些 key 返回 `APP_DATA_WRITE_FROZEN`,`GET` 留作历史核对与归档。
|
||||
- 所有业务 AppData key 已明确冻结或只读归档;`PUT /api/v1/data/:key` 对这些 key 返回 `APP_DATA_WRITE_FROZEN`,`GET` 留作迁移、历史核对与归档,不再服务正常业务 fallback。
|
||||
- 需求池已切到服务端分页、搜索、筛选、排序,不再要求加载全量 AppData 文档。
|
||||
- `packages/shared` 状态契约已统一为当前业务状态机。
|
||||
- V2.6/V2.7 协调边界:Xiaobao risk snapshots/insights 关系表写入和后台化归 V2.6;warning read-state API、部门/角色/密码规则/加班原因配置表归 V2.7。
|
||||
@@ -61,21 +61,26 @@ V2.4 已将高增长和核心业务领域从“AppData 主写 + 关系表同步
|
||||
- V2.6.3 已新增 PostgreSQL-backed `background_jobs` 运行时、去重/lease/retry 语义和 jobs 单元测试。
|
||||
- V2.6.4 已新增服务端小宝风险 summary refresh、后台 job handler,以及领域写入 dirty/enqueue 桥接。
|
||||
- V2.6.5 已新增服务端小宝 AI 解读队列,summary 刷新后按 signature/cooldown/escalation policy 入队,只写 `xiaobao_risk_insights` 缓存。
|
||||
- 小宝历史 AppData 缓存/已读状态已从正常前端运行时移除;`xiaobao-risk-snapshots`、`xiaobao-risk-insights`、`xiaobao-warning-views` 只作为归档/迁移读取入口。
|
||||
- V2.6.6 已新增 `/admin/ops` 运行时看板和 `GET /api/v1/ops/runtime`,展示慢请求、慢查询、job 队列和 dirty summary 数。
|
||||
- V2.7.1 已新增通知和已读状态,前端 `NotificationBell` 可展示 assignment / mention / risk_alert / overdue_item。
|
||||
- V2.7.2 已新增通用评论和提及能力,覆盖 DevTask/TestCase/Bug/Requirement/VersionPlan。
|
||||
- V2.7.3 已新增项目成员治理 API 和项目页成员面板,服务端强校验 Owner/Admin/Member/Viewer 边界。
|
||||
- V2.7.4 已新增管理驾驶舱和治理设置,聚合关系表指标并维护治理字典。
|
||||
- V2.7.4 已新增管理驾驶舱和治理设置,任务类型与需求池字典统一进入治理入口管理。
|
||||
- V2.7.5 已新增协作治理 RBAC/audit adapter,避免新增模块绕开服务端权限和审计边界。
|
||||
- V2.8 新增运维交付物集中在 `scripts/`、`.github/workflows/deploy-production.yml`、`deploy/monitoring/`、`docs/runbooks/`、`docs/deployment.md` 和 `docs/production-readiness.md`。
|
||||
- V3.1 Prototype Decompose Agent 已落地:后端 `/api/v1/ai/decompose`、前端产品方案 Tab 拆解入口、对账报告、去重、采纳草案、AI 估时、推荐负责人和无需求ID分组均已接入。
|
||||
- V3.2 Risk Watch Agent 已落地:服务端小宝 summary 刷新后按 policy 排入 `xiaobao.ai.interpret`,AI 解读只写 `xiaobao_risk_insights`,V2.2 小宝快读返回最新 generated insight,前端优先展示关系表 AI 解读。
|
||||
- V3.4 Business Analysis Agent 第一版已落地:共享分析契约、后端 `/api/v1/ai/analysis`、Semantic Layer、Metric Catalog、Metric Engine、Unified ChartSpec、ECharts Renderer、AI 助手业务分析对话,以及产品/项目/版本详情页上下文入口已接入。
|
||||
- AppData 退场、RBAC/审计、性能、小宝后台化、协作治理和生产发布都通过 production readiness 证据项追踪,避免把运维稳定版误当成一次性口头验收。
|
||||
|
||||
### 已完成(按时间倒序)
|
||||
|
||||
**2026-07-09**
|
||||
- V3.4 Business Analysis Agent 第一版落地:新增共享分析契约、后端只读分析管线、权限收窄、指标引擎、ECharts 渲染边界、AI 助手分析结果展示和产品/项目/版本详情页分析入口。
|
||||
|
||||
**2026-07-08**
|
||||
- V3.2 completed Risk Watch Agent read closure: `/api/v1/v2.2/xiaobao-warning` now attaches the latest generated relation-table AI insight, and the frontend merges relation-backed insights ahead of legacy AppData insight cache.
|
||||
- V3.2 completed Risk Watch Agent read closure: `/api/v1/v2.2/xiaobao-warning` now attaches the latest generated relation-table AI insight, and the frontend uses relation-backed insights without returning to legacy AppData insight cache.
|
||||
- V3.1 status corrected to completed: prototype decomposition already has backend AI calls, frontend decomposition buttons, report modal, dedupe, adoption, AI estimates, assignee recommendations, and no-requirement grouping.
|
||||
- V2.8 added PostgreSQL backup, fresh DB restore with explicit overwrite confirmation, and `server_data` volume backup automation.
|
||||
- V2.8 added release smoke suite and wired GitHub Actions deployment verification to runtime version, frontend root, products, V2.2 read path, and AI config checks.
|
||||
@@ -100,12 +105,13 @@ V2.4 已将高增长和核心业务领域从“AppData 主写 + 关系表同步
|
||||
- V2.5.5 added consistency module/controller/script for counts, partition keys, orphan refs, and audit coverage; historical audit gaps are warnings.
|
||||
- V2.5.6 added `/admin/audit` and `/admin/consistency` pages plus frontend API helpers and permission entries.
|
||||
- V2.4.0 completed shared domain status contract alignment for Requirement, VersionPlan, DevTask, TestCase, Bug, and Version.
|
||||
- V2.4.1 switched Product / Project / Version root mutations to domain APIs and left `products-overview` as compatibility fallback.
|
||||
- V2.4.1 switched Product / Project / Version root mutations to domain APIs; current runtime no longer falls back to `products-overview`.
|
||||
- V2.4.2 switched Requirement writes to relation-table CRUD and added server-side pagination, search, filters, sorting, and cursor support for the requirement pool.
|
||||
- V2.4.3 switched VersionPlan and DevTask writes to version-scoped domain APIs, with work activity evidence and Xiaobao dirty marking.
|
||||
- V2.4.4 switched TestCase and Bug writes to version-scoped domain APIs, preserving round-copy and bug workflow behavior.
|
||||
- V2.4.5 switched Member, TaskCategory, TaskWorklog, OvertimeRecord, and WorkActivity writes to domain APIs. AppData remains only for compatibility fallback and low-frequency config such as departments, roles, password rules, and overtime reasons.
|
||||
- V2.4.5 switched Member, TaskCategory, TaskWorklog, OvertimeRecord, and WorkActivity writes to domain APIs. AppData remains only for low-frequency config such as departments, roles, password rules, and overtime reasons; member identity and overtime records stay relation-backed.
|
||||
- Added focused source-contract tests proving migrated frontend stores use domain APIs as primary writes rather than AppData document saves.
|
||||
- Added runtime source-contract coverage proving migrated business stores and auth do not read/write AppData business documents.
|
||||
|
||||
**2026-07-06**
|
||||
- Added production CI/CD flow: GitHub Actions builds `web` and `server` Docker images, pushes immutable commit-SHA tags to GHCR, deploys by SSH, pulls images on the server, runs `pnpm --filter server db:deploy`, restarts Compose, and verifies `/api/v1/health/version`.
|
||||
@@ -204,7 +210,7 @@ NestJS + Prisma + PostgreSQL 已推进到 V2.8。第一阶段用 `app_data` JSON
|
||||
4. **关系化拆表**:把稳定模块从 JSONB 拆成 Product/Project/Version/Task 等领域表(关系模型、同步桥和 V2.4 领域写 API 已落地)
|
||||
5. **领域 CRUD 主写入**:前端保存不再写整份 JSON 文档,而是调用具体领域 API 写关系表(V2.4 已完成)
|
||||
6. **基础权限/审计骨架**:领域 API 从迁移期开始接入用户身份、资源作用域、操作人和审计事件入口(V2.5 已收口)
|
||||
7. **AppData 主路径移除**:业务写入已冻结;后续按核对结果逐模块删除 JSON fallback 和 `/data/:key` 依赖
|
||||
7. **AppData 主路径移除**:业务写入已冻结;正常业务运行时已移除 JSON fallback,`/data/:key` 仅保留迁移、归档和配置例外
|
||||
8. **认证**:当前为 V2.5 header auth adapter;正式 NextAuth.js + JWT 服务端校验待后续治理
|
||||
9. **权限**:RBAC(Owner/Admin/Member/Viewer),按项目/版本级别(V2.5 服务端 guard 已启用,V2.7 项目成员治理和企业级配置已补齐)
|
||||
10. **版本规则引擎收敛**:VersionPlan 完成条件、关联需求候选、TaskCategory 语义码、TestCase.categoryId 统一收束到规则层
|
||||
@@ -218,13 +224,13 @@ NestJS + Prisma + PostgreSQL 已推进到 V2.8。第一阶段用 `app_data` JSON
|
||||
目标是让关系表从“快读 + AppData 同步副本”升级为主写入路径。V2.4 按以下顺序完成:
|
||||
|
||||
1. V2.4.0:统一 `packages/shared` 状态枚举与当前前端业务口径。
|
||||
2. V2.4.1:Project / Version 替代 `products-overview` 中的项目和版本主写入,产品树文档保留兼容读取。
|
||||
2. V2.4.1:Project / Version 替代 `products-overview` 中的项目和版本主写入;当前产品树正常运行时直接读领域 API,不再回读该文档。
|
||||
3. V2.4.2:Requirement 按 `productId` 分区键主写,并支持需求池服务端分页、搜索、筛选、排序。
|
||||
4. V2.4.3:VersionPlan / DevTask 按 `versionId` 分区键主写,写入后继续复用小宝 dirty 策略和工作活动记录。
|
||||
5. V2.4.4:TestCase / Bug 按 `versionId` 分区键主写,保留测试轮次和缺陷闭环。
|
||||
6. V2.4.5:Member / TaskCategory / TaskWorklog / Overtime / WorkActivity 主写关系表,证据型数据保留追加语义。
|
||||
|
||||
V2.4 完成后的兼容边界:AppData 不再是上述领域的事实源,只用于 fallback、历史迁移和少量配置。部门、角色、密码规则、加班原因仍作为兼容配置保留,后续由 RBAC/配置表阶段单独收口。
|
||||
V2.4 完成后的当前边界:AppData 不再是上述领域的事实源,也不再作为正常业务 fallback;只用于历史迁移、归档核对和少量配置。部门、角色、密码规则、加班原因仍作为临时配置保留,后续由 RBAC/配置表阶段单独收口。
|
||||
|
||||
## V2.5 — AppData 退场 + RBAC/审计/一致性收口(已完成)
|
||||
|
||||
@@ -233,11 +239,11 @@ V2.5 在 V2.4 领域 CRUD 主写基础上完成横切收口:
|
||||
1. Server auth context、`@CurrentUser()`、`@RequirePermission()`、`PermissionGuard` 和 `PermissionService` 已落地。
|
||||
2. `@ProtectedMutation()` 成为领域写接口统一入口,同时挂权限、资源作用域和审计 metadata。
|
||||
3. `audit_events` append-only 表、审计查询接口和 `/admin/audit` 页面已落地。
|
||||
4. `AppDataRetirementService` 集中声明每个 AppData key 的 `write_frozen` / `read_only_archive` 状态与替代路径。
|
||||
4. `AppDataRetirementService` 集中声明每个 AppData key 的退场状态与替代路径;已迁移业务 key 进入 `write_frozen` / `read_only_archive`,`members` 仅因部门、角色、密码规则临时配置保持 `active`,`overtime` 仅因加班原因配置保持 `active`。
|
||||
5. AppData archive export/verify 脚本已落地,归档包含 checksum、key list 和 app version metadata。
|
||||
6. Consistency module、`pnpm consistency:v25` 和 `/admin/consistency` 页面已落地。
|
||||
|
||||
V2.5 完成后的保留边界:`GET /api/v1/data/:key` 仍可读历史 JSON;Xiaobao risk archive、warning read state、部门/角色/密码规则和加班原因配置表分别交给 V2.6/V2.7,不在 V2.5 临时发明不稳定 schema。
|
||||
V2.5 完成后的保留边界:`GET /api/v1/data/:key` 仍可读历史 JSON,但正常业务运行时不再使用已迁移业务 key;Xiaobao risk archive、warning read state、部门/角色/密码规则和加班原因配置表分别交给后续治理,不在 V2.5 临时发明不稳定 schema。
|
||||
|
||||
## V3 — AI Agent 集成
|
||||
|
||||
@@ -276,7 +282,7 @@ V2.5 完成后的保留边界:`GET /api/v1/data/:key` 仍可读历史 JSON;X
|
||||
- `xiaobao.ai.interpret` worker 重新读取当前 summary,跳过过期 signature,调用 `AiService.interpretRisk()`,只写 `xiaobao_risk_insights` 缓存。
|
||||
- 触发策略:`on_track` 不触发;`at_risk`、`likely_delayed`、`blocked` 自动触发;`attention` 当前服务端只在临近发版且仍有未完成工作时触发。
|
||||
- 缓存策略:exact signature 复用、6 小时 cooldown、风险等级升级可绕过 cooldown。
|
||||
- V2.2 小宝快读返回最新 generated insight,前端优先展示关系表 AI 解读,再兼容旧 AppData insight cache。
|
||||
- V2.2 小宝快读返回最新 generated insight,前端优先展示关系表 AI 解读,不再兼容运行时旧 AppData insight cache。
|
||||
|
||||
当前不继续做后台定时巡检或额外主动推送;已有 `risk_alert` 通知保持现状。若后续需要“主动每日巡检 + 消息推送”,另起阶段,不塞进 V3.2。
|
||||
|
||||
@@ -290,7 +296,7 @@ V2.5 完成后的保留边界:`GET /api/v1/data/:key` 仍可读历史 JSON;X
|
||||
2. 健康度智能解读(数据指标 → 自然语言报告)
|
||||
3. 需求转任务(需求采纳后一键生成 DevTask 草稿)
|
||||
|
||||
### V3.4 — Business Analysis Agent(下一阶段设计已确认)
|
||||
### V3.4 — Business Analysis Agent(已完成第一版)
|
||||
|
||||
**目标**:把 `/wenfan-xiaobao` 从内置帮助问答升级为业务数据分析对话,并在产品、项目、版本详情页提供带上下文的智能分析入口。用户可以围绕产品、项目、版本、需求、部门和成员进行单维或多维提问,系统返回 Insight Card、图表、分析报告、可点击证据和连续追问。
|
||||
|
||||
@@ -304,6 +310,14 @@ V2.5 完成后的保留边界:`GET /api/v1/data/:key` 仍可读历史 JSON;X
|
||||
- ChartSpec 为平台统一契约,前端第一版用 ECharts Renderer,不把 ECharts option 暴露为后端契约。
|
||||
- AI Analysis Design System 采用 Apple Vision 风格:大留白、大圆角、轻阴影、半透明材质、数字优先、折线面积渐变、横向圆角柱状、少颜色、无大屏炫光。
|
||||
|
||||
**第一版已完成**:
|
||||
- 共享分析契约:`AnalysisPlan`、`MetricResult`、`EvidenceItem`、`AnalysisReport`、`FollowUp` 和 `UnifiedChartSpec`。
|
||||
- 后端分析接口:`POST /api/v1/ai/analysis` 接入 Semantic Layer、Analysis Strategy、Analysis Plan Processor、Permission Scope Resolver、Metric Engine 和响应构建器。
|
||||
- 指标目录和指标引擎:第一版支持版本风险、完成趋势、逾期分布、需求状态/来源、部门/成员负载、Bug 严重度、测试通过率和加班相关指标。
|
||||
- 图表渲染边界:后端返回平台统一 ChartSpec,前端通过 ECharts Renderer 转换,避免把 ECharts option 作为后端契约。
|
||||
- AI 助手业务分析对话:`/wenfan-xiaobao` 优先请求业务分析,展示 Insight Card、图表、报告、Evidence 和只读 Follow-up;分析接口不可用时保留内置帮助 fallback。
|
||||
- 产品/项目/版本上下文入口:详情页可打开只读分析 Drawer,按当前 `surface` 和上下文 ID 发起分析。
|
||||
|
||||
**MVP 模板候选**:
|
||||
1. 版本风险排行
|
||||
2. 项目/版本完成趋势
|
||||
|
||||
@@ -72,7 +72,7 @@
|
||||
- [ ] 是否需要在 `workspace-engine.ts` 加聚合?
|
||||
- [ ] 删除版本时是否需要清理这类数据?
|
||||
- [ ] 工作台 / 版本详情 / 项目详情三处的统计是否同步?
|
||||
- [ ] 是否需要新增 `app_data` key(后端 `data-keys.ts` + 前端 `server-data.ts`)?
|
||||
- [ ] 是否需要新增关系表、Prisma model、领域 API、权限和审计?
|
||||
- [ ] 是否需要从旧浏览器数据做一次性迁移?(当前不做本地导入导出)
|
||||
|
||||
## Drawer(侧边详情)规范
|
||||
@@ -131,9 +131,9 @@
|
||||
朋友拉新代码出现"显示问题"时,按顺序排查:
|
||||
|
||||
1. 后端是否启动:`GET http://localhost:3001/api/v1/config/ai` 应返回 200
|
||||
2. 数据库是否启动并完成 Prisma 同步:`app_data` 表必须存在
|
||||
3. 对应 `app_data.key` 是否有值,例如 `products-overview` / `requirements` / `dev-tasks`
|
||||
4. 前端 store 是否已经调用对应 `fetch*` 方法
|
||||
2. 数据库是否启动并完成 Prisma migration:领域关系表必须存在
|
||||
3. 对应领域 API 是否有数据,例如 `/api/v1/products`、`/api/v1/v2.2/workspace`、`/api/v1/versions/:versionId/plans`
|
||||
4. 前端 store 是否已经调用对应 `fetch*` 方法,且是否传了正确的 `productId` / `versionId`
|
||||
5. 类型定义和实际 JSON 数据不一致(缺字段)
|
||||
6. 列宽溢出导致裁切
|
||||
7. 派生计算错误(filter 条件错)
|
||||
@@ -147,10 +147,12 @@
|
||||
- 涉及 UI 改动:`curl http://localhost:3000/<path>` 检查 200
|
||||
- 不会自动跑 dev server,假定它已经运行
|
||||
|
||||
## AppData 并发写入流程
|
||||
## AppData 归档/配置写入流程
|
||||
|
||||
- 前端通过 `loadServerData(key)` 读取业务文档时,必须缓存响应里的 `version`。
|
||||
- 前端通过 `saveServerData(key, value)` 保存已读取过的 key 时,必须把最近一次成功读取/保存得到的 `version` 一起提交。
|
||||
- 正常业务 store 不再通过 `loadServerData(key)` / `saveServerData(key, value)` 读取或保存已迁移业务文档。
|
||||
- 仅允许低频配置例外使用 AppData:成员配置中的部门/角色/密码规则、加班原因。小宝历史缓存/已读状态只保留迁移与归档读取价值,正常页面运行时不得读写这些 AppData key。
|
||||
- 前端通过 `loadServerData(key)` 读取配置文档时,必须缓存响应里的 `version`。
|
||||
- 前端通过 `saveServerData(key, value)` 保存已读取过的配置 key 时,必须把最近一次成功读取/保存得到的 `version` 一起提交。
|
||||
- 后端只在 `key + updatedAt(version)` 匹配时更新;如果其他用户已经先保存,返回 `409 APP_DATA_CONFLICT`,响应包含当前服务端 `currentVersion` 和 `currentValue`。
|
||||
- 收到 `ServerDataConflictError` 时,不要自动重试覆盖。当前处理策略是阻止静默覆盖,后续 UI 冲突合并能力再单独补。
|
||||
|
||||
@@ -159,14 +161,14 @@
|
||||
已迁移领域的 store 默认写入顺序:
|
||||
|
||||
1. 本地状态先做乐观更新,保持 UI 响应速度。
|
||||
2. 优先调用 `apps/web/lib/domain-api.ts` 中的领域 API。
|
||||
2. 调用 `apps/web/lib/domain-api.ts` 中的领域 API。
|
||||
3. 领域 API 成功后,用服务端返回行替换本地临时行;若返回 `activities`,直接合并工作活动证据。
|
||||
4. 领域 API 不可用或旧环境未部署时,才使用 `loadServerData` / `saveServerData` 做 AppData 兼容兜底。
|
||||
5. 不新增长期双写逻辑;AppData fallback 只用于兼容、迁移和回滚,不作为已迁移领域的事实源。
|
||||
4. 领域 API 不可用时回滚乐观更新并提示 API/网络错误,不再写 AppData 兼容兜底。
|
||||
5. 不新增双写逻辑;迁移、归档、回滚工具可以读历史 AppData,但正常业务 UI 不把它当事实源。
|
||||
|
||||
当前领域主写范围:
|
||||
|
||||
- Product / Project / Version:根数据主写,`products-overview` 仅兼容。
|
||||
- Product / Project / Version:根数据主写,正常运行时不读 `products-overview`。
|
||||
- Requirement:按 `productId` 写入,需求池列表走服务端分页、搜索、筛选、排序。
|
||||
- VersionPlan / DevTask / TestCase / Bug:按 `versionId` 写入,并维护工作活动和小宝 dirty 标记。
|
||||
- Member / TaskCategory:成员身份字段和任务类型字典主写关系表。
|
||||
@@ -177,7 +179,7 @@
|
||||
- 部门、角色、密码规则。
|
||||
- 加班原因。
|
||||
|
||||
新增领域 store 时,先写 source contract 测试证明主写不是 `saveServerData('<key>')`,再实现领域 API 和 fallback。
|
||||
新增领域 store 时,先写 source contract 测试证明运行时不调用 `loadServerData('<key>')` / `saveServerData('<key>')`,再实现领域 API。
|
||||
|
||||
## V2.5 权限、审计与 AppData 退场流程
|
||||
|
||||
@@ -193,7 +195,7 @@ AppData key 退场遵循:
|
||||
|
||||
1. 所有业务 key 先在 `AppDataRetirementService` 标注状态和替代路径。
|
||||
2. `write_frozen` / `read_only_archive` key 的写入返回 `409 APP_DATA_WRITE_FROZEN`;前端捕获 `ServerDataWriteFrozenError` 后提示改用领域 API。
|
||||
3. 读取仍可用,只用于历史数据核对、兼容导入、归档导出和故障排查。
|
||||
3. 读取仍可用,只用于历史数据核对、兼容导入、归档导出和故障排查;正常业务 UI 不应把读取结果当 fallback 数据源。
|
||||
4. 导出归档使用 `pnpm appdata:archive:export`,归档校验使用 `pnpm appdata:archive:verify -- --archive <file>`。
|
||||
5. 不新增 AppData key 作为主写;新业务先设计关系表、Prisma model、领域 API、权限和审计。
|
||||
|
||||
@@ -206,8 +208,8 @@ AppData key 退场遵循:
|
||||
|
||||
跨阶段协调点:
|
||||
|
||||
- `xiaobao-risk-snapshots` / `xiaobao-risk-insights` 已进入只读归档;V2.6 负责关系表写入、后台任务和重试。
|
||||
- `xiaobao-warning-views` 已进入只读归档;V2.7 负责 per-user read-state API。
|
||||
- `xiaobao-risk-snapshots` / `xiaobao-risk-insights` 已进入只读归档;正常前端运行时不再读取或写入这些 AppData key,V2.6 关系表和后台任务负责 summary/insight。
|
||||
- `xiaobao-warning-views` 已进入只读归档;正常前端运行时不再写该 AppData key,当前页面已读状态仅作为浏览器本地 UI 状态,后续由 per-user read-state API 承接。
|
||||
- 成员身份写 `users`,但部门、角色、密码规则和加班原因的正式配置 schema 仍属 V2.7 管理治理。
|
||||
|
||||
## 与我相关(Workspace)数据流
|
||||
@@ -350,9 +352,9 @@ Implementation convention:
|
||||
- `xiaobao.warning:manage`:查看所有未结束版本的预警。
|
||||
- `xiaobao.warning:view`:仅查看当前用户在 `version.members` 中的未结束版本。
|
||||
|
||||
V2.6/V3.2 后小宝当前 summary 由服务端后台刷新:领域写入标记 `xiaobao_risk_summaries.dirty=true` 并排入 `xiaobao.summary.refresh`,worker 从版本下的计划、开发任务、测试用例、Bug、日报和工作活动重新计算风险。页面仍可用前端 `buildXiaobaoWorkItems` 做兼容聚合和快照保存,但默认优先读取 V2.2 summary。
|
||||
V2.6/V3.2 后小宝当前 summary 由服务端后台刷新:领域写入标记 `xiaobao_risk_summaries.dirty=true` 并排入 `xiaobao.summary.refresh`,worker 从版本下的计划、开发任务、测试用例、Bug、日报和工作活动重新计算风险。页面仍可用前端 `buildXiaobaoWorkItems` 做兼容聚合,但不再把快照或解读保存到 AppData,默认优先读取 V2.2 summary。
|
||||
|
||||
AI 解读不由人工按钮触发。服务端 summary 刷新后按 policy 排入 `xiaobao.ai.interpret`:`at_risk`、`likely_delayed`、`blocked` 自动触发;`attention` 当前服务端只在临近发版且仍有未完成工作时触发,页面完整趋势策略仍保留作为兼容。缓存命中时复用解读;同版本最近 6 小时内已有解读时进入 cooldown,不重复请求,风险等级升级时可绕过;缓存保存时间使用服务端写入时间,不信任模型返回的 `generatedAt` 作为缓存新鲜度。V2.2 小宝快读会带出当前版本最新 generated 关系表解读,前端优先展示这份解读,再兼容旧 `xiaobao-risk-insights` AppData 缓存。
|
||||
AI 解读不由人工按钮触发。服务端 summary 刷新后按 policy 排入 `xiaobao.ai.interpret`:`at_risk`、`likely_delayed`、`blocked` 自动触发;`attention` 当前服务端只在临近发版且仍有未完成工作时触发。缓存命中时复用解读;同版本最近 6 小时内已有解读时进入 cooldown,不重复请求,风险等级升级时可绕过;缓存保存时间使用服务端写入时间,不信任模型返回的 `generatedAt` 作为缓存新鲜度。V2.2 小宝快读会带出当前版本最新 generated 关系表解读,前端优先展示这份解读,不再回读旧 `xiaobao-risk-insights` AppData 缓存。
|
||||
|
||||
静默风险包括长期无更新、无日报、无活动、进行中事项无人处理等信号。日报和工作活动是风险解释的重要证据,必须进入 AI 解读输入。
|
||||
|
||||
@@ -362,19 +364,21 @@ AI 解读不由人工按钮触发。服务端 summary 刷新后按 policy 排入
|
||||
|
||||
- `assignment`:负责人或处理人被分配工作。
|
||||
- `mention`:评论中 `@成员名` 或显式选择成员。
|
||||
- `risk_alert`:小宝预警保存高风险快照后提醒管理者。
|
||||
- `risk_alert`:小宝风险摘要/高风险事件触发后提醒管理者。
|
||||
- `overdue_item`:逾期事项提醒。
|
||||
|
||||
评论统一使用 `CommentPanel`,支持 DevTask、TestCase、Bug、Requirement 和 VersionPlan。创建/删除评论必须写 audit;提及成员必须生成 mention 通知。
|
||||
|
||||
项目成员治理走 `/projects/:projectId/members` 服务端接口。角色为 Owner/Admin/Member/Viewer,Owner/Admin 可管理成员;服务端禁止移除或降级最后一个 Owner。版本成员可见性继续兼容旧 `version.members` 展示,但治理来源应逐步收敛到 ProjectMember。
|
||||
|
||||
管理驾驶舱 `/admin/management` 只查关系表和小宝 summary,不读取 AppData,并通过 RBAC adapter 校验 `management:view`。治理设置 `/admin/governance` 集中维护任务类型与需求字典;使用中的字典不可硬删,字典变更必须写 audit,并通过 RBAC adapter 校验 `governance:manage`。
|
||||
管理驾驶舱 `/admin/management` 只查关系表和小宝 summary,不读取 AppData,并通过 RBAC adapter 校验 `management:view`。治理设置 `/admin/governance` 统一维护任务类型、需求类型、支持端与需求来源,并由 `governance:manage` 控制。需求池只消费治理字典,不再提供本地类型/来源/支持端管理抽屉;AI 分析读取这些字段作为维度,不负责修改字典。
|
||||
|
||||
## Business Analysis Agent 工作流(规划)
|
||||
## Business Analysis Agent 工作流(第一版已接入)
|
||||
|
||||
Business Analysis Agent 用于 AI 助手业务数据对话,以及产品/项目/版本详情页的上下文分析入口。它只读关系表和 summary,不写业务数据。
|
||||
|
||||
业务分析请求走 `POST /api/v1/ai/analysis`。前端必须传入 `surface` 和可用上下文 ID;后端基于当前用户、服务端权限和页面上下文再次收窄范围,不能只信任前端传入的权限或自然语言意图。
|
||||
|
||||
标准流程:
|
||||
|
||||
1. 用户在 `/wenfan-xiaobao` 或详情页输入问题。
|
||||
|
||||
@@ -116,7 +116,7 @@ export interface AgentDecomposeResponse {
|
||||
export interface AgentDecomposeError {
|
||||
ok: false;
|
||||
error: string;
|
||||
code: 'PROTOTYPE_FETCH_FAILED' | 'EMPTY_REQUIREMENTS' | 'API_ERROR' | 'PARSE_ERROR' | 'NO_PROVIDER' | 'UNKNOWN';
|
||||
code: 'PROTOTYPE_FETCH_FAILED' | 'EMPTY_REQUIREMENTS' | 'API_TIMEOUT' | 'API_ERROR' | 'PARSE_ERROR' | 'NO_PROVIDER' | 'UNKNOWN';
|
||||
}
|
||||
|
||||
export interface AgentRiskReason {
|
||||
|
||||
Reference in New Issue
Block a user