feat: 添加 plop 代码生成器模板

添加组件和模块的代码生成器模板,提高开发效率。

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
charilezhou
2026-01-17 14:08:56 +08:00
parent e8780e3c1a
commit 473c2c1510
17 changed files with 2407 additions and 0 deletions

251
plop/README.md Normal file
View File

@@ -0,0 +1,251 @@
# CRUD 代码生成器
基于 Plop.js 的全栈 CRUD 代码生成器,支持一键生成后端模块、前端模块、共享类型和 Prisma Model。
## 快速开始
```bash
pnpm generate
```
按提示输入模块信息即可自动生成完整的 CRUD 代码。
## 交互式提问
| 步骤 | 提示 | 说明 | 示例 |
|------|------|------|------|
| 1 | 模块名称 | 英文,小写开头 | `product` |
| 2 | 模块中文名 | 用于注释和 API 标签 | `产品` |
| 3 | 复数名称 | API 路径和表名 | `products` |
| 4 | 生成目标 | 多选:后端/前端/共享类型/Prisma | 全选 |
| 5 | 软删除 | 是否启用软删除 | `Yes` |
| 6 | 字段定义 | DSL 语法定义字段 | 见下方 |
| 7 | 搜索字段 | 选择可搜索的字段 | `name, status` |
| 8 | 分页配置 | 默认/最大分页、排序 | `20/100/createdAt/desc` |
## 字段 DSL 语法
### 基本格式
```
字段名:类型[修饰符] 标签 "示例值" [验证规则...]
```
### 类型
| 类型 | 说明 | Prisma | TypeScript |
|------|------|--------|------------|
| `string` | 字符串 | `String` | `string` |
| `number` | 数字 | `Float` | `number` |
| `boolean` | 布尔值 | `Boolean` | `boolean` |
| `date` | 日期 | `DateTime` | `Date` |
| `datetime` | 日期时间 | `DateTime` | `Date` |
| `enum(a,b,c)` | 枚举 | `String` | `'a' \| 'b' \| 'c'` |
### 修饰符
| 修饰符 | 说明 |
|--------|------|
| `?` | 可选字段nullable |
| `!` | 唯一约束 |
### 验证规则
| 规则 | 说明 | 适用类型 |
|------|------|----------|
| `min:n` | 最小值/最小长度 | string, number |
| `max:n` | 最大值/最大长度 | string, number |
| `email` | 邮箱格式 | string |
| `url` | URL 格式 | string |
### 控制标志
| 标志 | 说明 |
|------|------|
| `noCreate` | 不在创建表单中使用 |
| `noUpdate` | 不在更新表单中使用 |
| `noTable` | 不在表格列中显示 |
### 示例
```
# 必填字符串2-100 字符
name:string 名称 "示例名称" min:2 max:100
# 可选长文本
description:string? 描述 "描述内容" max:500
# 数字,最小值 0
price:number 价格 "99.99" min:0
# 枚举类型
status:enum(draft,published,archived) 状态 "draft"
# 唯一邮箱
email:string! 邮箱 "test@example.com" email
# 布尔值
isActive:boolean 是否激活 "true"
# 可选日期
publishedAt:datetime? 发布时间 "2026-01-16T10:00:00Z"
```
## 生成的文件
### 后端 (apps/api)
| 文件 | 说明 |
|------|------|
| `src/{module}/dto/{module}.dto.ts` | CreateDto、UpdateDto、ResponseDto、QueryDto |
| `src/{module}/{module}.service.ts` | CRUD 服务,继承 CrudService |
| `src/{module}/{module}.controller.ts` | RESTful 控制器,含 Swagger 文档 |
| `src/{module}/{module}.module.ts` | NestJS 模块 |
### 前端 (apps/web)
| 文件 | 说明 |
|------|------|
| `src/services/{module}.service.ts` | API 调用封装 |
| `src/hooks/use{Module}s.ts` | TanStack Query hooks |
| `src/components/{module}s/{Module}sTable.tsx` | 数据表格组件 |
| `src/components/{module}s/{Module}CreateDialog.tsx` | 创建对话框 |
| `src/components/{module}s/{Module}EditDialog.tsx` | 编辑对话框 |
### 共享类型 (packages/shared)
| 文件 | 说明 |
|------|------|
| `src/types/{module}.ts` | 接口类型定义 |
### Prisma
| 文件 | 说明 |
|------|------|
| `prisma/schema.prisma` | 追加模型定义 |
## 自动集成
生成器会自动修改以下文件完成集成:
| 文件 | 修改内容 |
|------|----------|
| `apps/api/src/app.module.ts` | 导入新模块 |
| `apps/api/src/prisma/prisma.service.ts` | 添加软删除模型配置 |
| `apps/web/src/config/constants.ts` | 添加 API 端点 |
| `packages/shared/src/types/index.ts` | 导出新类型 |
## 生成后步骤
```bash
# 1. 同步数据库
pnpm db:generate && pnpm db:push
# 2. 重启开发服务器
pnpm dev
```
## 完整示例
以生成「产品」模块为例:
```bash
$ pnpm generate
? 模块名称(英文,如 product: product
? 模块中文名(如 产品): 产品
? 复数名称(如 products: products
? 选择要生成的模块: 后端 (NestJS), 前端 (Next.js), 共享类型, Prisma Model
? 是否启用软删除? Yes
? 定义字段:
name:string 名称 "示例产品" min:2 max:100
description:string? 描述 "产品描述" max:500
price:number 价格 "99.99" min:0
stock:number 库存 "100" min:0
status:enum(draft,published,archived) 状态 "draft"
? 选择支持搜索的字段: name, status
? 默认分页大小: 20
? 最大分页大小: 100
? 默认排序字段: createdAt
? 默认排序方向: desc
✔ 生成 apps/api/src/product/dto/product.dto.ts
✔ 生成 apps/api/src/product/product.service.ts
✔ 生成 apps/api/src/product/product.controller.ts
✔ 生成 apps/api/src/product/product.module.ts
✔ 生成 apps/web/src/services/product.service.ts
✔ 生成 apps/web/src/hooks/useProducts.ts
✔ 生成 apps/web/src/components/products/ProductsTable.tsx
✔ 生成 apps/web/src/components/products/ProductCreateDialog.tsx
✔ 生成 apps/web/src/components/products/ProductEditDialog.tsx
✔ 生成 packages/shared/src/types/product.ts
✔ 修改 apps/api/prisma/schema.prisma
✔ 修改 apps/api/src/app.module.ts
✔ 修改 apps/api/src/prisma/prisma.service.ts
✔ 修改 apps/web/src/config/constants.ts
✔ 修改 packages/shared/src/types/index.ts
✨ 生成完成!
```
## 目录结构
```
plop/
├── plopfile.ts # 主配置入口
├── package.json # ESM 模块配置
├── generators/
│ └── crud.ts # CRUD 生成器逻辑
├── helpers/
│ └── index.ts # Handlebars helpers
├── utils/
│ └── field-parser.ts # 字段 DSL 解析器
└── templates/
├── api/ # 后端模板
│ ├── dto.hbs
│ ├── service.hbs
│ ├── controller.hbs
│ └── module.hbs
├── web/ # 前端模板
│ ├── service.hbs
│ ├── hooks.hbs
│ ├── table.hbs
│ ├── create-dialog.hbs
│ └── edit-dialog.hbs
├── shared/ # 共享类型模板
│ └── types.hbs
└── prisma/ # Prisma 模板
└── model.hbs
```
## 扩展模板
如需自定义模板,可直接修改 `plop/templates/` 目录下的 `.hbs` 文件。
### 可用的 Handlebars Helpers
| Helper | 说明 | 示例 |
|--------|------|------|
| `pascalCase` | 转 PascalCase | `product``Product` |
| `camelCase` | 转 camelCase | `product``product` |
| `kebabCase` | 转 kebab-case | `productItem``product-item` |
| `snakeCase` | 转 snake_case | `productItem``product_item` |
| `constantCase` | 转 CONSTANT_CASE | `product``PRODUCT` |
| `tsType` | 获取 TS 类型 | `string`, `number` 等 |
| `prismaType` | 获取 Prisma 类型 | `String`, `Float` 等 |
| `zodValidation` | 生成 Zod 验证 | `z.string().min(2)` |
| `formControl` | 生成表单控件 | `<Input .../>` |
## 常见问题
### Q: 如何添加自定义字段类型?
修改 `plop/utils/field-parser.ts` 中的类型映射。
### Q: 如何修改生成的代码风格?
直接编辑 `plop/templates/` 下的模板文件。
### Q: 生成后 TypeScript 报错?
确保运行 `pnpm db:generate` 更新 Prisma Client 类型。

363
plop/generators/crud.ts Normal file
View File

@@ -0,0 +1,363 @@
/**
* CRUD 生成器
* 交互式提问和文件生成逻辑
*/
import type { NodePlopAPI, ActionType } from 'plop';
import { parseFields, type FieldDefinition } from '../utils/field-parser.ts';
// 模板数据类型
interface TemplateData {
name: string;
chineseName: string;
pluralName: string;
generateTargets: string[];
softDelete: boolean;
fields: FieldDefinition[];
createFields: FieldDefinition[];
updateFields: FieldDefinition[];
responseFields: FieldDefinition[];
tableColumns: FieldDefinition[];
queryFields: FieldDefinition[];
selectFields: string[];
defaultPageSize: number;
maxPageSize: number;
defaultSortBy: string;
defaultSortOrder: string;
hasQueryDto: boolean;
hasTextarea: boolean;
hasSelect: boolean;
hasSwitch: boolean;
}
/**
* 注册 CRUD 生成器
*/
export function crudGenerator(plop: NodePlopAPI) {
plop.setGenerator('crud', {
description: '生成 CRUD 模块(后端 + 前端 + 共享类型 + Prisma',
prompts: [
{
type: 'input',
name: 'name',
message: '模块名称(英文,如 product:',
validate: (input: string) => {
if (!/^[a-z][a-zA-Z0-9]*$/.test(input)) {
return '模块名必须以小写字母开头,只能包含字母和数字';
}
return true;
},
},
{
type: 'input',
name: 'chineseName',
message: '模块中文名(如 产品):',
validate: (input: string) => !!input || '请输入中文名',
},
{
type: 'input',
name: 'pluralName',
message: '复数名称(如 products:',
default: (answers: { name: string }) => `${answers.name}s`,
},
{
type: 'checkbox',
name: 'generateTargets',
message: '选择要生成的模块:',
choices: [
{ name: '后端 (NestJS)', value: 'api', checked: true },
{ name: '前端 (Next.js)', value: 'web', checked: true },
{ name: '共享类型', value: 'shared', checked: true },
{ name: 'Prisma Model', value: 'prisma', checked: true },
],
},
{
type: 'confirm',
name: 'softDelete',
message: '是否启用软删除?',
default: true,
},
{
type: 'editor',
name: 'fieldsRaw',
message: '定义字段(使用 DSL 语法,见下方示例):',
default: `# 字段定义示例(每行一个字段)
# 格式: 字段名:类型[?可选][!唯一] 标签 "示例值" [验证规则]
#
# 类型: string, number, boolean, date, datetime, enum(v1,v2,...)
# 修饰符: ? 可选, ! 唯一
# 验证: min:n, max:n, email, url
# 标志: noCreate, noUpdate, noTable
#
# 示例:
# title:string 标题 "示例标题" min:2 max:100
# description:string? 描述 "描述内容" max:500
# price:number 价格 "99.99" min:0
# status:enum(draft,published,archived) 状态 "draft"
name:string 名称 "示例名称" min:2 max:100
`,
},
{
type: 'checkbox',
name: 'searchFieldNames',
message: '选择支持搜索的字段:',
choices: (answers: { fieldsRaw: string }) => {
try {
const fields = parseFields(answers.fieldsRaw);
return fields
.filter((f) => ['string', 'enum'].includes(f.type))
.map((f) => ({
name: `${f.name} (${f.label})`,
value: f.name,
}));
} catch {
return [];
}
},
},
{
type: 'number',
name: 'defaultPageSize',
message: '默认分页大小:',
default: 20,
},
{
type: 'number',
name: 'maxPageSize',
message: '最大分页大小:',
default: 100,
},
{
type: 'list',
name: 'defaultSortBy',
message: '默认排序字段:',
choices: (answers: { fieldsRaw: string }) => {
try {
const fields = parseFields(answers.fieldsRaw);
return [
{ name: 'createdAt (创建时间)', value: 'createdAt' },
{ name: 'updatedAt (更新时间)', value: 'updatedAt' },
...fields.map((f) => ({
name: `${f.name} (${f.label})`,
value: f.name,
})),
];
} catch {
return [{ name: 'createdAt (创建时间)', value: 'createdAt' }];
}
},
default: 'createdAt',
},
{
type: 'list',
name: 'defaultSortOrder',
message: '默认排序方向:',
choices: [
{ name: '降序 (desc)', value: 'desc' },
{ name: '升序 (asc)', value: 'asc' },
],
default: 'desc',
},
],
actions: (data) => {
if (!data) return [];
// 解析字段
let fields: FieldDefinition[];
try {
fields = parseFields(data.fieldsRaw);
} catch (error) {
console.error('字段解析错误:', error);
return [];
}
// 准备模板数据
const templateData: TemplateData = {
name: data.name,
chineseName: data.chineseName,
pluralName: data.pluralName,
generateTargets: data.generateTargets,
softDelete: data.softDelete,
fields,
createFields: fields.filter((f) => !f.flags.noCreate),
updateFields: fields.filter((f) => !f.flags.noUpdate),
responseFields: fields,
tableColumns: fields.filter((f) => !f.flags.noTable),
queryFields: fields.filter((f) =>
data.searchFieldNames?.includes(f.name),
),
selectFields: fields.map((f) => f.name),
defaultPageSize: data.defaultPageSize,
maxPageSize: data.maxPageSize,
defaultSortBy: data.defaultSortBy,
defaultSortOrder: data.defaultSortOrder,
hasQueryDto: data.searchFieldNames?.length > 0,
hasTextarea: fields.some((f) => {
if (f.type !== 'string') return false;
const maxLen = f.validations.find((v) => v.type === 'max')?.value;
return maxLen && Number(maxLen) > 100;
}),
hasSelect: fields.some((f) => f.type === 'enum'),
hasSwitch: fields.some((f) => f.type === 'boolean'),
};
const actions: ActionType[] = [];
// ===== 后端文件 =====
if (data.generateTargets.includes('api')) {
// DTO
actions.push({
type: 'add',
path: 'apps/api/src/{{kebabCase name}}/dto/{{kebabCase name}}.dto.ts',
templateFile: 'plop/templates/api/dto.hbs',
data: templateData,
});
// Service
actions.push({
type: 'add',
path: 'apps/api/src/{{kebabCase name}}/{{kebabCase name}}.service.ts',
templateFile: 'plop/templates/api/service.hbs',
data: templateData,
});
// Controller
actions.push({
type: 'add',
path: 'apps/api/src/{{kebabCase name}}/{{kebabCase name}}.controller.ts',
templateFile: 'plop/templates/api/controller.hbs',
data: templateData,
});
// Module
actions.push({
type: 'add',
path: 'apps/api/src/{{kebabCase name}}/{{kebabCase name}}.module.ts',
templateFile: 'plop/templates/api/module.hbs',
data: templateData,
});
// 修改 app.module.ts - 添加导入
actions.push({
type: 'modify',
path: 'apps/api/src/app.module.ts',
pattern: /(import.*from.*['"]\.\/\w+\/\w+\.module['"];?\n)(?!import)/,
template: `$1import { {{pascalCase name}}Module } from './{{kebabCase name}}/{{kebabCase name}}.module';\n`,
data: templateData,
});
// 修改 app.module.ts - 添加到 imports 数组
actions.push({
type: 'modify',
path: 'apps/api/src/app.module.ts',
pattern: /(imports:\s*\[[\s\S]*?)(UserModule)/,
template: `$1$2,\n {{pascalCase name}}Module`,
data: templateData,
});
}
// ===== 前端文件 =====
if (data.generateTargets.includes('web')) {
// Service
actions.push({
type: 'add',
path: 'apps/web/src/services/{{kebabCase name}}.service.ts',
templateFile: 'plop/templates/web/service.hbs',
data: templateData,
});
// Hooks
actions.push({
type: 'add',
path: 'apps/web/src/hooks/use{{pascalCase pluralName}}.ts',
templateFile: 'plop/templates/web/hooks.hbs',
data: templateData,
});
// 组件目录
actions.push({
type: 'add',
path: 'apps/web/src/components/{{kebabCase pluralName}}/{{pascalCase pluralName}}Table.tsx',
templateFile: 'plop/templates/web/table.hbs',
data: templateData,
});
actions.push({
type: 'add',
path: 'apps/web/src/components/{{kebabCase pluralName}}/{{pascalCase name}}CreateDialog.tsx',
templateFile: 'plop/templates/web/create-dialog.hbs',
data: templateData,
});
actions.push({
type: 'add',
path: 'apps/web/src/components/{{kebabCase pluralName}}/{{pascalCase name}}EditDialog.tsx',
templateFile: 'plop/templates/web/edit-dialog.hbs',
data: templateData,
});
// 修改 constants.ts - 添加 API 端点
actions.push({
type: 'modify',
path: 'apps/web/src/config/constants.ts',
pattern: /(USERS:\s*['"]\/users['"],?)/,
template: `$1\n {{constantCase pluralName}}: '/{{kebabCase pluralName}}',`,
data: templateData,
});
}
// ===== 共享类型 =====
if (data.generateTargets.includes('shared')) {
actions.push({
type: 'add',
path: 'packages/shared/src/types/{{kebabCase name}}.ts',
templateFile: 'plop/templates/shared/types.hbs',
data: templateData,
});
// 修改 types/index.ts - 添加导出
actions.push({
type: 'append',
path: 'packages/shared/src/types/index.ts',
template: `\n// {{chineseName}}\nexport * from './{{kebabCase name}}';\n`,
data: templateData,
});
}
// ===== Prisma Model =====
if (data.generateTargets.includes('prisma')) {
actions.push({
type: 'append',
path: 'apps/api/prisma/schema.prisma',
templateFile: 'plop/templates/prisma/model.hbs',
data: templateData,
});
// 如果启用软删除,修改 prisma.service.ts
if (data.softDelete) {
actions.push({
type: 'modify',
path: 'apps/api/src/prisma/prisma.service.ts',
pattern: /(const SOFT_DELETE_MODELS:\s*Prisma\.ModelName\[\]\s*=\s*\[[\s\S]*?)(\];)/,
template: `$1, '{{pascalCase name}}'$2`,
data: templateData,
});
}
}
// 打印生成信息
actions.push(() => {
console.log('\n✨ 生成完成!\n');
console.log('后续步骤:');
if (data.generateTargets.includes('prisma')) {
console.log('1. 运行 pnpm db:generate && pnpm db:push');
}
console.log('2. 重启开发服务器 pnpm dev\n');
return '完成';
});
return actions;
},
});
}

172
plop/helpers/index.ts Normal file
View File

@@ -0,0 +1,172 @@
/**
* Handlebars Helpers
* 为模板提供命名转换、类型处理等辅助函数
*/
import type { NodePlopAPI } from 'plop';
import {
type FieldDefinition,
getTsType,
getTsResponseType,
getPrismaType,
getValidationDecorators,
getValidationImports,
getZodValidation,
getFormControl,
getCellRenderer,
getWhereCondition,
} from '../utils/field-parser.ts';
/**
* 命名转换工具函数
*/
function toCamelCase(str: string): string {
return str.replace(/[-_](.)/g, (_, c) => c.toUpperCase()).replace(/^(.)/, (_, c) => c.toLowerCase());
}
function toPascalCase(str: string): string {
const camel = toCamelCase(str);
return camel.charAt(0).toUpperCase() + camel.slice(1);
}
function toKebabCase(str: string): string {
return str
.replace(/([a-z])([A-Z])/g, '$1-$2')
.replace(/[\s_]+/g, '-')
.toLowerCase();
}
function toSnakeCase(str: string): string {
return str
.replace(/([a-z])([A-Z])/g, '$1_$2')
.replace(/[\s-]+/g, '_')
.toLowerCase();
}
function toConstantCase(str: string): string {
return toSnakeCase(str).toUpperCase();
}
/**
* 注册所有 Handlebars helpers
*/
export function registerHelpers(plop: NodePlopAPI) {
// ===== 命名转换 helpers =====
plop.setHelper('pascalCase', (str: string) => toPascalCase(str));
plop.setHelper('camelCase', (str: string) => toCamelCase(str));
plop.setHelper('kebabCase', (str: string) => toKebabCase(str));
plop.setHelper('snakeCase', (str: string) => toSnakeCase(str));
plop.setHelper('constantCase', (str: string) => toConstantCase(str));
// ===== 字段类型处理 helpers =====
plop.setHelper('tsType', (field: FieldDefinition) => getTsType(field));
plop.setHelper('tsResponseType', (field: FieldDefinition) =>
getTsResponseType(field),
);
plop.setHelper('prismaType', (field: FieldDefinition) =>
getPrismaType(field),
);
// ===== 验证相关 helpers =====
plop.setHelper('validationDecorators', (field: FieldDefinition) =>
getValidationDecorators(field),
);
plop.setHelper('validationImports', (fields: FieldDefinition[]) =>
getValidationImports(fields).join(', '),
);
// ===== Zod 验证 helpers =====
plop.setHelper('zodValidation', (field: FieldDefinition) =>
getZodValidation(field),
);
// ===== 表单控件 helpers =====
plop.setHelper('formControl', (field: FieldDefinition) =>
getFormControl(field),
);
// ===== 表格渲染 helpers =====
plop.setHelper('cellRenderer', (field: FieldDefinition) =>
getCellRenderer(field),
);
// ===== 查询条件 helpers =====
plop.setHelper('whereCondition', (field: FieldDefinition) =>
getWhereCondition(field),
);
// ===== 条件判断 helpers =====
plop.setHelper('hasValidation', (fields: FieldDefinition[]) =>
fields.some((f) => f.validations.length > 0),
);
plop.setHelper('hasTransform', (fields: FieldDefinition[]) =>
fields.some((f) => f.type === 'date' || f.type === 'datetime'),
);
plop.setHelper('hasTextarea', (fields: FieldDefinition[]) =>
fields.some((f) => {
if (f.type !== 'string') return false;
const maxLen = f.validations.find((v) => v.type === 'max')?.value;
return maxLen && Number(maxLen) > 100;
}),
);
plop.setHelper('hasSelect', (fields: FieldDefinition[]) =>
fields.some((f) => f.type === 'enum'),
);
plop.setHelper('hasSwitch', (fields: FieldDefinition[]) =>
fields.some((f) => f.type === 'boolean'),
);
// ===== 字符串处理 helpers =====
plop.setHelper('join', (arr: string[], separator: string) =>
arr.join(separator),
);
plop.setHelper('jsonStringify', (obj: unknown) => JSON.stringify(obj));
// ===== 逻辑 helpers =====
plop.setHelper('eq', (a: unknown, b: unknown) => a === b);
plop.setHelper('ne', (a: unknown, b: unknown) => a !== b);
plop.setHelper('and', (a: unknown, b: unknown) => a && b);
plop.setHelper('or', (a: unknown, b: unknown) => a || b);
plop.setHelper('not', (a: unknown) => !a);
// 判断数组是否包含某值
plop.setHelper('includes', (arr: unknown[], value: unknown) =>
arr?.includes(value),
);
// ===== 默认值 helpers =====
plop.setHelper('defaultValue', (field: FieldDefinition) => {
switch (field.type) {
case 'string':
return "''";
case 'number':
return '0';
case 'boolean':
return 'false';
case 'enum':
return field.options?.[0] ? `'${field.options[0]}'` : "''";
default:
return "''";
}
});
}

3
plop/package.json Normal file
View File

@@ -0,0 +1,3 @@
{
"type": "module"
}

12
plop/plopfile.ts Normal file
View File

@@ -0,0 +1,12 @@
import type { NodePlopAPI } from 'plop';
import { registerHelpers } from './helpers';
import { crudGenerator } from './generators/crud';
export default function (plop: NodePlopAPI) {
// 注册自定义 Handlebars helpers
registerHelpers(plop);
// 注册 CRUD 生成器
crudGenerator(plop);
}

View File

@@ -0,0 +1,130 @@
import {
Controller,
Get,
Post,
Patch,
Delete,
Param,
Body,
Query,
UseGuards,
} from '@nestjs/common';
import {
ApiTags,
ApiOperation,
ApiBearerAuth,
ApiOkResponse,
ApiCreatedResponse,
} from '@nestjs/swagger';
import { Prisma } from '@prisma/client';
import {
Create{{pascalCase name}}Dto,
Update{{pascalCase name}}Dto,
{{pascalCase name}}ResponseDto,
Paginated{{pascalCase name}}ResponseDto,
{{#if hasQueryDto}}
{{pascalCase name}}QueryDto,
{{/if}}
} from './dto/{{kebabCase name}}.dto';
import { {{pascalCase name}}Service } from './{{kebabCase name}}.service';
import { JwtAuthGuard } from '@/auth/guards/jwt-auth.guard';
{{#unless hasQueryDto}}
import { PaginationQueryDto } from '@/common/crud';
{{/unless}}
@ApiTags('{{chineseName}}')
@Controller('{{kebabCase pluralName}}')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
export class {{pascalCase name}}Controller {
constructor(private readonly {{camelCase name}}Service: {{pascalCase name}}Service) {}
@Post()
@ApiOperation({ summary: '创建{{chineseName}}' })
@ApiCreatedResponse({ type: {{pascalCase name}}ResponseDto, description: '创建成功' })
create(@Body() dto: Create{{pascalCase name}}Dto) {
return this.{{camelCase name}}Service.create(dto);
}
@Get()
@ApiOperation({ summary: '获取所有{{chineseName}}(分页)' })
@ApiOkResponse({ type: Paginated{{pascalCase name}}ResponseDto, description: '{{chineseName}}列表' })
{{#if hasQueryDto}}
findAll(@Query() query: {{pascalCase name}}QueryDto) {
const { {{#each queryFields}}{{name}}, {{/each}}...pagination } = query;
const where: Prisma.{{pascalCase name}}WhereInput = {};
{{#each queryFields}}
if ({{name}}) {
{{#if (eq type 'string')}}
where.{{name}} = { contains: {{name}}, mode: 'insensitive' };
{{else}}
where.{{name}} = {{name}};
{{/if}}
}
{{/each}}
return this.{{camelCase name}}Service.findAll({ ...pagination, where });
}
{{else}}
findAll(@Query() query: PaginationQueryDto) {
return this.{{camelCase name}}Service.findAll(query);
}
{{/if}}
{{#if softDelete}}
@Get('deleted')
@ApiOperation({ summary: '获取已删除的{{chineseName}}列表(分页)' })
@ApiOkResponse({ type: Paginated{{pascalCase name}}ResponseDto, description: '已删除{{chineseName}}列表' })
{{#if hasQueryDto}}
findDeleted(@Query() query: {{pascalCase name}}QueryDto) {
const { {{#each queryFields}}{{name}}, {{/each}}...pagination } = query;
const where: Prisma.{{pascalCase name}}WhereInput = {};
{{#each queryFields}}
if ({{name}}) {
{{#if (eq type 'string')}}
where.{{name}} = { contains: {{name}}, mode: 'insensitive' };
{{else}}
where.{{name}} = {{name}};
{{/if}}
}
{{/each}}
return this.{{camelCase name}}Service.findDeleted({ ...pagination, where });
}
{{else}}
findDeleted(@Query() query: PaginationQueryDto) {
return this.{{camelCase name}}Service.findDeleted(query);
}
{{/if}}
{{/if}}
@Get(':id')
@ApiOperation({ summary: '根据 ID 获取{{chineseName}}' })
@ApiOkResponse({ type: {{pascalCase name}}ResponseDto, description: '{{chineseName}}详情' })
findById(@Param('id') id: string) {
return this.{{camelCase name}}Service.findById(id);
}
@Patch(':id')
@ApiOperation({ summary: '更新{{chineseName}}信息' })
@ApiOkResponse({ type: {{pascalCase name}}ResponseDto, description: '更新后的{{chineseName}}信息' })
update(@Param('id') id: string, @Body() dto: Update{{pascalCase name}}Dto) {
return this.{{camelCase name}}Service.update(id, dto);
}
@Delete(':id')
@ApiOperation({ summary: '删除{{chineseName}}' })
@ApiOkResponse({ description: '删除成功' })
delete(@Param('id') id: string) {
return this.{{camelCase name}}Service.delete(id);
}
{{#if softDelete}}
@Patch(':id/restore')
@ApiOperation({ summary: '恢复已删除的{{chineseName}}' })
@ApiOkResponse({ type: {{pascalCase name}}ResponseDto, description: '恢复后的{{chineseName}}信息' })
restore(@Param('id') id: string) {
return this.{{camelCase name}}Service.restore(id);
}
{{/if}}
}

View File

@@ -0,0 +1,92 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
{{#if (hasValidation fields)}}
import { {{validationImports fields}}, IsOptional } from 'class-validator';
{{else}}
import { IsOptional } from 'class-validator';
{{/if}}
{{#if (hasTransform fields)}}
import { Type } from 'class-transformer';
{{/if}}
import type {
Create{{pascalCase name}}Dto as ICreate{{pascalCase name}}Dto,
Update{{pascalCase name}}Dto as IUpdate{{pascalCase name}}Dto,
{{pascalCase name}}Response,
} from '@seclusion/shared';
import { createPaginatedResponseDto, PaginationQueryDto } from '@/common/crud';
/** 创建{{chineseName}}请求 DTO */
export class Create{{pascalCase name}}Dto implements ICreate{{pascalCase name}}Dto {
{{#each createFields}}
{{#if nullable}}
@ApiPropertyOptional({ example: {{{example}}}, description: '{{label}}' })
{{else}}
@ApiProperty({ example: {{{example}}}, description: '{{label}}' })
{{/if}}
{{#each (validationDecorators this)}}
{{{this}}}
{{/each}}
{{#if nullable}}
@IsOptional()
{{/if}}
{{name}}{{#if nullable}}?{{/if}}: {{tsType this}};
{{/each}}
}
/** 更新{{chineseName}}请求 DTO */
export class Update{{pascalCase name}}Dto implements IUpdate{{pascalCase name}}Dto {
{{#each updateFields}}
@ApiPropertyOptional({ example: {{{example}}}, description: '{{label}}' })
{{#each (validationDecorators this)}}
{{{this}}}
{{/each}}
@IsOptional()
{{name}}?: {{tsType this}};
{{/each}}
}
/** {{chineseName}}响应 DTO */
export class {{pascalCase name}}ResponseDto implements {{pascalCase name}}Response {
@ApiProperty({ example: 'clxxx123', description: '{{chineseName}} ID' })
id: string;
{{#each responseFields}}
{{#if nullable}}
@ApiProperty({ example: {{{example}}}, description: '{{label}}', nullable: true })
{{name}}: {{tsResponseType this}} | null;
{{else}}
@ApiProperty({ example: {{{example}}}, description: '{{label}}' })
{{name}}: {{tsResponseType this}};
{{/if}}
{{/each}}
@ApiProperty({ example: '2026-01-16T10:00:00.000Z', description: '创建时间' })
createdAt: string;
@ApiProperty({ example: '2026-01-16T10:00:00.000Z', description: '更新时间' })
updatedAt: string;
{{#if softDelete}}
@ApiPropertyOptional({ example: '2026-01-16T10:00:00.000Z', description: '删除时间', nullable: true })
deletedAt?: string | null;
{{/if}}
}
/** 分页{{chineseName}}响应 DTO */
export class Paginated{{pascalCase name}}ResponseDto extends createPaginatedResponseDto(
{{pascalCase name}}ResponseDto,
) {}
{{#if hasQueryDto}}
/** {{chineseName}}查询 DTO */
export class {{pascalCase name}}QueryDto extends PaginationQueryDto {
{{#each queryFields}}
@ApiPropertyOptional({ description: '按{{label}}筛选' })
@IsOptional()
{{name}}?: string;
{{/each}}
}
{{/if}}

View File

@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { {{pascalCase name}}Controller } from './{{kebabCase name}}.controller';
import { {{pascalCase name}}Service } from './{{kebabCase name}}.service';
@Module({
controllers: [{{pascalCase name}}Controller],
providers: [{{pascalCase name}}Service],
exports: [{{pascalCase name}}Service],
})
export class {{pascalCase name}}Module {}

View File

@@ -0,0 +1,52 @@
import { Injectable } from '@nestjs/common';
import { Prisma, {{pascalCase name}} } from '@prisma/client';
import { Create{{pascalCase name}}Dto, Update{{pascalCase name}}Dto } from './dto/{{kebabCase name}}.dto';
import { CrudOptions, CrudService } from '@/common/crud';
import { PrismaService } from '@/prisma/prisma.service';
@Injectable()
@CrudOptions({
softDelete: {{softDelete}},
defaultPageSize: {{defaultPageSize}},
maxPageSize: {{maxPageSize}},
defaultSortBy: '{{defaultSortBy}}',
defaultSortOrder: '{{defaultSortOrder}}',
defaultSelect: {
id: true,
{{#each selectFields}}
{{this}}: true,
{{/each}}
createdAt: true,
updatedAt: true,
{{#if softDelete}}
deletedAt: true,
{{/if}}
},
})
export class {{pascalCase name}}Service extends CrudService<
{{pascalCase name}},
Create{{pascalCase name}}Dto,
Update{{pascalCase name}}Dto,
Prisma.{{pascalCase name}}WhereInput,
Prisma.{{pascalCase name}}WhereUniqueInput
> {
constructor(prisma: PrismaService) {
super(prisma, '{{camelCase name}}');
}
protected getNotFoundMessage(): string {
return '{{chineseName}}不存在';
}
protected getDeletedMessage(): string {
return '{{chineseName}}已删除';
}
{{#if softDelete}}
protected getDeletedNotFoundMessage(): string {
return '已删除的{{chineseName}}不存在';
}
{{/if}}
}

View File

@@ -0,0 +1,16 @@
// {{chineseName}}模型
model {{pascalCase name}} {
id String @id @default(cuid(2))
{{#each fields}}
{{name}} {{prismaType this}}{{#if unique}} @unique{{/if}}
{{/each}}
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
{{#if softDelete}}
deletedAt DateTime?
{{/if}}
@@map("{{snakeCase pluralName}}")
}

View File

@@ -0,0 +1,28 @@
// ==================== {{chineseName}}相关类型 ====================
/** {{chineseName}}响应API 返回) */
export interface {{pascalCase name}}Response {
id: string;
{{#each responseFields}}
{{name}}: {{tsResponseType this}}{{#if nullable}} | null{{/if}};
{{/each}}
createdAt: string;
updatedAt: string;
{{#if softDelete}}
deletedAt?: string | null;
{{/if}}
}
/** 创建{{chineseName}}请求 */
export interface Create{{pascalCase name}}Dto {
{{#each createFields}}
{{name}}{{#if nullable}}?{{/if}}: {{tsType this}};
{{/each}}
}
/** 更新{{chineseName}}请求 */
export interface Update{{pascalCase name}}Dto {
{{#each updateFields}}
{{name}}?: {{tsType this}};
{{/each}}
}

View File

@@ -0,0 +1,125 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
import { z } from 'zod';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form';
import { Input } from '@/components/ui/input';
{{#if hasTextarea}}
import { Textarea } from '@/components/ui/textarea';
{{/if}}
{{#if hasSelect}}
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
{{/if}}
{{#if hasSwitch}}
import { Switch } from '@/components/ui/switch';
{{/if}}
import { useCreate{{pascalCase name}} } from '@/hooks/use{{pascalCase pluralName}}';
const create{{pascalCase name}}Schema = z.object({
{{#each createFields}}
{{name}}: {{zodValidation this}},
{{/each}}
});
type Create{{pascalCase name}}FormValues = z.infer<typeof create{{pascalCase name}}Schema>;
interface {{pascalCase name}}CreateDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function {{pascalCase name}}CreateDialog({
open,
onOpenChange,
}: {{pascalCase name}}CreateDialogProps) {
const create{{pascalCase name}} = useCreate{{pascalCase name}}();
const form = useForm<Create{{pascalCase name}}FormValues>({
resolver: zodResolver(create{{pascalCase name}}Schema),
defaultValues: {
{{#each createFields}}
{{name}}: {{defaultValue this}},
{{/each}}
},
});
const onSubmit = async (values: Create{{pascalCase name}}FormValues) => {
try {
await create{{pascalCase name}}.mutateAsync(values);
toast.success('{{chineseName}}创建成功');
form.reset();
onOpenChange(false);
} catch (error) {
toast.error(error instanceof Error ? error.message : '创建失败');
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>新建{{chineseName}}</DialogTitle>
<DialogDescription>填写以下信息创建新{{chineseName}}</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
{{#each createFields}}
<FormField
control={form.control}
name="{{name}}"
render={({ field }) => (
<FormItem>
<FormLabel>{{label}}{{#unless nullable}} *{{/unless}}</FormLabel>
<FormControl>
{{{formControl this}}}
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{{/each}}
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
>
取消
</Button>
<Button type="submit" disabled={create{{pascalCase name}}.isPending}>
{create{{pascalCase name}}.isPending ? '创建中...' : '创建'}
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,151 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import type { {{pascalCase name}}Response } from '@seclusion/shared';
import { useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
import { z } from 'zod';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form';
import { Input } from '@/components/ui/input';
{{#if hasTextarea}}
import { Textarea } from '@/components/ui/textarea';
{{/if}}
{{#if hasSelect}}
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
{{/if}}
{{#if hasSwitch}}
import { Switch } from '@/components/ui/switch';
{{/if}}
import { useUpdate{{pascalCase name}} } from '@/hooks/use{{pascalCase pluralName}}';
const edit{{pascalCase name}}Schema = z.object({
{{#each updateFields}}
{{name}}: {{zodValidation this}}.optional(),
{{/each}}
});
type Edit{{pascalCase name}}FormValues = z.infer<typeof edit{{pascalCase name}}Schema>;
interface {{pascalCase name}}EditDialogProps {
{{camelCase name}}: {{pascalCase name}}Response | null;
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function {{pascalCase name}}EditDialog({
{{camelCase name}},
open,
onOpenChange,
}: {{pascalCase name}}EditDialogProps) {
const update{{pascalCase name}} = useUpdate{{pascalCase name}}();
const form = useForm<Edit{{pascalCase name}}FormValues>({
resolver: zodResolver(edit{{pascalCase name}}Schema),
defaultValues: {
{{#each updateFields}}
{{name}}: {{defaultValue this}},
{{/each}}
},
});
// 当数据变化时重置表单
useEffect(() => {
if ({{camelCase name}}) {
form.reset({
{{#each updateFields}}
{{name}}: {{camelCase ../name}}.{{name}}{{#if nullable}} ?? {{defaultValue this}}{{/if}},
{{/each}}
});
}
}, [{{camelCase name}}, form]);
const onSubmit = async (values: Edit{{pascalCase name}}FormValues) => {
if (!{{camelCase name}}) return;
try {
await update{{pascalCase name}}.mutateAsync({
id: {{camelCase name}}.id,
data: values,
});
toast.success('{{chineseName}}已更新');
onOpenChange(false);
} catch (error) {
toast.error(error instanceof Error ? error.message : '更新失败');
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>编辑{{chineseName}}</DialogTitle>
<DialogDescription>修改{{chineseName}}信息</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
{{#each updateFields}}
<FormField
control={form.control}
name="{{name}}"
render={({ field }) => (
<FormItem>
<FormLabel>{{label}}</FormLabel>
<FormControl>
{{{formControl this}}}
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{{/each}}
<div className="text-sm text-muted-foreground space-y-1">
<p>
<span className="font-medium">ID:</span>{' '}
<span className="font-mono">{{{camelCase name}}?.id}</span>
</p>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
>
取消
</Button>
<Button type="submit" disabled={update{{pascalCase name}}.isPending}>
{update{{pascalCase name}}.isPending ? '保存中...' : '保存'}
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,120 @@
import type {
Create{{pascalCase name}}Dto,
Update{{pascalCase name}}Dto,
} from '@seclusion/shared';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import {
{{camelCase name}}Service,
type Get{{pascalCase pluralName}}Params,
} from '@/services/{{kebabCase name}}.service';
import { useIsAuthenticated } from '@/stores';
// Query Keys
export const {{camelCase name}}Keys = {
all: ['{{camelCase pluralName}}'] as const,
lists: () => [...{{camelCase name}}Keys.all, 'list'] as const,
list: (params: Get{{pascalCase pluralName}}Params) =>
[...{{camelCase name}}Keys.lists(), params] as const,
{{#if softDelete}}
deleted: () => [...{{camelCase name}}Keys.all, 'deleted'] as const,
deletedList: (params: Get{{pascalCase pluralName}}Params) =>
[...{{camelCase name}}Keys.deleted(), params] as const,
{{/if}}
details: () => [...{{camelCase name}}Keys.all, 'detail'] as const,
detail: (id: string) => [...{{camelCase name}}Keys.details(), id] as const,
};
// 获取{{chineseName}}列表
export function use{{pascalCase pluralName}}(params: Get{{pascalCase pluralName}}Params = {}) {
const isAuthenticated = useIsAuthenticated();
return useQuery({
queryKey: {{camelCase name}}Keys.list(params),
queryFn: () => {{camelCase name}}Service.get{{pascalCase pluralName}}(params),
enabled: isAuthenticated,
});
}
{{#if softDelete}}
// 获取已删除的{{chineseName}}列表
export function useDeleted{{pascalCase pluralName}}(
params: Get{{pascalCase pluralName}}Params = {},
) {
const isAuthenticated = useIsAuthenticated();
return useQuery({
queryKey: {{camelCase name}}Keys.deletedList(params),
queryFn: () => {{camelCase name}}Service.getDeleted{{pascalCase pluralName}}(params),
enabled: isAuthenticated,
});
}
{{/if}}
// 获取单个{{chineseName}}
export function use{{pascalCase name}}(id: string) {
const isAuthenticated = useIsAuthenticated();
return useQuery({
queryKey: {{camelCase name}}Keys.detail(id),
queryFn: () => {{camelCase name}}Service.get{{pascalCase name}}(id),
enabled: isAuthenticated && !!id,
});
}
// 创建{{chineseName}}
export function useCreate{{pascalCase name}}() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: Create{{pascalCase name}}Dto) =>
{{camelCase name}}Service.create{{pascalCase name}}(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: {{camelCase name}}Keys.lists() });
},
});
}
// 更新{{chineseName}}
export function useUpdate{{pascalCase name}}() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: string; data: Update{{pascalCase name}}Dto }) =>
{{camelCase name}}Service.update{{pascalCase name}}(id, data),
onSuccess: (_, { id }) => {
queryClient.invalidateQueries({ queryKey: {{camelCase name}}Keys.lists() });
queryClient.invalidateQueries({ queryKey: {{camelCase name}}Keys.detail(id) });
},
});
}
// 删除{{chineseName}}
export function useDelete{{pascalCase name}}() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => {{camelCase name}}Service.delete{{pascalCase name}}(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: {{camelCase name}}Keys.lists() });
{{#if softDelete}}
queryClient.invalidateQueries({ queryKey: {{camelCase name}}Keys.deleted() });
{{/if}}
},
});
}
{{#if softDelete}}
// 恢复{{chineseName}}
export function useRestore{{pascalCase name}}() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => {{camelCase name}}Service.restore{{pascalCase name}}(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: {{camelCase name}}Keys.lists() });
queryClient.invalidateQueries({ queryKey: {{camelCase name}}Keys.deleted() });
},
});
}
{{/if}}

View File

@@ -0,0 +1,80 @@
import type {
PaginatedResponse,
Create{{pascalCase name}}Dto,
Update{{pascalCase name}}Dto,
{{pascalCase name}}Response,
} from '@seclusion/shared';
import { API_ENDPOINTS } from '@/config/constants';
import { http } from '@/lib/http';
export interface Get{{pascalCase pluralName}}Params {
page?: number;
pageSize?: number;
{{#each queryFields}}
{{name}}?: string;
{{/each}}
}
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 },
);
},
{{#if softDelete}}
// 获取已删除的{{chineseName}}列表
getDeleted{{pascalCase pluralName}}: (
params: Get{{pascalCase pluralName}}Params = {},
): Promise<PaginatedResponse<{{pascalCase name}}Response>> => {
return http.get<PaginatedResponse<{{pascalCase name}}Response>>(
`${API_ENDPOINTS.{{constantCase pluralName}}}/deleted`,
{ params },
);
},
{{/if}}
// 获取单个{{chineseName}}
get{{pascalCase name}}: (id: string): Promise<{{pascalCase name}}Response> => {
return http.get<{{pascalCase name}}Response>(
`${API_ENDPOINTS.{{constantCase pluralName}}}/${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);
},
// 更新{{chineseName}}
update{{pascalCase name}}: (
id: string,
data: Update{{pascalCase name}}Dto,
): Promise<{{pascalCase name}}Response> => {
return http.patch<{{pascalCase name}}Response>(
`${API_ENDPOINTS.{{constantCase pluralName}}}/${id}`,
data,
);
},
// 删除{{chineseName}}
delete{{pascalCase name}}: (id: string): Promise<void> => {
return http.delete<void>(`${API_ENDPOINTS.{{constantCase pluralName}}}/${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`,
);
},
{{/if}}
};

View File

@@ -0,0 +1,420 @@
'use client';
import type { {{pascalCase name}}Response } from '@seclusion/shared';
import { formatDate } from '@seclusion/shared';
import type { ColumnDef } from '@tanstack/react-table';
import { MoreHorizontal, Pencil, Plus{{#if softDelete}}, RotateCcw{{/if}}, Trash2 } from 'lucide-react';
import { useCallback, useState } from 'react';
import { toast } from 'sonner';
import { {{pascalCase name}}CreateDialog } from './{{pascalCase name}}CreateDialog';
import { {{pascalCase name}}EditDialog } from './{{pascalCase name}}EditDialog';
import {
DataTable,
DataTableColumnHeader,
type PaginationState,
type SortingParams,
} from '@/components/shared/DataTable';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
{{#if softDelete}}
import { Badge } from '@/components/ui/badge';
{{/if}}
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { PAGINATION } from '@/config/constants';
import {
use{{pascalCase pluralName}},
{{#if softDelete}}
useDeleted{{pascalCase pluralName}},
{{/if}}
useDelete{{pascalCase name}},
{{#if softDelete}}
useRestore{{pascalCase name}},
{{/if}}
} from '@/hooks/use{{pascalCase pluralName}}';
interface {{pascalCase name}}ActionsProps {
{{camelCase name}}: {{pascalCase name}}Response;
isDeleted?: boolean;
onDelete: (id: string) => void;
{{#if softDelete}}
onRestore: (id: string) => void;
{{/if}}
onEdit: ({{camelCase name}}: {{pascalCase name}}Response) => void;
}
function {{pascalCase name}}Actions({
{{camelCase name}},
isDeleted,
onDelete,
{{#if softDelete}}
onRestore,
{{/if}}
onEdit,
}: {{pascalCase name}}ActionsProps) {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">打开菜单</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>操作</DropdownMenuLabel>
<DropdownMenuSeparator />
{{#if softDelete}}
{isDeleted ? (
<DropdownMenuItem onClick={() => onRestore({{camelCase name}}.id)}>
<RotateCcw className="mr-2 h-4 w-4" />
恢复
</DropdownMenuItem>
) : (
<>
<DropdownMenuItem onClick={() => onEdit({{camelCase name}})}>
<Pencil className="mr-2 h-4 w-4" />
编辑
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => onDelete({{camelCase name}}.id)}
className="text-destructive"
>
<Trash2 className="mr-2 h-4 w-4" />
删除
</DropdownMenuItem>
</>
)}
{{else}}
<DropdownMenuItem onClick={() => onEdit({{camelCase name}})}>
<Pencil className="mr-2 h-4 w-4" />
编辑
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => onDelete({{camelCase name}}.id)}
className="text-destructive"
>
<Trash2 className="mr-2 h-4 w-4" />
删除
</DropdownMenuItem>
{{/if}}
</DropdownMenuContent>
</DropdownMenu>
);
}
export function {{pascalCase pluralName}}Table() {
// 分页状态
const [pagination, setPagination] = useState<PaginationState>({
page: PAGINATION.DEFAULT_PAGE,
pageSize: PAGINATION.DEFAULT_PAGE_SIZE,
});
// 排序状态
const [sorting, setSorting] = useState<SortingParams>({});
// 对话框状态
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [{{camelCase name}}ToDelete, set{{pascalCase name}}ToDelete] = useState<string | null>(null);
{{#if softDelete}}
const [showDeleted, setShowDeleted] = useState(false);
{{/if}}
const [createDialogOpen, setCreateDialogOpen] = useState(false);
const [editDialogOpen, setEditDialogOpen] = useState(false);
const [{{camelCase name}}ToEdit, set{{pascalCase name}}ToEdit] = useState<{{pascalCase name}}Response | null>(
null,
);
// 查询
const {
data: {{camelCase pluralName}}Data,
isLoading: isLoading{{pascalCase pluralName}},
refetch: refetch{{pascalCase pluralName}},
} = use{{pascalCase pluralName}}({
page: pagination.page,
pageSize: pagination.pageSize,
...sorting,
});
{{#if softDelete}}
const {
data: deleted{{pascalCase pluralName}}Data,
isLoading: isLoadingDeleted,
refetch: refetchDeleted,
} = useDeleted{{pascalCase pluralName}}({
page: pagination.page,
pageSize: pagination.pageSize,
...sorting,
});
{{/if}}
// 变更
const delete{{pascalCase name}} = useDelete{{pascalCase name}}();
{{#if softDelete}}
const restore{{pascalCase name}} = useRestore{{pascalCase name}}();
{{/if}}
const handleDelete = useCallback((id: string) => {
set{{pascalCase name}}ToDelete(id);
setDeleteDialogOpen(true);
}, []);
const confirmDelete = useCallback(async () => {
if (!{{camelCase name}}ToDelete) return;
try {
await delete{{pascalCase name}}.mutateAsync({{camelCase name}}ToDelete);
toast.success('{{chineseName}}已删除');
} catch (error) {
toast.error(error instanceof Error ? error.message : '删除失败');
} finally {
setDeleteDialogOpen(false);
set{{pascalCase name}}ToDelete(null);
}
}, [{{camelCase name}}ToDelete, delete{{pascalCase name}}]);
{{#if softDelete}}
const handleRestore = useCallback(
async (id: string) => {
try {
await restore{{pascalCase name}}.mutateAsync(id);
toast.success('{{chineseName}}已恢复');
} catch (error) {
toast.error(error instanceof Error ? error.message : '恢复失败');
}
},
[restore{{pascalCase name}}],
);
{{/if}}
const handleEdit = useCallback(({{camelCase name}}: {{pascalCase name}}Response) => {
set{{pascalCase name}}ToEdit({{camelCase name}});
setEditDialogOpen(true);
}, []);
// 分页变化处理
const handlePaginationChange = useCallback((newPagination: PaginationState) => {
setPagination(newPagination);
}, []);
// 排序变化处理
const handleSortingChange = useCallback((newSorting: SortingParams) => {
setSorting(newSorting);
setPagination((prev) => ({ ...prev, page: 1 }));
}, []);
// 列定义
const columns: ColumnDef<{{pascalCase name}}Response>[] = [
{
accessorKey: 'id',
header: 'ID',
cell: ({ row }) => (
<span className="font-mono text-xs text-muted-foreground">
{row.original.id.slice(0, 8)}...
</span>
),
},
{{#each tableColumns}}
{
accessorKey: '{{name}}',
header: ({ column }) => (
<DataTableColumnHeader
title="{{label}}"
sortKey={column.id}
sorting={sorting}
onSortingChange={handleSortingChange}
/>
),
{{#if (cellRenderer this)}}
cell: ({ row }) => {{{cellRenderer this}}},
{{/if}}
},
{{/each}}
{
accessorKey: 'createdAt',
header: ({ column }) => (
<DataTableColumnHeader
title="创建时间"
sortKey={column.id}
sorting={sorting}
onSortingChange={handleSortingChange}
/>
),
cell: ({ row }) =>
formatDate(new Date(row.original.createdAt), 'YYYY-MM-DD HH:mm'),
},
{{#if softDelete}}
...(showDeleted
? [
{
accessorKey: 'deletedAt',
header: '删除时间',
cell: ({ row }: { row: { original: {{pascalCase name}}Response } }) =>
row.original.deletedAt
? formatDate(new Date(row.original.deletedAt), 'YYYY-MM-DD HH:mm')
: '-',
} as ColumnDef<{{pascalCase name}}Response>,
]
: []),
{{/if}}
{
id: 'actions',
header: '操作',
cell: ({ row }) => (
<{{pascalCase name}}Actions
{{camelCase name}}={row.original}
{{#if softDelete}}
isDeleted={showDeleted}
{{/if}}
onDelete={handleDelete}
{{#if softDelete}}
onRestore={handleRestore}
{{/if}}
onEdit={handleEdit}
/>
),
},
];
{{#if softDelete}}
const data = showDeleted ? deleted{{pascalCase pluralName}}Data : {{camelCase pluralName}}Data;
const isLoading = showDeleted ? isLoadingDeleted : isLoading{{pascalCase pluralName}};
{{else}}
const data = {{camelCase pluralName}}Data;
const isLoading = isLoading{{pascalCase pluralName}};
{{/if}}
return (
<div className="space-y-4">
{/* 工具栏 */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{{#if softDelete}}
<Button
variant={!showDeleted ? 'default' : 'outline'}
size="sm"
onClick={() => {
setShowDeleted(false);
setPagination((prev) => ({ ...prev, page: 1 }));
}}
>
{{chineseName}}列表
{{{camelCase pluralName}}Data && (
<Badge variant="secondary" className="ml-2">
{{{camelCase pluralName}}Data.total}
</Badge>
)}
</Button>
<Button
variant={showDeleted ? 'default' : 'outline'}
size="sm"
onClick={() => {
setShowDeleted(true);
setPagination((prev) => ({ ...prev, page: 1 }));
}}
>
已删除
{deleted{{pascalCase pluralName}}Data && (
<Badge variant="secondary" className="ml-2">
{deleted{{pascalCase pluralName}}Data.total}
</Badge>
)}
</Button>
{{/if}}
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() =>
{{#if softDelete}}
showDeleted ? refetchDeleted() : refetch{{pascalCase pluralName}}()
{{else}}
refetch{{pascalCase pluralName}}()
{{/if}}
}
>
刷新
</Button>
<Button size="sm" onClick={() => setCreateDialogOpen(true)}>
<Plus className="mr-2 h-4 w-4" />
新建{{chineseName}}
</Button>
</div>
</div>
{/* 数据表格 */}
<DataTable
columns={columns}
data={data?.items ?? []}
pagination={pagination}
paginationInfo={
data ? { total: data.total, totalPages: data.totalPages } : undefined
}
onPaginationChange={handlePaginationChange}
manualPagination
sorting={sorting}
onSortingChange={handleSortingChange}
manualSorting
isLoading={isLoading}
emptyMessage={
{{#if softDelete}}
showDeleted ? '暂无已删除{{chineseName}}' : '暂无{{chineseName}}'
{{else}}
'暂无{{chineseName}}'
{{/if}}
}
/>
{/* 删除确认弹窗 */}
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>确认删除</AlertDialogTitle>
<AlertDialogDescription>
确定要删除该{{chineseName}}吗?{{#if softDelete}}删除后可在「已删除」列表中恢复。{{/if}}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>取消</AlertDialogCancel>
<AlertDialogAction
onClick={confirmDelete}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
删除
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* 创建弹窗 */}
<{{pascalCase name}}CreateDialog
open={createDialogOpen}
onOpenChange={setCreateDialogOpen}
/>
{/* 编辑弹窗 */}
<{{pascalCase name}}EditDialog
{{camelCase name}}={{{camelCase name}}ToEdit}
open={editDialogOpen}
onOpenChange={setEditDialogOpen}
/>
</div>
);
}

381
plop/utils/field-parser.ts Normal file
View File

@@ -0,0 +1,381 @@
/**
* 字段 DSL 解析器
*
* DSL 语法:
* 字段名:类型[修饰符] 标签 "示例值" [验证规则...]
*
* 示例:
* title:string 标题 "示例标题" min:2 max:100
* email:string! 邮箱 "test@example.com" email
* description:string? 描述 "描述内容" max:500
* price:number 价格 "99.99" min:0
* status:enum(draft,published) 状态 "draft"
*/
export type FieldType =
| 'string'
| 'number'
| 'boolean'
| 'date'
| 'datetime'
| 'enum';
export interface Validation {
type: 'min' | 'max' | 'email' | 'url' | 'pattern';
value?: string | number;
}
export interface FieldDefinition {
name: string; // 字段名
type: FieldType; // 字段类型
label: string; // 中文标签
example: string; // 示例值
nullable: boolean; // 是否可空
unique: boolean; // 是否唯一
validations: Validation[]; // 验证规则
options?: string[]; // 枚举选项
flags: {
noCreate: boolean; // 不在创建时使用
noUpdate: boolean; // 不在更新时使用
noTable: boolean; // 不在表格中显示
};
}
// 匹配: 字段名:类型[修饰符] 标签 "示例值" [验证规则...]
const FIELD_REGEX =
/^(\w+):(\w+(?:\([^)]+\))?)([\?!]*)\s+(.+?)\s+"([^"]+)"(?:\s+(.+))?$/;
/**
* 解析字段 DSL 字符串
*/
export function parseFields(dsl: string): FieldDefinition[] {
const lines = dsl
.split('\n')
.map((line) => line.trim())
.filter((line) => line && !line.startsWith('#'));
return lines.map((line) => parseFieldLine(line));
}
/**
* 解析单行字段定义
*/
function parseFieldLine(line: string): FieldDefinition {
const match = line.match(FIELD_REGEX);
if (!match) {
throw new Error(`无效的字段定义: ${line}`);
}
const [, name, typeStr, modifiers, label, example, validationsStr] = match;
// 解析类型
let type: FieldType;
let options: string[] | undefined;
if (typeStr.startsWith('enum(')) {
type = 'enum';
options = typeStr
.slice(5, -1)
.split(',')
.map((s) => s.trim());
} else {
type = typeStr as FieldType;
}
// 解析修饰符
const nullable = modifiers.includes('?');
const unique = modifiers.includes('!');
// 解析验证规则和标志
const validations: Validation[] = [];
const flags = { noCreate: false, noUpdate: false, noTable: false };
if (validationsStr) {
const parts = validationsStr.split(/\s+/);
for (const part of parts) {
if (part === 'noCreate') {
flags.noCreate = true;
} else if (part === 'noUpdate') {
flags.noUpdate = true;
} else if (part === 'noTable') {
flags.noTable = true;
} else if (part.startsWith('min:')) {
validations.push({ type: 'min', value: parseFloat(part.slice(4)) });
} else if (part.startsWith('max:')) {
validations.push({ type: 'max', value: parseFloat(part.slice(4)) });
} else if (part === 'email') {
validations.push({ type: 'email' });
} else if (part === 'url') {
validations.push({ type: 'url' });
} else if (part.startsWith('pattern:')) {
validations.push({ type: 'pattern', value: part.slice(8) });
}
}
}
return {
name,
type,
label,
example,
nullable,
unique,
validations,
options,
flags,
};
}
/**
* 获取 TypeScript 类型
*/
export function getTsType(field: FieldDefinition): string {
const typeMap: Record<FieldType, string> = {
string: 'string',
number: 'number',
boolean: 'boolean',
date: 'Date',
datetime: 'Date',
enum: field.options?.map((o) => `'${o}'`).join(' | ') || 'string',
};
return typeMap[field.type] || 'string';
}
/**
* 获取 TypeScript 响应类型Date 转 string
*/
export function getTsResponseType(field: FieldDefinition): string {
if (field.type === 'date' || field.type === 'datetime') {
return 'string';
}
return getTsType(field);
}
/**
* 获取 Prisma 类型
*/
export function getPrismaType(field: FieldDefinition): string {
const typeMap: Record<FieldType, string> = {
string: 'String',
number: 'Float',
boolean: 'Boolean',
date: 'DateTime',
datetime: 'DateTime',
enum: 'String',
};
const baseType = typeMap[field.type] || 'String';
return field.nullable ? `${baseType}?` : baseType;
}
/**
* 获取验证装饰器列表
*/
export function getValidationDecorators(field: FieldDefinition): string[] {
const decorators: string[] = [];
// 类型验证
switch (field.type) {
case 'string':
decorators.push('@IsString()');
break;
case 'number':
decorators.push('@IsNumber()');
break;
case 'boolean':
decorators.push('@IsBoolean()');
break;
case 'date':
case 'datetime':
decorators.push('@IsDate()');
decorators.push('@Type(() => Date)');
break;
case 'enum':
decorators.push(
`@IsIn([${field.options?.map((o) => `'${o}'`).join(', ')}])`,
);
break;
}
// 验证规则
for (const v of field.validations) {
switch (v.type) {
case 'min':
if (field.type === 'string') {
decorators.push(`@MinLength(${v.value})`);
} else {
decorators.push(`@Min(${v.value})`);
}
break;
case 'max':
if (field.type === 'string') {
decorators.push(`@MaxLength(${v.value})`);
} else {
decorators.push(`@Max(${v.value})`);
}
break;
case 'email':
decorators.push('@IsEmail()');
break;
case 'url':
decorators.push('@IsUrl()');
break;
case 'pattern':
decorators.push(`@Matches(${v.value})`);
break;
}
}
return decorators;
}
/**
* 获取验证器导入列表
*/
export function getValidationImports(fields: FieldDefinition[]): string[] {
const imports = new Set<string>();
for (const field of fields) {
switch (field.type) {
case 'string':
imports.add('IsString');
break;
case 'number':
imports.add('IsNumber');
break;
case 'boolean':
imports.add('IsBoolean');
break;
case 'date':
case 'datetime':
imports.add('IsDate');
break;
case 'enum':
imports.add('IsIn');
break;
}
for (const v of field.validations) {
switch (v.type) {
case 'min':
imports.add(field.type === 'string' ? 'MinLength' : 'Min');
break;
case 'max':
imports.add(field.type === 'string' ? 'MaxLength' : 'Max');
break;
case 'email':
imports.add('IsEmail');
break;
case 'url':
imports.add('IsUrl');
break;
case 'pattern':
imports.add('Matches');
break;
}
}
if (field.nullable) {
imports.add('IsOptional');
}
}
return Array.from(imports);
}
/**
* 获取 Zod 验证字符串
*/
export function getZodValidation(field: FieldDefinition): string {
let zod = '';
switch (field.type) {
case 'string':
zod = 'z.string()';
for (const v of field.validations) {
if (v.type === 'min')
zod += `.min(${v.value}, '最少 ${v.value} 个字符')`;
if (v.type === 'max')
zod += `.max(${v.value}, '最多 ${v.value} 个字符')`;
if (v.type === 'email') zod += `.email('请输入有效的邮箱地址')`;
if (v.type === 'url') zod += `.url('请输入有效的 URL')`;
}
break;
case 'number':
zod = 'z.coerce.number()';
for (const v of field.validations) {
if (v.type === 'min') zod += `.min(${v.value}, '最小值为 ${v.value}')`;
if (v.type === 'max') zod += `.max(${v.value}, '最大值为 ${v.value}')`;
}
break;
case 'boolean':
zod = 'z.boolean()';
break;
case 'enum':
zod = `z.enum([${field.options?.map((o) => `'${o}'`).join(', ')}])`;
break;
default:
zod = 'z.string()';
}
if (field.nullable) {
zod += '.optional()';
}
return zod;
}
/**
* 生成表单控件代码
*/
export function getFormControl(field: FieldDefinition): string {
switch (field.type) {
case 'string': {
const maxLen = field.validations.find((v) => v.type === 'max')?.value;
if (maxLen && Number(maxLen) > 100) {
return `<Textarea placeholder="请输入${field.label}" {...field} />`;
}
return `<Input placeholder="请输入${field.label}" {...field} />`;
}
case 'number':
return `<Input type="number" placeholder="请输入${field.label}" {...field} onChange={e => field.onChange(e.target.valueAsNumber)} />`;
case 'boolean':
return `<Switch checked={field.value} onCheckedChange={field.onChange} />`;
case 'enum':
return `<Select onValueChange={field.onChange} defaultValue={field.value}>
<SelectTrigger>
<SelectValue placeholder="请选择${field.label}" />
</SelectTrigger>
<SelectContent>
${field.options?.map((o) => ` <SelectItem value="${o}">${o}</SelectItem>`).join('\n')}
</SelectContent>
</Select>`;
default:
return `<Input placeholder="请输入${field.label}" {...field} />`;
}
}
/**
* 生成表格单元格渲染器
*/
export function getCellRenderer(field: FieldDefinition): string | null {
switch (field.type) {
case 'boolean':
return `row.original.${field.name} ? '是' : '否'`;
case 'date':
return `formatDate(new Date(row.original.${field.name}), 'YYYY-MM-DD')`;
case 'datetime':
return `formatDate(new Date(row.original.${field.name}), 'YYYY-MM-DD HH:mm')`;
default:
return field.nullable ? `row.original.${field.name} || '-'` : null;
}
}
/**
* 生成 where 条件
*/
export function getWhereCondition(field: FieldDefinition): string {
if (field.type === 'string') {
return `{ contains: query.${field.name}, mode: 'insensitive' }`;
}
return `query.${field.name}`;
}