feat(plop): 代码生成器支持 CrudService 分层架构
- 新增 relation-parser.ts 关联关系 DSL 解析器 - 生成器支持三种服务类型选择:CrudService/RelationCrudService/ManyToManyCrudService - 添加关联关系、多对多关系、统计关系配置问题 - 修复 helpers 导入路径扩展名问题 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
340
docs/workflow/250119-plop-crud-generator-upgrade.md
Normal file
340
docs/workflow/250119-plop-crud-generator-upgrade.md
Normal file
@@ -0,0 +1,340 @@
|
|||||||
|
# Plop CRUD 代码生成器升级计划
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
根据 CrudService 分层架构(CrudService → RelationCrudService → ManyToManyCrudService),调整 plop 代码生成器,支持生成带关联关系的模块。
|
||||||
|
|
||||||
|
## 现状分析
|
||||||
|
|
||||||
|
### 当前生成器能力
|
||||||
|
- 生成单表 CRUD 模块(继承 CrudService)
|
||||||
|
- 支持软删除配置
|
||||||
|
- 支持字段 DSL 定义
|
||||||
|
- 支持过滤字段配置
|
||||||
|
|
||||||
|
### 不支持的场景
|
||||||
|
- 带关联查询的模块(RelationCrudService)
|
||||||
|
- 多对多关系的模块(ManyToManyCrudService)
|
||||||
|
- 生成 toResponseDto / toDetailDto 方法
|
||||||
|
- 生成关联类型定义
|
||||||
|
|
||||||
|
## 升级方案
|
||||||
|
|
||||||
|
### 新增交互问题
|
||||||
|
|
||||||
|
#### 1. 服务类型选择
|
||||||
|
```
|
||||||
|
? 选择服务类型:
|
||||||
|
○ CrudService(单表 CRUD)
|
||||||
|
○ RelationCrudService(带关联查询)
|
||||||
|
○ ManyToManyCrudService(多对多关系)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. 关联关系配置(当选择 RelationCrudService 或 ManyToManyCrudService)
|
||||||
|
使用编辑器输入 DSL 格式:
|
||||||
|
```
|
||||||
|
# 关联关系定义(每行一个关联)
|
||||||
|
# 格式: 关联名:目标模型 字段1,字段2,... [noList]
|
||||||
|
# noList: 不在列表中包含
|
||||||
|
#
|
||||||
|
# 示例:
|
||||||
|
# class:Class id,code,name
|
||||||
|
# headTeacher:Teacher id,teacherNo,name,subject
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3. 多对多关系配置(当选择 ManyToManyCrudService)
|
||||||
|
使用编辑器输入 DSL 格式:
|
||||||
|
```
|
||||||
|
# 多对多关系定义(每行一个关系)
|
||||||
|
# 格式: 关系名:中间表:外键:目标键:目标模型 字段1,字段2,...
|
||||||
|
#
|
||||||
|
# 示例:
|
||||||
|
# teachers:classTeacher:classId:teacherId:Teacher id,teacherNo,name,subject
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 4. 统计关系配置(当选择 RelationCrudService 或 ManyToManyCrudService)
|
||||||
|
```
|
||||||
|
? 需要在详情中统计数量的关系(逗号分隔,如 students,orders):
|
||||||
|
```
|
||||||
|
|
||||||
|
### 新增模板数据字段
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface TemplateData {
|
||||||
|
// 现有字段...
|
||||||
|
|
||||||
|
// 服务类型
|
||||||
|
serviceType: 'CrudService' | 'RelationCrudService' | 'ManyToManyCrudService';
|
||||||
|
|
||||||
|
// 关联配置
|
||||||
|
relations: Array<{
|
||||||
|
name: string; // 关联名,如 'class'
|
||||||
|
model: string; // 目标模型,如 'Class'
|
||||||
|
selectFields: string[]; // select 字段,如 ['id', 'code', 'name']
|
||||||
|
includeInList: boolean; // 是否在列表中包含
|
||||||
|
}>;
|
||||||
|
|
||||||
|
// 多对多配置
|
||||||
|
manyToMany: Array<{
|
||||||
|
name: string; // 关系名,如 'teachers'
|
||||||
|
through: string; // 中间表,如 'classTeacher'
|
||||||
|
foreignKey: string; // 当前实体外键,如 'classId'
|
||||||
|
targetKey: string; // 目标实体外键,如 'teacherId'
|
||||||
|
target: string; // 目标模型,如 'teacher'
|
||||||
|
targetModel: string; // 目标模型 PascalCase,如 'Teacher'
|
||||||
|
selectFields: string[]; // 目标实体 select 字段
|
||||||
|
}>;
|
||||||
|
|
||||||
|
// 统计关系
|
||||||
|
countRelations: string[];
|
||||||
|
|
||||||
|
// 派生标志
|
||||||
|
hasRelations: boolean;
|
||||||
|
hasManyToMany: boolean;
|
||||||
|
hasCountRelations: boolean;
|
||||||
|
needsResponseDto: boolean; // RelationCrudService 或 ManyToManyCrudService 时为 true
|
||||||
|
needsDetailDto: boolean; // ManyToManyCrudService 时为 true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 模板调整
|
||||||
|
|
||||||
|
#### service.hbs 调整
|
||||||
|
|
||||||
|
**调整前**:只支持 CrudService
|
||||||
|
```typescript
|
||||||
|
export class {{pascalCase name}}Service extends CrudService<...>
|
||||||
|
```
|
||||||
|
|
||||||
|
**调整后**:根据 serviceType 生成不同的代码
|
||||||
|
|
||||||
|
```handlebars
|
||||||
|
{{#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}}
|
||||||
|
|
||||||
|
// 生成关联类型定义
|
||||||
|
{{#if hasRelations}}
|
||||||
|
type {{pascalCase name}}WithRelations = {{pascalCase name}} & {
|
||||||
|
{{#each relations}}
|
||||||
|
{{name}}?: { {{#each selectFields}}{{this}}: {{../typeForField this}}; {{/each}}} | null;
|
||||||
|
{{/each}}
|
||||||
|
};
|
||||||
|
{{/if}}
|
||||||
|
|
||||||
|
// 生成详情类型定义(多对多)
|
||||||
|
{{#if hasManyToMany}}
|
||||||
|
type {{pascalCase name}}WithDetails = {{pascalCase name}}WithRelations & {
|
||||||
|
{{#each manyToMany}}
|
||||||
|
{{name}}?: Array<{ {{target}}: { {{#each selectFields}}{{this}}: {{../typeForField this}}; {{/each}}} }>;
|
||||||
|
{{/each}}
|
||||||
|
{{#if hasCountRelations}}
|
||||||
|
_count?: { {{#each countRelations}}{{this}}: number; {{/each}} };
|
||||||
|
{{/if}}
|
||||||
|
};
|
||||||
|
{{/if}}
|
||||||
|
|
||||||
|
// 继承对应的基类
|
||||||
|
export class {{pascalCase name}}Service extends {{serviceType}}<
|
||||||
|
{{pascalCase name}},
|
||||||
|
...
|
||||||
|
{{#if needsResponseDto}}
|
||||||
|
{{pascalCase name}}ResponseDto{{#if needsDetailDto}},
|
||||||
|
{{pascalCase name}}DetailResponseDto{{/if}}
|
||||||
|
{{/if}}
|
||||||
|
>
|
||||||
|
|
||||||
|
// 生成 @CrudOptions 配置
|
||||||
|
@CrudOptions({
|
||||||
|
filterableFields: [...],
|
||||||
|
{{#if hasRelations}}
|
||||||
|
relations: {
|
||||||
|
{{#each relations}}
|
||||||
|
{{name}}: { select: { {{#each selectFields}}{{this}}: true, {{/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, {{/each}} },
|
||||||
|
},
|
||||||
|
{{/each}}
|
||||||
|
},
|
||||||
|
{{/if}}
|
||||||
|
{{#if hasCountRelations}}
|
||||||
|
countRelations: [{{#each countRelations}}'{{this}}', {{/each}}],
|
||||||
|
{{/if}}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 生成 toResponseDto(RelationCrudService/ManyToManyCrudService 需要)
|
||||||
|
{{#if needsResponseDto}}
|
||||||
|
protected toResponseDto = (entity: {{pascalCase name}}WithRelations): {{pascalCase name}}ResponseDto => ({
|
||||||
|
id: entity.id,
|
||||||
|
{{#each fields}}
|
||||||
|
{{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}}
|
||||||
|
|
||||||
|
// 生成 toDetailDto(ManyToManyCrudService 需要)
|
||||||
|
{{#if needsDetailDto}}
|
||||||
|
protected override toDetailDto(entity: {{pascalCase name}}WithDetails): {{pascalCase name}}DetailResponseDto {
|
||||||
|
return {
|
||||||
|
...this.toResponseDto(entity),
|
||||||
|
{{#each manyToMany}}
|
||||||
|
{{name}}: entity.{{name}}?.map((item) => item.{{target}}) ?? [],
|
||||||
|
{{/each}}
|
||||||
|
{{#each countRelations}}
|
||||||
|
{{this}}Count: entity._count?.{{this}} ?? 0,
|
||||||
|
{{/each}}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
{{/if}}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### dto.hbs 调整
|
||||||
|
|
||||||
|
新增详情响应 DTO(当有多对多关系时):
|
||||||
|
|
||||||
|
```handlebars
|
||||||
|
{{#if needsDetailDto}}
|
||||||
|
/** {{chineseName}}详情响应 DTO */
|
||||||
|
export class {{pascalCase name}}DetailResponseDto extends {{pascalCase name}}ResponseDto {
|
||||||
|
{{#each manyToMany}}
|
||||||
|
@ApiProperty({ type: [{{targetModel}}BriefDto], description: '{{../chineseName}}的{{name}}' })
|
||||||
|
{{name}}: {{targetModel}}BriefDto[];
|
||||||
|
|
||||||
|
{{/each}}
|
||||||
|
{{#each countRelations}}
|
||||||
|
@ApiProperty({ example: 0, description: '{{this}}数量' })
|
||||||
|
{{this}}Count: number;
|
||||||
|
|
||||||
|
{{/each}}
|
||||||
|
}
|
||||||
|
{{/if}}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### controller.hbs 调整
|
||||||
|
|
||||||
|
根据服务类型调整端点:
|
||||||
|
|
||||||
|
```handlebars
|
||||||
|
{{#if (or (eq serviceType 'RelationCrudService') (eq serviceType 'ManyToManyCrudService'))}}
|
||||||
|
@Get()
|
||||||
|
findAll(@Query() query: ...) {
|
||||||
|
return this.{{camelCase name}}Service.findAllWithRelations(query);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id')
|
||||||
|
findById(@Param('id') id: string) {
|
||||||
|
return this.{{camelCase name}}Service.findByIdWithRelations(id);
|
||||||
|
}
|
||||||
|
{{/if}}
|
||||||
|
|
||||||
|
{{#if hasManyToMany}}
|
||||||
|
{{#each manyToMany}}
|
||||||
|
@Patch(':id/{{name}}')
|
||||||
|
@ApiOperation({ summary: '分配{{../chineseName}}的{{name}}' })
|
||||||
|
assign{{pascalCase name}}(@Param('id') id: string, @Body() dto: Assign{{pascalCase name}}Dto) {
|
||||||
|
return this.{{camelCase ../name}}Service.assignManyToMany(id, '{{name}}', { targetIds: dto.{{singular name}}Ids });
|
||||||
|
}
|
||||||
|
{{/each}}
|
||||||
|
{{/if}}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 实施步骤
|
||||||
|
|
||||||
|
#### 阶段 1:扩展生成器逻辑
|
||||||
|
- 文件:`plop/generators/crud.ts`
|
||||||
|
- 内容:
|
||||||
|
1. 添加 serviceType 选择问题
|
||||||
|
2. 添加关联配置问题(条件显示)
|
||||||
|
3. 添加多对多配置问题(条件显示)
|
||||||
|
4. 添加统计关系配置问题(条件显示)
|
||||||
|
5. 扩展 TemplateData 类型
|
||||||
|
6. 添加关联/多对多 DSL 解析函数
|
||||||
|
|
||||||
|
#### 阶段 2:新增辅助函数
|
||||||
|
- 文件:`plop/utils/relation-parser.ts`(新建)
|
||||||
|
- 内容:
|
||||||
|
1. `parseRelations(dsl)` - 解析关联配置
|
||||||
|
2. `parseManyToMany(dsl)` - 解析多对多配置
|
||||||
|
|
||||||
|
#### 阶段 3:调整模板
|
||||||
|
- 文件:`plop/templates/api/service.hbs`
|
||||||
|
- 文件:`plop/templates/api/dto.hbs`
|
||||||
|
- 文件:`plop/templates/api/controller.hbs`
|
||||||
|
- 文件:`plop/templates/shared/types.hbs`
|
||||||
|
|
||||||
|
#### 阶段 4:测试验证
|
||||||
|
1. 生成单表 CRUD 模块(如 Product)
|
||||||
|
2. 生成带关联查询的模块(模拟 Student)
|
||||||
|
3. 生成多对多关系的模块(模拟 Class)
|
||||||
|
|
||||||
|
## 预期效果
|
||||||
|
|
||||||
|
### 生成单表模块示例
|
||||||
|
```bash
|
||||||
|
$ pnpm plop crud
|
||||||
|
? 模块名称: product
|
||||||
|
? 服务类型: CrudService(单表 CRUD)
|
||||||
|
# 其他问题...
|
||||||
|
```
|
||||||
|
|
||||||
|
### 生成关联查询模块示例
|
||||||
|
```bash
|
||||||
|
$ pnpm plop crud
|
||||||
|
? 模块名称: order
|
||||||
|
? 服务类型: RelationCrudService(带关联查询)
|
||||||
|
? 关联关系配置:
|
||||||
|
user:User id,name,email
|
||||||
|
product:Product id,name,price
|
||||||
|
? 统计关系: items
|
||||||
|
```
|
||||||
|
|
||||||
|
### 生成多对多模块示例
|
||||||
|
```bash
|
||||||
|
$ pnpm plop crud
|
||||||
|
? 模块名称: course
|
||||||
|
? 服务类型: ManyToManyCrudService(多对多关系)
|
||||||
|
? 关联关系配置:
|
||||||
|
teacher:Teacher id,name
|
||||||
|
? 多对多关系配置:
|
||||||
|
students:courseStudent:courseId:studentId:Student id,name,studentNo
|
||||||
|
? 统计关系: lessons
|
||||||
|
```
|
||||||
|
|
||||||
|
## 向后兼容性
|
||||||
|
|
||||||
|
- 现有的单表 CRUD 模块生成方式保持不变
|
||||||
|
- 选择 CrudService 时,行为与调整前完全一致
|
||||||
|
- 新增的关联配置问题仅在选择 RelationCrudService 或 ManyToManyCrudService 时显示
|
||||||
|
|
||||||
|
## 文件清单
|
||||||
|
|
||||||
|
| 文件 | 操作 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `plop/generators/crud.ts` | 修改 | 添加服务类型选择和关联配置问题 |
|
||||||
|
| `plop/utils/relation-parser.ts` | 新建 | 关联/多对多 DSL 解析器 |
|
||||||
|
| `plop/helpers/index.ts` | 修改 | 添加新的 helper(如果需要)|
|
||||||
|
| `plop/templates/api/service.hbs` | 修改 | 支持三种服务类型 |
|
||||||
|
| `plop/templates/api/dto.hbs` | 修改 | 支持关联字段和详情 DTO |
|
||||||
|
| `plop/templates/api/controller.hbs` | 修改 | 支持关联查询端点 |
|
||||||
|
| `plop/templates/shared/types.hbs` | 修改 | 添加关联类型定义 |
|
||||||
@@ -1,10 +1,25 @@
|
|||||||
/**
|
/**
|
||||||
* CRUD 生成器
|
* CRUD 生成器
|
||||||
* 交互式提问和文件生成逻辑
|
* 交互式提问和文件生成逻辑
|
||||||
|
*
|
||||||
|
* 支持三种服务类型:
|
||||||
|
* - CrudService: 单表 CRUD
|
||||||
|
* - RelationCrudService: 带关联查询
|
||||||
|
* - ManyToManyCrudService: 多对多关系
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { NodePlopAPI, ActionType } from 'plop';
|
import type { NodePlopAPI, ActionType } from 'plop';
|
||||||
import { parseFields, type FieldDefinition } from '../utils/field-parser.ts';
|
import { parseFields, type FieldDefinition } from '../utils/field-parser';
|
||||||
|
import {
|
||||||
|
parseRelations,
|
||||||
|
parseManyToMany,
|
||||||
|
parseCountRelations,
|
||||||
|
type RelationConfig,
|
||||||
|
type ManyToManyConfig,
|
||||||
|
} from '../utils/relation-parser';
|
||||||
|
|
||||||
|
// 服务类型
|
||||||
|
type ServiceType = 'CrudService' | 'RelationCrudService' | 'ManyToManyCrudService';
|
||||||
|
|
||||||
// 模板数据类型
|
// 模板数据类型
|
||||||
interface TemplateData {
|
interface TemplateData {
|
||||||
@@ -28,6 +43,19 @@ interface TemplateData {
|
|||||||
hasTextarea: boolean;
|
hasTextarea: boolean;
|
||||||
hasSelect: boolean;
|
hasSelect: boolean;
|
||||||
hasSwitch: boolean;
|
hasSwitch: boolean;
|
||||||
|
|
||||||
|
// 服务类型相关
|
||||||
|
serviceType: ServiceType;
|
||||||
|
relations: RelationConfig[];
|
||||||
|
manyToMany: ManyToManyConfig[];
|
||||||
|
countRelations: string[];
|
||||||
|
|
||||||
|
// 派生标志
|
||||||
|
hasRelations: boolean;
|
||||||
|
hasManyToMany: boolean;
|
||||||
|
hasCountRelations: boolean;
|
||||||
|
needsResponseDto: boolean;
|
||||||
|
needsDetailDto: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -71,6 +99,18 @@ export function crudGenerator(plop: NodePlopAPI) {
|
|||||||
{ name: 'Prisma Model', value: 'prisma', checked: true },
|
{ name: 'Prisma Model', value: 'prisma', checked: true },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
// 服务类型选择
|
||||||
|
{
|
||||||
|
type: 'list',
|
||||||
|
name: 'serviceType',
|
||||||
|
message: '选择服务类型:',
|
||||||
|
choices: [
|
||||||
|
{ name: 'CrudService(单表 CRUD)', value: 'CrudService' },
|
||||||
|
{ name: 'RelationCrudService(带关联查询)', value: 'RelationCrudService' },
|
||||||
|
{ name: 'ManyToManyCrudService(多对多关系)', value: 'ManyToManyCrudService' },
|
||||||
|
],
|
||||||
|
default: 'CrudService',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
type: 'confirm',
|
type: 'confirm',
|
||||||
name: 'softDelete',
|
name: 'softDelete',
|
||||||
@@ -98,6 +138,49 @@ export function crudGenerator(plop: NodePlopAPI) {
|
|||||||
name:string 名称 "示例名称" min:2 max:100
|
name:string 名称 "示例名称" min:2 max:100
|
||||||
`,
|
`,
|
||||||
},
|
},
|
||||||
|
// 关联关系配置(RelationCrudService 或 ManyToManyCrudService 时显示)
|
||||||
|
{
|
||||||
|
type: 'editor',
|
||||||
|
name: 'relationsRaw',
|
||||||
|
message: '定义关联关系(见下方示例):',
|
||||||
|
when: (answers: { serviceType: ServiceType }) =>
|
||||||
|
answers.serviceType === 'RelationCrudService' ||
|
||||||
|
answers.serviceType === 'ManyToManyCrudService',
|
||||||
|
default: `# 关联关系定义(每行一个关联)
|
||||||
|
# 格式: 关联名:目标模型 字段1,字段2,... [noList]
|
||||||
|
# noList: 不在列表中包含
|
||||||
|
#
|
||||||
|
# 示例:
|
||||||
|
# class:Class id,code,name
|
||||||
|
# headTeacher:Teacher id,teacherNo,name,subject noList
|
||||||
|
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
// 多对多关系配置(ManyToManyCrudService 时显示)
|
||||||
|
{
|
||||||
|
type: 'editor',
|
||||||
|
name: 'manyToManyRaw',
|
||||||
|
message: '定义多对多关系(见下方示例):',
|
||||||
|
when: (answers: { serviceType: ServiceType }) =>
|
||||||
|
answers.serviceType === 'ManyToManyCrudService',
|
||||||
|
default: `# 多对多关系定义(每行一个关系)
|
||||||
|
# 格式: 关系名:中间表:外键:目标键:目标模型 字段1,字段2,...
|
||||||
|
#
|
||||||
|
# 示例:
|
||||||
|
# teachers:classTeacher:classId:teacherId:Teacher id,teacherNo,name,subject
|
||||||
|
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
// 统计关系配置(RelationCrudService 或 ManyToManyCrudService 时显示)
|
||||||
|
{
|
||||||
|
type: 'input',
|
||||||
|
name: 'countRelationsRaw',
|
||||||
|
message: '统计关系(逗号分隔,如 students,orders,留空跳过):',
|
||||||
|
when: (answers: { serviceType: ServiceType }) =>
|
||||||
|
answers.serviceType === 'RelationCrudService' ||
|
||||||
|
answers.serviceType === 'ManyToManyCrudService',
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
type: 'checkbox',
|
type: 'checkbox',
|
||||||
name: 'searchFieldNames',
|
name: 'searchFieldNames',
|
||||||
@@ -172,6 +255,28 @@ name:string 名称 "示例名称" min:2 max:100
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 解析关联配置
|
||||||
|
const serviceType = data.serviceType as ServiceType;
|
||||||
|
const relations =
|
||||||
|
serviceType !== 'CrudService' && data.relationsRaw
|
||||||
|
? parseRelations(data.relationsRaw)
|
||||||
|
: [];
|
||||||
|
const manyToMany =
|
||||||
|
serviceType === 'ManyToManyCrudService' && data.manyToManyRaw
|
||||||
|
? parseManyToMany(data.manyToManyRaw)
|
||||||
|
: [];
|
||||||
|
const countRelations =
|
||||||
|
serviceType !== 'CrudService' && data.countRelationsRaw
|
||||||
|
? parseCountRelations(data.countRelationsRaw)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
// 派生标志
|
||||||
|
const hasRelations = relations.length > 0;
|
||||||
|
const hasManyToMany = manyToMany.length > 0;
|
||||||
|
const hasCountRelations = countRelations.length > 0;
|
||||||
|
const needsResponseDto = serviceType !== 'CrudService';
|
||||||
|
const needsDetailDto = serviceType === 'ManyToManyCrudService' && (hasManyToMany || hasCountRelations);
|
||||||
|
|
||||||
// 准备模板数据
|
// 准备模板数据
|
||||||
const templateData: TemplateData = {
|
const templateData: TemplateData = {
|
||||||
name: data.name,
|
name: data.name,
|
||||||
@@ -200,6 +305,17 @@ name:string 名称 "示例名称" min:2 max:100
|
|||||||
}),
|
}),
|
||||||
hasSelect: fields.some((f) => f.type === 'enum'),
|
hasSelect: fields.some((f) => f.type === 'enum'),
|
||||||
hasSwitch: fields.some((f) => f.type === 'boolean'),
|
hasSwitch: fields.some((f) => f.type === 'boolean'),
|
||||||
|
|
||||||
|
// 服务类型相关
|
||||||
|
serviceType,
|
||||||
|
relations,
|
||||||
|
manyToMany,
|
||||||
|
countRelations,
|
||||||
|
hasRelations,
|
||||||
|
hasManyToMany,
|
||||||
|
hasCountRelations,
|
||||||
|
needsResponseDto,
|
||||||
|
needsDetailDto,
|
||||||
};
|
};
|
||||||
|
|
||||||
const actions: ActionType[] = [];
|
const actions: ActionType[] = [];
|
||||||
@@ -349,7 +465,17 @@ name:string 名称 "示例名称" min:2 max:100
|
|||||||
// 打印生成信息
|
// 打印生成信息
|
||||||
actions.push(() => {
|
actions.push(() => {
|
||||||
console.log('\n✨ 生成完成!\n');
|
console.log('\n✨ 生成完成!\n');
|
||||||
console.log('后续步骤:');
|
console.log(`服务类型: ${serviceType}`);
|
||||||
|
if (hasRelations) {
|
||||||
|
console.log(`关联关系: ${relations.map((r) => r.name).join(', ')}`);
|
||||||
|
}
|
||||||
|
if (hasManyToMany) {
|
||||||
|
console.log(`多对多关系: ${manyToMany.map((m) => m.name).join(', ')}`);
|
||||||
|
}
|
||||||
|
if (hasCountRelations) {
|
||||||
|
console.log(`统计关系: ${countRelations.join(', ')}`);
|
||||||
|
}
|
||||||
|
console.log('\n后续步骤:');
|
||||||
if (data.generateTargets.includes('prisma')) {
|
if (data.generateTargets.includes('prisma')) {
|
||||||
console.log('1. 运行 pnpm db:generate && pnpm db:push');
|
console.log('1. 运行 pnpm db:generate && pnpm db:push');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,13 +16,15 @@ import {
|
|||||||
getFormControl,
|
getFormControl,
|
||||||
getCellRenderer,
|
getCellRenderer,
|
||||||
getWhereCondition,
|
getWhereCondition,
|
||||||
} from '../utils/field-parser.ts';
|
} from '../utils/field-parser';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 命名转换工具函数
|
* 命名转换工具函数
|
||||||
*/
|
*/
|
||||||
function toCamelCase(str: string): string {
|
function toCamelCase(str: string): string {
|
||||||
return str.replace(/[-_](.)/g, (_, c) => c.toUpperCase()).replace(/^(.)/, (_, c) => c.toLowerCase());
|
return str
|
||||||
|
.replace(/[-_](.)/g, (_, c) => c.toUpperCase())
|
||||||
|
.replace(/^(.)/, (_, c) => c.toLowerCase());
|
||||||
}
|
}
|
||||||
|
|
||||||
function toPascalCase(str: string): string {
|
function toPascalCase(str: string): string {
|
||||||
@@ -64,56 +66,44 @@ export function registerHelpers(plop: NodePlopAPI) {
|
|||||||
|
|
||||||
plop.setHelper('tsType', (field: FieldDefinition) => getTsType(field));
|
plop.setHelper('tsType', (field: FieldDefinition) => getTsType(field));
|
||||||
|
|
||||||
plop.setHelper('tsResponseType', (field: FieldDefinition) =>
|
plop.setHelper('tsResponseType', (field: FieldDefinition) => getTsResponseType(field));
|
||||||
getTsResponseType(field),
|
|
||||||
);
|
|
||||||
|
|
||||||
plop.setHelper('prismaType', (field: FieldDefinition) =>
|
plop.setHelper('prismaType', (field: FieldDefinition) => getPrismaType(field));
|
||||||
getPrismaType(field),
|
|
||||||
);
|
|
||||||
|
|
||||||
// ===== 验证相关 helpers =====
|
// ===== 验证相关 helpers =====
|
||||||
|
|
||||||
plop.setHelper('validationDecorators', (field: FieldDefinition) =>
|
plop.setHelper('validationDecorators', (field: FieldDefinition) =>
|
||||||
getValidationDecorators(field),
|
getValidationDecorators(field)
|
||||||
);
|
);
|
||||||
|
|
||||||
plop.setHelper('validationImports', (fields: FieldDefinition[]) =>
|
plop.setHelper('validationImports', (fields: FieldDefinition[]) =>
|
||||||
getValidationImports(fields).join(', '),
|
getValidationImports(fields).join(', ')
|
||||||
);
|
);
|
||||||
|
|
||||||
// ===== Zod 验证 helpers =====
|
// ===== Zod 验证 helpers =====
|
||||||
|
|
||||||
plop.setHelper('zodValidation', (field: FieldDefinition) =>
|
plop.setHelper('zodValidation', (field: FieldDefinition) => getZodValidation(field));
|
||||||
getZodValidation(field),
|
|
||||||
);
|
|
||||||
|
|
||||||
// ===== 表单控件 helpers =====
|
// ===== 表单控件 helpers =====
|
||||||
|
|
||||||
plop.setHelper('formControl', (field: FieldDefinition) =>
|
plop.setHelper('formControl', (field: FieldDefinition) => getFormControl(field));
|
||||||
getFormControl(field),
|
|
||||||
);
|
|
||||||
|
|
||||||
// ===== 表格渲染 helpers =====
|
// ===== 表格渲染 helpers =====
|
||||||
|
|
||||||
plop.setHelper('cellRenderer', (field: FieldDefinition) =>
|
plop.setHelper('cellRenderer', (field: FieldDefinition) => getCellRenderer(field));
|
||||||
getCellRenderer(field),
|
|
||||||
);
|
|
||||||
|
|
||||||
// ===== 查询条件 helpers =====
|
// ===== 查询条件 helpers =====
|
||||||
|
|
||||||
plop.setHelper('whereCondition', (field: FieldDefinition) =>
|
plop.setHelper('whereCondition', (field: FieldDefinition) => getWhereCondition(field));
|
||||||
getWhereCondition(field),
|
|
||||||
);
|
|
||||||
|
|
||||||
// ===== 条件判断 helpers =====
|
// ===== 条件判断 helpers =====
|
||||||
|
|
||||||
plop.setHelper('hasValidation', (fields: FieldDefinition[]) =>
|
plop.setHelper('hasValidation', (fields: FieldDefinition[]) =>
|
||||||
fields.some((f) => f.validations.length > 0),
|
fields.some((f) => f.validations.length > 0)
|
||||||
);
|
);
|
||||||
|
|
||||||
plop.setHelper('hasTransform', (fields: FieldDefinition[]) =>
|
plop.setHelper('hasTransform', (fields: FieldDefinition[]) =>
|
||||||
fields.some((f) => f.type === 'date' || f.type === 'datetime'),
|
fields.some((f) => f.type === 'date' || f.type === 'datetime')
|
||||||
);
|
);
|
||||||
|
|
||||||
plop.setHelper('hasTextarea', (fields: FieldDefinition[]) =>
|
plop.setHelper('hasTextarea', (fields: FieldDefinition[]) =>
|
||||||
@@ -121,22 +111,18 @@ export function registerHelpers(plop: NodePlopAPI) {
|
|||||||
if (f.type !== 'string') return false;
|
if (f.type !== 'string') return false;
|
||||||
const maxLen = f.validations.find((v) => v.type === 'max')?.value;
|
const maxLen = f.validations.find((v) => v.type === 'max')?.value;
|
||||||
return maxLen && Number(maxLen) > 100;
|
return maxLen && Number(maxLen) > 100;
|
||||||
}),
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
plop.setHelper('hasSelect', (fields: FieldDefinition[]) =>
|
plop.setHelper('hasSelect', (fields: FieldDefinition[]) => fields.some((f) => f.type === 'enum'));
|
||||||
fields.some((f) => f.type === 'enum'),
|
|
||||||
);
|
|
||||||
|
|
||||||
plop.setHelper('hasSwitch', (fields: FieldDefinition[]) =>
|
plop.setHelper('hasSwitch', (fields: FieldDefinition[]) =>
|
||||||
fields.some((f) => f.type === 'boolean'),
|
fields.some((f) => f.type === 'boolean')
|
||||||
);
|
);
|
||||||
|
|
||||||
// ===== 字符串处理 helpers =====
|
// ===== 字符串处理 helpers =====
|
||||||
|
|
||||||
plop.setHelper('join', (arr: string[], separator: string) =>
|
plop.setHelper('join', (arr: string[], separator: string) => arr.join(separator));
|
||||||
arr.join(separator),
|
|
||||||
);
|
|
||||||
|
|
||||||
plop.setHelper('jsonStringify', (obj: unknown) => JSON.stringify(obj));
|
plop.setHelper('jsonStringify', (obj: unknown) => JSON.stringify(obj));
|
||||||
|
|
||||||
@@ -149,9 +135,7 @@ export function registerHelpers(plop: NodePlopAPI) {
|
|||||||
plop.setHelper('not', (a: unknown) => !a);
|
plop.setHelper('not', (a: unknown) => !a);
|
||||||
|
|
||||||
// 判断数组是否包含某值
|
// 判断数组是否包含某值
|
||||||
plop.setHelper('includes', (arr: unknown[], value: unknown) =>
|
plop.setHelper('includes', (arr: unknown[], value: unknown) => arr?.includes(value));
|
||||||
arr?.includes(value),
|
|
||||||
);
|
|
||||||
|
|
||||||
// ===== 默认值 helpers =====
|
// ===== 默认值 helpers =====
|
||||||
|
|
||||||
|
|||||||
216
plop/utils/relation-parser.ts
Normal file
216
plop/utils/relation-parser.ts
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
/**
|
||||||
|
* 关联关系 DSL 解析器
|
||||||
|
*
|
||||||
|
* DSL 格式:
|
||||||
|
* - 关联关系: 关联名:目标模型 字段1,字段2,... [noList]
|
||||||
|
* - 多对多: 关系名:中间表:外键:目标键:目标模型 字段1,字段2,...
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 关联关系配置
|
||||||
|
*/
|
||||||
|
export interface RelationConfig {
|
||||||
|
/** 关联名,如 'class' */
|
||||||
|
name: string;
|
||||||
|
/** 目标模型 PascalCase,如 'Class' */
|
||||||
|
model: string;
|
||||||
|
/** 目标模型 camelCase,如 'class' */
|
||||||
|
modelCamel: string;
|
||||||
|
/** select 字段列表 */
|
||||||
|
selectFields: string[];
|
||||||
|
/** 是否在列表中包含(默认 true) */
|
||||||
|
includeInList: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 多对多关系<E585B3><E7B3BB><EFBFBD>置
|
||||||
|
*/
|
||||||
|
export interface ManyToManyConfig {
|
||||||
|
/** 关系名,如 'teachers' */
|
||||||
|
name: string;
|
||||||
|
/** 中间表名,如 'classTeacher' */
|
||||||
|
through: string;
|
||||||
|
/** 当前实体外键,如 'classId' */
|
||||||
|
foreignKey: string;
|
||||||
|
/** 目标实体外键,如 'teacherId' */
|
||||||
|
targetKey: string;
|
||||||
|
/** 目标模型 camelCase,如 'teacher' */
|
||||||
|
target: string;
|
||||||
|
/** 目标模型 PascalCase,如 'Teacher' */
|
||||||
|
targetModel: string;
|
||||||
|
/** 目标实体 select 字段列表 */
|
||||||
|
selectFields: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析关联关系 DSL
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* 输入:
|
||||||
|
* ```
|
||||||
|
* class:Class id,code,name
|
||||||
|
* headTeacher:Teacher id,teacherNo,name,subject noList
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* 输出:
|
||||||
|
* [
|
||||||
|
* { name: 'class', model: 'Class', selectFields: ['id', 'code', 'name'], includeInList: true },
|
||||||
|
* { name: 'headTeacher', model: 'Teacher', selectFields: ['id', 'teacherNo', 'name', 'subject'], includeInList: false },
|
||||||
|
* ]
|
||||||
|
*/
|
||||||
|
export function parseRelations(dsl: string): RelationConfig[] {
|
||||||
|
const lines = dsl
|
||||||
|
.split('\n')
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter((line) => line && !line.startsWith('#'));
|
||||||
|
|
||||||
|
const relations: RelationConfig[] = [];
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
// 匹配格式: 关联名:目标模型 字段1,字段2,... [noList]
|
||||||
|
const match = line.match(/^(\w+):(\w+)\s+([\w,]+)(?:\s+(noList))?$/);
|
||||||
|
if (!match) {
|
||||||
|
console.warn(`无法解析关联配置行: ${line}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [, name, model, fieldsStr, noListFlag] = match;
|
||||||
|
const selectFields = fieldsStr.split(',').map((f) => f.trim());
|
||||||
|
|
||||||
|
relations.push({
|
||||||
|
name,
|
||||||
|
model,
|
||||||
|
modelCamel: model.charAt(0).toLowerCase() + model.slice(1),
|
||||||
|
selectFields,
|
||||||
|
includeInList: !noListFlag,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return relations;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析多对多关系 DSL
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* 输入:
|
||||||
|
* ```
|
||||||
|
* teachers:classTeacher:classId:teacherId:Teacher id,teacherNo,name,subject
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* 输出:
|
||||||
|
* [
|
||||||
|
* {
|
||||||
|
* name: 'teachers',
|
||||||
|
* through: 'classTeacher',
|
||||||
|
* foreignKey: 'classId',
|
||||||
|
* targetKey: 'teacherId',
|
||||||
|
* target: 'teacher',
|
||||||
|
* targetModel: 'Teacher',
|
||||||
|
* selectFields: ['id', 'teacherNo', 'name', 'subject'],
|
||||||
|
* },
|
||||||
|
* ]
|
||||||
|
*/
|
||||||
|
export function parseManyToMany(dsl: string): ManyToManyConfig[] {
|
||||||
|
const lines = dsl
|
||||||
|
.split('\n')
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter((line) => line && !line.startsWith('#'));
|
||||||
|
|
||||||
|
const configs: ManyToManyConfig[] = [];
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
// 匹配格式: 关系名:中间表:外键:目标键:目标模型 字段1,字段2,...
|
||||||
|
const match = line.match(/^(\w+):(\w+):(\w+):(\w+):(\w+)\s+([\w,]+)$/);
|
||||||
|
if (!match) {
|
||||||
|
console.warn(`无法解析多对多配置行: ${line}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [, name, through, foreignKey, targetKey, targetModel, fieldsStr] = match;
|
||||||
|
const selectFields = fieldsStr.split(',').map((f) => f.trim());
|
||||||
|
|
||||||
|
configs.push({
|
||||||
|
name,
|
||||||
|
through,
|
||||||
|
foreignKey,
|
||||||
|
targetKey,
|
||||||
|
target: targetModel.charAt(0).toLowerCase() + targetModel.slice(1),
|
||||||
|
targetModel,
|
||||||
|
selectFields,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return configs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析统计关系(逗号分隔的字符串)
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* 输入: "students, orders"
|
||||||
|
* 输出: ['students', 'orders']
|
||||||
|
*/
|
||||||
|
export function parseCountRelations(input: string): string[] {
|
||||||
|
if (!input || !input.trim()) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return input
|
||||||
|
.split(',')
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取字段的 TypeScript 类型(用于生成关联类型)
|
||||||
|
* 简化版本,假设大多数字段是 string
|
||||||
|
*/
|
||||||
|
export function getFieldType(fieldName: string): string {
|
||||||
|
// 常见的非字符串字段
|
||||||
|
const numberFields = ['id', 'count', 'amount', 'price', 'quantity', 'sort', 'order'];
|
||||||
|
const booleanFields = ['is', 'has', 'can', 'should', 'enabled', 'active', 'visible'];
|
||||||
|
|
||||||
|
if (numberFields.some((f) => fieldName.toLowerCase().includes(f))) {
|
||||||
|
return 'number';
|
||||||
|
}
|
||||||
|
if (booleanFields.some((f) => fieldName.toLowerCase().startsWith(f))) {
|
||||||
|
return 'boolean';
|
||||||
|
}
|
||||||
|
return 'string';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成关联类型的 select 对象字符串
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* 输入: ['id', 'code', 'name']
|
||||||
|
* 输出: '{ id: true, code: true, name: true }'
|
||||||
|
*/
|
||||||
|
export function generateSelectObject(fields: string[]): string {
|
||||||
|
const pairs = fields.map((f) => `${f}: true`);
|
||||||
|
return `{ ${pairs.join(', ')} }`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成关联类型定义字符串
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* 输入: { name: 'class', selectFields: ['id', 'code', 'name'] }
|
||||||
|
* 输出: 'class?: { id: string; code: string; name: string } | null'
|
||||||
|
*/
|
||||||
|
export function generateRelationTypeField(config: RelationConfig): string {
|
||||||
|
const fieldTypes = config.selectFields.map((f) => `${f}: ${getFieldType(f)}`);
|
||||||
|
return `${config.name}?: { ${fieldTypes.join('; ')} } | null`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成多对多类型定义字符串
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* 输入: { name: 'teachers', target: 'teacher', selectFields: ['id', 'name'] }
|
||||||
|
* 输出: 'teachers?: Array<{ teacher: { id: string; name: string } }>'
|
||||||
|
*/
|
||||||
|
export function generateManyToManyTypeField(config: ManyToManyConfig): string {
|
||||||
|
const fieldTypes = config.selectFields.map((f) => `${f}: ${getFieldType(f)}`);
|
||||||
|
return `${config.name}?: Array<{ ${config.target}: { ${fieldTypes.join('; ')} } }>`;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user