@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.
Files changed (25) hide show
  1. package/README.md +1 -0
  2. package/dist/create-keystone-app.js +0 -0
  3. package/dist/create-module.js +584 -2
  4. package/package.json +22 -23
  5. package/template/.claude/skills/keystone-dev/SKILL.md +90 -103
  6. package/template/.claude/skills/keystone-dev/references/ADVANCED_PATTERNS.md +716 -0
  7. package/template/.claude/skills/keystone-dev/references/APPROVAL.md +47 -0
  8. package/template/.claude/skills/keystone-dev/references/CAPABILITIES.md +60 -5
  9. package/template/.claude/skills/keystone-dev/references/CHECKLIST.md +285 -0
  10. package/template/.claude/skills/keystone-dev/references/GOTCHAS.md +390 -0
  11. package/template/.claude/skills/keystone-dev/references/PATTERNS.md +605 -0
  12. package/template/.claude/skills/keystone-dev/references/TEMPLATES.md +2562 -384
  13. package/template/.codex/skills/keystone-dev/SKILL.md +90 -103
  14. package/template/.codex/skills/keystone-dev/references/ADVANCED_PATTERNS.md +716 -0
  15. package/template/.codex/skills/keystone-dev/references/APPROVAL.md +47 -0
  16. package/template/.codex/skills/keystone-dev/references/CAPABILITIES.md +60 -5
  17. package/template/.codex/skills/keystone-dev/references/CHECKLIST.md +285 -0
  18. package/template/.codex/skills/keystone-dev/references/GOTCHAS.md +390 -0
  19. package/template/.codex/skills/keystone-dev/references/PATTERNS.md +605 -0
  20. package/template/.codex/skills/keystone-dev/references/TEMPLATES.md +2562 -384
  21. package/template/README.md +8 -1
  22. package/template/apps/server/go.mod +97 -97
  23. package/template/apps/server/go.sum +283 -283
  24. package/template/docs/CONVENTIONS.md +11 -8
  25. package/template/package.json +3 -3
@@ -1,532 +1,2710 @@
1
1
  # Keystone 代码模板
2
2
 
3
- ## 前端模板
3
+ > 所有模板基于 example 模块实际代码,使用占位符标记变量部分。
4
+ > 复制后替换占位符即可运行。
4
5
 
5
- ### Ant Design v6 注意事项
6
- - `Space` 使用 `orientation`,不要用 `direction`。
7
- - `Modal` 使用 `destroyOnHidden`,不要用 `destroyOnClose`。
8
- - `Drawer` 使用 `size`,不要用 `width`。
6
+ ## 占位符说明
9
7
 
10
- ### routes.tsx
11
- ```tsx
12
- import { RouteObject } from 'react-router-dom'
13
- import { registerModule } from '@robsun/keystone-web-core'
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
- const routes: RouteObject[] = [
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
- registerModule({ name: '{name}', routes })
33
- ```
19
+ ## 后端模板
34
20
 
35
- ### List.tsx (ProTable)
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
- const INITIAL_DATA: PaginatedData<{Entity}> = {
44
- items: [],
45
- total: 0,
46
- page: 1,
47
- page_size: 10,
48
- }
23
+ ```go
24
+ package __MODULE__
49
25
 
50
- export function Component() {
51
- const [loading, setLoading] = useState(false)
52
- const [data, setData] = useState(INITIAL_DATA)
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
- const columns = [
55
- { title: 'ID', dataIndex: 'id' },
56
- { title: '名称', dataIndex: 'name' },
57
- ]
42
+ type Module struct {
43
+ svc *service.__ENTITY__Service
44
+ }
58
45
 
59
- const fetchData = async (page = data.page, pageSize = data.page_size) => {
60
- setLoading(true)
61
- try {
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
- useEffect(() => {
70
- void fetchData()
71
- }, [])
50
+ func (m *Module) Name() string {
51
+ return "__MODULE__"
52
+ }
72
53
 
73
- return (
74
- <ProTable
75
- columns={columns}
76
- dataSource={data.items}
77
- rowKey="id"
78
- loading={loading}
79
- pagination={{
80
- current: data.page,
81
- pageSize: data.page_size,
82
- total: data.total,
83
- onChange: (page, pageSize) => {
84
- void fetchData(page, pageSize)
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
- ### Form.tsx (ProForm)
93
- ```tsx
94
- import { ProForm } from '@robsun/keystone-web-core'
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
- return (
114
- <ProForm onFinish={onSubmit}>
115
- {/* 表单字段 */}
116
- </ProForm>
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
- ### services/api.ts
122
- ```typescript
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
- export const create{Entity} = async (payload: Partial<{Entity}>) => {
136
- const { data } = await api.post<ApiResponse<{Entity}>>('/{module}/{resources}', payload)
137
- return data.data
114
+ func (m *Module) RegisterJobs(_ *jobs.Registry) error {
115
+ return nil
138
116
  }
139
117
 
140
- export const update{Entity} = async (id: number, payload: Partial<{Entity}>) => {
141
- const { data } = await api.patch<ApiResponse<{Entity}>>(`/{module}/{resources}/${id}`, payload)
142
- return data.data
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
- export const delete{Entity} = async (id: number) => {
146
- await api.delete<ApiResponse<{ id: number }>>(`/{module}/{resources}/${id}`)
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
- ### types.ts
151
- ```typescript
152
- export interface {Entity} {
153
- id: string
154
- name: string
155
- createdAt: string
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
- ### i18n 翻译文件
143
+ ### 2. domain/models/entity.go (模型)
161
144
 
162
- **locales/zh-CN/{module}.json**
163
- ```json
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
- **locales/en-US/{module}.json**
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
- ### 使用翻译的 List.tsx
243
- ```tsx
244
- import { useTranslation } from 'react-i18next'
245
- import { ProTable } from '@robsun/keystone-web-core'
150
+ type __ENTITY__Status string
246
151
 
247
- export function Component() {
248
- const { t } = useTranslation() // 自动使用当前模块的命名空间
152
+ const (
153
+ Status__ENTITY__Active __ENTITY__Status = "active"
154
+ Status__ENTITY__Inactive __ENTITY__Status = "inactive"
155
+ )
249
156
 
250
- const columns = [
251
- { title: t('{module}:list.columns.name'), dataIndex: 'name' },
252
- { title: t('{module}:list.columns.status'), dataIndex: 'status' },
253
- { title: t('{module}:list.columns.createdAt'), dataIndex: 'createdAt' },
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
- return (
257
- <ProTable
258
- columns={columns}
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 {name}
181
+ package service
270
182
 
271
183
  import (
272
- "github.com/gin-gonic/gin"
273
- "gorm.io/gorm"
184
+ "context"
185
+ "strings"
186
+
187
+ "__APP_NAME__/apps/server/internal/modules/__MODULE__/domain/models"
274
188
  )
275
189
 
276
- type Module struct{}
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
- func (m *Module) Name() string { return "{name}" }
202
+ type __ENTITY__Input struct {
203
+ Name string
204
+ Description string
205
+ Status models.__ENTITY__Status
206
+ }
279
207
 
280
- func (m *Module) RegisterRoutes(r *gin.RouterGroup) {
281
- g := r.Group("/{name}")
282
- g.GET("", handler.List)
283
- g.GET("/:id", handler.Detail)
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 (m *Module) RegisterPermissions() []Permission {
290
- return []Permission{
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 (m *Module) Migrate(db *gorm.DB) error {
299
- return db.AutoMigrate(&models.{Entity}{})
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 (m *Module) Seed(db *gorm.DB) error { return nil }
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
- ### handler/list.go
293
+ ### 4. domain/service/errors.go (错误定义)
294
+
306
295
  ```go
307
- package handler
296
+ package service
308
297
 
309
298
  import (
310
- "github.com/gin-gonic/gin"
299
+ "github.com/robsuncn/keystone/infra/i18n"
300
+ modulei18n "__APP_NAME__/apps/server/internal/modules/__MODULE__/i18n"
311
301
  )
312
302
 
313
- func (h *Handler) List(c *gin.Context) {
314
- page, pageSize := parsePagination(c)
315
- items, total, err := h.svc.List(c, page, pageSize)
316
- if err != nil {
317
- respondError(c, err)
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
- ### domain/models/entity.go
310
+ ### 5. api/handler/handler.go (HTTP 处理器)
311
+
325
312
  ```go
326
- package models
313
+ package handler
314
+
315
+ import (
316
+ "errors"
327
317
 
328
- import "time"
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
- type {Entity} struct {
331
- ID string `json:"id" gorm:"primaryKey"`
332
- Name string `json:"name"`
333
- CreatedAt time.Time `json:"createdAt"`
334
- UpdatedAt time.Time `json:"updatedAt"`
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
- ### domain/service/service.go
339
- ```go
340
- package service
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 Service struct {
343
- repo *repository.Repository
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
- func NewService(repo *repository.Repository) *Service {
347
- return &Service{repo: repo}
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 (s *Service) List(ctx context.Context, page, pageSize int) ([]models.{Entity}, int64, error) {
351
- return s.repo.List(ctx, page, pageSize)
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 (s *Service) Get(ctx context.Context, id string) (*models.{Entity}, error) {
355
- return s.repo.Get(ctx, id)
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 (s *Service) Create(ctx context.Context, entity *models.{Entity}) error {
359
- return s.repo.Create(ctx, entity)
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 (s *Service) Update(ctx context.Context, id string, entity *models.{Entity}) error {
363
- return s.repo.Update(ctx, id, entity)
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 (s *Service) Delete(ctx context.Context, id string) error {
367
- return s.repo.Delete(ctx, id)
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/repo.go
499
+ ### 6. infra/repository/repository.go (数据仓库)
500
+
372
501
  ```go
373
502
  package repository
374
503
 
375
504
  import (
376
- "context"
377
- "gorm.io/gorm"
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 Repository struct {
381
- db *gorm.DB
514
+ type __ENTITY__Repository struct {
515
+ db *gorm.DB
382
516
  }
383
517
 
384
- func NewRepository(db *gorm.DB) *Repository {
385
- return &Repository{db: db}
518
+ func New__ENTITY__Repository(db *gorm.DB) *__ENTITY__Repository {
519
+ return &__ENTITY__Repository{db: db}
386
520
  }
387
521
 
388
- func (r *Repository) List(ctx context.Context, page, pageSize int) ([]models.{Entity}, int64, error) {
389
- var items []models.{Entity}
390
- var total int64
391
-
392
- r.db.Model(&models.{Entity}{}).Count(&total)
393
- r.db.Offset((page - 1) * pageSize).Limit(pageSize).Find(&items)
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 *Repository) Get(ctx context.Context, id string) (*models.{Entity}, error) {
399
- var item models.{Entity}
400
- if err := r.db.First(&item, "id = ?", id).Error; err != nil {
401
- return nil, err
402
- }
403
- return &item, nil
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 *Repository) Create(ctx context.Context, entity *models.{Entity}) error {
407
- return r.db.Create(entity).Error
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 *Repository) Update(ctx context.Context, id string, entity *models.{Entity}) error {
411
- return r.db.Model(&models.{Entity}{}).Where("id = ?", id).Updates(entity).Error
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 *Repository) Delete(ctx context.Context, id string) error {
415
- return r.db.Delete(&models.{Entity}{}, "id = ?", id).Error
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/i18n.go
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 i18n
583
+ package modulei18n
422
584
 
423
585
  import (
424
- "embed"
425
- "github.com/robsuncn/keystone/infra/i18n"
586
+ "embed"
587
+ "github.com/robsuncn/keystone/infra/i18n"
426
588
  )
427
589
 
428
590
  //go:embed locales/*.json
429
- var Translations embed.FS
591
+ var translations embed.FS
430
592
 
431
- func init() {
432
- // 注册模块翻译文件
433
- i18n.MustLoadModuleTranslations("{module}", Translations)
593
+ func RegisterLocales() error {
594
+ return i18n.LoadModuleTranslations("__MODULE__", translations)
434
595
  }
435
596
  ```
436
597
 
437
- ### i18n/keys.go
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
- "{module}": {
463
- "item": {
464
- "created": "{实体}创建成功",
465
- "updated": "{实体}更新成功",
466
- "deleted": "{实体}删除成功",
467
- "notFound": "{实体}不存在",
468
- "invalid": "{实体}数据无效",
469
- "alreadyExists": "{实体}已存在"
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
- "nameTooLong": "名称长度不能超过 {{max}} 个字符"
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
- "{module}": {
483
- "item": {
484
- "created": "{Entity} created successfully",
485
- "updated": "{Entity} updated successfully",
486
- "deleted": "{Entity} deleted successfully",
487
- "notFound": "{Entity} not found",
488
- "invalid": "Invalid {entity} data",
489
- "alreadyExists": "{Entity} already exists"
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
- "nameTooLong": "Name cannot exceed {{max}} characters"
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
- ### 使用翻译的 service.go
654
+ ### 11. bootstrap/migrations/migrate.go
655
+
500
656
  ```go
501
- package service
657
+ package migrations
502
658
 
503
659
  import (
504
- "errors"
505
- "github.com/gin-gonic/gin"
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 (s *Service) Get(c *gin.Context, id string) (*models.{Entity}, error) {
511
- item, err := s.repo.Get(c, id)
512
- if err != nil {
513
- // 使用翻译键返回错误
514
- return nil, errors.New(i18n.T(c, modulei18n.KeyItemNotFound))
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
- return item, nil
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
- func (s *Service) Create(c *gin.Context, entity *models.{Entity}) error {
520
- if entity.Name == "" {
521
- return errors.New(i18n.T(c, modulei18n.KeyNameRequired))
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
- err := s.repo.Create(c, entity)
525
- if err != nil {
526
- return err
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
- return nil
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
  ```