From 05fe5fbb7060833b41b63afdfb6d006bf695c354 Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Sun, 28 Jun 2026 15:35:45 +0800 Subject: [PATCH 1/4] 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 | 51 +- src/components/app-settings-modal.tsx | 44 +- src/components/create-app-modal.tsx | 13 +- src/components/daily-check-quota.tsx | 113 ++- src/components/error-boundary.tsx | 10 +- src/components/footer.tsx | 40 +- src/components/top-navigation.tsx | 185 +++-- src/i18n/index.ts | 21 + src/i18n/locales/en.json | 745 ++++++++++++++++++ src/i18n/locales/zh-CN.json | 745 ++++++++++++++++++ src/index.tsx | 26 +- 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 | 248 +++--- src/pages/inactivated.tsx | 16 +- 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 | 80 +- src/pages/manage/components/setting-modal.tsx | 39 +- src/pages/manage/components/version-table.tsx | 193 +++-- src/pages/manage/index.tsx | 27 +- src/pages/realtime-metrics.tsx | 94 ++- src/pages/register.tsx | 28 +- .../reset-password/components/send-email.tsx | 16 +- .../components/set-password.tsx | 16 +- .../reset-password/components/success.tsx | 9 +- src/pages/reset-password/index.tsx | 10 +- src/pages/welcome.tsx | 16 +- 40 files changed, 2867 insertions(+), 950 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..7e042dd 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 ab43a4c..0bcfba2 100644 --- a/src/pages/manage/components/commit.tsx +++ b/src/pages/manage/components/commit.tsx @@ -2,10 +2,13 @@ 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'; const popoverOverlayStyle: React.CSSProperties = { maxWidth: 288, maxHeight: 240, overflowY: 'auto' }; export const Commit = ({ commit }: { commit?: Commit }) => { + const { t } = useTranslation(); + if (!commit) { return ( { content={
-
最近的提交:
+
{t('commit.title')}:
- 需要使用 cli v1.42.0+ 版本上传,且使用 git - 管理代码才能查看提交记录 + {t('commit.description')}
@@ -60,12 +62,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..0fc9ae4 100644 --- a/src/pages/manage/components/publish-feature-table.tsx +++ b/src/pages/manage/components/publish-feature-table.tsx @@ -1,10 +1,33 @@ import { Table, Tag } from 'antd'; +import { useTranslation } from 'react-i18next'; + +type SupportStatus = 'supported' | 'unsupported' | 'warning'; +type SupportCell = { label: string; status: SupportStatus }; +type PublishFeatureRow = { + key: string; + version: string; + fullRelease: SupportCell; + grayRelease: SupportCell; + bothRelease: SupportCell; +}; + +const statusColorMap: Record = { + supported: 'success', + unsupported: 'error', + warning: 'warning', +}; + +function renderSupportCell(cell: SupportCell) { + return {cell.label}; +} /** * 发布功能支持情况表格组件 * 展示不同 react-native-update 版本对各种发布功能的支持情况 */ export default function PublishFeatureTable() { + const { t } = useTranslation(); + return (
= 2.4.0)', + fullRelease: { label: t('publish_feature_table.supported'), status: 'supported' }, + grayRelease: { label: t('publish_feature_table.supported'), status: 'supported' }, + bothRelease: { label: t('publish_feature_table.both_supported'), status: 'supported' }, }, - ]} + ] satisfies PublishFeatureRow[]} columns={[ { title: ( - react-native-update 版本 + {t('publish_feature_table.version_header_line1')}
- (用户端) + {t('publish_feature_table.version_header_line2')}
), dataIndex: 'version', @@ -47,48 +70,29 @@ export default function PublishFeatureTable() { width: 200, }, { - title: '仅全量发布', + title: t('publish_feature_table.full_release_only'), dataIndex: 'fullRelease', key: 'fullRelease', align: 'center', - render: (text: string) => { - return ( - - {text} - - ); - }, + render: renderSupportCell, }, { - title: '仅灰度发布', + title: t('publish_feature_table.gray_release_only'), dataIndex: 'grayRelease', key: 'grayRelease', align: 'center', - render: (text: string) => { - return ( - - {text} - - ); - }, + render: renderSupportCell, }, { - title: '同时发布', + title: t('publish_feature_table.both_release'), dataIndex: 'bothRelease', key: 'bothRelease', align: 'center', - render: (text: string) => { - const color = text.includes('✓') - ? 'success' - : text.includes('⚠') - ? 'warning' - : 'error'; - return {text}; - }, + render: renderSupportCell, }, ]} /> -
注:取消发布不会导致已更新的用户回滚。
+
{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 9925e87..c11497e 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, useMemo, 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,8 +169,14 @@ function removeSelectedVersions({ } } Modal.confirm({ - title: '删除所选热更包:', - content: versionNames.join(','), + title: t('version_table.delete_title'), + content: ( +
+ {versionNames.map((name) => ( +
{name}
+ ))} +
+ ), maskClosable: true, okButtonProps: { danger: true }, async onOk() { @@ -173,68 +185,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 +266,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 +342,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(); @@ -352,12 +375,12 @@ export default function VersionTable() { rowKey="id" title={() => (
- {!isMobile && 热更包} + {!isMobile && {t('version_table.title')}} } - placeholder="搜索" + placeholder={t('common.search')} value={search} onChange={({ target }) => setSearch(target.value)} className="shrink-0 rounded bg-gray-100 px-2 text-sm leading-8" @@ -374,7 +397,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); @@ -396,11 +421,11 @@ export default function VersionTable() { ) : undefined diff --git a/src/pages/manage/index.tsx b/src/pages/manage/index.tsx index 42953b9..951fbcb 100644 --- a/src/pages/manage/index.tsx +++ b/src/pages/manage/index.tsx @@ -2,6 +2,7 @@ import { DownOutlined, SearchOutlined } from '@ant-design/icons'; import { Checkbox, Dropdown, Grid, Input, Layout, type MenuProps, Tabs } from 'antd'; import { type Dispatch, type SetStateAction, useMemo, 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,9 @@ const PackageFilterControl = ({ return ( 0 && !allVisibleSelected} @@ -93,6 +98,7 @@ const PackageFilterControl = ({ }; const ManageDashBoard = () => { + const { t } = useTranslation(); const screens = Grid.useBreakpoint(); const isMobile = !screens.md; const { packages, unusedPackages, packagesLoading, bindingsLoading } = @@ -159,7 +165,7 @@ const ManageDashBoard = () => { allowClear bordered={false} prefix={} - placeholder="搜索" + placeholder={t('common.search')} value={packageSearch} onChange={({ target }) => setPackageSearch(target.value)} className="rounded bg-gray-100 px-2 text-sm leading-8" @@ -175,12 +181,12 @@ const ManageDashBoard = () => { items={[ { key: 'versions', - label: '热更包', + label: t('manage.tab_versions'), children: , }, { key: 'packages', - label: '原生包', + label: t('manage.tab_packages'), children: (
@@ -204,7 +210,7 @@ const ManageDashBoard = () => { style={{ marginRight: 16, maxWidth: '100%' }} >
- 原生包 + {t('manage.tab_packages')} {packageSearchInput}
{packageList} @@ -217,6 +223,7 @@ const ManageDashBoard = () => { }; export const Manage = () => { + const { t } = useTranslation(); const params = useParams<{ id?: string }>(); const id = Number(params.id!); const { app } = useApp(id); @@ -233,7 +240,7 @@ export const Manage = () => { { if (realtimeMetricsPath) { @@ -241,7 +248,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..6af2c47 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,20 +87,27 @@ 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, opts?: Record) => string, +) => { + const count = point.value.toLocaleString(); if (point.isTotal || point.sharePercent === undefined) { - return countLabel; + return t('realtime_metrics.tooltip_count', { count }); } - return `${countLabel} (${point.sharePercent.toFixed(1)}%)`; + return t('realtime_metrics.tooltip_count_percent', { + count, + percent: point.sharePercent.toFixed(1), + }); }; export const Component = () => { + const { t } = useTranslation(); const [searchParams, setSearchParams] = useSearchParams({ attribute: 'hash', }); @@ -119,6 +130,9 @@ export const Component = () => { ? 'packageVersion_buildTime' : 'hash'; + const attributeOptions = getAttributeOptions(t); + const totalLabel = t('realtime_metrics.update_checks'); + const selectableAppKeys = useMemo( () => selectableApps @@ -183,7 +197,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 +208,7 @@ export const Component = () => { } } return points; - }, [data]); + }, [data, t]); const filteredChartData = useMemo(() => { const selectedPoints = chartData.filter( @@ -295,20 +309,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 +338,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 +352,7 @@ export const Component = () => { items: [ (point: ChartDataPoint) => ({ name: point.category, - value: formatTooltipItem(point), + value: formatTooltipItem(point, t), }), ], }, @@ -391,7 +405,7 @@ export const Component = () => { { if (!selectedApp) { @@ -403,7 +417,7 @@ export const Component = () => { onSettingsClick={ selectedApp ? () => openAppSettings(selectedApp) : undefined } - sectionLabel="实时数据" + sectionLabel={t('realtime_metrics.title')} />
@@ -427,7 +441,7 @@ export const Component = () => { {isAdmin && (
setManualAppKey(e.target.value)} onPressEnter={handleManualAppKeySubmit} @@ -442,19 +456,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 +478,16 @@ export const Component = () => {
- + {!selectedAppKey ? (
- 请选择应用 + {t('realtime_metrics.please_select_app')}
) : (
-
总请求数
+
{t('realtime_metrics.total_requests')}
{isLoading ? '-' : totalRequests.toLocaleString()}
@@ -482,12 +496,14 @@ export const Component = () => {
-
分类数量
+
{t('realtime_metrics.category_count')}
{categoryTotals.size}
- 当前维度:{selectedAttributeLabel} + {t('realtime_metrics.current_dimension_label', { + dimension: selectedAttributeLabel, + })}
@@ -554,7 +570,7 @@ export const Component = () => {
) : (
- 暂无 Top 10 数据 + {t('realtime_metrics.no_top_data')}
)}
@@ -563,13 +579,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..74c1b80 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..3b427ec 100644 --- a/src/pages/reset-password/components/success.tsx +++ b/src/pages/reset-password/components/success.tsx @@ -1,13 +1,16 @@ import { Button, Result } from 'antd'; +import { useTranslation } from 'react-i18next'; +import { rootRouterPath } from '@/router'; export default function Success() { + const { t } = useTranslation(); return ( - 登录 + , ]} /> diff --git a/src/pages/reset-password/index.tsx b/src/pages/reset-password/index.tsx index 10dd26e..0862dcd 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,16 +12,17 @@ const body = { }; export const ResetPassword = () => { + const { t } = useTranslation(); const { step = '0' } = useParams() as { step?: keyof typeof body }; return ( {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 428188b0fae3f8affdfb4e217560c15336418c1d Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Sun, 28 Jun 2026 15:46:30 +0800 Subject: [PATCH 2/4] 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 | 9 +- src/pages/user.tsx | 375 ++++++++++++++++++++++-------------- 3 files changed, 247 insertions(+), 146 deletions(-) diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 4904555..9b94da1 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.", @@ -136,7 +137,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 8e452e4..9c8b684 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 手动处理。", @@ -136,7 +137,11 @@ }, "fetching_addon_quote": "正在获取加购报价", "per_year": "/ 年", - "upgrade_button": "升级" + "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')); } } From 6dce8934120e3a95c3ddea090082a9e1a91b6f54 Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Sat, 4 Jul 2026 10:36:43 +0800 Subject: [PATCH 3/4] fix: address i18n review comments --- src/assets/favicon.svg | 1 + src/assets/logo-h.svg | 1 + src/assets/logo.svg | 1 + src/components/error-boundary.tsx | 4 +- src/components/top-navigation.tsx | 22 +++--- src/pages/activate.tsx | 7 +- src/pages/admin-apps.tsx | 55 +++++++++++--- src/pages/admin-config.tsx | 15 +++- src/pages/admin-metrics.tsx | 12 ++- src/pages/admin-users.tsx | 50 ++++++++++--- src/pages/api-tokens.tsx | 56 ++++++++++---- src/pages/apps.tsx | 23 ++++-- src/pages/audit-logs.tsx | 30 +++++--- src/pages/inactivated.tsx | 4 +- src/pages/manage/components/bind-package.tsx | 18 ++++- src/pages/manage/components/commit.tsx | 23 ++++-- src/pages/manage/components/deps-table.tsx | 18 +++-- src/pages/manage/components/package-list.tsx | 61 ++++++++++----- .../components/publish-feature-table.tsx | 75 +++++++++++++------ src/pages/manage/components/version-table.tsx | 6 +- src/pages/manage/index.tsx | 14 +++- src/pages/realtime-metrics.tsx | 24 ++++-- src/pages/register.tsx | 13 +++- .../reset-password/components/send-email.tsx | 6 +- .../components/set-password.tsx | 15 +++- src/pages/welcome.tsx | 4 +- 26 files changed, 413 insertions(+), 145 deletions(-) diff --git a/src/assets/favicon.svg b/src/assets/favicon.svg index 2604f73..388deef 100644 --- a/src/assets/favicon.svg +++ b/src/assets/favicon.svg @@ -1,4 +1,5 @@ + Pushy favicon \ No newline at end of file diff --git a/src/assets/logo-h.svg b/src/assets/logo-h.svg index 6fe6e85..c54c425 100644 --- a/src/assets/logo-h.svg +++ b/src/assets/logo-h.svg @@ -1,5 +1,6 @@ + Pushy horizontal logo diff --git a/src/assets/logo.svg b/src/assets/logo.svg index 41408c3..a7b7786 100644 --- a/src/assets/logo.svg +++ b/src/assets/logo.svg @@ -1,5 +1,6 @@ + Pushy logo diff --git a/src/components/error-boundary.tsx b/src/components/error-boundary.tsx index 84c68d1..9d1e079 100644 --- a/src/components/error-boundary.tsx +++ b/src/components/error-boundary.tsx @@ -66,7 +66,9 @@ export function ErrorBoundary() { - + } /> diff --git a/src/components/top-navigation.tsx b/src/components/top-navigation.tsx index c43bbad..cb0d181 100644 --- a/src/components/top-navigation.tsx +++ b/src/components/top-navigation.tsx @@ -58,7 +58,10 @@ const platformLabels: Record = { harmony: 'HarmonyOS', }; -function getExternalItems(t: (key: string) => string, language?: string): MenuItems { +function getExternalItems( + t: (key: string) => string, + language?: string, +): MenuItems { const isChinese = language?.toLowerCase().startsWith('zh'); const docsUrl = isChinese ? 'https://pushy.reactnative.cn/docs/getting-started.html' @@ -80,20 +83,12 @@ function getExternalItems(t: (key: string) => string, language?: string): MenuIt { key: 'document', icon: , - label: ( - - {t('nav.documentation')} - - ), + label: {t('nav.documentation')}, }, { key: 'about', icon: , - label: ( - - {t('nav.about_us')} - - ), + label: {t('nav.about_us')}, }, { key: 'ai-cresc', @@ -141,7 +136,10 @@ export default function TopNavigation({ }; }, []); - const externalItems = getExternalItems(t, i18n.resolvedLanguage ?? i18n.language); + const externalItems = getExternalItems( + t, + i18n.resolvedLanguage ?? i18n.language, + ); const authenticatedItems: MenuItems = showAuthenticatedChrome && user diff --git a/src/pages/activate.tsx b/src/pages/activate.tsx index 56d501a..85ccbf2 100644 --- a/src/pages/activate.tsx +++ b/src/pages/activate.tsx @@ -3,6 +3,7 @@ import { useQuery } from '@tanstack/react-query'; import { Button, Result } from 'antd'; import { useTranslation } from 'react-i18next'; import { Link, useLocation } from 'react-router-dom'; +import { rootRouterPath } from '@/router'; import { api } from '@/services/api'; export const Activate = () => { @@ -18,14 +19,16 @@ export const Activate = () => { return ; } if (isLoading) { - return } title={t('activate.activating')} />; + return ( + } title={t('activate.activating')} /> + ); } return ( + } diff --git a/src/pages/admin-apps.tsx b/src/pages/admin-apps.tsx index eefefcc..1a483c6 100644 --- a/src/pages/admin-apps.tsx +++ b/src/pages/admin-apps.tsx @@ -227,7 +227,11 @@ export const Component = () => { width: 120, render: (value: string | null) => ( - {value === 'enabled' ? t('admin_apps.ignore_build_yes') : value === 'disabled' ? t('admin_apps.ignore_build_no') : '-'} + {value === 'enabled' + ? t('admin_apps.ignore_build_yes') + : value === 'disabled' + ? t('admin_apps.ignore_build_no') + : '-'} ), }, @@ -307,7 +311,9 @@ export const Component = () => { simple: isMobile, showQuickJumper: !isMobile, showSizeChanger: !isMobile, - showTotal: isMobile ? undefined : (count) => t('admin_apps.apps_count', { count }), + showTotal: isMobile + ? undefined + : (count) => t('admin_apps.apps_count', { count }), onChange: (page, nextPageSize) => { patchSearchParams(setSearchParams, { page: String(page), @@ -349,10 +355,18 @@ export const Component = () => { > - + - + + + - + - + { + string): Record => ({ +const getModeLabels = ( + t: (key: string) => string, +): Record => ({ pv: t('admin_metrics.mode_requests'), uv: t('admin_metrics.mode_users'), }); @@ -386,8 +388,12 @@ export const Component = () => { }} className="w-full md:w-auto" > - {t('admin_metrics.mode_requests')} - {t('admin_metrics.mode_users')} + + {t('admin_metrics.mode_requests')} + + + {t('admin_metrics.mode_users')} + - + - + - + diff --git a/src/pages/api-tokens.tsx b/src/pages/api-tokens.tsx index 537753e..3e1db88 100644 --- a/src/pages/api-tokens.tsx +++ b/src/pages/api-tokens.tsx @@ -18,7 +18,7 @@ import { import type { ColumnsType } from 'antd/es/table'; import dayjs from 'dayjs'; import { useState } from 'react'; -import { useTranslation } from 'react-i18next'; +import { Trans, useTranslation } from 'react-i18next'; import { api } from '@/services/api'; import type { ApiToken } from '@/types'; @@ -124,9 +124,15 @@ function ApiTokensPage() { key: 'permissions', render: (permissions: ApiToken['permissions']) => ( - {permissions?.read && {t('api_tokens.perm_read')}} - {permissions?.write && {t('api_tokens.perm_write')}} - {permissions?.delete && {t('api_tokens.perm_delete')}} + {permissions?.read && ( + {t('api_tokens.perm_read')} + )} + {permissions?.write && ( + {t('api_tokens.perm_write')} + )} + {permissions?.delete && ( + {t('api_tokens.perm_delete')} + )} ), }, @@ -136,7 +142,9 @@ function ApiTokensPage() { key: 'expiresAt', responsive: ['sm'], render: (expiresAt: string | null) => - expiresAt ? dayjs(expiresAt).format('YYYY-MM-DD HH:mm') : t('api_tokens.never'), + expiresAt + ? dayjs(expiresAt).format('YYYY-MM-DD HH:mm') + : t('api_tokens.never'), }, { title: t('api_tokens.col_last_used'), @@ -144,7 +152,9 @@ function ApiTokensPage() { key: 'lastUsedAt', responsive: ['lg'], render: (lastUsedAt: string | null) => - lastUsedAt ? dayjs(lastUsedAt).format('YYYY-MM-DD HH:mm') : t('api_tokens.never_used'), + lastUsedAt + ? dayjs(lastUsedAt).format('YYYY-MM-DD HH:mm') + : t('api_tokens.never_used'), }, { title: t('api_tokens.col_created'), @@ -230,25 +240,41 @@ function ApiTokensPage() { - + - + }} + /> - + }} + /> - + }} + />
{t('api_tokens.perm_note')} @@ -256,7 +282,11 @@ function ApiTokensPage() { - + {
- - - + + +
@@ -116,7 +123,9 @@ export const Component = () => { ) : ( {!query && ( ,
} diff --git a/src/pages/manage/components/publish-feature-table.tsx b/src/pages/manage/components/publish-feature-table.tsx index 0fc9ae4..97cf2d1 100644 --- a/src/pages/manage/components/publish-feature-table.tsx +++ b/src/pages/manage/components/publish-feature-table.tsx @@ -33,29 +33,58 @@ export default function PublishFeatureTable() {
{ } }; const isUnusedPackageFilter = packageFilter === 'unused'; - const allPackageDataSource = isUnusedPackageFilter ? unusedPackages : packages; + const allPackageDataSource = isUnusedPackageFilter + ? unusedPackages + : packages; const normalizedPackageSearch = packageSearch.trim().toLowerCase(); const packageDataSource = useMemo( () => diff --git a/src/pages/realtime-metrics.tsx b/src/pages/realtime-metrics.tsx index 6af2c47..98f3c4c 100644 --- a/src/pages/realtime-metrics.tsx +++ b/src/pages/realtime-metrics.tsx @@ -77,7 +77,10 @@ const formatCategory = ( rawCategory.endsWith(`${CATEGORY_SEPARATOR}unknown`) ) { return { - label: rawCategory.replace(CATEGORY_SEPARATOR, `: ${t('realtime_metrics.none')}`), + label: rawCategory.replace( + CATEGORY_SEPARATOR, + `: ${t('realtime_metrics.none')}`, + ), isTotal: false, }; } @@ -89,7 +92,10 @@ const formatCategory = ( 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 }, + { + label: t('realtime_metrics.package'), + value: 'packageVersion_buildTime' as const, + }, ]; const formatTooltipItem = ( @@ -478,7 +484,11 @@ export const Component = () => { - + {!selectedAppKey ? (
{t('realtime_metrics.please_select_app')} @@ -487,7 +497,9 @@ export const Component = () => {
-
{t('realtime_metrics.total_requests')}
+
+ {t('realtime_metrics.total_requests')} +
{isLoading ? '-' : totalRequests.toLocaleString()}
@@ -496,7 +508,9 @@ export const Component = () => {
-
{t('realtime_metrics.category_count')}
+
+ {t('realtime_metrics.category_count')} +
{categoryTotals.size}
diff --git a/src/pages/register.tsx b/src/pages/register.tsx index 74c1b80..b7f9851 100644 --- a/src/pages/register.tsx +++ b/src/pages/register.tsx @@ -37,10 +37,19 @@ export const Register = () => {
{t('register.slogan')}
- + - + - + } /> From 347b29bdfae1174735a683e2404fcab3ebd75d01 Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Sat, 4 Jul 2026 10:46:00 +0800 Subject: [PATCH 4/4] fix: add language switcher to mobile menu --- src/components/top-navigation.tsx | 94 +++++++++++++++++++++++++++++-- src/i18n/locales/en.json | 5 +- src/i18n/locales/zh-CN.json | 5 +- 3 files changed, 97 insertions(+), 7 deletions(-) diff --git a/src/components/top-navigation.tsx b/src/components/top-navigation.tsx index cb0d181..308849a 100644 --- a/src/components/top-navigation.tsx +++ b/src/components/top-navigation.tsx @@ -6,6 +6,7 @@ import { EyeInvisibleOutlined, EyeOutlined, FileTextOutlined, + GlobalOutlined, InfoCircleOutlined, KeyOutlined, LineChartOutlined, @@ -18,7 +19,16 @@ import { UserOutlined, } from '@ant-design/icons'; import type { MenuProps } from 'antd'; -import { Button, Drawer, Empty, Input, Menu, Popover, Tag } from 'antd'; +import { + Button, + Drawer, + Dropdown, + Empty, + Input, + Menu, + Popover, + Tag, +} from 'antd'; import type { ReactNode } from 'react'; import { useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; @@ -102,6 +112,67 @@ function getExternalItems( ]; } +const languageOptions = [ + { key: 'zh-CN', labelKey: 'nav.language_zh' }, + { key: 'en', labelKey: 'nav.language_en' }, +] as const; + +function getCurrentLanguage(language?: string) { + return language?.toLowerCase().startsWith('zh') ? 'zh-CN' : 'en'; +} + +function LanguageSwitcher({ compact = false }: { compact?: boolean }) { + const { t, i18n } = useTranslation(); + const currentLanguage = getCurrentLanguage( + i18n.resolvedLanguage ?? i18n.language, + ); + const items: MenuItems = languageOptions.map(({ key, labelKey }) => ({ + key, + label: t(labelKey), + })); + + return ( + { + void i18n.changeLanguage(key); + }, + }} + placement="bottomRight" + trigger={['click']} + > + + + ); +} + +function getLanguageMenuItem( + t: (key: string) => string, + currentLanguage: string, +): MenuItems[number] { + return { + key: 'language', + icon: , + label: t('nav.language'), + children: languageOptions.map(({ key, labelKey }) => ({ + key: `language:${key}`, + label: currentLanguage === key ? `${t(labelKey)} ✓` : t(labelKey), + })), + }; +} + export default function TopNavigation({ isMobile, showAuthenticatedChrome, @@ -136,10 +207,10 @@ export default function TopNavigation({ }; }, []); - const externalItems = getExternalItems( - t, + const currentLanguage = getCurrentLanguage( i18n.resolvedLanguage ?? i18n.language, ); + const externalItems = getExternalItems(t, currentLanguage); const authenticatedItems: MenuItems = showAuthenticatedChrome && user @@ -240,6 +311,8 @@ export default function TopNavigation({ : []; const mobileItems: MenuItems = [ + getLanguageMenuItem(t, currentLanguage), + { type: 'divider' as const }, ...authenticatedItems, ...(authenticatedItems.length ? [{ type: 'divider' as const }] : []), ...externalItems, @@ -291,7 +364,10 @@ export default function TopNavigation({ items={mobileItems} onClose={() => setMobileMenuOpen(false)} open={mobileMenuOpen} - selectedKeys={selectedKeys} + selectedKeys={[...selectedKeys, `language:${currentLanguage}`]} + onLanguageChange={(language) => { + void i18n.changeLanguage(language); + }} />
) : ( @@ -303,6 +379,7 @@ export default function TopNavigation({ items={[...authenticatedItems, ...externalItems]} style={{ height: 64, lineHeight: '64px' }} /> + {showAuthenticatedChrome && user && ( void; + onLanguageChange: (language: string) => void; open: boolean; selectedKeys: string[]; }) { @@ -345,7 +424,12 @@ function MobileMenuSheet({ className="border-e-0!" items={items} mode="inline" - onClick={onClose} + onClick={({ key }) => { + if (String(key).startsWith('language:')) { + onLanguageChange(String(key).replace('language:', '')); + } + onClose(); + }} selectedKeys={selectedKeys} /> diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 9b94da1..4c7b7d5 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -526,7 +526,10 @@ "checks": "checks", "app_settings": "App Settings", "open_app_settings": "Open {{name}} settings", - "paused": "Paused" + "paused": "Paused", + "language": "Language", + "language_en": "English", + "language_zh": "中文" }, "app_drawer": { "expand": "Expand app list", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 9c8b684..f3a6419 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -526,7 +526,10 @@ "checks": "检查次数", "app_settings": "应用设置", "open_app_settings": "打开 {{name}} 应用设置", - "paused": "暂停" + "paused": "暂停", + "language": "语言", + "language_en": "English", + "language_zh": "中文" }, "app_drawer": { "expand": "展开应用列表",