feat(plop): 优化生成器支持 Prisma 关联关系

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
charilezhou
2026-01-19 17:30:18 +08:00
parent e5b3285519
commit 3119460f13
17 changed files with 488 additions and 116 deletions

View File

@@ -116,9 +116,11 @@ export const DEFAULT_CRUD_OPTIONS: Required<CrudServiceOptions> = {
/** /**
* 分页查询参数(扩展 shared 包的类型,添加过滤条件) * 分页查询参数(扩展 shared 包的类型,添加过滤条件)
* 允许额外的查询参数用于 filterableFields 过滤
*/ */
export interface FindAllParams<WhereInput = Record<string, unknown>> extends PaginationParams { export interface FindAllParams<WhereInput = Record<string, unknown>> extends PaginationParams {
where?: WhereInput; where?: WhereInput;
[key: string]: unknown;
} }
/** /**

View File

@@ -15,6 +15,9 @@ import { IsInt, IsOptional, IsString, Matches } from 'class-validator';
* - pageSize=-1 或 pageSize=0 返回所有数据 * - pageSize=-1 或 pageSize=0 返回所有数据
*/ */
export class PaginationQueryDto implements PaginationParams { export class PaginationQueryDto implements PaginationParams {
// 索引签名:允许额外的查询参数(用于 filterableFields
[key: string]: unknown;
@ApiPropertyOptional({ description: '页码', default: 1, minimum: 1 }) @ApiPropertyOptional({ description: '页码', default: 1, minimum: 1 })
@Type(() => Number) @Type(() => Number)
@IsInt() @IsInt()

View File

@@ -18,7 +18,9 @@
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^22.10.2", "@types/node": "^22.10.2",
"@types/pluralize": "^0.0.33",
"plop": "^4.0.4", "plop": "^4.0.4",
"pluralize": "^8.0.0",
"prettier": "^3.4.2", "prettier": "^3.4.2",
"tsx": "^4.21.0", "tsx": "^4.21.0",
"turbo": "^2.3.3", "turbo": "^2.3.3",

View File

@@ -9,13 +9,18 @@
*/ */
import type { NodePlopAPI, ActionType } from 'plop'; import type { NodePlopAPI, ActionType } from 'plop';
import pluralize from 'pluralize';
import { parseFields, type FieldDefinition } from '../utils/field-parser'; import { parseFields, type FieldDefinition } from '../utils/field-parser';
import { import {
parseRelations, parseRelations,
parseManyToMany, parseManyToMany,
parseCountRelations, parseCountRelations,
parseOneToMany,
parseManyToOne,
type RelationConfig, type RelationConfig,
type ManyToManyConfig, type ManyToManyConfig,
type OneToManyConfig,
type ManyToOneConfig,
} from '../utils/relation-parser'; } from '../utils/relation-parser';
import { import {
parseSchema, parseSchema,
@@ -56,11 +61,15 @@ interface TemplateData {
serviceType: ServiceType; serviceType: ServiceType;
relations: RelationConfig[]; relations: RelationConfig[];
manyToMany: ManyToManyConfig[]; manyToMany: ManyToManyConfig[];
oneToMany: OneToManyConfig[];
manyToOne: ManyToOneConfig[];
countRelations: string[]; countRelations: string[];
// 派生标志 // 派生标志
hasRelations: boolean; hasRelations: boolean;
hasManyToMany: boolean; hasManyToMany: boolean;
hasOneToMany: boolean;
hasManyToOne: boolean;
hasCountRelations: boolean; hasCountRelations: boolean;
needsResponseDto: boolean; needsResponseDto: boolean;
needsDetailDto: boolean; needsDetailDto: boolean;
@@ -90,12 +99,6 @@ export function crudGenerator(plop: NodePlopAPI) {
message: '模块中文名(如 产品):', message: '模块中文名(如 产品):',
validate: (input: string) => !!input || '请输入中文名', validate: (input: string) => !!input || '请输入中文名',
}, },
{
type: 'input',
name: 'pluralName',
message: '复数名称(如 products:',
default: (answers: { name: string }) => `${answers.name}s`,
},
{ {
type: 'checkbox', type: 'checkbox',
name: 'generateTargets', name: 'generateTargets',
@@ -146,59 +149,6 @@ export function crudGenerator(plop: NodePlopAPI) {
name:string 名称 "示例名称" min:2 max:100 name:string 名称 "示例名称" min:2 max:100
`, `,
}, },
// 关联关系配置RelationCrudService 或 ManyToManyCrudService 时显示)
{
type: 'checkbox',
name: 'relationModels',
message: '选择要关联的模型(可多选,之后会配置字段):',
when: (answers: { serviceType: ServiceType }) =>
answers.serviceType === 'RelationCrudService' ||
answers.serviceType === 'ManyToManyCrudService',
choices: () => {
try {
const models = parseSchema();
return getAvailableModels(models).map((m) => ({
name: m,
value: m,
}));
} catch {
return [];
}
},
},
{
type: 'editor',
name: 'relationsRaw',
message: '配置关联关系(已预填选中的模型,可调整字段和选项):',
when: (answers: { serviceType: ServiceType; relationModels?: string[] }) =>
(answers.serviceType === 'RelationCrudService' ||
answers.serviceType === 'ManyToManyCrudService') &&
answers.relationModels &&
answers.relationModels.length > 0,
default: (answers: { relationModels?: string[] }) => {
const models = parseSchema();
const lines: string[] = [
'# 关联关系配置(每行一个)',
'# 格式: 关联名:目标模型 字段1,字段2,... [noList]',
'# noList: 不在列表中显示',
'#',
];
for (const modelName of answers.relationModels || []) {
const model = getModelByName(models, modelName);
if (model) {
const fields = getSelectableFields(model);
// 默认选择 id 和前几个重要字段
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(', ')}`);
}
}
return lines.join('\n') + '\n';
},
},
// 多对多关系配置ManyToManyCrudService 时显示) // 多对多关系配置ManyToManyCrudService 时显示)
{ {
type: 'checkbox', type: 'checkbox',
@@ -260,6 +210,137 @@ name:string 名称 "示例名称" min:2 max:100
return lines.join('\n') + '\n'; 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 时显示) // 统计关系配置RelationCrudService 或 ManyToManyCrudService 时显示)
{ {
type: 'input', type: 'input',
@@ -346,6 +427,7 @@ name:string 名称 "示例名称" min:2 max:100
// 解析关联配置 // 解析关联配置
const serviceType = data.serviceType as ServiceType; const serviceType = data.serviceType as ServiceType;
const pascalName = data.name.charAt(0).toUpperCase() + data.name.slice(1);
const relations = const relations =
serviceType !== 'CrudService' && data.relationsRaw serviceType !== 'CrudService' && data.relationsRaw
? parseRelations(data.relationsRaw) ? parseRelations(data.relationsRaw)
@@ -354,6 +436,12 @@ name:string 名称 "示例名称" min:2 max:100
serviceType === 'ManyToManyCrudService' && data.manyToManyRaw serviceType === 'ManyToManyCrudService' && data.manyToManyRaw
? parseManyToMany(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 = const countRelations =
serviceType !== 'CrudService' && data.countRelationsRaw serviceType !== 'CrudService' && data.countRelationsRaw
? parseCountRelations(data.countRelationsRaw) ? parseCountRelations(data.countRelationsRaw)
@@ -362,6 +450,8 @@ name:string 名称 "示例名称" min:2 max:100
// 派生标志 // 派生标志
const hasRelations = relations.length > 0; const hasRelations = relations.length > 0;
const hasManyToMany = manyToMany.length > 0; const hasManyToMany = manyToMany.length > 0;
const hasOneToMany = oneToMany.length > 0;
const hasManyToOne = manyToOne.length > 0;
const hasCountRelations = countRelations.length > 0; const hasCountRelations = countRelations.length > 0;
const needsResponseDto = serviceType !== 'CrudService'; const needsResponseDto = serviceType !== 'CrudService';
const needsDetailDto = serviceType === 'ManyToManyCrudService' && (hasManyToMany || hasCountRelations); const needsDetailDto = serviceType === 'ManyToManyCrudService' && (hasManyToMany || hasCountRelations);
@@ -370,7 +460,7 @@ name:string 名称 "示例名称" min:2 max:100
const templateData: TemplateData = { const templateData: TemplateData = {
name: data.name, name: data.name,
chineseName: data.chineseName, chineseName: data.chineseName,
pluralName: data.pluralName, pluralName: pluralize(data.name),
generateTargets: data.generateTargets, generateTargets: data.generateTargets,
softDelete: data.softDelete, softDelete: data.softDelete,
fields, fields,
@@ -399,9 +489,13 @@ name:string 名称 "示例名称" min:2 max:100
serviceType, serviceType,
relations, relations,
manyToMany, manyToMany,
oneToMany,
manyToOne,
countRelations, countRelations,
hasRelations, hasRelations,
hasManyToMany, hasManyToMany,
hasOneToMany,
hasManyToOne,
hasCountRelations, hasCountRelations,
needsResponseDto, needsResponseDto,
needsDetailDto, needsDetailDto,
@@ -417,6 +511,7 @@ name:string 名称 "示例名称" min:2 max:100
path: 'apps/api/src/{{kebabCase name}}/dto/{{kebabCase name}}.dto.ts', path: 'apps/api/src/{{kebabCase name}}/dto/{{kebabCase name}}.dto.ts',
templateFile: 'templates/api/dto.hbs', templateFile: 'templates/api/dto.hbs',
data: templateData, data: templateData,
abortOnFail: false,
}); });
// Service // Service
@@ -425,6 +520,7 @@ name:string 名称 "示例名称" min:2 max:100
path: 'apps/api/src/{{kebabCase name}}/{{kebabCase name}}.service.ts', path: 'apps/api/src/{{kebabCase name}}/{{kebabCase name}}.service.ts',
templateFile: 'templates/api/service.hbs', templateFile: 'templates/api/service.hbs',
data: templateData, data: templateData,
abortOnFail: false,
}); });
// Controller // Controller
@@ -433,6 +529,7 @@ name:string 名称 "示例名称" min:2 max:100
path: 'apps/api/src/{{kebabCase name}}/{{kebabCase name}}.controller.ts', path: 'apps/api/src/{{kebabCase name}}/{{kebabCase name}}.controller.ts',
templateFile: 'templates/api/controller.hbs', templateFile: 'templates/api/controller.hbs',
data: templateData, data: templateData,
abortOnFail: false,
}); });
// Module // Module
@@ -441,6 +538,7 @@ name:string 名称 "示例名称" min:2 max:100
path: 'apps/api/src/{{kebabCase name}}/{{kebabCase name}}.module.ts', path: 'apps/api/src/{{kebabCase name}}/{{kebabCase name}}.module.ts',
templateFile: 'templates/api/module.hbs', templateFile: 'templates/api/module.hbs',
data: templateData, data: templateData,
abortOnFail: false,
}); });
// 修改 app.module.ts - 添加导入 // 修改 app.module.ts - 添加导入
@@ -450,6 +548,7 @@ name:string 名称 "示例名称" min:2 max:100
pattern: /(import \{ \w+Module \} from '\.\/\w+\/\w+\.module';)\n(\n@Module)/, 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`, template: `$1\nimport { {{pascalCase name}}Module } from './{{kebabCase name}}/{{kebabCase name}}.module';\n$2`,
data: templateData, data: templateData,
abortOnFail: false,
}); });
// 修改 app.module.ts - 添加到 imports 数组 // 修改 app.module.ts - 添加到 imports 数组
@@ -459,6 +558,7 @@ name:string 名称 "示例名称" min:2 max:100
pattern: /(\s+)(StudentModule,?\s*\n)(\s*\],)/, pattern: /(\s+)(StudentModule,?\s*\n)(\s*\],)/,
template: `$1$2$1{{pascalCase name}}Module,\n$3`, template: `$1$2$1{{pascalCase name}}Module,\n$3`,
data: templateData, data: templateData,
abortOnFail: false,
}); });
} }
@@ -470,6 +570,7 @@ name:string 名称 "示例名称" min:2 max:100
path: 'apps/web/src/services/{{kebabCase name}}.service.ts', path: 'apps/web/src/services/{{kebabCase name}}.service.ts',
templateFile: 'templates/web/service.hbs', templateFile: 'templates/web/service.hbs',
data: templateData, data: templateData,
abortOnFail: false,
}); });
// Hooks // Hooks
@@ -478,6 +579,7 @@ name:string 名称 "示例名称" min:2 max:100
path: 'apps/web/src/hooks/use{{pascalCase pluralName}}.ts', path: 'apps/web/src/hooks/use{{pascalCase pluralName}}.ts',
templateFile: 'templates/web/hooks.hbs', templateFile: 'templates/web/hooks.hbs',
data: templateData, data: templateData,
abortOnFail: false,
}); });
// 组件目录 // 组件目录
@@ -486,6 +588,7 @@ name:string 名称 "示例名称" min:2 max:100
path: 'apps/web/src/components/{{kebabCase pluralName}}/{{pascalCase pluralName}}Table.tsx', path: 'apps/web/src/components/{{kebabCase pluralName}}/{{pascalCase pluralName}}Table.tsx',
templateFile: 'templates/web/table.hbs', templateFile: 'templates/web/table.hbs',
data: templateData, data: templateData,
abortOnFail: false,
}); });
actions.push({ actions.push({
@@ -493,6 +596,7 @@ name:string 名称 "示例名称" min:2 max:100
path: 'apps/web/src/components/{{kebabCase pluralName}}/{{pascalCase name}}CreateDialog.tsx', path: 'apps/web/src/components/{{kebabCase pluralName}}/{{pascalCase name}}CreateDialog.tsx',
templateFile: 'templates/web/create-dialog.hbs', templateFile: 'templates/web/create-dialog.hbs',
data: templateData, data: templateData,
abortOnFail: false,
}); });
actions.push({ actions.push({
@@ -500,6 +604,16 @@ name:string 名称 "示例名称" min:2 max:100
path: 'apps/web/src/components/{{kebabCase pluralName}}/{{pascalCase name}}EditDialog.tsx', path: 'apps/web/src/components/{{kebabCase pluralName}}/{{pascalCase name}}EditDialog.tsx',
templateFile: 'templates/web/edit-dialog.hbs', templateFile: 'templates/web/edit-dialog.hbs',
data: templateData, 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 端点 // 修改 constants.ts - 添加 API 端点
@@ -509,6 +623,7 @@ name:string 名称 "示例名称" min:2 max:100
pattern: /(USERS:\s*['"]\/users['"],?)/, pattern: /(USERS:\s*['"]\/users['"],?)/,
template: `$1\n {{constantCase pluralName}}: '/{{kebabCase pluralName}}',`, template: `$1\n {{constantCase pluralName}}: '/{{kebabCase pluralName}}',`,
data: templateData, data: templateData,
abortOnFail: false,
}); });
} }
@@ -519,6 +634,7 @@ name:string 名称 "示例名称" min:2 max:100
path: 'packages/shared/src/types/{{kebabCase name}}.ts', path: 'packages/shared/src/types/{{kebabCase name}}.ts',
templateFile: 'templates/shared/types.hbs', templateFile: 'templates/shared/types.hbs',
data: templateData, data: templateData,
abortOnFail: false,
}); });
// 修改 types/index.ts - 添加导出 // 修改 types/index.ts - 添加导出
@@ -527,6 +643,7 @@ name:string 名称 "示例名称" min:2 max:100
path: 'packages/shared/src/types/index.ts', path: 'packages/shared/src/types/index.ts',
template: `\n// {{chineseName}}\nexport * from './{{kebabCase name}}';\n`, template: `\n// {{chineseName}}\nexport * from './{{kebabCase name}}';\n`,
data: templateData, data: templateData,
abortOnFail: false,
}); });
} }
@@ -537,6 +654,7 @@ name:string 名称 "示例名称" min:2 max:100
path: 'apps/api/prisma/schema.prisma', path: 'apps/api/prisma/schema.prisma',
templateFile: 'templates/prisma/model.hbs', templateFile: 'templates/prisma/model.hbs',
data: templateData, data: templateData,
abortOnFail: false,
}); });
// 如果启用软删除,修改 prisma.service.ts // 如果启用软删除,修改 prisma.service.ts
@@ -547,6 +665,7 @@ name:string 名称 "示例名称" min:2 max:100
pattern: /(const SOFT_DELETE_MODELS:\s*Prisma\.ModelName\[\]\s*=\s*\[[\s\S]*?)(\];)/, pattern: /(const SOFT_DELETE_MODELS:\s*Prisma\.ModelName\[\]\s*=\s*\[[\s\S]*?)(\];)/,
template: `$1, '{{pascalCase name}}'$2`, template: `$1, '{{pascalCase name}}'$2`,
data: templateData, data: templateData,
abortOnFail: false,
}); });
} }
} }
@@ -561,14 +680,44 @@ name:string 名称 "示例名称" min:2 max:100
if (hasManyToMany) { if (hasManyToMany) {
console.log(`多对多关系: ${manyToMany.map((m) => m.name).join(', ')}`); 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) { if (hasCountRelations) {
console.log(`统计关系: ${countRelations.join(', ')}`); 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后续步骤:'); console.log('\n后续步骤:');
if (data.generateTargets.includes('prisma')) { 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('1. 运行 pnpm db:generate && pnpm db:push');
} }
console.log('2. 重启开发服务器 pnpm dev\n'); console.log(`${hasOneToMany || hasManyToOne ? '3' : '2'}. 重启开发服务器 pnpm dev\n`);
return '完成'; return '完成';
}); });

View File

@@ -16,6 +16,7 @@ import {
getFormControl, getFormControl,
getCellRenderer, getCellRenderer,
getWhereCondition, getWhereCondition,
getFormattedExample,
} from '../utils/field-parser'; } from '../utils/field-parser';
/** /**
@@ -92,6 +93,10 @@ export function registerHelpers(plop: NodePlopAPI) {
plop.setHelper('cellRenderer', (field: FieldDefinition) => getCellRenderer(field)); plop.setHelper('cellRenderer', (field: FieldDefinition) => getCellRenderer(field));
// ===== Example 格式化 helpers =====
plop.setHelper('formattedExample', (field: FieldDefinition) => getFormattedExample(field));
// ===== 查询条件 helpers ===== // ===== 查询条件 helpers =====
plop.setHelper('whereCondition', (field: FieldDefinition) => getWhereCondition(field)); plop.setHelper('whereCondition', (field: FieldDefinition) => getWhereCondition(field));

View File

@@ -16,7 +16,7 @@ import {
ApiOkResponse, ApiOkResponse,
ApiCreatedResponse, ApiCreatedResponse,
} from '@nestjs/swagger'; } from '@nestjs/swagger';
{{#if (eq serviceType 'CrudService')}} {{#if hasQueryDto}}
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
{{/if}} {{/if}}
@@ -40,7 +40,7 @@ import { {{pascalCase name}}Service } from './{{kebabCase name}}.service';
import { JwtAuthGuard } from '@/auth/guards/jwt-auth.guard'; import { JwtAuthGuard } from '@/auth/guards/jwt-auth.guard';
{{#unless hasQueryDto}} {{#unless hasQueryDto}}
import { PaginationQueryDto } from '@/common/crud'; import { PaginationQueryDto } from '@/common/crud/dto/pagination.dto';
{{/unless}} {{/unless}}
@ApiTags('{{chineseName}}') @ApiTags('{{chineseName}}')

View File

@@ -13,15 +13,16 @@ import type {
{{pascalCase name}}Response, {{pascalCase name}}Response,
} from '@seclusion/shared'; } from '@seclusion/shared';
import { createPaginatedResponseDto, PaginationQueryDto } from '@/common/crud'; import { createPaginatedResponseDto } from '@/common/crud/dto/paginated-response.dto';
import { PaginationQueryDto } from '@/common/crud/dto/pagination.dto';
/** 创建{{chineseName}}请求 DTO */ /** 创建{{chineseName}}请求 DTO */
export class Create{{pascalCase name}}Dto implements ICreate{{pascalCase name}}Dto { export class Create{{pascalCase name}}Dto implements ICreate{{pascalCase name}}Dto {
{{#each createFields}} {{#each createFields}}
{{#if nullable}} {{#if nullable}}
@ApiPropertyOptional({ example: {{{example}}}, description: '{{label}}' }) @ApiPropertyOptional({ example: {{{formattedExample this}}}, description: '{{label}}' })
{{else}} {{else}}
@ApiProperty({ example: {{{example}}}, description: '{{label}}' }) @ApiProperty({ example: {{{formattedExample this}}}, description: '{{label}}' })
{{/if}} {{/if}}
{{#each (validationDecorators this)}} {{#each (validationDecorators this)}}
{{{this}}} {{{this}}}
@@ -37,7 +38,7 @@ export class Create{{pascalCase name}}Dto implements ICreate{{pascalCase name}}D
/** 更新{{chineseName}}请求 DTO */ /** 更新{{chineseName}}请求 DTO */
export class Update{{pascalCase name}}Dto implements IUpdate{{pascalCase name}}Dto { export class Update{{pascalCase name}}Dto implements IUpdate{{pascalCase name}}Dto {
{{#each updateFields}} {{#each updateFields}}
@ApiPropertyOptional({ example: {{{example}}}, description: '{{label}}' }) @ApiPropertyOptional({ example: {{{formattedExample this}}}, description: '{{label}}' })
{{#each (validationDecorators this)}} {{#each (validationDecorators this)}}
{{{this}}} {{{this}}}
{{/each}} {{/each}}
@@ -99,10 +100,10 @@ export class {{pascalCase name}}ResponseDto implements {{pascalCase name}}Respon
{{#each responseFields}} {{#each responseFields}}
{{#if nullable}} {{#if nullable}}
@ApiProperty({ example: {{{example}}}, description: '{{label}}', nullable: true }) @ApiProperty({ example: {{{formattedExample this}}}, description: '{{label}}', nullable: true })
{{name}}: {{tsResponseType this}} | null; {{name}}: {{tsResponseType this}} | null;
{{else}} {{else}}
@ApiProperty({ example: {{{example}}}, description: '{{label}}' }) @ApiProperty({ example: {{{formattedExample this}}}, description: '{{label}}' })
{{name}}: {{tsResponseType this}}; {{name}}: {{tsResponseType this}};
{{/if}} {{/if}}

View File

@@ -5,12 +5,42 @@ model {{pascalCase name}} {
id String @id @default(cuid(2)) id String @id @default(cuid(2))
{{#each fields}} {{#each fields}}
{{name}} {{prismaType this}}{{#if unique}} @unique{{/if}} {{name}} {{prismaType this}}{{#if unique}} @unique{{/if}}
{{/each}}
{{#each manyToOne}}
{{foreignKey}} String{{#if optional}}?{{/if}}
{{name}} {{targetModel}}{{#if optional}}?{{/if}} @relation(fields: [{{foreignKey}}], references: [id])
{{/each}} {{/each}}
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
{{#if softDelete}} {{#if softDelete}}
deletedAt DateTime? deletedAt DateTime?
{{/if}} {{/if}}
{{#each oneToMany}}
// 一对多:一个{{../chineseName}}有多个{{targetModel}}
{{name}} {{targetModel}}[]
{{/each}}
@@map("{{snakeCase pluralName}}") @@map("{{snakeCase pluralName}}")
} }
{{#if hasOneToMany}}
// ⚠️ 请在以下模型中添加外键字段:
{{#each oneToMany}}
// model {{targetModel}} {
// ...
// {{foreignKey}} String{{#if optional}}?{{/if}}
// {{backRelation}} {{pascalCase ../name}}{{#if optional}}?{{/if}} @relation(fields: [{{foreignKey}}], references: [id])
// }
{{/each}}
{{/if}}
{{#if hasManyToOne}}
// ⚠️ 请在以下模型中添加反向关联:
{{#each manyToOne}}
// model {{targetModel}} {
// ...
// {{backRelation}} {{pascalCase ../name}}[]
// }
{{/each}}
{{/if}}

View File

@@ -42,7 +42,7 @@ import { useCreate{{pascalCase name}} } from '@/hooks/use{{pascalCase pluralName
const create{{pascalCase name}}Schema = z.object({ const create{{pascalCase name}}Schema = z.object({
{{#each createFields}} {{#each createFields}}
{{name}}: {{zodValidation this}}, {{name}}: {{{zodValidation this}}},
{{/each}} {{/each}}
}); });
@@ -63,7 +63,7 @@ export function {{pascalCase name}}CreateDialog({
resolver: zodResolver(create{{pascalCase name}}Schema), resolver: zodResolver(create{{pascalCase name}}Schema),
defaultValues: { defaultValues: {
{{#each createFields}} {{#each createFields}}
{{name}}: {{defaultValue this}}, {{name}}: {{{defaultValue this}}},
{{/each}} {{/each}}
}, },
}); });

View File

@@ -44,7 +44,7 @@ import { useUpdate{{pascalCase name}} } from '@/hooks/use{{pascalCase pluralName
const edit{{pascalCase name}}Schema = z.object({ const edit{{pascalCase name}}Schema = z.object({
{{#each updateFields}} {{#each updateFields}}
{{name}}: {{zodValidation this}}.optional(), {{name}}: {{{zodValidation this}}}.optional(),
{{/each}} {{/each}}
}); });
@@ -67,7 +67,7 @@ export function {{pascalCase name}}EditDialog({
resolver: zodResolver(edit{{pascalCase name}}Schema), resolver: zodResolver(edit{{pascalCase name}}Schema),
defaultValues: { defaultValues: {
{{#each updateFields}} {{#each updateFields}}
{{name}}: {{defaultValue this}}, {{name}}: {{{defaultValue this}}},
{{/each}} {{/each}}
}, },
}); });
@@ -77,7 +77,7 @@ export function {{pascalCase name}}EditDialog({
if ({{camelCase name}}) { if ({{camelCase name}}) {
form.reset({ form.reset({
{{#each updateFields}} {{#each updateFields}}
{{name}}: {{camelCase ../name}}.{{name}}{{#if nullable}} ?? {{defaultValue this}}{{/if}}, {{name}}: {{camelCase ../name}}.{{name}}{{#if nullable}} ?? {{{defaultValue this}}}{{/if}},
{{/each}} {{/each}}
}); });
} }

View File

@@ -0,0 +1,33 @@
'use client';
import { {{pascalCase pluralName}}Table } from '@/components/{{kebabCase pluralName}}/{{pascalCase pluralName}}Table';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
export default function {{pascalCase pluralName}}Page() {
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">{{chineseName}}管理</h1>
<p className="text-muted-foreground">管理系统中的所有{{chineseName}}信息。</p>
</div>
</div>
<Card>
<CardHeader>
<CardTitle>{{chineseName}}列表</CardTitle>
<CardDescription>查看和管理所有{{chineseName}},支持新建、编辑和删除操作。</CardDescription>
</CardHeader>
<CardContent>
<{{pascalCase pluralName}}Table />
</CardContent>
</Card>
</div>
);
}

View File

@@ -16,15 +16,14 @@ export interface Get{{pascalCase pluralName}}Params {
{{/each}} {{/each}}
} }
const BASE_URL = API_ENDPOINTS.{{constantCase pluralName}};
export const {{camelCase name}}Service = { export const {{camelCase name}}Service = {
// 获取{{chineseName}}列表 // 获取{{chineseName}}列表
get{{pascalCase pluralName}}: ( get{{pascalCase pluralName}}: (
params: Get{{pascalCase pluralName}}Params = {}, params: Get{{pascalCase pluralName}}Params = {},
): Promise<PaginatedResponse<{{pascalCase name}}Response>> => { ): Promise<PaginatedResponse<{{pascalCase name}}Response>> => {
return http.get<PaginatedResponse<{{pascalCase name}}Response>>( return http.get<PaginatedResponse<{{pascalCase name}}Response>>(BASE_URL, { params });
API_ENDPOINTS.{{constantCase pluralName}},
{ params },
);
}, },
{{#if softDelete}} {{#if softDelete}}
@@ -33,7 +32,7 @@ export const {{camelCase name}}Service = {
params: Get{{pascalCase pluralName}}Params = {}, params: Get{{pascalCase pluralName}}Params = {},
): Promise<PaginatedResponse<{{pascalCase name}}Response>> => { ): Promise<PaginatedResponse<{{pascalCase name}}Response>> => {
return http.get<PaginatedResponse<{{pascalCase name}}Response>>( return http.get<PaginatedResponse<{{pascalCase name}}Response>>(
`${API_ENDPOINTS.{{constantCase pluralName}}}/deleted`, `${BASE_URL}/deleted`,
{ params }, { params },
); );
}, },
@@ -41,16 +40,14 @@ export const {{camelCase name}}Service = {
{{/if}} {{/if}}
// 获取单个{{chineseName}} // 获取单个{{chineseName}}
get{{pascalCase name}}: (id: string): Promise<{{pascalCase name}}Response> => { get{{pascalCase name}}: (id: string): Promise<{{pascalCase name}}Response> => {
return http.get<{{pascalCase name}}Response>( return http.get<{{pascalCase name}}Response>(`${BASE_URL}/${id}`);
`${API_ENDPOINTS.{{constantCase pluralName}}}/${id}`,
);
}, },
// 创建{{chineseName}} // 创建{{chineseName}}
create{{pascalCase name}}: ( create{{pascalCase name}}: (
data: Create{{pascalCase name}}Dto, data: Create{{pascalCase name}}Dto,
): Promise<{{pascalCase name}}Response> => { ): Promise<{{pascalCase name}}Response> => {
return http.post<{{pascalCase name}}Response>(API_ENDPOINTS.{{constantCase pluralName}}, data); return http.post<{{pascalCase name}}Response>(BASE_URL, data);
}, },
// 更新{{chineseName}} // 更新{{chineseName}}
@@ -58,23 +55,18 @@ export const {{camelCase name}}Service = {
id: string, id: string,
data: Update{{pascalCase name}}Dto, data: Update{{pascalCase name}}Dto,
): Promise<{{pascalCase name}}Response> => { ): Promise<{{pascalCase name}}Response> => {
return http.patch<{{pascalCase name}}Response>( return http.patch<{{pascalCase name}}Response>(`${BASE_URL}/${id}`, data);
`${API_ENDPOINTS.{{constantCase pluralName}}}/${id}`,
data,
);
}, },
// 删除{{chineseName}} // 删除{{chineseName}}
delete{{pascalCase name}}: (id: string): Promise<void> => { delete{{pascalCase name}}: (id: string): Promise<void> => {
return http.delete<void>(`${API_ENDPOINTS.{{constantCase pluralName}}}/${id}`); return http.delete<void>(`${BASE_URL}/${id}`);
}, },
{{#if softDelete}} {{#if softDelete}}
// 恢复{{chineseName}} // 恢复{{chineseName}}
restore{{pascalCase name}}: (id: string): Promise<{{pascalCase name}}Response> => { restore{{pascalCase name}}: (id: string): Promise<{{pascalCase name}}Response> => {
return http.patch<{{pascalCase name}}Response>( return http.patch<{{pascalCase name}}Response>(`${BASE_URL}/${id}/restore`);
`${API_ENDPOINTS.{{constantCase pluralName}}}/${id}/restore`,
);
}, },
{{/if}} {{/if}}
}; };

View File

@@ -315,9 +315,9 @@ export function {{pascalCase pluralName}}Table() {
}} }}
> >
{{chineseName}}列表 {{chineseName}}列表
{{{camelCase pluralName}}Data && ( { {{camelCase pluralName}}Data && (
<Badge variant="secondary" className="ml-2"> <Badge variant="secondary" className="ml-2">
{{{camelCase pluralName}}Data.total} { {{camelCase pluralName}}Data.total}
</Badge> </Badge>
)} )}
</Button> </Button>
@@ -411,7 +411,7 @@ export function {{pascalCase pluralName}}Table() {
{/* 编辑弹窗 */} {/* 编辑弹窗 */}
<{{pascalCase name}}EditDialog <{{pascalCase name}}EditDialog
{{camelCase name}}={{{camelCase name}}ToEdit} {{camelCase name}}={ {{camelCase name}}ToEdit}
open={editDialogOpen} open={editDialogOpen}
onOpenChange={setEditDialogOpen} onOpenChange={setEditDialogOpen}
/> />

View File

@@ -379,3 +379,23 @@ export function getWhereCondition(field: FieldDefinition): string {
} }
return `query.${field.name}`; return `query.${field.name}`;
} }
/**
* 获取格式化后的 example 值(用于代码生成)
* 字符串类型加引号,数字/布尔值直接输出
*/
export function getFormattedExample(field: FieldDefinition): string {
switch (field.type) {
case 'string':
case 'enum':
case 'date':
case 'datetime':
return `'${field.example}'`;
case 'number':
return field.example;
case 'boolean':
return field.example.toLowerCase() === 'true' ? 'true' : 'false';
default:
return `'${field.example}'`;
}
}

View File

@@ -4,10 +4,11 @@
* DSL 格式: * DSL 格式:
* - 关联关系: 关联名:目标模型 字段1,字段2,... [noList] * - 关联关系: 关联名:目标模型 字段1,字段2,... [noList]
* - 多对多: 关系名:中间表:外键:目标键:目标模型 字段1,字段2,... * - 多对多: 关系名:中间表:外键:目标键:目标模型 字段1,字段2,...
* - 一对多: 关系名:目标模型 [optional]
*/ */
/** /**
* 关联关系配置 * 关联关系配置(用于查询时 include
*/ */
export interface RelationConfig { export interface RelationConfig {
/** 关联名,如 'class' */ /** 关联名,如 'class' */
@@ -22,6 +23,48 @@ export interface RelationConfig {
includeInList: boolean; includeInList: boolean;
} }
/**
* 一对多关系配置(新模型包含多个目标模型)
* 例如Dormitory 包含多个 Student
* - 新模型students Student[]
* - 目标模型dormitoryId String?, dormitory Dormitory?
*/
export interface OneToManyConfig {
/** 关系名(复数),如 'students' */
name: string;
/** 目标模型 PascalCase如 'Student' */
targetModel: string;
/** 目标模型 camelCase如 'student' */
target: string;
/** 外键字段名(在目标模型中),如 'dormitoryId' */
foreignKey: string;
/** 反向关联名(在目标模型中),如 'dormitory' */
backRelation: string;
/** 是否可选(外键是否可为 null默认 true */
optional: boolean;
}
/**
* 多对一关系配置(新模型属于一个目标模型)
* 例如Grade(成绩) 属于 Student
* - 新模型studentId String, student Student
* - 目标模型grades Grade[]
*/
export interface ManyToOneConfig {
/** 外键字段名,如 'studentId' */
foreignKey: string;
/** 关联名,如 'student' */
name: string;
/** 目标模型 PascalCase如 'Student' */
targetModel: string;
/** 目标模型 camelCase如 'student' */
target: string;
/** 反向关联名(在目标模型中,复数),如 'grades' */
backRelation: string;
/** 是否可选,默认 false */
optional: boolean;
}
/** /**
* 多对多关系<E585B3><E7B3BB><EFBFBD> * 多对多关系<E585B3><E7B3BB><EFBFBD>
*/ */
@@ -214,3 +257,84 @@ export function generateManyToManyTypeField(config: ManyToManyConfig): string {
const fieldTypes = config.selectFields.map((f) => `${f}: ${getFieldType(f)}`); const fieldTypes = config.selectFields.map((f) => `${f}: ${getFieldType(f)}`);
return `${config.name}?: Array<{ ${config.target}: { ${fieldTypes.join('; ')} } }>`; return `${config.name}?: Array<{ ${config.target}: { ${fieldTypes.join('; ')} } }>`;
} }
/**
* 解析一对多关系配置
* 格式: 关系名:目标模型 [optional]
*
* @example
* 输入: "students:Student optional"
* 输出: { name: 'students', targetModel: 'Student', target: 'student', foreignKey: 'dormitoryId', backRelation: 'dormitory', optional: true }
*/
export function parseOneToMany(dsl: string, currentModelName: string): OneToManyConfig[] {
const lines = dsl
.split('\n')
.map((line) => line.trim())
.filter((line) => line && !line.startsWith('#'));
const configs: OneToManyConfig[] = [];
const currentModelCamel = currentModelName.charAt(0).toLowerCase() + currentModelName.slice(1);
for (const line of lines) {
// 匹配格式: 关系名:目标模型 [optional]
const match = line.match(/^(\w+):(\w+)(?:\s+(optional))?$/);
if (!match) {
console.warn(`无法解析一对多配置行: ${line}`);
continue;
}
const [, name, targetModel, optionalFlag] = match;
const target = targetModel.charAt(0).toLowerCase() + targetModel.slice(1);
configs.push({
name,
targetModel,
target,
foreignKey: `${currentModelCamel}Id`,
backRelation: currentModelCamel,
optional: !!optionalFlag,
});
}
return configs;
}
/**
* 解析多对一关系配置
* 格式: 关联名:目标模型 [optional]
*
* @example
* 输入: "student:Student"
* 输出: { foreignKey: 'studentId', name: 'student', targetModel: 'Student', target: 'student', backRelation: 'grades', optional: false }
*/
export function parseManyToOne(dsl: string, currentModelPluralName: string): ManyToOneConfig[] {
const lines = dsl
.split('\n')
.map((line) => line.trim())
.filter((line) => line && !line.startsWith('#'));
const configs: ManyToOneConfig[] = [];
for (const line of lines) {
// 匹配格式: 关联名:目标模型 [optional]
const match = line.match(/^(\w+):(\w+)(?:\s+(optional))?$/);
if (!match) {
console.warn(`无法解析多对一配置行: ${line}`);
continue;
}
const [, name, targetModel, optionalFlag] = match;
const target = targetModel.charAt(0).toLowerCase() + targetModel.slice(1);
configs.push({
foreignKey: `${target}Id`,
name,
targetModel,
target,
backRelation: currentModelPluralName,
optional: !!optionalFlag,
});
}
return configs;
}

11
pnpm-lock.yaml generated
View File

@@ -11,9 +11,15 @@ importers:
'@types/node': '@types/node':
specifier: ^22.10.2 specifier: ^22.10.2
version: 22.19.3 version: 22.19.3
'@types/pluralize':
specifier: ^0.0.33
version: 0.0.33
plop: plop:
specifier: ^4.0.4 specifier: ^4.0.4
version: 4.0.4(@types/node@22.19.3) version: 4.0.4(@types/node@22.19.3)
pluralize:
specifier: ^8.0.0
version: 8.0.0
prettier: prettier:
specifier: ^3.4.2 specifier: ^3.4.2
version: 3.7.4 version: 3.7.4
@@ -2472,6 +2478,9 @@ packages:
'@types/picomatch@4.0.2': '@types/picomatch@4.0.2':
resolution: {integrity: sha512-qHHxQ+P9PysNEGbALT8f8YOSHW0KJu6l2xU8DYY0fu/EmGxXdVnuTLvFUvBgPJMSqXq29SYHveejeAha+4AYgA==} resolution: {integrity: sha512-qHHxQ+P9PysNEGbALT8f8YOSHW0KJu6l2xU8DYY0fu/EmGxXdVnuTLvFUvBgPJMSqXq29SYHveejeAha+4AYgA==}
'@types/pluralize@0.0.33':
resolution: {integrity: sha512-JOqsl+ZoCpP4e8TDke9W79FDcSgPAR0l6pixx2JHkhnRjvShyYiAYw2LVsnA7K08Y6DeOnaU6ujmENO4os/cYg==}
'@types/qs@6.14.0': '@types/qs@6.14.0':
resolution: {integrity: sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==} resolution: {integrity: sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==}
@@ -8784,6 +8793,8 @@ snapshots:
'@types/picomatch@4.0.2': {} '@types/picomatch@4.0.2': {}
'@types/pluralize@0.0.33': {}
'@types/qs@6.14.0': {} '@types/qs@6.14.0': {}
'@types/range-parser@1.2.7': {} '@types/range-parser@1.2.7': {}