@robsun/create-keystone-app 0.2.13 → 0.4.0

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 (88) hide show
  1. package/README.md +46 -43
  2. package/dist/create-keystone-app.js +347 -10
  3. package/dist/create-module.js +1219 -607
  4. package/package.json +22 -23
  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 +81 -73
  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 +60 -18
  23. package/template/apps/server/go.sum +183 -31
  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 +10 -7
  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 -103
  72. package/template/.claude/skills/keystone-dev/references/APPROVAL.md +0 -121
  73. package/template/.claude/skills/keystone-dev/references/CAPABILITIES.md +0 -261
  74. package/template/.claude/skills/keystone-dev/references/TEMPLATES.md +0 -532
  75. package/template/.claude/skills/keystone-dev/references/TESTING.md +0 -44
  76. package/template/.codex/skills/keystone-dev/SKILL.md +0 -103
  77. package/template/.codex/skills/keystone-dev/references/APPROVAL.md +0 -121
  78. package/template/.codex/skills/keystone-dev/references/CAPABILITIES.md +0 -261
  79. package/template/.codex/skills/keystone-dev/references/TEMPLATES.md +0 -532
  80. package/template/.codex/skills/keystone-dev/references/TESTING.md +0 -44
  81. package/template/apps/server/internal/app/routes/module_routes.go +0 -16
  82. package/template/apps/server/internal/app/routes/routes.go +0 -226
  83. package/template/apps/server/internal/app/startup/startup.go +0 -74
  84. package/template/apps/server/internal/frontend/handler.go +0 -122
  85. package/template/apps/server/internal/modules/registry.go +0 -145
  86. package/template/apps/web/src/modules/example/help/faq.md +0 -23
  87. package/template/apps/web/src/modules/example/help/items.md +0 -26
  88. package/template/apps/web/src/modules/example/help/overview.md +0 -25
@@ -0,0 +1,1088 @@
1
+ # Keystone 代码模式库
2
+
3
+ ---
4
+
5
+ ## 一、架构概览
6
+
7
+ ### 目录结构
8
+
9
+ **后端:**
10
+ ```
11
+ apps/server/internal/modules/{module}/
12
+ ├── module.go # 模块入口
13
+ ├── api/handler/ # HTTP Handler
14
+ ├── domain/models/ # 数据模型
15
+ ├── domain/service/ # 业务逻辑 + errors.go
16
+ ├── infra/repository/ # 数据库操作
17
+ └── i18n/ # keys.go + i18n.go + locales/
18
+ ```
19
+
20
+ **前端:**
21
+ ```
22
+ apps/web/src/modules/{module}/
23
+ ├── index.ts # 模块注册
24
+ ├── routes.tsx # 路由配置
25
+ ├── types.ts # 类型定义
26
+ ├── services/api.ts # API 调用
27
+ ├── pages/ # 页面组件
28
+ └── locales/ # i18n 文件
29
+ ```
30
+
31
+ ### 分层职责
32
+
33
+ | 层 | 职责 | 禁止 |
34
+ |---|------|------|
35
+ | Handler | 请求解析、响应格式化 | 业务逻辑、直接操作 DB |
36
+ | Service | 业务逻辑、数据验证 | 直接操作 DB、HTTP 相关 |
37
+ | Repository | 数据库操作、租户过滤 | 业务逻辑 |
38
+ | Model | 数据结构、枚举验证 | 业务逻辑 |
39
+
40
+ ### 核心依赖
41
+
42
+ | 包 | 用途 |
43
+ |---|------|
44
+ | `github.com/robsuncn/keystone/api/response` | 统一响应 |
45
+ | `github.com/robsuncn/keystone/api/handler/common` | Handler 辅助 |
46
+ | `github.com/robsuncn/keystone/domain/models` | BaseModel |
47
+ | `@robsun/keystone-web-core` | api, registerModule, loadModuleLocales |
48
+
49
+ ---
50
+
51
+ ## 二、约定
52
+
53
+ ### 命名约定
54
+
55
+ | 对象 | 后端 | 前端 |
56
+ |------|------|------|
57
+ | 目录 | kebab-case | kebab-case |
58
+ | 文件 | snake_case (.go) | PascalCase (.tsx) / camelCase (.ts) |
59
+ | 类型 | PascalCase | PascalCase |
60
+ | 函数 | PascalCase | camelCase |
61
+ | 变量 | camelCase | camelCase |
62
+ | DB 表 | snake_case 复数 | - |
63
+ | JSON | snake_case | - |
64
+ | i18n key | `module.category.name` | `category.name` |
65
+
66
+ ### 字段类型映射
67
+
68
+ | YAML | Go | TypeScript | GORM |
69
+ |------|-----|------------|------|
70
+ | string | string | string | `gorm:"size:200"` |
71
+ | text | string | string | `gorm:"type:text"` |
72
+ | integer | int | number | - |
73
+ | decimal | float64 | number | `gorm:"type:decimal(10,2)"` |
74
+ | boolean | bool | boolean | - |
75
+ | date | time.Time | string | `gorm:"type:date"` |
76
+ | datetime | time.Time | string | - |
77
+ | enum | 自定义类型 | 联合类型 | `gorm:"size:20"` |
78
+
79
+ ### 占位符
80
+
81
+ | 占位符 | 说明 | 示例 |
82
+ |--------|------|------|
83
+ | `{module}` | 模块名小写 | `customer` |
84
+ | `{module-kebab}` | kebab-case | `customer-order` |
85
+ | `{Entity}` | PascalCase | `Customer` |
86
+ | `{table_name}` | snake_case 复数 | `customers` |
87
+
88
+ ---
89
+
90
+ ## 三、后端模式
91
+
92
+ ### B1: Module 入口
93
+
94
+ ```go
95
+ package {module}
96
+
97
+ import (
98
+ "github.com/gin-gonic/gin"
99
+ "gorm.io/gorm"
100
+
101
+ "github.com/robsuncn/keystone/domain/permissions"
102
+ "github.com/robsuncn/keystone/infra/jobs"
103
+
104
+ {module}handler "__APP_NAME__/apps/server/internal/modules/{module}/api/handler"
105
+ {module}models "__APP_NAME__/apps/server/internal/modules/{module}/domain/models"
106
+ {module}service "__APP_NAME__/apps/server/internal/modules/{module}/domain/service"
107
+ {module}migrations "__APP_NAME__/apps/server/internal/modules/{module}/bootstrap/migrations"
108
+ {module}repository "__APP_NAME__/apps/server/internal/modules/{module}/infra/repository"
109
+ )
110
+
111
+ type Module struct {
112
+ items *{module}service.ItemService
113
+ }
114
+
115
+ func NewModule() *Module {
116
+ return &Module{}
117
+ }
118
+
119
+ func (m *Module) Name() string {
120
+ return "{module-kebab}"
121
+ }
122
+
123
+ func (m *Module) RegisterRoutes(rg *gin.RouterGroup) {
124
+ if rg == nil || m == nil {
125
+ return
126
+ }
127
+ handler := {module}handler.NewItemHandler(m.items)
128
+ if handler == nil {
129
+ return
130
+ }
131
+ group := rg.Group("/{module-kebab}")
132
+ group.GET("/items", handler.List)
133
+ group.POST("/items", handler.Create)
134
+ group.PATCH("/items/:id", handler.Update)
135
+ group.DELETE("/items/:id", handler.Delete)
136
+ }
137
+
138
+ func (m *Module) RegisterModels() []interface{} {
139
+ return []interface{}{&{module}models.{Entity}{}}
140
+ }
141
+
142
+ func (m *Module) RegisterPermissions(reg *permissions.Registry) error {
143
+ if reg == nil {
144
+ return nil
145
+ }
146
+ if err := reg.CreateMenuI18n("{module-kebab}:item", "{Entity} Items",
147
+ "permission.{module}.item", "{module-kebab}", 10); err != nil {
148
+ return err
149
+ }
150
+ if err := reg.CreateActionI18n("{module-kebab}:item:view", "View Items",
151
+ "permission.{module}.item.view", "{module-kebab}", "{module-kebab}:item"); err != nil {
152
+ return err
153
+ }
154
+ if err := reg.CreateActionI18n("{module-kebab}:item:manage", "Manage Items",
155
+ "permission.{module}.item.manage", "{module-kebab}", "{module-kebab}:item"); err != nil {
156
+ return err
157
+ }
158
+ return nil
159
+ }
160
+
161
+ func (m *Module) RegisterJobs(_ *jobs.Registry) error {
162
+ return nil
163
+ }
164
+
165
+ func (m *Module) Migrate(db *gorm.DB) error {
166
+ if db == nil {
167
+ return nil
168
+ }
169
+ m.ensureServices(db)
170
+ return {module}migrations.Migrate(db)
171
+ }
172
+
173
+ func (m *Module) Seed(_ *gorm.DB) error {
174
+ return nil
175
+ }
176
+
177
+ func (m *Module) ensureServices(db *gorm.DB) {
178
+ if m == nil || db == nil || m.items != nil {
179
+ return
180
+ }
181
+ repo := {module}repository.NewItemRepository(db)
182
+ m.items = {module}service.NewItemService(repo)
183
+ }
184
+ ```
185
+
186
+ ### B2: Model
187
+
188
+ ```go
189
+ package models
190
+
191
+ import "github.com/robsuncn/keystone/domain/models"
192
+
193
+ type ItemStatus string
194
+
195
+ const (
196
+ StatusActive ItemStatus = "active"
197
+ StatusInactive ItemStatus = "inactive"
198
+ )
199
+
200
+ func (s ItemStatus) IsValid() bool {
201
+ switch s {
202
+ case StatusActive, StatusInactive:
203
+ return true
204
+ default:
205
+ return false
206
+ }
207
+ }
208
+
209
+ type {Entity} struct {
210
+ models.BaseModel
211
+ Name string `gorm:"size:200;not null" json:"name"`
212
+ Description string `gorm:"size:1000" json:"description"`
213
+ Status ItemStatus `gorm:"size:20;not null;default:'active'" json:"status"`
214
+ }
215
+
216
+ func ({Entity}) TableName() string {
217
+ return "{table_name}"
218
+ }
219
+ ```
220
+
221
+ ### B3: Handler
222
+
223
+ ```go
224
+ package handler
225
+
226
+ import (
227
+ "errors"
228
+
229
+ "github.com/gin-gonic/gin"
230
+ hcommon "github.com/robsuncn/keystone/api/handler/common"
231
+ "github.com/robsuncn/keystone/api/response"
232
+ "github.com/robsuncn/keystone/infra/i18n"
233
+
234
+ "{module}models" "__APP_NAME__/apps/server/internal/modules/{module}/domain/models"
235
+ "{module}service" "__APP_NAME__/apps/server/internal/modules/{module}/domain/service"
236
+ modulei18n "__APP_NAME__/apps/server/internal/modules/{module}/i18n"
237
+ )
238
+
239
+ type ItemHandler struct {
240
+ svc *{module}service.ItemService
241
+ }
242
+
243
+ func NewItemHandler(svc *{module}service.ItemService) *ItemHandler {
244
+ if svc == nil {
245
+ return nil
246
+ }
247
+ return &ItemHandler{svc: svc}
248
+ }
249
+
250
+ type itemInput struct {
251
+ Name string `json:"name"`
252
+ Description string `json:"description"`
253
+ Status {module}models.ItemStatus `json:"status"`
254
+ }
255
+
256
+ type itemUpdateInput struct {
257
+ Name *string `json:"name"`
258
+ Description *string `json:"description"`
259
+ Status *{module}models.ItemStatus `json:"status"`
260
+ }
261
+
262
+ const defaultTenantID uint = 1
263
+
264
+ func resolveTenantID(c *gin.Context) uint {
265
+ if c == nil {
266
+ return defaultTenantID
267
+ }
268
+ if tenantID, ok := hcommon.GetTenantID(c); ok && tenantID > 0 {
269
+ return tenantID
270
+ }
271
+ return defaultTenantID
272
+ }
273
+
274
+ func (h *ItemHandler) List(c *gin.Context) {
275
+ if h == nil || h.svc == nil {
276
+ response.ServiceUnavailableI18n(c, modulei18n.MsgServiceUnavailable)
277
+ return
278
+ }
279
+ tenantID := resolveTenantID(c)
280
+ items, err := h.svc.List(c.Request.Context(), tenantID)
281
+ if err != nil {
282
+ response.InternalErrorI18n(c, modulei18n.MsgItemLoadFailed)
283
+ return
284
+ }
285
+ response.Success(c, gin.H{"items": items})
286
+ }
287
+
288
+ func (h *ItemHandler) Create(c *gin.Context) {
289
+ if h == nil || h.svc == nil {
290
+ response.ServiceUnavailableI18n(c, modulei18n.MsgServiceUnavailable)
291
+ return
292
+ }
293
+ tenantID := resolveTenantID(c)
294
+ var input itemInput
295
+ if err := c.ShouldBindJSON(&input); err != nil {
296
+ response.BadRequestI18n(c, modulei18n.MsgInvalidPayload)
297
+ return
298
+ }
299
+ item, err := h.svc.Create(c.Request.Context(), tenantID, {module}service.ItemInput{
300
+ Name: input.Name,
301
+ Description: input.Description,
302
+ Status: input.Status,
303
+ })
304
+ if err != nil {
305
+ handleServiceError(c, err)
306
+ return
307
+ }
308
+ response.CreatedI18n(c, modulei18n.MsgItemCreated, item)
309
+ }
310
+
311
+ func (h *ItemHandler) Update(c *gin.Context) {
312
+ if h == nil || h.svc == nil {
313
+ response.ServiceUnavailableI18n(c, modulei18n.MsgServiceUnavailable)
314
+ return
315
+ }
316
+ tenantID := resolveTenantID(c)
317
+ id, err := hcommon.ParseUintParam(c, "id")
318
+ if err != nil || id == 0 {
319
+ response.BadRequestI18n(c, modulei18n.MsgInvalidID)
320
+ return
321
+ }
322
+ var input itemUpdateInput
323
+ if err := c.ShouldBindJSON(&input); err != nil {
324
+ response.BadRequestI18n(c, modulei18n.MsgInvalidPayload)
325
+ return
326
+ }
327
+ item, err := h.svc.Update(c.Request.Context(), tenantID, id, {module}service.ItemUpdateInput{
328
+ Name: input.Name,
329
+ Description: input.Description,
330
+ Status: input.Status,
331
+ })
332
+ if err != nil {
333
+ handleServiceError(c, err)
334
+ return
335
+ }
336
+ response.SuccessI18n(c, modulei18n.MsgItemUpdated, item)
337
+ }
338
+
339
+ func (h *ItemHandler) Delete(c *gin.Context) {
340
+ if h == nil || h.svc == nil {
341
+ response.ServiceUnavailableI18n(c, modulei18n.MsgServiceUnavailable)
342
+ return
343
+ }
344
+ tenantID := resolveTenantID(c)
345
+ id, err := hcommon.ParseUintParam(c, "id")
346
+ if err != nil || id == 0 {
347
+ response.BadRequestI18n(c, modulei18n.MsgInvalidID)
348
+ return
349
+ }
350
+ if err := h.svc.Delete(c.Request.Context(), tenantID, id); err != nil {
351
+ handleServiceError(c, err)
352
+ return
353
+ }
354
+ response.SuccessI18n(c, modulei18n.MsgItemDeleted, gin.H{"id": id})
355
+ }
356
+
357
+ func handleServiceError(c *gin.Context, err error) {
358
+ var i18nErr *i18n.I18nError
359
+ if errors.As(err, &i18nErr) {
360
+ if i18nErr.Key == modulei18n.MsgItemNotFound {
361
+ response.NotFoundI18n(c, i18nErr.Key)
362
+ } else {
363
+ response.BadRequestI18n(c, i18nErr.Key)
364
+ }
365
+ return
366
+ }
367
+ response.InternalErrorI18n(c, modulei18n.MsgItemUpdateFailed)
368
+ }
369
+ ```
370
+
371
+ ### B4: Service
372
+
373
+ ```go
374
+ package service
375
+
376
+ import (
377
+ "context"
378
+ "strings"
379
+
380
+ "{module}models" "__APP_NAME__/apps/server/internal/modules/{module}/domain/models"
381
+ )
382
+
383
+ type ItemRepository interface {
384
+ List(ctx context.Context, tenantID uint) ([]{module}models.{Entity}, error)
385
+ FindByID(tenantID, id uint) (*{module}models.{Entity}, error)
386
+ Create(ctx context.Context, item *{module}models.{Entity}) error
387
+ Update(ctx context.Context, item *{module}models.{Entity}) error
388
+ Delete(ctx context.Context, item *{module}models.{Entity}) error
389
+ }
390
+
391
+ type ItemService struct {
392
+ repo ItemRepository
393
+ }
394
+
395
+ type ItemInput struct {
396
+ Name string
397
+ Description string
398
+ Status {module}models.ItemStatus
399
+ }
400
+
401
+ type ItemUpdateInput struct {
402
+ Name *string
403
+ Description *string
404
+ Status *{module}models.ItemStatus
405
+ }
406
+
407
+ func NewItemService(repo ItemRepository) *ItemService {
408
+ return &ItemService{repo: repo}
409
+ }
410
+
411
+ func (s *ItemService) List(ctx context.Context, tenantID uint) ([]{module}models.{Entity}, error) {
412
+ return s.repo.List(ctx, tenantID)
413
+ }
414
+
415
+ func (s *ItemService) Create(ctx context.Context, tenantID uint, input ItemInput) (*{module}models.{Entity}, error) {
416
+ name := strings.TrimSpace(input.Name)
417
+ if name == "" {
418
+ return nil, ErrNameRequired
419
+ }
420
+ status := input.Status
421
+ if status == "" {
422
+ status = {module}models.StatusActive
423
+ }
424
+ if !status.IsValid() {
425
+ return nil, ErrStatusInvalid
426
+ }
427
+ item := &{module}models.{Entity}{
428
+ Name: name,
429
+ Description: strings.TrimSpace(input.Description),
430
+ Status: status,
431
+ }
432
+ item.TenantID = tenantID
433
+ if err := s.repo.Create(ctx, item); err != nil {
434
+ return nil, err
435
+ }
436
+ return item, nil
437
+ }
438
+
439
+ func (s *ItemService) Update(ctx context.Context, tenantID, id uint, input ItemUpdateInput) (*{module}models.{Entity}, error) {
440
+ item, err := s.repo.FindByID(tenantID, id)
441
+ if err != nil {
442
+ return nil, err
443
+ }
444
+ if input.Name != nil {
445
+ name := strings.TrimSpace(*input.Name)
446
+ if name == "" {
447
+ return nil, ErrNameRequired
448
+ }
449
+ item.Name = name
450
+ }
451
+ if input.Description != nil {
452
+ item.Description = strings.TrimSpace(*input.Description)
453
+ }
454
+ if input.Status != nil {
455
+ if !input.Status.IsValid() {
456
+ return nil, ErrStatusInvalid
457
+ }
458
+ item.Status = *input.Status
459
+ }
460
+ if err := s.repo.Update(ctx, item); err != nil {
461
+ return nil, err
462
+ }
463
+ return item, nil
464
+ }
465
+
466
+ func (s *ItemService) Delete(ctx context.Context, tenantID, id uint) error {
467
+ item, err := s.repo.FindByID(tenantID, id)
468
+ if err != nil {
469
+ return err
470
+ }
471
+ return s.repo.Delete(ctx, item)
472
+ }
473
+ ```
474
+
475
+ ### B5: Errors
476
+
477
+ ```go
478
+ package service
479
+
480
+ import (
481
+ "github.com/robsuncn/keystone/infra/i18n"
482
+ modulei18n "__APP_NAME__/apps/server/internal/modules/{module}/i18n"
483
+ )
484
+
485
+ var (
486
+ ErrItemNotFound = &i18n.I18nError{Key: modulei18n.MsgItemNotFound}
487
+ ErrNameRequired = &i18n.I18nError{Key: modulei18n.MsgNameRequired}
488
+ ErrStatusInvalid = &i18n.I18nError{Key: modulei18n.MsgStatusInvalid}
489
+ )
490
+ ```
491
+
492
+ ### B6: Repository
493
+
494
+ ```go
495
+ package repository
496
+
497
+ import (
498
+ "context"
499
+ "errors"
500
+
501
+ "gorm.io/gorm"
502
+
503
+ "{module}models" "__APP_NAME__/apps/server/internal/modules/{module}/domain/models"
504
+ "{module}service" "__APP_NAME__/apps/server/internal/modules/{module}/domain/service"
505
+ )
506
+
507
+ type ItemRepository struct {
508
+ db *gorm.DB
509
+ }
510
+
511
+ func NewItemRepository(db *gorm.DB) *ItemRepository {
512
+ return &ItemRepository{db: db}
513
+ }
514
+
515
+ func (r *ItemRepository) List(ctx context.Context, tenantID uint) ([]{module}models.{Entity}, error) {
516
+ var items []{module}models.{Entity}
517
+ err := r.db.WithContext(ctx).
518
+ Where("tenant_id = ?", tenantID).
519
+ Order("created_at desc").
520
+ Find(&items).Error
521
+ return items, err
522
+ }
523
+
524
+ func (r *ItemRepository) FindByID(tenantID, id uint) (*{module}models.{Entity}, error) {
525
+ var item {module}models.{Entity}
526
+ err := r.db.Where("tenant_id = ? AND id = ?", tenantID, id).First(&item).Error
527
+ if errors.Is(err, gorm.ErrRecordNotFound) {
528
+ return nil, {module}service.ErrItemNotFound
529
+ }
530
+ return &item, err
531
+ }
532
+
533
+ func (r *ItemRepository) Create(ctx context.Context, item *{module}models.{Entity}) error {
534
+ return r.db.WithContext(ctx).Create(item).Error
535
+ }
536
+
537
+ func (r *ItemRepository) Update(ctx context.Context, item *{module}models.{Entity}) error {
538
+ return r.db.WithContext(ctx).Save(item).Error
539
+ }
540
+
541
+ func (r *ItemRepository) Delete(ctx context.Context, item *{module}models.{Entity}) error {
542
+ return r.db.WithContext(ctx).Delete(item).Error
543
+ }
544
+ ```
545
+
546
+ ### B7: i18n Keys
547
+
548
+ ```go
549
+ package {module}i18n
550
+
551
+ const (
552
+ MsgItemCreated = "{module-kebab}.item.created"
553
+ MsgItemUpdated = "{module-kebab}.item.updated"
554
+ MsgItemDeleted = "{module-kebab}.item.deleted"
555
+ MsgItemNotFound = "{module-kebab}.item.notFound"
556
+ MsgItemLoadFailed = "{module-kebab}.item.loadFailed"
557
+ MsgItemCreateFailed = "{module-kebab}.item.createFailed"
558
+ MsgItemUpdateFailed = "{module-kebab}.item.updateFailed"
559
+ MsgItemDeleteFailed = "{module-kebab}.item.deleteFailed"
560
+ MsgNameRequired = "{module-kebab}.validation.nameRequired"
561
+ MsgStatusInvalid = "{module-kebab}.validation.statusInvalid"
562
+ MsgInvalidID = "{module-kebab}.validation.invalidId"
563
+ MsgInvalidPayload = "{module-kebab}.validation.invalidPayload"
564
+ MsgServiceUnavailable = "{module-kebab}.service.unavailable"
565
+ )
566
+ ```
567
+
568
+ ### B8: i18n Registration
569
+
570
+ ```go
571
+ package {module}i18n
572
+
573
+ import (
574
+ "embed"
575
+ "github.com/robsuncn/keystone/infra/i18n"
576
+ )
577
+
578
+ //go:embed locales/*.json
579
+ var localeFS embed.FS
580
+
581
+ func RegisterLocales() error {
582
+ return i18n.LoadModuleLocales(localeFS, "locales")
583
+ }
584
+ ```
585
+
586
+ ### B9: i18n Locales
587
+
588
+ **zh-CN.json:**
589
+ ```json
590
+ {
591
+ "{module-kebab}.item.created": "创建成功",
592
+ "{module-kebab}.item.updated": "更新成功",
593
+ "{module-kebab}.item.deleted": "删除成功",
594
+ "{module-kebab}.item.notFound": "记录不存在",
595
+ "{module-kebab}.item.loadFailed": "加载失败",
596
+ "{module-kebab}.validation.nameRequired": "名称不能为空",
597
+ "{module-kebab}.validation.statusInvalid": "状态值无效",
598
+ "{module-kebab}.validation.invalidId": "无效的 ID",
599
+ "{module-kebab}.validation.invalidPayload": "请求参数无效",
600
+ "{module-kebab}.service.unavailable": "服务暂不可用",
601
+ "permission.{module}.item": "{Entity}管理",
602
+ "permission.{module}.item.view": "查看{Entity}",
603
+ "permission.{module}.item.manage": "管理{Entity}"
604
+ }
605
+ ```
606
+
607
+ **en-US.json:**
608
+ ```json
609
+ {
610
+ "{module-kebab}.item.created": "Created successfully",
611
+ "{module-kebab}.item.updated": "Updated successfully",
612
+ "{module-kebab}.item.deleted": "Deleted successfully",
613
+ "{module-kebab}.item.notFound": "Record not found",
614
+ "{module-kebab}.item.loadFailed": "Failed to load",
615
+ "{module-kebab}.validation.nameRequired": "Name is required",
616
+ "{module-kebab}.validation.statusInvalid": "Invalid status value",
617
+ "{module-kebab}.validation.invalidId": "Invalid ID",
618
+ "{module-kebab}.validation.invalidPayload": "Invalid request payload",
619
+ "{module-kebab}.service.unavailable": "Service unavailable",
620
+ "permission.{module}.item": "{Entity} Items",
621
+ "permission.{module}.item.view": "View {Entity}",
622
+ "permission.{module}.item.manage": "Manage {Entity}"
623
+ }
624
+ ```
625
+
626
+ ---
627
+
628
+ ## 四、前端模式
629
+
630
+ ### F1: 模块入口
631
+
632
+ ```typescript
633
+ import { registerModule, loadModuleLocales } from '@robsun/keystone-web-core'
634
+ import { {module}Routes } from './routes'
635
+
636
+ loadModuleLocales('{module}', {
637
+ 'zh-CN': () => import('./locales/zh-CN/{module}.json'),
638
+ 'en-US': () => import('./locales/en-US/{module}.json'),
639
+ })
640
+
641
+ registerModule({ name: '{module}', routes: {module}Routes })
642
+ ```
643
+
644
+ ### F2: 路由配置
645
+
646
+ ```typescript
647
+ import { lazy, Suspense, type ComponentType, type ReactElement } from 'react'
648
+ import type { RouteObject } from 'react-router-dom'
649
+ import { AppstoreOutlined } from '@ant-design/icons'
650
+ import { Spin } from 'antd'
651
+
652
+ const lazyNamed = <T extends Record<string, ComponentType>, K extends keyof T>(
653
+ factory: () => Promise<T>,
654
+ name: K
655
+ ) =>
656
+ lazy(async () => {
657
+ const module = await factory()
658
+ return { default: module[name] }
659
+ })
660
+
661
+ const withSuspense = (element: ReactElement) => (
662
+ <Suspense fallback={<div style={{ padding: 24, display: 'flex', justifyContent: 'center' }}><Spin /></div>}>
663
+ {element}
664
+ </Suspense>
665
+ )
666
+
667
+ const {Entity}ItemsPage = lazyNamed(() => import('./pages/{Entity}ItemsPage'), '{Entity}ItemsPage')
668
+
669
+ export const {module}Routes: RouteObject[] = [
670
+ {
671
+ path: '{module-kebab}',
672
+ element: <{Entity}ItemsPage />,
673
+ handle: {
674
+ menu: {
675
+ labelKey: '{module}:menu.items',
676
+ icon: <AppstoreOutlined />,
677
+ permission: '{module-kebab}:item:view',
678
+ },
679
+ breadcrumbKey: '{module}:menu.items',
680
+ permission: '{module-kebab}:item:view',
681
+ },
682
+ },
683
+ ].map((route) => ({
684
+ ...route,
685
+ element: route.element ? withSuspense(route.element) : route.element,
686
+ }))
687
+ ```
688
+
689
+ ### F3: 类型定义
690
+
691
+ ```typescript
692
+ export type {Entity}Status = 'active' | 'inactive'
693
+
694
+ export interface {Entity} {
695
+ id: number
696
+ name: string
697
+ description: string
698
+ status: {Entity}Status
699
+ created_at: string
700
+ updated_at: string
701
+ }
702
+
703
+ export interface Create{Entity}Input {
704
+ name: string
705
+ description?: string
706
+ status?: {Entity}Status
707
+ }
708
+
709
+ export interface Update{Entity}Input {
710
+ name?: string
711
+ description?: string
712
+ status?: {Entity}Status
713
+ }
714
+ ```
715
+
716
+ ### F4: API 服务
717
+
718
+ ```typescript
719
+ import { api, type ApiResponse } from '@robsun/keystone-web-core'
720
+ import type { {Entity}, Create{Entity}Input, Update{Entity}Input } from '../types'
721
+
722
+ type ListResponse = { items: {Entity}[] }
723
+
724
+ export const list{Entity}Items = async () => {
725
+ const { data } = await api.get<ApiResponse<ListResponse>>('/{module-kebab}/items')
726
+ return data.data.items
727
+ }
728
+
729
+ export const create{Entity}Item = async (payload: Create{Entity}Input) => {
730
+ const { data } = await api.post<ApiResponse<{Entity}>>('/{module-kebab}/items', payload)
731
+ return data.data
732
+ }
733
+
734
+ export const update{Entity}Item = async (id: number, payload: Update{Entity}Input) => {
735
+ const { data } = await api.patch<ApiResponse<{Entity}>>(`/{module-kebab}/items/${id}`, payload)
736
+ return data.data
737
+ }
738
+
739
+ export const delete{Entity}Item = async (id: number) => {
740
+ await api.delete<ApiResponse<void>>(`/{module-kebab}/items/${id}`)
741
+ }
742
+ ```
743
+
744
+ ### F5: 列表页面
745
+
746
+ ```typescript
747
+ import { useCallback, useEffect, useMemo, useState } from 'react'
748
+ import { App, Button, Card, Form, Input, Modal, Popconfirm, Select, Space, Table, Tag, Typography } from 'antd'
749
+ import type { ColumnsType } from 'antd/es/table'
750
+ import { useTranslation } from '@robsun/keystone-web-core'
751
+ import dayjs from 'dayjs'
752
+ import { create{Entity}Item, delete{Entity}Item, list{Entity}Items, update{Entity}Item } from '../services/api'
753
+ import type { {Entity}, {Entity}Status } from '../types'
754
+
755
+ type FormValues = { name: string; description?: string; status: {Entity}Status }
756
+
757
+ const statusColors: Record<{Entity}Status, string> = { active: 'success', inactive: 'default' }
758
+
759
+ export function {Entity}ItemsPage() {
760
+ const { t } = useTranslation('{module}')
761
+ const { t: tc } = useTranslation('common')
762
+ const { message } = App.useApp()
763
+
764
+ const [items, setItems] = useState<{Entity}[]>([])
765
+ const [loading, setLoading] = useState(false)
766
+ const [modalOpen, setModalOpen] = useState(false)
767
+ const [saving, setSaving] = useState(false)
768
+ const [editingItem, setEditingItem] = useState<{Entity} | null>(null)
769
+ const [form] = Form.useForm<FormValues>()
770
+
771
+ const statusOptions = useMemo(() => [
772
+ { value: 'active', label: t('status.active') },
773
+ { value: 'inactive', label: t('status.inactive') },
774
+ ], [t])
775
+
776
+ const fetchItems = useCallback(async () => {
777
+ setLoading(true)
778
+ try {
779
+ setItems(await list{Entity}Items())
780
+ } catch (err) {
781
+ message.error(err instanceof Error ? err.message : t('messages.loadFailed'))
782
+ } finally {
783
+ setLoading(false)
784
+ }
785
+ }, [message, t])
786
+
787
+ // 初始加载只执行一次
788
+ useEffect(() => {
789
+ void fetchItems()
790
+ // eslint-disable-next-line react-hooks/exhaustive-deps
791
+ }, [])
792
+
793
+ const openCreate = useCallback(() => { setEditingItem(null); setModalOpen(true) }, [])
794
+ const openEdit = useCallback((item: {Entity}) => { setEditingItem(item); setModalOpen(true) }, [])
795
+ const closeModal = useCallback(() => { setModalOpen(false); setEditingItem(null) }, [])
796
+
797
+ useEffect(() => {
798
+ if (!modalOpen) return
799
+ form.resetFields()
800
+ if (editingItem) {
801
+ form.setFieldsValue({ name: editingItem.name, description: editingItem.description, status: editingItem.status })
802
+ } else {
803
+ form.setFieldsValue({ status: 'active' })
804
+ }
805
+ }, [editingItem, form, modalOpen])
806
+
807
+ const handleSubmit = useCallback(async () => {
808
+ let values: FormValues
809
+ try { values = await form.validateFields() } catch { return }
810
+ const payload = { name: values.name.trim(), description: values.description?.trim() ?? '', status: values.status }
811
+ setSaving(true)
812
+ try {
813
+ if (editingItem) {
814
+ await update{Entity}Item(editingItem.id, payload)
815
+ message.success(t('messages.updateSuccess'))
816
+ } else {
817
+ await create{Entity}Item(payload)
818
+ message.success(t('messages.createSuccess'))
819
+ }
820
+ closeModal()
821
+ await fetchItems()
822
+ } catch (err) {
823
+ message.error(err instanceof Error ? err.message : tc('messages.operationFailed'))
824
+ } finally {
825
+ setSaving(false)
826
+ }
827
+ }, [closeModal, editingItem, fetchItems, form, message, t, tc])
828
+
829
+ const handleDelete = useCallback(async (id: number) => {
830
+ try {
831
+ await delete{Entity}Item(id)
832
+ await fetchItems()
833
+ message.success(t('messages.deleteSuccess'))
834
+ } catch (err) {
835
+ message.error(err instanceof Error ? err.message : tc('messages.operationFailed'))
836
+ }
837
+ }, [fetchItems, message, t, tc])
838
+
839
+ const columns: ColumnsType<{Entity}> = useMemo(() => [
840
+ { title: t('fields.name'), dataIndex: 'name', key: 'name' },
841
+ { title: t('fields.description'), dataIndex: 'description', key: 'description',
842
+ render: (v: string) => v ? <Typography.Text type="secondary">{v}</Typography.Text> : '-' },
843
+ { title: t('fields.status'), dataIndex: 'status', key: 'status',
844
+ render: (v: {Entity}Status) => <Tag color={statusColors[v]}>{t(`status.${v}`)}</Tag> },
845
+ { title: tc('table.updatedAt'), dataIndex: 'updated_at', key: 'updated_at',
846
+ render: (v: string) => v ? dayjs(v).format('YYYY-MM-DD HH:mm') : '-' },
847
+ { title: tc('table.actions'), key: 'actions',
848
+ render: (_, r) => (
849
+ <Space>
850
+ <Button type="link" onClick={() => openEdit(r)}>{tc('actions.edit')}</Button>
851
+ <Popconfirm title={tc('confirm.deleteContent')} onConfirm={() => handleDelete(r.id)}>
852
+ <Button type="link" danger>{tc('actions.delete')}</Button>
853
+ </Popconfirm>
854
+ </Space>
855
+ ) },
856
+ ], [handleDelete, openEdit, t, tc])
857
+
858
+ return (
859
+ <Card title={t('page.title')} extra={
860
+ <Space>
861
+ <Button onClick={fetchItems} loading={loading}>{tc('actions.refresh')}</Button>
862
+ <Button type="primary" onClick={openCreate}>{t('page.createButton')}</Button>
863
+ </Space>
864
+ }>
865
+ <Table<{Entity}> rowKey="id" loading={loading} columns={columns} dataSource={items} pagination={false} />
866
+ <Modal title={editingItem ? tc('actions.edit') : tc('actions.create')} open={modalOpen}
867
+ onCancel={closeModal} onOk={handleSubmit} confirmLoading={saving}
868
+ okText={editingItem ? tc('actions.save') : tc('actions.create')} destroyOnHidden>
869
+ <Form form={form} layout="vertical" initialValues={{ status: 'active' }}>
870
+ <Form.Item label={t('form.nameLabel')} name="name"
871
+ rules={[{ required: true, whitespace: true, message: tc('form.required') }]}>
872
+ <Input placeholder={t('form.namePlaceholder')} allowClear />
873
+ </Form.Item>
874
+ <Form.Item label={t('form.descriptionLabel')} name="description">
875
+ <Input.TextArea rows={3} placeholder={t('form.descriptionPlaceholder')} />
876
+ </Form.Item>
877
+ <Form.Item label={t('fields.status')} name="status" rules={[{ required: true, message: tc('form.required') }]}>
878
+ <Select options={statusOptions} />
879
+ </Form.Item>
880
+ </Form>
881
+ </Modal>
882
+ </Card>
883
+ )
884
+ }
885
+ ```
886
+
887
+ ### F6: i18n Locales
888
+
889
+ **zh-CN/{module}.json:**
890
+ ```json
891
+ {
892
+ "menu": { "items": "{Entity}管理" },
893
+ "page": { "title": "{Entity}列表", "createButton": "新建" },
894
+ "fields": { "name": "名称", "description": "描述", "status": "状态" },
895
+ "form": { "nameLabel": "名称", "namePlaceholder": "请输入名称", "descriptionLabel": "描述", "descriptionPlaceholder": "请输入描述" },
896
+ "messages": { "createSuccess": "创建成功", "updateSuccess": "更新成功", "deleteSuccess": "删除成功", "loadFailed": "加载失败" },
897
+ "status": { "active": "启用", "inactive": "停用" }
898
+ }
899
+ ```
900
+
901
+ **en-US/{module}.json:**
902
+ ```json
903
+ {
904
+ "menu": { "items": "{Entity} Items" },
905
+ "page": { "title": "{Entity} List", "createButton": "Create" },
906
+ "fields": { "name": "Name", "description": "Description", "status": "Status" },
907
+ "form": { "nameLabel": "Name", "namePlaceholder": "Enter name", "descriptionLabel": "Description", "descriptionPlaceholder": "Enter description" },
908
+ "messages": { "createSuccess": "Created successfully", "updateSuccess": "Updated successfully", "deleteSuccess": "Deleted successfully", "loadFailed": "Failed to load" },
909
+ "status": { "active": "Active", "inactive": "Inactive" }
910
+ }
911
+ ```
912
+
913
+ ---
914
+
915
+ ## 五、模块注册
916
+
917
+ **后端 manifest.go:**
918
+ ```go
919
+ import {module} "internal/modules/{module}"
920
+
921
+ func RegisterModules() []core.Module {
922
+ return []core.Module{
923
+ {module}.NewModule(),
924
+ }
925
+ }
926
+ ```
927
+
928
+ **后端 config.yaml:**
929
+ ```yaml
930
+ modules:
931
+ enabled:
932
+ - {module}
933
+ ```
934
+
935
+ **前端 main.tsx:**
936
+ ```typescript
937
+ import './modules/{module}'
938
+ ```
939
+
940
+ ---
941
+
942
+ ## 六、高级模式
943
+
944
+ ### 事务处理
945
+
946
+ ```go
947
+ func (s *Service) CreateWithDetails(ctx context.Context, input Input) error {
948
+ return s.db.Transaction(func(tx *gorm.DB) error {
949
+ master := &Master{Name: input.Name}
950
+ if err := tx.Create(master).Error; err != nil {
951
+ return err
952
+ }
953
+ for _, d := range input.Details {
954
+ detail := &Detail{MasterID: master.ID, Value: d.Value}
955
+ if err := tx.Create(detail).Error; err != nil {
956
+ return err
957
+ }
958
+ }
959
+ return nil
960
+ })
961
+ }
962
+ ```
963
+
964
+ ### 审批流集成
965
+
966
+ **Model 扩展:**
967
+ ```go
968
+ type Entity struct {
969
+ models.BaseModel
970
+ Name string `json:"name"`
971
+ Status EntityStatus `json:"status"` // draft, pending, approved, rejected
972
+ ApprovalInstanceID *uint `json:"approval_instance_id"`
973
+ RejectReason string `json:"reject_reason"`
974
+ }
975
+ ```
976
+
977
+ **Service 提交审批:**
978
+ ```go
979
+ func (s *Service) Submit(ctx context.Context, tenantID, id, userID uint) error {
980
+ entity, err := s.repo.FindByID(tenantID, id)
981
+ if err != nil {
982
+ return err
983
+ }
984
+ if entity.Status != models.StatusDraft {
985
+ return ErrCannotSubmit
986
+ }
987
+ instance, err := s.approval.CreateInstance(ctx, approval.CreateInstanceInput{
988
+ TenantID: tenantID,
989
+ BusinessType: ApprovalBusinessType,
990
+ BusinessID: id,
991
+ ApplicantID: userID,
992
+ Context: map[string]interface{}{"name": entity.Name},
993
+ })
994
+ if err != nil {
995
+ return err
996
+ }
997
+ entity.Status = models.StatusPending
998
+ entity.ApprovalInstanceID = &instance.ID
999
+ return s.repo.Update(ctx, entity)
1000
+ }
1001
+ ```
1002
+
1003
+ **审批回调:**
1004
+ ```go
1005
+ func (c *ApprovalCallback) OnApproved(ctx context.Context, tenantID, businessID, approverID uint) error {
1006
+ return c.repo.UpdateStatus(ctx, tenantID, businessID, models.StatusApproved)
1007
+ }
1008
+
1009
+ func (c *ApprovalCallback) OnRejected(ctx context.Context, tenantID, businessID, approverID uint, reason string) error {
1010
+ entity, _ := c.repo.FindByID(tenantID, businessID)
1011
+ entity.Status = models.StatusRejected
1012
+ entity.RejectReason = reason
1013
+ return c.repo.Update(ctx, entity)
1014
+ }
1015
+ ```
1016
+
1017
+ ### 数据权限
1018
+
1019
+ ```go
1020
+ func (h *Handler) List(c *gin.Context) {
1021
+ tenantID := resolveTenantID(c)
1022
+ userID, _ := hcommon.GetUserID(c)
1023
+ scope := datascope.FromContext(c)
1024
+
1025
+ items, err := h.svc.ListWithScope(c.Request.Context(), tenantID, userID, scope)
1026
+ if err != nil {
1027
+ response.InternalErrorI18n(c, modulei18n.MsgListFailed)
1028
+ return
1029
+ }
1030
+ response.Success(c, gin.H{"items": items})
1031
+ }
1032
+ ```
1033
+
1034
+ ### 文件上传
1035
+
1036
+ ```go
1037
+ func (h *Handler) Upload(c *gin.Context) {
1038
+ file, err := c.FormFile("file")
1039
+ if err != nil {
1040
+ response.BadRequestI18n(c, modulei18n.MsgInvalidFile)
1041
+ return
1042
+ }
1043
+ if file.Size > 10*1024*1024 { // 10MB
1044
+ response.BadRequestI18n(c, modulei18n.MsgFileTooLarge)
1045
+ return
1046
+ }
1047
+ path, err := h.storage.Save(c.Request.Context(), file)
1048
+ if err != nil {
1049
+ response.InternalErrorI18n(c, modulei18n.MsgUploadFailed)
1050
+ return
1051
+ }
1052
+ response.Success(c, gin.H{"path": path})
1053
+ }
1054
+ ```
1055
+
1056
+ ### 分页查询
1057
+
1058
+ ```go
1059
+ import "github.com/robsuncn/keystone/infra/pagination"
1060
+
1061
+ func (h *Handler) List(c *gin.Context) {
1062
+ tenantID := resolveTenantID(c)
1063
+ pageReq := pagination.ParseRequest(c)
1064
+
1065
+ items, total, err := h.svc.List(c.Request.Context(), tenantID, pageReq)
1066
+ if err != nil {
1067
+ response.InternalErrorI18n(c, modulei18n.MsgListFailed)
1068
+ return
1069
+ }
1070
+ response.Success(c, pagination.NewResponse(items, total, pageReq))
1071
+ }
1072
+ ```
1073
+
1074
+ ### 软删除
1075
+
1076
+ ```go
1077
+ // 查询自动排除已删除
1078
+ db.Find(&items)
1079
+
1080
+ // 包含已删除
1081
+ db.Unscoped().Find(&items)
1082
+
1083
+ // 恢复已删除
1084
+ db.Unscoped().Model(&entity).Update("deleted_at", nil)
1085
+
1086
+ // 永久删除
1087
+ db.Unscoped().Delete(&entity)
1088
+ ```