@robsun/create-keystone-app 0.2.12 → 0.2.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/dist/create-keystone-app.js +0 -0
- package/dist/create-module.js +584 -2
- package/package.json +22 -23
- package/template/.claude/skills/keystone-dev/SKILL.md +90 -103
- package/template/.claude/skills/keystone-dev/references/ADVANCED_PATTERNS.md +716 -0
- package/template/.claude/skills/keystone-dev/references/APPROVAL.md +47 -0
- package/template/.claude/skills/keystone-dev/references/CAPABILITIES.md +60 -5
- package/template/.claude/skills/keystone-dev/references/CHECKLIST.md +285 -0
- package/template/.claude/skills/keystone-dev/references/GOTCHAS.md +390 -0
- package/template/.claude/skills/keystone-dev/references/PATTERNS.md +605 -0
- package/template/.claude/skills/keystone-dev/references/TEMPLATES.md +2562 -384
- package/template/.codex/skills/keystone-dev/SKILL.md +90 -103
- package/template/.codex/skills/keystone-dev/references/ADVANCED_PATTERNS.md +716 -0
- package/template/.codex/skills/keystone-dev/references/APPROVAL.md +47 -0
- package/template/.codex/skills/keystone-dev/references/CAPABILITIES.md +60 -5
- package/template/.codex/skills/keystone-dev/references/CHECKLIST.md +285 -0
- package/template/.codex/skills/keystone-dev/references/GOTCHAS.md +390 -0
- package/template/.codex/skills/keystone-dev/references/PATTERNS.md +605 -0
- package/template/.codex/skills/keystone-dev/references/TEMPLATES.md +2562 -384
- package/template/README.md +8 -1
- package/template/apps/server/go.mod +97 -97
- package/template/apps/server/go.sum +283 -283
- package/template/docs/CONVENTIONS.md +11 -8
- package/template/package.json +3 -3
|
@@ -1,532 +1,2710 @@
|
|
|
1
1
|
# Keystone 代码模板
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
> 所有模板基于 example 模块实际代码,使用占位符标记变量部分。
|
|
4
|
+
> 复制后替换占位符即可运行。
|
|
4
5
|
|
|
5
|
-
|
|
6
|
-
- `Space` 使用 `orientation`,不要用 `direction`。
|
|
7
|
-
- `Modal` 使用 `destroyOnHidden`,不要用 `destroyOnClose`。
|
|
8
|
-
- `Drawer` 使用 `size`,不要用 `width`。
|
|
6
|
+
## 占位符说明
|
|
9
7
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
8
|
+
| 占位符 | 含义 | 示例 |
|
|
9
|
+
|--------|------|------|
|
|
10
|
+
| `__MODULE__` | 模块名 (小写) | `order`, `product` |
|
|
11
|
+
| `__MODULE_TITLE__` | 模块中文名 | `订单`, `商品` |
|
|
12
|
+
| `__ENTITY__` | 实体名 (PascalCase) | `Order`, `Product` |
|
|
13
|
+
| `__ENTITY_LOWER__` | 实体名 (小写) | `order`, `product` |
|
|
14
|
+
| `__RESOURCE__` | 资源名 (复数小写) | `orders`, `products` |
|
|
15
|
+
| `__APP_NAME__` | 应用名 (go.mod 中定义) | 脚手架自动替换 |
|
|
14
16
|
|
|
15
|
-
|
|
16
|
-
{
|
|
17
|
-
path: '{name}',
|
|
18
|
-
handle: {
|
|
19
|
-
menu: { title: '{Title}', icon: 'icon-name' },
|
|
20
|
-
permission: '{name}:{resource}:view',
|
|
21
|
-
helpKey: '{name}/index',
|
|
22
|
-
},
|
|
23
|
-
children: [
|
|
24
|
-
{ index: true, lazy: () => import('./pages/List') },
|
|
25
|
-
{ path: 'create', lazy: () => import('./pages/Form') },
|
|
26
|
-
{ path: ':id', lazy: () => import('./pages/Detail') },
|
|
27
|
-
{ path: ':id/edit', lazy: () => import('./pages/Form') },
|
|
28
|
-
],
|
|
29
|
-
},
|
|
30
|
-
]
|
|
17
|
+
---
|
|
31
18
|
|
|
32
|
-
|
|
33
|
-
```
|
|
19
|
+
## 后端模板
|
|
34
20
|
|
|
35
|
-
###
|
|
36
|
-
```tsx
|
|
37
|
-
import { useEffect, useState } from 'react'
|
|
38
|
-
import { ProTable } from '@robsun/keystone-web-core'
|
|
39
|
-
import type { PaginatedData } from '@robsun/keystone-web-core'
|
|
40
|
-
import type { {Entity} } from '../types'
|
|
41
|
-
import { list{Entity} } from '../services/api'
|
|
21
|
+
### 1. module.go (模块入口)
|
|
42
22
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
total: 0,
|
|
46
|
-
page: 1,
|
|
47
|
-
page_size: 10,
|
|
48
|
-
}
|
|
23
|
+
```go
|
|
24
|
+
package __MODULE__
|
|
49
25
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
26
|
+
import (
|
|
27
|
+
"github.com/gin-gonic/gin"
|
|
28
|
+
"gorm.io/gorm"
|
|
29
|
+
|
|
30
|
+
"github.com/robsuncn/keystone/domain/permissions"
|
|
31
|
+
"github.com/robsuncn/keystone/infra/jobs"
|
|
32
|
+
|
|
33
|
+
handler "__APP_NAME__/apps/server/internal/modules/__MODULE__/api/handler"
|
|
34
|
+
migrations "__APP_NAME__/apps/server/internal/modules/__MODULE__/bootstrap/migrations"
|
|
35
|
+
seeds "__APP_NAME__/apps/server/internal/modules/__MODULE__/bootstrap/seeds"
|
|
36
|
+
models "__APP_NAME__/apps/server/internal/modules/__MODULE__/domain/models"
|
|
37
|
+
service "__APP_NAME__/apps/server/internal/modules/__MODULE__/domain/service"
|
|
38
|
+
modulei18n "__APP_NAME__/apps/server/internal/modules/__MODULE__/i18n"
|
|
39
|
+
repository "__APP_NAME__/apps/server/internal/modules/__MODULE__/infra/repository"
|
|
40
|
+
)
|
|
53
41
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
]
|
|
42
|
+
type Module struct {
|
|
43
|
+
svc *service.__ENTITY__Service
|
|
44
|
+
}
|
|
58
45
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
const result = await list{Entity}({ page, page_size: pageSize })
|
|
63
|
-
setData(result)
|
|
64
|
-
} finally {
|
|
65
|
-
setLoading(false)
|
|
66
|
-
}
|
|
67
|
-
}
|
|
46
|
+
func NewModule() *Module {
|
|
47
|
+
return &Module{}
|
|
48
|
+
}
|
|
68
49
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
50
|
+
func (m *Module) Name() string {
|
|
51
|
+
return "__MODULE__"
|
|
52
|
+
}
|
|
72
53
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
/>
|
|
88
|
-
)
|
|
54
|
+
func (m *Module) RegisterRoutes(rg *gin.RouterGroup) {
|
|
55
|
+
if rg == nil || m == nil {
|
|
56
|
+
return
|
|
57
|
+
}
|
|
58
|
+
h := handler.New__ENTITY__Handler(m.svc)
|
|
59
|
+
if h == nil {
|
|
60
|
+
return
|
|
61
|
+
}
|
|
62
|
+
group := rg.Group("/__MODULE__")
|
|
63
|
+
group.GET("/__RESOURCE__", h.List)
|
|
64
|
+
group.POST("/__RESOURCE__", h.Create)
|
|
65
|
+
group.GET("/__RESOURCE__/:id", h.Get)
|
|
66
|
+
group.PATCH("/__RESOURCE__/:id", h.Update)
|
|
67
|
+
group.DELETE("/__RESOURCE__/:id", h.Delete)
|
|
89
68
|
}
|
|
90
|
-
```
|
|
91
69
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
import { useParams, useNavigate } from 'react-router-dom'
|
|
96
|
-
import { create{Entity}, update{Entity} } from '../services/api'
|
|
97
|
-
|
|
98
|
-
export function Component() {
|
|
99
|
-
const { id: rawId } = useParams()
|
|
100
|
-
const navigate = useNavigate()
|
|
101
|
-
const id = Number(rawId)
|
|
102
|
-
const isEdit = Number.isFinite(id)
|
|
103
|
-
|
|
104
|
-
const onSubmit = async (values: any) => {
|
|
105
|
-
if (isEdit) {
|
|
106
|
-
await update{Entity}(id, values)
|
|
107
|
-
} else {
|
|
108
|
-
await create{Entity}(values)
|
|
109
|
-
}
|
|
110
|
-
navigate('/{name}')
|
|
111
|
-
}
|
|
70
|
+
func (m *Module) RegisterModels() []interface{} {
|
|
71
|
+
return []interface{}{&models.__ENTITY__{}}
|
|
72
|
+
}
|
|
112
73
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
74
|
+
func (m *Module) RegisterPermissions(reg *permissions.Registry) error {
|
|
75
|
+
if reg == nil {
|
|
76
|
+
return nil
|
|
77
|
+
}
|
|
78
|
+
// 菜单权限
|
|
79
|
+
if err := reg.CreateMenuI18n(
|
|
80
|
+
"__MODULE__:__ENTITY_LOWER__",
|
|
81
|
+
"__MODULE_TITLE__",
|
|
82
|
+
"permission.__MODULE__.__ENTITY_LOWER__",
|
|
83
|
+
"__MODULE__",
|
|
84
|
+
10,
|
|
85
|
+
); err != nil {
|
|
86
|
+
return err
|
|
87
|
+
}
|
|
88
|
+
// 操作权限
|
|
89
|
+
if err := reg.CreateActionI18n(
|
|
90
|
+
"__MODULE__:__ENTITY_LOWER__:view",
|
|
91
|
+
"View __MODULE_TITLE__",
|
|
92
|
+
"permission.__MODULE__.__ENTITY_LOWER__.view",
|
|
93
|
+
"__MODULE__",
|
|
94
|
+
"__MODULE__:__ENTITY_LOWER__",
|
|
95
|
+
); err != nil {
|
|
96
|
+
return err
|
|
97
|
+
}
|
|
98
|
+
if err := reg.CreateActionI18n(
|
|
99
|
+
"__MODULE__:__ENTITY_LOWER__:manage",
|
|
100
|
+
"Manage __MODULE_TITLE__",
|
|
101
|
+
"permission.__MODULE__.__ENTITY_LOWER__.manage",
|
|
102
|
+
"__MODULE__",
|
|
103
|
+
"__MODULE__:__ENTITY_LOWER__",
|
|
104
|
+
); err != nil {
|
|
105
|
+
return err
|
|
106
|
+
}
|
|
107
|
+
return nil
|
|
118
108
|
}
|
|
119
|
-
```
|
|
120
109
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
import { api, type ApiResponse, type PaginatedData, type PaginationParams } from '@robsun/keystone-web-core'
|
|
124
|
-
import type { {Entity} } from '../types'
|
|
125
|
-
|
|
126
|
-
// 注意:使用 api 而非 apiClient,响应格式为 { data: { ... } }
|
|
127
|
-
export const list{Entity} = async (params: PaginationParams = {}) => {
|
|
128
|
-
const { data } = await api.get<ApiResponse<PaginatedData<{Entity}>>>(
|
|
129
|
-
'/{module}/{resources}',
|
|
130
|
-
{ params }
|
|
131
|
-
)
|
|
132
|
-
return data.data
|
|
110
|
+
func (m *Module) RegisterI18n() error {
|
|
111
|
+
return modulei18n.RegisterLocales()
|
|
133
112
|
}
|
|
134
113
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
return data.data
|
|
114
|
+
func (m *Module) RegisterJobs(_ *jobs.Registry) error {
|
|
115
|
+
return nil
|
|
138
116
|
}
|
|
139
117
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
118
|
+
func (m *Module) Migrate(db *gorm.DB) error {
|
|
119
|
+
if db == nil {
|
|
120
|
+
return nil
|
|
121
|
+
}
|
|
122
|
+
m.ensureServices(db)
|
|
123
|
+
return migrations.Migrate(db)
|
|
143
124
|
}
|
|
144
125
|
|
|
145
|
-
|
|
146
|
-
|
|
126
|
+
func (m *Module) Seed(db *gorm.DB) error {
|
|
127
|
+
if db == nil {
|
|
128
|
+
return nil
|
|
129
|
+
}
|
|
130
|
+
m.ensureServices(db)
|
|
131
|
+
return seeds.Seed(db)
|
|
147
132
|
}
|
|
148
|
-
```
|
|
149
133
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
updatedAt: string
|
|
134
|
+
func (m *Module) ensureServices(db *gorm.DB) {
|
|
135
|
+
if m == nil || db == nil || m.svc != nil {
|
|
136
|
+
return
|
|
137
|
+
}
|
|
138
|
+
repo := repository.New__ENTITY__Repository(db)
|
|
139
|
+
m.svc = service.New__ENTITY__Service(repo)
|
|
157
140
|
}
|
|
158
141
|
```
|
|
159
142
|
|
|
160
|
-
###
|
|
143
|
+
### 2. domain/models/entity.go (模型)
|
|
161
144
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
{
|
|
165
|
-
"title": "{模块标题}",
|
|
166
|
-
"list": {
|
|
167
|
-
"title": "{实体}列表",
|
|
168
|
-
"empty": "暂无数据",
|
|
169
|
-
"columns": {
|
|
170
|
-
"name": "名称",
|
|
171
|
-
"status": "状态",
|
|
172
|
-
"createdAt": "创建时间"
|
|
173
|
-
}
|
|
174
|
-
},
|
|
175
|
-
"form": {
|
|
176
|
-
"create": "创建{实体}",
|
|
177
|
-
"edit": "编辑{实体}",
|
|
178
|
-
"fields": {
|
|
179
|
-
"name": "名称",
|
|
180
|
-
"description": "描述"
|
|
181
|
-
},
|
|
182
|
-
"placeholders": {
|
|
183
|
-
"name": "请输入名称"
|
|
184
|
-
}
|
|
185
|
-
},
|
|
186
|
-
"actions": {
|
|
187
|
-
"create": "新建",
|
|
188
|
-
"edit": "编辑",
|
|
189
|
-
"delete": "删除",
|
|
190
|
-
"save": "保存",
|
|
191
|
-
"cancel": "取消"
|
|
192
|
-
},
|
|
193
|
-
"messages": {
|
|
194
|
-
"createSuccess": "创建成功",
|
|
195
|
-
"updateSuccess": "更新成功",
|
|
196
|
-
"deleteSuccess": "删除成功",
|
|
197
|
-
"deleteConfirm": "确定要删除吗?"
|
|
198
|
-
}
|
|
199
|
-
}
|
|
200
|
-
```
|
|
145
|
+
```go
|
|
146
|
+
package models
|
|
201
147
|
|
|
202
|
-
|
|
203
|
-
```json
|
|
204
|
-
{
|
|
205
|
-
"title": "{Module Title}",
|
|
206
|
-
"list": {
|
|
207
|
-
"title": "{Entity} List",
|
|
208
|
-
"empty": "No data",
|
|
209
|
-
"columns": {
|
|
210
|
-
"name": "Name",
|
|
211
|
-
"status": "Status",
|
|
212
|
-
"createdAt": "Created At"
|
|
213
|
-
}
|
|
214
|
-
},
|
|
215
|
-
"form": {
|
|
216
|
-
"create": "Create {Entity}",
|
|
217
|
-
"edit": "Edit {Entity}",
|
|
218
|
-
"fields": {
|
|
219
|
-
"name": "Name",
|
|
220
|
-
"description": "Description"
|
|
221
|
-
},
|
|
222
|
-
"placeholders": {
|
|
223
|
-
"name": "Enter name"
|
|
224
|
-
}
|
|
225
|
-
},
|
|
226
|
-
"actions": {
|
|
227
|
-
"create": "Create",
|
|
228
|
-
"edit": "Edit",
|
|
229
|
-
"delete": "Delete",
|
|
230
|
-
"save": "Save",
|
|
231
|
-
"cancel": "Cancel"
|
|
232
|
-
},
|
|
233
|
-
"messages": {
|
|
234
|
-
"createSuccess": "Created successfully",
|
|
235
|
-
"updateSuccess": "Updated successfully",
|
|
236
|
-
"deleteSuccess": "Deleted successfully",
|
|
237
|
-
"deleteConfirm": "Are you sure to delete?"
|
|
238
|
-
}
|
|
239
|
-
}
|
|
240
|
-
```
|
|
148
|
+
import "github.com/robsuncn/keystone/domain/models"
|
|
241
149
|
|
|
242
|
-
|
|
243
|
-
```tsx
|
|
244
|
-
import { useTranslation } from 'react-i18next'
|
|
245
|
-
import { ProTable } from '@robsun/keystone-web-core'
|
|
150
|
+
type __ENTITY__Status string
|
|
246
151
|
|
|
247
|
-
|
|
248
|
-
|
|
152
|
+
const (
|
|
153
|
+
Status__ENTITY__Active __ENTITY__Status = "active"
|
|
154
|
+
Status__ENTITY__Inactive __ENTITY__Status = "inactive"
|
|
155
|
+
)
|
|
249
156
|
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
157
|
+
func (s __ENTITY__Status) IsValid() bool {
|
|
158
|
+
switch s {
|
|
159
|
+
case Status__ENTITY__Active, Status__ENTITY__Inactive:
|
|
160
|
+
return true
|
|
161
|
+
default:
|
|
162
|
+
return false
|
|
163
|
+
}
|
|
164
|
+
}
|
|
255
165
|
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
166
|
+
type __ENTITY__ struct {
|
|
167
|
+
models.BaseModel
|
|
168
|
+
Name string `gorm:"size:200;not null" json:"name"`
|
|
169
|
+
Description string `gorm:"size:1000" json:"description"`
|
|
170
|
+
Status __ENTITY__Status `gorm:"size:20;not null;default:'active'" json:"status"`
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
func (__ENTITY__) TableName() string {
|
|
174
|
+
return "__MODULE_____RESOURCE__"
|
|
262
175
|
}
|
|
263
176
|
```
|
|
264
177
|
|
|
265
|
-
|
|
178
|
+
### 3. domain/service/service.go (服务层)
|
|
266
179
|
|
|
267
|
-
### module.go
|
|
268
180
|
```go
|
|
269
|
-
package
|
|
181
|
+
package service
|
|
270
182
|
|
|
271
183
|
import (
|
|
272
|
-
|
|
273
|
-
|
|
184
|
+
"context"
|
|
185
|
+
"strings"
|
|
186
|
+
|
|
187
|
+
"__APP_NAME__/apps/server/internal/modules/__MODULE__/domain/models"
|
|
274
188
|
)
|
|
275
189
|
|
|
276
|
-
type
|
|
190
|
+
type __ENTITY__Repository interface {
|
|
191
|
+
List(ctx context.Context, tenantID uint) ([]models.__ENTITY__, error)
|
|
192
|
+
FindByID(tenantID, id uint) (*models.__ENTITY__, error)
|
|
193
|
+
Create(ctx context.Context, entity *models.__ENTITY__) error
|
|
194
|
+
Update(ctx context.Context, entity *models.__ENTITY__) error
|
|
195
|
+
Delete(ctx context.Context, entity *models.__ENTITY__) error
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
type __ENTITY__Service struct {
|
|
199
|
+
repo __ENTITY__Repository
|
|
200
|
+
}
|
|
277
201
|
|
|
278
|
-
|
|
202
|
+
type __ENTITY__Input struct {
|
|
203
|
+
Name string
|
|
204
|
+
Description string
|
|
205
|
+
Status models.__ENTITY__Status
|
|
206
|
+
}
|
|
279
207
|
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
g.POST("", handler.Create)
|
|
285
|
-
g.PATCH("/:id", handler.Update)
|
|
286
|
-
g.DELETE("/:id", handler.Delete)
|
|
208
|
+
type __ENTITY__UpdateInput struct {
|
|
209
|
+
Name *string
|
|
210
|
+
Description *string
|
|
211
|
+
Status *models.__ENTITY__Status
|
|
287
212
|
}
|
|
288
213
|
|
|
289
|
-
func (
|
|
290
|
-
|
|
291
|
-
{Name: "{name}:{resource}:view", Title: "查看{Title}"},
|
|
292
|
-
{Name: "{name}:{resource}:create", Title: "创建{Title}"},
|
|
293
|
-
{Name: "{name}:{resource}:update", Title: "编辑{Title}"},
|
|
294
|
-
{Name: "{name}:{resource}:delete", Title: "删除{Title}"},
|
|
295
|
-
}
|
|
214
|
+
func New__ENTITY__Service(repo __ENTITY__Repository) *__ENTITY__Service {
|
|
215
|
+
return &__ENTITY__Service{repo: repo}
|
|
296
216
|
}
|
|
297
217
|
|
|
298
|
-
func (
|
|
299
|
-
|
|
218
|
+
func (s *__ENTITY__Service) List(ctx context.Context, tenantID uint) ([]models.__ENTITY__, error) {
|
|
219
|
+
return s.repo.List(ctx, tenantID)
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
func (s *__ENTITY__Service) Create(ctx context.Context, tenantID uint, input __ENTITY__Input) (*models.__ENTITY__, error) {
|
|
223
|
+
name := strings.TrimSpace(input.Name)
|
|
224
|
+
if name == "" {
|
|
225
|
+
return nil, ErrNameRequired
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
status := input.Status
|
|
229
|
+
if status == "" {
|
|
230
|
+
status = models.Status__ENTITY__Active
|
|
231
|
+
}
|
|
232
|
+
if !status.IsValid() {
|
|
233
|
+
return nil, ErrStatusInvalid
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
entity := &models.__ENTITY__{
|
|
237
|
+
Name: name,
|
|
238
|
+
Description: strings.TrimSpace(input.Description),
|
|
239
|
+
Status: status,
|
|
240
|
+
}
|
|
241
|
+
entity.TenantID = tenantID
|
|
242
|
+
|
|
243
|
+
if err := s.repo.Create(ctx, entity); err != nil {
|
|
244
|
+
return nil, err
|
|
245
|
+
}
|
|
246
|
+
return entity, nil
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
func (s *__ENTITY__Service) Update(
|
|
250
|
+
ctx context.Context,
|
|
251
|
+
tenantID, id uint,
|
|
252
|
+
input __ENTITY__UpdateInput,
|
|
253
|
+
) (*models.__ENTITY__, error) {
|
|
254
|
+
entity, err := s.repo.FindByID(tenantID, id)
|
|
255
|
+
if err != nil {
|
|
256
|
+
return nil, err
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if input.Name != nil {
|
|
260
|
+
name := strings.TrimSpace(*input.Name)
|
|
261
|
+
if name == "" {
|
|
262
|
+
return nil, ErrNameRequired
|
|
263
|
+
}
|
|
264
|
+
entity.Name = name
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
if input.Description != nil {
|
|
268
|
+
entity.Description = strings.TrimSpace(*input.Description)
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if input.Status != nil {
|
|
272
|
+
if !input.Status.IsValid() {
|
|
273
|
+
return nil, ErrStatusInvalid
|
|
274
|
+
}
|
|
275
|
+
entity.Status = *input.Status
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
if err := s.repo.Update(ctx, entity); err != nil {
|
|
279
|
+
return nil, err
|
|
280
|
+
}
|
|
281
|
+
return entity, nil
|
|
300
282
|
}
|
|
301
283
|
|
|
302
|
-
func (
|
|
284
|
+
func (s *__ENTITY__Service) Delete(ctx context.Context, tenantID, id uint) error {
|
|
285
|
+
entity, err := s.repo.FindByID(tenantID, id)
|
|
286
|
+
if err != nil {
|
|
287
|
+
return err
|
|
288
|
+
}
|
|
289
|
+
return s.repo.Delete(ctx, entity)
|
|
290
|
+
}
|
|
303
291
|
```
|
|
304
292
|
|
|
305
|
-
###
|
|
293
|
+
### 4. domain/service/errors.go (错误定义)
|
|
294
|
+
|
|
306
295
|
```go
|
|
307
|
-
package
|
|
296
|
+
package service
|
|
308
297
|
|
|
309
298
|
import (
|
|
310
|
-
|
|
299
|
+
"github.com/robsuncn/keystone/infra/i18n"
|
|
300
|
+
modulei18n "__APP_NAME__/apps/server/internal/modules/__MODULE__/i18n"
|
|
311
301
|
)
|
|
312
302
|
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
return
|
|
319
|
-
}
|
|
320
|
-
respondPaginated(c, items, total, page, pageSize)
|
|
321
|
-
}
|
|
303
|
+
var (
|
|
304
|
+
Err__ENTITY__NotFound = &i18n.I18nError{Key: modulei18n.Msg__ENTITY__NotFound}
|
|
305
|
+
ErrNameRequired = &i18n.I18nError{Key: modulei18n.MsgNameRequired}
|
|
306
|
+
ErrStatusInvalid = &i18n.I18nError{Key: modulei18n.MsgStatusInvalid}
|
|
307
|
+
)
|
|
322
308
|
```
|
|
323
309
|
|
|
324
|
-
###
|
|
310
|
+
### 5. api/handler/handler.go (HTTP 处理器)
|
|
311
|
+
|
|
325
312
|
```go
|
|
326
|
-
package
|
|
313
|
+
package handler
|
|
314
|
+
|
|
315
|
+
import (
|
|
316
|
+
"errors"
|
|
327
317
|
|
|
328
|
-
|
|
318
|
+
"github.com/gin-gonic/gin"
|
|
319
|
+
hcommon "github.com/robsuncn/keystone/api/handler/common"
|
|
320
|
+
"github.com/robsuncn/keystone/api/response"
|
|
321
|
+
"github.com/robsuncn/keystone/infra/i18n"
|
|
329
322
|
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
323
|
+
modulei18n "__APP_NAME__/apps/server/internal/modules/__MODULE__/i18n"
|
|
324
|
+
"__APP_NAME__/apps/server/internal/modules/__MODULE__/domain/models"
|
|
325
|
+
"__APP_NAME__/apps/server/internal/modules/__MODULE__/domain/service"
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
type __ENTITY__Handler struct {
|
|
329
|
+
svc *service.__ENTITY__Service
|
|
335
330
|
}
|
|
336
|
-
```
|
|
337
331
|
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
332
|
+
func New__ENTITY__Handler(svc *service.__ENTITY__Service) *__ENTITY__Handler {
|
|
333
|
+
if svc == nil {
|
|
334
|
+
return nil
|
|
335
|
+
}
|
|
336
|
+
return &__ENTITY__Handler{svc: svc}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
type createInput struct {
|
|
340
|
+
Name string `json:"name"`
|
|
341
|
+
Description string `json:"description"`
|
|
342
|
+
Status models.__ENTITY__Status `json:"status"`
|
|
343
|
+
}
|
|
341
344
|
|
|
342
|
-
type
|
|
343
|
-
|
|
345
|
+
type updateInput struct {
|
|
346
|
+
Name *string `json:"name"`
|
|
347
|
+
Description *string `json:"description"`
|
|
348
|
+
Status *models.__ENTITY__Status `json:"status"`
|
|
344
349
|
}
|
|
345
350
|
|
|
346
|
-
|
|
347
|
-
|
|
351
|
+
const defaultTenantID uint = 1
|
|
352
|
+
|
|
353
|
+
func (h *__ENTITY__Handler) List(c *gin.Context) {
|
|
354
|
+
if h == nil || h.svc == nil {
|
|
355
|
+
response.ServiceUnavailableI18n(c, modulei18n.MsgServiceUnavailable)
|
|
356
|
+
return
|
|
357
|
+
}
|
|
358
|
+
tenantID := resolveTenantID(c)
|
|
359
|
+
|
|
360
|
+
items, err := h.svc.List(c.Request.Context(), tenantID)
|
|
361
|
+
if err != nil {
|
|
362
|
+
response.InternalErrorI18n(c, modulei18n.MsgLoadFailed)
|
|
363
|
+
return
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
response.Success(c, gin.H{"items": items})
|
|
348
367
|
}
|
|
349
368
|
|
|
350
|
-
func (
|
|
351
|
-
|
|
369
|
+
func (h *__ENTITY__Handler) Get(c *gin.Context) {
|
|
370
|
+
if h == nil || h.svc == nil {
|
|
371
|
+
response.ServiceUnavailableI18n(c, modulei18n.MsgServiceUnavailable)
|
|
372
|
+
return
|
|
373
|
+
}
|
|
374
|
+
tenantID := resolveTenantID(c)
|
|
375
|
+
|
|
376
|
+
id, err := hcommon.ParseUintParam(c, "id")
|
|
377
|
+
if err != nil || id == 0 {
|
|
378
|
+
response.BadRequestI18n(c, modulei18n.MsgInvalidID)
|
|
379
|
+
return
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// 需要在 service 中添加 Get 方法
|
|
383
|
+
// item, err := h.svc.Get(c.Request.Context(), tenantID, id)
|
|
384
|
+
// ...
|
|
385
|
+
response.Success(c, gin.H{"id": id, "tenant_id": tenantID})
|
|
352
386
|
}
|
|
353
387
|
|
|
354
|
-
func (
|
|
355
|
-
|
|
388
|
+
func (h *__ENTITY__Handler) Create(c *gin.Context) {
|
|
389
|
+
if h == nil || h.svc == nil {
|
|
390
|
+
response.ServiceUnavailableI18n(c, modulei18n.MsgServiceUnavailable)
|
|
391
|
+
return
|
|
392
|
+
}
|
|
393
|
+
tenantID := resolveTenantID(c)
|
|
394
|
+
|
|
395
|
+
var input createInput
|
|
396
|
+
if err := c.ShouldBindJSON(&input); err != nil {
|
|
397
|
+
response.BadRequestI18n(c, modulei18n.MsgInvalidPayload)
|
|
398
|
+
return
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
entity, err := h.svc.Create(c.Request.Context(), tenantID, service.__ENTITY__Input{
|
|
402
|
+
Name: input.Name,
|
|
403
|
+
Description: input.Description,
|
|
404
|
+
Status: input.Status,
|
|
405
|
+
})
|
|
406
|
+
if err != nil {
|
|
407
|
+
var i18nErr *i18n.I18nError
|
|
408
|
+
if errors.As(err, &i18nErr) {
|
|
409
|
+
response.BadRequestI18n(c, i18nErr.Key)
|
|
410
|
+
return
|
|
411
|
+
}
|
|
412
|
+
response.InternalErrorI18n(c, modulei18n.MsgCreateFailed)
|
|
413
|
+
return
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
response.CreatedI18n(c, modulei18n.MsgCreated, entity)
|
|
356
417
|
}
|
|
357
418
|
|
|
358
|
-
func (
|
|
359
|
-
|
|
419
|
+
func (h *__ENTITY__Handler) Update(c *gin.Context) {
|
|
420
|
+
if h == nil || h.svc == nil {
|
|
421
|
+
response.ServiceUnavailableI18n(c, modulei18n.MsgServiceUnavailable)
|
|
422
|
+
return
|
|
423
|
+
}
|
|
424
|
+
tenantID := resolveTenantID(c)
|
|
425
|
+
|
|
426
|
+
id, err := hcommon.ParseUintParam(c, "id")
|
|
427
|
+
if err != nil || id == 0 {
|
|
428
|
+
response.BadRequestI18n(c, modulei18n.MsgInvalidID)
|
|
429
|
+
return
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
var input updateInput
|
|
433
|
+
if err := c.ShouldBindJSON(&input); err != nil {
|
|
434
|
+
response.BadRequestI18n(c, modulei18n.MsgInvalidPayload)
|
|
435
|
+
return
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
entity, err := h.svc.Update(c.Request.Context(), tenantID, id, service.__ENTITY__UpdateInput{
|
|
439
|
+
Name: input.Name,
|
|
440
|
+
Description: input.Description,
|
|
441
|
+
Status: input.Status,
|
|
442
|
+
})
|
|
443
|
+
if err != nil {
|
|
444
|
+
var i18nErr *i18n.I18nError
|
|
445
|
+
if errors.As(err, &i18nErr) {
|
|
446
|
+
if i18nErr.Key == modulei18n.Msg__ENTITY__NotFound {
|
|
447
|
+
response.NotFoundI18n(c, i18nErr.Key)
|
|
448
|
+
} else {
|
|
449
|
+
response.BadRequestI18n(c, i18nErr.Key)
|
|
450
|
+
}
|
|
451
|
+
return
|
|
452
|
+
}
|
|
453
|
+
response.InternalErrorI18n(c, modulei18n.MsgUpdateFailed)
|
|
454
|
+
return
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
response.SuccessI18n(c, modulei18n.MsgUpdated, entity)
|
|
360
458
|
}
|
|
361
459
|
|
|
362
|
-
func (
|
|
363
|
-
|
|
460
|
+
func (h *__ENTITY__Handler) Delete(c *gin.Context) {
|
|
461
|
+
if h == nil || h.svc == nil {
|
|
462
|
+
response.ServiceUnavailableI18n(c, modulei18n.MsgServiceUnavailable)
|
|
463
|
+
return
|
|
464
|
+
}
|
|
465
|
+
tenantID := resolveTenantID(c)
|
|
466
|
+
|
|
467
|
+
id, err := hcommon.ParseUintParam(c, "id")
|
|
468
|
+
if err != nil || id == 0 {
|
|
469
|
+
response.BadRequestI18n(c, modulei18n.MsgInvalidID)
|
|
470
|
+
return
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
if err := h.svc.Delete(c.Request.Context(), tenantID, id); err != nil {
|
|
474
|
+
var i18nErr *i18n.I18nError
|
|
475
|
+
if errors.As(err, &i18nErr) {
|
|
476
|
+
if i18nErr.Key == modulei18n.Msg__ENTITY__NotFound {
|
|
477
|
+
response.NotFoundI18n(c, i18nErr.Key)
|
|
478
|
+
return
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
response.InternalErrorI18n(c, modulei18n.MsgDeleteFailed)
|
|
482
|
+
return
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
response.SuccessI18n(c, modulei18n.MsgDeleted, gin.H{"id": id})
|
|
364
486
|
}
|
|
365
487
|
|
|
366
|
-
func (
|
|
367
|
-
|
|
488
|
+
func resolveTenantID(c *gin.Context) uint {
|
|
489
|
+
if c == nil {
|
|
490
|
+
return defaultTenantID
|
|
491
|
+
}
|
|
492
|
+
if tenantID, ok := hcommon.GetTenantID(c); ok && tenantID > 0 {
|
|
493
|
+
return tenantID
|
|
494
|
+
}
|
|
495
|
+
return defaultTenantID
|
|
368
496
|
}
|
|
369
497
|
```
|
|
370
498
|
|
|
371
|
-
### infra/repository/
|
|
499
|
+
### 6. infra/repository/repository.go (数据仓库)
|
|
500
|
+
|
|
372
501
|
```go
|
|
373
502
|
package repository
|
|
374
503
|
|
|
375
504
|
import (
|
|
376
|
-
|
|
377
|
-
|
|
505
|
+
"context"
|
|
506
|
+
"errors"
|
|
507
|
+
|
|
508
|
+
"gorm.io/gorm"
|
|
509
|
+
|
|
510
|
+
"__APP_NAME__/apps/server/internal/modules/__MODULE__/domain/models"
|
|
511
|
+
"__APP_NAME__/apps/server/internal/modules/__MODULE__/domain/service"
|
|
378
512
|
)
|
|
379
513
|
|
|
380
|
-
type
|
|
381
|
-
|
|
514
|
+
type __ENTITY__Repository struct {
|
|
515
|
+
db *gorm.DB
|
|
382
516
|
}
|
|
383
517
|
|
|
384
|
-
func
|
|
385
|
-
|
|
518
|
+
func New__ENTITY__Repository(db *gorm.DB) *__ENTITY__Repository {
|
|
519
|
+
return &__ENTITY__Repository{db: db}
|
|
386
520
|
}
|
|
387
521
|
|
|
388
|
-
func (r *
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
return items, total, nil
|
|
522
|
+
func (r *__ENTITY__Repository) List(ctx context.Context, tenantID uint) ([]models.__ENTITY__, error) {
|
|
523
|
+
var items []models.__ENTITY__
|
|
524
|
+
err := r.db.WithContext(ctx).
|
|
525
|
+
Where("tenant_id = ?", tenantID).
|
|
526
|
+
Order("created_at desc").
|
|
527
|
+
Find(&items).Error
|
|
528
|
+
return items, err
|
|
396
529
|
}
|
|
397
530
|
|
|
398
|
-
func (r *
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
531
|
+
func (r *__ENTITY__Repository) FindByID(tenantID, id uint) (*models.__ENTITY__, error) {
|
|
532
|
+
var entity models.__ENTITY__
|
|
533
|
+
err := r.db.Where("tenant_id = ? AND id = ?", tenantID, id).First(&entity).Error
|
|
534
|
+
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
535
|
+
return nil, service.Err__ENTITY__NotFound
|
|
536
|
+
}
|
|
537
|
+
return &entity, err
|
|
404
538
|
}
|
|
405
539
|
|
|
406
|
-
func (r *
|
|
407
|
-
|
|
540
|
+
func (r *__ENTITY__Repository) Create(ctx context.Context, entity *models.__ENTITY__) error {
|
|
541
|
+
return r.db.WithContext(ctx).Create(entity).Error
|
|
408
542
|
}
|
|
409
543
|
|
|
410
|
-
func (r *
|
|
411
|
-
|
|
544
|
+
func (r *__ENTITY__Repository) Update(ctx context.Context, entity *models.__ENTITY__) error {
|
|
545
|
+
return r.db.WithContext(ctx).Save(entity).Error
|
|
412
546
|
}
|
|
413
547
|
|
|
414
|
-
func (r *
|
|
415
|
-
|
|
548
|
+
func (r *__ENTITY__Repository) Delete(ctx context.Context, entity *models.__ENTITY__) error {
|
|
549
|
+
return r.db.WithContext(ctx).Delete(entity).Error
|
|
416
550
|
}
|
|
417
551
|
```
|
|
418
552
|
|
|
419
|
-
### i18n/
|
|
553
|
+
### 7. i18n/keys.go (翻译键)
|
|
554
|
+
|
|
555
|
+
```go
|
|
556
|
+
package modulei18n
|
|
557
|
+
|
|
558
|
+
const (
|
|
559
|
+
// 实体操作消息
|
|
560
|
+
MsgCreated = "__MODULE__.__ENTITY_LOWER__.created"
|
|
561
|
+
MsgUpdated = "__MODULE__.__ENTITY_LOWER__.updated"
|
|
562
|
+
MsgDeleted = "__MODULE__.__ENTITY_LOWER__.deleted"
|
|
563
|
+
Msg__ENTITY__NotFound = "__MODULE__.__ENTITY_LOWER__.notFound"
|
|
564
|
+
MsgLoadFailed = "__MODULE__.__ENTITY_LOWER__.loadFailed"
|
|
565
|
+
MsgCreateFailed = "__MODULE__.__ENTITY_LOWER__.createFailed"
|
|
566
|
+
MsgUpdateFailed = "__MODULE__.__ENTITY_LOWER__.updateFailed"
|
|
567
|
+
MsgDeleteFailed = "__MODULE__.__ENTITY_LOWER__.deleteFailed"
|
|
568
|
+
|
|
569
|
+
// 验证消息
|
|
570
|
+
MsgNameRequired = "__MODULE__.validation.nameRequired"
|
|
571
|
+
MsgStatusInvalid = "__MODULE__.validation.statusInvalid"
|
|
572
|
+
MsgInvalidID = "__MODULE__.validation.invalidId"
|
|
573
|
+
MsgInvalidPayload = "__MODULE__.validation.invalidPayload"
|
|
574
|
+
|
|
575
|
+
// 服务消息
|
|
576
|
+
MsgServiceUnavailable = "__MODULE__.service.unavailable"
|
|
577
|
+
)
|
|
578
|
+
```
|
|
579
|
+
|
|
580
|
+
### 8. i18n/i18n.go (翻译注册)
|
|
581
|
+
|
|
420
582
|
```go
|
|
421
|
-
package
|
|
583
|
+
package modulei18n
|
|
422
584
|
|
|
423
585
|
import (
|
|
424
|
-
|
|
425
|
-
|
|
586
|
+
"embed"
|
|
587
|
+
"github.com/robsuncn/keystone/infra/i18n"
|
|
426
588
|
)
|
|
427
589
|
|
|
428
590
|
//go:embed locales/*.json
|
|
429
|
-
var
|
|
591
|
+
var translations embed.FS
|
|
430
592
|
|
|
431
|
-
func
|
|
432
|
-
|
|
433
|
-
i18n.MustLoadModuleTranslations("{module}", Translations)
|
|
593
|
+
func RegisterLocales() error {
|
|
594
|
+
return i18n.LoadModuleTranslations("__MODULE__", translations)
|
|
434
595
|
}
|
|
435
596
|
```
|
|
436
597
|
|
|
437
|
-
### i18n/
|
|
438
|
-
```go
|
|
439
|
-
package i18n
|
|
440
|
-
|
|
441
|
-
// 翻译键常量定义
|
|
442
|
-
const (
|
|
443
|
-
// 成功消息
|
|
444
|
-
KeyItemCreated = "{module}.item.created"
|
|
445
|
-
KeyItemUpdated = "{module}.item.updated"
|
|
446
|
-
KeyItemDeleted = "{module}.item.deleted"
|
|
447
|
-
|
|
448
|
-
// 错误消息
|
|
449
|
-
KeyItemNotFound = "{module}.item.notFound"
|
|
450
|
-
KeyItemInvalid = "{module}.item.invalid"
|
|
451
|
-
KeyItemAlreadyExists = "{module}.item.alreadyExists"
|
|
452
|
-
|
|
453
|
-
// 验证消息
|
|
454
|
-
KeyNameRequired = "{module}.validation.nameRequired"
|
|
455
|
-
KeyNameTooLong = "{module}.validation.nameTooLong"
|
|
456
|
-
)
|
|
457
|
-
```
|
|
598
|
+
### 9. i18n/locales/zh-CN.json
|
|
458
599
|
|
|
459
|
-
### i18n/locales/zh-CN.json
|
|
460
600
|
```json
|
|
461
601
|
{
|
|
462
|
-
"
|
|
463
|
-
"
|
|
464
|
-
"created": "
|
|
465
|
-
"updated": "
|
|
466
|
-
"deleted": "
|
|
467
|
-
"notFound": "
|
|
468
|
-
"
|
|
469
|
-
"
|
|
602
|
+
"__MODULE__": {
|
|
603
|
+
"__ENTITY_LOWER__": {
|
|
604
|
+
"created": "__MODULE_TITLE__创建成功",
|
|
605
|
+
"updated": "__MODULE_TITLE__更新成功",
|
|
606
|
+
"deleted": "__MODULE_TITLE__删除成功",
|
|
607
|
+
"notFound": "__MODULE_TITLE__不存在",
|
|
608
|
+
"loadFailed": "加载__MODULE_TITLE__失败",
|
|
609
|
+
"createFailed": "创建__MODULE_TITLE__失败",
|
|
610
|
+
"updateFailed": "更新__MODULE_TITLE__失败",
|
|
611
|
+
"deleteFailed": "删除__MODULE_TITLE__失败"
|
|
470
612
|
},
|
|
471
613
|
"validation": {
|
|
472
614
|
"nameRequired": "名称不能为空",
|
|
473
|
-
"
|
|
615
|
+
"statusInvalid": "状态无效",
|
|
616
|
+
"invalidId": "无效的ID",
|
|
617
|
+
"invalidPayload": "请求数据格式错误"
|
|
618
|
+
},
|
|
619
|
+
"service": {
|
|
620
|
+
"unavailable": "服务暂不可用"
|
|
474
621
|
}
|
|
475
622
|
}
|
|
476
623
|
}
|
|
477
624
|
```
|
|
478
625
|
|
|
479
|
-
### i18n/locales/en-US.json
|
|
626
|
+
### 10. i18n/locales/en-US.json
|
|
627
|
+
|
|
480
628
|
```json
|
|
481
629
|
{
|
|
482
|
-
"
|
|
483
|
-
"
|
|
484
|
-
"created": "
|
|
485
|
-
"updated": "
|
|
486
|
-
"deleted": "
|
|
487
|
-
"notFound": "
|
|
488
|
-
"
|
|
489
|
-
"
|
|
630
|
+
"__MODULE__": {
|
|
631
|
+
"__ENTITY_LOWER__": {
|
|
632
|
+
"created": "__ENTITY__ created successfully",
|
|
633
|
+
"updated": "__ENTITY__ updated successfully",
|
|
634
|
+
"deleted": "__ENTITY__ deleted successfully",
|
|
635
|
+
"notFound": "__ENTITY__ not found",
|
|
636
|
+
"loadFailed": "Failed to load __ENTITY_LOWER__",
|
|
637
|
+
"createFailed": "Failed to create __ENTITY_LOWER__",
|
|
638
|
+
"updateFailed": "Failed to update __ENTITY_LOWER__",
|
|
639
|
+
"deleteFailed": "Failed to delete __ENTITY_LOWER__"
|
|
490
640
|
},
|
|
491
641
|
"validation": {
|
|
492
642
|
"nameRequired": "Name is required",
|
|
493
|
-
"
|
|
643
|
+
"statusInvalid": "Invalid status",
|
|
644
|
+
"invalidId": "Invalid ID",
|
|
645
|
+
"invalidPayload": "Invalid request payload"
|
|
646
|
+
},
|
|
647
|
+
"service": {
|
|
648
|
+
"unavailable": "Service unavailable"
|
|
494
649
|
}
|
|
495
650
|
}
|
|
496
651
|
}
|
|
497
652
|
```
|
|
498
653
|
|
|
499
|
-
###
|
|
654
|
+
### 11. bootstrap/migrations/migrate.go
|
|
655
|
+
|
|
500
656
|
```go
|
|
501
|
-
package
|
|
657
|
+
package migrations
|
|
502
658
|
|
|
503
659
|
import (
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
"github.com/robsuncn/keystone/infra/i18n"
|
|
507
|
-
modulei18n "app/internal/modules/{module}/i18n"
|
|
660
|
+
"gorm.io/gorm"
|
|
661
|
+
"__APP_NAME__/apps/server/internal/modules/__MODULE__/domain/models"
|
|
508
662
|
)
|
|
509
663
|
|
|
510
|
-
func (
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
664
|
+
func Migrate(db *gorm.DB) error {
|
|
665
|
+
return db.AutoMigrate(&models.__ENTITY__{})
|
|
666
|
+
}
|
|
667
|
+
```
|
|
668
|
+
|
|
669
|
+
### 12. bootstrap/seeds/seed.go
|
|
670
|
+
|
|
671
|
+
```go
|
|
672
|
+
package seeds
|
|
673
|
+
|
|
674
|
+
import "gorm.io/gorm"
|
|
675
|
+
|
|
676
|
+
func Seed(db *gorm.DB) error {
|
|
677
|
+
// 添加初始数据(可选)
|
|
678
|
+
return nil
|
|
679
|
+
}
|
|
680
|
+
```
|
|
681
|
+
|
|
682
|
+
---
|
|
683
|
+
|
|
684
|
+
## 前端模板
|
|
685
|
+
|
|
686
|
+
### 1. types.ts (类型定义)
|
|
687
|
+
|
|
688
|
+
```typescript
|
|
689
|
+
export type __ENTITY__Status = 'active' | 'inactive'
|
|
690
|
+
|
|
691
|
+
export interface __ENTITY__ {
|
|
692
|
+
id: number
|
|
693
|
+
name: string
|
|
694
|
+
description: string
|
|
695
|
+
status: __ENTITY__Status
|
|
696
|
+
created_at: string
|
|
697
|
+
updated_at: string
|
|
698
|
+
}
|
|
699
|
+
```
|
|
700
|
+
|
|
701
|
+
### 2. services/api.ts (API 服务)
|
|
702
|
+
|
|
703
|
+
```typescript
|
|
704
|
+
import { api, type ApiResponse } from '@robsun/keystone-web-core'
|
|
705
|
+
import type { __ENTITY__, __ENTITY__Status } from '../types'
|
|
706
|
+
|
|
707
|
+
type ListResponse = {
|
|
708
|
+
items: __ENTITY__[]
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
export const list__ENTITY__s = async () => {
|
|
712
|
+
const { data } = await api.get<ApiResponse<ListResponse>>('/__MODULE__/__RESOURCE__')
|
|
713
|
+
return data.data.items
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
export const get__ENTITY__ = async (id: number) => {
|
|
717
|
+
const { data } = await api.get<ApiResponse<__ENTITY__>>(`/__MODULE__/__RESOURCE__/${id}`)
|
|
718
|
+
return data.data
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
export const create__ENTITY__ = async (payload: {
|
|
722
|
+
name: string
|
|
723
|
+
description?: string
|
|
724
|
+
status?: __ENTITY__Status
|
|
725
|
+
}) => {
|
|
726
|
+
const { data } = await api.post<ApiResponse<__ENTITY__>>('/__MODULE__/__RESOURCE__', payload)
|
|
727
|
+
return data.data
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
export const update__ENTITY__ = async (
|
|
731
|
+
id: number,
|
|
732
|
+
payload: { name?: string; description?: string; status?: __ENTITY__Status }
|
|
733
|
+
) => {
|
|
734
|
+
const { data } = await api.patch<ApiResponse<__ENTITY__>>(`/__MODULE__/__RESOURCE__/${id}`, payload)
|
|
735
|
+
return data.data
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
export const delete__ENTITY__ = async (id: number) => {
|
|
739
|
+
await api.delete<ApiResponse<{ id: number }>>(`/__MODULE__/__RESOURCE__/${id}`)
|
|
740
|
+
}
|
|
741
|
+
```
|
|
742
|
+
|
|
743
|
+
### 3. routes.tsx (路由定义)
|
|
744
|
+
|
|
745
|
+
```tsx
|
|
746
|
+
import { lazy, Suspense, type ComponentType, type ReactElement } from 'react'
|
|
747
|
+
import type { RouteObject } from 'react-router-dom'
|
|
748
|
+
import { AppstoreOutlined } from '@ant-design/icons'
|
|
749
|
+
import { Spin } from 'antd'
|
|
750
|
+
|
|
751
|
+
const lazyNamed = <T extends Record<string, ComponentType>, K extends keyof T>(
|
|
752
|
+
factory: () => Promise<T>,
|
|
753
|
+
name: K
|
|
754
|
+
) =>
|
|
755
|
+
lazy(async () => {
|
|
756
|
+
const module = await factory()
|
|
757
|
+
return { default: module[name] }
|
|
758
|
+
})
|
|
759
|
+
|
|
760
|
+
const withSuspense = (element: ReactElement) => (
|
|
761
|
+
<Suspense
|
|
762
|
+
fallback={
|
|
763
|
+
<div style={{ padding: 24, display: 'flex', justifyContent: 'center' }}>
|
|
764
|
+
<Spin />
|
|
765
|
+
</div>
|
|
515
766
|
}
|
|
516
|
-
|
|
767
|
+
>
|
|
768
|
+
{element}
|
|
769
|
+
</Suspense>
|
|
770
|
+
)
|
|
771
|
+
|
|
772
|
+
const __ENTITY__ListPage = lazyNamed(() => import('./pages/__ENTITY__ListPage'), '__ENTITY__ListPage')
|
|
773
|
+
|
|
774
|
+
export const __MODULE__Routes: RouteObject[] = [
|
|
775
|
+
{
|
|
776
|
+
path: '__MODULE__',
|
|
777
|
+
element: <__ENTITY__ListPage />,
|
|
778
|
+
handle: {
|
|
779
|
+
menu: {
|
|
780
|
+
labelKey: '__MODULE__:menu.__RESOURCE__',
|
|
781
|
+
icon: <AppstoreOutlined />,
|
|
782
|
+
permission: '__MODULE__:__ENTITY_LOWER__:view',
|
|
783
|
+
},
|
|
784
|
+
breadcrumbKey: '__MODULE__:menu.__RESOURCE__',
|
|
785
|
+
permission: '__MODULE__:__ENTITY_LOWER__:view',
|
|
786
|
+
helpKey: '__MODULE__/__RESOURCE__',
|
|
787
|
+
},
|
|
788
|
+
},
|
|
789
|
+
].map((route) => ({
|
|
790
|
+
...route,
|
|
791
|
+
element: route.element ? withSuspense(route.element) : route.element,
|
|
792
|
+
}))
|
|
793
|
+
```
|
|
794
|
+
|
|
795
|
+
### 4. pages/__ENTITY__ListPage.tsx (列表页)
|
|
796
|
+
|
|
797
|
+
```tsx
|
|
798
|
+
import { useCallback, useEffect, useMemo, useState } from 'react'
|
|
799
|
+
import { App, Button, Card, Form, Input, Modal, Popconfirm, Select, Space, Table, Tag, Typography } from 'antd'
|
|
800
|
+
import type { ColumnsType } from 'antd/es/table'
|
|
801
|
+
import { useTranslation } from 'react-i18next'
|
|
802
|
+
import dayjs from 'dayjs'
|
|
803
|
+
import { list__ENTITY__s, create__ENTITY__, update__ENTITY__, delete__ENTITY__ } from '../services/api'
|
|
804
|
+
import type { __ENTITY__, __ENTITY__Status } from '../types'
|
|
805
|
+
|
|
806
|
+
type FormValues = {
|
|
807
|
+
name: string
|
|
808
|
+
description?: string
|
|
809
|
+
status: __ENTITY__Status
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
const statusColors: Record<__ENTITY__Status, string> = {
|
|
813
|
+
active: 'success',
|
|
814
|
+
inactive: 'default',
|
|
517
815
|
}
|
|
518
816
|
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
817
|
+
export function __ENTITY__ListPage() {
|
|
818
|
+
const { t } = useTranslation('__MODULE__')
|
|
819
|
+
const { t: tc } = useTranslation('common')
|
|
820
|
+
const { message } = App.useApp()
|
|
821
|
+
const [items, setItems] = useState<__ENTITY__[]>([])
|
|
822
|
+
const [loading, setLoading] = useState(false)
|
|
823
|
+
const [modalOpen, setModalOpen] = useState(false)
|
|
824
|
+
const [saving, setSaving] = useState(false)
|
|
825
|
+
const [editingItem, setEditingItem] = useState<__ENTITY__ | null>(null)
|
|
826
|
+
const [form] = Form.useForm<FormValues>()
|
|
827
|
+
|
|
828
|
+
const statusOptions = useMemo(
|
|
829
|
+
() => [
|
|
830
|
+
{ value: 'active', label: t('status.active') },
|
|
831
|
+
{ value: 'inactive', label: t('status.inactive') },
|
|
832
|
+
],
|
|
833
|
+
[t]
|
|
834
|
+
)
|
|
835
|
+
|
|
836
|
+
const fetchItems = useCallback(async () => {
|
|
837
|
+
setLoading(true)
|
|
838
|
+
try {
|
|
839
|
+
const data = await list__ENTITY__s()
|
|
840
|
+
setItems(data)
|
|
841
|
+
} catch (err) {
|
|
842
|
+
const detail = err instanceof Error ? err.message : t('messages.loadFailed')
|
|
843
|
+
message.error(detail)
|
|
844
|
+
} finally {
|
|
845
|
+
setLoading(false)
|
|
846
|
+
}
|
|
847
|
+
}, [message, t])
|
|
848
|
+
|
|
849
|
+
useEffect(() => {
|
|
850
|
+
void fetchItems()
|
|
851
|
+
}, [fetchItems])
|
|
852
|
+
|
|
853
|
+
const openCreate = useCallback(() => {
|
|
854
|
+
setEditingItem(null)
|
|
855
|
+
setModalOpen(true)
|
|
856
|
+
}, [])
|
|
857
|
+
|
|
858
|
+
const openEdit = useCallback((item: __ENTITY__) => {
|
|
859
|
+
setEditingItem(item)
|
|
860
|
+
setModalOpen(true)
|
|
861
|
+
}, [])
|
|
862
|
+
|
|
863
|
+
const closeModal = useCallback(() => {
|
|
864
|
+
setModalOpen(false)
|
|
865
|
+
setEditingItem(null)
|
|
866
|
+
}, [])
|
|
867
|
+
|
|
868
|
+
useEffect(() => {
|
|
869
|
+
if (!modalOpen) return
|
|
870
|
+
form.resetFields()
|
|
871
|
+
if (editingItem) {
|
|
872
|
+
form.setFieldsValue({
|
|
873
|
+
name: editingItem.name,
|
|
874
|
+
description: editingItem.description,
|
|
875
|
+
status: editingItem.status,
|
|
876
|
+
})
|
|
877
|
+
} else {
|
|
878
|
+
form.setFieldsValue({ status: 'active' })
|
|
879
|
+
}
|
|
880
|
+
}, [editingItem, form, modalOpen])
|
|
881
|
+
|
|
882
|
+
const handleSubmit = useCallback(async () => {
|
|
883
|
+
let values: FormValues
|
|
884
|
+
try {
|
|
885
|
+
values = await form.validateFields()
|
|
886
|
+
} catch {
|
|
887
|
+
return
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
const payload = {
|
|
891
|
+
name: values.name.trim(),
|
|
892
|
+
description: values.description?.trim() ?? '',
|
|
893
|
+
status: values.status,
|
|
522
894
|
}
|
|
523
895
|
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
896
|
+
setSaving(true)
|
|
897
|
+
try {
|
|
898
|
+
if (editingItem) {
|
|
899
|
+
await update__ENTITY__(editingItem.id, payload)
|
|
900
|
+
message.success(t('messages.updateSuccess'))
|
|
901
|
+
} else {
|
|
902
|
+
await create__ENTITY__(payload)
|
|
903
|
+
message.success(t('messages.createSuccess'))
|
|
904
|
+
}
|
|
905
|
+
closeModal()
|
|
906
|
+
await fetchItems()
|
|
907
|
+
} catch (err) {
|
|
908
|
+
const detail = err instanceof Error ? err.message : tc('messages.operationFailed')
|
|
909
|
+
message.error(detail)
|
|
910
|
+
} finally {
|
|
911
|
+
setSaving(false)
|
|
527
912
|
}
|
|
913
|
+
}, [closeModal, editingItem, fetchItems, form, message, t, tc])
|
|
914
|
+
|
|
915
|
+
const handleDelete = useCallback(
|
|
916
|
+
async (id: number) => {
|
|
917
|
+
try {
|
|
918
|
+
await delete__ENTITY__(id)
|
|
919
|
+
await fetchItems()
|
|
920
|
+
message.success(t('messages.deleteSuccess'))
|
|
921
|
+
} catch (err) {
|
|
922
|
+
const detail = err instanceof Error ? err.message : tc('messages.operationFailed')
|
|
923
|
+
message.error(detail)
|
|
924
|
+
}
|
|
925
|
+
},
|
|
926
|
+
[fetchItems, message, t, tc]
|
|
927
|
+
)
|
|
928
|
+
|
|
929
|
+
const columns: ColumnsType<__ENTITY__> = useMemo(
|
|
930
|
+
() => [
|
|
931
|
+
{ title: t('table.name'), dataIndex: 'name', key: 'name' },
|
|
932
|
+
{
|
|
933
|
+
title: t('table.description'),
|
|
934
|
+
dataIndex: 'description',
|
|
935
|
+
key: 'description',
|
|
936
|
+
render: (value: string) =>
|
|
937
|
+
value ? <Typography.Text type="secondary">{value}</Typography.Text> : '-',
|
|
938
|
+
},
|
|
939
|
+
{
|
|
940
|
+
title: t('table.status'),
|
|
941
|
+
dataIndex: 'status',
|
|
942
|
+
key: 'status',
|
|
943
|
+
render: (value: __ENTITY__Status) => (
|
|
944
|
+
<Tag color={statusColors[value]}>{t(`status.${value}`)}</Tag>
|
|
945
|
+
),
|
|
946
|
+
},
|
|
947
|
+
{
|
|
948
|
+
title: tc('table.updatedAt'),
|
|
949
|
+
dataIndex: 'updated_at',
|
|
950
|
+
key: 'updated_at',
|
|
951
|
+
render: (value: string) => (value ? dayjs(value).format('YYYY-MM-DD HH:mm') : '-'),
|
|
952
|
+
},
|
|
953
|
+
{
|
|
954
|
+
title: tc('table.actions'),
|
|
955
|
+
key: 'actions',
|
|
956
|
+
render: (_, record) => (
|
|
957
|
+
<Space>
|
|
958
|
+
<Button type="link" onClick={() => openEdit(record)}>
|
|
959
|
+
{tc('actions.edit')}
|
|
960
|
+
</Button>
|
|
961
|
+
<Popconfirm title={tc('confirm.deleteContent')} onConfirm={() => handleDelete(record.id)}>
|
|
962
|
+
<Button type="link" danger>
|
|
963
|
+
{tc('actions.delete')}
|
|
964
|
+
</Button>
|
|
965
|
+
</Popconfirm>
|
|
966
|
+
</Space>
|
|
967
|
+
),
|
|
968
|
+
},
|
|
969
|
+
],
|
|
970
|
+
[handleDelete, openEdit, t, tc]
|
|
971
|
+
)
|
|
972
|
+
|
|
973
|
+
return (
|
|
974
|
+
<Card
|
|
975
|
+
title={t('page.title')}
|
|
976
|
+
extra={
|
|
977
|
+
<Space>
|
|
978
|
+
<Button onClick={fetchItems} loading={loading}>
|
|
979
|
+
{tc('actions.refresh')}
|
|
980
|
+
</Button>
|
|
981
|
+
<Button type="primary" onClick={openCreate}>
|
|
982
|
+
{t('page.createButton')}
|
|
983
|
+
</Button>
|
|
984
|
+
</Space>
|
|
985
|
+
}
|
|
986
|
+
>
|
|
987
|
+
<Table<__ENTITY__>
|
|
988
|
+
rowKey="id"
|
|
989
|
+
loading={loading}
|
|
990
|
+
columns={columns}
|
|
991
|
+
dataSource={items}
|
|
992
|
+
pagination={false}
|
|
993
|
+
/>
|
|
994
|
+
|
|
995
|
+
<Modal
|
|
996
|
+
title={editingItem ? tc('actions.edit') : tc('actions.create')}
|
|
997
|
+
open={modalOpen}
|
|
998
|
+
onCancel={closeModal}
|
|
999
|
+
onOk={handleSubmit}
|
|
1000
|
+
confirmLoading={saving}
|
|
1001
|
+
okText={editingItem ? tc('actions.save') : tc('actions.create')}
|
|
1002
|
+
destroyOnHidden
|
|
1003
|
+
>
|
|
1004
|
+
<Form form={form} layout="vertical" initialValues={{ status: 'active' }}>
|
|
1005
|
+
<Form.Item
|
|
1006
|
+
label={t('form.nameLabel')}
|
|
1007
|
+
name="name"
|
|
1008
|
+
rules={[{ required: true, whitespace: true, message: tc('form.required') }]}
|
|
1009
|
+
>
|
|
1010
|
+
<Input placeholder={t('form.namePlaceholder')} allowClear />
|
|
1011
|
+
</Form.Item>
|
|
1012
|
+
<Form.Item label={t('form.descriptionLabel')} name="description">
|
|
1013
|
+
<Input.TextArea rows={3} placeholder={t('form.descriptionPlaceholder')} />
|
|
1014
|
+
</Form.Item>
|
|
1015
|
+
<Form.Item
|
|
1016
|
+
label={t('table.status')}
|
|
1017
|
+
name="status"
|
|
1018
|
+
rules={[{ required: true, message: tc('form.required') }]}
|
|
1019
|
+
>
|
|
1020
|
+
<Select options={statusOptions} />
|
|
1021
|
+
</Form.Item>
|
|
1022
|
+
</Form>
|
|
1023
|
+
</Modal>
|
|
1024
|
+
</Card>
|
|
1025
|
+
)
|
|
1026
|
+
}
|
|
1027
|
+
```
|
|
1028
|
+
|
|
1029
|
+
### 5. index.ts (模块注册)
|
|
1030
|
+
|
|
1031
|
+
```typescript
|
|
1032
|
+
import { registerRoutes } from '@robsun/keystone-web-core'
|
|
1033
|
+
import { __MODULE__Routes } from './routes'
|
|
1034
|
+
|
|
1035
|
+
// 注册翻译
|
|
1036
|
+
import './locales/zh-CN/__MODULE__.json'
|
|
1037
|
+
import './locales/en-US/__MODULE__.json'
|
|
1038
|
+
|
|
1039
|
+
// 注册路由
|
|
1040
|
+
registerRoutes(__MODULE__Routes)
|
|
1041
|
+
```
|
|
528
1042
|
|
|
529
|
-
|
|
530
|
-
|
|
1043
|
+
### 6. locales/zh-CN/__MODULE__.json
|
|
1044
|
+
|
|
1045
|
+
```json
|
|
1046
|
+
{
|
|
1047
|
+
"menu": {
|
|
1048
|
+
"__RESOURCE__": "__MODULE_TITLE__管理"
|
|
1049
|
+
},
|
|
1050
|
+
"page": {
|
|
1051
|
+
"title": "__MODULE_TITLE__管理",
|
|
1052
|
+
"createButton": "新建__MODULE_TITLE__"
|
|
1053
|
+
},
|
|
1054
|
+
"table": {
|
|
1055
|
+
"name": "名称",
|
|
1056
|
+
"description": "描述",
|
|
1057
|
+
"status": "状态"
|
|
1058
|
+
},
|
|
1059
|
+
"form": {
|
|
1060
|
+
"nameLabel": "名称",
|
|
1061
|
+
"namePlaceholder": "请输入名称",
|
|
1062
|
+
"descriptionLabel": "描述",
|
|
1063
|
+
"descriptionPlaceholder": "请输入描述"
|
|
1064
|
+
},
|
|
1065
|
+
"status": {
|
|
1066
|
+
"active": "启用",
|
|
1067
|
+
"inactive": "禁用"
|
|
1068
|
+
},
|
|
1069
|
+
"messages": {
|
|
1070
|
+
"loadFailed": "加载数据失败",
|
|
1071
|
+
"createSuccess": "创建成功",
|
|
1072
|
+
"updateSuccess": "更新成功",
|
|
1073
|
+
"deleteSuccess": "删除成功"
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
```
|
|
1077
|
+
|
|
1078
|
+
### 7. locales/en-US/__MODULE__.json
|
|
1079
|
+
|
|
1080
|
+
```json
|
|
1081
|
+
{
|
|
1082
|
+
"menu": {
|
|
1083
|
+
"__RESOURCE__": "__MODULE_TITLE__ Management"
|
|
1084
|
+
},
|
|
1085
|
+
"page": {
|
|
1086
|
+
"title": "__MODULE_TITLE__ Management",
|
|
1087
|
+
"createButton": "Create __MODULE_TITLE__"
|
|
1088
|
+
},
|
|
1089
|
+
"table": {
|
|
1090
|
+
"name": "Name",
|
|
1091
|
+
"description": "Description",
|
|
1092
|
+
"status": "Status"
|
|
1093
|
+
},
|
|
1094
|
+
"form": {
|
|
1095
|
+
"nameLabel": "Name",
|
|
1096
|
+
"namePlaceholder": "Enter name",
|
|
1097
|
+
"descriptionLabel": "Description",
|
|
1098
|
+
"descriptionPlaceholder": "Enter description"
|
|
1099
|
+
},
|
|
1100
|
+
"status": {
|
|
1101
|
+
"active": "Active",
|
|
1102
|
+
"inactive": "Inactive"
|
|
1103
|
+
},
|
|
1104
|
+
"messages": {
|
|
1105
|
+
"loadFailed": "Failed to load data",
|
|
1106
|
+
"createSuccess": "Created successfully",
|
|
1107
|
+
"updateSuccess": "Updated successfully",
|
|
1108
|
+
"deleteSuccess": "Deleted successfully"
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1111
|
+
```
|
|
1112
|
+
|
|
1113
|
+
---
|
|
1114
|
+
|
|
1115
|
+
## Ant Design v6 注意事项
|
|
1116
|
+
|
|
1117
|
+
- `Space` 使用 `direction`(v6 已修复),不要用 `orientation`
|
|
1118
|
+
- `Modal` 使用 `destroyOnHidden`,不要用 `destroyOnClose`
|
|
1119
|
+
- `Drawer` 使用 `size`,不要用 `width`
|
|
1120
|
+
- `Table` 的 `columns` 使用 `ColumnsType<T>` 类型
|
|
1121
|
+
|
|
1122
|
+
---
|
|
1123
|
+
|
|
1124
|
+
# 高级业务模板
|
|
1125
|
+
|
|
1126
|
+
> 以下模板覆盖复杂业务场景,基于实际项目经验提炼。
|
|
1127
|
+
|
|
1128
|
+
---
|
|
1129
|
+
|
|
1130
|
+
## 多实体关联模板
|
|
1131
|
+
|
|
1132
|
+
> 适用场景:订单-订单项、文章-评论、商品-SKU 等主从关系。
|
|
1133
|
+
|
|
1134
|
+
### 占位符说明(扩展)
|
|
1135
|
+
|
|
1136
|
+
| 占位符 | 含义 | 示例 |
|
|
1137
|
+
|--------|------|------|
|
|
1138
|
+
| `__MASTER__` | 主实体名 (PascalCase) | `Order` |
|
|
1139
|
+
| `__MASTER_LOWER__` | 主实体名 (小写) | `order` |
|
|
1140
|
+
| `__DETAIL__` | 明细实体名 (PascalCase) | `OrderItem` |
|
|
1141
|
+
| `__DETAIL_LOWER__` | 明细实体名 (小写) | `orderitem` |
|
|
1142
|
+
|
|
1143
|
+
### 1. 主实体模型 (domain/models/master.go)
|
|
1144
|
+
|
|
1145
|
+
```go
|
|
1146
|
+
package models
|
|
1147
|
+
|
|
1148
|
+
import "github.com/robsuncn/keystone/domain/models"
|
|
1149
|
+
|
|
1150
|
+
type __MASTER__Status string
|
|
1151
|
+
|
|
1152
|
+
const (
|
|
1153
|
+
Status__MASTER__Draft __MASTER__Status = "draft"
|
|
1154
|
+
Status__MASTER__Submitted __MASTER__Status = "submitted"
|
|
1155
|
+
Status__MASTER__Approved __MASTER__Status = "approved"
|
|
1156
|
+
Status__MASTER__Rejected __MASTER__Status = "rejected"
|
|
1157
|
+
)
|
|
1158
|
+
|
|
1159
|
+
func (s __MASTER__Status) IsValid() bool {
|
|
1160
|
+
switch s {
|
|
1161
|
+
case Status__MASTER__Draft, Status__MASTER__Submitted,
|
|
1162
|
+
Status__MASTER__Approved, Status__MASTER__Rejected:
|
|
1163
|
+
return true
|
|
1164
|
+
default:
|
|
1165
|
+
return false
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
type __MASTER__ struct {
|
|
1170
|
+
models.BaseModel
|
|
1171
|
+
Code string `gorm:"size:50;not null;uniqueIndex:idx___MASTER_LOWER___tenant_code" json:"code"`
|
|
1172
|
+
Title string `gorm:"size:200;not null" json:"title"`
|
|
1173
|
+
Status __MASTER__Status `gorm:"size:20;not null;default:'draft'" json:"status"`
|
|
1174
|
+
TotalAmount float64 `gorm:"type:decimal(18,2);default:0" json:"total_amount"`
|
|
1175
|
+
Remark string `gorm:"size:500" json:"remark"`
|
|
1176
|
+
|
|
1177
|
+
// 关联
|
|
1178
|
+
Items []__DETAIL__ `gorm:"foreignKey:__MASTER__ID" json:"items,omitempty"`
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
func (__MASTER__) TableName() string {
|
|
1182
|
+
return "__MODULE_____MASTER_LOWER__s"
|
|
1183
|
+
}
|
|
1184
|
+
```
|
|
1185
|
+
|
|
1186
|
+
### 2. 明细实体模型 (domain/models/detail.go)
|
|
1187
|
+
|
|
1188
|
+
```go
|
|
1189
|
+
package models
|
|
1190
|
+
|
|
1191
|
+
import "github.com/robsuncn/keystone/domain/models"
|
|
1192
|
+
|
|
1193
|
+
type __DETAIL__ struct {
|
|
1194
|
+
models.BaseModel
|
|
1195
|
+
__MASTER__ID uint `gorm:"not null;index" json:"__MASTER_LOWER___id"`
|
|
1196
|
+
ProductName string `gorm:"size:200;not null" json:"product_name"`
|
|
1197
|
+
Quantity int `gorm:"not null;default:1" json:"quantity"`
|
|
1198
|
+
UnitPrice float64 `gorm:"type:decimal(18,2);not null" json:"unit_price"`
|
|
1199
|
+
Amount float64 `gorm:"type:decimal(18,2);not null" json:"amount"`
|
|
1200
|
+
SortOrder int `gorm:"default:0" json:"sort_order"`
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
func (__DETAIL__) TableName() string {
|
|
1204
|
+
return "__MODULE_____DETAIL_LOWER__s"
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
// 计算金额
|
|
1208
|
+
func (d *__DETAIL__) CalcAmount() {
|
|
1209
|
+
d.Amount = float64(d.Quantity) * d.UnitPrice
|
|
1210
|
+
}
|
|
1211
|
+
```
|
|
1212
|
+
|
|
1213
|
+
### 3. Service 带事务的创建 (domain/service/service.go)
|
|
1214
|
+
|
|
1215
|
+
```go
|
|
1216
|
+
package service
|
|
1217
|
+
|
|
1218
|
+
import (
|
|
1219
|
+
"context"
|
|
1220
|
+
"fmt"
|
|
1221
|
+
"strings"
|
|
1222
|
+
"time"
|
|
1223
|
+
|
|
1224
|
+
"gorm.io/gorm"
|
|
1225
|
+
|
|
1226
|
+
"__APP_NAME__/apps/server/internal/modules/__MODULE__/domain/models"
|
|
1227
|
+
)
|
|
1228
|
+
|
|
1229
|
+
type __MASTER__Service struct {
|
|
1230
|
+
db *gorm.DB
|
|
1231
|
+
repo __MASTER__Repository
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
type Create__MASTER__Input struct {
|
|
1235
|
+
Title string
|
|
1236
|
+
Remark string
|
|
1237
|
+
Items []Create__DETAIL__Input
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
type Create__DETAIL__Input struct {
|
|
1241
|
+
ProductName string
|
|
1242
|
+
Quantity int
|
|
1243
|
+
UnitPrice float64
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
func (s *__MASTER__Service) Create(
|
|
1247
|
+
ctx context.Context,
|
|
1248
|
+
tenantID uint,
|
|
1249
|
+
input Create__MASTER__Input,
|
|
1250
|
+
) (*models.__MASTER__, error) {
|
|
1251
|
+
// 验证
|
|
1252
|
+
if strings.TrimSpace(input.Title) == "" {
|
|
1253
|
+
return nil, ErrTitleRequired
|
|
1254
|
+
}
|
|
1255
|
+
if len(input.Items) == 0 {
|
|
1256
|
+
return nil, ErrItemsRequired
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
var result *models.__MASTER__
|
|
1260
|
+
|
|
1261
|
+
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
1262
|
+
// 生成编号
|
|
1263
|
+
code := fmt.Sprintf("ORD%s%04d", time.Now().Format("20060102"), time.Now().UnixNano()%10000)
|
|
1264
|
+
|
|
1265
|
+
// 创建主实体
|
|
1266
|
+
master := &models.__MASTER__{
|
|
1267
|
+
Code: code,
|
|
1268
|
+
Title: strings.TrimSpace(input.Title),
|
|
1269
|
+
Status: models.Status__MASTER__Draft,
|
|
1270
|
+
Remark: strings.TrimSpace(input.Remark),
|
|
1271
|
+
}
|
|
1272
|
+
master.TenantID = tenantID
|
|
1273
|
+
|
|
1274
|
+
if err := tx.Create(master).Error; err != nil {
|
|
1275
|
+
return err
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
// 创建明细并计算总金额
|
|
1279
|
+
var totalAmount float64
|
|
1280
|
+
for i, itemInput := range input.Items {
|
|
1281
|
+
item := &models.__DETAIL__{
|
|
1282
|
+
__MASTER__ID: master.ID,
|
|
1283
|
+
ProductName: strings.TrimSpace(itemInput.ProductName),
|
|
1284
|
+
Quantity: itemInput.Quantity,
|
|
1285
|
+
UnitPrice: itemInput.UnitPrice,
|
|
1286
|
+
SortOrder: i + 1,
|
|
1287
|
+
}
|
|
1288
|
+
item.TenantID = tenantID
|
|
1289
|
+
item.CalcAmount()
|
|
1290
|
+
totalAmount += item.Amount
|
|
1291
|
+
|
|
1292
|
+
if err := tx.Create(item).Error; err != nil {
|
|
1293
|
+
return err
|
|
1294
|
+
}
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
// 更新主表总金额
|
|
1298
|
+
master.TotalAmount = totalAmount
|
|
1299
|
+
if err := tx.Save(master).Error; err != nil {
|
|
1300
|
+
return err
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1303
|
+
result = master
|
|
1304
|
+
return nil
|
|
1305
|
+
})
|
|
1306
|
+
|
|
1307
|
+
if err != nil {
|
|
1308
|
+
return nil, err
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
// 重新加载关联
|
|
1312
|
+
return s.repo.FindByIDWithItems(tenantID, result.ID)
|
|
1313
|
+
}
|
|
1314
|
+
```
|
|
1315
|
+
|
|
1316
|
+
### 4. Repository 预加载查询 (infra/repository/repository.go)
|
|
1317
|
+
|
|
1318
|
+
```go
|
|
1319
|
+
package repository
|
|
1320
|
+
|
|
1321
|
+
import (
|
|
1322
|
+
"context"
|
|
1323
|
+
"errors"
|
|
1324
|
+
|
|
1325
|
+
"gorm.io/gorm"
|
|
1326
|
+
|
|
1327
|
+
"github.com/robsuncn/keystone/infra/pagination"
|
|
1328
|
+
"__APP_NAME__/apps/server/internal/modules/__MODULE__/domain/models"
|
|
1329
|
+
"__APP_NAME__/apps/server/internal/modules/__MODULE__/domain/service"
|
|
1330
|
+
)
|
|
1331
|
+
|
|
1332
|
+
type __MASTER__Repository struct {
|
|
1333
|
+
db *gorm.DB
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
func New__MASTER__Repository(db *gorm.DB) *__MASTER__Repository {
|
|
1337
|
+
return &__MASTER__Repository{db: db}
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1340
|
+
// 列表查询(不加载明细)
|
|
1341
|
+
func (r *__MASTER__Repository) List(
|
|
1342
|
+
ctx context.Context,
|
|
1343
|
+
tenantID uint,
|
|
1344
|
+
filter service.ListFilter,
|
|
1345
|
+
pageReq pagination.Request,
|
|
1346
|
+
) ([]models.__MASTER__, int64, error) {
|
|
1347
|
+
query := r.db.WithContext(ctx).
|
|
1348
|
+
Model(&models.__MASTER__{}).
|
|
1349
|
+
Where("tenant_id = ?", tenantID)
|
|
1350
|
+
|
|
1351
|
+
// 应用过滤
|
|
1352
|
+
if filter.Status != nil {
|
|
1353
|
+
query = query.Where("status = ?", *filter.Status)
|
|
1354
|
+
}
|
|
1355
|
+
if filter.Keyword != nil && *filter.Keyword != "" {
|
|
1356
|
+
keyword := "%" + *filter.Keyword + "%"
|
|
1357
|
+
query = query.Where("code LIKE ? OR title LIKE ?", keyword, keyword)
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1360
|
+
var items []models.__MASTER__
|
|
1361
|
+
total, err := pagination.Paginate(query.Order("created_at desc"), pageReq, &items)
|
|
1362
|
+
return items, total, err
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
// 单个查询(预加载明细)
|
|
1366
|
+
func (r *__MASTER__Repository) FindByIDWithItems(tenantID, id uint) (*models.__MASTER__, error) {
|
|
1367
|
+
var entity models.__MASTER__
|
|
1368
|
+
err := r.db.
|
|
1369
|
+
Preload("Items", func(db *gorm.DB) *gorm.DB {
|
|
1370
|
+
return db.Order("sort_order asc")
|
|
1371
|
+
}).
|
|
1372
|
+
Where("tenant_id = ? AND id = ?", tenantID, id).
|
|
1373
|
+
First(&entity).Error
|
|
1374
|
+
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
1375
|
+
return nil, service.Err__MASTER__NotFound
|
|
1376
|
+
}
|
|
1377
|
+
return &entity, err
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
// 删除主实体(级联删除明细)
|
|
1381
|
+
func (r *__MASTER__Repository) DeleteWithItems(ctx context.Context, tenantID, id uint) error {
|
|
1382
|
+
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
1383
|
+
// 先删明细
|
|
1384
|
+
if err := tx.Where("__MASTER_LOWER___id = ?", id).Delete(&models.__DETAIL__{}).Error; err != nil {
|
|
1385
|
+
return err
|
|
1386
|
+
}
|
|
1387
|
+
// 再删主表
|
|
1388
|
+
result := tx.Where("tenant_id = ? AND id = ?", tenantID, id).Delete(&models.__MASTER__{})
|
|
1389
|
+
if result.RowsAffected == 0 {
|
|
1390
|
+
return service.Err__MASTER__NotFound
|
|
1391
|
+
}
|
|
1392
|
+
return result.Error
|
|
1393
|
+
})
|
|
1394
|
+
}
|
|
1395
|
+
```
|
|
1396
|
+
|
|
1397
|
+
### 5. 前端类型定义 (types.ts)
|
|
1398
|
+
|
|
1399
|
+
```typescript
|
|
1400
|
+
export type __MASTER__Status = 'draft' | 'submitted' | 'approved' | 'rejected'
|
|
1401
|
+
|
|
1402
|
+
export interface __DETAIL__ {
|
|
1403
|
+
id: number
|
|
1404
|
+
__MASTER_LOWER___id: number
|
|
1405
|
+
product_name: string
|
|
1406
|
+
quantity: number
|
|
1407
|
+
unit_price: number
|
|
1408
|
+
amount: number
|
|
1409
|
+
sort_order: number
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1412
|
+
export interface __MASTER__ {
|
|
1413
|
+
id: number
|
|
1414
|
+
code: string
|
|
1415
|
+
title: string
|
|
1416
|
+
status: __MASTER__Status
|
|
1417
|
+
total_amount: number
|
|
1418
|
+
remark: string
|
|
1419
|
+
items?: __DETAIL__[]
|
|
1420
|
+
created_at: string
|
|
1421
|
+
updated_at: string
|
|
1422
|
+
}
|
|
1423
|
+
|
|
1424
|
+
export interface Create__MASTER__Input {
|
|
1425
|
+
title: string
|
|
1426
|
+
remark?: string
|
|
1427
|
+
items: {
|
|
1428
|
+
product_name: string
|
|
1429
|
+
quantity: number
|
|
1430
|
+
unit_price: number
|
|
1431
|
+
}[]
|
|
1432
|
+
}
|
|
1433
|
+
```
|
|
1434
|
+
|
|
1435
|
+
### 6. 前端主从表单组件 (components/__MASTER__Form.tsx)
|
|
1436
|
+
|
|
1437
|
+
```tsx
|
|
1438
|
+
import { useCallback, useEffect } from 'react'
|
|
1439
|
+
import { Button, Form, Input, InputNumber, Space, Table } from 'antd'
|
|
1440
|
+
import { DeleteOutlined, PlusOutlined } from '@ant-design/icons'
|
|
1441
|
+
import type { ColumnsType } from 'antd/es/table'
|
|
1442
|
+
import { useTranslation } from 'react-i18next'
|
|
1443
|
+
|
|
1444
|
+
interface ItemRow {
|
|
1445
|
+
key: string
|
|
1446
|
+
product_name: string
|
|
1447
|
+
quantity: number
|
|
1448
|
+
unit_price: number
|
|
1449
|
+
amount: number
|
|
1450
|
+
}
|
|
1451
|
+
|
|
1452
|
+
interface FormValues {
|
|
1453
|
+
title: string
|
|
1454
|
+
remark?: string
|
|
1455
|
+
items: ItemRow[]
|
|
1456
|
+
}
|
|
1457
|
+
|
|
1458
|
+
interface Props {
|
|
1459
|
+
initialValues?: Partial<FormValues>
|
|
1460
|
+
onSubmit: (values: FormValues) => Promise<void>
|
|
1461
|
+
loading?: boolean
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1464
|
+
export function __MASTER__Form({ initialValues, onSubmit, loading }: Props) {
|
|
1465
|
+
const { t } = useTranslation('__MODULE__')
|
|
1466
|
+
const { t: tc } = useTranslation('common')
|
|
1467
|
+
const [form] = Form.useForm<FormValues>()
|
|
1468
|
+
const items = Form.useWatch('items', form) || []
|
|
1469
|
+
|
|
1470
|
+
useEffect(() => {
|
|
1471
|
+
if (initialValues) {
|
|
1472
|
+
form.setFieldsValue(initialValues)
|
|
1473
|
+
} else {
|
|
1474
|
+
form.setFieldsValue({
|
|
1475
|
+
items: [{ key: '1', product_name: '', quantity: 1, unit_price: 0, amount: 0 }],
|
|
1476
|
+
})
|
|
1477
|
+
}
|
|
1478
|
+
}, [form, initialValues])
|
|
1479
|
+
|
|
1480
|
+
const addItem = useCallback(() => {
|
|
1481
|
+
const currentItems = form.getFieldValue('items') || []
|
|
1482
|
+
form.setFieldsValue({
|
|
1483
|
+
items: [
|
|
1484
|
+
...currentItems,
|
|
1485
|
+
{ key: String(Date.now()), product_name: '', quantity: 1, unit_price: 0, amount: 0 },
|
|
1486
|
+
],
|
|
1487
|
+
})
|
|
1488
|
+
}, [form])
|
|
1489
|
+
|
|
1490
|
+
const removeItem = useCallback(
|
|
1491
|
+
(key: string) => {
|
|
1492
|
+
const currentItems = form.getFieldValue('items') || []
|
|
1493
|
+
form.setFieldsValue({
|
|
1494
|
+
items: currentItems.filter((item: ItemRow) => item.key !== key),
|
|
1495
|
+
})
|
|
1496
|
+
},
|
|
1497
|
+
[form]
|
|
1498
|
+
)
|
|
1499
|
+
|
|
1500
|
+
const updateAmount = useCallback(
|
|
1501
|
+
(key: string) => {
|
|
1502
|
+
const currentItems: ItemRow[] = form.getFieldValue('items') || []
|
|
1503
|
+
const updated = currentItems.map((item) => {
|
|
1504
|
+
if (item.key === key) {
|
|
1505
|
+
return { ...item, amount: item.quantity * item.unit_price }
|
|
1506
|
+
}
|
|
1507
|
+
return item
|
|
1508
|
+
})
|
|
1509
|
+
form.setFieldsValue({ items: updated })
|
|
1510
|
+
},
|
|
1511
|
+
[form]
|
|
1512
|
+
)
|
|
1513
|
+
|
|
1514
|
+
const totalAmount = items.reduce((sum: number, item: ItemRow) => sum + (item.amount || 0), 0)
|
|
1515
|
+
|
|
1516
|
+
const columns: ColumnsType<ItemRow> = [
|
|
1517
|
+
{
|
|
1518
|
+
title: t('form.productName'),
|
|
1519
|
+
dataIndex: 'product_name',
|
|
1520
|
+
render: (_, record, index) => (
|
|
1521
|
+
<Form.Item
|
|
1522
|
+
name={['items', index, 'product_name']}
|
|
1523
|
+
rules={[{ required: true, message: tc('form.required') }]}
|
|
1524
|
+
style={{ marginBottom: 0 }}
|
|
1525
|
+
>
|
|
1526
|
+
<Input placeholder={t('form.productNamePlaceholder')} />
|
|
1527
|
+
</Form.Item>
|
|
1528
|
+
),
|
|
1529
|
+
},
|
|
1530
|
+
{
|
|
1531
|
+
title: t('form.quantity'),
|
|
1532
|
+
dataIndex: 'quantity',
|
|
1533
|
+
width: 120,
|
|
1534
|
+
render: (_, record, index) => (
|
|
1535
|
+
<Form.Item name={['items', index, 'quantity']} style={{ marginBottom: 0 }}>
|
|
1536
|
+
<InputNumber
|
|
1537
|
+
min={1}
|
|
1538
|
+
onChange={() => updateAmount(record.key)}
|
|
1539
|
+
style={{ width: '100%' }}
|
|
1540
|
+
/>
|
|
1541
|
+
</Form.Item>
|
|
1542
|
+
),
|
|
1543
|
+
},
|
|
1544
|
+
{
|
|
1545
|
+
title: t('form.unitPrice'),
|
|
1546
|
+
dataIndex: 'unit_price',
|
|
1547
|
+
width: 140,
|
|
1548
|
+
render: (_, record, index) => (
|
|
1549
|
+
<Form.Item name={['items', index, 'unit_price']} style={{ marginBottom: 0 }}>
|
|
1550
|
+
<InputNumber
|
|
1551
|
+
min={0}
|
|
1552
|
+
precision={2}
|
|
1553
|
+
onChange={() => updateAmount(record.key)}
|
|
1554
|
+
style={{ width: '100%' }}
|
|
1555
|
+
/>
|
|
1556
|
+
</Form.Item>
|
|
1557
|
+
),
|
|
1558
|
+
},
|
|
1559
|
+
{
|
|
1560
|
+
title: t('form.amount'),
|
|
1561
|
+
dataIndex: 'amount',
|
|
1562
|
+
width: 120,
|
|
1563
|
+
render: (_, record, index) => (
|
|
1564
|
+
<Form.Item name={['items', index, 'amount']} style={{ marginBottom: 0 }}>
|
|
1565
|
+
<InputNumber disabled precision={2} style={{ width: '100%' }} />
|
|
1566
|
+
</Form.Item>
|
|
1567
|
+
),
|
|
1568
|
+
},
|
|
1569
|
+
{
|
|
1570
|
+
title: tc('table.actions'),
|
|
1571
|
+
width: 80,
|
|
1572
|
+
render: (_, record) => (
|
|
1573
|
+
<Button
|
|
1574
|
+
type="text"
|
|
1575
|
+
danger
|
|
1576
|
+
icon={<DeleteOutlined />}
|
|
1577
|
+
onClick={() => removeItem(record.key)}
|
|
1578
|
+
disabled={items.length <= 1}
|
|
1579
|
+
/>
|
|
1580
|
+
),
|
|
1581
|
+
},
|
|
1582
|
+
]
|
|
1583
|
+
|
|
1584
|
+
const handleSubmit = async () => {
|
|
1585
|
+
const values = await form.validateFields()
|
|
1586
|
+
await onSubmit(values)
|
|
1587
|
+
}
|
|
1588
|
+
|
|
1589
|
+
return (
|
|
1590
|
+
<Form form={form} layout="vertical">
|
|
1591
|
+
<Form.Item
|
|
1592
|
+
label={t('form.title')}
|
|
1593
|
+
name="title"
|
|
1594
|
+
rules={[{ required: true, whitespace: true, message: tc('form.required') }]}
|
|
1595
|
+
>
|
|
1596
|
+
<Input placeholder={t('form.titlePlaceholder')} />
|
|
1597
|
+
</Form.Item>
|
|
1598
|
+
|
|
1599
|
+
<Form.Item label={t('form.remark')} name="remark">
|
|
1600
|
+
<Input.TextArea rows={2} placeholder={t('form.remarkPlaceholder')} />
|
|
1601
|
+
</Form.Item>
|
|
1602
|
+
|
|
1603
|
+
<Form.Item label={t('form.items')} required>
|
|
1604
|
+
<Table
|
|
1605
|
+
rowKey="key"
|
|
1606
|
+
columns={columns}
|
|
1607
|
+
dataSource={items}
|
|
1608
|
+
pagination={false}
|
|
1609
|
+
size="small"
|
|
1610
|
+
footer={() => (
|
|
1611
|
+
<Space style={{ width: '100%', justifyContent: 'space-between' }}>
|
|
1612
|
+
<Button type="dashed" icon={<PlusOutlined />} onClick={addItem}>
|
|
1613
|
+
{t('form.addItem')}
|
|
1614
|
+
</Button>
|
|
1615
|
+
<span>
|
|
1616
|
+
{t('form.totalAmount')}: <strong>¥{totalAmount.toFixed(2)}</strong>
|
|
1617
|
+
</span>
|
|
1618
|
+
</Space>
|
|
1619
|
+
)}
|
|
1620
|
+
/>
|
|
1621
|
+
</Form.Item>
|
|
1622
|
+
|
|
1623
|
+
<Form.Item>
|
|
1624
|
+
<Button type="primary" onClick={handleSubmit} loading={loading}>
|
|
1625
|
+
{tc('actions.submit')}
|
|
1626
|
+
</Button>
|
|
1627
|
+
</Form.Item>
|
|
1628
|
+
</Form>
|
|
1629
|
+
)
|
|
1630
|
+
}
|
|
1631
|
+
```
|
|
1632
|
+
|
|
1633
|
+
---
|
|
1634
|
+
|
|
1635
|
+
## 审批流业务模板
|
|
1636
|
+
|
|
1637
|
+
> 适用场景:费用申请、请假审批、采购申请等需要审批的业务。
|
|
1638
|
+
|
|
1639
|
+
### 1. 模型添加审批字段 (domain/models/entity.go)
|
|
1640
|
+
|
|
1641
|
+
```go
|
|
1642
|
+
package models
|
|
1643
|
+
|
|
1644
|
+
import "github.com/robsuncn/keystone/domain/models"
|
|
1645
|
+
|
|
1646
|
+
type __ENTITY__Status string
|
|
1647
|
+
|
|
1648
|
+
const (
|
|
1649
|
+
Status__ENTITY__Draft __ENTITY__Status = "draft"
|
|
1650
|
+
Status__ENTITY__Pending __ENTITY__Status = "pending" // 审批中
|
|
1651
|
+
Status__ENTITY__Approved __ENTITY__Status = "approved"
|
|
1652
|
+
Status__ENTITY__Rejected __ENTITY__Status = "rejected"
|
|
1653
|
+
Status__ENTITY__Cancelled __ENTITY__Status = "cancelled"
|
|
1654
|
+
)
|
|
1655
|
+
|
|
1656
|
+
type __ENTITY__ struct {
|
|
1657
|
+
models.BaseModel
|
|
1658
|
+
Code string `gorm:"size:50;not null;uniqueIndex" json:"code"`
|
|
1659
|
+
Title string `gorm:"size:200;not null" json:"title"`
|
|
1660
|
+
Status __ENTITY__Status `gorm:"size:20;not null;default:'draft'" json:"status"`
|
|
1661
|
+
ApprovalInstanceID *uint `gorm:"index" json:"approval_instance_id,omitempty"`
|
|
1662
|
+
RejectReason string `gorm:"size:500" json:"reject_reason,omitempty"`
|
|
1663
|
+
// ... 其他业务字段
|
|
1664
|
+
}
|
|
1665
|
+
```
|
|
1666
|
+
|
|
1667
|
+
### 2. Service 提交审批 (domain/service/submit.go)
|
|
1668
|
+
|
|
1669
|
+
```go
|
|
1670
|
+
package service
|
|
1671
|
+
|
|
1672
|
+
import (
|
|
1673
|
+
"context"
|
|
1674
|
+
"fmt"
|
|
1675
|
+
|
|
1676
|
+
approval "github.com/robsuncn/keystone/domain/approval/service"
|
|
1677
|
+
"__APP_NAME__/apps/server/internal/modules/__MODULE__/domain/models"
|
|
1678
|
+
)
|
|
1679
|
+
|
|
1680
|
+
// 提交审批
|
|
1681
|
+
func (s *__ENTITY__Service) Submit(ctx context.Context, tenantID, id, userID uint) error {
|
|
1682
|
+
entity, err := s.repo.FindByID(tenantID, id)
|
|
1683
|
+
if err != nil {
|
|
1684
|
+
return err
|
|
1685
|
+
}
|
|
1686
|
+
|
|
1687
|
+
// 验证状态
|
|
1688
|
+
if entity.Status != models.Status__ENTITY__Draft {
|
|
1689
|
+
return ErrInvalidStatusForSubmit
|
|
1690
|
+
}
|
|
1691
|
+
|
|
1692
|
+
// 创建审批实例
|
|
1693
|
+
instance, err := s.approval.CreateInstance(ctx, approval.CreateInstanceInput{
|
|
1694
|
+
TenantID: tenantID,
|
|
1695
|
+
BusinessType: "__MODULE___approval", // 审批业务类型,需在审批模块预先配置
|
|
1696
|
+
BusinessID: id,
|
|
1697
|
+
ApplicantID: userID,
|
|
1698
|
+
Context: map[string]interface{}{
|
|
1699
|
+
"code": entity.Code,
|
|
1700
|
+
"title": entity.Title,
|
|
1701
|
+
// 其他审批表单需要展示的字段
|
|
1702
|
+
},
|
|
1703
|
+
})
|
|
1704
|
+
if err != nil {
|
|
1705
|
+
return fmt.Errorf("创建审批实例失败: %w", err)
|
|
1706
|
+
}
|
|
1707
|
+
|
|
1708
|
+
// 更新业务状态
|
|
1709
|
+
entity.Status = models.Status__ENTITY__Pending
|
|
1710
|
+
entity.ApprovalInstanceID = &instance.ID
|
|
1711
|
+
return s.repo.Update(ctx, entity)
|
|
1712
|
+
}
|
|
1713
|
+
|
|
1714
|
+
// 撤回审批
|
|
1715
|
+
func (s *__ENTITY__Service) Cancel(ctx context.Context, tenantID, id, userID uint) error {
|
|
1716
|
+
entity, err := s.repo.FindByID(tenantID, id)
|
|
1717
|
+
if err != nil {
|
|
1718
|
+
return err
|
|
1719
|
+
}
|
|
1720
|
+
|
|
1721
|
+
if entity.Status != models.Status__ENTITY__Pending {
|
|
1722
|
+
return ErrInvalidStatusForCancel
|
|
1723
|
+
}
|
|
1724
|
+
|
|
1725
|
+
if entity.ApprovalInstanceID == nil {
|
|
1726
|
+
return ErrNoApprovalInstance
|
|
1727
|
+
}
|
|
1728
|
+
|
|
1729
|
+
// 取消审批实例
|
|
1730
|
+
if err := s.approval.Cancel(ctx, *entity.ApprovalInstanceID, userID); err != nil {
|
|
1731
|
+
return err
|
|
1732
|
+
}
|
|
1733
|
+
|
|
1734
|
+
// 更新业务状态
|
|
1735
|
+
entity.Status = models.Status__ENTITY__Draft
|
|
1736
|
+
entity.ApprovalInstanceID = nil
|
|
1737
|
+
return s.repo.Update(ctx, entity)
|
|
1738
|
+
}
|
|
1739
|
+
```
|
|
1740
|
+
|
|
1741
|
+
### 3. 审批回调实现 (domain/service/callback.go)
|
|
1742
|
+
|
|
1743
|
+
```go
|
|
1744
|
+
package service
|
|
1745
|
+
|
|
1746
|
+
import (
|
|
1747
|
+
"context"
|
|
1748
|
+
"log/slog"
|
|
1749
|
+
|
|
1750
|
+
approval "github.com/robsuncn/keystone/domain/approval/service"
|
|
1751
|
+
"__APP_NAME__/apps/server/internal/modules/__MODULE__/domain/models"
|
|
1752
|
+
"__APP_NAME__/apps/server/internal/modules/__MODULE__/infra/repository"
|
|
1753
|
+
)
|
|
1754
|
+
|
|
1755
|
+
const ApprovalBusinessType = "__MODULE___approval"
|
|
1756
|
+
|
|
1757
|
+
// __ENTITY__ApprovalCallback 实现审批回调接口
|
|
1758
|
+
type __ENTITY__ApprovalCallback struct {
|
|
1759
|
+
repo *repository.__ENTITY__Repository
|
|
1760
|
+
}
|
|
1761
|
+
|
|
1762
|
+
func New__ENTITY__ApprovalCallback(repo *repository.__ENTITY__Repository) *__ENTITY__ApprovalCallback {
|
|
1763
|
+
return &__ENTITY__ApprovalCallback{repo: repo}
|
|
1764
|
+
}
|
|
1765
|
+
|
|
1766
|
+
// OnApproved 审批通过回调
|
|
1767
|
+
func (c *__ENTITY__ApprovalCallback) OnApproved(
|
|
1768
|
+
ctx context.Context,
|
|
1769
|
+
tenantID, businessID, approverID uint,
|
|
1770
|
+
) error {
|
|
1771
|
+
entity, err := c.repo.FindByID(tenantID, businessID)
|
|
1772
|
+
if err != nil {
|
|
1773
|
+
return err
|
|
1774
|
+
}
|
|
1775
|
+
|
|
1776
|
+
if entity.Status != models.Status__ENTITY__Pending {
|
|
1777
|
+
slog.Warn("审批回调:状态不匹配",
|
|
1778
|
+
"expected", models.Status__ENTITY__Pending,
|
|
1779
|
+
"actual", entity.Status,
|
|
1780
|
+
)
|
|
1781
|
+
return nil // 幂等处理
|
|
1782
|
+
}
|
|
1783
|
+
|
|
1784
|
+
entity.Status = models.Status__ENTITY__Approved
|
|
1785
|
+
return c.repo.Update(ctx, entity)
|
|
1786
|
+
}
|
|
1787
|
+
|
|
1788
|
+
// OnRejected 审批拒绝回调
|
|
1789
|
+
func (c *__ENTITY__ApprovalCallback) OnRejected(
|
|
1790
|
+
ctx context.Context,
|
|
1791
|
+
tenantID, businessID, approverID uint,
|
|
1792
|
+
reason string,
|
|
1793
|
+
) error {
|
|
1794
|
+
entity, err := c.repo.FindByID(tenantID, businessID)
|
|
1795
|
+
if err != nil {
|
|
1796
|
+
return err
|
|
1797
|
+
}
|
|
1798
|
+
|
|
1799
|
+
if entity.Status != models.Status__ENTITY__Pending {
|
|
1800
|
+
return nil // 幂等处理
|
|
1801
|
+
}
|
|
1802
|
+
|
|
1803
|
+
entity.Status = models.Status__ENTITY__Rejected
|
|
1804
|
+
entity.RejectReason = reason
|
|
1805
|
+
return c.repo.Update(ctx, entity)
|
|
1806
|
+
}
|
|
1807
|
+
```
|
|
1808
|
+
|
|
1809
|
+
### 4. Module 注册回调 (module.go)
|
|
1810
|
+
|
|
1811
|
+
```go
|
|
1812
|
+
package __MODULE__
|
|
1813
|
+
|
|
1814
|
+
import (
|
|
1815
|
+
approval "github.com/robsuncn/keystone/domain/approval/service"
|
|
1816
|
+
// ...
|
|
1817
|
+
)
|
|
1818
|
+
|
|
1819
|
+
// RegisterApprovalCallback 在应用启动时调用
|
|
1820
|
+
func (m *Module) RegisterApprovalCallback(registry *approval.CallbackRegistry) {
|
|
1821
|
+
callback := service.New__ENTITY__ApprovalCallback(m.repo)
|
|
1822
|
+
|
|
1823
|
+
// 包装为带重试的回调
|
|
1824
|
+
retryable := approval.NewRetryableCallback(callback, approval.RetryConfig{
|
|
1825
|
+
MaxRetries: 3,
|
|
1826
|
+
InitialBackoff: time.Second,
|
|
1827
|
+
MaxBackoff: 30 * time.Second,
|
|
1828
|
+
BackoffFactor: 2.0,
|
|
1829
|
+
})
|
|
1830
|
+
|
|
1831
|
+
registry.Register(service.ApprovalBusinessType, retryable)
|
|
1832
|
+
}
|
|
1833
|
+
```
|
|
1834
|
+
|
|
1835
|
+
### 5. Handler 审批操作 (api/handler/approval.go)
|
|
1836
|
+
|
|
1837
|
+
```go
|
|
1838
|
+
package handler
|
|
1839
|
+
|
|
1840
|
+
import (
|
|
1841
|
+
"github.com/gin-gonic/gin"
|
|
1842
|
+
hcommon "github.com/robsuncn/keystone/api/handler/common"
|
|
1843
|
+
"github.com/robsuncn/keystone/api/response"
|
|
1844
|
+
|
|
1845
|
+
modulei18n "__APP_NAME__/apps/server/internal/modules/__MODULE__/i18n"
|
|
1846
|
+
)
|
|
1847
|
+
|
|
1848
|
+
// Submit 提交审批
|
|
1849
|
+
func (h *__ENTITY__Handler) Submit(c *gin.Context) {
|
|
1850
|
+
if h == nil || h.svc == nil {
|
|
1851
|
+
response.ServiceUnavailableI18n(c, modulei18n.MsgServiceUnavailable)
|
|
1852
|
+
return
|
|
1853
|
+
}
|
|
1854
|
+
tenantID := resolveTenantID(c)
|
|
1855
|
+
userID, _ := hcommon.GetUserID(c)
|
|
1856
|
+
|
|
1857
|
+
id, err := hcommon.ParseUintParam(c, "id")
|
|
1858
|
+
if err != nil || id == 0 {
|
|
1859
|
+
response.BadRequestI18n(c, modulei18n.MsgInvalidID)
|
|
1860
|
+
return
|
|
1861
|
+
}
|
|
1862
|
+
|
|
1863
|
+
if err := h.svc.Submit(c.Request.Context(), tenantID, id, userID); err != nil {
|
|
1864
|
+
handleServiceError(c, err)
|
|
1865
|
+
return
|
|
1866
|
+
}
|
|
1867
|
+
|
|
1868
|
+
response.SuccessI18n(c, modulei18n.MsgSubmitted, nil)
|
|
1869
|
+
}
|
|
1870
|
+
|
|
1871
|
+
// Cancel 撤回审批
|
|
1872
|
+
func (h *__ENTITY__Handler) Cancel(c *gin.Context) {
|
|
1873
|
+
if h == nil || h.svc == nil {
|
|
1874
|
+
response.ServiceUnavailableI18n(c, modulei18n.MsgServiceUnavailable)
|
|
1875
|
+
return
|
|
1876
|
+
}
|
|
1877
|
+
tenantID := resolveTenantID(c)
|
|
1878
|
+
userID, _ := hcommon.GetUserID(c)
|
|
1879
|
+
|
|
1880
|
+
id, err := hcommon.ParseUintParam(c, "id")
|
|
1881
|
+
if err != nil || id == 0 {
|
|
1882
|
+
response.BadRequestI18n(c, modulei18n.MsgInvalidID)
|
|
1883
|
+
return
|
|
1884
|
+
}
|
|
1885
|
+
|
|
1886
|
+
if err := h.svc.Cancel(c.Request.Context(), tenantID, id, userID); err != nil {
|
|
1887
|
+
handleServiceError(c, err)
|
|
1888
|
+
return
|
|
1889
|
+
}
|
|
1890
|
+
|
|
1891
|
+
response.SuccessI18n(c, modulei18n.MsgCancelled, nil)
|
|
1892
|
+
}
|
|
1893
|
+
```
|
|
1894
|
+
|
|
1895
|
+
### 6. 路由注册审批操作
|
|
1896
|
+
|
|
1897
|
+
```go
|
|
1898
|
+
func (m *Module) RegisterRoutes(rg *gin.RouterGroup) {
|
|
1899
|
+
h := handler.New__ENTITY__Handler(m.svc)
|
|
1900
|
+
group := rg.Group("/__MODULE__")
|
|
1901
|
+
|
|
1902
|
+
// CRUD 路由
|
|
1903
|
+
group.GET("/__RESOURCE__", h.List)
|
|
1904
|
+
group.POST("/__RESOURCE__", h.Create)
|
|
1905
|
+
group.GET("/__RESOURCE__/:id", h.Get)
|
|
1906
|
+
group.PATCH("/__RESOURCE__/:id", h.Update)
|
|
1907
|
+
group.DELETE("/__RESOURCE__/:id", h.Delete)
|
|
1908
|
+
|
|
1909
|
+
// 审批操作路由
|
|
1910
|
+
group.POST("/__RESOURCE__/:id/submit", h.Submit) // 提交审批
|
|
1911
|
+
group.POST("/__RESOURCE__/:id/cancel", h.Cancel) // 撤回审批
|
|
1912
|
+
}
|
|
1913
|
+
```
|
|
1914
|
+
|
|
1915
|
+
### 7. 前端审批操作按钮 (components/ApprovalActions.tsx)
|
|
1916
|
+
|
|
1917
|
+
```tsx
|
|
1918
|
+
import { useCallback, useState } from 'react'
|
|
1919
|
+
import { App, Button, Popconfirm, Space, Tag } from 'antd'
|
|
1920
|
+
import { CheckCircleOutlined, CloseCircleOutlined, SendOutlined, UndoOutlined } from '@ant-design/icons'
|
|
1921
|
+
import { useTranslation } from 'react-i18next'
|
|
1922
|
+
import type { __ENTITY__Status } from '../types'
|
|
1923
|
+
|
|
1924
|
+
interface Props {
|
|
1925
|
+
id: number
|
|
1926
|
+
status: __ENTITY__Status
|
|
1927
|
+
onSubmit: (id: number) => Promise<void>
|
|
1928
|
+
onCancel: (id: number) => Promise<void>
|
|
1929
|
+
onRefresh: () => void
|
|
1930
|
+
}
|
|
1931
|
+
|
|
1932
|
+
const statusConfig: Record<__ENTITY__Status, { color: string; icon: React.ReactNode }> = {
|
|
1933
|
+
draft: { color: 'default', icon: null },
|
|
1934
|
+
pending: { color: 'processing', icon: <CloseCircleOutlined spin /> },
|
|
1935
|
+
approved: { color: 'success', icon: <CheckCircleOutlined /> },
|
|
1936
|
+
rejected: { color: 'error', icon: <CloseCircleOutlined /> },
|
|
1937
|
+
cancelled: { color: 'default', icon: null },
|
|
1938
|
+
}
|
|
1939
|
+
|
|
1940
|
+
export function ApprovalActions({ id, status, onSubmit, onCancel, onRefresh }: Props) {
|
|
1941
|
+
const { t } = useTranslation('__MODULE__')
|
|
1942
|
+
const { t: tc } = useTranslation('common')
|
|
1943
|
+
const { message } = App.useApp()
|
|
1944
|
+
const [loading, setLoading] = useState(false)
|
|
1945
|
+
|
|
1946
|
+
const handleSubmit = useCallback(async () => {
|
|
1947
|
+
setLoading(true)
|
|
1948
|
+
try {
|
|
1949
|
+
await onSubmit(id)
|
|
1950
|
+
message.success(t('messages.submitSuccess'))
|
|
1951
|
+
onRefresh()
|
|
1952
|
+
} catch (err) {
|
|
1953
|
+
message.error(err instanceof Error ? err.message : tc('messages.operationFailed'))
|
|
1954
|
+
} finally {
|
|
1955
|
+
setLoading(false)
|
|
1956
|
+
}
|
|
1957
|
+
}, [id, message, onRefresh, onSubmit, t, tc])
|
|
1958
|
+
|
|
1959
|
+
const handleCancel = useCallback(async () => {
|
|
1960
|
+
setLoading(true)
|
|
1961
|
+
try {
|
|
1962
|
+
await onCancel(id)
|
|
1963
|
+
message.success(t('messages.cancelSuccess'))
|
|
1964
|
+
onRefresh()
|
|
1965
|
+
} catch (err) {
|
|
1966
|
+
message.error(err instanceof Error ? err.message : tc('messages.operationFailed'))
|
|
1967
|
+
} finally {
|
|
1968
|
+
setLoading(false)
|
|
1969
|
+
}
|
|
1970
|
+
}, [id, message, onRefresh, onCancel, t, tc])
|
|
1971
|
+
|
|
1972
|
+
const config = statusConfig[status]
|
|
1973
|
+
|
|
1974
|
+
return (
|
|
1975
|
+
<Space>
|
|
1976
|
+
<Tag color={config.color} icon={config.icon}>
|
|
1977
|
+
{t(`status.${status}`)}
|
|
1978
|
+
</Tag>
|
|
1979
|
+
|
|
1980
|
+
{status === 'draft' && (
|
|
1981
|
+
<Popconfirm title={t('confirm.submit')} onConfirm={handleSubmit}>
|
|
1982
|
+
<Button type="primary" icon={<SendOutlined />} loading={loading} size="small">
|
|
1983
|
+
{t('actions.submit')}
|
|
1984
|
+
</Button>
|
|
1985
|
+
</Popconfirm>
|
|
1986
|
+
)}
|
|
1987
|
+
|
|
1988
|
+
{status === 'pending' && (
|
|
1989
|
+
<Popconfirm title={t('confirm.cancel')} onConfirm={handleCancel}>
|
|
1990
|
+
<Button icon={<UndoOutlined />} loading={loading} size="small">
|
|
1991
|
+
{t('actions.cancel')}
|
|
1992
|
+
</Button>
|
|
1993
|
+
</Popconfirm>
|
|
1994
|
+
)}
|
|
1995
|
+
</Space>
|
|
1996
|
+
)
|
|
1997
|
+
}
|
|
1998
|
+
```
|
|
1999
|
+
|
|
2000
|
+
---
|
|
2001
|
+
|
|
2002
|
+
## 导入/导出 Job 模板
|
|
2003
|
+
|
|
2004
|
+
> 适用场景:批量导入数据、导出报表等异步任务。
|
|
2005
|
+
|
|
2006
|
+
### 1. 导入 Job 实现 (domain/jobs/import.go)
|
|
2007
|
+
|
|
2008
|
+
```go
|
|
2009
|
+
package jobs
|
|
2010
|
+
|
|
2011
|
+
import (
|
|
2012
|
+
"context"
|
|
2013
|
+
"encoding/csv"
|
|
2014
|
+
"fmt"
|
|
2015
|
+
"io"
|
|
2016
|
+
"strings"
|
|
2017
|
+
|
|
2018
|
+
"github.com/robsuncn/keystone/infra/jobs"
|
|
2019
|
+
"github.com/robsuncn/keystone/infra/storage"
|
|
2020
|
+
|
|
2021
|
+
"__APP_NAME__/apps/server/internal/modules/__MODULE__/domain/models"
|
|
2022
|
+
"__APP_NAME__/apps/server/internal/modules/__MODULE__/infra/repository"
|
|
2023
|
+
)
|
|
2024
|
+
|
|
2025
|
+
const ImportJobType = "__MODULE___import"
|
|
2026
|
+
|
|
2027
|
+
type Import__ENTITY__Job struct {
|
|
2028
|
+
repo *repository.__ENTITY__Repository
|
|
2029
|
+
storage storage.Service
|
|
2030
|
+
}
|
|
2031
|
+
|
|
2032
|
+
func NewImport__ENTITY__Job(repo *repository.__ENTITY__Repository, storage storage.Service) *Import__ENTITY__Job {
|
|
2033
|
+
return &Import__ENTITY__Job{repo: repo, storage: storage}
|
|
2034
|
+
}
|
|
2035
|
+
|
|
2036
|
+
func (j *Import__ENTITY__Job) Type() string {
|
|
2037
|
+
return ImportJobType
|
|
2038
|
+
}
|
|
2039
|
+
|
|
2040
|
+
func (j *Import__ENTITY__Job) Execute(ctx context.Context, params jobs.Params, progress jobs.ProgressReporter) error {
|
|
2041
|
+
fileID := params.GetString("file_id")
|
|
2042
|
+
tenantID := params.GetUint("tenant_id")
|
|
2043
|
+
|
|
2044
|
+
if fileID == "" {
|
|
2045
|
+
return fmt.Errorf("file_id is required")
|
|
2046
|
+
}
|
|
2047
|
+
|
|
2048
|
+
// 下载文件
|
|
2049
|
+
progress.Update(5, "正在读取文件...")
|
|
2050
|
+
reader, err := j.storage.Download(ctx, fileID)
|
|
2051
|
+
if err != nil {
|
|
2052
|
+
return fmt.Errorf("下载文件失败: %w", err)
|
|
2053
|
+
}
|
|
2054
|
+
defer reader.Close()
|
|
2055
|
+
|
|
2056
|
+
// 解析 CSV
|
|
2057
|
+
progress.Update(10, "正在解析数据...")
|
|
2058
|
+
csvReader := csv.NewReader(reader)
|
|
2059
|
+
records, err := csvReader.ReadAll()
|
|
2060
|
+
if err != nil {
|
|
2061
|
+
return fmt.Errorf("解析 CSV 失败: %w", err)
|
|
2062
|
+
}
|
|
2063
|
+
|
|
2064
|
+
if len(records) < 2 {
|
|
2065
|
+
return fmt.Errorf("文件为空或只有表头")
|
|
2066
|
+
}
|
|
2067
|
+
|
|
2068
|
+
// 跳过表头
|
|
2069
|
+
dataRows := records[1:]
|
|
2070
|
+
total := len(dataRows)
|
|
2071
|
+
successCount := 0
|
|
2072
|
+
errorRows := make([]string, 0)
|
|
2073
|
+
|
|
2074
|
+
// 批量处理
|
|
2075
|
+
batchSize := 100
|
|
2076
|
+
for i := 0; i < total; i += batchSize {
|
|
2077
|
+
end := i + batchSize
|
|
2078
|
+
if end > total {
|
|
2079
|
+
end = total
|
|
2080
|
+
}
|
|
2081
|
+
|
|
2082
|
+
batch := dataRows[i:end]
|
|
2083
|
+
for rowIdx, row := range batch {
|
|
2084
|
+
actualRow := i + rowIdx + 2 // +2 因为跳过表头且从1开始
|
|
2085
|
+
|
|
2086
|
+
if len(row) < 2 {
|
|
2087
|
+
errorRows = append(errorRows, fmt.Sprintf("第%d行: 列数不足", actualRow))
|
|
2088
|
+
continue
|
|
2089
|
+
}
|
|
2090
|
+
|
|
2091
|
+
entity := &models.__ENTITY__{
|
|
2092
|
+
Name: strings.TrimSpace(row[0]),
|
|
2093
|
+
Description: strings.TrimSpace(row[1]),
|
|
2094
|
+
Status: models.Status__ENTITY__Active,
|
|
2095
|
+
}
|
|
2096
|
+
entity.TenantID = tenantID
|
|
2097
|
+
|
|
2098
|
+
if entity.Name == "" {
|
|
2099
|
+
errorRows = append(errorRows, fmt.Sprintf("第%d行: 名称不能为空", actualRow))
|
|
2100
|
+
continue
|
|
2101
|
+
}
|
|
2102
|
+
|
|
2103
|
+
if err := j.repo.Create(ctx, entity); err != nil {
|
|
2104
|
+
errorRows = append(errorRows, fmt.Sprintf("第%d行: %s", actualRow, err.Error()))
|
|
2105
|
+
continue
|
|
2106
|
+
}
|
|
2107
|
+
|
|
2108
|
+
successCount++
|
|
2109
|
+
}
|
|
2110
|
+
|
|
2111
|
+
// 更新进度
|
|
2112
|
+
percent := 10 + int(float64(end)/float64(total)*85)
|
|
2113
|
+
progress.Update(percent, fmt.Sprintf("已处理 %d/%d 条", end, total))
|
|
2114
|
+
}
|
|
2115
|
+
|
|
2116
|
+
// 完成
|
|
2117
|
+
progress.Update(100, fmt.Sprintf("导入完成: 成功 %d 条, 失败 %d 条", successCount, len(errorRows)))
|
|
2118
|
+
|
|
2119
|
+
// 如果有错误,记录到结果
|
|
2120
|
+
if len(errorRows) > 0 {
|
|
2121
|
+
progress.SetResult(map[string]interface{}{
|
|
2122
|
+
"success_count": successCount,
|
|
2123
|
+
"error_count": len(errorRows),
|
|
2124
|
+
"errors": errorRows,
|
|
2125
|
+
})
|
|
2126
|
+
}
|
|
2127
|
+
|
|
2128
|
+
return nil
|
|
2129
|
+
}
|
|
2130
|
+
```
|
|
2131
|
+
|
|
2132
|
+
### 2. 导出 Job 实现 (domain/jobs/export.go)
|
|
2133
|
+
|
|
2134
|
+
```go
|
|
2135
|
+
package jobs
|
|
2136
|
+
|
|
2137
|
+
import (
|
|
2138
|
+
"bytes"
|
|
2139
|
+
"context"
|
|
2140
|
+
"encoding/csv"
|
|
2141
|
+
"fmt"
|
|
2142
|
+
|
|
2143
|
+
"github.com/robsuncn/keystone/infra/jobs"
|
|
2144
|
+
"github.com/robsuncn/keystone/infra/storage"
|
|
2145
|
+
|
|
2146
|
+
"__APP_NAME__/apps/server/internal/modules/__MODULE__/domain/service"
|
|
2147
|
+
"__APP_NAME__/apps/server/internal/modules/__MODULE__/infra/repository"
|
|
2148
|
+
)
|
|
2149
|
+
|
|
2150
|
+
const ExportJobType = "__MODULE___export"
|
|
2151
|
+
|
|
2152
|
+
type Export__ENTITY__Job struct {
|
|
2153
|
+
repo *repository.__ENTITY__Repository
|
|
2154
|
+
storage storage.Service
|
|
2155
|
+
}
|
|
2156
|
+
|
|
2157
|
+
func NewExport__ENTITY__Job(repo *repository.__ENTITY__Repository, storage storage.Service) *Export__ENTITY__Job {
|
|
2158
|
+
return &Export__ENTITY__Job{repo: repo, storage: storage}
|
|
2159
|
+
}
|
|
2160
|
+
|
|
2161
|
+
func (j *Export__ENTITY__Job) Type() string {
|
|
2162
|
+
return ExportJobType
|
|
2163
|
+
}
|
|
2164
|
+
|
|
2165
|
+
func (j *Export__ENTITY__Job) Execute(ctx context.Context, params jobs.Params, progress jobs.ProgressReporter) error {
|
|
2166
|
+
tenantID := params.GetUint("tenant_id")
|
|
2167
|
+
filter := service.ListFilter{
|
|
2168
|
+
Status: params.GetStringPtr("status"),
|
|
2169
|
+
}
|
|
2170
|
+
|
|
2171
|
+
// 查询数据
|
|
2172
|
+
progress.Update(10, "正在查询数据...")
|
|
2173
|
+
items, err := j.repo.ListAll(ctx, tenantID, filter)
|
|
2174
|
+
if err != nil {
|
|
2175
|
+
return fmt.Errorf("查询数据失败: %w", err)
|
|
2176
|
+
}
|
|
2177
|
+
|
|
2178
|
+
if len(items) == 0 {
|
|
2179
|
+
return fmt.Errorf("没有数据可导出")
|
|
2180
|
+
}
|
|
2181
|
+
|
|
2182
|
+
// 生成 CSV
|
|
2183
|
+
progress.Update(30, "正在生成文件...")
|
|
2184
|
+
var buf bytes.Buffer
|
|
2185
|
+
writer := csv.NewWriter(&buf)
|
|
2186
|
+
|
|
2187
|
+
// 写表头
|
|
2188
|
+
if err := writer.Write([]string{"ID", "名称", "描述", "状态", "创建时间"}); err != nil {
|
|
2189
|
+
return err
|
|
2190
|
+
}
|
|
2191
|
+
|
|
2192
|
+
// 写数据
|
|
2193
|
+
for i, item := range items {
|
|
2194
|
+
row := []string{
|
|
2195
|
+
fmt.Sprintf("%d", item.ID),
|
|
2196
|
+
item.Name,
|
|
2197
|
+
item.Description,
|
|
2198
|
+
string(item.Status),
|
|
2199
|
+
item.CreatedAt.Format("2006-01-02 15:04:05"),
|
|
2200
|
+
}
|
|
2201
|
+
if err := writer.Write(row); err != nil {
|
|
2202
|
+
return err
|
|
2203
|
+
}
|
|
2204
|
+
|
|
2205
|
+
if i%100 == 0 {
|
|
2206
|
+
percent := 30 + int(float64(i)/float64(len(items))*50)
|
|
2207
|
+
progress.Update(percent, fmt.Sprintf("已处理 %d/%d 条", i, len(items)))
|
|
2208
|
+
}
|
|
2209
|
+
}
|
|
2210
|
+
writer.Flush()
|
|
2211
|
+
|
|
2212
|
+
// 上传文件
|
|
2213
|
+
progress.Update(85, "正在保存文件...")
|
|
2214
|
+
filename := fmt.Sprintf("export_%s_%d.csv", "__MODULE__", params.GetUint("job_id"))
|
|
2215
|
+
fileID, err := j.storage.Upload(ctx, storage.UploadInput{
|
|
2216
|
+
Filename: filename,
|
|
2217
|
+
ContentType: "text/csv",
|
|
2218
|
+
Reader: bytes.NewReader(buf.Bytes()),
|
|
2219
|
+
Size: int64(buf.Len()),
|
|
2220
|
+
})
|
|
2221
|
+
if err != nil {
|
|
2222
|
+
return fmt.Errorf("上传文件失败: %w", err)
|
|
2223
|
+
}
|
|
2224
|
+
|
|
2225
|
+
// 设置结果
|
|
2226
|
+
progress.Update(100, "导出完成")
|
|
2227
|
+
progress.SetResult(map[string]interface{}{
|
|
2228
|
+
"file_id": fileID,
|
|
2229
|
+
"filename": filename,
|
|
2230
|
+
"count": len(items),
|
|
2231
|
+
})
|
|
2232
|
+
|
|
2233
|
+
return nil
|
|
2234
|
+
}
|
|
2235
|
+
```
|
|
2236
|
+
|
|
2237
|
+
### 3. Module 注册 Jobs (module.go)
|
|
2238
|
+
|
|
2239
|
+
```go
|
|
2240
|
+
func (m *Module) RegisterJobs(reg *jobs.Registry) error {
|
|
2241
|
+
if reg == nil {
|
|
2242
|
+
return nil
|
|
2243
|
+
}
|
|
2244
|
+
|
|
2245
|
+
// 注册导入 Job
|
|
2246
|
+
importJob := modulejobs.NewImport__ENTITY__Job(m.repo, m.storage)
|
|
2247
|
+
if err := reg.Register(importJob); err != nil {
|
|
2248
|
+
return err
|
|
2249
|
+
}
|
|
2250
|
+
|
|
2251
|
+
// 注册导出 Job
|
|
2252
|
+
exportJob := modulejobs.NewExport__ENTITY__Job(m.repo, m.storage)
|
|
2253
|
+
if err := reg.Register(exportJob); err != nil {
|
|
2254
|
+
return err
|
|
2255
|
+
}
|
|
2256
|
+
|
|
2257
|
+
return nil
|
|
2258
|
+
}
|
|
2259
|
+
```
|
|
2260
|
+
|
|
2261
|
+
### 4. Handler 创建导入导出任务 (api/handler/jobs.go)
|
|
2262
|
+
|
|
2263
|
+
```go
|
|
2264
|
+
package handler
|
|
2265
|
+
|
|
2266
|
+
import (
|
|
2267
|
+
"github.com/gin-gonic/gin"
|
|
2268
|
+
hcommon "github.com/robsuncn/keystone/api/handler/common"
|
|
2269
|
+
"github.com/robsuncn/keystone/api/response"
|
|
2270
|
+
"github.com/robsuncn/keystone/infra/jobs"
|
|
2271
|
+
|
|
2272
|
+
modulei18n "__APP_NAME__/apps/server/internal/modules/__MODULE__/i18n"
|
|
2273
|
+
modulejobs "__APP_NAME__/apps/server/internal/modules/__MODULE__/domain/jobs"
|
|
2274
|
+
)
|
|
2275
|
+
|
|
2276
|
+
type ImportInput struct {
|
|
2277
|
+
FileID string `json:"file_id" binding:"required"`
|
|
2278
|
+
}
|
|
2279
|
+
|
|
2280
|
+
// StartImport 开始导入
|
|
2281
|
+
func (h *__ENTITY__Handler) StartImport(c *gin.Context) {
|
|
2282
|
+
tenantID := resolveTenantID(c)
|
|
2283
|
+
userID, _ := hcommon.GetUserID(c)
|
|
2284
|
+
|
|
2285
|
+
var input ImportInput
|
|
2286
|
+
if err := c.ShouldBindJSON(&input); err != nil {
|
|
2287
|
+
response.BadRequestI18n(c, modulei18n.MsgInvalidPayload)
|
|
2288
|
+
return
|
|
2289
|
+
}
|
|
2290
|
+
|
|
2291
|
+
job, err := h.jobs.Create(c.Request.Context(), jobs.CreateInput{
|
|
2292
|
+
Type: modulejobs.ImportJobType,
|
|
2293
|
+
TenantID: tenantID,
|
|
2294
|
+
UserID: userID,
|
|
2295
|
+
Params: map[string]interface{}{
|
|
2296
|
+
"file_id": input.FileID,
|
|
2297
|
+
"tenant_id": tenantID,
|
|
2298
|
+
},
|
|
2299
|
+
})
|
|
2300
|
+
if err != nil {
|
|
2301
|
+
response.InternalErrorI18n(c, modulei18n.MsgJobCreateFailed)
|
|
2302
|
+
return
|
|
2303
|
+
}
|
|
2304
|
+
|
|
2305
|
+
response.Success(c, job)
|
|
2306
|
+
}
|
|
2307
|
+
|
|
2308
|
+
type ExportInput struct {
|
|
2309
|
+
Status *string `json:"status"`
|
|
2310
|
+
}
|
|
2311
|
+
|
|
2312
|
+
// StartExport 开始导出
|
|
2313
|
+
func (h *__ENTITY__Handler) StartExport(c *gin.Context) {
|
|
2314
|
+
tenantID := resolveTenantID(c)
|
|
2315
|
+
userID, _ := hcommon.GetUserID(c)
|
|
2316
|
+
|
|
2317
|
+
var input ExportInput
|
|
2318
|
+
_ = c.ShouldBindJSON(&input)
|
|
2319
|
+
|
|
2320
|
+
job, err := h.jobs.Create(c.Request.Context(), jobs.CreateInput{
|
|
2321
|
+
Type: modulejobs.ExportJobType,
|
|
2322
|
+
TenantID: tenantID,
|
|
2323
|
+
UserID: userID,
|
|
2324
|
+
Params: map[string]interface{}{
|
|
2325
|
+
"tenant_id": tenantID,
|
|
2326
|
+
"status": input.Status,
|
|
2327
|
+
},
|
|
2328
|
+
})
|
|
2329
|
+
if err != nil {
|
|
2330
|
+
response.InternalErrorI18n(c, modulei18n.MsgJobCreateFailed)
|
|
2331
|
+
return
|
|
2332
|
+
}
|
|
2333
|
+
|
|
2334
|
+
response.Success(c, job)
|
|
2335
|
+
}
|
|
2336
|
+
```
|
|
2337
|
+
|
|
2338
|
+
### 5. 前端导入组件 (components/ImportButton.tsx)
|
|
2339
|
+
|
|
2340
|
+
```tsx
|
|
2341
|
+
import { useCallback, useState } from 'react'
|
|
2342
|
+
import { App, Button, Modal, Progress, Space, Upload } from 'antd'
|
|
2343
|
+
import { UploadOutlined, DownloadOutlined } from '@ant-design/icons'
|
|
2344
|
+
import type { UploadFile } from 'antd'
|
|
2345
|
+
import { useTranslation } from 'react-i18next'
|
|
2346
|
+
import { uploadFile } from '@robsun/keystone-web-core'
|
|
2347
|
+
import { startImport, getJob } from '../services/api'
|
|
2348
|
+
|
|
2349
|
+
interface Props {
|
|
2350
|
+
onComplete: () => void
|
|
2351
|
+
}
|
|
2352
|
+
|
|
2353
|
+
export function ImportButton({ onComplete }: Props) {
|
|
2354
|
+
const { t } = useTranslation('__MODULE__')
|
|
2355
|
+
const { t: tc } = useTranslation('common')
|
|
2356
|
+
const { message } = App.useApp()
|
|
2357
|
+
const [modalOpen, setModalOpen] = useState(false)
|
|
2358
|
+
const [uploading, setUploading] = useState(false)
|
|
2359
|
+
const [jobId, setJobId] = useState<number | null>(null)
|
|
2360
|
+
const [progress, setProgress] = useState(0)
|
|
2361
|
+
const [progressText, setProgressText] = useState('')
|
|
2362
|
+
|
|
2363
|
+
const handleUpload = useCallback(
|
|
2364
|
+
async (file: UploadFile) => {
|
|
2365
|
+
if (!file.originFileObj) return
|
|
2366
|
+
|
|
2367
|
+
setUploading(true)
|
|
2368
|
+
try {
|
|
2369
|
+
// 上传文件
|
|
2370
|
+
const fileId = await uploadFile(file.originFileObj)
|
|
2371
|
+
|
|
2372
|
+
// 创建导入任务
|
|
2373
|
+
const job = await startImport({ file_id: fileId })
|
|
2374
|
+
setJobId(job.id)
|
|
2375
|
+
|
|
2376
|
+
// 轮询进度
|
|
2377
|
+
const pollProgress = async () => {
|
|
2378
|
+
const jobStatus = await getJob(job.id)
|
|
2379
|
+
setProgress(jobStatus.progress || 0)
|
|
2380
|
+
setProgressText(jobStatus.message || '')
|
|
2381
|
+
|
|
2382
|
+
if (jobStatus.status === 'completed') {
|
|
2383
|
+
message.success(t('messages.importSuccess'))
|
|
2384
|
+
setModalOpen(false)
|
|
2385
|
+
onComplete()
|
|
2386
|
+
} else if (jobStatus.status === 'failed') {
|
|
2387
|
+
message.error(jobStatus.error || t('messages.importFailed'))
|
|
2388
|
+
} else {
|
|
2389
|
+
setTimeout(pollProgress, 1000)
|
|
2390
|
+
}
|
|
2391
|
+
}
|
|
2392
|
+
pollProgress()
|
|
2393
|
+
} catch (err) {
|
|
2394
|
+
message.error(err instanceof Error ? err.message : tc('messages.operationFailed'))
|
|
2395
|
+
} finally {
|
|
2396
|
+
setUploading(false)
|
|
2397
|
+
}
|
|
2398
|
+
},
|
|
2399
|
+
[message, onComplete, t, tc]
|
|
2400
|
+
)
|
|
2401
|
+
|
|
2402
|
+
const downloadTemplate = useCallback(() => {
|
|
2403
|
+
const csv = 'Name,Description\nExample 1,Description 1\nExample 2,Description 2'
|
|
2404
|
+
const blob = new Blob([csv], { type: 'text/csv' })
|
|
2405
|
+
const url = URL.createObjectURL(blob)
|
|
2406
|
+
const a = document.createElement('a')
|
|
2407
|
+
a.href = url
|
|
2408
|
+
a.download = 'import_template.csv'
|
|
2409
|
+
a.click()
|
|
2410
|
+
URL.revokeObjectURL(url)
|
|
2411
|
+
}, [])
|
|
2412
|
+
|
|
2413
|
+
return (
|
|
2414
|
+
<>
|
|
2415
|
+
<Button icon={<UploadOutlined />} onClick={() => setModalOpen(true)}>
|
|
2416
|
+
{t('actions.import')}
|
|
2417
|
+
</Button>
|
|
2418
|
+
|
|
2419
|
+
<Modal
|
|
2420
|
+
title={t('import.title')}
|
|
2421
|
+
open={modalOpen}
|
|
2422
|
+
onCancel={() => setModalOpen(false)}
|
|
2423
|
+
footer={null}
|
|
2424
|
+
destroyOnHidden
|
|
2425
|
+
>
|
|
2426
|
+
<Space direction="vertical" style={{ width: '100%' }}>
|
|
2427
|
+
<Button icon={<DownloadOutlined />} onClick={downloadTemplate}>
|
|
2428
|
+
{t('import.downloadTemplate')}
|
|
2429
|
+
</Button>
|
|
2430
|
+
|
|
2431
|
+
<Upload
|
|
2432
|
+
accept=".csv"
|
|
2433
|
+
maxCount={1}
|
|
2434
|
+
beforeUpload={(file) => {
|
|
2435
|
+
handleUpload({ originFileObj: file } as UploadFile)
|
|
2436
|
+
return false
|
|
2437
|
+
}}
|
|
2438
|
+
disabled={uploading || jobId !== null}
|
|
2439
|
+
>
|
|
2440
|
+
<Button icon={<UploadOutlined />} loading={uploading}>
|
|
2441
|
+
{t('import.selectFile')}
|
|
2442
|
+
</Button>
|
|
2443
|
+
</Upload>
|
|
2444
|
+
|
|
2445
|
+
{jobId && (
|
|
2446
|
+
<div>
|
|
2447
|
+
<Progress percent={progress} />
|
|
2448
|
+
<p>{progressText}</p>
|
|
2449
|
+
</div>
|
|
2450
|
+
)}
|
|
2451
|
+
</Space>
|
|
2452
|
+
</Modal>
|
|
2453
|
+
</>
|
|
2454
|
+
)
|
|
2455
|
+
}
|
|
2456
|
+
```
|
|
2457
|
+
|
|
2458
|
+
---
|
|
2459
|
+
|
|
2460
|
+
## 前端高级组件示例
|
|
2461
|
+
|
|
2462
|
+
### 1. ProTable 带分页过滤 (pages/__ENTITY__ProTablePage.tsx)
|
|
2463
|
+
|
|
2464
|
+
```tsx
|
|
2465
|
+
import { useCallback, useEffect, useMemo, useState } from 'react'
|
|
2466
|
+
import { App, Button, Card, Input, Select, Space, Tag } from 'antd'
|
|
2467
|
+
import { PlusOutlined, ReloadOutlined, SearchOutlined } from '@ant-design/icons'
|
|
2468
|
+
import type { ColumnsType } from 'antd/es/table'
|
|
2469
|
+
import { useTranslation } from 'react-i18next'
|
|
2470
|
+
import { ProTable, type ProTableProps } from '@robsun/keystone-web-core'
|
|
2471
|
+
import dayjs from 'dayjs'
|
|
2472
|
+
import { list__ENTITY__s } from '../services/api'
|
|
2473
|
+
import type { __ENTITY__, __ENTITY__Status } from '../types'
|
|
2474
|
+
|
|
2475
|
+
const statusColors: Record<__ENTITY__Status, string> = {
|
|
2476
|
+
active: 'success',
|
|
2477
|
+
inactive: 'default',
|
|
2478
|
+
}
|
|
2479
|
+
|
|
2480
|
+
export function __ENTITY__ProTablePage() {
|
|
2481
|
+
const { t } = useTranslation('__MODULE__')
|
|
2482
|
+
const { t: tc } = useTranslation('common')
|
|
2483
|
+
const { message } = App.useApp()
|
|
2484
|
+
|
|
2485
|
+
const [loading, setLoading] = useState(false)
|
|
2486
|
+
const [data, setData] = useState<__ENTITY__[]>([])
|
|
2487
|
+
const [total, setTotal] = useState(0)
|
|
2488
|
+
const [page, setPage] = useState(1)
|
|
2489
|
+
const [pageSize, setPageSize] = useState(10)
|
|
2490
|
+
const [filters, setFilters] = useState<{ keyword?: string; status?: __ENTITY__Status }>({})
|
|
2491
|
+
|
|
2492
|
+
const fetchData = useCallback(async () => {
|
|
2493
|
+
setLoading(true)
|
|
2494
|
+
try {
|
|
2495
|
+
const result = await list__ENTITY__s({
|
|
2496
|
+
page,
|
|
2497
|
+
page_size: pageSize,
|
|
2498
|
+
...filters,
|
|
2499
|
+
})
|
|
2500
|
+
setData(result.items)
|
|
2501
|
+
setTotal(result.total)
|
|
2502
|
+
} catch (err) {
|
|
2503
|
+
message.error(err instanceof Error ? err.message : t('messages.loadFailed'))
|
|
2504
|
+
} finally {
|
|
2505
|
+
setLoading(false)
|
|
2506
|
+
}
|
|
2507
|
+
}, [filters, message, page, pageSize, t])
|
|
2508
|
+
|
|
2509
|
+
useEffect(() => {
|
|
2510
|
+
void fetchData()
|
|
2511
|
+
}, [fetchData])
|
|
2512
|
+
|
|
2513
|
+
const columns: ColumnsType<__ENTITY__> = useMemo(
|
|
2514
|
+
() => [
|
|
2515
|
+
{ title: 'ID', dataIndex: 'id', key: 'id', width: 80 },
|
|
2516
|
+
{ title: t('table.name'), dataIndex: 'name', key: 'name' },
|
|
2517
|
+
{ title: t('table.description'), dataIndex: 'description', key: 'description', ellipsis: true },
|
|
2518
|
+
{
|
|
2519
|
+
title: t('table.status'),
|
|
2520
|
+
dataIndex: 'status',
|
|
2521
|
+
key: 'status',
|
|
2522
|
+
width: 100,
|
|
2523
|
+
render: (status: __ENTITY__Status) => (
|
|
2524
|
+
<Tag color={statusColors[status]}>{t(`status.${status}`)}</Tag>
|
|
2525
|
+
),
|
|
2526
|
+
},
|
|
2527
|
+
{
|
|
2528
|
+
title: tc('table.createdAt'),
|
|
2529
|
+
dataIndex: 'created_at',
|
|
2530
|
+
key: 'created_at',
|
|
2531
|
+
width: 180,
|
|
2532
|
+
render: (value: string) => dayjs(value).format('YYYY-MM-DD HH:mm'),
|
|
2533
|
+
},
|
|
2534
|
+
{
|
|
2535
|
+
title: tc('table.actions'),
|
|
2536
|
+
key: 'actions',
|
|
2537
|
+
width: 150,
|
|
2538
|
+
render: (_, record) => (
|
|
2539
|
+
<Space>
|
|
2540
|
+
<Button type="link" size="small">
|
|
2541
|
+
{tc('actions.edit')}
|
|
2542
|
+
</Button>
|
|
2543
|
+
<Button type="link" size="small" danger>
|
|
2544
|
+
{tc('actions.delete')}
|
|
2545
|
+
</Button>
|
|
2546
|
+
</Space>
|
|
2547
|
+
),
|
|
2548
|
+
},
|
|
2549
|
+
],
|
|
2550
|
+
[t, tc]
|
|
2551
|
+
)
|
|
2552
|
+
|
|
2553
|
+
const toolbar = (
|
|
2554
|
+
<Space wrap>
|
|
2555
|
+
<Input
|
|
2556
|
+
placeholder={t('filter.keyword')}
|
|
2557
|
+
prefix={<SearchOutlined />}
|
|
2558
|
+
allowClear
|
|
2559
|
+
onChange={(e) => setFilters((prev) => ({ ...prev, keyword: e.target.value || undefined }))}
|
|
2560
|
+
style={{ width: 200 }}
|
|
2561
|
+
/>
|
|
2562
|
+
<Select
|
|
2563
|
+
placeholder={t('filter.status')}
|
|
2564
|
+
allowClear
|
|
2565
|
+
onChange={(value) => setFilters((prev) => ({ ...prev, status: value }))}
|
|
2566
|
+
options={[
|
|
2567
|
+
{ value: 'active', label: t('status.active') },
|
|
2568
|
+
{ value: 'inactive', label: t('status.inactive') },
|
|
2569
|
+
]}
|
|
2570
|
+
style={{ width: 120 }}
|
|
2571
|
+
/>
|
|
2572
|
+
<Button icon={<ReloadOutlined />} onClick={fetchData}>
|
|
2573
|
+
{tc('actions.refresh')}
|
|
2574
|
+
</Button>
|
|
2575
|
+
<Button type="primary" icon={<PlusOutlined />}>
|
|
2576
|
+
{t('page.createButton')}
|
|
2577
|
+
</Button>
|
|
2578
|
+
</Space>
|
|
2579
|
+
)
|
|
2580
|
+
|
|
2581
|
+
return (
|
|
2582
|
+
<Card title={t('page.title')} extra={toolbar}>
|
|
2583
|
+
<ProTable<__ENTITY__>
|
|
2584
|
+
rowKey="id"
|
|
2585
|
+
loading={loading}
|
|
2586
|
+
columns={columns}
|
|
2587
|
+
dataSource={data}
|
|
2588
|
+
pagination={{
|
|
2589
|
+
current: page,
|
|
2590
|
+
pageSize,
|
|
2591
|
+
total,
|
|
2592
|
+
showSizeChanger: true,
|
|
2593
|
+
showQuickJumper: true,
|
|
2594
|
+
showTotal: (total) => tc('pagination.total', { total }),
|
|
2595
|
+
onChange: (p, ps) => {
|
|
2596
|
+
setPage(p)
|
|
2597
|
+
setPageSize(ps)
|
|
2598
|
+
},
|
|
2599
|
+
}}
|
|
2600
|
+
/>
|
|
2601
|
+
</Card>
|
|
2602
|
+
)
|
|
2603
|
+
}
|
|
2604
|
+
```
|
|
2605
|
+
|
|
2606
|
+
### 2. 数据导入向导 (components/__ENTITY__Importer.tsx)
|
|
2607
|
+
|
|
2608
|
+
```tsx
|
|
2609
|
+
import { useCallback, useState } from 'react'
|
|
2610
|
+
import { App, Modal } from 'antd'
|
|
2611
|
+
import { useTranslation } from 'react-i18next'
|
|
2612
|
+
import {
|
|
2613
|
+
DataImporter,
|
|
2614
|
+
type ColumnMapping,
|
|
2615
|
+
type ImportResult,
|
|
2616
|
+
type ValidationResult,
|
|
2617
|
+
} from '@robsun/keystone-web-core'
|
|
2618
|
+
import { batchCreate__ENTITY__s } from '../services/api'
|
|
2619
|
+
|
|
2620
|
+
interface Props {
|
|
2621
|
+
open: boolean
|
|
2622
|
+
onClose: () => void
|
|
2623
|
+
onComplete: () => void
|
|
2624
|
+
}
|
|
2625
|
+
|
|
2626
|
+
const columns: ColumnMapping[] = [
|
|
2627
|
+
{ field: 'name', label: '名称', required: true },
|
|
2628
|
+
{ field: 'description', label: '描述', required: false },
|
|
2629
|
+
{ field: 'status', label: '状态', required: false, defaultValue: 'active' },
|
|
2630
|
+
]
|
|
2631
|
+
|
|
2632
|
+
export function __ENTITY__Importer({ open, onClose, onComplete }: Props) {
|
|
2633
|
+
const { t } = useTranslation('__MODULE__')
|
|
2634
|
+
const { message } = App.useApp()
|
|
2635
|
+
const [importing, setImporting] = useState(false)
|
|
2636
|
+
|
|
2637
|
+
const handleValidate = useCallback(async (data: Record<string, unknown>[]): Promise<ValidationResult> => {
|
|
2638
|
+
const errors: { row: number; field: string; message: string }[] = []
|
|
2639
|
+
|
|
2640
|
+
data.forEach((row, index) => {
|
|
2641
|
+
if (!row.name || String(row.name).trim() === '') {
|
|
2642
|
+
errors.push({ row: index + 1, field: 'name', message: '名称不能为空' })
|
|
2643
|
+
}
|
|
2644
|
+
if (row.status && !['active', 'inactive'].includes(String(row.status))) {
|
|
2645
|
+
errors.push({ row: index + 1, field: 'status', message: '状态值无效' })
|
|
2646
|
+
}
|
|
2647
|
+
})
|
|
2648
|
+
|
|
2649
|
+
return {
|
|
2650
|
+
valid: errors.length === 0,
|
|
2651
|
+
errors,
|
|
2652
|
+
warnings: [],
|
|
2653
|
+
}
|
|
2654
|
+
}, [])
|
|
2655
|
+
|
|
2656
|
+
const handleImport = useCallback(
|
|
2657
|
+
async (data: Record<string, unknown>[]): Promise<ImportResult> => {
|
|
2658
|
+
setImporting(true)
|
|
2659
|
+
try {
|
|
2660
|
+
const items = data.map((row) => ({
|
|
2661
|
+
name: String(row.name).trim(),
|
|
2662
|
+
description: row.description ? String(row.description).trim() : '',
|
|
2663
|
+
status: (row.status as 'active' | 'inactive') || 'active',
|
|
2664
|
+
}))
|
|
2665
|
+
|
|
2666
|
+
const result = await batchCreate__ENTITY__s(items)
|
|
2667
|
+
|
|
2668
|
+
message.success(t('messages.importSuccess'))
|
|
2669
|
+
onComplete()
|
|
2670
|
+
|
|
2671
|
+
return {
|
|
2672
|
+
success: true,
|
|
2673
|
+
imported: result.created,
|
|
2674
|
+
failed: 0,
|
|
2675
|
+
errors: [],
|
|
2676
|
+
}
|
|
2677
|
+
} catch (err) {
|
|
2678
|
+
return {
|
|
2679
|
+
success: false,
|
|
2680
|
+
imported: 0,
|
|
2681
|
+
failed: data.length,
|
|
2682
|
+
errors: [{ row: 0, field: '', message: err instanceof Error ? err.message : '导入失败' }],
|
|
2683
|
+
}
|
|
2684
|
+
} finally {
|
|
2685
|
+
setImporting(false)
|
|
2686
|
+
}
|
|
2687
|
+
},
|
|
2688
|
+
[message, onComplete, t]
|
|
2689
|
+
)
|
|
2690
|
+
|
|
2691
|
+
return (
|
|
2692
|
+
<Modal
|
|
2693
|
+
title={t('import.title')}
|
|
2694
|
+
open={open}
|
|
2695
|
+
onCancel={onClose}
|
|
2696
|
+
footer={null}
|
|
2697
|
+
width={800}
|
|
2698
|
+
destroyOnHidden
|
|
2699
|
+
>
|
|
2700
|
+
<DataImporter
|
|
2701
|
+
columns={columns}
|
|
2702
|
+
onValidate={handleValidate}
|
|
2703
|
+
onImport={handleImport}
|
|
2704
|
+
loading={importing}
|
|
2705
|
+
templateFileName="__MODULE___import_template.csv"
|
|
2706
|
+
/>
|
|
2707
|
+
</Modal>
|
|
2708
|
+
)
|
|
531
2709
|
}
|
|
532
2710
|
```
|