@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.
File without changes
File without changes
package/package.json CHANGED
@@ -1,6 +1,10 @@
1
- {
1
+ {
2
2
  "name": "@robsun/create-keystone-app",
3
- "version": "0.2.7",
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
+ }
@@ -4,7 +4,7 @@ go 1.24.3
4
4
 
5
5
  require (
6
6
  github.com/gin-gonic/gin v1.11.0
7
- github.com/robsuncn/keystone v0.1.1
7
+ github.com/robsuncn/keystone v0.2.0
8
8
  github.com/swaggo/files v1.0.1
9
9
  github.com/swaggo/gin-swagger v1.6.0
10
10
  github.com/swaggo/swag v1.16.2
@@ -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.ServiceUnavailable(c, "example items unavailable")
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.InternalError(c, "failed to load example items")
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.ServiceUnavailable(c, "example items unavailable")
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.BadRequest(c, "invalid payload")
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
- switch {
75
- case errors.Is(err, service.ErrTitleRequired):
76
- response.BadRequest(c, "title is required")
77
- case errors.Is(err, service.ErrStatusInvalid):
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.Created(c, item)
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.ServiceUnavailable(c, "example items unavailable")
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.BadRequest(c, "invalid id")
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.BadRequest(c, "invalid payload")
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
- switch {
114
- case errors.Is(err, service.ErrItemNotFound):
115
- response.NotFound(c, "item not found")
116
- case errors.Is(err, service.ErrTitleRequired):
117
- response.BadRequest(c, "title is required")
118
- case errors.Is(err, service.ErrStatusInvalid):
119
- response.BadRequest(c, "invalid status")
120
- default:
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.SuccessWithMessage(c, "updated", item)
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.ServiceUnavailable(c, "example items unavailable")
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.BadRequest(c, "invalid id")
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
- if errors.Is(err, service.ErrItemNotFound) {
144
- response.NotFound(c, "item not found")
145
- return
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.InternalError(c, "failed to delete example item")
150
+ response.InternalErrorI18n(c, examplei18n.MsgItemDeleteFailed)
148
151
  return
149
152
  }
150
153
 
151
- response.SuccessWithMessage(c, "deleted", gin.H{"id": id})
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
- if err := reg.CreateMenu("example:item", "Example Items", "example", 10); err != nil {
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.CreateAction("example:item:view", "View Items", "example", "example:item"); err != nil {
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.CreateAction("example:item:manage", "Manage Items", "example", "example:item"); err != nil {
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.1.9",
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 statusMeta: Record<ExampleItemStatus, { label: string; color: string }> = {
20
- active: { label: 'Active', color: 'success' },
21
- inactive: { label: 'Inactive', color: 'default' },
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 : 'Failed to load items'
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('Item updated')
113
+ message.success(t('messages.updateSuccess'))
108
114
  } else {
109
115
  await createExampleItem(payload)
110
- message.success('Item created')
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 : 'Failed to save item'
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('Item deleted')
133
+ message.success(t('messages.deleteSuccess'))
128
134
  } catch (err) {
129
- const detail = err instanceof Error ? err.message : 'Failed to delete item'
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: 'Title', dataIndex: 'title', key: 'title' },
144
+ { title: t('table.name'), dataIndex: 'title', key: 'title' },
139
145
  {
140
- title: 'Description',
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: 'Status',
153
+ title: t('table.status'),
148
154
  dataIndex: 'status',
149
155
  key: 'status',
150
- render: (value: ExampleItemStatus) => {
151
- const meta = statusMeta[value]
152
- return <Tag color={meta.color}>{meta.label}</Tag>
153
- },
156
+ render: (value: ExampleItemStatus) => (
157
+ <Tag color={statusColors[value]}>{t(`status.${value}`)}</Tag>
158
+ ),
154
159
  },
155
160
  {
156
- title: 'Updated',
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: 'Actions',
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
- Edit
172
+ {tc('actions.edit')}
168
173
  </Button>
169
- <Popconfirm title="Delete this item?" onConfirm={() => handleDelete(record.id)}>
174
+ <Popconfirm title={tc('confirm.deleteContent')} onConfirm={() => handleDelete(record.id)}>
170
175
  <Button type="link" danger>
171
- Delete
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="Example Items"
188
+ title={t('page.title')}
184
189
  extra={
185
190
  <Space>
186
191
  <Button onClick={fetchItems} loading={loading}>
187
- Refresh
192
+ {tc('actions.refresh')}
188
193
  </Button>
189
194
  <Button type="primary" onClick={openCreate}>
190
- New Item
195
+ {t('page.createButton')}
191
196
  </Button>
192
197
  </Space>
193
198
  }
194
199
  >
195
- <Space orientation="vertical" size="middle" style={{ width: '100%' }}>
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 ? 'Edit Item' : 'New Item'}
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 ? 'Save' : 'Create'}
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="Title"
221
+ label={t('form.nameLabel')}
220
222
  name="title"
221
- rules={[{ required: true, whitespace: true, message: 'Title is required' }]}
223
+ rules={[{ required: true, whitespace: true, message: tc('form.required') }]}
222
224
  >
223
- <Input placeholder="Item title" allowClear />
225
+ <Input placeholder={t('form.namePlaceholder')} allowClear />
224
226
  </Form.Item>
225
- <Form.Item label="Description" name="description">
226
- <Input.TextArea rows={3} placeholder="Short description" />
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="Status" name="status" rules={[{ required: true, message: 'Select status' }]}>
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: { label: 'Example Items', icon: <AppstoreOutlined />, permission: 'example:item:view' },
35
- breadcrumb: 'Example Items',
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/`
@@ -14,6 +14,7 @@
14
14
  "clean": "node scripts/clean.js",
15
15
  "web:dev": "pnpm -C apps/web dev",
16
16
  "server:dev": "go -C apps/server run ./cmd/server",
17
+ "generate:i18n-types": "node scripts/generate-i18n-types.js",
17
18
  "prepare": "husky"
18
19
  },
19
20
  "devDependencies": {
@@ -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()