diff --git a/apps/server/package.json b/apps/server/package.json index 0cea8df..72b6838 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -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", diff --git a/apps/server/src/app.module.ts b/apps/server/src/app.module.ts index ee5f2c9..136c394 100644 --- a/apps/server/src/app.module.ts +++ b/apps/server/src/app.module.ts @@ -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: [], }) diff --git a/apps/server/src/modules/product/dto/create-product.dto.ts b/apps/server/src/modules/product/dto/create-product.dto.ts new file mode 100644 index 0000000..a7e481c --- /dev/null +++ b/apps/server/src/modules/product/dto/create-product.dto.ts @@ -0,0 +1,11 @@ +import { IsString, IsNotEmpty, IsOptional } from 'class-validator'; + +export class CreateProductDto { + @IsString() + @IsNotEmpty() + name!: string; + + @IsString() + @IsOptional() + description?: string; +} diff --git a/apps/server/src/modules/product/dto/update-product.dto.ts b/apps/server/src/modules/product/dto/update-product.dto.ts new file mode 100644 index 0000000..e2d43fc --- /dev/null +++ b/apps/server/src/modules/product/dto/update-product.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/mapped-types'; +import { CreateProductDto } from './create-product.dto'; + +export class UpdateProductDto extends PartialType(CreateProductDto) {} diff --git a/apps/server/src/modules/product/product.controller.ts b/apps/server/src/modules/product/product.controller.ts new file mode 100644 index 0000000..0221da2 --- /dev/null +++ b/apps/server/src/modules/product/product.controller.ts @@ -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); + } +} diff --git a/apps/server/src/modules/product/product.module.ts b/apps/server/src/modules/product/product.module.ts new file mode 100644 index 0000000..6b38d8a --- /dev/null +++ b/apps/server/src/modules/product/product.module.ts @@ -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 {} diff --git a/apps/server/src/modules/product/product.service.ts b/apps/server/src/modules/product/product.service.ts new file mode 100644 index 0000000..dd4679e --- /dev/null +++ b/apps/server/src/modules/product/product.service.ts @@ -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('产品不存在'); + } +} diff --git a/apps/server/src/modules/requirement/dto/create-requirement.dto.ts b/apps/server/src/modules/requirement/dto/create-requirement.dto.ts new file mode 100644 index 0000000..8952705 --- /dev/null +++ b/apps/server/src/modules/requirement/dto/create-requirement.dto.ts @@ -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; +} diff --git a/apps/server/src/modules/requirement/dto/update-requirement-status.dto.ts b/apps/server/src/modules/requirement/dto/update-requirement-status.dto.ts new file mode 100644 index 0000000..306ddf1 --- /dev/null +++ b/apps/server/src/modules/requirement/dto/update-requirement-status.dto.ts @@ -0,0 +1,7 @@ +import { IsEnum } from 'class-validator'; +import { RequirementStatus } from '@ftb/shared'; + +export class UpdateRequirementStatusDto { + @IsEnum(RequirementStatus) + status!: RequirementStatus; +} diff --git a/apps/server/src/modules/requirement/dto/update-requirement.dto.ts b/apps/server/src/modules/requirement/dto/update-requirement.dto.ts new file mode 100644 index 0000000..4511516 --- /dev/null +++ b/apps/server/src/modules/requirement/dto/update-requirement.dto.ts @@ -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; +} diff --git a/apps/server/src/modules/requirement/requirement.controller.ts b/apps/server/src/modules/requirement/requirement.controller.ts new file mode 100644 index 0000000..3a7ea75 --- /dev/null +++ b/apps/server/src/modules/requirement/requirement.controller.ts @@ -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); + } +} diff --git a/apps/server/src/modules/requirement/requirement.module.ts b/apps/server/src/modules/requirement/requirement.module.ts new file mode 100644 index 0000000..853e306 --- /dev/null +++ b/apps/server/src/modules/requirement/requirement.module.ts @@ -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 {} diff --git a/apps/server/src/modules/requirement/requirement.service.ts b/apps/server/src/modules/requirement/requirement.service.ts new file mode 100644 index 0000000..1a4235b --- /dev/null +++ b/apps/server/src/modules/requirement/requirement.service.ts @@ -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 = { + [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 } }); + } +} \ No newline at end of file diff --git a/apps/server/src/prisma/prisma.module.ts b/apps/server/src/prisma/prisma.module.ts new file mode 100644 index 0000000..7207426 --- /dev/null +++ b/apps/server/src/prisma/prisma.module.ts @@ -0,0 +1,9 @@ +import { Global, Module } from '@nestjs/common'; +import { PrismaService } from './prisma.service'; + +@Global() +@Module({ + providers: [PrismaService], + exports: [PrismaService], +}) +export class PrismaModule {} diff --git a/apps/server/src/prisma/prisma.service.ts b/apps/server/src/prisma/prisma.service.ts new file mode 100644 index 0000000..bb6565f --- /dev/null +++ b/apps/server/src/prisma/prisma.service.ts @@ -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(); + } +} diff --git a/apps/web/app/products/[id]/page.tsx b/apps/web/app/products/[id]/page.tsx new file mode 100644 index 0000000..fc5e7b2 --- /dev/null +++ b/apps/web/app/products/[id]/page.tsx @@ -0,0 +1,133 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { useParams, useRouter } from 'next/navigation'; +import { RequirementStatus } from '@ftb/shared'; +import { useProductStore } from '@/stores/useProductStore'; +import { useRequirementStore } from '@/stores/useRequirementStore'; +import { RequirementTable } from '@/components/product/RequirementTable'; +import { RequirementForm } from '@/components/product/RequirementForm'; +import { ProductForm } from '@/components/product/ProductForm'; + +export default function ProductDetailPage() { + const params = useParams(); + const router = useRouter(); + const productId = params.id as string; + + const { currentProduct, fetchProduct, updateProduct, deleteProduct } = useProductStore(); + const { requirements, statusFilter, fetchRequirements, createRequirement, updateRequirement, updateStatus, deleteRequirement, setStatusFilter } = useRequirementStore(); + + const [showReqForm, setShowReqForm] = useState(false); + const [editingReq, setEditingReq] = useState(null); + const [editingProduct, setEditingProduct] = useState(false); + const [activeTab, setActiveTab] = useState<'requirements' | 'projects' | 'versions'>('requirements'); + + useEffect(() => { + fetchProduct(productId); + fetchRequirements(productId); + }, [productId, fetchProduct, fetchRequirements]); + + const handleFilterChange = (status: RequirementStatus | null) => { + setStatusFilter(status); + fetchRequirements(productId, status || undefined); + }; + + const handleCreateReq = async (data: { title: string; description: string; priority: number }) => { + await createRequirement(productId, { ...data, creatorId: 'temp-user-id' }); + setShowReqForm(false); + }; + + const handleUpdateReq = async (data: { title: string; description: string; priority: number }) => { + if (editingReq) { + await updateRequirement(productId, editingReq.id, data); + setEditingReq(null); + } + }; + + const handleDeleteProduct = async () => { + if (confirm('确定要删除此产品吗?')) { + await deleteProduct(productId); + router.push('/products'); + } + }; + + if (!currentProduct) { + return
加载中...
; + } + + return ( +
+
+ router.push('/products')}>产品列表 + / + {currentProduct.name} +
+ +
+

{currentProduct.name}

+
+ + +
+
+ + {currentProduct.description && ( +

{currentProduct.description}

+ )} + + {editingProduct && ( +
+ { await updateProduct(productId, data); setEditingProduct(false); await fetchProduct(productId); }} + onCancel={() => setEditingProduct(false)} + /> +
+ )} + +
+ {(['requirements', 'projects', 'versions'] as const).map((tab) => ( + + ))} +
+ + {activeTab === 'requirements' && ( + <> + {(showReqForm || editingReq) && ( +
+

{editingReq ? '编辑需求' : '新建需求'}

+ { setShowReqForm(false); setEditingReq(null); }} + /> +
+ )} + setEditingReq(req)} + onStatusChange={(id, status) => updateStatus(productId, id, status)} + onDelete={(id) => deleteRequirement(productId, id)} + onCreate={() => setShowReqForm(true)} + /> + + )} + + {activeTab === 'projects' && ( +
项目管理(后续迭代实现)
+ )} + + {activeTab === 'versions' && ( +
版本管理(后续迭代实现)
+ )} +
+ ); +} diff --git a/apps/web/app/products/page.tsx b/apps/web/app/products/page.tsx new file mode 100644 index 0000000..932be90 --- /dev/null +++ b/apps/web/app/products/page.tsx @@ -0,0 +1,59 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { useProductStore } from '@/stores/useProductStore'; +import { ProductCard } from '@/components/product/ProductCard'; +import { ProductForm } from '@/components/product/ProductForm'; + +export default function ProductsPage() { + const router = useRouter(); + const { products, loading, fetchProducts, createProduct } = useProductStore(); + const [showForm, setShowForm] = useState(false); + + useEffect(() => { + fetchProducts(); + }, [fetchProducts]); + + const handleCreate = async (data: { name: string; description: string }) => { + await createProduct(data); + setShowForm(false); + }; + + return ( +
+
+

产品列表

+ +
+ + {showForm && ( +
+

新建产品

+ setShowForm(false)} /> +
+ )} + + {loading ? ( +
加载中...
+ ) : products.length === 0 ? ( +
暂无产品,点击上方按钮创建
+ ) : ( +
+ {products.map((product) => ( + router.push(`/products/${id}`)} + /> + ))} +
+ )} +
+ ); +} diff --git a/apps/web/components/product/ProductCard.tsx b/apps/web/components/product/ProductCard.tsx new file mode 100644 index 0000000..3332732 --- /dev/null +++ b/apps/web/components/product/ProductCard.tsx @@ -0,0 +1,30 @@ +'use client'; + +import { Product } from '@ftb/shared'; + +interface ProductWithCount extends Product { + _count?: { requirements: number; projects: number }; +} + +interface Props { + product: ProductWithCount; + onClick: (id: string) => void; +} + +export function ProductCard({ product, onClick }: Props) { + return ( +
onClick(product.id)} + > +

{product.name}

+ {product.description && ( +

{product.description}

+ )} +
+ {product._count?.requirements ?? 0} 需求 + {product._count?.projects ?? 0} 项目 +
+
+ ); +} diff --git a/apps/web/components/product/ProductForm.tsx b/apps/web/components/product/ProductForm.tsx new file mode 100644 index 0000000..60df6cc --- /dev/null +++ b/apps/web/components/product/ProductForm.tsx @@ -0,0 +1,62 @@ +'use client'; + +import { useState } from 'react'; + +interface Props { + initialData?: { name: string; description: string }; + onSubmit: (data: { name: string; description: string }) => void; + onCancel: () => void; +} + +export function ProductForm({ initialData, onSubmit, onCancel }: Props) { + const [name, setName] = useState(initialData?.name || ''); + const [description, setDescription] = useState(initialData?.description || ''); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (!name.trim()) return; + onSubmit({ name: name.trim(), description: description.trim() }); + }; + + return ( +
+
+ + setName(e.target.value)} + className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500" + placeholder="输入产品名称" + /> +
+
+ +