Skip to content

Commit d211ee4

Browse files
authored
Merge pull request #399 from Constrat/feat/i18n
feat: i18n
2 parents caf7e8d + 8306bee commit d211ee4

File tree

123 files changed

+6001
-1048
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

123 files changed

+6001
-1048
lines changed

.eslintignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
.eslintrc.js
22
do not edit these files
3+
generated

.eslintrc.js

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ module.exports = {
1515
'plugin:import/recommended',
1616
'plugin:import/typescript',
1717
'plugin:react/jsx-runtime',
18-
"plugin:react-hooks/recommended"
18+
'plugin:react-hooks/recommended',
1919
],
2020
parser: '@typescript-eslint/parser',
2121
parserOptions: {
@@ -25,8 +25,8 @@ module.exports = {
2525
ecmaVersion: 'latest',
2626
sourceType: 'module',
2727

28-
project: './tsconfig.json',
29-
tsconfigRootDir: './',
28+
project: ['./tsconfig.json', './tsconfig.node.json'],
29+
tsconfigRootDir: __dirname,
3030
},
3131
plugins: ['react', '@typescript-eslint', 'prettier', 'import'],
3232
rules: {
@@ -36,7 +36,7 @@ module.exports = {
3636
'react/self-closing-comp': 'error',
3737
'no-unused-vars': 'off',
3838
eqeqeq: 'error',
39-
"react-hooks/exhaustive-deps": "error",
39+
'react-hooks/exhaustive-deps': 'error',
4040
},
4141
settings: {
4242
react: {

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,3 +24,5 @@ dist-ssr
2424
*.sw?
2525

2626
build/
27+
public/locales/*.json
28+
src/i18n/generated

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@
4545
"linkifyjs": "^3.0.5",
4646
"lodash-es": "^4.17.21",
4747
"maa-copilot-client": "https://github.com/MaaAssistantArknights/maa-copilot-client-ts.git#0.1.0-SNAPSHOT.824.f8ad839",
48+
"mitt": "^3.0.1",
4849
"normalize.css": "^8.0.1",
4950
"prettier": "^3.2.5",
5051
"react": "^18.0.0",

scripts/generate-translations.ts

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import { isObject } from 'lodash'
2+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
3+
import { join } from 'node:path'
4+
import { fileURLToPath } from 'node:url'
5+
import { inspect } from 'node:util'
6+
import { Plugin } from 'vite'
7+
8+
const translationsFile = fileURLToPath(
9+
new URL('../src/i18n/translations.json', import.meta.url),
10+
)
11+
const outputDir = fileURLToPath(
12+
new URL('../src/i18n/generated', import.meta.url),
13+
)
14+
15+
export function generateTranslations(): Plugin {
16+
splitTranslations()
17+
18+
return {
19+
name: 'generate-translations',
20+
apply: 'serve',
21+
configureServer(server) {
22+
server.watcher.on('change', (filePath) => {
23+
if (filePath === translationsFile) {
24+
try {
25+
splitTranslations()
26+
} catch (e) {
27+
console.error('Failed to generate translations:', e)
28+
}
29+
}
30+
})
31+
},
32+
}
33+
}
34+
35+
function splitTranslations() {
36+
if (!existsSync(outputDir)) {
37+
mkdirSync(outputDir, { recursive: true })
38+
}
39+
40+
const translationsJson = readFileSync(translationsFile, 'utf-8')
41+
const languages = Object.keys(
42+
JSON.parse(translationsJson).essentials.language,
43+
)
44+
const essentials: Record<string, Record<string, unknown>> = {}
45+
46+
for (const language of languages) {
47+
const languageTranslations = JSON.parse(translationsJson, (key, value) => {
48+
if (
49+
isObject(value) &&
50+
Object.keys(value).some((key) => languages.includes(key))
51+
) {
52+
return value[language] || '__NOT_TRANSLATED__'
53+
}
54+
return value
55+
})
56+
57+
essentials[language] = languageTranslations.essentials
58+
delete languageTranslations.essentials
59+
60+
writeTsFile(`${language}.ts`, languageTranslations)
61+
}
62+
63+
writeTsFile(`essentials.ts`, essentials)
64+
65+
console.log(`Translations generated for ${languages.join(', ')}`)
66+
}
67+
68+
function writeTsFile(filename: string, jsonObject: Record<string, unknown>) {
69+
const filePath = join(outputDir, filename)
70+
71+
const literalObject = inspect(jsonObject, {
72+
depth: 100,
73+
maxStringLength: Infinity,
74+
breakLength: Infinity,
75+
sorted: false,
76+
compact: false,
77+
})
78+
79+
const content = `// This file is auto-generated by generate-translations.ts, do not edit it directly.
80+
export default ${literalObject} as const`
81+
82+
if (existsSync(filePath) && readFileSync(filePath, 'utf-8') === content) {
83+
// skip writing to avoid unnecessary hot reloads
84+
return
85+
}
86+
writeFileSync(filePath, content, 'utf-8')
87+
}

src/App.tsx

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { authAtom } from 'store/auth'
66
import { TokenManager } from 'utils/token-manager'
77

88
import { GlobalErrorBoundary } from './components/GlobalErrorBoundary'
9+
import { I18NProvider } from './i18n/I18NProvider'
910
import { FCC } from './types'
1011

1112
// jotai 在没有 Provider 时会使用默认的 store
@@ -14,18 +15,18 @@ TokenManager.setAuthSetter((v) => getDefaultStore().set(authAtom, v))
1415

1516
export const App: FCC = ({ children }) => {
1617
return (
17-
<>
18-
<SWRConfig
19-
value={{
20-
focusThrottleInterval: 1000 * 60,
21-
errorRetryInterval: 1000 * 3,
22-
errorRetryCount: 3,
23-
}}
24-
>
25-
<GlobalErrorBoundary>
18+
<SWRConfig
19+
value={{
20+
focusThrottleInterval: 1000 * 60,
21+
errorRetryInterval: 1000 * 3,
22+
errorRetryCount: 3,
23+
}}
24+
>
25+
<GlobalErrorBoundary>
26+
<I18NProvider>
2627
<BrowserRouter>{children}</BrowserRouter>
27-
</GlobalErrorBoundary>
28-
</SWRConfig>
29-
</>
28+
</I18NProvider>
29+
</GlobalErrorBoundary>
30+
</SWRConfig>
3031
)
3132
}

src/apis/announcement.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import useSWR from 'swr'
22

3+
import { i18n } from '../i18n/i18n'
34
import mockFile from './mock/announcements.md?url'
45

56
const isMock = process.env.NODE_ENV === 'development'
@@ -20,7 +21,7 @@ export function useAnnouncement() {
2021
.then((res) => res.text())
2122
.catch((e) => {
2223
if ((e as Error).message === 'Failed to fetch') {
23-
throw new Error('网络错误')
24+
throw new Error(i18n.apis.announcement.network_error)
2425
}
2526

2627
throw e

src/apis/comment.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import useSWRInfinite from 'swr/infinite'
66

77
import { CommentApi } from 'utils/maa-copilot-client'
88

9+
import { i18n } from '../i18n/i18n'
910
import { CommentRating } from '../models/comment'
1011
import { Operation } from '../models/operation'
1112

@@ -35,7 +36,7 @@ export function useComments({
3536
}
3637

3738
if (!isFinite(+operationId)) {
38-
throw new Error('operationId is not a valid number')
39+
throw new Error(i18n.apis.comment.invalid_operation_id)
3940
}
4041

4142
return [
@@ -86,6 +87,7 @@ export async function sendComment(req: {
8687
copilotId: req.operationId,
8788
fromCommentId: req.fromCommentId,
8889
notification: false,
90+
commentStatus: 'ENABLED',
8991
},
9092
})
9193
}

src/apis/mock/announcements.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
<!--
1+
<!--
22
可以写注释,会过滤掉
33
TODO: 支持 HTML 标签
44
-->

src/components/AccountManager.tsx

Lines changed: 24 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { LoginPanel } from 'components/account/LoginPanel'
2121
import { authAtom } from 'store/auth'
2222
import { useCurrentSize } from 'utils/useCurrenSize'
2323

24+
import { useTranslation } from '../i18n/i18n'
2425
import {
2526
GlobalErrorBoundary,
2627
withGlobalErrorBoundary,
@@ -30,6 +31,7 @@ import { EditDialog } from './account/EditDialog'
3031
import { RegisterPanel } from './account/RegisterPanel'
3132

3233
const AccountMenu: FC = () => {
34+
const t = useTranslation()
3335
const [authState, setAuthState] = useAtom(authAtom)
3436
const [logoutDialogOpen, setLogoutDialogOpen] = useState(false)
3537
const [editDialogOpen, setEditDialogOpen] = useState(false)
@@ -39,24 +41,24 @@ const AccountMenu: FC = () => {
3941
setAuthState({})
4042
AppToaster.show({
4143
intent: 'success',
42-
message: '已退出登录',
44+
message: t.components.AccountManager.logout_success,
4345
})
4446
}
4547

4648
return (
4749
<>
4850
<Alert
4951
isOpen={logoutDialogOpen}
50-
cancelButtonText="取消"
51-
confirmButtonText="退出登录"
52+
cancelButtonText={t.components.AccountManager.cancel}
53+
confirmButtonText={t.components.AccountManager.logout}
5254
icon="log-out"
5355
intent="danger"
5456
canOutsideClickCancel
5557
onCancel={() => setLogoutDialogOpen(false)}
5658
onConfirm={handleLogout}
5759
>
58-
<H4>退出登录</H4>
59-
<p>确定要退出登录吗?</p>
60+
<H4>{t.components.AccountManager.logout}</H4>
61+
<p>{t.components.AccountManager.logout_confirm}</p>
6062
</Alert>
6163

6264
<EditDialog
@@ -69,19 +71,22 @@ const AccountMenu: FC = () => {
6971
<MenuItem
7072
disabled
7173
icon="warning-sign"
72-
text="账号未激活,请在退出登录后,以重置密码的方式激活"
74+
text={t.components.AccountManager.account_not_activated}
7375
/>
7476
)}
7577

7678
<MenuItem
7779
icon="person"
78-
text={(isSM ? authState.username + ' - ' : '') + '个人主页'}
80+
text={
81+
(isSM ? authState.username + ' - ' : '') +
82+
t.components.AccountManager.profile
83+
}
7984
href={`/profile/${authState.userId}`}
8085
/>
8186
<MenuItem
8287
shouldDismissPopover={false}
8388
icon="edit"
84-
text="修改信息..."
89+
text={t.components.AccountManager.edit_info}
8590
onClick={() => setEditDialogOpen(true)}
8691
/>
8792
<MenuDivider />
@@ -90,7 +95,7 @@ const AccountMenu: FC = () => {
9095
shouldDismissPopover={false}
9196
intent="danger"
9297
icon="log-out"
93-
text="退出登录"
98+
text={t.components.AccountManager.logout}
9499
onClick={() => setLogoutDialogOpen(true)}
95100
/>
96101
</Menu>
@@ -102,11 +107,12 @@ export const AccountAuthDialog: ComponentType<{
102107
open?: boolean
103108
onClose?: () => void
104109
}> = withGlobalErrorBoundary(({ open, onClose }) => {
110+
const t = useTranslation()
105111
const [activeTab, setActiveTab] = useState<TabId>('login')
106112

107113
return (
108114
<Dialog
109-
title="PRTS Plus 账户"
115+
title={t.components.AccountManager.maa_account}
110116
icon="user"
111117
isOpen={open}
112118
onClose={onClose}
@@ -127,7 +133,9 @@ export const AccountAuthDialog: ComponentType<{
127133
title={
128134
<div>
129135
<Icon icon="person" />
130-
<span className="ml-1">登录</span>
136+
<span className="ml-1">
137+
{t.components.AccountManager.login}
138+
</span>
131139
</div>
132140
}
133141
panel={
@@ -142,7 +150,9 @@ export const AccountAuthDialog: ComponentType<{
142150
title={
143151
<div>
144152
<Icon icon="new-person" />
145-
<span className="ml-1">注册</span>
153+
<span className="ml-1">
154+
{t.components.AccountManager.register}
155+
</span>
146156
</div>
147157
}
148158
panel={<RegisterPanel onComplete={() => setActiveTab('login')} />}
@@ -155,6 +165,7 @@ export const AccountAuthDialog: ComponentType<{
155165
})
156166

157167
export const AccountManager: ComponentType = withGlobalErrorBoundary(() => {
168+
const t = useTranslation()
158169
const [open, setOpen] = useState(false)
159170
const [authState] = useAtom(authAtom)
160171
const { isSM } = useCurrentSize()
@@ -173,7 +184,7 @@ export const AccountManager: ComponentType = withGlobalErrorBoundary(() => {
173184
</Popover2>
174185
) : (
175186
<Button className="ml-auto" icon="user" onClick={() => setOpen(true)}>
176-
{!isSM && '登录 / 注册'}
187+
{!isSM && t.components.AccountManager.login_register}
177188
</Button>
178189
)}
179190
</>

0 commit comments

Comments
 (0)