diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index bbb4238..f0cc890 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -12,7 +12,7 @@ model User { email String password String name String? - avatarId String? // 头像文件 ID + avatarId String? // 头像文件 ID isSuperAdmin Boolean @default(false) // 超级管理员标记 createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -28,18 +28,18 @@ model User { // 文件表 model File { - id String @id @default(cuid(2)) - filename String // 原始文件名 - objectName String // MinIO 中的对象名 - mimeType String // MIME 类型 - size Int // 文件大小(字节) - purpose String // 用途: avatar, attachment 等 - uploaderId String // 上传者 ID - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - deletedAt DateTime? + id String @id @default(cuid(2)) + filename String // 原始文件名 + objectName String // MinIO 中的对象名 + mimeType String // MIME 类型 + size Int // 文件大小(字节) + purpose String // 用途: avatar, attachment 等 + uploaderId String // 上传者 ID + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + deletedAt DateTime? - uploader User @relation("FileUploader", fields: [uploaderId], references: [id]) + uploader User @relation("FileUploader", fields: [uploaderId], references: [id]) @@index([uploaderId]) @@index([purpose]) @@ -161,10 +161,10 @@ model Teacher { id String @id @default(cuid(2)) teacherNo String @unique // 工号 name String - gender String? // male / female + gender String? // male / female phone String? email String? - subject String? // 任教科目 + subject String? // 任教科目 createdAt DateTime @default(now()) updatedAt DateTime @updatedAt deletedAt DateTime? @@ -180,16 +180,16 @@ model Teacher { model Class { id String @id @default(cuid(2)) code String @unique // 班级代码 - name String // 班级名称 - grade String? // 年级 - headTeacherId String? // 班主任 ID(一对一) + name String // 班级名称 + grade String? // 年级 + headTeacherId String? // 班主任 ID(一对一) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt deletedAt DateTime? // 关系 headTeacher Teacher? @relation("HeadTeacher", fields: [headTeacherId], references: [id]) - students Student[] // 班级学生(一对多) + students Student[] // 班级学生(一对多) teachers ClassTeacher[] // 任课教师(多对多) @@index([headTeacherId]) @@ -201,10 +201,10 @@ model Student { id String @id @default(cuid(2)) studentNo String @unique // 学号 name String - gender String? // male / female + gender String? // male / female phone String? email String? - classId String? // 所属班级(多对一) + classId String? // 所属班级(多对一) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt deletedAt DateTime? diff --git a/apps/api/src/common/crud/crud.types.ts b/apps/api/src/common/crud/crud.types.ts index de724b0..390a088 100644 --- a/apps/api/src/common/crud/crud.types.ts +++ b/apps/api/src/common/crud/crud.types.ts @@ -116,9 +116,11 @@ export const DEFAULT_CRUD_OPTIONS: Required = { /** * 分页查询参数(扩展 shared 包的类型,添加过滤条件) + * 允许额外的查询参数用于 filterableFields 过滤 */ export interface FindAllParams> extends PaginationParams { where?: WhereInput; + [key: string]: unknown; } /** diff --git a/apps/api/src/common/crud/dto/pagination.dto.ts b/apps/api/src/common/crud/dto/pagination.dto.ts index dfdc46e..dfee579 100644 --- a/apps/api/src/common/crud/dto/pagination.dto.ts +++ b/apps/api/src/common/crud/dto/pagination.dto.ts @@ -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() diff --git a/package.json b/package.json index f41828c..0bef6fc 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/plop/generators/crud.ts b/plop/generators/crud.ts index 17974dd..af409be 100644 --- a/plop/generators/crud.ts +++ b/plop/generators/crud.ts @@ -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 '完成'; }); diff --git a/plop/helpers/index.ts b/plop/helpers/index.ts index 951b596..a10ccb1 100644 --- a/plop/helpers/index.ts +++ b/plop/helpers/index.ts @@ -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)); diff --git a/plop/templates/api/controller.hbs b/plop/templates/api/controller.hbs index 4240924..b5080b5 100644 --- a/plop/templates/api/controller.hbs +++ b/plop/templates/api/controller.hbs @@ -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}}') diff --git a/plop/templates/api/dto.hbs b/plop/templates/api/dto.hbs index c99543f..6b580d6 100644 --- a/plop/templates/api/dto.hbs +++ b/plop/templates/api/dto.hbs @@ -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}} diff --git a/plop/templates/prisma/model.hbs b/plop/templates/prisma/model.hbs index 94a3db3..a286f19 100644 --- a/plop/templates/prisma/model.hbs +++ b/plop/templates/prisma/model.hbs @@ -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}} diff --git a/plop/templates/web/create-dialog.hbs b/plop/templates/web/create-dialog.hbs index e77e077..7e13461 100644 --- a/plop/templates/web/create-dialog.hbs +++ b/plop/templates/web/create-dialog.hbs @@ -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}} }, }); diff --git a/plop/templates/web/edit-dialog.hbs b/plop/templates/web/edit-dialog.hbs index 067e0a9..23480fc 100644 --- a/plop/templates/web/edit-dialog.hbs +++ b/plop/templates/web/edit-dialog.hbs @@ -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}} }); } diff --git a/plop/templates/web/page.hbs b/plop/templates/web/page.hbs new file mode 100644 index 0000000..526a479 --- /dev/null +++ b/plop/templates/web/page.hbs @@ -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 ( +
+
+
+

{{chineseName}}管理

+

管理系统中的所有{{chineseName}}信息。

+
+
+ + + + {{chineseName}}列表 + 查看和管理所有{{chineseName}},支持新建、编辑和删除操作。 + + + <{{pascalCase pluralName}}Table /> + + +
+ ); +} diff --git a/plop/templates/web/service.hbs b/plop/templates/web/service.hbs index 70f81b8..e63f899 100644 --- a/plop/templates/web/service.hbs +++ b/plop/templates/web/service.hbs @@ -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> => { - return http.get>( - API_ENDPOINTS.{{constantCase pluralName}}, - { params }, - ); + return http.get>(BASE_URL, { params }); }, {{#if softDelete}} @@ -33,7 +32,7 @@ export const {{camelCase name}}Service = { params: Get{{pascalCase pluralName}}Params = {}, ): Promise> => { return http.get>( - `${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 => { - return http.delete(`${API_ENDPOINTS.{{constantCase pluralName}}}/${id}`); + return http.delete(`${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}} }; diff --git a/plop/templates/web/table.hbs b/plop/templates/web/table.hbs index 3a65af8..c8140de 100644 --- a/plop/templates/web/table.hbs +++ b/plop/templates/web/table.hbs @@ -315,9 +315,9 @@ export function {{pascalCase pluralName}}Table() { }} > {{chineseName}}列表 - {{{camelCase pluralName}}Data && ( + { {{camelCase pluralName}}Data && ( - {{{camelCase pluralName}}Data.total} + { {{camelCase pluralName}}Data.total} )} @@ -411,7 +411,7 @@ export function {{pascalCase pluralName}}Table() { {/* 编辑弹窗 */} <{{pascalCase name}}EditDialog - {{camelCase name}}={{{camelCase name}}ToEdit} + {{camelCase name}}={ {{camelCase name}}ToEdit} open={editDialogOpen} onOpenChange={setEditDialogOpen} /> diff --git a/plop/utils/field-parser.ts b/plop/utils/field-parser.ts index b11b7d0..e6f4d57 100644 --- a/plop/utils/field-parser.ts +++ b/plop/utils/field-parser.ts @@ -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}'`; + } +} diff --git a/plop/utils/relation-parser.ts b/plop/utils/relation-parser.ts index dd68ffd..03f430e 100644 --- a/plop/utils/relation-parser.ts +++ b/plop/utils/relation-parser.ts @@ -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; +} + /** * 多对多关系���置 */ @@ -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; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bb2f833..94b6d29 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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': {}