feat: 添加 plop 代码生成器模板
添加组件和模块的代码生成器模板,提高开发效率。 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
251
plop/README.md
Normal file
251
plop/README.md
Normal 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
363
plop/generators/crud.ts
Normal 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
172
plop/helpers/index.ts
Normal 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
3
plop/package.json
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"type": "module"
|
||||||
|
}
|
||||||
12
plop/plopfile.ts
Normal file
12
plop/plopfile.ts
Normal 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);
|
||||||
|
}
|
||||||
130
plop/templates/api/controller.hbs
Normal file
130
plop/templates/api/controller.hbs
Normal 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}}
|
||||||
|
}
|
||||||
92
plop/templates/api/dto.hbs
Normal file
92
plop/templates/api/dto.hbs
Normal 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}}
|
||||||
11
plop/templates/api/module.hbs
Normal file
11
plop/templates/api/module.hbs
Normal 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 {}
|
||||||
52
plop/templates/api/service.hbs
Normal file
52
plop/templates/api/service.hbs
Normal 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}}
|
||||||
|
}
|
||||||
16
plop/templates/prisma/model.hbs
Normal file
16
plop/templates/prisma/model.hbs
Normal 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}}")
|
||||||
|
}
|
||||||
28
plop/templates/shared/types.hbs
Normal file
28
plop/templates/shared/types.hbs
Normal 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}}
|
||||||
|
}
|
||||||
125
plop/templates/web/create-dialog.hbs
Normal file
125
plop/templates/web/create-dialog.hbs
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
151
plop/templates/web/edit-dialog.hbs
Normal file
151
plop/templates/web/edit-dialog.hbs
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
120
plop/templates/web/hooks.hbs
Normal file
120
plop/templates/web/hooks.hbs
Normal 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}}
|
||||||
80
plop/templates/web/service.hbs
Normal file
80
plop/templates/web/service.hbs
Normal 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}}
|
||||||
|
};
|
||||||
420
plop/templates/web/table.hbs
Normal file
420
plop/templates/web/table.hbs
Normal 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
381
plop/utils/field-parser.ts
Normal 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}`;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user