@robsun/create-keystone-app 0.2.15 → 0.4.1

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 (96) hide show
  1. package/README.md +46 -44
  2. package/dist/create-keystone-app.js +347 -10
  3. package/dist/create-module.js +1217 -1187
  4. package/package.json +1 -1
  5. package/template/.claude/skills/keystone-implement/SKILL.md +113 -0
  6. package/template/.claude/skills/keystone-implement/references/CHECKLIST.md +91 -0
  7. package/template/.claude/skills/keystone-implement/references/PATTERNS.md +1088 -0
  8. package/template/.claude/skills/keystone-implement/references/SCHEMA.md +135 -0
  9. package/template/.claude/skills/keystone-implement/references/TESTING.md +231 -0
  10. package/template/.claude/skills/keystone-requirements/SKILL.md +296 -0
  11. package/template/.claude/skills/keystone-requirements/references/CONFIRM_TEMPLATE.md +170 -0
  12. package/template/.claude/skills/keystone-requirements/references/SCHEMA.md +135 -0
  13. package/template/.eslintrc.js +3 -0
  14. package/template/.github/workflows/ci.yml +30 -0
  15. package/template/.github/workflows/release.yml +32 -0
  16. package/template/.golangci.yml +11 -0
  17. package/template/README.md +82 -81
  18. package/template/apps/server/README.md +8 -0
  19. package/template/apps/server/cmd/server/main.go +27 -185
  20. package/template/apps/server/config.example.yaml +31 -1
  21. package/template/apps/server/config.yaml +31 -1
  22. package/template/apps/server/go.mod +61 -19
  23. package/template/apps/server/go.sum +185 -32
  24. package/template/apps/server/internal/frontend/embed.go +3 -8
  25. package/template/apps/server/internal/modules/example/README.md +18 -0
  26. package/template/apps/server/internal/modules/example/api/handler/handler_test.go +9 -0
  27. package/template/apps/server/internal/modules/example/api/handler/item_handler.go +468 -165
  28. package/template/apps/server/internal/modules/example/bootstrap/seeds/item.go +217 -8
  29. package/template/apps/server/internal/modules/example/domain/models/item.go +40 -7
  30. package/template/apps/server/internal/modules/example/domain/service/approval_callback.go +68 -0
  31. package/template/apps/server/internal/modules/example/domain/service/approval_schema.go +41 -0
  32. package/template/apps/server/internal/modules/example/domain/service/errors.go +20 -22
  33. package/template/apps/server/internal/modules/example/domain/service/item_service.go +267 -7
  34. package/template/apps/server/internal/modules/example/domain/service/item_service_test.go +281 -0
  35. package/template/apps/server/internal/modules/example/i18n/keys.go +32 -20
  36. package/template/apps/server/internal/modules/example/i18n/locales/en-US.json +30 -18
  37. package/template/apps/server/internal/modules/example/i18n/locales/zh-CN.json +30 -18
  38. package/template/apps/server/internal/modules/example/infra/exporter/item_exporter.go +119 -0
  39. package/template/apps/server/internal/modules/example/infra/importer/item_importer.go +77 -0
  40. package/template/apps/server/internal/modules/example/infra/repository/item_repository.go +99 -49
  41. package/template/apps/server/internal/modules/example/module.go +171 -97
  42. package/template/apps/server/internal/modules/example/tests/integration_test.go +7 -0
  43. package/template/apps/server/internal/modules/manifest.go +7 -7
  44. package/template/apps/web/README.md +4 -2
  45. package/template/apps/web/package.json +1 -1
  46. package/template/apps/web/src/app.config.ts +8 -6
  47. package/template/apps/web/src/index.css +7 -3
  48. package/template/apps/web/src/main.tsx +2 -5
  49. package/template/apps/web/src/modules/example/help/en-US/faq.md +27 -0
  50. package/template/apps/web/src/modules/example/help/en-US/items.md +30 -0
  51. package/template/apps/web/src/modules/example/help/en-US/overview.md +31 -0
  52. package/template/apps/web/src/modules/example/help/zh-CN/faq.md +27 -0
  53. package/template/apps/web/src/modules/example/help/zh-CN/items.md +31 -0
  54. package/template/apps/web/src/modules/example/help/zh-CN/overview.md +32 -0
  55. package/template/apps/web/src/modules/example/locales/en-US/example.json +99 -32
  56. package/template/apps/web/src/modules/example/locales/zh-CN/example.json +85 -18
  57. package/template/apps/web/src/modules/example/pages/ExampleItemsPage.tsx +840 -237
  58. package/template/apps/web/src/modules/example/services/exampleItems.ts +79 -8
  59. package/template/apps/web/src/modules/example/types.ts +14 -1
  60. package/template/apps/web/src/modules/index.ts +1 -0
  61. package/template/apps/web/vite.config.ts +9 -3
  62. package/template/docs/CONVENTIONS.md +17 -17
  63. package/template/package.json +4 -5
  64. package/template/pnpm-lock.yaml +76 -5
  65. package/template/scripts/build.bat +15 -3
  66. package/template/scripts/build.sh +9 -3
  67. package/template/scripts/check-help.js +249 -0
  68. package/template/scripts/compress-assets.js +89 -0
  69. package/template/scripts/test.bat +23 -0
  70. package/template/scripts/test.sh +16 -0
  71. package/template/.claude/skills/keystone-dev/SKILL.md +0 -90
  72. package/template/.claude/skills/keystone-dev/references/ADVANCED_PATTERNS.md +0 -716
  73. package/template/.claude/skills/keystone-dev/references/APPROVAL.md +0 -121
  74. package/template/.claude/skills/keystone-dev/references/CAPABILITIES.md +0 -261
  75. package/template/.claude/skills/keystone-dev/references/CHECKLIST.md +0 -285
  76. package/template/.claude/skills/keystone-dev/references/GOTCHAS.md +0 -390
  77. package/template/.claude/skills/keystone-dev/references/PATTERNS.md +0 -605
  78. package/template/.claude/skills/keystone-dev/references/TEMPLATES.md +0 -2710
  79. package/template/.claude/skills/keystone-dev/references/TESTING.md +0 -44
  80. package/template/.codex/skills/keystone-dev/SKILL.md +0 -90
  81. package/template/.codex/skills/keystone-dev/references/ADVANCED_PATTERNS.md +0 -716
  82. package/template/.codex/skills/keystone-dev/references/APPROVAL.md +0 -121
  83. package/template/.codex/skills/keystone-dev/references/CAPABILITIES.md +0 -261
  84. package/template/.codex/skills/keystone-dev/references/CHECKLIST.md +0 -285
  85. package/template/.codex/skills/keystone-dev/references/GOTCHAS.md +0 -390
  86. package/template/.codex/skills/keystone-dev/references/PATTERNS.md +0 -605
  87. package/template/.codex/skills/keystone-dev/references/TEMPLATES.md +0 -2710
  88. package/template/.codex/skills/keystone-dev/references/TESTING.md +0 -44
  89. package/template/apps/server/internal/app/routes/module_routes.go +0 -16
  90. package/template/apps/server/internal/app/routes/routes.go +0 -226
  91. package/template/apps/server/internal/app/startup/startup.go +0 -74
  92. package/template/apps/server/internal/frontend/handler.go +0 -122
  93. package/template/apps/server/internal/modules/registry.go +0 -145
  94. package/template/apps/web/src/modules/example/help/faq.md +0 -23
  95. package/template/apps/web/src/modules/example/help/items.md +0 -26
  96. package/template/apps/web/src/modules/example/help/overview.md +0 -25
@@ -1,2710 +0,0 @@
1
- # Keystone 代码模板
2
-
3
- > 所有模板基于 example 模块实际代码,使用占位符标记变量部分。
4
- > 复制后替换占位符即可运行。
5
-
6
- ## 占位符说明
7
-
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 中定义) | 脚手架自动替换 |
16
-
17
- ---
18
-
19
- ## 后端模板
20
-
21
- ### 1. module.go (模块入口)
22
-
23
- ```go
24
- package __MODULE__
25
-
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
- )
41
-
42
- type Module struct {
43
- svc *service.__ENTITY__Service
44
- }
45
-
46
- func NewModule() *Module {
47
- return &Module{}
48
- }
49
-
50
- func (m *Module) Name() string {
51
- return "__MODULE__"
52
- }
53
-
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)
68
- }
69
-
70
- func (m *Module) RegisterModels() []interface{} {
71
- return []interface{}{&models.__ENTITY__{}}
72
- }
73
-
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
108
- }
109
-
110
- func (m *Module) RegisterI18n() error {
111
- return modulei18n.RegisterLocales()
112
- }
113
-
114
- func (m *Module) RegisterJobs(_ *jobs.Registry) error {
115
- return nil
116
- }
117
-
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)
124
- }
125
-
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)
132
- }
133
-
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)
140
- }
141
- ```
142
-
143
- ### 2. domain/models/entity.go (模型)
144
-
145
- ```go
146
- package models
147
-
148
- import "github.com/robsuncn/keystone/domain/models"
149
-
150
- type __ENTITY__Status string
151
-
152
- const (
153
- Status__ENTITY__Active __ENTITY__Status = "active"
154
- Status__ENTITY__Inactive __ENTITY__Status = "inactive"
155
- )
156
-
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
- }
165
-
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__"
175
- }
176
- ```
177
-
178
- ### 3. domain/service/service.go (服务层)
179
-
180
- ```go
181
- package service
182
-
183
- import (
184
- "context"
185
- "strings"
186
-
187
- "__APP_NAME__/apps/server/internal/modules/__MODULE__/domain/models"
188
- )
189
-
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
- }
201
-
202
- type __ENTITY__Input struct {
203
- Name string
204
- Description string
205
- Status models.__ENTITY__Status
206
- }
207
-
208
- type __ENTITY__UpdateInput struct {
209
- Name *string
210
- Description *string
211
- Status *models.__ENTITY__Status
212
- }
213
-
214
- func New__ENTITY__Service(repo __ENTITY__Repository) *__ENTITY__Service {
215
- return &__ENTITY__Service{repo: repo}
216
- }
217
-
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
282
- }
283
-
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
- }
291
- ```
292
-
293
- ### 4. domain/service/errors.go (错误定义)
294
-
295
- ```go
296
- package service
297
-
298
- import (
299
- "github.com/robsuncn/keystone/infra/i18n"
300
- modulei18n "__APP_NAME__/apps/server/internal/modules/__MODULE__/i18n"
301
- )
302
-
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
- )
308
- ```
309
-
310
- ### 5. api/handler/handler.go (HTTP 处理器)
311
-
312
- ```go
313
- package handler
314
-
315
- import (
316
- "errors"
317
-
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"
322
-
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
330
- }
331
-
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
- }
344
-
345
- type updateInput struct {
346
- Name *string `json:"name"`
347
- Description *string `json:"description"`
348
- Status *models.__ENTITY__Status `json:"status"`
349
- }
350
-
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})
367
- }
368
-
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})
386
- }
387
-
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)
417
- }
418
-
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)
458
- }
459
-
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})
486
- }
487
-
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
496
- }
497
- ```
498
-
499
- ### 6. infra/repository/repository.go (数据仓库)
500
-
501
- ```go
502
- package repository
503
-
504
- import (
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"
512
- )
513
-
514
- type __ENTITY__Repository struct {
515
- db *gorm.DB
516
- }
517
-
518
- func New__ENTITY__Repository(db *gorm.DB) *__ENTITY__Repository {
519
- return &__ENTITY__Repository{db: db}
520
- }
521
-
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
529
- }
530
-
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
538
- }
539
-
540
- func (r *__ENTITY__Repository) Create(ctx context.Context, entity *models.__ENTITY__) error {
541
- return r.db.WithContext(ctx).Create(entity).Error
542
- }
543
-
544
- func (r *__ENTITY__Repository) Update(ctx context.Context, entity *models.__ENTITY__) error {
545
- return r.db.WithContext(ctx).Save(entity).Error
546
- }
547
-
548
- func (r *__ENTITY__Repository) Delete(ctx context.Context, entity *models.__ENTITY__) error {
549
- return r.db.WithContext(ctx).Delete(entity).Error
550
- }
551
- ```
552
-
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
-
582
- ```go
583
- package modulei18n
584
-
585
- import (
586
- "embed"
587
- "github.com/robsuncn/keystone/infra/i18n"
588
- )
589
-
590
- //go:embed locales/*.json
591
- var translations embed.FS
592
-
593
- func RegisterLocales() error {
594
- return i18n.LoadModuleTranslations("__MODULE__", translations)
595
- }
596
- ```
597
-
598
- ### 9. i18n/locales/zh-CN.json
599
-
600
- ```json
601
- {
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__失败"
612
- },
613
- "validation": {
614
- "nameRequired": "名称不能为空",
615
- "statusInvalid": "状态无效",
616
- "invalidId": "无效的ID",
617
- "invalidPayload": "请求数据格式错误"
618
- },
619
- "service": {
620
- "unavailable": "服务暂不可用"
621
- }
622
- }
623
- }
624
- ```
625
-
626
- ### 10. i18n/locales/en-US.json
627
-
628
- ```json
629
- {
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__"
640
- },
641
- "validation": {
642
- "nameRequired": "Name is required",
643
- "statusInvalid": "Invalid status",
644
- "invalidId": "Invalid ID",
645
- "invalidPayload": "Invalid request payload"
646
- },
647
- "service": {
648
- "unavailable": "Service unavailable"
649
- }
650
- }
651
- }
652
- ```
653
-
654
- ### 11. bootstrap/migrations/migrate.go
655
-
656
- ```go
657
- package migrations
658
-
659
- import (
660
- "gorm.io/gorm"
661
- "__APP_NAME__/apps/server/internal/modules/__MODULE__/domain/models"
662
- )
663
-
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>
766
- }
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',
815
- }
816
-
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,
894
- }
895
-
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)
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
- ```
1042
-
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
- )
2709
- }
2710
- ```