@robsun/create-keystone-app 0.2.6 → 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.
Files changed (31) hide show
  1. package/package.json +2 -2
  2. package/template/.claude/skills/keystone-dev/SKILL.md +12 -9
  3. package/template/.claude/skills/keystone-dev/references/APPROVAL.md +74 -0
  4. package/template/.claude/skills/keystone-dev/{CAPABILITIES.md → references/CAPABILITIES.md} +1 -0
  5. package/template/.claude/skills/keystone-dev/{TEMPLATES.md → references/TEMPLATES.md} +1 -0
  6. package/template/.claude/skills/keystone-dev/references/TESTING.md +44 -0
  7. package/template/.codex/skills/keystone-dev/SKILL.md +12 -9
  8. package/template/.codex/skills/keystone-dev/references/APPROVAL.md +74 -0
  9. package/template/.codex/skills/keystone-dev/{CAPABILITIES.md → references/CAPABILITIES.md} +1 -0
  10. package/template/.codex/skills/keystone-dev/{TEMPLATES.md → references/TEMPLATES.md} +1 -0
  11. package/template/.codex/skills/keystone-dev/references/TESTING.md +44 -0
  12. package/template/apps/server/go.mod +1 -1
  13. package/template/apps/server/go.sum +1 -0
  14. package/template/apps/server/internal/modules/example/api/handler/item_handler.go +35 -32
  15. package/template/apps/server/internal/modules/example/domain/service/errors.go +13 -0
  16. package/template/apps/server/internal/modules/example/i18n/i18n.go +16 -0
  17. package/template/apps/server/internal/modules/example/i18n/keys.go +23 -0
  18. package/template/apps/server/internal/modules/example/i18n/locales/en-US.json +18 -0
  19. package/template/apps/server/internal/modules/example/i18n/locales/zh-CN.json +18 -0
  20. package/template/apps/server/internal/modules/example/module.go +9 -3
  21. package/template/apps/web/package.json +3 -1
  22. package/template/apps/web/src/app.config.ts +8 -1
  23. package/template/apps/web/src/modules/example/index.ts +7 -1
  24. package/template/apps/web/src/modules/example/locales/en-US/example.json +32 -0
  25. package/template/apps/web/src/modules/example/locales/zh-CN/example.json +32 -0
  26. package/template/apps/web/src/modules/example/pages/ExampleItemsPage.tsx +47 -45
  27. package/template/apps/web/src/modules/example/routes.tsx +6 -2
  28. package/template/docs/CONVENTIONS.md +73 -1
  29. package/template/docs/I18N.md +319 -0
  30. package/template/package.json +1 -0
  31. package/template/scripts/generate-i18n-types.js +154 -0
@@ -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`。