@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.
- package/package.json +2 -2
- package/template/.claude/skills/keystone-dev/SKILL.md +12 -9
- package/template/.claude/skills/keystone-dev/references/APPROVAL.md +74 -0
- package/template/.claude/skills/keystone-dev/{CAPABILITIES.md → references/CAPABILITIES.md} +1 -0
- package/template/.claude/skills/keystone-dev/{TEMPLATES.md → references/TEMPLATES.md} +1 -0
- package/template/.claude/skills/keystone-dev/references/TESTING.md +44 -0
- package/template/.codex/skills/keystone-dev/SKILL.md +12 -9
- package/template/.codex/skills/keystone-dev/references/APPROVAL.md +74 -0
- package/template/.codex/skills/keystone-dev/{CAPABILITIES.md → references/CAPABILITIES.md} +1 -0
- package/template/.codex/skills/keystone-dev/{TEMPLATES.md → references/TEMPLATES.md} +1 -0
- package/template/.codex/skills/keystone-dev/references/TESTING.md +44 -0
- 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
|
@@ -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`。
|