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