From d5bc2b704a6d463e9487dc142dab37ddb86860c6 Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Sun, 28 Jun 2026 15:35:45 +0800 Subject: [PATCH 1/2] feat: add i18n support with English and Chinese locales - Install i18next, react-i18next, i18next-browser-languagedetector - Create i18n config with en and zh-CN locales - Replace hardcoded strings across 36 component/page files - Add Ant Design ConfigProvider with dynamic locale switching - Add language switcher in top navigation (desktop + mobile) Note: user.tsx i18n pending (complex file, separate commit) --- bun.lock | 15 + package.json | 3 + src/components/app-detail-header.tsx | 18 +- src/components/app-drawer.tsx | 48 +- src/components/app-settings-modal.tsx | 44 +- src/components/create-app-modal.tsx | 13 +- src/components/daily-check-quota.tsx | 88 ++- src/components/error-boundary.tsx | 10 +- src/components/footer.tsx | 40 +- src/components/top-navigation.tsx | 177 +++-- src/i18n/index.ts | 21 + src/i18n/locales/en.json | 738 ++++++++++++++++++ src/i18n/locales/zh-CN.json | 719 +++++++++++++++++ src/index.tsx | 25 +- src/pages/activate.tsx | 8 +- src/pages/admin-apps.tsx | 74 +- src/pages/admin-config.tsx | 38 +- src/pages/admin-metrics.tsx | 45 +- src/pages/admin-service-status.tsx | 388 +++++---- src/pages/admin-users.tsx | 88 ++- src/pages/api-tokens.tsx | 104 +-- src/pages/apps.tsx | 33 +- src/pages/audit-logs.tsx | 240 +++--- src/pages/inactivated.tsx | 14 +- src/pages/login.tsx | 14 +- src/pages/manage/components/bind-package.tsx | 60 +- src/pages/manage/components/commit.tsx | 16 +- src/pages/manage/components/deps-table.tsx | 25 +- src/pages/manage/components/package-list.tsx | 103 +-- .../components/publish-feature-table.tsx | 33 +- src/pages/manage/components/setting-modal.tsx | 39 +- src/pages/manage/components/version-table.tsx | 183 +++-- src/pages/manage/index.tsx | 23 +- src/pages/realtime-metrics.tsx | 82 +- src/pages/register.tsx | 28 +- .../reset-password/components/send-email.tsx | 16 +- .../components/set-password.tsx | 16 +- .../reset-password/components/success.tsx | 6 +- src/pages/reset-password/index.tsx | 8 +- src/pages/welcome.tsx | 16 +- 40 files changed, 2751 insertions(+), 908 deletions(-) create mode 100644 src/i18n/index.ts create mode 100644 src/i18n/locales/en.json create mode 100644 src/i18n/locales/zh-CN.json diff --git a/bun.lock b/bun.lock index 888b59c..c50bd26 100644 --- a/bun.lock +++ b/bun.lock @@ -16,9 +16,12 @@ "git-url-parse": "^16.1.0", "hash-wasm": "^4.12.0", "history": "^5.3.0", + "i18next": "^26.3.3", + "i18next-browser-languagedetector": "^8.2.1", "json-diff-kit": "^1.0.35", "react": "^19.2.7", "react-dom": "^19.2.7", + "react-i18next": "^17.0.8", "react-router-dom": "^7.18.0", "ua-parser-js": "^2.0.10", "vanilla-jsoneditor": "^3.12.0", @@ -721,8 +724,14 @@ "history": ["history@5.3.0", "https://registry.npmmirror.com/history/-/history-5.3.0.tgz", { "dependencies": { "@babel/runtime": "^7.7.6" } }, "sha512-ZqaKwjjrAYUYfLG+htGaIIZ4nioX2L70ZUMIFysS3xvBsSG4x/n1V6TXV3N8ZYNuFGlDirFg32T7B6WOUPDYcQ=="], + "html-parse-stringify": ["html-parse-stringify@3.0.1", "", { "dependencies": { "void-elements": "3.1.0" } }, "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg=="], + "html2canvas": ["html2canvas@1.4.1", "https://registry.npmmirror.com/html2canvas/-/html2canvas-1.4.1.tgz", { "dependencies": { "css-line-break": "^2.1.0", "text-segmentation": "^1.0.3" } }, "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA=="], + "i18next": ["i18next@26.3.3", "", { "peerDependencies": { "typescript": "^5 || ^6" }, "optionalPeers": ["typescript"] }, "sha512-aYVegyBdXSO93CMMihvr47jI7GHSOcIahMpJX+qzUXDzW4xDJf2uenIA+45vDU+YhiVdcfsql70AC9RVdMNrHg=="], + + "i18next-browser-languagedetector": ["i18next-browser-languagedetector@8.2.1", "", { "dependencies": { "@babel/runtime": "^7.23.2" } }, "sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw=="], + "iconv-lite": ["iconv-lite@0.6.3", "https://registry.npmmirror.com/iconv-lite/-/iconv-lite-0.6.3.tgz", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], "immutable-json-patch": ["immutable-json-patch@6.0.2", "https://registry.npmmirror.com/immutable-json-patch/-/immutable-json-patch-6.0.2.tgz", {}, "sha512-KwCA5DXJiyldda8SPha1zB+6+vbEi5/jRRcYii/6yFXlyu9ZjiSH/wPq8Ri2Hk8iGjjTMcHW3Z21S4MOpl7sOw=="], @@ -889,6 +898,8 @@ "react-dom": ["react-dom@19.2.7", "https://registry.npmmirror.com/react-dom/-/react-dom-19.2.7.tgz", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.7" } }, "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ=="], + "react-i18next": ["react-i18next@17.0.8", "", { "dependencies": { "@babel/runtime": "^7.29.2", "html-parse-stringify": "^3.0.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "i18next": ">= 26.2.0", "react": ">= 16.8.0", "react-dom": "*", "react-native": "*", "typescript": "^5 || ^6" }, "optionalPeers": ["react-dom", "react-native", "typescript"] }, "sha512-0ooKbGLU8JXhe1zwpQUWIeXSgLPOfwJmgheWRIUpcoA0CpyabpGhayjdG+/eA5esC1AQ8h2jWpXjJfzQzeDOCw=="], + "react-is": ["react-is@18.3.1", "https://registry.npmmirror.com/react-is/-/react-is-18.3.1.tgz", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="], "react-refresh": ["react-refresh@0.18.0", "https://registry.npmmirror.com/react-refresh/-/react-refresh-0.18.0.tgz", {}, "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw=="], @@ -973,12 +984,16 @@ "update-browserslist-db": ["update-browserslist-db@1.2.3", "https://registry.npmmirror.com/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], + "use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="], + "utrie": ["utrie@1.0.2", "https://registry.npmmirror.com/utrie/-/utrie-1.0.2.tgz", { "dependencies": { "base64-arraybuffer": "^1.0.2" } }, "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw=="], "vanilla-jsoneditor": ["vanilla-jsoneditor@3.12.0", "https://registry.npmmirror.com/vanilla-jsoneditor/-/vanilla-jsoneditor-3.12.0.tgz", { "dependencies": { "@codemirror/autocomplete": "^6.18.1", "@codemirror/commands": "^6.7.1", "@codemirror/lang-json": "^6.0.1", "@codemirror/language": "^6.10.3", "@codemirror/lint": "^6.8.2", "@codemirror/search": "^6.5.6", "@codemirror/state": "^6.4.1", "@codemirror/view": "^6.34.1", "@fortawesome/free-regular-svg-icons": "^6.6.0 || ^7.0.1", "@fortawesome/free-solid-svg-icons": "^6.6.0 || ^7.0.1", "@jsonquerylang/jsonquery": "^3.1.1 || ^4.0.0 || ^5.0.0", "@lezer/highlight": "^1.2.1", "@replit/codemirror-indentation-markers": "^6.5.3", "ajv": "^8.17.1", "codemirror-wrapped-line-indent": "^1.0.8", "diff-sequences": "^29.6.3", "immutable-json-patch": "^6.0.1", "jmespath": "^0.16.0", "json-source-map": "^0.6.1", "jsonpath-plus": "^10.3.0", "jsonrepair": "^3.0.0", "lodash-es": "^4.17.23", "memoize-one": "^6.0.0", "natural-compare-lite": "^1.4.0", "svelte": "^5.0.0", "vanilla-picker": "^2.12.3" } }, "sha512-3cLH1jdr2t1+t9XnPkF9EiR394ty8hcVNX/GTj83RjEmkUMZyL/HvQ3e1PvQ3Be8rfH3AKcgySZYLKFfpVnjqQ=="], "vanilla-picker": ["vanilla-picker@2.12.3", "https://registry.npmmirror.com/vanilla-picker/-/vanilla-picker-2.12.3.tgz", { "dependencies": { "@sphinxxxx/color-conversion": "^2.2.2" } }, "sha512-qVkT1E7yMbUsB2mmJNFmaXMWE2hF8ffqzMMwe9zdAikd8u2VfnsVY2HQcOUi2F38bgbxzlJBEdS1UUhOXdF9GQ=="], + "void-elements": ["void-elements@3.1.0", "", {}, "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w=="], + "w3c-keyname": ["w3c-keyname@2.2.8", "https://registry.npmmirror.com/w3c-keyname/-/w3c-keyname-2.2.8.tgz", {}, "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ=="], "whatwg-mimetype": ["whatwg-mimetype@3.0.0", "https://registry.npmmirror.com/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", {}, "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q=="], diff --git a/package.json b/package.json index 5240142..9073cb4 100644 --- a/package.json +++ b/package.json @@ -21,9 +21,12 @@ "git-url-parse": "^16.1.0", "hash-wasm": "^4.12.0", "history": "^5.3.0", + "i18next": "^26.3.3", + "i18next-browser-languagedetector": "^8.2.1", "json-diff-kit": "^1.0.35", "react": "^19.2.7", "react-dom": "^19.2.7", + "react-i18next": "^17.0.8", "react-router-dom": "^7.18.0", "ua-parser-js": "^2.0.10", "vanilla-jsoneditor": "^3.12.0", diff --git a/src/components/app-detail-header.tsx b/src/components/app-detail-header.tsx index 0b155fa..e58237b 100644 --- a/src/components/app-detail-header.tsx +++ b/src/components/app-detail-header.tsx @@ -5,6 +5,7 @@ import { } from '@ant-design/icons'; import { Breadcrumb, Button, Tag } from 'antd'; import type { ReactNode } from 'react'; +import { useTranslation } from 'react-i18next'; import { cn } from '@/utils/helper'; import PlatformIcon from './platform-icon'; @@ -17,7 +18,7 @@ export interface AppDetailHeaderApp { export function AppDetailHeader({ activeView, app, - appNameFallback = '选择应用', + appNameFallback, managementDisabled, metricsDisabled, onManagementClick, @@ -37,6 +38,9 @@ export function AppDetailHeader({ sectionLabel: string; settingsDisabled?: boolean; }) { + const { t } = useTranslation(); + const fallbackName = appNameFallback ?? t('app_detail_header.select_app'); + return (
@@ -51,9 +55,11 @@ export function AppDetailHeader({ - {app?.name || appNameFallback} + {app?.name || fallbackName} - {app?.status === 'paused' && 暂停} + {app?.status === 'paused' && ( + {t('app_detail_header.paused')} + )} ), }, @@ -68,7 +74,7 @@ export function AppDetailHeader({ disabled={settingsDisabled} onClick={onSettingsClick} > - 应用设置 + {t('app_detail_header.app_settings')} )}
@@ -82,14 +88,14 @@ export function AppDetailHeader({ active={activeView === 'management'} disabled={managementDisabled} icon={} - label="应用发布" + label={t('app_detail_header.tab_releases')} onClick={onManagementClick} /> } - label="实时数据" + label={t('app_detail_header.tab_metrics')} onClick={onMetricsClick} />
diff --git a/src/components/app-drawer.tsx b/src/components/app-drawer.tsx index 393fffd..e103864 100644 --- a/src/components/app-drawer.tsx +++ b/src/components/app-drawer.tsx @@ -8,6 +8,7 @@ import { import { Empty, Grid, Input, Radio, Tag } from 'antd'; import type { ReactNode } from 'react'; import { useEffect, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; import { cn, getManageAppDrawerCollapsed, @@ -74,6 +75,7 @@ export function AppDrawer({ onSettings?: (app: AppDrawerItem) => void; placement: Exclude; }) { + const { t } = useTranslation(); const [query, setQuery] = useState(''); const normalizedQuery = query.trim().toLowerCase(); const filteredApps = useMemo(() => { @@ -111,13 +113,15 @@ export function AppDrawer({ >
@@ -154,16 +162,22 @@ export function AppDrawer({ size="small" value={placement} > - 左侧 - 右侧 - 隐藏 + + {t('app_drawer.placement_left')} + + + {t('app_drawer.placement_right')} + + + {t('app_drawer.placement_hide')} +
} - placeholder="搜索应用" + placeholder={t('app_drawer.search_apps')} value={query} onChange={(event) => setQuery(event.target.value)} /> @@ -199,7 +213,7 @@ export function AppDrawer({ !isLoading && ( ) @@ -330,6 +344,7 @@ function AppIconButton({ isActive: boolean; onSelect: (app: AppDrawerItem) => void; }) { + const { t } = useTranslation(); return ( {onSettings && ( diff --git a/src/components/create-app-modal.tsx b/src/components/create-app-modal.tsx index ce8b552..4004e0e 100644 --- a/src/components/create-app-modal.tsx +++ b/src/components/create-app-modal.tsx @@ -1,4 +1,5 @@ import { Form, Input, Modal, message, Select } from 'antd'; +import i18n from '@/i18n'; import { api } from '@/services/api'; import PlatformIcon from './platform-icon'; @@ -7,6 +8,7 @@ export const showCreateAppModal = ({ }: { onCreated?: (id: number) => void | Promise; } = {}) => { + const t = i18n.t.bind(i18n); let name = ''; let platform = 'android'; @@ -17,15 +19,18 @@ export const showCreateAppModal = ({ content: (

- + { name = target.value; }} /> - + } value={searchKeyword} onChange={(event) => setSearchKeyword(event.target.value)} @@ -305,7 +307,7 @@ export const Component = () => { simple: isMobile, showQuickJumper: !isMobile, showSizeChanger: !isMobile, - showTotal: isMobile ? undefined : (count) => `共 ${count} 个应用`, + showTotal: isMobile ? undefined : (count) => t('admin_apps.apps_count', { count }), onChange: (page, nextPageSize) => { patchSearchParams(setSearchParams, { page: String(page), @@ -319,13 +321,13 @@ export const Component = () => { setIsModalOpen(false)} footer={[ , , ]} > @@ -341,16 +343,16 @@ export const Component = () => { - - + + - + + + - - + + - - + + + - + = { - pv: '请求数', - uv: '用户数', -}; + +const getModeLabels = (t: (key: string) => string): Record => ({ + pv: t('admin_metrics.mode_requests'), + uv: t('admin_metrics.mode_users'), +}); const metricKeyOptions = [ { label: 'rn', value: 'rn' }, @@ -123,6 +125,7 @@ const parseDateRange = ( }; export const Component = () => { + const { t } = useTranslation(); const [searchParams, setSearchParams] = useSearchParams(); const legendValuesRef = useRef([]); const defaultRangeRef = useRef<[Dayjs, Dayjs] | null>(null); @@ -137,6 +140,8 @@ export const Component = () => { const startDate = rangeStart.toISOString(); const endDate = rangeEnd.toISOString(); + const modeLabels = getModeLabels(t); + const { data: pvMetrics, isLoading: isLoadingPv } = useQuery({ queryKey: ['globalMetrics', startDate, endDate, 'pv'], queryFn: () => @@ -308,7 +313,7 @@ export const Component = () => { shapeField: 'smooth', axis: { x: { - title: '时间', + title: t('admin_metrics.time'), labelAutoRotate: true, labelFormatter: (value: string) => { const parsed = dayjs(value); @@ -365,10 +370,10 @@ export const Component = () => {
- 全局数据统计 + {t('admin_metrics.title')}
- 当前时间范围、指标模式和分类前缀都会写入 URL,方便回放同一视图。 + {t('admin_metrics.description')}
@@ -381,11 +386,11 @@ export const Component = () => { }} className="w-full md:w-auto" > - 请求数 - 用户数 + {t('admin_metrics.mode_requests')} + {t('admin_metrics.mode_users')} } value={searchKeyword} onChange={(event) => setSearchKeyword(event.target.value)} @@ -361,7 +365,7 @@ export const Component = () => { simple: isMobile, showQuickJumper: !isMobile, showSizeChanger: !isMobile, - showTotal: isMobile ? undefined : (count) => `共 ${count} 个用户`, + showTotal: isMobile ? undefined : (count) => t('admin_users.users_count', { count }), onChange: (page, nextPageSize) => { patchSearchParams(setSearchParams, { page: String(page), @@ -375,13 +379,13 @@ export const Component = () => { setIsModalOpen(false)} footer={[ , , ]} > - + - + - + - + @@ -429,17 +433,17 @@ export const Component = () => { size="small" onClick={() => handleExtendTierExpiry(days)} > - +{days} 天 + {t('admin_users.expiry_plus_days', { days })} ))} { - message.error(error.message || '创建失败'); + message.error(error.message || t('api_tokens.create_failed')); }, }); const revokeMutation = useMutation({ mutationFn: api.revokeApiToken, onSuccess: () => { - message.success('Token 已撤销'); + message.success(t('api_tokens.revoke_success')); queryClient.invalidateQueries({ queryKey: ['apiTokens'] }); }, onError: (error: Error) => { - message.error(error.message || '撤销失败'); + message.error(error.message || t('api_tokens.revoke_failed')); }, }); @@ -85,29 +87,29 @@ function ApiTokensPage() { const columns: ColumnsType = [ { - title: 'ID', + title: t('api_tokens.col_id'), dataIndex: 'id', key: 'id', responsive: ['md'], width: 60, }, { - title: '名称', + title: t('api_tokens.col_name'), dataIndex: 'name', key: 'name', render: (name: string, record: ApiToken) => ( {name} - {record.isRevoked && 已撤销} + {record.isRevoked && {t('api_tokens.revoked')}} {record.isExpired && !record.isRevoked && ( - 已过期 + {t('api_tokens.expired')} )} ), }, { - title: 'Token', + title: t('api_tokens.col_token'), dataIndex: 'tokenSuffix', key: 'tokenSuffix', render: (tokenSuffix: string) => ( @@ -117,35 +119,35 @@ function ApiTokensPage() { ), }, { - title: '权限', + title: t('api_tokens.col_permissions'), dataIndex: 'permissions', key: 'permissions', render: (permissions: ApiToken['permissions']) => ( - {permissions?.read && 读取} - {permissions?.write && 写入} - {permissions?.delete && 删除} + {permissions?.read && {t('api_tokens.perm_read')}} + {permissions?.write && {t('api_tokens.perm_write')}} + {permissions?.delete && {t('api_tokens.perm_delete')}} ), }, { - title: '过期时间', + title: t('api_tokens.col_expires'), dataIndex: 'expiresAt', key: 'expiresAt', responsive: ['sm'], render: (expiresAt: string | null) => - expiresAt ? dayjs(expiresAt).format('YYYY-MM-DD HH:mm') : '永不过期', + expiresAt ? dayjs(expiresAt).format('YYYY-MM-DD HH:mm') : t('api_tokens.never'), }, { - title: '最后使用', + title: t('api_tokens.col_last_used'), dataIndex: 'lastUsedAt', key: 'lastUsedAt', responsive: ['lg'], render: (lastUsedAt: string | null) => - lastUsedAt ? dayjs(lastUsedAt).format('YYYY-MM-DD HH:mm') : '从未使用', + lastUsedAt ? dayjs(lastUsedAt).format('YYYY-MM-DD HH:mm') : t('api_tokens.never_used'), }, { - title: '创建时间', + title: t('api_tokens.col_created'), dataIndex: 'createdAt', key: 'createdAt', responsive: ['lg'], @@ -153,15 +155,15 @@ function ApiTokensPage() { dayjs(createdAt).format('YYYY-MM-DD HH:mm'), }, { - title: '操作', + title: t('api_tokens.col_action'), key: 'action', render: (_: unknown, record: ApiToken) => ( revokeMutation.mutate(record.id)} - okText="确定" - cancelText="取消" + okText={t('api_tokens.yes')} + cancelText={t('api_tokens.no')} disabled={record.isRevoked} > ), @@ -181,17 +183,17 @@ function ApiTokensPage() {
-
API Token 管理
+
{t('api_tokens.title')}
- API Token 可用于 CI/CD 流程或自动化脚本中调用{' '} + {t('api_tokens.description_prefix')}{' '} - Pushy API + {t('api_tokens.pushy_api')} - 。每个用户最多可同时保留 10 个活跃的 Token。 + {t('api_tokens.description_suffix')}
{ @@ -226,42 +228,42 @@ function ApiTokensPage() { > - + - 读取 (read) - 查看应用、版本、原生包信息 + - 写入 (write) - 创建和更新应用、发布版本、上传原生包 + - 删除 (delete) - 删除应用、版本、原生包 +
- 注意:写入权限不包括读取权限,如需同时读取请勾选读取权限 + {t('api_tokens.perm_note')}
- + } - placeholder="搜索操作、接口、IP、API Key" + placeholder={t('audit_logs.search_placeholder')} onChange={(event) => setSearchInput(event.target.value)} className="w-full md:w-64" /> { (password = target.value)} @@ -53,13 +55,13 @@ export const Login = () => { loading={loading} block > - 登录 + {t('login.login_button')} - 注册 - 忘记密码? + {t('login.register')} + {t('login.forgot_password')} diff --git a/src/pages/manage/components/bind-package.tsx b/src/pages/manage/components/bind-package.tsx index 9a6f690..1feec08 100644 --- a/src/pages/manage/components/bind-package.tsx +++ b/src/pages/manage/components/bind-package.tsx @@ -15,6 +15,7 @@ import { Table, } from 'antd'; import { useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; import { api } from '@/services/api'; import { useManageContext } from '../hooks/useManageContext'; @@ -67,16 +68,18 @@ function getDepsChangeColumns({ summary, filters, onFilterChange, + t, }: { summary: DepChangeSummary; filters: DepChangeFilters; onFilterChange: (type: DepChangeType, checked: boolean) => void; + t: (key: string) => string; }) { return [ { title: ( - 依赖( + {t('bind_package.col_dependencies')}( { @@ -84,7 +87,7 @@ function getDepsChangeColumns({ }} /> - 新增 {summary.added} + {t('bind_package.change_added')} {summary.added} - 移除 {summary.removed} + {t('bind_package.change_removed')} {summary.removed} - 变更 {summary.changed} + {t('bind_package.change_changed')} {summary.changed} @@ -114,7 +117,7 @@ function getDepsChangeColumns({ ellipsis: true, }, { - title: '版本变化', + title: t('bind_package.col_version_change'), key: 'versionChange', ellipsis: true, render: (_: unknown, record: DepChangeRow) => { @@ -135,7 +138,7 @@ function getDepsChangeColumns({ if (record.changeType === '新增') { return ( - 新增 + {t('bind_package.change_added')} | {record.oldVersion} @@ -148,7 +151,7 @@ function getDepsChangeColumns({ return ( - 移除 + {t('bind_package.change_removed')} | {record.oldVersion} @@ -171,6 +174,7 @@ const DepsChangeConfirmContent = ({ versionDisplayName: string | number; changes: DepChangeRow[]; }) => { + const { t } = useTranslation(); const [filters, setFilters] = useState({ 新增: true, 移除: true, @@ -190,25 +194,20 @@ const DepsChangeConfirmContent = ({ onFilterChange: (type, checked) => { setFilters((prev) => ({ ...prev, [type]: checked })); }, + t, }), - [summary, filters], + [summary, filters, t], ); return (
-
目标原生包:{packageName}
-
热更包:{versionDisplayName}
+
{t('bind_package.target_package')}{packageName}
+
{t('bind_package.ota_version')}{versionDisplayName}
- 如果变更的依赖是纯 JS 模块,则一般没有影响;若包含 - 原生代码 - 的新增或变化,热更可能导致功能不正常甚至闪退。建议仔细检查并在正式发布前使用扫码功能完整测试。 - - } + message={t('bind_package.native_warning')} /> className="mt-3" @@ -217,7 +216,7 @@ const DepsChangeConfirmContent = ({ columns={columns} dataSource={filteredChanges} scroll={{ y: 320 }} - locale={{ emptyText: '当前筛选条件下无依赖变化' }} + locale={{ emptyText: t('bind_package.no_dep_changes') }} />
); @@ -289,6 +288,7 @@ const BindPackage = ({ versionDeps?: Record; versionName?: string; }) => { + const { t } = useTranslation(); const { packages: allPackages, appId, @@ -371,11 +371,11 @@ const BindPackage = ({ ); Modal.confirm({ - title: '检测到依赖变化,确认继续发布?', + title: t('bind_package.dep_changes_title'), maskClosable: true, okButtonProps: { danger: true }, - okText: '继续发布', - cancelText: '取消', + okText: t('bind_package.publish_anyway'), + cancelText: t('bind_package.cancel'), width: 820, content, async onOk() { @@ -392,17 +392,17 @@ const BindPackage = ({ publishMenuItems.push( { key: 'all', - label: '全部可用原生包', + label: t('bind_package.all_packages'), children: [ { key: 'all-full', - label: '全量', + label: t('bind_package.full_release'), icon: , onClick: () => publishToPackages(availablePackages), }, { key: 'all-gray', - label: '灰度', + label: t('bind_package.staged_release'), icon: , children: [1, 2, 5, 10, 20, 50].map((percentage) => ({ key: `all-gray-${percentage}`, @@ -422,13 +422,13 @@ const BindPackage = ({ children: [ { key: `pkg-${p.id}-full`, - label: '全量', + label: t('bind_package.full_release'), icon: , onClick: () => publishToPackage(p), }, { key: `pkg-${p.id}-gray`, - label: '灰度', + label: t('bind_package.staged_release'), icon: , children: [1, 2, 5, 10, 20, 50].map((percentage) => ({ key: `pkg-${p.id}-gray-${percentage}`, @@ -460,7 +460,7 @@ const BindPackage = ({ : [ { key: 'full', - label: '全量', + label: t('bind_package.full_release'), icon: , onClick: () => publishToPackage(p), }, @@ -469,7 +469,7 @@ const BindPackage = ({ if (rolloutConfigNumber < 50 && !isFull) { items.push({ key: 'gray', - label: '灰度', + label: t('bind_package.staged_release'), icon: , children: [1, 2, 5, 10, 20, 50].reduce< NonNullable @@ -490,7 +490,7 @@ const BindPackage = ({ } items.push({ key: 'unpublish', - label: '取消发布', + label: t('bind_package.unpublish'), icon: , onClick: () => { const bindingId = binding.id; @@ -535,7 +535,7 @@ const BindPackage = ({ className="ant-typography-edit" > )} diff --git a/src/pages/manage/components/commit.tsx b/src/pages/manage/components/commit.tsx index ffb4a28..7848776 100644 --- a/src/pages/manage/components/commit.tsx +++ b/src/pages/manage/components/commit.tsx @@ -2,8 +2,11 @@ import { PullRequestOutlined } from '@ant-design/icons'; import { Button, Popover } from 'antd'; import dayjs from 'dayjs'; import gitUrlParse from 'git-url-parse'; +import { useTranslation } from 'react-i18next'; export const Commit = ({ commit }: { commit?: Commit }) => { + const { t } = useTranslation(); + if (!commit) { return ( { content={
-
最近的提交:
+
{t('commit.title')}:
- 需要使用 cli v1.42.0+ 版本上传,且使用 git - 管理代码才能查看提交记录 + {t('commit.description')}
@@ -56,12 +58,12 @@ export const Commit = ({ commit }: { commit?: Commit }) => { content={
-
最近的提交:
-
作者:{author}
+
{t('commit.title_with_commit')}:
+
{t('commit.author')}{author}
- 时间:{time.fromNow()}({time.format('YYYY-MM-DD HH:mm:ss')}) + {t('commit.time')}{time.fromNow()}({time.format('YYYY-MM-DD HH:mm:ss')})
-
摘要:{message}
+
{t('commit.summary')}{message}

{url ? ( ; name?: string; }) => { + const { t } = useTranslation(); const { packages, appId } = useManageContext(); const [popoverOpen, setPopoverOpen] = useState(false); const { versions, isLoading: versionsLoading } = useAllVersions({ @@ -40,7 +42,7 @@ export const DepsTable = ({ <>
-
JavaScript 依赖列表{!diffs && `(${name})`}
+
{t('deps_table.js_deps_title')}{!diffs && `(${name})`}
{diffs && (
{diffs.newName} @@ -55,7 +57,7 @@ export const DepsTable = ({ setDiffs(null); }} > - 返回 + {t('deps_table.back')} ) : ( { if (p.deps) { @@ -81,12 +83,12 @@ export const DepsTable = ({ { key: 'version', type: 'group', - label: '热更包', + label: t('deps_table.ota_versions'), children: versionsLoading ? [ { key: 'version_loading', - label: '加载中...', + label: t('deps_table.loading'), disabled: true, }, ] @@ -118,21 +120,21 @@ export const DepsTable = ({ setDiffs({ oldDeps: pkg?.deps, newDeps: deps, - newName: `原生包 ${pkg?.name}`, + newName: t('deps_table.native_package_with_name', { name: pkg?.name }), }); } else { const version = versions.find((v) => v.id === +id); setDiffs({ oldDeps: version?.deps, newDeps: deps, - newName: `热更包 ${version?.name}`, + newName: t('deps_table.ota_version_with_name', { name: version?.name }), }); } }, }} > @@ -164,15 +166,14 @@ export const DepsTable = ({ )}
- 仅在上传 - 时抓取package.json的直接依赖, 不保证严格匹配包内容,仅供参考。 + {t('deps_table.note')}
) : (
-

JavaScript 依赖列表

+

{t('deps_table.js_deps_heading')}

- 需要使用 cli v1.42.0+ 版本上传才能查看依赖列表 + {t('deps_table.cli_required')}
)} diff --git a/src/pages/manage/components/package-list.tsx b/src/pages/manage/components/package-list.tsx index 2910d31..196aff7 100644 --- a/src/pages/manage/components/package-list.tsx +++ b/src/pages/manage/components/package-list.tsx @@ -20,6 +20,7 @@ import { Typography, } from 'antd'; import { type Dispatch, type SetStateAction, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; import { Link } from 'react-router-dom'; import { rootRouterPath } from '@/router'; import { api } from '@/services/api'; @@ -38,6 +39,7 @@ const PackageList = ({ selectedPackageIds: number[]; setSelectedPackageIds: Dispatch>; }) => { + const { t } = useTranslation(); const { app, appId, packageTimestampWarnings } = useManageContext(); const selectedPackageIdSet = useMemo( () => new Set(selectedPackageIds), @@ -84,10 +86,10 @@ const PackageList = ({ (id) => !selectedPackages.some((item) => item.id === id), ), ); - }) + }, t) } > - 删除 + {t('package_list.delete_button')}
) : undefined @@ -112,16 +114,17 @@ function removeSelectedPackages( items: Package[], appId: number, onSuccess: () => void, + t: (key: string, opts?: Record) => string, ) { if (items.length === 0) { return; } Modal.confirm({ - title: '确认永久删除所选原生包?', + title: t('package_list.batch_delete_title'), content: (
- 删除后无法恢复,请确认这些原生包不再需要。 + {t('package_list.batch_delete_warning')}
{items.map((item) => ( @@ -142,12 +145,12 @@ function removeSelectedPackages( }); } -function remove(item: Package, appId: number) { +function remove(item: Package, appId: number, t: (key: string, opts?: Record) => string) { Modal.confirm({ - title: `确认永久删除原生包“${item.name}”?`, + title: t('package_list.single_delete_title', { name: item.name }), content: ( - 删除后无法恢复,请确认这个原生包不再需要。 + {t('package_list.single_delete_warning')} ), maskClosable: true, @@ -158,7 +161,7 @@ function remove(item: Package, appId: number) { }); } -function edit(item: Package, appId: number) { +function edit(item: Package, appId: number, t: (key: string) => string) { let { note, status } = item; Modal.confirm({ icon: null, @@ -166,21 +169,21 @@ function edit(item: Package, appId: number) { maskClosable: true, content: (
- + (note = target.value)} /> - + @@ -201,31 +204,34 @@ const TimestampWarning = ({ }: { warningTimestamps: string[]; realtimeMetricsPath: string; -}) => ( - -
发现不同时间戳:
-
- {warningTimestamps.map((timestamp) => ( -
{timestamp}
- ))} -
-
- 需要在应用设置中打开“忽略时间戳”选项,否则这些包无法获得热更新。 -
-
- 点击此处查看实时数据 +}) => { + const { t } = useTranslation(); + return ( + +
{t('package_list.mismatch_title')}
+
+ {warningTimestamps.map((timestamp) => ( +
{timestamp}
+ ))} +
+
+ {t('package_list.mismatch_desc')} +
+
+ {t('package_list.view_realtime')} +
-
- } - > - - - - -); + } + > + + + + + ); +}; const Item = ({ item, @@ -240,8 +246,13 @@ const Item = ({ warningTimestamps: string[]; realtimeMetricsPath?: string; }) => { + const { t } = useTranslation(); const { appId } = useManageContext(); const hasTimestampWarning = warningTimestamps.length > 0; + const statusMap: Partial, string>> = { + paused: t('package_list.status_map_paused'), + expired: t('package_list.status_map_expired'), + }; return (
@@ -264,21 +275,21 @@ const Item = ({ /> )} {item.status && item.status !== 'normal' && ( - {status[item.status]} + {statusMap[item.status]} )}
- +
} @@ -304,7 +315,3 @@ const Item = ({
); }; -const status: Partial, string>> = { - paused: '暂停', - expired: '过期', -}; diff --git a/src/pages/manage/components/publish-feature-table.tsx b/src/pages/manage/components/publish-feature-table.tsx index 2279b2d..6625afe 100644 --- a/src/pages/manage/components/publish-feature-table.tsx +++ b/src/pages/manage/components/publish-feature-table.tsx @@ -1,10 +1,13 @@ import { Table, Tag } from 'antd'; +import { useTranslation } from 'react-i18next'; /** * 发布功能支持情况表格组件 * 展示不同 react-native-update 版本对各种发布功能的支持情况 */ export default function PublishFeatureTable() { + const { t } = useTranslation(); + return (
= 2.4.0)', + fullRelease: t('publish_feature_table.supported'), + grayRelease: t('publish_feature_table.supported'), + bothRelease: t('publish_feature_table.both_supported'), }, ]} columns={[ { title: ( - react-native-update 版本 + {t('publish_feature_table.version_header_line1')}
- (用户端) + {t('publish_feature_table.version_header_line2')}
), dataIndex: 'version', @@ -47,7 +50,7 @@ export default function PublishFeatureTable() { width: 200, }, { - title: '仅全量发布', + title: t('publish_feature_table.full_release_only'), dataIndex: 'fullRelease', key: 'fullRelease', align: 'center', @@ -60,7 +63,7 @@ export default function PublishFeatureTable() { }, }, { - title: '仅灰度发布', + title: t('publish_feature_table.gray_release_only'), dataIndex: 'grayRelease', key: 'grayRelease', align: 'center', @@ -73,7 +76,7 @@ export default function PublishFeatureTable() { }, }, { - title: '同时发布', + title: t('publish_feature_table.both_release'), dataIndex: 'bothRelease', key: 'bothRelease', align: 'center', @@ -88,7 +91,7 @@ export default function PublishFeatureTable() { }, ]} /> -
注:取消发布不会导致已更新的用户回滚。
+
{t('publish_feature_table.note')}
); } diff --git a/src/pages/manage/components/setting-modal.tsx b/src/pages/manage/components/setting-modal.tsx index 9806f55..ac24277 100644 --- a/src/pages/manage/components/setting-modal.tsx +++ b/src/pages/manage/components/setting-modal.tsx @@ -1,11 +1,13 @@ import { DeleteFilled } from '@ant-design/icons'; import { Button, Form, Input, Modal, Switch, Typography } from 'antd'; +import { useTranslation } from 'react-i18next'; import { rootRouterPath, router } from '@/router'; import { api } from '@/services/api'; import { useUserInfo } from '@/utils/hooks'; import { useManageContext } from '../hooks/useManageContext'; const SettingModal = () => { + const { t } = useTranslation(); const { user } = useUserInfo(); const { appId } = useManageContext(); const appKey = Form.useWatch('appKey') as string; @@ -13,21 +15,29 @@ const SettingModal = () => { return ( <> - + {appId} - + {appKey} - + @@ -35,18 +45,21 @@ const SettingModal = () => { (value ? 'normal' : 'paused')} getValueProps={(value) => ({ value: value === 'normal' || value === null || value === undefined, })} > - + (value ? 'enabled' : 'disabled')} getValueProps={(value) => ({ value: value === 'enabled' })} @@ -56,18 +69,18 @@ const SettingModal = () => { (user?.tier === 'free' || user?.tier === 'standard') && ignoreBuildTime !== 'enabled' } - checkedChildren="已启用" - unCheckedChildren="已禁用" + checkedChildren={t('setting_modal.enabled')} + unCheckedChildren={t('setting_modal.disabled')} /> - + diff --git a/src/pages/manage/components/version-table.tsx b/src/pages/manage/components/version-table.tsx index fda085c..ef27e8f 100644 --- a/src/pages/manage/components/version-table.tsx +++ b/src/pages/manage/components/version-table.tsx @@ -12,6 +12,7 @@ import { } from 'antd'; import type { ColumnType } from 'antd/lib/table'; import { type ReactNode, useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; import type { TextContent } from 'vanilla-jsoneditor'; import { TEST_QR_CODE_DOC } from '@/constants/links'; import { api } from '@/services/api'; @@ -25,28 +26,29 @@ import PublishFeatureTable from './publish-feature-table'; const DEEP_LINK_EXAMPLE = 'pushy://'; -function getDeepLinkError(deepLink: string) { +function getDeepLinkError(deepLink: string, t: (key: string) => string) { if (!deepLink) { - return '请输入 App 已注册的 URL Scheme,例如 pushy://'; + return t('version_table.deep_link_required'); } if (/^https?:\/\//i.test(deepLink)) { - return '这里不是网页地址,请填写 App 的自定义 Scheme,例如 pushy://'; + return t('version_table.deep_link_not_url'); } if (/[?#]/.test(deepLink) || !deepLink.endsWith('://')) { - return '这里只填写 Scheme 前缀,格式为 scheme://,不要带路径、参数或版本信息'; + return t('version_table.deep_link_format'); } if (!/^[a-z][a-z0-9+.-]*:\/\/$/i.test(deepLink)) { - return 'Scheme 需以字母开头,只能包含字母、数字、+、-、.'; + return t('version_table.deep_link_scheme'); } return ''; } const TestQrCode = ({ name, hash }: { name?: string; hash: string }) => { + const { t } = useTranslation(); const { appId, deepLink, setDeepLink } = useManageContext(); const [enableDeepLink, setEnableDeepLink] = useState(!!deepLink); const normalizedDeepLink = deepLink.trim(); const deepLinkError = enableDeepLink - ? getDeepLinkError(normalizedDeepLink) + ? getDeepLinkError(normalizedDeepLink, t) : ''; const isDeepLinkValid = enableDeepLink && !deepLinkError; @@ -70,14 +72,14 @@ const TestQrCode = ({ name, hash }: { name?: string; hash: string }) => { content={
@@ -88,10 +90,10 @@ const TestQrCode = ({ name, hash }: { name?: string; hash: string }) => {
{isDeepLinkValid - ? '二维码会拉起 App 并传入热更包 Hash' + ? t('version_table.qr_pass_hash') : enableDeepLink - ? 'Deep Link 格式未通过,当前二维码仍为普通 JSON' - : '未使用 Deep Link 时,二维码内容为普通 JSON'} + ? t('version_table.qr_deep_link_invalid') + : t('version_table.qr_no_deep_link')} { setEnableDeepLink(target.checked); }} > - 用 Deep Link 打开 App + {t('version_table.use_deep_link')} {enableDeepLink ? (
- 填 App 原生注册的 URL Scheme,只填前缀,不填路径或参数。 + {t('version_table.deep_link_hint')} { @@ -131,7 +133,9 @@ const TestQrCode = ({ name, hash }: { name?: string; hash: string }) => { ) : ( - 生成示例:{normalizedDeepLink}?type=... + {t('version_table.deep_link_example', { + link: normalizedDeepLink, + })} )}
@@ -150,10 +154,12 @@ function removeSelectedVersions({ selected, versions, appId, + t, }: { selected: number[]; versions: Version[]; appId: number; + t: (key: string) => string; }) { const versionNames: string[] = []; const selectedSet = new Set(selected); @@ -163,7 +169,7 @@ function removeSelectedVersions({ } } Modal.confirm({ - title: '删除所选热更包:', + title: t('version_table.delete_title'), content: versionNames.join(','), maskClosable: true, okButtonProps: { danger: true }, @@ -173,68 +179,75 @@ function removeSelectedVersions({ }); } -const columns: ColumnType[] = [ - { - title: '版本', - dataIndex: 'name', - render: (_, record) => ( - - - - - - } - /> - ), - }, - { - title: '描述', - dataIndex: 'description', - responsive: ['md'], - render: (_, record) => ( - - ), - }, - { - title: '自定义元信息', - dataIndex: 'metaInfo', - responsive: ['lg'], - render: (_, record) => , - }, - { - title: ( - }> - 发布到原生包 - - ( - 功能说明) - - - ), - dataIndex: 'packages', - width: '100%', - render: (_, { id, config, deps, name }) => ( - - ), - }, - { - title: '上传时间', - dataIndex: 'createdAt', - responsive: ['md'], - render: (_, record) => ( - - ), - }, -]; +function getColumns(t: (key: string) => string): ColumnType[] { + return [ + { + title: t('version_table.col_version'), + dataIndex: 'name', + render: (_, record) => ( + + + + + + } + /> + ), + }, + { + title: t('version_table.col_description'), + dataIndex: 'description', + responsive: ['md'], + render: (_, record) => ( + + ), + }, + { + title: t('version_table.col_metadata'), + dataIndex: 'metaInfo', + responsive: ['lg'], + render: (_, record) => ( + + ), + }, + { + title: ( + }> + {t('version_table.col_publish')} + + ( + {t('version_table.col_publish_info')}) + + + ), + dataIndex: 'packages', + width: '100%', + render: (_, { id, config, deps, name }) => ( + + ), + }, + { + title: t('version_table.col_uploaded'), + dataIndex: 'createdAt', + responsive: ['md'], + render: (_, record) => ( + + ), + }, + ]; +} const TextColumn = ({ record, @@ -247,6 +260,8 @@ const TextColumn = ({ isEditable?: boolean; extra?: ReactNode; }) => { + const { t } = useTranslation(); + const columns = getColumns(t); const key = recordKey; const { appId } = useManageContext(); let value = record[key as keyof Version] as string; @@ -321,6 +336,8 @@ const TextColumn = ({ ); }; export default function VersionTable() { + const { t } = useTranslation(); + const columns = getColumns(t); const screens = Grid.useBreakpoint(); const isMobile = !screens.md; const { appId } = useManageContext(); @@ -337,7 +354,7 @@ export default function VersionTable() {
'热更包'} + title={() => t('version_table.title')} columns={columns} dataSource={versions} size={isMobile ? 'small' : 'middle'} @@ -347,7 +364,9 @@ export default function VersionTable() { total: count, current: offset / pageSize + 1, pageSize, - showTotal: isMobile ? undefined : (total) => `共 ${total} 个 `, + showTotal: isMobile + ? undefined + : (total) => t('version_table.total_versions', { total }), onChange(page, size) { if (size) { setOffset((page - 1) * size); @@ -369,11 +388,11 @@ export default function VersionTable() { ) : undefined diff --git a/src/pages/manage/index.tsx b/src/pages/manage/index.tsx index 32835aa..b786f71 100644 --- a/src/pages/manage/index.tsx +++ b/src/pages/manage/index.tsx @@ -2,6 +2,7 @@ import { DownOutlined } from '@ant-design/icons'; import { Checkbox, Dropdown, Grid, Layout, type MenuProps, Tabs } from 'antd'; import { type Dispatch, type SetStateAction, useState } from 'react'; +import { useTranslation } from 'react-i18next'; import { useParams } from 'react-router-dom'; import './manage.css'; @@ -43,16 +44,18 @@ const PackageFilterControl = ({ selectedPackageIds: number[]; setSelectedPackageIds: Dispatch>; }) => { - const filterLabel = filter === 'all' ? '全部' : '未使用'; + const { t } = useTranslation(); + const filterLabel = + filter === 'all' ? t('manage.filter_all') : t('manage.filter_unused'); const items: MenuProps['items'] = [ { key: 'all', - label: '全部', + label: t('manage.filter_all'), onClick: () => setFilter('all'), }, { key: 'unused', - label: '未使用', + label: t('manage.filter_unused'), onClick: () => setFilter('unused'), }, ]; @@ -67,7 +70,7 @@ const PackageFilterControl = ({ return ( 0 && !allVisibleSelected} @@ -93,6 +96,7 @@ const PackageFilterControl = ({ }; const ManageDashBoard = () => { + const { t } = useTranslation(); const screens = Grid.useBreakpoint(); const isMobile = !screens.md; const { packages, unusedPackages, packagesLoading, bindingsLoading } = @@ -149,12 +153,12 @@ const ManageDashBoard = () => { items={[ { key: 'versions', - label: '热更包', + label: t('manage.tab_versions'), children: , }, { key: 'packages', - label: '原生包', + label: t('manage.tab_packages'), children: (
{packageList}
), @@ -172,7 +176,7 @@ const ManageDashBoard = () => { width={280} style={{ marginRight: 16, maxWidth: '100%' }} > -
原生包
+
{t('manage.tab_packages')}
{packageList} @@ -183,6 +187,7 @@ const ManageDashBoard = () => { }; export const Manage = () => { + const { t } = useTranslation(); const params = useParams<{ id?: string }>(); const id = Number(params.id!); const { app } = useApp(id); @@ -199,7 +204,7 @@ export const Manage = () => { { if (realtimeMetricsPath) { @@ -207,7 +212,7 @@ export const Manage = () => { } }} onSettingsClick={app ? () => openAppSettings(app) : undefined} - sectionLabel="应用" + sectionLabel={t('manage.breadcrumb_apps')} /> diff --git a/src/pages/realtime-metrics.tsx b/src/pages/realtime-metrics.tsx index 6da36a2..cf93933 100644 --- a/src/pages/realtime-metrics.tsx +++ b/src/pages/realtime-metrics.tsx @@ -4,6 +4,7 @@ import { Card, DatePicker, Input, Radio, Spin } from 'antd'; import type { Dayjs } from 'dayjs'; import dayjs from 'dayjs'; import { useEffect, useMemo, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; import { useSearchParams } from 'react-router-dom'; import { AppDetailHeader } from '@/components/app-detail-header'; import { AppDrawerLayout, useAppWorkspaceList } from '@/components/app-drawer'; @@ -31,7 +32,6 @@ interface FormattedCategory { isTotal: boolean; } -const TOTAL_LABEL = '查询热更次数'; const CATEGORY_SEPARATOR = '\u001f'; type ChartController = { @@ -39,30 +39,34 @@ type ChartController = { on: (...args: unknown[]) => unknown; }; -const formatCategory = (rawCategory: string): FormattedCategory => { +const formatCategory = ( + rawCategory: string, + t: (key: string, opts?: Record) => string, +): FormattedCategory => { + const totalLabel = t('realtime_metrics.update_checks'); if (!rawCategory) { - return { label: 'unknown', isTotal: false }; + return { label: t('realtime_metrics.unknown'), isTotal: false }; } if (rawCategory === '_total' || rawCategory === 'total') { - return { label: TOTAL_LABEL, isTotal: true }; + return { label: totalLabel, isTotal: true }; } const parts = rawCategory.split(CATEGORY_SEPARATOR); if (parts.length >= 2) { const key = parts[0]; let value = parts.slice(1).join(); if (!value || value === 'unknown') { - value = '无'; + value = t('realtime_metrics.none'); } if (key === 'hash') { return { - label: `已更新到热更包: ${value}`, + label: `${t('realtime_metrics.bundle_prefix')} ${value}`, attribute: 'hash', isTotal: false, }; } if (key === 'packageVersion_buildTime') { return { - label: `原生包: ${value}`, + label: `${t('realtime_metrics.package_prefix')} ${value}`, attribute: 'packageVersion_buildTime', isTotal: false, }; @@ -73,7 +77,7 @@ const formatCategory = (rawCategory: string): FormattedCategory => { rawCategory.endsWith(`${CATEGORY_SEPARATOR}unknown`) ) { return { - label: rawCategory.replace(CATEGORY_SEPARATOR, ': 无'), + label: rawCategory.replace(CATEGORY_SEPARATOR, `: ${t('realtime_metrics.none')}`), isTotal: false, }; } @@ -83,13 +87,13 @@ const formatCategory = (rawCategory: string): FormattedCategory => { }; }; -const attributeOptions = [ - { label: '热更包', value: 'hash' }, - { label: '原生包', value: 'packageVersion_buildTime' }, +const getAttributeOptions = (t: (key: string) => string) => [ + { label: t('realtime_metrics.bundle'), value: 'hash' as const }, + { label: t('realtime_metrics.package'), value: 'packageVersion_buildTime' as const }, ]; -const formatTooltipItem = (point: ChartDataPoint) => { - const countLabel = `${point.value.toLocaleString()} 次`; +const formatTooltipItem = (point: ChartDataPoint, t: (key: string) => string) => { + const countLabel = `${point.value.toLocaleString()}${t('realtime_metrics.checks_suffix')}`; if (point.isTotal || point.sharePercent === undefined) { return countLabel; } @@ -97,6 +101,7 @@ const formatTooltipItem = (point: ChartDataPoint) => { }; export const Component = () => { + const { t } = useTranslation(); const [searchParams, setSearchParams] = useSearchParams({ attribute: 'hash', }); @@ -119,6 +124,9 @@ export const Component = () => { ? 'packageVersion_buildTime' : 'hash'; + const attributeOptions = getAttributeOptions(t); + const totalLabel = t('realtime_metrics.update_checks'); + const selectableAppKeys = useMemo( () => selectableApps @@ -183,7 +191,7 @@ export const Component = () => { for (const bucket of data.data) { for (const [dictIndex, count] of bucket.data) { const rawCategory = data.dict[dictIndex] || ''; - const { label, attribute, isTotal } = formatCategory(rawCategory); + const { label, attribute, isTotal } = formatCategory(rawCategory, t); points.push({ time: bucket.time, value: count, @@ -194,7 +202,7 @@ export const Component = () => { } } return points; - }, [data]); + }, [data, t]); const filteredChartData = useMemo(() => { const selectedPoints = chartData.filter( @@ -295,20 +303,20 @@ export const Component = () => { attributeOptions.find((option) => option.value === selectedAttribute) ?.label || selectedAttribute ); - }, [selectedAttribute]); + }, [selectedAttribute, attributeOptions]); const defaultLegendValues = useMemo(() => { const topTen = sortedCategories.slice(0, 10); if (!hasTotal) return topTen; - return [TOTAL_LABEL, ...topTen]; - }, [sortedCategories, hasTotal]); + return [totalLabel, ...topTen]; + }, [sortedCategories, hasTotal, totalLabel]); const colorDomain = useMemo(() => { if (hasTotal) { - return [TOTAL_LABEL, ...sortedCategories]; + return [totalLabel, ...sortedCategories]; } return sortedCategories; - }, [sortedCategories, hasTotal]); + }, [sortedCategories, hasTotal, totalLabel]); legendValuesRef.current = defaultLegendValues; @@ -324,7 +332,7 @@ export const Component = () => { shapeField: 'smooth', axis: { x: { - title: '时间', + title: t('realtime_metrics.time'), labelAutoRotate: true, labelFormatter: (value: string) => { const parsed = dayjs(value); @@ -338,7 +346,7 @@ export const Component = () => { items: [ (point: ChartDataPoint) => ({ name: point.category, - value: formatTooltipItem(point), + value: formatTooltipItem(point, t), }), ], }, @@ -391,7 +399,7 @@ export const Component = () => { { if (!selectedApp) { @@ -403,7 +411,7 @@ export const Component = () => { onSettingsClick={ selectedApp ? () => openAppSettings(selectedApp) : undefined } - sectionLabel="实时数据" + sectionLabel={t('realtime_metrics.title')} />
@@ -427,7 +435,7 @@ export const Component = () => { {isAdmin && (
setManualAppKey(e.target.value)} onPressEnter={handleManualAppKeySubmit} @@ -442,19 +450,19 @@ export const Component = () => { style={{ width: '100%' }} presets={[ { - label: '过去1小时', + label: t('realtime_metrics.range_1h'), value: [dayjs().subtract(1, 'hour'), dayjs()], }, { - label: '过去6小时', + label: t('realtime_metrics.range_6h'), value: [dayjs().subtract(6, 'hour'), dayjs()], }, { - label: '过去24小时', + label: t('realtime_metrics.range_24h'), value: [dayjs().subtract(24, 'hour'), dayjs()], }, { - label: '过去7天', + label: t('realtime_metrics.range_7d'), value: [dayjs().subtract(7, 'day'), dayjs()], }, ]} @@ -464,16 +472,16 @@ export const Component = () => {
- + {!selectedAppKey ? (
- 请选择应用 + {t('realtime_metrics.please_select_app')}
) : (
-
总请求数
+
{t('realtime_metrics.total_requests')}
{isLoading ? '-' : totalRequests.toLocaleString()}
@@ -482,12 +490,12 @@ export const Component = () => {
-
分类数量
+
{t('realtime_metrics.category_count')}
{categoryTotals.size}
- 当前维度:{selectedAttributeLabel} + {t('realtime_metrics.current_dimension')}{selectedAttributeLabel}
@@ -554,7 +562,7 @@ export const Component = () => {
) : (
- 暂无 Top 10 数据 + {t('realtime_metrics.no_top_data')}
)} @@ -563,13 +571,13 @@ export const Component = () => { {!selectedAppKey ? (
- 请选择应用 + {t('realtime_metrics.please_select_app')}
) : filteredChartData.length > 0 ? ( ) : (
- 暂无数据 + {t('realtime_metrics.no_data')}
)}
diff --git a/src/pages/register.tsx b/src/pages/register.tsx index ec3a298..27fb645 100644 --- a/src/pages/register.tsx +++ b/src/pages/register.tsx @@ -2,6 +2,7 @@ import { Button, Checkbox, Form, Input, message, Row } from 'antd'; import { md5 } from 'hash-wasm'; import type { CSSProperties } from 'react'; import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; import { Link } from 'react-router-dom'; import { api } from '@/services/api'; import { setUserEmail } from '@/services/auth'; @@ -10,6 +11,7 @@ import { rootRouterPath, router } from '../router'; import { isPasswordValid } from '../utils/helper'; export const Register = () => { + const { t } = useTranslation(); const [loading, setLoading] = useState(false); async function submit(values: { [key: string]: string }) { @@ -22,7 +24,7 @@ export const Register = () => { setUserEmail(values.email); router.navigate(rootRouterPath.welcome); } catch (_) { - message.error('该邮箱已被注册'); + message.error(t('register.email_exists')); } setLoading(false); } @@ -32,13 +34,13 @@ export const Register = () => {
submit(values)}>
-
极速热更新框架 for React Native
+
{t('register.slogan')}
- + - + { () => ({ async validator(_, value: string) { if (value && !isPasswordValid(value)) { - throw '密码中需要同时包含大、小写字母和数字,且长度不少于6位'; + throw t('register.password_rules'); } }, }), @@ -56,7 +58,7 @@ export const Register = () => { > { ({ getFieldValue }) => ({ async validator(_, value: string) { if (getFieldValue('pwd') !== value) { - throw '两次输入的密码不一致'; + throw t('register.password_mismatch'); } }, }), @@ -78,7 +80,7 @@ export const Register = () => { > { size="large" loading={loading} > - 注册 + {t('register.create_button')} @@ -104,7 +106,7 @@ export const Register = () => { validator: (_, value) => value ? Promise.resolve() - : Promise.reject(Error('请阅读并同意后勾选此处')), + : Promise.reject(Error(t('register.agreement_required'))), }, ]} hasFeedback @@ -112,19 +114,19 @@ export const Register = () => { > - 已阅读并同意 + {t('register.agreement_prefix')}{' '} - 用户协议 + {t('register.agreement_link')} - 已有帐号? + {t('register.has_account')} diff --git a/src/pages/reset-password/components/send-email.tsx b/src/pages/reset-password/components/send-email.tsx index d5e0e23..5848cc7 100644 --- a/src/pages/reset-password/components/send-email.tsx +++ b/src/pages/reset-password/components/send-email.tsx @@ -1,17 +1,19 @@ import { useMutation } from '@tanstack/react-query'; import { Button, Form, Input, message, Result } from 'antd'; import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; import { api } from '@/services/api'; export default function SendEmail() { + const { t } = useTranslation(); const [sent, setSent] = useState(false); const { mutateAsync: sendEmail, isPending } = useMutation({ mutationFn: (email: string) => api.resetpwdSendMail({ email }), onSuccess: () => { - message.info('邮件发送成功,请注意查收'); + message.info(t('reset_password.send_success')); }, onError: () => { - message.error('邮件发送失败'); + message.error(t('reset_password.send_failed')); }, }); @@ -19,8 +21,8 @@ export default function SendEmail() { return ( ); } @@ -35,13 +37,13 @@ export default function SendEmail() { > - + diff --git a/src/pages/reset-password/components/set-password.tsx b/src/pages/reset-password/components/set-password.tsx index 6cdf01d..99c2e2c 100644 --- a/src/pages/reset-password/components/set-password.tsx +++ b/src/pages/reset-password/components/set-password.tsx @@ -1,12 +1,14 @@ import { Button, Form, Input, message } from 'antd'; import { md5 } from 'hash-wasm'; import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; import { useLocation } from 'react-router-dom'; import { api } from '@/services/api'; import { rootRouterPath, router } from '../../../router'; import { isPasswordValid } from '../../../utils/helper'; export default function SetPassword() { + const { t } = useTranslation(); const { search } = useLocation(); const [loading, setLoading] = useState(false); return ( @@ -21,7 +23,7 @@ export default function SetPassword() { }); router.navigate(rootRouterPath.resetPassword('3')); } catch (e) { - message.error((e as Error).message ?? '网络错误'); + message.error((e as Error).message ?? t('reset_password.network_error')); } setLoading(false); }} @@ -34,9 +36,7 @@ export default function SetPassword() { validator(_, value: string) { if (value && !isPasswordValid(value)) { return Promise.reject( - Error( - '密码中需要同时包含大、小写字母和数字,且长度不少于6位', - ), + Error(t('reset_password.password_rules')), ); } return Promise.resolve(); @@ -44,7 +44,7 @@ export default function SetPassword() { }), ]} > - + ({ validator(_, value: string) { if (getFieldValue('newPwd') !== value) { - return Promise.reject(Error('两次输入的密码不一致')); + return Promise.reject(Error(t('reset_password.password_mismatch'))); } return Promise.resolve(); }, @@ -62,14 +62,14 @@ export default function SetPassword() { > diff --git a/src/pages/reset-password/components/success.tsx b/src/pages/reset-password/components/success.tsx index 9600d5a..822132f 100644 --- a/src/pages/reset-password/components/success.tsx +++ b/src/pages/reset-password/components/success.tsx @@ -1,13 +1,15 @@ import { Button, Result } from 'antd'; +import { useTranslation } from 'react-i18next'; export default function Success() { + const { t } = useTranslation(); return ( - 登录 + {t('reset_password.login_button')} , ]} /> diff --git a/src/pages/reset-password/index.tsx b/src/pages/reset-password/index.tsx index 10dd26e..9d6ae2f 100644 --- a/src/pages/reset-password/index.tsx +++ b/src/pages/reset-password/index.tsx @@ -1,4 +1,5 @@ import { Card, Steps } from 'antd'; +import { useTranslation } from 'react-i18next'; import { useParams } from 'react-router-dom'; import SendEmail from './components/send-email'; import SetPassword from './components/set-password'; @@ -11,6 +12,7 @@ const body = { }; export const ResetPassword = () => { + const { t } = useTranslation(); const { step = '0' } = useParams() as { step?: keyof typeof body }; return ( @@ -18,9 +20,9 @@ export const ResetPassword = () => { className="mb-12" current={Number(step)} items={[ - { title: '输入绑定邮箱' }, - { title: '设置新密码' }, - { title: '设置成功' }, + { title: t('reset_password.step_email') }, + { title: t('reset_password.step_password') }, + { title: t('reset_password.step_success') }, ]} /> {body[step]} diff --git a/src/pages/welcome.tsx b/src/pages/welcome.tsx index ba729af..1c980a1 100644 --- a/src/pages/welcome.tsx +++ b/src/pages/welcome.tsx @@ -1,6 +1,7 @@ import { useMutation } from '@tanstack/react-query'; import { Button, message, Result } from 'antd'; import { useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; import { activationEmailResendCooldownStorageKey } from '@/constants/local-storage'; import { api } from '@/services/api'; import { getUserEmail } from '@/services/auth'; @@ -8,6 +9,7 @@ import { useLocalStorageCooldown } from '@/utils/hooks'; import { rootRouterPath, router } from '../router'; export const Welcome = () => { + const { t } = useTranslation(); useEffect(() => { if (!getUserEmail()) { router.navigate(rootRouterPath.login); @@ -24,10 +26,10 @@ export const Welcome = () => { mutationFn: () => api.sendEmail({ email: getUserEmail() }), onSuccess: () => { startCooldown(); - message.info('邮件发送成功,请注意查收'); + message.info(t('welcome.email_sent')); }, onError: () => { - message.error('邮件发送失败'); + message.error(t('welcome.send_failed')); }, }); @@ -35,15 +37,15 @@ export const Welcome = () => { - 感谢您关注由 React Native 中文网提供的热更新服务 + {t('welcome.thanks_line1')}
- 我们已经往您的邮箱发送了一封激活邮件 + {t('welcome.thanks_line2')}
- 请点击邮件内的激活链接激活您的帐号 + {t('welcome.thanks_line3')}
} - subTitle="如未收到激活邮件,请点击" + subTitle={t('welcome.no_email')} extra={ } /> From bcd0c19d8758eb0d2f445f13fff33610ee2e3d5c Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Sun, 28 Jun 2026 15:46:30 +0800 Subject: [PATCH 2/2] feat: add i18n to user.tsx - Replace all 63 Chinese strings with t() calls - Convert module-level constants to functions accepting t - Add useTranslation to 7 components - TypeScript and biome checks pass --- src/i18n/locales/en.json | 9 +- src/i18n/locales/zh-CN.json | 12 +- src/pages/user.tsx | 375 ++++++++++++++++++++++-------------- 3 files changed, 250 insertions(+), 146 deletions(-) diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index a771a3c..3dd057e 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -47,7 +47,8 @@ "back_login": "Back to login" }, "user": { - "invoice_hint": "Please send an email to <1>hi@charmlot.com with the following:", + "invoice_hint_before_email": "Please send an email to ", + "invoice_hint_after_email": " with the following:", "invoice_company": "Company name, tax ID, registration email, invoice receipt email (defaults to registration email), and payment screenshot.", "invoice_default": "We will reply with a standard electronic invoice to the receipt email (check spam), categorized as software service.", "purchasing_note": "You can only renew the same plan or upgrade. To purchase a lower plan, wait for the current one to expire, or contact QQ support 34731408.", @@ -134,7 +135,11 @@ }, "fetching_addon_quote": "Fetching addon quote", "per_year": "/ year", - "upgrade_button": "Upgrade" + "upgrade_button": "Upgrade", + "date_format": "YYYY-MM-DD", + "upgrade_title_with_expire": "Upgrade (expires unchanged: {{date}}, {{days}} days)", + "upgrade_proration_text": "Prorated: {{dailyAmount}} × {{days}} days = {{amount}}", + "addon_proration_amount": "Prorated {{amount}}" }, "apps": { "title": "Applications", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index bae93cc..a221f2b 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -47,7 +47,8 @@ "back_login": "返回登录" }, "user": { - "invoice_hint": "请发送邮件至 <1>hi@charmlot.com,并写明:", + "invoice_hint_before_email": "请发送邮件至 ", + "invoice_hint_after_email": ",并写明:", "invoice_company": "公司名称、税号、注册邮箱、接收发票邮箱(不写则发送到注册邮箱),附带支付截图。", "invoice_default": "我们默认会回复普通电子发票到接收邮箱(请同时留意垃圾邮件),类目为软件服务。", "purchasing_note": "只可续费相同服务版本或升级更高版本,如果您需要购买较低的服务版本,请等待当前版本过期,或联系 QQ 客服 34731408 手动处理。", @@ -131,7 +132,14 @@ "vip1": "大客户VIP1版", "vip2": "大客户VIP2版", "vip3": "大客户VIP3版" - } + }, + "fetching_addon_quote": "正在获取加购报价", + "per_year": "/ 年", + "upgrade_button": "升级", + "date_format": "YYYY年MM月DD日", + "upgrade_title_with_expire": "升级(有效期不变:至 {{date}},{{days}} 天)", + "upgrade_proration_text": "补差价 {{dailyAmount}} × {{days}} 天 = {{amount}}", + "addon_proration_amount": "补差价 {{amount}}" }, "apps": { "title": "应用列表", diff --git a/src/pages/user.tsx b/src/pages/user.tsx index 78499fd..7e5d59f 100644 --- a/src/pages/user.tsx +++ b/src/pages/user.tsx @@ -17,6 +17,8 @@ import { } from 'antd'; import dayjs from 'dayjs'; import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import i18n from '@/i18n'; import { api } from '@/services/api'; import { logout } from '@/services/auth'; import { ANNUAL_BILLING_MONTHS } from '@/utils/billing'; @@ -33,17 +35,29 @@ type ProductTier = keyof typeof products; type PurchasableTier = Exclude; type OrderQuotes = NonNullable>>; -const purchasableTiers: Array<{ +const PURCHASABLE_TIER_KEYS: PurchasableTier[] = [ + 'standard', + 'premium', + 'pro', + 'vip1', + 'vip2', + 'vip3', +]; + +const getPurchasableTiers = ( + t: (key: string) => string, +): Array<{ label: string; tier: PurchasableTier; -}> = [ - { label: '标准版', tier: 'standard' }, - { label: '高级版', tier: 'premium' }, - { label: '专业版', tier: 'pro' }, - { label: '大客户VIP1版', tier: 'vip1' }, - { label: '大客户VIP2版', tier: 'vip2' }, - { label: '大客户VIP3版', tier: 'vip3' }, +}> => [ + { label: t('user.purchasable_tiers.standard'), tier: 'standard' }, + { label: t('user.purchasable_tiers.premium'), tier: 'premium' }, + { label: t('user.purchasable_tiers.pro'), tier: 'pro' }, + { label: t('user.purchasable_tiers.vip1'), tier: 'vip1' }, + { label: t('user.purchasable_tiers.vip2'), tier: 'vip2' }, + { label: t('user.purchasable_tiers.vip3'), tier: 'vip3' }, ]; + const purchaseButtonClassName = 'w-full justify-center sm:w-[160px]'; const checkUpdateAddonEligibleTiers = new Set([ 'premium', @@ -53,20 +67,17 @@ const checkUpdateAddonEligibleTiers = new Set([ 'vip3', ]); -const InvoiceHint = ( +const getInvoiceHint = (t: (key: string) => string) => (

- 请发送邮件至 hi@charmlot.com - ,并写明: -

-

- - 公司名称、税号、注册邮箱、接收发票邮箱(不写则发送到注册邮箱),附带支付截图。 - + {t('user.invoice_hint_before_email')} + hi@charmlot.com + {t('user.invoice_hint_after_email')}

- 我们默认会回复普通电子发票到接收邮箱(请同时留意垃圾邮件),类目为软件服务。 + {t('user.invoice_company')}

+

{t('user.invoice_default')}

); @@ -105,55 +116,71 @@ function getRemainingBillableDays(expiresAt?: string, now?: string) { return days > 0 ? days : null; } -function formatExpireDate(expiresAt?: string) { - return expiresAt ? dayjs(expiresAt).format('YYYY年MM月DD日') : '当前到期日'; +function formatExpireDate( + expiresAt: string | undefined, + t: (key: string) => string, +) { + return expiresAt + ? dayjs(expiresAt).format(t('user.date_format')) + : t('user.current_expire'); } -function formatRenewedExpireDate({ - expiresAt, - months, - now, -}: { - expiresAt?: string; - months: number; - now?: string; -}) { +function formatRenewedExpireDate( + { + expiresAt, + months, + now, + }: { + expiresAt?: string; + months: number; + now?: string; + }, + t: (key: string) => string, +) { const currentExpireDay = expiresAt ? dayjs(expiresAt) : null; const nowDay = dayjs(now); const baseDay = currentExpireDay?.isAfter(nowDay) ? currentExpireDay : nowDay; - return baseDay.add(months, 'month').format('YYYY年MM月DD日'); + return baseDay.add(months, 'month').format(t('user.date_format')); } -function formatWan(value: number) { - return `${value / 10_000}万`; +function formatWan(value: number, t: (key: string) => string) { + return `${value / 10_000}${t('user.wan_unit')}`; } function isPurchasableTier(tier?: string): tier is PurchasableTier { - return !!tier && purchasableTiers.some((option) => option.tier === tier); + return !!tier && PURCHASABLE_TIER_KEYS.includes(tier as PurchasableTier); } -function getPurchasableTierLabel(tier: PurchasableTier) { - return purchasableTiers.find((option) => option.tier === tier)?.label ?? tier; +function getPurchasableTierLabel( + tier: PurchasableTier, + t: (key: string) => string, +) { + return ( + getPurchasableTiers(t).find((option) => option.tier === tier)?.label ?? tier + ); } -function getQuotaDetailItems(tier: PurchasableTier) { +function getQuotaDetailItems( + tier: PurchasableTier, + t: (key: string) => string, +) { const quota = quotas[tier]; return [ { - label: '检查次数每日', - value: formatWan(quota.pv), + label: t('user.check_quota_daily'), + value: formatWan(quota.pv, t), }, { - label: '应用个数', - value: `${quota.app.toLocaleString()} 个`, + label: t('user.app_count'), + value: `${quota.app.toLocaleString()} ${t('user.count_unit')}`, }, { - label: '原生包数', - value: `${quota.package.toLocaleString()} 个`, + label: t('user.native_pkg_count'), + value: `${quota.package.toLocaleString()} ${t('user.count_unit')}`, }, { - label: '热更包数', - value: `${quota.bundle.toLocaleString()} 个`, + label: t('user.hotfix_count'), + value: `${quota.bundle.toLocaleString()} ${t('user.count_unit')}`, }, ]; } @@ -181,8 +208,9 @@ function canPurchaseCheckUpdateAddon({ return checkUpdateAddonEligibleTiers.has(tier); } -const defaultCheckUpdateAddonEligibilityHint = - '仅高级版及以上可加购检查额度,当前版本可先升级后再加购。'; +const getDefaultCheckUpdateAddonEligibilityHint = ( + t: (key: string) => string, +) => t('user.addon_eligible_hint'); type PurchaseMenuOption = { amountText: string; @@ -201,7 +229,7 @@ type PurchaseMenuOption = { function PurchaseActionPopover({ buttonLabel, - emptyText = '暂无可购买项目', + emptyText, hint, loading, title, @@ -218,6 +246,9 @@ function PurchaseActionPopover({ widthClassName?: string; options: PurchaseMenuOption[]; }) { + const { t } = useTranslation(); + const resolvedEmptyText = emptyText ?? t('user.addon_empty'); + const content = (
- {emptyText} + {resolvedEmptyText}
)}
@@ -365,6 +396,7 @@ const RenewalPurchaseButton = ({ tier: Tier; tierExpiresAt?: string; }) => { + const { t } = useTranslation(); const [loadingPlan, setLoadingPlan] = useState(null); const addonUnits = quotes?.current.checkUpdateAddonUnits ?? 0; const addonMonthlyPrice = quotes?.current.checkUpdateAddonMonthlyPrice ?? 0; @@ -386,11 +418,14 @@ const RenewalPurchaseButton = ({ return { amountText: formatMoney(option.quote.amount), - description: `续费后到期日 ${formatRenewedExpireDate({ - expiresAt: tierExpiresAt, - months, - now: serverTime, - })}`, + description: `${t('user.renew_after_expire')} ${formatRenewedExpireDate( + { + expiresAt: tierExpiresAt, + months, + now: serverTime, + }, + t, + )}`, key: option.key, onClick: async () => { setLoadingPlan(option.key); @@ -402,33 +437,41 @@ const RenewalPurchaseButton = ({ }, tag: billing && isAnnual && monthlyTotal > billing.annualPrice - ? `约${formatDiscount( - (billing.annualPrice / monthlyTotal) * 10, - )}折优惠` + ? t('user.about_discount', { + discount: formatDiscount( + (billing.annualPrice / monthlyTotal) * 10, + ), + }) : undefined, - title: isAnnual ? `${months} 个月(年付)` : `${months} 个月`, + title: isAnnual + ? `${months} ${t('user.annual_billing')}` + : `${months} ${t('user.price_month')}`, }; }) : [ { - amountText: quotesLoading ? '报价中' : '按订单结算', + amountText: quotesLoading + ? t('user.quoting') + : t('user.order_settle'), description: quotesLoading - ? '正在获取续费报价' - : '当前版本暂未返回可续费价格', + ? t('user.fetching_renewal_quote') + : t('user.renewal_unavailable'), disabled: true, key: 'unavailable', - title: '续费', + title: t('user.renew'), }, ]; return ( 0 - ? `当前价格含加购费用每月 ${formatMoney(addonMonthlyPrice)}` + ? t('user.addon_price_monthly', { + price: formatMoney(addonMonthlyPrice), + }) : undefined } options={renewalOptions} @@ -449,22 +492,26 @@ const UpgradePurchaseControls = ({ serverTime?: string; tierExpiresAt?: string; }) => { + const { t } = useTranslation(); const [loadingTier, setLoadingTier] = useState(null); const upgradeOptions = quotes?.upgrades ?? []; if (upgradeOptions.length === 0) { - return null; // 没有可升级的版本 + return null; } const remainingDays = getRemainingBillableDays(tierExpiresAt, serverTime); const title = currentTier === 'free' - ? '升级购买' - : `升级(有效期不变:至 ${formatExpireDate(tierExpiresAt)},${remainingDays ?? '-'} 天)`; + ? t('user.upgrade_purchase') + : t('user.upgrade_title_with_expire', { + date: formatExpireDate(tierExpiresAt, t), + days: remainingDays ?? '-', + }); const hint = currentTier === 'free' - ? '选择目标版本后按年付开通服务。' - : '补差价由后端按剩余有效期报价,未超过优惠阈值按月费差额折算,超过后按年费优惠折算。'; + ? t('user.upgrade_hint_free') + : t('user.upgrade_hint_paid'); const menuOptions: PurchaseMenuOption[] = upgradeOptions.map((option) => { const quote = option.quote; @@ -472,18 +519,22 @@ const UpgradePurchaseControls = ({ const tier = isPurchasableTier(option.tier) ? option.tier : undefined; const amountText = currentTier === 'free' - ? `年付 ${formatMoney(quote.amount)}` + ? `${t('user.annual_pay')} ${formatMoney(quote.amount)}` : proration - ? `补差价 ${formatMoney(proration.dailyAmount)} × ${proration.days} 天 = ${formatMoney(proration.amount)}` - : '按订单结算'; + ? t('user.upgrade_proration_text', { + dailyAmount: formatMoney(proration.dailyAmount), + days: proration.days, + amount: formatMoney(proration.amount), + }) + : t('user.order_settle'); const disabled = quotesLoading || !tier || (currentTier !== 'free' && !proration); return { amountText, description: - currentTier === 'free' ? '购买后从支付日起开通服务' : undefined, - details: tier ? getQuotaDetailItems(tier) : undefined, + currentTier === 'free' ? t('user.purchase_after_pay') : undefined, + details: tier ? getQuotaDetailItems(tier, t) : undefined, disabled, key: option.key, onClick: async () => { @@ -495,13 +546,13 @@ const UpgradePurchaseControls = ({ setLoadingTier(null); } }, - title: tier ? getPurchasableTierLabel(tier) : option.key, + title: tier ? getPurchasableTierLabel(tier, t) : option.key, }; }); return ( currentQuota.app ? 'exception' : 'normal', - value: `${appCount.toLocaleString()} / ${currentQuota.app.toLocaleString()} 个`, + value: `${appCount.toLocaleString()} / ${currentQuota.app.toLocaleString()} ${t('user.count_unit')}`, }, { key: 'bundle', - label: '热更包数量', + label: t('user.hotfix_count_label'), limit: currentQuota.bundle, loading: isVersionCountLoading, note: isVersionCountLoading - ? '正在统计各应用热更包数量' - : '最高单应用使用量', + ? t('user.counting_hotfix') + : t('user.max_single_app'), percent: isVersionCountLoading ? 0 : Math.min(100, (maxVersionCount / currentQuota.bundle) * 100), status: maxVersionCount > currentQuota.bundle ? 'exception' : 'normal', value: isVersionCountLoading - ? '统计中' - : `${maxVersionCount.toLocaleString()} / ${currentQuota.bundle.toLocaleString()} 个`, + ? t('user.counting') + : `${maxVersionCount.toLocaleString()} / ${currentQuota.bundle.toLocaleString()} ${t('user.count_unit')}`, }, { key: 'package', - label: '原生包数量', + label: t('user.native_pkg_count_label'), limit: currentQuota.package, loading: isPackageCountLoading, note: isPackageCountLoading - ? '正在统计各应用原生包数量' - : '最高单应用使用量', + ? t('user.counting_native') + : t('user.max_single_app'), percent: isPackageCountLoading ? 0 : Math.min(100, (maxPackageCount / currentQuota.package) * 100), status: maxPackageCount > currentQuota.package ? 'exception' : 'normal', value: isPackageCountLoading - ? '统计中' - : `${maxPackageCount.toLocaleString()} / ${currentQuota.package.toLocaleString()} 个`, + ? t('user.counting') + : `${maxPackageCount.toLocaleString()} / ${currentQuota.package.toLocaleString()} ${t('user.count_unit')}`, }, ]; const quotaSizeLimits = [ { - label: '单个原生包大小', + label: t('user.single_native_size'), value: currentQuota.packageSize, }, { - label: '单个热更包大小', + label: t('user.single_hotfix_size'), value: currentQuota.bundleSize, }, { - label: '检查额度上限', - value: `${currentQuota.pv.toLocaleString()} 次 / 日`, + label: t('user.check_quota_limit'), + value: `${currentQuota.pv.toLocaleString()} ${t('user.per_day')}`, }, ]; const handleLogout = () => { - message.info('您已退出登录'); + message.info(t('user.logged_out')); logout(); }; return (
- {name} - + {name} + {email} - +
{tierDisplay} {!quota && defaultQuota && ( @@ -672,7 +724,7 @@ function UserPanel() { )}
- +
{displayExpireDay ? ( @@ -685,7 +737,7 @@ function UserPanel() { )} ) : ( -
+
{t('user.no_expire')}
)}
- +
- 只可续费相同服务版本或升级更高版本,如果您需要购买较低的服务版本,请等待当前版本过期,或联系 - QQ 客服 34731408 手动处理。 + {t('user.purchasing_note')}
- + - 查看价格表 + {t('user.view_pricing')}
@@ -792,6 +843,7 @@ function QuotaDetailsPanel({ tier: Tier; tierExpiresAt?: string; }) { + const { t } = useTranslation(); const billingConfig = useOrderBillingConfig(); const addonQuota = billingConfig.checkUpdateAddon?.quota ?? 100_000; const baseTier = @@ -828,11 +880,11 @@ function QuotaDetailsPanel({ : 'text-slate-900'; const warningTag = quotaWarning.isExceeded && displayRemaining < 0 - ? '已超额' + ? t('user.already_exceeded') : quotaWarning.isExceeded - ? '已用尽' + ? t('user.already_exhausted') : quotaWarning.isLow - ? '偏低' + ? t('user.low') : undefined; return ( @@ -862,9 +914,11 @@ function QuotaDetailsPanel({ /> )}
-
每日检查额度
+
+ {t('user.daily_check_title')} +
- 客户端检查热更新时消耗,按账户全部应用汇总。 + {t('user.daily_check_desc')}
@@ -879,7 +933,9 @@ function QuotaDetailsPanel({
-
今日剩余额度
+
+ {t('user.remaining_today')} +
- 上限 {dailyQuota.toLocaleString()} 次 / 日(套餐内{' '} - {packageIncludedQuota.toLocaleString()} 次 + 加购{' '} - {packageExtraQuota.toLocaleString()} 次) + {t('user.quota_limit_info', { + dailyQuota: dailyQuota.toLocaleString(), + included: packageIncludedQuota.toLocaleString(), + extra: packageExtraQuota.toLocaleString(), + })}
{quotaWarning.isExceeded && displayRemaining < 0 && (
- 已超出 {Math.abs(displayRemaining).toLocaleString()} 次 + {t('user.exceeded_by', { + count: Math.abs(displayRemaining).toLocaleString(), + })}
)} {quotaWarning.isLow && (
- 低于 {Math.round(CHECK_QUOTA_LOW_RATIO * 100)} - %,请留意检查频率 + {t('user.low_below', { + percent: Math.round(CHECK_QUOTA_LOW_RATIO * 100), + })}
)}
@@ -942,8 +1003,11 @@ function QuotaDetailsPanel({
@@ -957,7 +1021,9 @@ function QuotaDetailsPanel({
{row.label} - {row.status === 'exception' && 超额} + {row.status === 'exception' && ( + {t('user.over_quota')} + )}
{row.note}
@@ -973,7 +1039,9 @@ function QuotaDetailsPanel({
-
规格限制
+
+ {t('user.spec_limits')} +
{sizeLimits.map((item) => (
@@ -1006,12 +1074,13 @@ function CheckUpdateAddonPurchase({ tier: Tier; tierExpiresAt?: string; }) { + const { t } = useTranslation(); const [loadingUnits, setLoadingUnits] = useState(null); const monthlyUnitPrice = billingConfig.checkUpdateAddon?.monthlyUnitPrice ?? 100; const eligibilityHint = billingConfig.checkUpdateAddon?.eligibilityMessage ?? - defaultCheckUpdateAddonEligibilityHint; + getDefaultCheckUpdateAddonEligibilityHint(t); const isExistingPaidService = tier !== 'free' && !!tierExpiresAt; const canPurchaseAddon = canPurchaseCheckUpdateAddon({ dailyQuota, @@ -1028,8 +1097,10 @@ function CheckUpdateAddonPurchase({ return { amountText: proration - ? `补差价 ${formatMoney(proration.amount)}` - : `${formatMoney(quote.amount)} / 年`, + ? t('user.addon_proration_amount', { + amount: formatMoney(proration.amount), + }) + : `${formatMoney(quote.amount)} ${t('user.per_year')}`, disabled, key: option.key, onClick: async () => { @@ -1040,42 +1111,61 @@ function CheckUpdateAddonPurchase({ setLoadingUnits(null); } }, - title: `+${(addonQuota * units).toLocaleString()} 次 / 日`, + title: `+${(addonQuota * units).toLocaleString()} ${t('user.per_day')}`, }; }) : [ { - amountText: quotesLoading ? '报价中' : '按订单结算', - description: quotesLoading ? '正在获取加购报价' : undefined, + amountText: quotesLoading + ? t('user.quoting') + : t('user.order_settle'), + description: quotesLoading + ? t('user.fetching_addon_quote') + : undefined, disabled: true, key: 'unavailable', - title: '加购检查额度', + title: t('user.check_quota_addon'), }, ]; return (
-
检查额度加购
+
+ {t('user.check_quota_addon')} +
{canPurchaseAddon - ? `每增加 ${addonQuota.toLocaleString()} 次 / 日,每月额外收费 ${formatMoney(monthlyUnitPrice)}。` + ? t('user.addon_price_desc', { + quota: addonQuota.toLocaleString(), + price: formatMoney(monthlyUnitPrice), + }) : eligibilityHint}
{canPurchaseAddon ? ( @@ -1083,7 +1173,7 @@ function CheckUpdateAddonPurchase({ @@ -1103,6 +1193,7 @@ function MiniQuotaBars({ tooltipSuffix: string; values?: number[]; }) { + const { t } = useTranslation(); const bars = (values ?? []) .slice(0, 7) .reverse() @@ -1166,7 +1257,7 @@ function MiniQuotaBars({
) : (
- 暂无 7 天明细 + {t('user.no_7day_details')}
)}
@@ -1200,7 +1291,7 @@ async function purchase(tier: keyof typeof products, months?: number) { window.location.href = orderResponse.payUrl; } else if (orderResponse?.payUrl) { console.error('Invalid payment URL:', orderResponse.payUrl); - message.error('支付链接无效'); + message.error(i18n.t('user.payment_invalid')); } } @@ -1210,7 +1301,7 @@ async function purchaseCheckUpdateAddon(units: number) { window.location.href = orderResponse.payUrl; } else if (orderResponse?.payUrl) { console.error('Invalid payment URL:', orderResponse.payUrl); - message.error('支付链接无效'); + message.error(i18n.t('user.payment_invalid')); } }