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:
@@ -116,9 +116,11 @@ export const DEFAULT_CRUD_OPTIONS: Required<CrudServiceOptions> = {
|
||||
|
||||
/**
|
||||
* 分页查询参数(扩展 shared 包的类型,添加过滤条件)
|
||||
* 允许额外的查询参数用于 filterableFields 过滤
|
||||
*/
|
||||
export interface FindAllParams<WhereInput = Record<string, unknown>> extends PaginationParams {
|
||||
where?: WhereInput;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -15,6 +15,9 @@ import { IsInt, IsOptional, IsString, Matches } from 'class-validator';
|
||||
* - pageSize=-1 或 pageSize=0 返回所有数据
|
||||
*/
|
||||
export class PaginationQueryDto implements PaginationParams {
|
||||
// 索引签名:允许额外的查询参数(用于 filterableFields)
|
||||
[key: string]: unknown;
|
||||
|
||||
@ApiPropertyOptional({ description: '页码', default: 1, minimum: 1 })
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
|
||||
@@ -18,7 +18,9 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.10.2",
|
||||
"@types/pluralize": "^0.0.33",
|
||||
"plop": "^4.0.4",
|
||||
"pluralize": "^8.0.0",
|
||||
"prettier": "^3.4.2",
|
||||
"tsx": "^4.21.0",
|
||||
"turbo": "^2.3.3",
|
||||
|
||||
@@ -9,13 +9,18 @@
|
||||
*/
|
||||
|
||||
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,
|
||||
@@ -56,11 +61,15 @@ interface TemplateData {
|
||||
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;
|
||||
@@ -90,12 +99,6 @@ export function crudGenerator(plop: NodePlopAPI) {
|
||||
message: '模块中文名(如 产品):',
|
||||
validate: (input: string) => !!input || '请输入中文名',
|
||||
},
|
||||
{
|
||||
type: 'input',
|
||||
name: 'pluralName',
|
||||
message: '复数名称(如 products):',
|
||||
default: (answers: { name: string }) => `${answers.name}s`,
|
||||
},
|
||||
{
|
||||
type: 'checkbox',
|
||||
name: 'generateTargets',
|
||||
@@ -146,59 +149,6 @@ export function crudGenerator(plop: NodePlopAPI) {
|
||||
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 时显示)
|
||||
{
|
||||
type: 'checkbox',
|
||||
@@ -260,6 +210,137 @@ name:string 名称 "示例名称" min:2 max:100
|
||||
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',
|
||||
@@ -346,6 +427,7 @@ name:string 名称 "示例名称" min:2 max:100
|
||||
|
||||
// 解析关联配置
|
||||
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)
|
||||
@@ -354,6 +436,12 @@ name:string 名称 "示例名称" min:2 max:100
|
||||
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)
|
||||
@@ -362,6 +450,8 @@ name:string 名称 "示例名称" min:2 max:100
|
||||
// 派生标志
|
||||
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);
|
||||
@@ -370,7 +460,7 @@ name:string 名称 "示例名称" min:2 max:100
|
||||
const templateData: TemplateData = {
|
||||
name: data.name,
|
||||
chineseName: data.chineseName,
|
||||
pluralName: data.pluralName,
|
||||
pluralName: pluralize(data.name),
|
||||
generateTargets: data.generateTargets,
|
||||
softDelete: data.softDelete,
|
||||
fields,
|
||||
@@ -399,9 +489,13 @@ name:string 名称 "示例名称" min:2 max:100
|
||||
serviceType,
|
||||
relations,
|
||||
manyToMany,
|
||||
oneToMany,
|
||||
manyToOne,
|
||||
countRelations,
|
||||
hasRelations,
|
||||
hasManyToMany,
|
||||
hasOneToMany,
|
||||
hasManyToOne,
|
||||
hasCountRelations,
|
||||
needsResponseDto,
|
||||
needsDetailDto,
|
||||
@@ -417,6 +511,7 @@ name:string 名称 "示例名称" min:2 max:100
|
||||
path: 'apps/api/src/{{kebabCase name}}/dto/{{kebabCase name}}.dto.ts',
|
||||
templateFile: 'templates/api/dto.hbs',
|
||||
data: templateData,
|
||||
abortOnFail: false,
|
||||
});
|
||||
|
||||
// Service
|
||||
@@ -425,6 +520,7 @@ name:string 名称 "示例名称" min:2 max:100
|
||||
path: 'apps/api/src/{{kebabCase name}}/{{kebabCase name}}.service.ts',
|
||||
templateFile: 'templates/api/service.hbs',
|
||||
data: templateData,
|
||||
abortOnFail: false,
|
||||
});
|
||||
|
||||
// Controller
|
||||
@@ -433,6 +529,7 @@ name:string 名称 "示例名称" min:2 max:100
|
||||
path: 'apps/api/src/{{kebabCase name}}/{{kebabCase name}}.controller.ts',
|
||||
templateFile: 'templates/api/controller.hbs',
|
||||
data: templateData,
|
||||
abortOnFail: false,
|
||||
});
|
||||
|
||||
// Module
|
||||
@@ -441,6 +538,7 @@ name:string 名称 "示例名称" min:2 max:100
|
||||
path: 'apps/api/src/{{kebabCase name}}/{{kebabCase name}}.module.ts',
|
||||
templateFile: 'templates/api/module.hbs',
|
||||
data: templateData,
|
||||
abortOnFail: false,
|
||||
});
|
||||
|
||||
// 修改 app.module.ts - 添加导入
|
||||
@@ -450,6 +548,7 @@ name:string 名称 "示例名称" min:2 max:100
|
||||
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 数组
|
||||
@@ -459,6 +558,7 @@ name:string 名称 "示例名称" min:2 max:100
|
||||
pattern: /(\s+)(StudentModule,?\s*\n)(\s*\],)/,
|
||||
template: `$1$2$1{{pascalCase name}}Module,\n$3`,
|
||||
data: templateData,
|
||||
abortOnFail: false,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -470,6 +570,7 @@ name:string 名称 "示例名称" min:2 max:100
|
||||
path: 'apps/web/src/services/{{kebabCase name}}.service.ts',
|
||||
templateFile: 'templates/web/service.hbs',
|
||||
data: templateData,
|
||||
abortOnFail: false,
|
||||
});
|
||||
|
||||
// Hooks
|
||||
@@ -478,6 +579,7 @@ name:string 名称 "示例名称" min:2 max:100
|
||||
path: 'apps/web/src/hooks/use{{pascalCase pluralName}}.ts',
|
||||
templateFile: 'templates/web/hooks.hbs',
|
||||
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',
|
||||
templateFile: 'templates/web/table.hbs',
|
||||
data: templateData,
|
||||
abortOnFail: false,
|
||||
});
|
||||
|
||||
actions.push({
|
||||
@@ -493,6 +596,7 @@ name:string 名称 "示例名称" min:2 max:100
|
||||
path: 'apps/web/src/components/{{kebabCase pluralName}}/{{pascalCase name}}CreateDialog.tsx',
|
||||
templateFile: 'templates/web/create-dialog.hbs',
|
||||
data: templateData,
|
||||
abortOnFail: false,
|
||||
});
|
||||
|
||||
actions.push({
|
||||
@@ -500,6 +604,16 @@ name:string 名称 "示例名称" min:2 max:100
|
||||
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 端点
|
||||
@@ -509,6 +623,7 @@ name:string 名称 "示例名称" min:2 max:100
|
||||
pattern: /(USERS:\s*['"]\/users['"],?)/,
|
||||
template: `$1\n {{constantCase pluralName}}: '/{{kebabCase pluralName}}',`,
|
||||
data: templateData,
|
||||
abortOnFail: false,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -519,6 +634,7 @@ name:string 名称 "示例名称" min:2 max:100
|
||||
path: 'packages/shared/src/types/{{kebabCase name}}.ts',
|
||||
templateFile: 'templates/shared/types.hbs',
|
||||
data: templateData,
|
||||
abortOnFail: false,
|
||||
});
|
||||
|
||||
// 修改 types/index.ts - 添加导出
|
||||
@@ -527,6 +643,7 @@ name:string 名称 "示例名称" min:2 max:100
|
||||
path: 'packages/shared/src/types/index.ts',
|
||||
template: `\n// {{chineseName}}\nexport * from './{{kebabCase name}}';\n`,
|
||||
data: templateData,
|
||||
abortOnFail: false,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -537,6 +654,7 @@ name:string 名称 "示例名称" min:2 max:100
|
||||
path: 'apps/api/prisma/schema.prisma',
|
||||
templateFile: 'templates/prisma/model.hbs',
|
||||
data: templateData,
|
||||
abortOnFail: false,
|
||||
});
|
||||
|
||||
// 如果启用软删除,修改 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]*?)(\];)/,
|
||||
template: `$1, '{{pascalCase name}}'$2`,
|
||||
data: templateData,
|
||||
abortOnFail: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -561,14 +680,44 @@ name:string 名称 "示例名称" min:2 max:100
|
||||
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 (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('2. 重启开发服务器 pnpm dev\n');
|
||||
console.log(`${hasOneToMany || hasManyToOne ? '3' : '2'}. 重启开发服务器 pnpm dev\n`);
|
||||
return '完成';
|
||||
});
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
getFormControl,
|
||||
getCellRenderer,
|
||||
getWhereCondition,
|
||||
getFormattedExample,
|
||||
} from '../utils/field-parser';
|
||||
|
||||
/**
|
||||
@@ -92,6 +93,10 @@ export function registerHelpers(plop: NodePlopAPI) {
|
||||
|
||||
plop.setHelper('cellRenderer', (field: FieldDefinition) => getCellRenderer(field));
|
||||
|
||||
// ===== Example 格式化 helpers =====
|
||||
|
||||
plop.setHelper('formattedExample', (field: FieldDefinition) => getFormattedExample(field));
|
||||
|
||||
// ===== 查询条件 helpers =====
|
||||
|
||||
plop.setHelper('whereCondition', (field: FieldDefinition) => getWhereCondition(field));
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
ApiOkResponse,
|
||||
ApiCreatedResponse,
|
||||
} from '@nestjs/swagger';
|
||||
{{#if (eq serviceType 'CrudService')}}
|
||||
{{#if hasQueryDto}}
|
||||
import { Prisma } from '@prisma/client';
|
||||
{{/if}}
|
||||
|
||||
@@ -40,7 +40,7 @@ import { {{pascalCase name}}Service } from './{{kebabCase name}}.service';
|
||||
|
||||
import { JwtAuthGuard } from '@/auth/guards/jwt-auth.guard';
|
||||
{{#unless hasQueryDto}}
|
||||
import { PaginationQueryDto } from '@/common/crud';
|
||||
import { PaginationQueryDto } from '@/common/crud/dto/pagination.dto';
|
||||
{{/unless}}
|
||||
|
||||
@ApiTags('{{chineseName}}')
|
||||
|
||||
@@ -13,15 +13,16 @@ import type {
|
||||
{{pascalCase name}}Response,
|
||||
} 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 */
|
||||
export class Create{{pascalCase name}}Dto implements ICreate{{pascalCase name}}Dto {
|
||||
{{#each createFields}}
|
||||
{{#if nullable}}
|
||||
@ApiPropertyOptional({ example: {{{example}}}, description: '{{label}}' })
|
||||
@ApiPropertyOptional({ example: {{{formattedExample this}}}, description: '{{label}}' })
|
||||
{{else}}
|
||||
@ApiProperty({ example: {{{example}}}, description: '{{label}}' })
|
||||
@ApiProperty({ example: {{{formattedExample this}}}, description: '{{label}}' })
|
||||
{{/if}}
|
||||
{{#each (validationDecorators this)}}
|
||||
{{{this}}}
|
||||
@@ -37,7 +38,7 @@ export class Create{{pascalCase name}}Dto implements ICreate{{pascalCase name}}D
|
||||
/** 更新{{chineseName}}请求 DTO */
|
||||
export class Update{{pascalCase name}}Dto implements IUpdate{{pascalCase name}}Dto {
|
||||
{{#each updateFields}}
|
||||
@ApiPropertyOptional({ example: {{{example}}}, description: '{{label}}' })
|
||||
@ApiPropertyOptional({ example: {{{formattedExample this}}}, description: '{{label}}' })
|
||||
{{#each (validationDecorators this)}}
|
||||
{{{this}}}
|
||||
{{/each}}
|
||||
@@ -99,10 +100,10 @@ export class {{pascalCase name}}ResponseDto implements {{pascalCase name}}Respon
|
||||
|
||||
{{#each responseFields}}
|
||||
{{#if nullable}}
|
||||
@ApiProperty({ example: {{{example}}}, description: '{{label}}', nullable: true })
|
||||
@ApiProperty({ example: {{{formattedExample this}}}, description: '{{label}}', nullable: true })
|
||||
{{name}}: {{tsResponseType this}} | null;
|
||||
{{else}}
|
||||
@ApiProperty({ example: {{{example}}}, description: '{{label}}' })
|
||||
@ApiProperty({ example: {{{formattedExample this}}}, description: '{{label}}' })
|
||||
{{name}}: {{tsResponseType this}};
|
||||
{{/if}}
|
||||
|
||||
|
||||
@@ -5,12 +5,42 @@ model {{pascalCase name}} {
|
||||
id String @id @default(cuid(2))
|
||||
{{#each fields}}
|
||||
{{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}}
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
{{#if softDelete}}
|
||||
deletedAt DateTime?
|
||||
{{/if}}
|
||||
{{#each oneToMany}}
|
||||
|
||||
// 一对多:一个{{../chineseName}}有多个{{targetModel}}
|
||||
{{name}} {{targetModel}}[]
|
||||
{{/each}}
|
||||
|
||||
@@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}}
|
||||
|
||||
@@ -42,7 +42,7 @@ import { useCreate{{pascalCase name}} } from '@/hooks/use{{pascalCase pluralName
|
||||
|
||||
const create{{pascalCase name}}Schema = z.object({
|
||||
{{#each createFields}}
|
||||
{{name}}: {{zodValidation this}},
|
||||
{{name}}: {{{zodValidation this}}},
|
||||
{{/each}}
|
||||
});
|
||||
|
||||
@@ -63,7 +63,7 @@ export function {{pascalCase name}}CreateDialog({
|
||||
resolver: zodResolver(create{{pascalCase name}}Schema),
|
||||
defaultValues: {
|
||||
{{#each createFields}}
|
||||
{{name}}: {{defaultValue this}},
|
||||
{{name}}: {{{defaultValue this}}},
|
||||
{{/each}}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -44,7 +44,7 @@ import { useUpdate{{pascalCase name}} } from '@/hooks/use{{pascalCase pluralName
|
||||
|
||||
const edit{{pascalCase name}}Schema = z.object({
|
||||
{{#each updateFields}}
|
||||
{{name}}: {{zodValidation this}}.optional(),
|
||||
{{name}}: {{{zodValidation this}}}.optional(),
|
||||
{{/each}}
|
||||
});
|
||||
|
||||
@@ -67,7 +67,7 @@ export function {{pascalCase name}}EditDialog({
|
||||
resolver: zodResolver(edit{{pascalCase name}}Schema),
|
||||
defaultValues: {
|
||||
{{#each updateFields}}
|
||||
{{name}}: {{defaultValue this}},
|
||||
{{name}}: {{{defaultValue this}}},
|
||||
{{/each}}
|
||||
},
|
||||
});
|
||||
@@ -77,7 +77,7 @@ export function {{pascalCase name}}EditDialog({
|
||||
if ({{camelCase name}}) {
|
||||
form.reset({
|
||||
{{#each updateFields}}
|
||||
{{name}}: {{camelCase ../name}}.{{name}}{{#if nullable}} ?? {{defaultValue this}}{{/if}},
|
||||
{{name}}: {{camelCase ../name}}.{{name}}{{#if nullable}} ?? {{{defaultValue this}}}{{/if}},
|
||||
{{/each}}
|
||||
});
|
||||
}
|
||||
|
||||
33
plop/templates/web/page.hbs
Normal file
33
plop/templates/web/page.hbs
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -16,15 +16,14 @@ export interface Get{{pascalCase pluralName}}Params {
|
||||
{{/each}}
|
||||
}
|
||||
|
||||
const BASE_URL = API_ENDPOINTS.{{constantCase pluralName}};
|
||||
|
||||
export const {{camelCase name}}Service = {
|
||||
// 获取{{chineseName}}列表
|
||||
get{{pascalCase pluralName}}: (
|
||||
params: Get{{pascalCase pluralName}}Params = {},
|
||||
): Promise<PaginatedResponse<{{pascalCase name}}Response>> => {
|
||||
return http.get<PaginatedResponse<{{pascalCase name}}Response>>(
|
||||
API_ENDPOINTS.{{constantCase pluralName}},
|
||||
{ params },
|
||||
);
|
||||
return http.get<PaginatedResponse<{{pascalCase name}}Response>>(BASE_URL, { params });
|
||||
},
|
||||
|
||||
{{#if softDelete}}
|
||||
@@ -33,7 +32,7 @@ export const {{camelCase name}}Service = {
|
||||
params: Get{{pascalCase pluralName}}Params = {},
|
||||
): Promise<PaginatedResponse<{{pascalCase name}}Response>> => {
|
||||
return http.get<PaginatedResponse<{{pascalCase name}}Response>>(
|
||||
`${API_ENDPOINTS.{{constantCase pluralName}}}/deleted`,
|
||||
`${BASE_URL}/deleted`,
|
||||
{ params },
|
||||
);
|
||||
},
|
||||
@@ -41,16 +40,14 @@ export const {{camelCase name}}Service = {
|
||||
{{/if}}
|
||||
// 获取单个{{chineseName}}
|
||||
get{{pascalCase name}}: (id: string): Promise<{{pascalCase name}}Response> => {
|
||||
return http.get<{{pascalCase name}}Response>(
|
||||
`${API_ENDPOINTS.{{constantCase pluralName}}}/${id}`,
|
||||
);
|
||||
return http.get<{{pascalCase name}}Response>(`${BASE_URL}/${id}`);
|
||||
},
|
||||
|
||||
// 创建{{chineseName}}
|
||||
create{{pascalCase name}}: (
|
||||
data: Create{{pascalCase name}}Dto,
|
||||
): 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}}
|
||||
@@ -58,23 +55,18 @@ export const {{camelCase name}}Service = {
|
||||
id: string,
|
||||
data: Update{{pascalCase name}}Dto,
|
||||
): Promise<{{pascalCase name}}Response> => {
|
||||
return http.patch<{{pascalCase name}}Response>(
|
||||
`${API_ENDPOINTS.{{constantCase pluralName}}}/${id}`,
|
||||
data,
|
||||
);
|
||||
return http.patch<{{pascalCase name}}Response>(`${BASE_URL}/${id}`, data);
|
||||
},
|
||||
|
||||
// 删除{{chineseName}}
|
||||
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}}
|
||||
// 恢复{{chineseName}}
|
||||
restore{{pascalCase name}}: (id: string): Promise<{{pascalCase name}}Response> => {
|
||||
return http.patch<{{pascalCase name}}Response>(
|
||||
`${API_ENDPOINTS.{{constantCase pluralName}}}/${id}/restore`,
|
||||
);
|
||||
return http.patch<{{pascalCase name}}Response>(`${BASE_URL}/${id}/restore`);
|
||||
},
|
||||
{{/if}}
|
||||
};
|
||||
|
||||
@@ -379,3 +379,23 @@ export function getWhereCondition(field: FieldDefinition): string {
|
||||
}
|
||||
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}'`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,10 +4,11 @@
|
||||
* DSL 格式:
|
||||
* - 关联关系: 关联名:目标模型 字段1,字段2,... [noList]
|
||||
* - 多对多: 关系名:中间表:外键:目标键:目标模型 字段1,字段2,...
|
||||
* - 一对多: 关系名:目标模型 [optional]
|
||||
*/
|
||||
|
||||
/**
|
||||
* 关联关系配置
|
||||
* 关联关系配置(用于查询时 include)
|
||||
*/
|
||||
export interface RelationConfig {
|
||||
/** 关联名,如 'class' */
|
||||
@@ -22,6 +23,48 @@ export interface RelationConfig {
|
||||
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>置
|
||||
*/
|
||||
@@ -214,3 +257,84 @@ export function generateManyToManyTypeField(config: ManyToManyConfig): string {
|
||||
const fieldTypes = config.selectFields.map((f) => `${f}: ${getFieldType(f)}`);
|
||||
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
11
pnpm-lock.yaml
generated
@@ -11,9 +11,15 @@ importers:
|
||||
'@types/node':
|
||||
specifier: ^22.10.2
|
||||
version: 22.19.3
|
||||
'@types/pluralize':
|
||||
specifier: ^0.0.33
|
||||
version: 0.0.33
|
||||
plop:
|
||||
specifier: ^4.0.4
|
||||
version: 4.0.4(@types/node@22.19.3)
|
||||
pluralize:
|
||||
specifier: ^8.0.0
|
||||
version: 8.0.0
|
||||
prettier:
|
||||
specifier: ^3.4.2
|
||||
version: 3.7.4
|
||||
@@ -2472,6 +2478,9 @@ packages:
|
||||
'@types/picomatch@4.0.2':
|
||||
resolution: {integrity: sha512-qHHxQ+P9PysNEGbALT8f8YOSHW0KJu6l2xU8DYY0fu/EmGxXdVnuTLvFUvBgPJMSqXq29SYHveejeAha+4AYgA==}
|
||||
|
||||
'@types/pluralize@0.0.33':
|
||||
resolution: {integrity: sha512-JOqsl+ZoCpP4e8TDke9W79FDcSgPAR0l6pixx2JHkhnRjvShyYiAYw2LVsnA7K08Y6DeOnaU6ujmENO4os/cYg==}
|
||||
|
||||
'@types/qs@6.14.0':
|
||||
resolution: {integrity: sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==}
|
||||
|
||||
@@ -8784,6 +8793,8 @@ snapshots:
|
||||
|
||||
'@types/picomatch@4.0.2': {}
|
||||
|
||||
'@types/pluralize@0.0.33': {}
|
||||
|
||||
'@types/qs@6.14.0': {}
|
||||
|
||||
'@types/range-parser@1.2.7': {}
|
||||
|
||||
Reference in New Issue
Block a user