Files
seclusion/plop/generators/crud.ts
charilezhou 3119460f13 feat(plop): 优化生成器支持 Prisma 关联关系
- 支持一对多/多对一关系定义并生成到 Prisma schema
- 简化流程:查询关联配置根据关系自动预填
- 修复 Handlebars 模板 HTML 转义导致的乱码问题
- 修复 controller 模板缺少 Prisma 导入的问题
- 新增页面模板 (page.hbs) 生成前端页面
- 添加 FindAllParams/PaginationQueryDto 索引签名修复类型兼容

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-19 17:30:18 +08:00

728 lines
25 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* CRUD 生成器
* 交互式提问和文件生成逻辑
*
* 支持三种服务类型:
* - CrudService: 单表 CRUD
* - RelationCrudService: 带关联查询
* - ManyToManyCrudService: 多对多关系
*/
import type { NodePlopAPI, ActionType } from 'plop';
import pluralize from 'pluralize';
import { parseFields, type FieldDefinition } from '../utils/field-parser';
import {
parseRelations,
parseManyToMany,
parseCountRelations,
parseOneToMany,
parseManyToOne,
type RelationConfig,
type ManyToManyConfig,
type OneToManyConfig,
type ManyToOneConfig,
} from '../utils/relation-parser';
import {
parseSchema,
getAvailableModels,
getSelectableFields,
getModelByName,
inferManyToManyConfig,
type SchemaModel,
} from '../utils/schema-parser';
// 服务类型
type ServiceType = 'CrudService' | 'RelationCrudService' | 'ManyToManyCrudService';
// 模板数据类型
interface TemplateData {
name: string;
chineseName: string;
pluralName: string;
generateTargets: string[];
softDelete: boolean;
fields: FieldDefinition[];
createFields: FieldDefinition[];
updateFields: FieldDefinition[];
responseFields: FieldDefinition[];
tableColumns: FieldDefinition[];
queryFields: FieldDefinition[];
selectFields: string[];
defaultPageSize: number;
maxPageSize: number;
defaultSortBy: string;
defaultSortOrder: string;
hasQueryDto: boolean;
hasTextarea: boolean;
hasSelect: boolean;
hasSwitch: boolean;
// 服务类型相关
serviceType: ServiceType;
relations: RelationConfig[];
manyToMany: ManyToManyConfig[];
oneToMany: OneToManyConfig[];
manyToOne: ManyToOneConfig[];
countRelations: string[];
// 派生标志
hasRelations: boolean;
hasManyToMany: boolean;
hasOneToMany: boolean;
hasManyToOne: boolean;
hasCountRelations: boolean;
needsResponseDto: boolean;
needsDetailDto: boolean;
}
/**
* 注册 CRUD 生成器
*/
export function crudGenerator(plop: NodePlopAPI) {
plop.setGenerator('crud', {
description: '生成 CRUD 模块(后端 + 前端 + 共享类型 + Prisma',
prompts: [
{
type: 'input',
name: 'name',
message: '模块名称(英文,如 product:',
validate: (input: string) => {
if (!/^[a-z][a-zA-Z0-9]*$/.test(input)) {
return '模块名必须以小写字母开头,只能包含字母和数字';
}
return true;
},
},
{
type: 'input',
name: 'chineseName',
message: '模块中文名(如 产品):',
validate: (input: string) => !!input || '请输入中文名',
},
{
type: 'checkbox',
name: 'generateTargets',
message: '选择要生成的模块:',
choices: [
{ name: '后端 (NestJS)', value: 'api', checked: true },
{ name: '前端 (Next.js)', value: 'web', checked: true },
{ name: '共享类型', value: 'shared', 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',
name: 'softDelete',
message: '是否启用软删除?',
default: true,
},
{
type: 'editor',
name: 'fieldsRaw',
message: '定义字段(使用 DSL 语法,见下方示例):',
default: `# 字段定义示例(每行一个字段)
# 格式: 字段名:类型[?可选][!唯一] 标签 "示例值" [验证规则]
#
# 类型: string, number, boolean, date, datetime, enum(v1,v2,...)
# 修饰符: ? 可选, ! 唯一
# 验证: min:n, max:n, email, url
# 标志: noCreate, noUpdate, noTable
#
# 示例:
# title:string 标题 "示例标题" min:2 max:100
# description:string? 描述 "描述内容" max:500
# price:number 价格 "99.99" min:0
# status:enum(draft,published,archived) 状态 "draft"
name:string 名称 "示例名称" min:2 max:100
`,
},
// 多对多关系配置ManyToManyCrudService 时显示)
{
type: 'checkbox',
name: 'manyToManyModels',
message: '选择多对多关联的模型(可多选):',
when: (answers: { serviceType: ServiceType }) =>
answers.serviceType === 'ManyToManyCrudService',
choices: () => {
try {
const models = parseSchema();
return getAvailableModels(models).map((m) => ({
name: m,
value: m,
}));
} catch {
return [];
}
},
},
{
type: 'editor',
name: 'manyToManyRaw',
message: '配置多对多关系(已预填选中的模型,请确认中间表配置):',
when: (answers: { serviceType: ServiceType; manyToManyModels?: string[] }) =>
answers.serviceType === 'ManyToManyCrudService' &&
answers.manyToManyModels &&
answers.manyToManyModels.length > 0,
default: (answers: { name: string; manyToManyModels?: string[] }) => {
const models = parseSchema();
const currentModelName = answers.name.charAt(0).toUpperCase() + answers.name.slice(1);
const lines: string[] = [
'# 多对多关系配置(每行一个)',
'# 格式: 关系名:中间表:外键:目标键:目标模型 字段1,字段2,...',
'#',
];
for (const targetModelName of answers.manyToManyModels || []) {
const targetModel = getModelByName(models, targetModelName);
if (targetModel) {
const config = inferManyToManyConfig(models, currentModelName, targetModelName);
const fields = getSelectableFields(targetModel);
const defaultFields = fields.slice(0, 4).join(',');
const relationName = targetModelName.charAt(0).toLowerCase() + targetModelName.slice(1) + 's';
if (config) {
lines.push(`${relationName}:${config.through}:${config.foreignKey}:${config.targetKey}:${targetModelName} ${defaultFields}`);
} else {
// 如果无法推断,给出模板
const through = `${answers.name}${targetModelName}`;
const foreignKey = `${answers.name}Id`;
const targetKey = `${targetModelName.charAt(0).toLowerCase() + targetModelName.slice(1)}Id`;
lines.push(`${relationName}:${through}:${foreignKey}:${targetKey}:${targetModelName} ${defaultFields}`);
lines.push(`# ⚠ 请确认中间表配置是否正确`);
}
lines.push(`# 可用字段: ${fields.join(', ')}`);
}
}
return lines.join('\n') + '\n';
},
},
// 一对多关系配置(新模型包含多个目标模型)
{
type: 'checkbox',
name: 'oneToManyModels',
message: '选择一对多关联的目标模型(新模型包含多个目标,如:宿舍包含多个学生):',
choices: () => {
try {
const models = parseSchema();
return getAvailableModels(models).map((m) => ({
name: m,
value: m,
}));
} catch {
return [];
}
},
},
{
type: 'editor',
name: 'oneToManyRaw',
message: '配置一对多关系:',
when: (answers: { oneToManyModels?: string[] }) =>
answers.oneToManyModels && answers.oneToManyModels.length > 0,
default: (answers: { name: string; oneToManyModels?: string[] }) => {
const lines: string[] = [
'# 一对多关系配置(每行一个)',
'# 格式: 关系名:目标模型 [optional]',
'# optional: 表示外键可为空',
'#',
];
for (const targetModelName of answers.oneToManyModels || []) {
const relationName = targetModelName.charAt(0).toLowerCase() + targetModelName.slice(1) + 's';
lines.push(`${relationName}:${targetModelName} optional`);
}
return lines.join('\n') + '\n';
},
},
// 多对一关系配置(新模型属于一个目标模型)
{
type: 'checkbox',
name: 'manyToOneModels',
message: '选择多对一关联的目标模型(新模型属于目标,如:成绩属于学生):',
choices: () => {
try {
const models = parseSchema();
return getAvailableModels(models).map((m) => ({
name: m,
value: m,
}));
} catch {
return [];
}
},
},
{
type: 'editor',
name: 'manyToOneRaw',
message: '配置多对一关系:',
when: (answers: { manyToOneModels?: string[] }) =>
answers.manyToOneModels && answers.manyToOneModels.length > 0,
default: (answers: { name: string; manyToOneModels?: string[] }) => {
const lines: string[] = [
'# 多对一关系配置(每行一个)',
'# 格式: 关联名:目标模型 [optional]',
'# optional: 表示外键可为空',
'#',
];
for (const targetModelName of answers.manyToOneModels || []) {
const relationName = targetModelName.charAt(0).toLowerCase() + targetModelName.slice(1);
lines.push(`${relationName}:${targetModelName}`);
}
return lines.join('\n') + '\n';
},
},
// 查询关联配置(根据一对多/多对一自动预填,用于配置 API 响应返回哪些关联字段)
{
type: 'editor',
name: 'relationsRaw',
message: '配置查询时要返回的关联字段(已根据关系自动预填):',
when: (answers: {
serviceType: ServiceType;
oneToManyModels?: string[];
manyToOneModels?: string[];
}) =>
(answers.serviceType === 'RelationCrudService' ||
answers.serviceType === 'ManyToManyCrudService') &&
((answers.oneToManyModels && answers.oneToManyModels.length > 0) ||
(answers.manyToOneModels && answers.manyToOneModels.length > 0)),
default: (answers: {
oneToManyModels?: string[];
manyToOneModels?: string[];
}) => {
const models = parseSchema();
const lines: string[] = [
'# 查询关联配置(每行一个)',
'# 格式: 关联名:目标模型 字段1,字段2,... [noList]',
'# noList: 不在列表中显示(仅详情显示)',
'#',
];
// 根据多对一关系生成(查询时 include 关联对象)
for (const modelName of answers.manyToOneModels || []) {
const model = getModelByName(models, modelName);
if (model) {
const fields = getSelectableFields(model);
const defaultFields = fields.slice(0, 4).join(',');
const relationName = modelName.charAt(0).toLowerCase() + modelName.slice(1);
lines.push(`${relationName}:${modelName} ${defaultFields}`);
lines.push(`# 可用字段: ${fields.join(', ')}`);
}
}
// 根据一对多关系生成(查询时 include 关联数组,默认 noList
for (const modelName of answers.oneToManyModels || []) {
const model = getModelByName(models, modelName);
if (model) {
const fields = getSelectableFields(model);
const defaultFields = fields.slice(0, 4).join(',');
const relationName = modelName.charAt(0).toLowerCase() + modelName.slice(1) + 's';
lines.push(`${relationName}:${modelName} ${defaultFields} noList`);
lines.push(`# 可用字段: ${fields.join(', ')}`);
}
}
return lines.join('\n') + '\n';
},
},
// 统计关系配置RelationCrudService 或 ManyToManyCrudService 时显示)
{
type: 'input',
name: 'countRelationsRaw',
message: '统计关系(输入将来会有的关系名,逗号分隔,如 students,orders:',
when: (answers: { serviceType: ServiceType }) =>
answers.serviceType === 'RelationCrudService' ||
answers.serviceType === 'ManyToManyCrudService',
default: '',
},
{
type: 'checkbox',
name: 'searchFieldNames',
message: '选择支持搜索的字段:',
choices: (answers: { fieldsRaw: string }) => {
try {
const fields = parseFields(answers.fieldsRaw);
return fields
.filter((f) => ['string', 'enum'].includes(f.type))
.map((f) => ({
name: `${f.name} (${f.label})`,
value: f.name,
}));
} catch {
return [];
}
},
},
{
type: 'number',
name: 'defaultPageSize',
message: '默认分页大小:',
default: 20,
},
{
type: 'number',
name: 'maxPageSize',
message: '最大分页大小:',
default: 100,
},
{
type: 'list',
name: 'defaultSortBy',
message: '默认排序字段:',
choices: (answers: { fieldsRaw: string }) => {
try {
const fields = parseFields(answers.fieldsRaw);
return [
{ name: 'createdAt (创建时间)', value: 'createdAt' },
{ name: 'updatedAt (更新时间)', value: 'updatedAt' },
...fields.map((f) => ({
name: `${f.name} (${f.label})`,
value: f.name,
})),
];
} catch {
return [{ name: 'createdAt (创建时间)', value: 'createdAt' }];
}
},
default: 'createdAt',
},
{
type: 'list',
name: 'defaultSortOrder',
message: '默认排序方向:',
choices: [
{ name: '降序 (desc)', value: 'desc' },
{ name: '升序 (asc)', value: 'asc' },
],
default: 'desc',
},
],
actions: (data) => {
if (!data) return [];
// 解析字段
let fields: FieldDefinition[];
try {
fields = parseFields(data.fieldsRaw);
} catch (error) {
console.error('字段解析错误:', error);
return [];
}
// 解析关联配置
const serviceType = data.serviceType as ServiceType;
const pascalName = data.name.charAt(0).toUpperCase() + data.name.slice(1);
const relations =
serviceType !== 'CrudService' && data.relationsRaw
? parseRelations(data.relationsRaw)
: [];
const manyToMany =
serviceType === 'ManyToManyCrudService' && data.manyToManyRaw
? parseManyToMany(data.manyToManyRaw)
: [];
const oneToMany = data.oneToManyRaw
? parseOneToMany(data.oneToManyRaw, pascalName)
: [];
const manyToOne = data.manyToOneRaw
? parseManyToOne(data.manyToOneRaw, pluralize(data.name))
: [];
const countRelations =
serviceType !== 'CrudService' && data.countRelationsRaw
? parseCountRelations(data.countRelationsRaw)
: [];
// 派生标志
const hasRelations = relations.length > 0;
const hasManyToMany = manyToMany.length > 0;
const hasOneToMany = oneToMany.length > 0;
const hasManyToOne = manyToOne.length > 0;
const hasCountRelations = countRelations.length > 0;
const needsResponseDto = serviceType !== 'CrudService';
const needsDetailDto = serviceType === 'ManyToManyCrudService' && (hasManyToMany || hasCountRelations);
// 准备模板数据
const templateData: TemplateData = {
name: data.name,
chineseName: data.chineseName,
pluralName: pluralize(data.name),
generateTargets: data.generateTargets,
softDelete: data.softDelete,
fields,
createFields: fields.filter((f) => !f.flags.noCreate),
updateFields: fields.filter((f) => !f.flags.noUpdate),
responseFields: fields,
tableColumns: fields.filter((f) => !f.flags.noTable),
queryFields: fields.filter((f) =>
data.searchFieldNames?.includes(f.name),
),
selectFields: fields.map((f) => f.name),
defaultPageSize: data.defaultPageSize,
maxPageSize: data.maxPageSize,
defaultSortBy: data.defaultSortBy,
defaultSortOrder: data.defaultSortOrder,
hasQueryDto: data.searchFieldNames?.length > 0,
hasTextarea: fields.some((f) => {
if (f.type !== 'string') return false;
const maxLen = f.validations.find((v) => v.type === 'max')?.value;
return maxLen && Number(maxLen) > 100;
}),
hasSelect: fields.some((f) => f.type === 'enum'),
hasSwitch: fields.some((f) => f.type === 'boolean'),
// 服务类型相关
serviceType,
relations,
manyToMany,
oneToMany,
manyToOne,
countRelations,
hasRelations,
hasManyToMany,
hasOneToMany,
hasManyToOne,
hasCountRelations,
needsResponseDto,
needsDetailDto,
};
const actions: ActionType[] = [];
// ===== 后端文件 =====
if (data.generateTargets.includes('api')) {
// DTO
actions.push({
type: 'add',
path: 'apps/api/src/{{kebabCase name}}/dto/{{kebabCase name}}.dto.ts',
templateFile: 'templates/api/dto.hbs',
data: templateData,
abortOnFail: false,
});
// Service
actions.push({
type: 'add',
path: 'apps/api/src/{{kebabCase name}}/{{kebabCase name}}.service.ts',
templateFile: 'templates/api/service.hbs',
data: templateData,
abortOnFail: false,
});
// Controller
actions.push({
type: 'add',
path: 'apps/api/src/{{kebabCase name}}/{{kebabCase name}}.controller.ts',
templateFile: 'templates/api/controller.hbs',
data: templateData,
abortOnFail: false,
});
// Module
actions.push({
type: 'add',
path: 'apps/api/src/{{kebabCase name}}/{{kebabCase name}}.module.ts',
templateFile: 'templates/api/module.hbs',
data: templateData,
abortOnFail: false,
});
// 修改 app.module.ts - 添加导入
actions.push({
type: 'modify',
path: 'apps/api/src/app.module.ts',
pattern: /(import \{ \w+Module \} from '\.\/\w+\/\w+\.module';)\n(\n@Module)/,
template: `$1\nimport { {{pascalCase name}}Module } from './{{kebabCase name}}/{{kebabCase name}}.module';\n$2`,
data: templateData,
abortOnFail: false,
});
// 修改 app.module.ts - 添加到 imports 数组
actions.push({
type: 'modify',
path: 'apps/api/src/app.module.ts',
pattern: /(\s+)(StudentModule,?\s*\n)(\s*\],)/,
template: `$1$2$1{{pascalCase name}}Module,\n$3`,
data: templateData,
abortOnFail: false,
});
}
// ===== 前端文件 =====
if (data.generateTargets.includes('web')) {
// Service
actions.push({
type: 'add',
path: 'apps/web/src/services/{{kebabCase name}}.service.ts',
templateFile: 'templates/web/service.hbs',
data: templateData,
abortOnFail: false,
});
// Hooks
actions.push({
type: 'add',
path: 'apps/web/src/hooks/use{{pascalCase pluralName}}.ts',
templateFile: 'templates/web/hooks.hbs',
data: templateData,
abortOnFail: false,
});
// 组件目录
actions.push({
type: 'add',
path: 'apps/web/src/components/{{kebabCase pluralName}}/{{pascalCase pluralName}}Table.tsx',
templateFile: 'templates/web/table.hbs',
data: templateData,
abortOnFail: false,
});
actions.push({
type: 'add',
path: 'apps/web/src/components/{{kebabCase pluralName}}/{{pascalCase name}}CreateDialog.tsx',
templateFile: 'templates/web/create-dialog.hbs',
data: templateData,
abortOnFail: false,
});
actions.push({
type: 'add',
path: 'apps/web/src/components/{{kebabCase pluralName}}/{{pascalCase name}}EditDialog.tsx',
templateFile: 'templates/web/edit-dialog.hbs',
data: templateData,
abortOnFail: false,
});
// 页面
actions.push({
type: 'add',
path: 'apps/web/src/app/(dashboard)/{{kebabCase pluralName}}/page.tsx',
templateFile: 'templates/web/page.hbs',
data: templateData,
abortOnFail: false,
});
// 修改 constants.ts - 添加 API 端点
actions.push({
type: 'modify',
path: 'apps/web/src/config/constants.ts',
pattern: /(USERS:\s*['"]\/users['"],?)/,
template: `$1\n {{constantCase pluralName}}: '/{{kebabCase pluralName}}',`,
data: templateData,
abortOnFail: false,
});
}
// ===== 共享类型 =====
if (data.generateTargets.includes('shared')) {
actions.push({
type: 'add',
path: 'packages/shared/src/types/{{kebabCase name}}.ts',
templateFile: 'templates/shared/types.hbs',
data: templateData,
abortOnFail: false,
});
// 修改 types/index.ts - 添加导出
actions.push({
type: 'append',
path: 'packages/shared/src/types/index.ts',
template: `\n// {{chineseName}}\nexport * from './{{kebabCase name}}';\n`,
data: templateData,
abortOnFail: false,
});
}
// ===== Prisma Model =====
if (data.generateTargets.includes('prisma')) {
actions.push({
type: 'append',
path: 'apps/api/prisma/schema.prisma',
templateFile: 'templates/prisma/model.hbs',
data: templateData,
abortOnFail: false,
});
// 如果启用软删除,修改 prisma.service.ts
if (data.softDelete) {
actions.push({
type: 'modify',
path: 'apps/api/src/prisma/prisma.service.ts',
pattern: /(const SOFT_DELETE_MODELS:\s*Prisma\.ModelName\[\]\s*=\s*\[[\s\S]*?)(\];)/,
template: `$1, '{{pascalCase name}}'$2`,
data: templateData,
abortOnFail: false,
});
}
}
// 打印生成信息
actions.push(() => {
console.log('\n✨ 生成完成!\n');
console.log(`服务类型: ${serviceType}`);
if (hasRelations) {
console.log(`关联关系: ${relations.map((r) => r.name).join(', ')}`);
}
if (hasManyToMany) {
console.log(`多对多关系: ${manyToMany.map((m) => m.name).join(', ')}`);
}
if (hasOneToMany) {
console.log(`一对多关系: ${oneToMany.map((o) => `${o.name}${o.targetModel}`).join(', ')}`);
}
if (hasManyToOne) {
console.log(`多对一关系: ${manyToOne.map((m) => `${m.name}${m.targetModel}`).join(', ')}`);
}
if (hasCountRelations) {
console.log(`统计关系: ${countRelations.join(', ')}`);
}
// 打印需要手动修改目标模型的提示
if (hasOneToMany || hasManyToOne) {
console.log('\n⚠ 需要手动修改目标模型:');
if (hasOneToMany) {
console.log('\n--- 一对多关系(请在目标模型中添加外键字段)---');
for (const rel of oneToMany) {
console.log(`\n 在 model ${rel.targetModel} 中添加:`);
console.log(` ${rel.foreignKey} String${rel.optional ? '?' : ''}`);
console.log(` ${rel.backRelation} ${pascalName}${rel.optional ? '?' : ''} @relation(fields: [${rel.foreignKey}], references: [id])`);
}
}
if (hasManyToOne) {
console.log('\n--- 多对一关系(请在目标模型中添加反向关联)---');
for (const rel of manyToOne) {
console.log(`\n 在 model ${rel.targetModel} 中添加:`);
console.log(` ${rel.backRelation} ${pascalName}[]`);
}
}
}
console.log('\n后续步骤:');
if (hasOneToMany || hasManyToOne) {
console.log('1. 按照上述提示修改目标模型');
console.log('2. 运行 pnpm db:generate && pnpm db:push');
} else if (data.generateTargets.includes('prisma')) {
console.log('1. 运行 pnpm db:generate && pnpm db:push');
}
console.log(`${hasOneToMany || hasManyToOne ? '3' : '2'}. 重启开发服务器 pnpm dev\n`);
return '完成';
});
return actions;
},
});
}