feat(plop): 模板支持关联关系和多对多生成

- service.hbs: 支持三种服务类型,生成 @CrudOptions 关联配置和 DTO 转换方法
- dto.hbs: 生成 BriefDto、DetailResponseDto、AssignDto
- controller.hbs: 支持关联查询端点和多对多分配端点
- types.hbs: 添加关联类型接口定义

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
charilezhou
2026-01-19 16:03:12 +08:00
parent f126e03cf1
commit f492e7c172
4 changed files with 321 additions and 14 deletions

View File

@@ -2,7 +2,7 @@ import {
Controller,
Get,
Post,
Patch,
{{#if (eq serviceType 'CrudService')}}Patch{{else}}Put{{/if}},
Delete,
Param,
Body,
@@ -16,16 +16,25 @@ import {
ApiOkResponse,
ApiCreatedResponse,
} from '@nestjs/swagger';
{{#if (eq serviceType 'CrudService')}}
import { Prisma } from '@prisma/client';
{{/if}}
import {
Create{{pascalCase name}}Dto,
Update{{pascalCase name}}Dto,
{{pascalCase name}}ResponseDto,
{{#if needsDetailDto}}
{{pascalCase name}}DetailResponseDto,
{{/if}}
Paginated{{pascalCase name}}ResponseDto,
{{#if hasQueryDto}}
{{pascalCase name}}QueryDto,
{{/if}}
{{#each manyToMany}}
Assign{{pascalCase name}}Dto,
{{targetModel}}BriefDto,
{{/each}}
} from './dto/{{kebabCase name}}.dto';
import { {{pascalCase name}}Service } from './{{kebabCase name}}.service';
@@ -53,6 +62,7 @@ export class {{pascalCase name}}Controller {
@ApiOkResponse({ type: Paginated{{pascalCase name}}ResponseDto, description: '{{chineseName}}列表' })
{{#if hasQueryDto}}
findAll(@Query() query: {{pascalCase name}}QueryDto) {
{{#if (eq serviceType 'CrudService')}}
const { {{#each queryFields}}{{name}}, {{/each}}...pagination } = query;
const where: Prisma.{{pascalCase name}}WhereInput = {};
{{#each queryFields}}
@@ -65,10 +75,17 @@ export class {{pascalCase name}}Controller {
}
{{/each}}
return this.{{camelCase name}}Service.findAll({ ...pagination, where });
{{else}}
return this.{{camelCase name}}Service.findAllWithRelations(query);
{{/if}}
}
{{else}}
findAll(@Query() query: PaginationQueryDto) {
{{#if (eq serviceType 'CrudService')}}
return this.{{camelCase name}}Service.findAll(query);
{{else}}
return this.{{camelCase name}}Service.findAllWithRelations(query);
{{/if}}
}
{{/if}}
@@ -100,12 +117,24 @@ export class {{pascalCase name}}Controller {
{{/if}}
@Get(':id')
@ApiOperation({ summary: '根据 ID 获取{{chineseName}}' })
{{#if needsDetailDto}}
@ApiOkResponse({ type: {{pascalCase name}}DetailResponseDto, description: '{{chineseName}}详情' })
findById(@Param('id') id: string) {
return this.{{camelCase name}}Service.findByIdWithRelations(id);
}
{{else if needsResponseDto}}
@ApiOkResponse({ type: {{pascalCase name}}ResponseDto, description: '{{chineseName}}详情' })
findById(@Param('id') id: string) {
return this.{{camelCase name}}Service.findByIdWithRelations(id);
}
{{else}}
@ApiOkResponse({ type: {{pascalCase name}}ResponseDto, description: '{{chineseName}}详情' })
findById(@Param('id') id: string) {
return this.{{camelCase name}}Service.findById(id);
}
{{/if}}
@Patch(':id')
@{{#if (eq serviceType 'CrudService')}}Patch{{else}}Put{{/if}}(':id')
@ApiOperation({ summary: '更新{{chineseName}}信息' })
@ApiOkResponse({ type: {{pascalCase name}}ResponseDto, description: '更新后的{{chineseName}}信息' })
update(@Param('id') id: string, @Body() dto: Update{{pascalCase name}}Dto) {
@@ -120,11 +149,29 @@ export class {{pascalCase name}}Controller {
}
{{#if softDelete}}
@Patch(':id/restore')
@{{#if (eq serviceType 'CrudService')}}Patch{{else}}Put{{/if}}(':id/restore')
@ApiOperation({ summary: '恢复已删除的{{chineseName}}' })
@ApiOkResponse({ type: {{pascalCase name}}ResponseDto, description: '恢复后的{{chineseName}}信息' })
restore(@Param('id') id: string) {
return this.{{camelCase name}}Service.restore(id);
}
{{/if}}
{{#if hasManyToMany}}
{{#each manyToMany}}
@Get(':id/{{name}}')
@ApiOperation({ summary: '获取{{../chineseName}}{{name}}列表' })
@ApiOkResponse({ type: [{{targetModel}}BriefDto], description: '{{name}}列表' })
get{{pascalCase name}}(@Param('id') id: string) {
return this.{{camelCase ../name}}Service.getManyToManyTargets(id, '{{name}}');
}
@Put(':id/{{name}}')
@ApiOperation({ summary: '分配{{../chineseName}}{{name}}' })
@ApiOkResponse({ type: {{pascalCase ../name}}DetailResponseDto, description: '分配成功' })
assign{{pascalCase name}}(@Param('id') id: string, @Body() dto: Assign{{pascalCase name}}Dto) {
return this.{{camelCase ../name}}Service.assignManyToMany(id, '{{name}}', { targetIds: dto.{{target}}Ids });
}
{{/each}}
{{/if}}
}

View File

@@ -1,10 +1,10 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { ApiProperty, ApiPropertyOptional{{#if needsDetailDto}}, PartialType{{/if}} } from '@nestjs/swagger';
{{#if (hasValidation fields)}}
import { {{validationImports fields}}, IsOptional } from 'class-validator';
import { {{validationImports fields}}, IsOptional{{#if hasManyToMany}}, IsArray, IsString{{/if}} } from 'class-validator';
{{else}}
import { IsOptional } from 'class-validator';
import { IsOptional{{#if hasManyToMany}}, IsArray, IsString{{/if}} } from 'class-validator';
{{/if}}
{{#if (hasTransform fields)}}
{{#if (or (hasTransform fields) hasManyToMany)}}
import { Type } from 'class-transformer';
{{/if}}
import type {
@@ -47,6 +47,51 @@ export class Update{{pascalCase name}}Dto implements IUpdate{{pascalCase name}}D
{{/each}}
}
{{#if hasRelations}}
{{#each relations}}
/** {{model}}简要信息 */
export class {{model}}BriefDto {
@ApiProperty({ example: 'clxxx123', description: '{{model}} ID' })
id: string;
{{#each selectFields}}
{{#unless (eq this 'id')}}
@ApiProperty({ description: '{{this}}' })
{{this}}: string;
{{/unless}}
{{/each}}
}
{{/each}}
{{/if}}
{{#if hasManyToMany}}
{{#each manyToMany}}
/** {{targetModel}}简要信息 */
export class {{targetModel}}BriefDto {
@ApiProperty({ example: 'clxxx123', description: '{{targetModel}} ID' })
id: string;
{{#each selectFields}}
{{#unless (eq this 'id')}}
@ApiProperty({ description: '{{this}}' })
{{this}}?: string;
{{/unless}}
{{/each}}
}
/** 分配{{name}} DTO */
export class Assign{{pascalCase name}}Dto {
@ApiProperty({ description: '{{targetModel}} ID 列表', type: [String] })
@IsArray()
@IsString({ each: true })
@Type(() => String)
{{target}}Ids: string[];
}
{{/each}}
{{/if}}
/** {{chineseName}}响应 DTO */
export class {{pascalCase name}}ResponseDto implements {{pascalCase name}}Response {
@ApiProperty({ example: 'clxxx123', description: '{{chineseName}} ID' })
@@ -61,6 +106,13 @@ export class {{pascalCase name}}ResponseDto implements {{pascalCase name}}Respon
{{name}}: {{tsResponseType this}};
{{/if}}
{{/each}}
{{#each relations}}
{{#if includeInList}}
@ApiPropertyOptional({ type: {{model}}BriefDto, description: '{{name}}信息' })
{{name}}?: {{model}}BriefDto;
{{/if}}
{{/each}}
@ApiProperty({ example: '2026-01-16T10:00:00.000Z', description: '创建时间' })
createdAt: string;
@@ -74,6 +126,29 @@ export class {{pascalCase name}}ResponseDto implements {{pascalCase name}}Respon
{{/if}}
}
{{#if needsDetailDto}}
/** {{chineseName}}详情响应 DTO */
export class {{pascalCase name}}DetailResponseDto extends {{pascalCase name}}ResponseDto {
{{#each relations}}
{{#unless includeInList}}
@ApiPropertyOptional({ type: {{model}}BriefDto, description: '{{name}}信息' })
{{name}}?: {{model}}BriefDto;
{{/unless}}
{{/each}}
{{#each manyToMany}}
@ApiProperty({ type: [{{targetModel}}BriefDto], description: '{{name}}列表' })
{{name}}: {{targetModel}}BriefDto[];
{{/each}}
{{#each countRelations}}
@ApiProperty({ example: 0, description: '{{this}}数量' })
{{this}}Count: number;
{{/each}}
}
{{/if}}
/** 分页{{chineseName}}响应 DTO */
export class Paginated{{pascalCase name}}ResponseDto extends createPaginatedResponseDto(
{{pascalCase name}}ResponseDto,

View File

@@ -1,14 +1,45 @@
import { Injectable } from '@nestjs/common';
import { Prisma, {{pascalCase name}} } from '@prisma/client';
import type { Prisma, {{pascalCase name}} } from '@prisma/client';
import { Create{{pascalCase name}}Dto, Update{{pascalCase name}}Dto } from './dto/{{kebabCase name}}.dto';
{{#if needsResponseDto}}
import { {{pascalCase name}}ResponseDto{{#if needsDetailDto}}, {{pascalCase name}}DetailResponseDto{{/if}} } from './dto/{{kebabCase name}}.dto';
{{else}}
import { Update{{pascalCase name}}Dto } from './dto/{{kebabCase name}}.dto';
{{/if}}
import { CrudOptions, CrudService } from '@/common/crud';
import { CrudOptions } from '@/common/crud/crud.decorator';
{{#if (eq serviceType 'CrudService')}}
import { CrudService } from '@/common/crud/crud.service';
{{else if (eq serviceType 'RelationCrudService')}}
import { RelationCrudService } from '@/common/crud/relation-crud.service';
{{else}}
import { ManyToManyCrudService } from '@/common/crud/many-to-many-crud.service';
{{/if}}
import { PrismaService } from '@/prisma/prisma.service';
{{#if hasRelations}}
// 带关联的实体类型
type {{pascalCase name}}WithRelations = {{pascalCase name}} & {
{{#each relations}}
{{name}}?: { {{#each selectFields}}{{this}}: string{{#unless @last}}; {{/unless}}{{/each}} } | null;
{{/each}}
};
{{/if}}
{{#if needsDetailDto}}
// 详情实体类型(包含多对多和统计)
type {{pascalCase name}}WithDetails = {{#if hasRelations}}{{pascalCase name}}WithRelations{{else}}{{pascalCase name}}{{/if}} & {
{{#each manyToMany}}
{{name}}?: Array<{ {{target}}: { {{#each selectFields}}{{this}}: string{{#unless @last}}; {{/unless}}{{/each}} } }>;
{{/each}}
{{#if hasCountRelations}}
_count?: { {{#each countRelations}}{{this}}: number{{#unless @last}}; {{/unless}}{{/each}} };
{{/if}}
};
{{/if}}
@Injectable()
@CrudOptions({
softDelete: {{softDelete}},
defaultPageSize: {{defaultPageSize}},
maxPageSize: {{maxPageSize}},
defaultSortBy: '{{defaultSortBy}}',
@@ -24,13 +55,58 @@ import { PrismaService } from '@/prisma/prisma.service';
deletedAt: true,
{{/if}}
},
{{#if hasQueryDto}}
filterableFields: [
{{#each queryFields}}
{{#if (eq type 'string')}}
{ field: '{{name}}', operator: 'contains' },
{{else}}
'{{name}}',
{{/if}}
{{/each}}
],
{{/if}}
{{#if hasRelations}}
relations: {
{{#each relations}}
{{name}}: {
select: { {{#each selectFields}}{{this}}: true{{#unless @last}}, {{/unless}}{{/each}} },
{{#unless includeInList}}
includeInList: false,
{{/unless}}
},
{{/each}}
},
{{/if}}
{{#if hasManyToMany}}
manyToMany: {
{{#each manyToMany}}
{{name}}: {
through: '{{through}}',
foreignKey: '{{foreignKey}}',
targetKey: '{{targetKey}}',
target: '{{target}}',
targetSelect: { {{#each selectFields}}{{this}}: true{{#unless @last}}, {{/unless}}{{/each}} },
},
{{/each}}
},
{{/if}}
{{#if hasCountRelations}}
countRelations: [{{#each countRelations}}'{{this}}'{{#unless @last}}, {{/unless}}{{/each}}],
{{/if}}
})
export class {{pascalCase name}}Service extends CrudService<
export class {{pascalCase name}}Service extends {{serviceType}}<
{{pascalCase name}},
Create{{pascalCase name}}Dto,
Prisma.{{pascalCase name}}CreateInput,
{{#if needsResponseDto}}
Prisma.{{pascalCase name}}UpdateInput,
{{else}}
Update{{pascalCase name}}Dto,
{{/if}}
Prisma.{{pascalCase name}}WhereInput,
Prisma.{{pascalCase name}}WhereUniqueInput
Prisma.{{pascalCase name}}WhereUniqueInput{{#if needsResponseDto}},
{{pascalCase name}}ResponseDto{{#if needsDetailDto}},
{{pascalCase name}}DetailResponseDto{{/if}}{{/if}}
> {
constructor(prisma: PrismaService) {
super(prisma, '{{camelCase name}}');
@@ -49,4 +125,54 @@ export class {{pascalCase name}}Service extends CrudService<
return '已删除的{{chineseName}}不存在';
}
{{/if}}
{{#if needsResponseDto}}
/**
* 转换为响应 DTO
*/
protected toResponseDto = (entity: {{#if hasRelations}}{{pascalCase name}}WithRelations{{else}}{{pascalCase name}}{{/if}}): {{pascalCase name}}ResponseDto => ({
id: entity.id,
{{#each responseFields}}
{{name}}: entity.{{name}}{{#if nullable}} ?? undefined{{/if}},
{{/each}}
{{#each relations}}
{{#if includeInList}}
{{name}}: entity.{{name}} ?? undefined,
{{/if}}
{{/each}}
createdAt: entity.createdAt.toISOString(),
updatedAt: entity.updatedAt.toISOString(),
{{#if ../softDelete}}
deletedAt: entity.deletedAt?.toISOString() ?? undefined,
{{/if}}
});
{{/if}}
{{#if needsDetailDto}}
/**
* 转换为详情响应 DTO
*/
protected override toDetailDto(entity: {{pascalCase name}}WithDetails): {{pascalCase name}}DetailResponseDto {
return {
id: entity.id,
{{#each responseFields}}
{{name}}: entity.{{name}}{{#if nullable}} ?? undefined{{/if}},
{{/each}}
{{#each relations}}
{{name}}: entity.{{name}} ?? undefined,
{{/each}}
{{#each manyToMany}}
{{name}}: entity.{{name}}?.map((item) => item.{{target}}) ?? [],
{{/each}}
{{#each countRelations}}
{{this}}Count: entity._count?.{{this}} ?? 0,
{{/each}}
createdAt: entity.createdAt.toISOString(),
updatedAt: entity.updatedAt.toISOString(),
{{#if ../softDelete}}
deletedAt: entity.deletedAt?.toISOString() ?? undefined,
{{/if}}
};
}
{{/if}}
}

View File

@@ -1,10 +1,43 @@
// ==================== {{chineseName}}相关类型 ====================
{{#if hasRelations}}
{{#each relations}}
/** {{model}}简要信息 */
export interface {{model}}Brief {
id: string;
{{#each selectFields}}
{{#unless (eq this 'id')}}
{{this}}: string;
{{/unless}}
{{/each}}
}
{{/each}}
{{/if}}
{{#if hasManyToMany}}
{{#each manyToMany}}
/** {{targetModel}}简要信息 */
export interface {{targetModel}}Brief {
id: string;
{{#each selectFields}}
{{#unless (eq this 'id')}}
{{this}}?: string;
{{/unless}}
{{/each}}
}
{{/each}}
{{/if}}
/** {{chineseName}}响应API 返回) */
export interface {{pascalCase name}}Response {
id: string;
{{#each responseFields}}
{{name}}: {{tsResponseType this}}{{#if nullable}} | null{{/if}};
{{/each}}
{{#each relations}}
{{#if includeInList}}
{{name}}?: {{model}}Brief;
{{/if}}
{{/each}}
createdAt: string;
updatedAt: string;
@@ -12,6 +45,23 @@ export interface {{pascalCase name}}Response {
deletedAt?: string | null;
{{/if}}
}
{{#if needsDetailDto}}
/** {{chineseName}}详情响应 */
export interface {{pascalCase name}}DetailResponse extends {{pascalCase name}}Response {
{{#each relations}}
{{#unless includeInList}}
{{name}}?: {{model}}Brief;
{{/unless}}
{{/each}}
{{#each manyToMany}}
{{name}}: {{targetModel}}Brief[];
{{/each}}
{{#each countRelations}}
{{this}}Count: number;
{{/each}}
}
{{/if}}
/** 创建{{chineseName}}请求 */
export interface Create{{pascalCase name}}Dto {
@@ -26,3 +76,12 @@ export interface Update{{pascalCase name}}Dto {
{{name}}?: {{tsType this}};
{{/each}}
}
{{#if hasManyToMany}}
{{#each manyToMany}}
/** 分配{{name}}请求 */
export interface Assign{{pascalCase name}}Dto {
{{target}}Ids: string[];
}
{{/each}}
{{/if}}