feat(product): 实现产品需求管理模块(后端 + 前端)
后端:Product Module(CRUD)、Requirement Module(CRUD + 状态机流转)、PrismaService 前端:产品列表页、产品详情页(含需求池 Tab)、Zustand Store、API 封装 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -16,13 +16,16 @@
|
||||
"db:studio": "prisma studio"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ftb/shared": "workspace:*",
|
||||
"@nestjs/common": "^10.0.0",
|
||||
"@nestjs/core": "^10.0.0",
|
||||
"@nestjs/mapped-types": "^2.1.1",
|
||||
"@nestjs/platform-express": "^10.0.0",
|
||||
"@prisma/client": "^5.15.0",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.15.1",
|
||||
"reflect-metadata": "^0.2.0",
|
||||
"rxjs": "^7.8.0",
|
||||
"@ftb/shared": "workspace:*"
|
||||
"rxjs": "^7.8.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^10.0.0",
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PrismaModule } from './prisma/prisma.module';
|
||||
import { ProductModule } from './modules/product/product.module';
|
||||
import { RequirementModule } from './modules/requirement/requirement.module';
|
||||
|
||||
@Module({
|
||||
imports: [],
|
||||
imports: [PrismaModule, ProductModule, RequirementModule],
|
||||
controllers: [],
|
||||
providers: [],
|
||||
})
|
||||
|
||||
11
apps/server/src/modules/product/dto/create-product.dto.ts
Normal file
11
apps/server/src/modules/product/dto/create-product.dto.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { IsString, IsNotEmpty, IsOptional } from 'class-validator';
|
||||
|
||||
export class CreateProductDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
name!: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
description?: string;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import { PartialType } from '@nestjs/mapped-types';
|
||||
import { CreateProductDto } from './create-product.dto';
|
||||
|
||||
export class UpdateProductDto extends PartialType(CreateProductDto) {}
|
||||
34
apps/server/src/modules/product/product.controller.ts
Normal file
34
apps/server/src/modules/product/product.controller.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { Controller, Get, Post, Patch, Delete, Param, Body } from '@nestjs/common';
|
||||
import { ProductService } from './product.service';
|
||||
import { CreateProductDto } from './dto/create-product.dto';
|
||||
import { UpdateProductDto } from './dto/update-product.dto';
|
||||
|
||||
@Controller('products')
|
||||
export class ProductController {
|
||||
constructor(private readonly productService: ProductService) {}
|
||||
|
||||
@Post()
|
||||
create(@Body() dto: CreateProductDto) {
|
||||
return this.productService.create(dto);
|
||||
}
|
||||
|
||||
@Get()
|
||||
findAll() {
|
||||
return this.productService.findAll();
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
findOne(@Param('id') id: string) {
|
||||
return this.productService.findOne(id);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
update(@Param('id') id: string, @Body() dto: UpdateProductDto) {
|
||||
return this.productService.update(id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
remove(@Param('id') id: string) {
|
||||
return this.productService.remove(id);
|
||||
}
|
||||
}
|
||||
10
apps/server/src/modules/product/product.module.ts
Normal file
10
apps/server/src/modules/product/product.module.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ProductController } from './product.controller';
|
||||
import { ProductService } from './product.service';
|
||||
|
||||
@Module({
|
||||
controllers: [ProductController],
|
||||
providers: [ProductService],
|
||||
exports: [ProductService],
|
||||
})
|
||||
export class ProductModule {}
|
||||
50
apps/server/src/modules/product/product.service.ts
Normal file
50
apps/server/src/modules/product/product.service.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { CreateProductDto } from './dto/create-product.dto';
|
||||
import { UpdateProductDto } from './dto/update-product.dto';
|
||||
|
||||
@Injectable()
|
||||
export class ProductService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
create(dto: CreateProductDto) {
|
||||
return this.prisma.product.create({ data: dto });
|
||||
}
|
||||
|
||||
findAll() {
|
||||
return this.prisma.product.findMany({
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: {
|
||||
_count: { select: { requirements: true, projects: true } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async findOne(id: string) {
|
||||
const product = await this.prisma.product.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
requirements: { orderBy: { createdAt: 'desc' } },
|
||||
versions: { orderBy: { createdAt: 'desc' } },
|
||||
_count: { select: { projects: true } },
|
||||
},
|
||||
});
|
||||
if (!product) throw new NotFoundException('产品不存在');
|
||||
return product;
|
||||
}
|
||||
|
||||
async update(id: string, dto: UpdateProductDto) {
|
||||
await this.ensureExists(id);
|
||||
return this.prisma.product.update({ where: { id }, data: dto });
|
||||
}
|
||||
|
||||
async remove(id: string) {
|
||||
await this.ensureExists(id);
|
||||
return this.prisma.product.delete({ where: { id } });
|
||||
}
|
||||
|
||||
private async ensureExists(id: string) {
|
||||
const exists = await this.prisma.product.findUnique({ where: { id } });
|
||||
if (!exists) throw new NotFoundException('产品不存在');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { IsString, IsNotEmpty, IsOptional, IsInt, Min, Max } from 'class-validator';
|
||||
|
||||
export class CreateRequirementDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
title!: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
description?: string;
|
||||
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
@Max(4)
|
||||
@IsOptional()
|
||||
priority?: number;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
creatorId!: string;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { IsEnum } from 'class-validator';
|
||||
import { RequirementStatus } from '@ftb/shared';
|
||||
|
||||
export class UpdateRequirementStatusDto {
|
||||
@IsEnum(RequirementStatus)
|
||||
status!: RequirementStatus;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { PartialType } from '@nestjs/mapped-types';
|
||||
import { CreateRequirementDto } from './create-requirement.dto';
|
||||
import { IsOptional, IsString } from 'class-validator';
|
||||
|
||||
export class UpdateRequirementDto extends PartialType(CreateRequirementDto) {
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
creatorId?: never;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { Controller, Get, Post, Patch, Delete, Param, Body, Query } from '@nestjs/common';
|
||||
import { RequirementService } from './requirement.service';
|
||||
import { CreateRequirementDto } from './dto/create-requirement.dto';
|
||||
import { UpdateRequirementDto } from './dto/update-requirement.dto';
|
||||
import { UpdateRequirementStatusDto } from './dto/update-requirement-status.dto';
|
||||
|
||||
@Controller('products/:productId/requirements')
|
||||
export class RequirementController {
|
||||
constructor(private readonly requirementService: RequirementService) {}
|
||||
|
||||
@Post()
|
||||
create(
|
||||
@Param('productId') productId: string,
|
||||
@Body() dto: CreateRequirementDto,
|
||||
) {
|
||||
return this.requirementService.create(productId, dto);
|
||||
}
|
||||
|
||||
@Get()
|
||||
findAll(
|
||||
@Param('productId') productId: string,
|
||||
@Query('status') status?: string,
|
||||
) {
|
||||
return this.requirementService.findAll(productId, status);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
findOne(
|
||||
@Param('productId') productId: string,
|
||||
@Param('id') id: string,
|
||||
) {
|
||||
return this.requirementService.findOne(productId, id);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
update(
|
||||
@Param('productId') productId: string,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateRequirementDto,
|
||||
) {
|
||||
return this.requirementService.update(productId, id, dto);
|
||||
}
|
||||
|
||||
@Patch(':id/status')
|
||||
updateStatus(
|
||||
@Param('productId') productId: string,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateRequirementStatusDto,
|
||||
) {
|
||||
return this.requirementService.updateStatus(productId, id, dto.status);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
remove(
|
||||
@Param('productId') productId: string,
|
||||
@Param('id') id: string,
|
||||
) {
|
||||
return this.requirementService.remove(productId, id);
|
||||
}
|
||||
}
|
||||
10
apps/server/src/modules/requirement/requirement.module.ts
Normal file
10
apps/server/src/modules/requirement/requirement.module.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { RequirementController } from './requirement.controller';
|
||||
import { RequirementService } from './requirement.service';
|
||||
|
||||
@Module({
|
||||
controllers: [RequirementController],
|
||||
providers: [RequirementService],
|
||||
exports: [RequirementService],
|
||||
})
|
||||
export class RequirementModule {}
|
||||
81
apps/server/src/modules/requirement/requirement.service.ts
Normal file
81
apps/server/src/modules/requirement/requirement.service.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { RequirementStatus } from '@ftb/shared';
|
||||
import { CreateRequirementDto } from './dto/create-requirement.dto';
|
||||
import { UpdateRequirementDto } from './dto/update-requirement.dto';
|
||||
|
||||
const VALID_TRANSITIONS: Record<string, string[]> = {
|
||||
[RequirementStatus.DRAFT]: [RequirementStatus.REVIEWING],
|
||||
[RequirementStatus.REVIEWING]: [RequirementStatus.APPROVED, RequirementStatus.REJECTED],
|
||||
[RequirementStatus.APPROVED]: [RequirementStatus.DELIVERED],
|
||||
[RequirementStatus.REJECTED]: [RequirementStatus.DRAFT],
|
||||
[RequirementStatus.DELIVERED]: [],
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class RequirementService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
create(productId: string, dto: CreateRequirementDto) {
|
||||
return this.prisma.requirement.create({
|
||||
data: {
|
||||
productId,
|
||||
title: dto.title,
|
||||
description: dto.description || '',
|
||||
priority: dto.priority ?? 0,
|
||||
creatorId: dto.creatorId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
findAll(productId: string, status?: string) {
|
||||
return this.prisma.requirement.findMany({
|
||||
where: {
|
||||
productId,
|
||||
...(status ? { status } : {}),
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: { creator: { select: { id: true, name: true } } },
|
||||
});
|
||||
}
|
||||
|
||||
async findOne(productId: string, id: string) {
|
||||
const req = await this.prisma.requirement.findFirst({
|
||||
where: { id, productId },
|
||||
include: { creator: { select: { id: true, name: true } } },
|
||||
});
|
||||
if (!req) throw new NotFoundException('需求不存在');
|
||||
return req;
|
||||
}
|
||||
|
||||
async update(productId: string, id: string, dto: UpdateRequirementDto) {
|
||||
await this.findOne(productId, id);
|
||||
return this.prisma.requirement.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(dto.title !== undefined && { title: dto.title }),
|
||||
...(dto.description !== undefined && { description: dto.description }),
|
||||
...(dto.priority !== undefined && { priority: dto.priority }),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async updateStatus(productId: string, id: string, newStatus: string) {
|
||||
const req = await this.findOne(productId, id);
|
||||
const allowed = VALID_TRANSITIONS[req.status] || [];
|
||||
if (!allowed.includes(newStatus)) {
|
||||
throw new BadRequestException(
|
||||
`无法从 "${req.status}" 转换到 "${newStatus}"`,
|
||||
);
|
||||
}
|
||||
return this.prisma.requirement.update({
|
||||
where: { id },
|
||||
data: { status: newStatus },
|
||||
});
|
||||
}
|
||||
|
||||
async remove(productId: string, id: string) {
|
||||
await this.findOne(productId, id);
|
||||
return this.prisma.requirement.delete({ where: { id } });
|
||||
}
|
||||
}
|
||||
9
apps/server/src/prisma/prisma.module.ts
Normal file
9
apps/server/src/prisma/prisma.module.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { PrismaService } from './prisma.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [PrismaService],
|
||||
exports: [PrismaService],
|
||||
})
|
||||
export class PrismaModule {}
|
||||
13
apps/server/src/prisma/prisma.service.ts
Normal file
13
apps/server/src/prisma/prisma.service.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
@Injectable()
|
||||
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
|
||||
async onModuleInit() {
|
||||
await this.$connect();
|
||||
}
|
||||
|
||||
async onModuleDestroy() {
|
||||
await this.$disconnect();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user