@robsun/create-keystone-app 0.2.7 → 0.2.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/create-keystone-app.js +0 -0
- package/dist/create-module.js +0 -0
- package/package.json +7 -6
- package/template/apps/server/go.mod +1 -1
- package/template/apps/server/go.sum +1 -0
- package/template/apps/server/internal/modules/example/api/handler/item_handler.go +35 -32
- package/template/apps/server/internal/modules/example/domain/service/errors.go +13 -0
- package/template/apps/server/internal/modules/example/i18n/i18n.go +16 -0
- package/template/apps/server/internal/modules/example/i18n/keys.go +23 -0
- package/template/apps/server/internal/modules/example/i18n/locales/en-US.json +18 -0
- package/template/apps/server/internal/modules/example/i18n/locales/zh-CN.json +18 -0
- package/template/apps/server/internal/modules/example/module.go +9 -3
- package/template/apps/web/package.json +3 -1
- package/template/apps/web/src/app.config.ts +8 -1
- package/template/apps/web/src/modules/example/index.ts +7 -1
- package/template/apps/web/src/modules/example/locales/en-US/example.json +32 -0
- package/template/apps/web/src/modules/example/locales/zh-CN/example.json +32 -0
- package/template/apps/web/src/modules/example/pages/ExampleItemsPage.tsx +47 -45
- package/template/apps/web/src/modules/example/routes.tsx +6 -2
- package/template/docs/CONVENTIONS.md +73 -1
- package/template/docs/I18N.md +319 -0
- package/template/package.json +1 -0
- package/template/scripts/generate-i18n-types.js +154 -0
|
File without changes
|
package/dist/create-module.js
CHANGED
|
File without changes
|
package/package.json
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
|
-
{
|
|
1
|
+
{
|
|
2
2
|
"name": "@robsun/create-keystone-app",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.8",
|
|
4
|
+
"scripts": {
|
|
5
|
+
"build": "node scripts/build.js",
|
|
6
|
+
"prepublishOnly": "node scripts/build.js && node scripts/prune-template-deps.js"
|
|
7
|
+
},
|
|
4
8
|
"publishConfig": {
|
|
5
9
|
"access": "public"
|
|
6
10
|
},
|
|
@@ -15,8 +19,5 @@
|
|
|
15
19
|
],
|
|
16
20
|
"engines": {
|
|
17
21
|
"node": ">=18"
|
|
18
|
-
},
|
|
19
|
-
"scripts": {
|
|
20
|
-
"build": "node scripts/build.js"
|
|
21
22
|
}
|
|
22
|
-
}
|
|
23
|
+
}
|
|
@@ -144,6 +144,7 @@ github.com/richardlehane/msoleps v1.0.4 h1:WuESlvhX3gH2IHcd8UqyCuFY5yiq/GR/yqaSM
|
|
|
144
144
|
github.com/richardlehane/msoleps v1.0.4/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
|
|
145
145
|
github.com/robsuncn/keystone v0.1.1 h1:0BK2lL9wGjp9/0ZWnwoNnEHqGvAkGYLQbkwlVemDSzQ=
|
|
146
146
|
github.com/robsuncn/keystone v0.1.1/go.mod h1:VPNHWG9pZi00SRC8hqy47EvfxnI795/ZC1vSkLm6x1c=
|
|
147
|
+
github.com/robsuncn/keystone v0.2.0/go.mod h1:qIpuWlWXmuwy+lEuyMDLy5FLzjRWki/oJ3nGO5jyIFc=
|
|
147
148
|
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
|
148
149
|
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
|
149
150
|
github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
|
|
@@ -6,7 +6,9 @@ import (
|
|
|
6
6
|
"github.com/gin-gonic/gin"
|
|
7
7
|
hcommon "github.com/robsuncn/keystone/api/handler/common"
|
|
8
8
|
"github.com/robsuncn/keystone/api/response"
|
|
9
|
+
"github.com/robsuncn/keystone/infra/i18n"
|
|
9
10
|
|
|
11
|
+
examplei18n "__APP_NAME__/apps/server/internal/modules/example/i18n"
|
|
10
12
|
"__APP_NAME__/apps/server/internal/modules/example/domain/models"
|
|
11
13
|
"__APP_NAME__/apps/server/internal/modules/example/domain/service"
|
|
12
14
|
)
|
|
@@ -38,14 +40,14 @@ const defaultTenantID uint = 1
|
|
|
38
40
|
|
|
39
41
|
func (h *ItemHandler) List(c *gin.Context) {
|
|
40
42
|
if h == nil || h.items == nil {
|
|
41
|
-
response.
|
|
43
|
+
response.ServiceUnavailableI18n(c, examplei18n.MsgServiceUnavailable)
|
|
42
44
|
return
|
|
43
45
|
}
|
|
44
46
|
tenantID := resolveTenantID(c)
|
|
45
47
|
|
|
46
48
|
items, err := h.items.List(c.Request.Context(), tenantID)
|
|
47
49
|
if err != nil {
|
|
48
|
-
response.
|
|
50
|
+
response.InternalErrorI18n(c, examplei18n.MsgItemLoadFailed)
|
|
49
51
|
return
|
|
50
52
|
}
|
|
51
53
|
|
|
@@ -54,14 +56,14 @@ func (h *ItemHandler) List(c *gin.Context) {
|
|
|
54
56
|
|
|
55
57
|
func (h *ItemHandler) Create(c *gin.Context) {
|
|
56
58
|
if h == nil || h.items == nil {
|
|
57
|
-
response.
|
|
59
|
+
response.ServiceUnavailableI18n(c, examplei18n.MsgServiceUnavailable)
|
|
58
60
|
return
|
|
59
61
|
}
|
|
60
62
|
tenantID := resolveTenantID(c)
|
|
61
63
|
|
|
62
64
|
var input itemInput
|
|
63
65
|
if err := c.ShouldBindJSON(&input); err != nil {
|
|
64
|
-
response.
|
|
66
|
+
response.BadRequestI18n(c, examplei18n.MsgInvalidPayload)
|
|
65
67
|
return
|
|
66
68
|
}
|
|
67
69
|
|
|
@@ -71,36 +73,34 @@ func (h *ItemHandler) Create(c *gin.Context) {
|
|
|
71
73
|
Status: input.Status,
|
|
72
74
|
})
|
|
73
75
|
if err != nil {
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
response.
|
|
77
|
-
|
|
78
|
-
response.BadRequest(c, "invalid status")
|
|
79
|
-
default:
|
|
80
|
-
response.InternalError(c, "failed to create example item")
|
|
76
|
+
var i18nErr *i18n.I18nError
|
|
77
|
+
if errors.As(err, &i18nErr) {
|
|
78
|
+
response.BadRequestI18n(c, i18nErr.Key)
|
|
79
|
+
return
|
|
81
80
|
}
|
|
81
|
+
response.InternalErrorI18n(c, examplei18n.MsgItemCreateFailed)
|
|
82
82
|
return
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
-
response.
|
|
85
|
+
response.CreatedI18n(c, examplei18n.MsgItemCreated, item)
|
|
86
86
|
}
|
|
87
87
|
|
|
88
88
|
func (h *ItemHandler) Update(c *gin.Context) {
|
|
89
89
|
if h == nil || h.items == nil {
|
|
90
|
-
response.
|
|
90
|
+
response.ServiceUnavailableI18n(c, examplei18n.MsgServiceUnavailable)
|
|
91
91
|
return
|
|
92
92
|
}
|
|
93
93
|
tenantID := resolveTenantID(c)
|
|
94
94
|
|
|
95
95
|
id, err := hcommon.ParseUintParam(c, "id")
|
|
96
96
|
if err != nil || id == 0 {
|
|
97
|
-
response.
|
|
97
|
+
response.BadRequestI18n(c, examplei18n.MsgInvalidID)
|
|
98
98
|
return
|
|
99
99
|
}
|
|
100
100
|
|
|
101
101
|
var input itemUpdateInput
|
|
102
102
|
if err := c.ShouldBindJSON(&input); err != nil {
|
|
103
|
-
response.
|
|
103
|
+
response.BadRequestI18n(c, examplei18n.MsgInvalidPayload)
|
|
104
104
|
return
|
|
105
105
|
}
|
|
106
106
|
|
|
@@ -110,45 +110,48 @@ func (h *ItemHandler) Update(c *gin.Context) {
|
|
|
110
110
|
Status: input.Status,
|
|
111
111
|
})
|
|
112
112
|
if err != nil {
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
response.InternalError(c, "failed to update example item")
|
|
113
|
+
var i18nErr *i18n.I18nError
|
|
114
|
+
if errors.As(err, &i18nErr) {
|
|
115
|
+
if i18nErr.Key == examplei18n.MsgItemNotFound {
|
|
116
|
+
response.NotFoundI18n(c, i18nErr.Key)
|
|
117
|
+
} else {
|
|
118
|
+
response.BadRequestI18n(c, i18nErr.Key)
|
|
119
|
+
}
|
|
120
|
+
return
|
|
122
121
|
}
|
|
122
|
+
response.InternalErrorI18n(c, examplei18n.MsgItemUpdateFailed)
|
|
123
123
|
return
|
|
124
124
|
}
|
|
125
125
|
|
|
126
|
-
response.
|
|
126
|
+
response.SuccessI18n(c, examplei18n.MsgItemUpdated, item)
|
|
127
127
|
}
|
|
128
128
|
|
|
129
129
|
func (h *ItemHandler) Delete(c *gin.Context) {
|
|
130
130
|
if h == nil || h.items == nil {
|
|
131
|
-
response.
|
|
131
|
+
response.ServiceUnavailableI18n(c, examplei18n.MsgServiceUnavailable)
|
|
132
132
|
return
|
|
133
133
|
}
|
|
134
134
|
tenantID := resolveTenantID(c)
|
|
135
135
|
|
|
136
136
|
id, err := hcommon.ParseUintParam(c, "id")
|
|
137
137
|
if err != nil || id == 0 {
|
|
138
|
-
response.
|
|
138
|
+
response.BadRequestI18n(c, examplei18n.MsgInvalidID)
|
|
139
139
|
return
|
|
140
140
|
}
|
|
141
141
|
|
|
142
142
|
if err := h.items.Delete(c.Request.Context(), tenantID, id); err != nil {
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
143
|
+
var i18nErr *i18n.I18nError
|
|
144
|
+
if errors.As(err, &i18nErr) {
|
|
145
|
+
if i18nErr.Key == examplei18n.MsgItemNotFound {
|
|
146
|
+
response.NotFoundI18n(c, i18nErr.Key)
|
|
147
|
+
return
|
|
148
|
+
}
|
|
146
149
|
}
|
|
147
|
-
response.
|
|
150
|
+
response.InternalErrorI18n(c, examplei18n.MsgItemDeleteFailed)
|
|
148
151
|
return
|
|
149
152
|
}
|
|
150
153
|
|
|
151
|
-
response.
|
|
154
|
+
response.SuccessI18n(c, examplei18n.MsgItemDeleted, gin.H{"id": id})
|
|
152
155
|
}
|
|
153
156
|
|
|
154
157
|
func resolveTenantID(c *gin.Context) uint {
|
|
@@ -7,3 +7,16 @@ var (
|
|
|
7
7
|
ErrTitleRequired = errors.New("title is required")
|
|
8
8
|
ErrStatusInvalid = errors.New("status is invalid")
|
|
9
9
|
)
|
|
10
|
+
|
|
11
|
+
// I18n version (uncomment when using keystone with i18n support):
|
|
12
|
+
//
|
|
13
|
+
// import (
|
|
14
|
+
// "github.com/robsuncn/keystone/infra/i18n"
|
|
15
|
+
// examplei18n "your-app/apps/server/internal/modules/example/i18n"
|
|
16
|
+
// )
|
|
17
|
+
//
|
|
18
|
+
// var (
|
|
19
|
+
// ErrItemNotFound = &i18n.I18nError{Key: examplei18n.MsgItemNotFound}
|
|
20
|
+
// ErrTitleRequired = &i18n.I18nError{Key: examplei18n.MsgTitleRequired}
|
|
21
|
+
// ErrStatusInvalid = &i18n.I18nError{Key: examplei18n.MsgStatusInvalid}
|
|
22
|
+
// )
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
package examplei18n
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"embed"
|
|
5
|
+
|
|
6
|
+
"github.com/robsuncn/keystone/infra/i18n"
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
//go:embed locales/*.json
|
|
10
|
+
var localeFS embed.FS
|
|
11
|
+
|
|
12
|
+
// RegisterLocales registers the example module's locale files with the i18n system.
|
|
13
|
+
// Call this during module initialization.
|
|
14
|
+
func RegisterLocales() error {
|
|
15
|
+
return i18n.LoadModuleLocales(localeFS, "locales")
|
|
16
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
package examplei18n
|
|
2
|
+
|
|
3
|
+
// Example module i18n message keys
|
|
4
|
+
const (
|
|
5
|
+
// Item messages
|
|
6
|
+
MsgItemCreated = "example.item.created"
|
|
7
|
+
MsgItemUpdated = "example.item.updated"
|
|
8
|
+
MsgItemDeleted = "example.item.deleted"
|
|
9
|
+
MsgItemNotFound = "example.item.notFound"
|
|
10
|
+
MsgItemLoadFailed = "example.item.loadFailed"
|
|
11
|
+
MsgItemCreateFailed = "example.item.createFailed"
|
|
12
|
+
MsgItemUpdateFailed = "example.item.updateFailed"
|
|
13
|
+
MsgItemDeleteFailed = "example.item.deleteFailed"
|
|
14
|
+
|
|
15
|
+
// Validation messages
|
|
16
|
+
MsgTitleRequired = "example.validation.titleRequired"
|
|
17
|
+
MsgStatusInvalid = "example.validation.statusInvalid"
|
|
18
|
+
MsgInvalidID = "example.validation.invalidId"
|
|
19
|
+
MsgInvalidPayload = "example.validation.invalidPayload"
|
|
20
|
+
|
|
21
|
+
// Service messages
|
|
22
|
+
MsgServiceUnavailable = "example.service.unavailable"
|
|
23
|
+
)
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"example.item.created": "Item created successfully",
|
|
3
|
+
"example.item.updated": "Item updated successfully",
|
|
4
|
+
"example.item.deleted": "Item deleted successfully",
|
|
5
|
+
"example.item.notFound": "Item not found",
|
|
6
|
+
"example.item.loadFailed": "Failed to load items",
|
|
7
|
+
"example.item.createFailed": "Failed to create item",
|
|
8
|
+
"example.item.updateFailed": "Failed to update item",
|
|
9
|
+
"example.item.deleteFailed": "Failed to delete item",
|
|
10
|
+
"example.validation.titleRequired": "Title is required",
|
|
11
|
+
"example.validation.statusInvalid": "Invalid status value",
|
|
12
|
+
"example.validation.invalidId": "Invalid ID",
|
|
13
|
+
"example.validation.invalidPayload": "Invalid request payload",
|
|
14
|
+
"example.service.unavailable": "Example service is unavailable",
|
|
15
|
+
"permission.example.item": "Example Items",
|
|
16
|
+
"permission.example.item.view": "View Items",
|
|
17
|
+
"permission.example.item.manage": "Manage Items"
|
|
18
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"example.item.created": "项目创建成功",
|
|
3
|
+
"example.item.updated": "项目更新成功",
|
|
4
|
+
"example.item.deleted": "项目删除成功",
|
|
5
|
+
"example.item.notFound": "项目不存在",
|
|
6
|
+
"example.item.loadFailed": "加载项目列表失败",
|
|
7
|
+
"example.item.createFailed": "创建项目失败",
|
|
8
|
+
"example.item.updateFailed": "更新项目失败",
|
|
9
|
+
"example.item.deleteFailed": "删除项目失败",
|
|
10
|
+
"example.validation.titleRequired": "标题不能为空",
|
|
11
|
+
"example.validation.statusInvalid": "状态值无效",
|
|
12
|
+
"example.validation.invalidId": "无效的 ID",
|
|
13
|
+
"example.validation.invalidPayload": "请求参数无效",
|
|
14
|
+
"example.service.unavailable": "示例服务暂不可用",
|
|
15
|
+
"permission.example.item": "示例项目",
|
|
16
|
+
"permission.example.item.view": "查看项目",
|
|
17
|
+
"permission.example.item.manage": "管理项目"
|
|
18
|
+
}
|
|
@@ -12,6 +12,7 @@ import (
|
|
|
12
12
|
exampleseeds "__APP_NAME__/apps/server/internal/modules/example/bootstrap/seeds"
|
|
13
13
|
examplemodels "__APP_NAME__/apps/server/internal/modules/example/domain/models"
|
|
14
14
|
exampleservice "__APP_NAME__/apps/server/internal/modules/example/domain/service"
|
|
15
|
+
examplei18n "__APP_NAME__/apps/server/internal/modules/example/i18n"
|
|
15
16
|
examplerepository "__APP_NAME__/apps/server/internal/modules/example/infra/repository"
|
|
16
17
|
)
|
|
17
18
|
|
|
@@ -50,18 +51,23 @@ func (m *Module) RegisterPermissions(reg *permissions.Registry) error {
|
|
|
50
51
|
if reg == nil {
|
|
51
52
|
return nil
|
|
52
53
|
}
|
|
53
|
-
|
|
54
|
+
// Use NameKey for i18n support
|
|
55
|
+
if err := reg.CreateMenuI18n("example:item", "Example Items", "permission.example.item", "example", 10); err != nil {
|
|
54
56
|
return err
|
|
55
57
|
}
|
|
56
|
-
if err := reg.
|
|
58
|
+
if err := reg.CreateActionI18n("example:item:view", "View Items", "permission.example.item.view", "example", "example:item"); err != nil {
|
|
57
59
|
return err
|
|
58
60
|
}
|
|
59
|
-
if err := reg.
|
|
61
|
+
if err := reg.CreateActionI18n("example:item:manage", "Manage Items", "permission.example.item.manage", "example", "example:item"); err != nil {
|
|
60
62
|
return err
|
|
61
63
|
}
|
|
62
64
|
return nil
|
|
63
65
|
}
|
|
64
66
|
|
|
67
|
+
func (m *Module) RegisterI18n() error {
|
|
68
|
+
return examplei18n.RegisterLocales()
|
|
69
|
+
}
|
|
70
|
+
|
|
65
71
|
func (m *Module) RegisterJobs(_ *jobs.Registry) error {
|
|
66
72
|
return nil
|
|
67
73
|
}
|
|
@@ -17,11 +17,13 @@
|
|
|
17
17
|
},
|
|
18
18
|
"dependencies": {
|
|
19
19
|
"@ant-design/icons": "^6.1.0",
|
|
20
|
-
"@robsun/keystone-web-core": "0.
|
|
20
|
+
"@robsun/keystone-web-core": "^0.2.0",
|
|
21
21
|
"antd": "^6.0.1",
|
|
22
22
|
"dayjs": "^1.11.19",
|
|
23
|
+
"i18next": "^24.2.3",
|
|
23
24
|
"react": "^19.2.0",
|
|
24
25
|
"react-dom": "^19.2.0",
|
|
26
|
+
"react-i18next": "^15.5.1",
|
|
25
27
|
"react-router-dom": "^7.10.1"
|
|
26
28
|
},
|
|
27
29
|
"devDependencies": {
|
|
@@ -13,5 +13,12 @@ export const appConfig: Partial<KeystoneWebConfig> = {
|
|
|
13
13
|
approval: {
|
|
14
14
|
businessTypes: [{ value: 'general', label: 'General Flow' }],
|
|
15
15
|
},
|
|
16
|
+
ui: {
|
|
17
|
+
i18n: {
|
|
18
|
+
enabled: true,
|
|
19
|
+
defaultLocale: 'zh-CN',
|
|
20
|
+
supportedLocales: ['zh-CN', 'en-US'],
|
|
21
|
+
},
|
|
22
|
+
},
|
|
16
23
|
}
|
|
17
|
-
|
|
24
|
+
|
|
@@ -1,4 +1,10 @@
|
|
|
1
|
-
import { registerModule } from '@robsun/keystone-web-core'
|
|
1
|
+
import { registerModule, loadModuleLocales } from '@robsun/keystone-web-core'
|
|
2
2
|
import { exampleRoutes } from './routes'
|
|
3
3
|
|
|
4
|
+
// Load module i18n translations
|
|
5
|
+
loadModuleLocales('example', {
|
|
6
|
+
'zh-CN': () => import('./locales/zh-CN/example.json'),
|
|
7
|
+
'en-US': () => import('./locales/en-US/example.json'),
|
|
8
|
+
})
|
|
9
|
+
|
|
4
10
|
registerModule({ name: 'example', routes: exampleRoutes })
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"menu": {
|
|
3
|
+
"items": "Example Items"
|
|
4
|
+
},
|
|
5
|
+
"page": {
|
|
6
|
+
"title": "Example Items",
|
|
7
|
+
"createButton": "Create Item"
|
|
8
|
+
},
|
|
9
|
+
"table": {
|
|
10
|
+
"name": "Name",
|
|
11
|
+
"description": "Description",
|
|
12
|
+
"status": "Status",
|
|
13
|
+
"createdAt": "Created At",
|
|
14
|
+
"actions": "Actions"
|
|
15
|
+
},
|
|
16
|
+
"form": {
|
|
17
|
+
"nameLabel": "Name",
|
|
18
|
+
"namePlaceholder": "Enter name",
|
|
19
|
+
"descriptionLabel": "Description",
|
|
20
|
+
"descriptionPlaceholder": "Enter description"
|
|
21
|
+
},
|
|
22
|
+
"messages": {
|
|
23
|
+
"createSuccess": "Created successfully",
|
|
24
|
+
"updateSuccess": "Updated successfully",
|
|
25
|
+
"deleteSuccess": "Deleted successfully",
|
|
26
|
+
"loadFailed": "Failed to load"
|
|
27
|
+
},
|
|
28
|
+
"status": {
|
|
29
|
+
"active": "Active",
|
|
30
|
+
"inactive": "Inactive"
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"menu": {
|
|
3
|
+
"items": "示例列表"
|
|
4
|
+
},
|
|
5
|
+
"page": {
|
|
6
|
+
"title": "示例项目",
|
|
7
|
+
"createButton": "新建项目"
|
|
8
|
+
},
|
|
9
|
+
"table": {
|
|
10
|
+
"name": "名称",
|
|
11
|
+
"description": "描述",
|
|
12
|
+
"status": "状态",
|
|
13
|
+
"createdAt": "创建时间",
|
|
14
|
+
"actions": "操作"
|
|
15
|
+
},
|
|
16
|
+
"form": {
|
|
17
|
+
"nameLabel": "名称",
|
|
18
|
+
"namePlaceholder": "请输入名称",
|
|
19
|
+
"descriptionLabel": "描述",
|
|
20
|
+
"descriptionPlaceholder": "请输入描述"
|
|
21
|
+
},
|
|
22
|
+
"messages": {
|
|
23
|
+
"createSuccess": "创建成功",
|
|
24
|
+
"updateSuccess": "更新成功",
|
|
25
|
+
"deleteSuccess": "删除成功",
|
|
26
|
+
"loadFailed": "加载失败"
|
|
27
|
+
},
|
|
28
|
+
"status": {
|
|
29
|
+
"active": "启用",
|
|
30
|
+
"inactive": "停用"
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { useCallback, useEffect, useMemo, useState } from 'react'
|
|
2
2
|
import { App, Button, Card, Form, Input, Modal, Popconfirm, Select, Space, Table, Tag, Typography } from 'antd'
|
|
3
3
|
import type { ColumnsType } from 'antd/es/table'
|
|
4
|
+
import { useTranslation } from 'react-i18next'
|
|
4
5
|
import dayjs from 'dayjs'
|
|
5
6
|
import {
|
|
6
7
|
createExampleItem,
|
|
@@ -16,17 +17,14 @@ type ExampleItemFormValues = {
|
|
|
16
17
|
status: ExampleItemStatus
|
|
17
18
|
}
|
|
18
19
|
|
|
19
|
-
const
|
|
20
|
-
active:
|
|
21
|
-
inactive:
|
|
20
|
+
const statusColors: Record<ExampleItemStatus, string> = {
|
|
21
|
+
active: 'success',
|
|
22
|
+
inactive: 'default',
|
|
22
23
|
}
|
|
23
24
|
|
|
24
|
-
const statusOptions = [
|
|
25
|
-
{ value: 'active', label: 'Active' },
|
|
26
|
-
{ value: 'inactive', label: 'Inactive' },
|
|
27
|
-
]
|
|
28
|
-
|
|
29
25
|
export function ExampleItemsPage() {
|
|
26
|
+
const { t } = useTranslation('example')
|
|
27
|
+
const { t: tc } = useTranslation('common')
|
|
30
28
|
const { message } = App.useApp()
|
|
31
29
|
const [items, setItems] = useState<ExampleItem[]>([])
|
|
32
30
|
const [loading, setLoading] = useState(false)
|
|
@@ -35,18 +33,26 @@ export function ExampleItemsPage() {
|
|
|
35
33
|
const [editingItem, setEditingItem] = useState<ExampleItem | null>(null)
|
|
36
34
|
const [form] = Form.useForm<ExampleItemFormValues>()
|
|
37
35
|
|
|
36
|
+
const statusOptions = useMemo(
|
|
37
|
+
() => [
|
|
38
|
+
{ value: 'active', label: t('status.active') },
|
|
39
|
+
{ value: 'inactive', label: t('status.inactive') },
|
|
40
|
+
],
|
|
41
|
+
[t]
|
|
42
|
+
)
|
|
43
|
+
|
|
38
44
|
const fetchItems = useCallback(async () => {
|
|
39
45
|
setLoading(true)
|
|
40
46
|
try {
|
|
41
47
|
const data = await listExampleItems()
|
|
42
48
|
setItems(data)
|
|
43
49
|
} catch (err) {
|
|
44
|
-
const detail = err instanceof Error ? err.message : '
|
|
50
|
+
const detail = err instanceof Error ? err.message : t('messages.loadFailed')
|
|
45
51
|
message.error(detail)
|
|
46
52
|
} finally {
|
|
47
53
|
setLoading(false)
|
|
48
54
|
}
|
|
49
|
-
}, [message])
|
|
55
|
+
}, [message, t])
|
|
50
56
|
|
|
51
57
|
useEffect(() => {
|
|
52
58
|
void fetchItems()
|
|
@@ -104,98 +110,94 @@ export function ExampleItemsPage() {
|
|
|
104
110
|
try {
|
|
105
111
|
if (editingItem) {
|
|
106
112
|
await updateExampleItem(editingItem.id, payload)
|
|
107
|
-
message.success('
|
|
113
|
+
message.success(t('messages.updateSuccess'))
|
|
108
114
|
} else {
|
|
109
115
|
await createExampleItem(payload)
|
|
110
|
-
message.success('
|
|
116
|
+
message.success(t('messages.createSuccess'))
|
|
111
117
|
}
|
|
112
118
|
closeModal()
|
|
113
119
|
await fetchItems()
|
|
114
120
|
} catch (err) {
|
|
115
|
-
const detail = err instanceof Error ? err.message : '
|
|
121
|
+
const detail = err instanceof Error ? err.message : tc('messages.operationFailed')
|
|
116
122
|
message.error(detail)
|
|
117
123
|
} finally {
|
|
118
124
|
setSaving(false)
|
|
119
125
|
}
|
|
120
|
-
}, [closeModal, editingItem, fetchItems, form, message])
|
|
126
|
+
}, [closeModal, editingItem, fetchItems, form, message, t, tc])
|
|
121
127
|
|
|
122
128
|
const handleDelete = useCallback(
|
|
123
129
|
async (id: number) => {
|
|
124
130
|
try {
|
|
125
131
|
await deleteExampleItem(id)
|
|
126
132
|
await fetchItems()
|
|
127
|
-
message.success('
|
|
133
|
+
message.success(t('messages.deleteSuccess'))
|
|
128
134
|
} catch (err) {
|
|
129
|
-
const detail = err instanceof Error ? err.message : '
|
|
135
|
+
const detail = err instanceof Error ? err.message : tc('messages.operationFailed')
|
|
130
136
|
message.error(detail)
|
|
131
137
|
}
|
|
132
138
|
},
|
|
133
|
-
[fetchItems, message]
|
|
139
|
+
[fetchItems, message, t, tc]
|
|
134
140
|
)
|
|
135
141
|
|
|
136
142
|
const columns: ColumnsType<ExampleItem> = useMemo(
|
|
137
143
|
() => [
|
|
138
|
-
{ title: '
|
|
144
|
+
{ title: t('table.name'), dataIndex: 'title', key: 'title' },
|
|
139
145
|
{
|
|
140
|
-
title: '
|
|
146
|
+
title: t('table.description'),
|
|
141
147
|
dataIndex: 'description',
|
|
142
148
|
key: 'description',
|
|
143
149
|
render: (value: string) =>
|
|
144
150
|
value ? <Typography.Text type="secondary">{value}</Typography.Text> : '-',
|
|
145
151
|
},
|
|
146
152
|
{
|
|
147
|
-
title: '
|
|
153
|
+
title: t('table.status'),
|
|
148
154
|
dataIndex: 'status',
|
|
149
155
|
key: 'status',
|
|
150
|
-
render: (value: ExampleItemStatus) =>
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
},
|
|
156
|
+
render: (value: ExampleItemStatus) => (
|
|
157
|
+
<Tag color={statusColors[value]}>{t(`status.${value}`)}</Tag>
|
|
158
|
+
),
|
|
154
159
|
},
|
|
155
160
|
{
|
|
156
|
-
title: '
|
|
161
|
+
title: tc('table.updatedAt'),
|
|
157
162
|
dataIndex: 'updated_at',
|
|
158
163
|
key: 'updated_at',
|
|
159
164
|
render: (value: string) => (value ? dayjs(value).format('YYYY-MM-DD HH:mm') : '-'),
|
|
160
165
|
},
|
|
161
166
|
{
|
|
162
|
-
title: '
|
|
167
|
+
title: tc('table.actions'),
|
|
163
168
|
key: 'actions',
|
|
164
169
|
render: (_, record) => (
|
|
165
170
|
<Space>
|
|
166
171
|
<Button type="link" onClick={() => openEdit(record)}>
|
|
167
|
-
|
|
172
|
+
{tc('actions.edit')}
|
|
168
173
|
</Button>
|
|
169
|
-
<Popconfirm title=
|
|
174
|
+
<Popconfirm title={tc('confirm.deleteContent')} onConfirm={() => handleDelete(record.id)}>
|
|
170
175
|
<Button type="link" danger>
|
|
171
|
-
|
|
176
|
+
{tc('actions.delete')}
|
|
172
177
|
</Button>
|
|
173
178
|
</Popconfirm>
|
|
174
179
|
</Space>
|
|
175
180
|
),
|
|
176
181
|
},
|
|
177
182
|
],
|
|
178
|
-
[handleDelete, openEdit]
|
|
183
|
+
[handleDelete, openEdit, t, tc]
|
|
179
184
|
)
|
|
180
185
|
|
|
181
186
|
return (
|
|
182
187
|
<Card
|
|
183
|
-
title=
|
|
188
|
+
title={t('page.title')}
|
|
184
189
|
extra={
|
|
185
190
|
<Space>
|
|
186
191
|
<Button onClick={fetchItems} loading={loading}>
|
|
187
|
-
|
|
192
|
+
{tc('actions.refresh')}
|
|
188
193
|
</Button>
|
|
189
194
|
<Button type="primary" onClick={openCreate}>
|
|
190
|
-
|
|
195
|
+
{t('page.createButton')}
|
|
191
196
|
</Button>
|
|
192
197
|
</Space>
|
|
193
198
|
}
|
|
194
199
|
>
|
|
195
|
-
<Space
|
|
196
|
-
<Typography.Text type="secondary">
|
|
197
|
-
This page demonstrates a full CRUD workflow wired to the Example module API.
|
|
198
|
-
</Typography.Text>
|
|
200
|
+
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
|
|
199
201
|
<Table<ExampleItem>
|
|
200
202
|
rowKey="id"
|
|
201
203
|
loading={loading}
|
|
@@ -206,26 +208,26 @@ export function ExampleItemsPage() {
|
|
|
206
208
|
</Space>
|
|
207
209
|
|
|
208
210
|
<Modal
|
|
209
|
-
title={editingItem ? '
|
|
211
|
+
title={editingItem ? tc('actions.edit') : tc('actions.create')}
|
|
210
212
|
open={modalOpen}
|
|
211
213
|
onCancel={closeModal}
|
|
212
214
|
onOk={handleSubmit}
|
|
213
215
|
confirmLoading={saving}
|
|
214
|
-
okText={editingItem ? '
|
|
216
|
+
okText={editingItem ? tc('actions.save') : tc('actions.create')}
|
|
215
217
|
destroyOnHidden
|
|
216
218
|
>
|
|
217
219
|
<Form form={form} layout="vertical" initialValues={{ status: 'active' }}>
|
|
218
220
|
<Form.Item
|
|
219
|
-
label=
|
|
221
|
+
label={t('form.nameLabel')}
|
|
220
222
|
name="title"
|
|
221
|
-
rules={[{ required: true, whitespace: true, message: '
|
|
223
|
+
rules={[{ required: true, whitespace: true, message: tc('form.required') }]}
|
|
222
224
|
>
|
|
223
|
-
<Input placeholder=
|
|
225
|
+
<Input placeholder={t('form.namePlaceholder')} allowClear />
|
|
224
226
|
</Form.Item>
|
|
225
|
-
<Form.Item label=
|
|
226
|
-
<Input.TextArea rows={3} placeholder=
|
|
227
|
+
<Form.Item label={t('form.descriptionLabel')} name="description">
|
|
228
|
+
<Input.TextArea rows={3} placeholder={t('form.descriptionPlaceholder')} />
|
|
227
229
|
</Form.Item>
|
|
228
|
-
<Form.Item label=
|
|
230
|
+
<Form.Item label={t('table.status')} name="status" rules={[{ required: true, message: tc('form.required') }]}>
|
|
229
231
|
<Select options={statusOptions} />
|
|
230
232
|
</Form.Item>
|
|
231
233
|
</Form>
|
|
@@ -31,8 +31,12 @@ export const exampleRoutes: RouteObject[] = [
|
|
|
31
31
|
path: 'example',
|
|
32
32
|
element: <ExampleItemsPage />,
|
|
33
33
|
handle: {
|
|
34
|
-
menu: {
|
|
35
|
-
|
|
34
|
+
menu: {
|
|
35
|
+
labelKey: 'example:menu.items',
|
|
36
|
+
icon: <AppstoreOutlined />,
|
|
37
|
+
permission: 'example:item:view',
|
|
38
|
+
},
|
|
39
|
+
breadcrumbKey: 'example:menu.items',
|
|
36
40
|
permission: 'example:item:view',
|
|
37
41
|
helpKey: 'example/items',
|
|
38
42
|
},
|
|
@@ -146,7 +146,79 @@ RegisterPermissions 负责定义权限种子与后台管理项
|
|
|
146
146
|
- Go 通过 `//go:embed` 嵌入前端产物。
|
|
147
147
|
- 保留 `apps/server/internal/frontend/dist/.gitkeep`,避免 embed 找不到目录。
|
|
148
148
|
|
|
149
|
+
## 多语言支持 (i18n)
|
|
150
|
+
|
|
151
|
+
> 详细指南请参考 [I18N.md](./I18N.md)
|
|
152
|
+
|
|
153
|
+
### 启用多语言
|
|
154
|
+
在 `apps/web/src/app.config.ts` 中配置:
|
|
155
|
+
```ts
|
|
156
|
+
export const appConfig: Partial<KeystoneWebConfig> = {
|
|
157
|
+
// ...
|
|
158
|
+
ui: {
|
|
159
|
+
i18n: {
|
|
160
|
+
enabled: true,
|
|
161
|
+
defaultLocale: 'zh-CN',
|
|
162
|
+
supportedLocales: ['zh-CN', 'en-US'],
|
|
163
|
+
},
|
|
164
|
+
},
|
|
165
|
+
}
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
### 前端模块 i18n
|
|
169
|
+
1. 创建语言包目录 `modules/<module>/locales/zh-CN/*.json` 和 `en-US/*.json`
|
|
170
|
+
2. 在 `modules/<module>/index.ts` 注册:
|
|
171
|
+
```ts
|
|
172
|
+
import { registerModule, loadModuleLocales } from '@robsun/keystone-web-core'
|
|
173
|
+
|
|
174
|
+
loadModuleLocales('myModule', {
|
|
175
|
+
'zh-CN': () => import('./locales/zh-CN/myModule.json'),
|
|
176
|
+
'en-US': () => import('./locales/en-US/myModule.json'),
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
registerModule({ name: 'myModule', routes: myRoutes })
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
3. 路由配置使用 `labelKey` 和 `breadcrumbKey`:
|
|
183
|
+
```tsx
|
|
184
|
+
{
|
|
185
|
+
path: 'items',
|
|
186
|
+
handle: {
|
|
187
|
+
menu: {
|
|
188
|
+
label: '项目列表', // 回退文案
|
|
189
|
+
labelKey: 'myModule:menu.items', // i18n 键(优先)
|
|
190
|
+
icon: <AppstoreOutlined />,
|
|
191
|
+
},
|
|
192
|
+
breadcrumb: '项目列表',
|
|
193
|
+
breadcrumbKey: 'myModule:menu.items',
|
|
194
|
+
},
|
|
195
|
+
}
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
4. 在组件中使用翻译:
|
|
199
|
+
```tsx
|
|
200
|
+
import { useTranslation } from 'react-i18next'
|
|
201
|
+
|
|
202
|
+
function MyComponent() {
|
|
203
|
+
const { t } = useTranslation('myModule')
|
|
204
|
+
return <Button>{t('actions.save')}</Button>
|
|
205
|
+
}
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
### 后端 i18n
|
|
209
|
+
后端已内置 i18n 支持,使用 `Accept-Language` 请求头自动选择语言:
|
|
210
|
+
```go
|
|
211
|
+
import "your-project/infra/i18n"
|
|
212
|
+
|
|
213
|
+
// 响应
|
|
214
|
+
response.SuccessI18n(c, i18n.MsgCreated, data)
|
|
215
|
+
response.BadRequestI18n(c, i18n.MsgInvalidRequest)
|
|
216
|
+
|
|
217
|
+
// 自定义错误
|
|
218
|
+
return &i18n.I18nError{Key: "order.notFound"}
|
|
219
|
+
```
|
|
220
|
+
|
|
149
221
|
## Testing
|
|
150
222
|
- Web:`pnpm -C apps/web test`(Vitest)。
|
|
151
223
|
- Server:`go -C apps/server test ./...`。
|
|
152
|
-
- 全量:`pnpm test`。
|
|
224
|
+
- 全量:`pnpm test`。
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
# Keystone i18n Guide
|
|
2
|
+
|
|
3
|
+
This guide explains how to use internationalization (i18n) in your Keystone application.
|
|
4
|
+
|
|
5
|
+
## Overview
|
|
6
|
+
|
|
7
|
+
Keystone provides built-in i18n support for both frontend and backend:
|
|
8
|
+
- **Frontend**: Uses `react-i18next` with automatic language detection
|
|
9
|
+
- **Backend**: Uses `go-i18n` with Accept-Language header parsing
|
|
10
|
+
|
|
11
|
+
Supported languages:
|
|
12
|
+
- Chinese Simplified (zh-CN) - Default
|
|
13
|
+
- English (en-US)
|
|
14
|
+
|
|
15
|
+
## Configuration
|
|
16
|
+
|
|
17
|
+
### Enable i18n
|
|
18
|
+
|
|
19
|
+
i18n is enabled by default in `app.config.ts`:
|
|
20
|
+
|
|
21
|
+
```typescript
|
|
22
|
+
export const appConfig: Partial<KeystoneWebConfig> = {
|
|
23
|
+
ui: {
|
|
24
|
+
i18n: {
|
|
25
|
+
enabled: true,
|
|
26
|
+
defaultLocale: 'zh-CN',
|
|
27
|
+
supportedLocales: ['zh-CN', 'en-US'],
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
}
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Frontend Usage
|
|
34
|
+
|
|
35
|
+
### Basic Usage
|
|
36
|
+
|
|
37
|
+
Use the `useTranslation` hook in components:
|
|
38
|
+
|
|
39
|
+
```tsx
|
|
40
|
+
import { useTranslation } from 'react-i18next'
|
|
41
|
+
|
|
42
|
+
function MyComponent() {
|
|
43
|
+
const { t } = useTranslation('myModule') // Namespace
|
|
44
|
+
const { t: tc } = useTranslation('common') // Common namespace
|
|
45
|
+
|
|
46
|
+
return (
|
|
47
|
+
<div>
|
|
48
|
+
<h1>{t('page.title')}</h1>
|
|
49
|
+
<Button>{tc('actions.save')}</Button>
|
|
50
|
+
</div>
|
|
51
|
+
)
|
|
52
|
+
}
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
### Namespaces
|
|
56
|
+
|
|
57
|
+
| Namespace | Description | Example Keys |
|
|
58
|
+
|-----------|-------------|--------------|
|
|
59
|
+
| `common` | Shared UI elements | `actions.save`, `messages.success` |
|
|
60
|
+
| `auth` | Authentication | `login.title`, `logout.success` |
|
|
61
|
+
| `system` | System management | `users.table.name` |
|
|
62
|
+
| `validation` | Form validation | `required`, `email.invalid` |
|
|
63
|
+
|
|
64
|
+
### Module Locale Files
|
|
65
|
+
|
|
66
|
+
Create locale files in your module:
|
|
67
|
+
|
|
68
|
+
```
|
|
69
|
+
modules/my-module/
|
|
70
|
+
├── locales/
|
|
71
|
+
│ ├── zh-CN/
|
|
72
|
+
│ │ └── myModule.json
|
|
73
|
+
│ └── en-US/
|
|
74
|
+
│ └── myModule.json
|
|
75
|
+
└── index.ts
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Example `myModule.json`:
|
|
79
|
+
|
|
80
|
+
```json
|
|
81
|
+
{
|
|
82
|
+
"menu": {
|
|
83
|
+
"title": "My Module"
|
|
84
|
+
},
|
|
85
|
+
"page": {
|
|
86
|
+
"title": "Page Title",
|
|
87
|
+
"createButton": "Create"
|
|
88
|
+
},
|
|
89
|
+
"table": {
|
|
90
|
+
"name": "Name",
|
|
91
|
+
"status": "Status"
|
|
92
|
+
},
|
|
93
|
+
"messages": {
|
|
94
|
+
"createSuccess": "Created successfully",
|
|
95
|
+
"deleteSuccess": "Deleted successfully"
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
### Register Module Locales
|
|
101
|
+
|
|
102
|
+
In your module's `index.ts`:
|
|
103
|
+
|
|
104
|
+
```typescript
|
|
105
|
+
import { registerModule, loadModuleLocales } from '@robsun/keystone-web-core'
|
|
106
|
+
import { myRoutes } from './routes'
|
|
107
|
+
|
|
108
|
+
// Load module i18n translations
|
|
109
|
+
loadModuleLocales('myModule', {
|
|
110
|
+
'zh-CN': () => import('./locales/zh-CN/myModule.json'),
|
|
111
|
+
'en-US': () => import('./locales/en-US/myModule.json'),
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
registerModule({ name: 'my-module', routes: myRoutes })
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
### Route Labels
|
|
118
|
+
|
|
119
|
+
Use `labelKey` for menu and breadcrumb labels:
|
|
120
|
+
|
|
121
|
+
```tsx
|
|
122
|
+
export const myRoutes: RouteObject[] = [
|
|
123
|
+
{
|
|
124
|
+
path: 'my-page',
|
|
125
|
+
element: <MyPage />,
|
|
126
|
+
handle: {
|
|
127
|
+
menu: {
|
|
128
|
+
labelKey: 'myModule:menu.title', // i18n key
|
|
129
|
+
icon: <AppstoreOutlined />,
|
|
130
|
+
},
|
|
131
|
+
breadcrumbKey: 'myModule:menu.title',
|
|
132
|
+
},
|
|
133
|
+
},
|
|
134
|
+
]
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
### Variables in Translations
|
|
138
|
+
|
|
139
|
+
```json
|
|
140
|
+
{
|
|
141
|
+
"greeting": "Hello, {{name}}!",
|
|
142
|
+
"itemCount": "{{count}} items"
|
|
143
|
+
}
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
```tsx
|
|
147
|
+
t('greeting', { name: 'John' }) // "Hello, John!"
|
|
148
|
+
t('itemCount', { count: 5 }) // "5 items"
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
## Backend Usage
|
|
152
|
+
|
|
153
|
+
### Response Messages
|
|
154
|
+
|
|
155
|
+
Use i18n response functions in handlers:
|
|
156
|
+
|
|
157
|
+
```go
|
|
158
|
+
import (
|
|
159
|
+
"github.com/robsuncn/keystone/api/response"
|
|
160
|
+
"github.com/robsuncn/keystone/infra/i18n"
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
func (h *Handler) Create(c *gin.Context) {
|
|
164
|
+
// Success with translated message
|
|
165
|
+
response.CreatedI18n(c, "item.created", data)
|
|
166
|
+
|
|
167
|
+
// Error with translated message
|
|
168
|
+
response.BadRequestI18n(c, "validation.titleRequired")
|
|
169
|
+
response.NotFoundI18n(c, "item.notFound")
|
|
170
|
+
response.InternalErrorI18n(c, "item.createFailed")
|
|
171
|
+
}
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
### I18n Error Type
|
|
175
|
+
|
|
176
|
+
Define errors with i18n support:
|
|
177
|
+
|
|
178
|
+
```go
|
|
179
|
+
import (
|
|
180
|
+
"github.com/robsuncn/keystone/infra/i18n"
|
|
181
|
+
mymodulei18n "myapp/internal/modules/mymodule/i18n"
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
var (
|
|
185
|
+
ErrItemNotFound = &i18n.I18nError{Key: mymodulei18n.MsgItemNotFound}
|
|
186
|
+
ErrTitleRequired = &i18n.I18nError{Key: mymodulei18n.MsgTitleRequired}
|
|
187
|
+
)
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
Handle i18n errors in handlers:
|
|
191
|
+
|
|
192
|
+
```go
|
|
193
|
+
func (h *Handler) Get(c *gin.Context) {
|
|
194
|
+
item, err := h.svc.Get(ctx, id)
|
|
195
|
+
if err != nil {
|
|
196
|
+
var i18nErr *i18n.I18nError
|
|
197
|
+
if errors.As(err, &i18nErr) {
|
|
198
|
+
response.NotFoundI18n(c, i18nErr.Key)
|
|
199
|
+
return
|
|
200
|
+
}
|
|
201
|
+
response.InternalErrorI18n(c, "common.internalError")
|
|
202
|
+
return
|
|
203
|
+
}
|
|
204
|
+
response.Success(c, item)
|
|
205
|
+
}
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
### Module Locale Files (Backend)
|
|
209
|
+
|
|
210
|
+
Create i18n files in your module:
|
|
211
|
+
|
|
212
|
+
```
|
|
213
|
+
internal/modules/mymodule/
|
|
214
|
+
├── i18n/
|
|
215
|
+
│ ├── i18n.go # Registration
|
|
216
|
+
│ ├── keys.go # Message key constants
|
|
217
|
+
│ └── locales/
|
|
218
|
+
│ ├── zh-CN.json
|
|
219
|
+
│ └── en-US.json
|
|
220
|
+
└── module.go
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
`keys.go`:
|
|
224
|
+
|
|
225
|
+
```go
|
|
226
|
+
package mymodulei18n
|
|
227
|
+
|
|
228
|
+
const (
|
|
229
|
+
MsgItemCreated = "mymodule.item.created"
|
|
230
|
+
MsgItemNotFound = "mymodule.item.notFound"
|
|
231
|
+
MsgTitleRequired = "mymodule.validation.titleRequired"
|
|
232
|
+
)
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
`i18n.go`:
|
|
236
|
+
|
|
237
|
+
```go
|
|
238
|
+
package mymodulei18n
|
|
239
|
+
|
|
240
|
+
import (
|
|
241
|
+
"embed"
|
|
242
|
+
"github.com/robsuncn/keystone/infra/i18n"
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
//go:embed locales/*.json
|
|
246
|
+
var localeFS embed.FS
|
|
247
|
+
|
|
248
|
+
func RegisterLocales() error {
|
|
249
|
+
return i18n.LoadModuleLocales(localeFS, "locales")
|
|
250
|
+
}
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
Register in `module.go`:
|
|
254
|
+
|
|
255
|
+
```go
|
|
256
|
+
func (m *Module) RegisterI18n() error {
|
|
257
|
+
return mymodulei18n.RegisterLocales()
|
|
258
|
+
}
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
### Permission Names
|
|
262
|
+
|
|
263
|
+
Use `NameKey` for translatable permission names:
|
|
264
|
+
|
|
265
|
+
```go
|
|
266
|
+
func (m *Module) RegisterPermissions(reg *permissions.Registry) error {
|
|
267
|
+
reg.CreateMenuI18n("mymodule:item", "My Items", "permission.mymodule.item", "mymodule", 10)
|
|
268
|
+
reg.CreateActionI18n("mymodule:item:view", "View Items", "permission.mymodule.item.view", "mymodule", "mymodule:item")
|
|
269
|
+
return nil
|
|
270
|
+
}
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
## Language Switching
|
|
274
|
+
|
|
275
|
+
The language switcher is automatically available in the header when i18n is enabled. Users can switch between supported languages, and:
|
|
276
|
+
|
|
277
|
+
1. The UI immediately updates to the selected language
|
|
278
|
+
2. All subsequent API requests include the `Accept-Language` header
|
|
279
|
+
3. The language preference is persisted in localStorage
|
|
280
|
+
|
|
281
|
+
## Best Practices
|
|
282
|
+
|
|
283
|
+
1. **Use namespaces**: Group translations by module/feature
|
|
284
|
+
2. **Common translations**: Use the `common` namespace for shared elements
|
|
285
|
+
3. **Key naming**: Use dot notation for hierarchy (`page.title`, `form.nameLabel`)
|
|
286
|
+
4. **Avoid concatenation**: Use variables instead of string concatenation
|
|
287
|
+
5. **Default language**: Always provide Chinese (zh-CN) as the default
|
|
288
|
+
6. **Consistent keys**: Use the same key structure across languages
|
|
289
|
+
|
|
290
|
+
## Type Safety (Optional)
|
|
291
|
+
|
|
292
|
+
Generate TypeScript types for translation keys:
|
|
293
|
+
|
|
294
|
+
```bash
|
|
295
|
+
pnpm generate:i18n-types
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
This creates `apps/web/src/types/i18n.d.ts` with type definitions for all your translation keys, enabling:
|
|
299
|
+
- Autocomplete for translation keys in your IDE
|
|
300
|
+
- Compile-time errors for invalid keys
|
|
301
|
+
|
|
302
|
+
After generating types, you can use them in your components:
|
|
303
|
+
|
|
304
|
+
```tsx
|
|
305
|
+
import { useTranslation } from 'react-i18next'
|
|
306
|
+
|
|
307
|
+
function MyComponent() {
|
|
308
|
+
const { t } = useTranslation('myModule')
|
|
309
|
+
// t('invalid.key') - TypeScript error!
|
|
310
|
+
// t('page.title') - OK with autocomplete
|
|
311
|
+
}
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
## Adding a New Language
|
|
315
|
+
|
|
316
|
+
1. Update `supportedLocales` in `app.config.ts`
|
|
317
|
+
2. Create locale files for the new language
|
|
318
|
+
3. Update backend `supportedLangs` in `infra/i18n/i18n.go`
|
|
319
|
+
4. Add backend locale file in `infra/i18n/locales/`
|
package/template/package.json
CHANGED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* i18n Type Definition Generator
|
|
4
|
+
*
|
|
5
|
+
* Generates TypeScript type definitions from JSON locale files
|
|
6
|
+
* for type-safe translations in your application.
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
* node scripts/generate-i18n-types.js
|
|
10
|
+
*
|
|
11
|
+
* Output:
|
|
12
|
+
* apps/web/src/types/i18n.d.ts
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import fs from 'fs'
|
|
16
|
+
import path from 'path'
|
|
17
|
+
import { fileURLToPath } from 'url'
|
|
18
|
+
|
|
19
|
+
const __filename = fileURLToPath(import.meta.url)
|
|
20
|
+
const __dirname = path.dirname(__filename)
|
|
21
|
+
const rootDir = path.resolve(__dirname, '..')
|
|
22
|
+
const webSrcDir = path.join(rootDir, 'apps/web/src')
|
|
23
|
+
|
|
24
|
+
// Configuration
|
|
25
|
+
const LOCALE_DIRS = [
|
|
26
|
+
// Platform locales from keystone-web-core (reference only, not generated)
|
|
27
|
+
// Module locales
|
|
28
|
+
path.join(webSrcDir, 'modules'),
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
const OUTPUT_FILE = path.join(webSrcDir, 'types/i18n.d.ts')
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Recursively flattens a nested object into dot-notation keys
|
|
35
|
+
*/
|
|
36
|
+
function flattenKeys(obj, prefix = '') {
|
|
37
|
+
const keys = []
|
|
38
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
39
|
+
const fullKey = prefix ? `${prefix}.${key}` : key
|
|
40
|
+
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
|
41
|
+
keys.push(...flattenKeys(value, fullKey))
|
|
42
|
+
} else {
|
|
43
|
+
keys.push(fullKey)
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return keys
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Finds all locale JSON files in a directory
|
|
51
|
+
*/
|
|
52
|
+
function findLocaleFiles(dir) {
|
|
53
|
+
const files = []
|
|
54
|
+
if (!fs.existsSync(dir)) return files
|
|
55
|
+
|
|
56
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true })
|
|
57
|
+
for (const entry of entries) {
|
|
58
|
+
const fullPath = path.join(dir, entry.name)
|
|
59
|
+
if (entry.isDirectory()) {
|
|
60
|
+
// Look for locales directory
|
|
61
|
+
if (entry.name === 'locales') {
|
|
62
|
+
const zhDir = path.join(fullPath, 'zh-CN')
|
|
63
|
+
if (fs.existsSync(zhDir)) {
|
|
64
|
+
const jsonFiles = fs.readdirSync(zhDir).filter(f => f.endsWith('.json'))
|
|
65
|
+
for (const jsonFile of jsonFiles) {
|
|
66
|
+
files.push({
|
|
67
|
+
namespace: path.basename(jsonFile, '.json'),
|
|
68
|
+
path: path.join(zhDir, jsonFile),
|
|
69
|
+
})
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
} else {
|
|
73
|
+
files.push(...findLocaleFiles(fullPath))
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return files
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Generates TypeScript type definitions
|
|
82
|
+
*/
|
|
83
|
+
function generateTypes() {
|
|
84
|
+
const namespaces = new Map()
|
|
85
|
+
|
|
86
|
+
// Collect all locale files
|
|
87
|
+
for (const localeDir of LOCALE_DIRS) {
|
|
88
|
+
const files = findLocaleFiles(localeDir)
|
|
89
|
+
for (const file of files) {
|
|
90
|
+
try {
|
|
91
|
+
const content = fs.readFileSync(file.path, 'utf-8')
|
|
92
|
+
const json = JSON.parse(content)
|
|
93
|
+
const keys = flattenKeys(json)
|
|
94
|
+
namespaces.set(file.namespace, keys)
|
|
95
|
+
} catch (err) {
|
|
96
|
+
console.warn(`Warning: Could not parse ${file.path}:`, err.message)
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (namespaces.size === 0) {
|
|
102
|
+
console.log('No locale files found. Skipping type generation.')
|
|
103
|
+
return
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Generate TypeScript content
|
|
107
|
+
let output = `// Auto-generated by scripts/generate-i18n-types.js
|
|
108
|
+
// Do not edit manually
|
|
109
|
+
|
|
110
|
+
import 'react-i18next'
|
|
111
|
+
|
|
112
|
+
declare module 'react-i18next' {
|
|
113
|
+
interface CustomTypeOptions {
|
|
114
|
+
defaultNS: 'common'
|
|
115
|
+
resources: {
|
|
116
|
+
`
|
|
117
|
+
|
|
118
|
+
for (const [namespace, keys] of namespaces) {
|
|
119
|
+
output += ` ${namespace}: {\n`
|
|
120
|
+
for (const key of keys) {
|
|
121
|
+
// Create nested type from dot notation
|
|
122
|
+
output += ` '${key}': string\n`
|
|
123
|
+
}
|
|
124
|
+
output += ` }\n`
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
output += ` }
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Type-safe translation keys
|
|
132
|
+
`
|
|
133
|
+
|
|
134
|
+
for (const [namespace, keys] of namespaces) {
|
|
135
|
+
const typeName = namespace.charAt(0).toUpperCase() + namespace.slice(1) + 'Keys'
|
|
136
|
+
output += `export type ${typeName} = \n`
|
|
137
|
+
output += keys.map(k => ` | '${k}'`).join('\n')
|
|
138
|
+
output += '\n\n'
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Ensure output directory exists
|
|
142
|
+
const outputDir = path.dirname(OUTPUT_FILE)
|
|
143
|
+
if (!fs.existsSync(outputDir)) {
|
|
144
|
+
fs.mkdirSync(outputDir, { recursive: true })
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Write output file
|
|
148
|
+
fs.writeFileSync(OUTPUT_FILE, output, 'utf-8')
|
|
149
|
+
console.log(`Generated i18n types: ${OUTPUT_FILE}`)
|
|
150
|
+
console.log(`Namespaces: ${[...namespaces.keys()].join(', ')}`)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Run generator
|
|
154
|
+
generateTypes()
|