feat: initial i18n support

This commit is contained in:
JustSong 2025-02-01 12:15:38 +08:00
parent 1521df6551
commit bdf312e5dc
7 changed files with 173 additions and 32 deletions

View File

@ -5,10 +5,14 @@
"dependencies": {
"axios": "^0.27.2",
"history": "^5.3.0",
"i18next": "^24.2.2",
"i18next-browser-languagedetector": "^8.0.2",
"i18next-http-backend": "^3.0.2",
"marked": "^4.1.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-dropzone": "^14.2.3",
"react-i18next": "^15.4.0",
"react-router-dom": "^6.3.0",
"react-scripts": "5.0.1",
"react-toastify": "^9.0.8",

View File

@ -0,0 +1,38 @@
{
"header": {
"home": "Home",
"channel": "Channel",
"token": "Token",
"redemption": "Redemption",
"topup": "Top Up",
"user": "User",
"dashboard": "Dashboard",
"log": "Log",
"setting": "Settings",
"about": "About",
"chat": "Chat",
"login": "Login",
"logout": "Logout",
"register": "Register"
},
"topup": {
"title": "Top Up Center",
"get_code": {
"title": "Get Redemption Code",
"current_quota": "Current Available Quota",
"button": "Get Code Now"
},
"redeem_code": {
"title": "Redeem Code",
"placeholder": "Please enter redemption code",
"paste": "Paste",
"paste_error": "Cannot access clipboard, please paste manually",
"submit": "Redeem Now",
"submitting": "Redeeming...",
"empty_code": "Please enter the redemption code!",
"success": "Top up successful!",
"request_failed": "Request failed",
"no_link": "Admin has not set up the top-up link!"
}
}
}

View File

@ -0,0 +1,38 @@
{
"header": {
"home": "首页",
"channel": "渠道",
"token": "令牌",
"redemption": "兑换",
"topup": "充值",
"user": "用户",
"dashboard": "总览",
"log": "日志",
"setting": "设置",
"about": "关于",
"chat": "聊天",
"login": "登录",
"logout": "注销",
"register": "注册"
},
"topup": {
"title": "充值中心",
"get_code": {
"title": "获取兑换码",
"current_quota": "当前可用额度",
"button": "立即获取兑换码"
},
"redeem_code": {
"title": "兑换码充值",
"placeholder": "请输入兑换码",
"paste": "粘贴",
"paste_error": "无法访问剪贴板,请手动粘贴",
"submit": "立即兑换",
"submitting": "兑换中...",
"empty_code": "请输入兑换码!",
"success": "充值成功!",
"request_failed": "请求失败",
"no_link": "超级管理员未设置充值链接!"
}
}
}

View File

@ -1,6 +1,7 @@
import React, { useContext, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { UserContext } from '../context/User';
import { useTranslation } from 'react-i18next';
import {
Button,
@ -23,55 +24,55 @@ import '../index.css';
// Header Buttons
let headerButtons = [
{
name: '首页',
name: 'header.home',
to: '/',
icon: 'home',
},
{
name: '渠道',
name: 'header.channel',
to: '/channel',
icon: 'sitemap',
admin: true,
},
{
name: '令牌',
name: 'header.token',
to: '/token',
icon: 'key',
},
{
name: '兑换',
name: 'header.redemption',
to: '/redemption',
icon: 'dollar sign',
admin: true,
},
{
name: '充值',
name: 'header.topup',
to: '/topup',
icon: 'cart',
},
{
name: '用户',
name: 'header.user',
to: '/user',
icon: 'user',
admin: true,
},
{
name: '总览',
name: 'header.dashboard',
to: '/dashboard',
icon: 'chart bar',
},
{
name: '日志',
name: 'header.log',
to: '/log',
icon: 'book',
},
{
name: '设置',
name: 'header.setting',
to: '/setting',
icon: 'setting',
},
{
name: '关于',
name: 'header.about',
to: '/about',
icon: 'info circle',
},
@ -79,13 +80,14 @@ let headerButtons = [
if (localStorage.getItem('chat_link')) {
headerButtons.splice(1, 0, {
name: '聊天',
name: 'header.chat',
to: '/chat',
icon: 'comments',
});
}
const Header = () => {
const { t, i18n } = useTranslation();
const [userState, userDispatch] = useContext(UserContext);
let navigate = useNavigate();
@ -112,13 +114,14 @@ const Header = () => {
if (isMobile) {
return (
<Menu.Item
key={button.name}
onClick={() => {
navigate(button.to);
setShowSidebar(false);
}}
style={{ fontSize: '15px' }}
>
{button.name}
{t(button.name)}
</Menu.Item>
);
}
@ -134,12 +137,22 @@ const Header = () => {
}}
>
<Icon name={button.icon} style={{ marginRight: '4px' }} />
{button.name}
{t(button.name)}
</Menu.Item>
);
});
};
// Add language switcher dropdown
const languageOptions = [
{ key: 'zh', text: '中文', value: 'zh' },
{ key: 'en', text: 'English', value: 'en' },
];
const changeLanguage = (language) => {
i18n.changeLanguage(language);
};
if (isMobile()) {
return (
<>
@ -175,10 +188,18 @@ const Header = () => {
<Segment style={{ marginTop: 0, borderTop: '0' }}>
<Menu secondary vertical style={{ width: '100%', margin: 0 }}>
{renderButtons(true)}
<Menu.Item>
<Dropdown
selection
options={languageOptions}
value={i18n.language}
onChange={(_, { value }) => changeLanguage(value)}
/>
</Menu.Item>
<Menu.Item>
{userState.user ? (
<Button onClick={logout} style={{ color: '#666666' }}>
注销
{t('header.logout')}
</Button>
) : (
<>
@ -188,7 +209,7 @@ const Header = () => {
navigate('/login');
}}
>
登录
{t('header.login')}
</Button>
<Button
onClick={() => {
@ -196,7 +217,7 @@ const Header = () => {
navigate('/register');
}}
>
注册
{t('header.register')}
</Button>
</>
)}
@ -235,6 +256,17 @@ const Header = () => {
</Menu.Item>
{renderButtons(false)}
<Menu.Menu position='right'>
<Dropdown
item
options={languageOptions}
value={i18n.language}
onChange={(_, { value }) => changeLanguage(value)}
style={{
fontSize: '15px',
fontWeight: '400',
color: '#666',
}}
/>
{userState.user ? (
<Dropdown
text={userState.user.username}
@ -255,13 +287,13 @@ const Header = () => {
color: '#666',
}}
>
注销
{t('header.logout')}
</Dropdown.Item>
</Dropdown.Menu>
</Dropdown>
) : (
<Menu.Item
name='登录'
name={t('header.login')}
as={Link}
to='/login'
className='btn btn-link'

23
web/default/src/i18n.js Normal file
View File

@ -0,0 +1,23 @@
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import Backend from 'i18next-http-backend';
import LanguageDetector from 'i18next-browser-languagedetector';
i18n
.use(Backend)
.use(LanguageDetector)
.use(initReactI18next)
.init({
fallbackLng: 'zh',
debug: process.env.NODE_ENV === 'development',
interpolation: {
escapeValue: false,
},
backend: {
loadPath: '/locales/{{lng}}/{{ns}}.json',
},
});
export default i18n;

View File

@ -11,6 +11,7 @@ import { UserProvider } from './context/User';
import { ToastContainer } from 'react-toastify';
import 'react-toastify/dist/ReactToastify.css';
import { StatusProvider } from './context/Status';
import './i18n';
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(

View File

@ -10,8 +10,10 @@ import {
} from 'semantic-ui-react';
import { API, showError, showInfo, showSuccess } from '../../helpers';
import { renderQuota } from '../../helpers/render';
import { useTranslation } from 'react-i18next';
const TopUp = () => {
const { t } = useTranslation();
const [redemptionCode, setRedemptionCode] = useState('');
const [topUpLink, setTopUpLink] = useState('');
const [userQuota, setUserQuota] = useState(0);
@ -20,7 +22,7 @@ const TopUp = () => {
const topUp = async () => {
if (redemptionCode === '') {
showInfo('请输入兑换码!');
showInfo(t('topup.redeem_code.empty_code'));
return;
}
setIsSubmitting(true);
@ -30,7 +32,7 @@ const TopUp = () => {
});
const { success, message, data } = res.data;
if (success) {
showSuccess('充值成功!');
showSuccess(t('topup.redeem_code.success'));
setUserQuota((quota) => {
return quota + data;
});
@ -39,7 +41,7 @@ const TopUp = () => {
showError(message);
}
} catch (err) {
showError('请求失败');
showError(t('topup.redeem_code.request_failed'));
} finally {
setIsSubmitting(false);
}
@ -47,13 +49,12 @@ const TopUp = () => {
const openTopUpLink = () => {
if (!topUpLink) {
showError('超级管理员未设置充值链接!');
showError(t('topup.redeem_code.no_link'));
return;
}
let url = new URL(topUpLink);
let username = user.username;
let user_id = user.id;
// add username and user_id to the topup link
url.searchParams.append('username', username);
url.searchParams.append('user_id', user_id);
url.searchParams.append('transaction_id', crypto.randomUUID());
@ -87,7 +88,7 @@ const TopUp = () => {
<Card fluid className='chart-card'>
<Card.Content>
<Card.Header>
<Header as='h2'>充值中心</Header>
<Header as='h2'>{t('topup.title')}</Header>
</Card.Header>
<Grid columns={2} stackable>
@ -109,7 +110,7 @@ const TopUp = () => {
<Card.Header>
<Header as='h3' style={{ color: '#2185d0', margin: '1em' }}>
<i className='credit card icon'></i>
获取兑换码
{t('topup.get_code.title')}
</Header>
</Card.Header>
<Card.Description
@ -132,7 +133,9 @@ const TopUp = () => {
<Statistic.Value style={{ color: '#2185d0' }}>
{renderQuota(userQuota)}
</Statistic.Value>
<Statistic.Label>当前可用额度</Statistic.Label>
<Statistic.Label>
{t('topup.get_code.current_quota')}
</Statistic.Label>
</Statistic>
</div>
@ -145,7 +148,7 @@ const TopUp = () => {
onClick={openTopUpLink}
style={{ width: '80%' }}
>
立即获取兑换码
{t('topup.get_code.button')}
</Button>
</div>
</div>
@ -172,7 +175,7 @@ const TopUp = () => {
<Card.Header>
<Header as='h3' style={{ color: '#21ba45', margin: '1em' }}>
<i className='ticket alternate icon'></i>
兑换码充值
{t('topup.redeem_code.title')}
</Header>
</Card.Header>
<Card.Description
@ -194,7 +197,7 @@ const TopUp = () => {
fluid
icon='key'
iconPosition='left'
placeholder='请输入兑换码'
placeholder={t('topup.redeem_code.placeholder')}
value={redemptionCode}
onChange={(e) => {
setRedemptionCode(e.target.value);
@ -207,14 +210,14 @@ const TopUp = () => {
action={
<Button
icon='paste'
content='粘贴'
content={t('topup.redeem_code.paste')}
onClick={async () => {
try {
const text =
await navigator.clipboard.readText();
setRedemptionCode(text.trim());
} catch (err) {
showError('无法访问剪贴板,请手动粘贴');
showError(t('topup.redeem_code.paste_error'));
}
}}
/>
@ -230,7 +233,9 @@ const TopUp = () => {
loading={isSubmitting}
disabled={isSubmitting}
>
{isSubmitting ? '兑换中...' : '立即兑换'}
{isSubmitting
? t('topup.redeem_code.submitting')
: t('topup.redeem_code.submit')}
</Button>
</div>
</div>