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:
charilezhou
2026-01-19 16:02:48 +08:00
parent 3ae13fd512
commit f126e03cf1
4 changed files with 703 additions and 37 deletions

View File

@@ -1,10 +1,25 @@
/**
* CRUD 生成器
* 交互式提问和文件生成逻辑
*
* 支持三种服务类型:
* - CrudService: 单表 CRUD
* - RelationCrudService: 带关联查询
* - ManyToManyCrudService: 多对多关系
*/
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 {
@@ -28,6 +43,19 @@ interface TemplateData {
hasTextarea: boolean;
hasSelect: 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 },
],
},
// 服务类型选择
{
type: 'list',
name: 'serviceType',
message: '选择服务类型:',
choices: [
{ name: 'CrudService单表 CRUD', value: 'CrudService' },
{ name: 'RelationCrudService带关联查询', value: 'RelationCrudService' },
{ name: 'ManyToManyCrudService多对多关系', value: 'ManyToManyCrudService' },
],
default: 'CrudService',
},
{
type: 'confirm',
name: 'softDelete',
@@ -98,6 +138,49 @@ export function crudGenerator(plop: NodePlopAPI) {
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',
name: 'searchFieldNames',
@@ -172,6 +255,28 @@ name:string 名称 "示例名称" min:2 max:100
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 = {
name: data.name,
@@ -200,6 +305,17 @@ name:string 名称 "示例名称" min:2 max:100
}),
hasSelect: fields.some((f) => f.type === 'enum'),
hasSwitch: fields.some((f) => f.type === 'boolean'),
// 服务类型相关
serviceType,
relations,
manyToMany,
countRelations,
hasRelations,
hasManyToMany,
hasCountRelations,
needsResponseDto,
needsDetailDto,
};
const actions: ActionType[] = [];
@@ -349,7 +465,17 @@ name:string 名称 "示例名称" min:2 max:100
// 打印生成信息
actions.push(() => {
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')) {
console.log('1. 运行 pnpm db:generate && pnpm db:push');
}

View File

@@ -16,13 +16,15 @@ import {
getFormControl,
getCellRenderer,
getWhereCondition,
} from '../utils/field-parser.ts';
} from '../utils/field-parser';
/**
* 命名转换工具函数
*/
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 {
@@ -64,56 +66,44 @@ export function registerHelpers(plop: NodePlopAPI) {
plop.setHelper('tsType', (field: FieldDefinition) => getTsType(field));
plop.setHelper('tsResponseType', (field: FieldDefinition) =>
getTsResponseType(field),
);
plop.setHelper('tsResponseType', (field: FieldDefinition) => getTsResponseType(field));
plop.setHelper('prismaType', (field: FieldDefinition) =>
getPrismaType(field),
);
plop.setHelper('prismaType', (field: FieldDefinition) => getPrismaType(field));
// ===== 验证相关 helpers =====
plop.setHelper('validationDecorators', (field: FieldDefinition) =>
getValidationDecorators(field),
getValidationDecorators(field)
);
plop.setHelper('validationImports', (fields: FieldDefinition[]) =>
getValidationImports(fields).join(', '),
getValidationImports(fields).join(', ')
);
// ===== Zod 验证 helpers =====
plop.setHelper('zodValidation', (field: FieldDefinition) =>
getZodValidation(field),
);
plop.setHelper('zodValidation', (field: FieldDefinition) => getZodValidation(field));
// ===== 表单控件 helpers =====
plop.setHelper('formControl', (field: FieldDefinition) =>
getFormControl(field),
);
plop.setHelper('formControl', (field: FieldDefinition) => getFormControl(field));
// ===== 表格渲染 helpers =====
plop.setHelper('cellRenderer', (field: FieldDefinition) =>
getCellRenderer(field),
);
plop.setHelper('cellRenderer', (field: FieldDefinition) => getCellRenderer(field));
// ===== 查询条件 helpers =====
plop.setHelper('whereCondition', (field: FieldDefinition) =>
getWhereCondition(field),
);
plop.setHelper('whereCondition', (field: FieldDefinition) => getWhereCondition(field));
// ===== 条件判断 helpers =====
plop.setHelper('hasValidation', (fields: FieldDefinition[]) =>
fields.some((f) => f.validations.length > 0),
fields.some((f) => f.validations.length > 0)
);
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[]) =>
@@ -121,22 +111,18 @@ export function registerHelpers(plop: NodePlopAPI) {
if (f.type !== 'string') return false;
const maxLen = f.validations.find((v) => v.type === 'max')?.value;
return maxLen && Number(maxLen) > 100;
}),
})
);
plop.setHelper('hasSelect', (fields: FieldDefinition[]) =>
fields.some((f) => f.type === 'enum'),
);
plop.setHelper('hasSelect', (fields: FieldDefinition[]) => fields.some((f) => f.type === 'enum'));
plop.setHelper('hasSwitch', (fields: FieldDefinition[]) =>
fields.some((f) => f.type === 'boolean'),
fields.some((f) => f.type === 'boolean')
);
// ===== 字符串处理 helpers =====
plop.setHelper('join', (arr: string[], separator: string) =>
arr.join(separator),
);
plop.setHelper('join', (arr: string[], separator: string) => arr.join(separator));
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('includes', (arr: unknown[], value: unknown) =>
arr?.includes(value),
);
plop.setHelper('includes', (arr: unknown[], value: unknown) => arr?.includes(value));
// ===== 默认值 helpers =====

View 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('; ')} } }>`;
}