feat: i18n support

This commit is contained in:
JustSong
2025-02-01 17:00:24 +08:00
parent 60f2776795
commit ae20aea555
13 changed files with 156 additions and 63 deletions

View File

@@ -240,5 +240,36 @@
"display_short": "${{amount}}", "display_short": "${{amount}}",
"unit": "$" "unit": "$"
} }
},
"redemption": {
"title": "Redemption Management",
"edit": {
"title_edit": "Update Redemption Code",
"title_create": "Create New Redemption Code",
"name": "Name",
"name_placeholder": "Please enter name",
"quota": "Quota",
"quota_placeholder": "Please enter quota per redemption code",
"count": "Generate Count",
"count_placeholder": "Please enter number of codes to generate",
"buttons": {
"submit": "Submit",
"cancel": "Cancel"
}
}
},
"log": {
"title": "Operation Log",
"usage_details": "Usage Details",
"total_quota": "Total Quota Used",
"click_to_view": "Click to View",
"table": {
"id": "ID",
"username": "Username",
"type": "Type",
"content": "Content",
"amount": "Amount",
"time": "Time"
}
} }
} }

View File

@@ -240,5 +240,36 @@
"display_short": "${{amount}}", "display_short": "${{amount}}",
"unit": "$" "unit": "$"
} }
},
"redemption": {
"title": "兑换管理",
"edit": {
"title_edit": "更新兑换码信息",
"title_create": "创建新的兑换码",
"name": "名称",
"name_placeholder": "请输入名称",
"quota": "额度",
"quota_placeholder": "请输入单个兑换码中包含的额度",
"count": "生成数量",
"count_placeholder": "请输入生成数量",
"buttons": {
"submit": "提交",
"cancel": "取消"
}
}
},
"log": {
"title": "操作日志",
"usage_details": "使用明细",
"total_quota": "总消耗额度",
"click_to_view": "点击查看",
"table": {
"id": "ID",
"username": "用户名",
"type": "类型",
"content": "内容",
"amount": "数量",
"time": "时间"
}
} }
} }

View File

@@ -45,7 +45,8 @@ function App() {
try { try {
const res = await API.get('/api/status'); const res = await API.get('/api/status');
const { success, message, data } = res.data || {}; // Add default empty object const { success, message, data } = res.data || {}; // Add default empty object
if (success && data) { // Check data exists if (success && data) {
// Check data exists
localStorage.setItem('status', JSON.stringify(data)); localStorage.setItem('status', JSON.stringify(data));
statusDispatch({ type: 'set', payload: data }); statusDispatch({ type: 'set', payload: data });
localStorage.setItem('system_name', data.system_name); localStorage.setItem('system_name', data.system_name);

View File

@@ -18,6 +18,7 @@ import {
showWarning, showWarning,
timestamp2string, timestamp2string,
} from '../helpers'; } from '../helpers';
import { useTranslation } from 'react-i18next';
import { ITEMS_PER_PAGE } from '../constants'; import { ITEMS_PER_PAGE } from '../constants';
import { renderColorLabel, renderQuota } from '../helpers/render'; import { renderColorLabel, renderQuota } from '../helpers/render';
@@ -137,6 +138,7 @@ function renderDetail(log) {
} }
const LogsTable = () => { const LogsTable = () => {
const { t } = useTranslation();
const [logs, setLogs] = useState([]); const [logs, setLogs] = useState([]);
const [showStat, setShowStat] = useState(false); const [showStat, setShowStat] = useState(false);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -309,14 +311,14 @@ const LogsTable = () => {
<> <>
<> <>
<Header as='h3'> <Header as='h3'>
使用明细总消耗额度 {t('log.usage_details')}{t('log.total_quota')}
{showStat && renderQuota(stat.quota)} {showStat && renderQuota(stat.quota, t)}
{!showStat && ( {!showStat && (
<span <span
onClick={handleEyeClick} onClick={handleEyeClick}
style={{ cursor: 'pointer', color: 'gray' }} style={{ cursor: 'pointer', color: 'gray' }}
> >
点击查看 {t('log.click_to_view')}
</span> </span>
)} )}
@@ -554,7 +556,7 @@ const LogsTable = () => {
{log.completion_tokens ? log.completion_tokens : ''} {log.completion_tokens ? log.completion_tokens : ''}
</Table.Cell> </Table.Cell>
<Table.Cell> <Table.Cell>
{log.quota ? renderQuota(log.quota, 6) : ''} {log.quota ? renderQuota(log.quota, t, 6) : ''}
</Table.Cell> </Table.Cell>
</> </>
)} )}

View File

@@ -1,4 +1,5 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { import {
Button, Button,
Form, Form,
@@ -25,39 +26,37 @@ function renderTimestamp(timestamp) {
return <>{timestamp2string(timestamp)}</>; return <>{timestamp2string(timestamp)}</>;
} }
function renderStatus(status) { function renderStatus(status, t) {
switch (status) { switch (status) {
case 1: case 1:
return ( return (
<Label basic color='green'> <Label basic color='green'>
未使用 {t('redemption.status.unused')}
</Label> </Label>
); );
case 2: case 2:
return ( return (
<Label basic color='red'> <Label basic color='red'>
{' '} {t('redemption.status.disabled')}
已禁用{' '}
</Label> </Label>
); );
case 3: case 3:
return ( return (
<Label basic color='grey'> <Label basic color='grey'>
{' '} {t('redemption.status.used')}
已使用{' '}
</Label> </Label>
); );
default: default:
return ( return (
<Label basic color='black'> <Label basic color='black'>
{' '} {t('redemption.status.unknown')}
未知状态{' '}
</Label> </Label>
); );
} }
} }
const RedemptionsTable = () => { const RedemptionsTable = () => {
const { t } = useTranslation();
const [redemptions, setRedemptions] = useState([]); const [redemptions, setRedemptions] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [activePage, setActivePage] = useState(1); const [activePage, setActivePage] = useState(1);
@@ -260,8 +259,8 @@ const RedemptionsTable = () => {
<Table.Cell> <Table.Cell>
{redemption.name ? redemption.name : '无'} {redemption.name ? redemption.name : '无'}
</Table.Cell> </Table.Cell>
<Table.Cell>{renderStatus(redemption.status)}</Table.Cell> <Table.Cell>{renderStatus(redemption.status, t)}</Table.Cell>
<Table.Cell>{renderQuota(redemption.quota)}</Table.Cell> <Table.Cell>{renderQuota(redemption.quota, t)}</Table.Cell>
<Table.Cell> <Table.Cell>
{renderTimestamp(redemption.created_time)} {renderTimestamp(redemption.created_time)}
</Table.Cell> </Table.Cell>

View File

@@ -69,14 +69,14 @@ const TokensTable = () => {
{ key: 'next', text: t('token.copy_options.next'), value: 'next' }, { key: 'next', text: t('token.copy_options.next'), value: 'next' },
{ key: 'ama', text: t('token.copy_options.ama'), value: 'ama' }, { key: 'ama', text: t('token.copy_options.ama'), value: 'ama' },
{ key: 'opencat', text: t('token.copy_options.opencat'), value: 'opencat' }, { key: 'opencat', text: t('token.copy_options.opencat'), value: 'opencat' },
{ key: 'lobe', text: t('token.copy_options.lobe'), value: 'lobechat' } { key: 'lobe', text: t('token.copy_options.lobe'), value: 'lobechat' },
]; ];
const OPEN_LINK_OPTIONS = [ const OPEN_LINK_OPTIONS = [
{ key: 'next', text: t('token.copy_options.next'), value: 'next' }, { key: 'next', text: t('token.copy_options.next'), value: 'next' },
{ key: 'ama', text: t('token.copy_options.ama'), value: 'ama' }, { key: 'ama', text: t('token.copy_options.ama'), value: 'ama' },
{ key: 'opencat', text: t('token.copy_options.opencat'), value: 'opencat' }, { key: 'opencat', text: t('token.copy_options.opencat'), value: 'opencat' },
{ key: 'lobe', text: t('token.copy_options.lobe'), value: 'lobechat' } { key: 'lobe', text: t('token.copy_options.lobe'), value: 'lobechat' },
]; ];
const [tokens, setTokens] = useState([]); const [tokens, setTokens] = useState([]);
@@ -135,7 +135,8 @@ const TokensTable = () => {
let nextUrl; let nextUrl;
if (nextLink) { if (nextLink) {
nextUrl = nextLink + `/#/?settings={"key":"sk-${key}","url":"${serverAddress}"}`; nextUrl =
nextLink + `/#/?settings={"key":"sk-${key}","url":"${serverAddress}"}`;
} else { } else {
nextUrl = `https://app.nextchat.dev/#/?settings={"key":"sk-${key}","url":"${serverAddress}"}`; nextUrl = `https://app.nextchat.dev/#/?settings={"key":"sk-${key}","url":"${serverAddress}"}`;
} }
@@ -152,7 +153,9 @@ const TokensTable = () => {
url = nextUrl; url = nextUrl;
break; break;
case 'lobechat': case 'lobechat':
url = nextLink + `/?settings={"keyVaults":{"openai":{"apiKey":"sk-${key}","baseURL":"${serverAddress}/v1"}}}`; url =
nextLink +
`/?settings={"keyVaults":{"openai":{"apiKey":"sk-${key}","baseURL":"${serverAddress}/v1"}}}`;
break; break;
default: default:
url = `sk-${key}`; url = `sk-${key}`;
@@ -376,19 +379,21 @@ const TokensTable = () => {
.map((token, idx) => { .map((token, idx) => {
if (token.deleted) return <></>; if (token.deleted) return <></>;
const copyOptionsWithHandlers = COPY_OPTIONS.map(option => ({ const copyOptionsWithHandlers = COPY_OPTIONS.map((option) => ({
...option, ...option,
onClick: async () => { onClick: async () => {
await onCopy(option.value, token.key); await onCopy(option.value, token.key);
} },
})); }));
const openLinkOptionsWithHandlers = OPEN_LINK_OPTIONS.map(option => ({ const openLinkOptionsWithHandlers = OPEN_LINK_OPTIONS.map(
(option) => ({
...option, ...option,
onClick: async () => { onClick: async () => {
await onOpenLink(option.value, token.key); await onOpenLink(option.value, token.key);
} },
})); })
);
return ( return (
<Table.Row key={token.id}> <Table.Row key={token.id}>
@@ -473,7 +478,11 @@ const TokensTable = () => {
? t('token.buttons.disable') ? t('token.buttons.disable')
: t('token.buttons.enable')} : t('token.buttons.enable')}
</Button> </Button>
<Button size={'small'} as={Link} to={'/token/edit/' + token.id}> <Button
size={'small'}
as={Link}
to={'/token/edit/' + token.id}
>
{t('token.buttons.edit')} {t('token.buttons.edit')}
</Button> </Button>
</div> </div>

View File

@@ -10,6 +10,7 @@ import {
} from 'semantic-ui-react'; } from 'semantic-ui-react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { API, showError, showSuccess } from '../helpers'; import { API, showError, showSuccess } from '../helpers';
import { useTranslation } from 'react-i18next';
import { ITEMS_PER_PAGE } from '../constants'; import { ITEMS_PER_PAGE } from '../constants';
import { import {
@@ -33,6 +34,7 @@ function renderRole(role) {
} }
const UsersTable = () => { const UsersTable = () => {
const { t } = useTranslation();
const [users, setUsers] = useState([]); const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [activePage, setActivePage] = useState(1); const [activePage, setActivePage] = useState(1);
@@ -266,12 +268,12 @@ const UsersTable = () => {
<Table.Cell> <Table.Cell>
<Popup <Popup
content='剩余额度' content='剩余额度'
trigger={<Label basic>{renderQuota(user.quota)}</Label>} trigger={<Label basic>{renderQuota(user.quota, t)}</Label>}
/> />
<Popup <Popup
content='已用额度' content='已用额度'
trigger={ trigger={
<Label basic>{renderQuota(user.used_quota)}</Label> <Label basic>{renderQuota(user.used_quota, t)}</Label>
} }
/> />
<Popup <Popup

View File

@@ -41,8 +41,11 @@ export function renderNumber(num) {
} }
export function renderQuota(quota, t, precision = 2) { export function renderQuota(quota, t, precision = 2) {
const displayInCurrency = localStorage.getItem('display_in_currency') === 'true'; const displayInCurrency =
const quotaPerUnit = parseFloat(localStorage.getItem('quota_per_unit') || '1'); localStorage.getItem('display_in_currency') === 'true';
const quotaPerUnit = parseFloat(
localStorage.getItem('quota_per_unit') || '1'
);
if (displayInCurrency) { if (displayInCurrency) {
const amount = (quota / quotaPerUnit).toFixed(precision); const amount = (quota / quotaPerUnit).toFixed(precision);
@@ -53,8 +56,11 @@ export function renderQuota(quota, t, precision = 2) {
} }
export function renderQuotaWithPrompt(quota, t) { export function renderQuotaWithPrompt(quota, t) {
const displayInCurrency = localStorage.getItem('display_in_currency') === 'true'; const displayInCurrency =
const quotaPerUnit = parseFloat(localStorage.getItem('quota_per_unit') || '1'); localStorage.getItem('display_in_currency') === 'true';
const quotaPerUnit = parseFloat(
localStorage.getItem('quota_per_unit') || '1'
);
if (displayInCurrency) { if (displayInCurrency) {
const amount = (quota / quotaPerUnit).toFixed(2); const amount = (quota / quotaPerUnit).toFixed(2);

View File

@@ -1,16 +1,21 @@
import React from 'react'; import React from 'react';
import { Card } from 'semantic-ui-react'; import { Card } from 'semantic-ui-react';
import { useTranslation } from 'react-i18next';
import LogsTable from '../../components/LogsTable'; import LogsTable from '../../components/LogsTable';
const Log = () => ( const Log = () => {
const { t } = useTranslation();
return (
<div className='dashboard-container'> <div className='dashboard-container'>
<Card fluid className='chart-card'> <Card fluid className='chart-card'>
<Card.Content> <Card.Content>
{/*<Card.Header className='header'>操作日志</Card.Header>*/} <Card.Header className='header'>{t('log.title')}</Card.Header>
<LogsTable /> <LogsTable />
</Card.Content> </Card.Content>
</Card> </Card>
</div> </div>
); );
};
export default Log; export default Log;

View File

@@ -1,10 +1,12 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Form, Card } from 'semantic-ui-react'; import { Button, Form, Card } from 'semantic-ui-react';
import { useParams, useNavigate } from 'react-router-dom'; import { useParams, useNavigate } from 'react-router-dom';
import { API, downloadTextAsFile, showError, showSuccess } from '../../helpers'; import { API, downloadTextAsFile, showError, showSuccess } from '../../helpers';
import { renderQuota, renderQuotaWithPrompt } from '../../helpers/render'; import { renderQuota, renderQuotaWithPrompt } from '../../helpers/render';
const EditRedemption = () => { const EditRedemption = () => {
const { t } = useTranslation();
const params = useParams(); const params = useParams();
const navigate = useNavigate(); const navigate = useNavigate();
const redemptionId = params.id; const redemptionId = params.id;
@@ -83,14 +85,14 @@ const EditRedemption = () => {
<Card fluid className='chart-card'> <Card fluid className='chart-card'>
<Card.Content> <Card.Content>
<Card.Header className='header'> <Card.Header className='header'>
{isEdit ? '更新兑换码信息' : '创建新的兑换码'} {isEdit ? t('redemption.edit.title_edit') : t('redemption.edit.title_create')}
</Card.Header> </Card.Header>
<Form loading={loading} autoComplete='new-password'> <Form loading={loading} autoComplete='new-password'>
<Form.Field> <Form.Field>
<Form.Input <Form.Input
label='名称' label={t('redemption.edit.name')}
name='name' name='name'
placeholder={'请输入名称'} placeholder={t('redemption.edit.name_placeholder')}
onChange={handleInputChange} onChange={handleInputChange}
value={name} value={name}
autoComplete='new-password' autoComplete='new-password'
@@ -99,9 +101,9 @@ const EditRedemption = () => {
</Form.Field> </Form.Field>
<Form.Field> <Form.Field>
<Form.Input <Form.Input
label={`额度${renderQuotaWithPrompt(quota)}`} label={`${t('redemption.edit.quota')}${renderQuotaWithPrompt(quota, t)}`}
name='quota' name='quota'
placeholder={'请输入单个兑换码中包含的额度'} placeholder={t('redemption.edit.quota_placeholder')}
onChange={handleInputChange} onChange={handleInputChange}
value={quota} value={quota}
autoComplete='new-password' autoComplete='new-password'
@@ -112,9 +114,9 @@ const EditRedemption = () => {
<> <>
<Form.Field> <Form.Field>
<Form.Input <Form.Input
label='生成数量' label={t('redemption.edit.count')}
name='count' name='count'
placeholder={'请输入生成数量'} placeholder={t('redemption.edit.count_placeholder')}
onChange={handleInputChange} onChange={handleInputChange}
value={count} value={count}
autoComplete='new-password' autoComplete='new-password'
@@ -124,9 +126,11 @@ const EditRedemption = () => {
</> </>
)} )}
<Button positive onClick={submit}> <Button positive onClick={submit}>
提交 {t('redemption.edit.buttons.submit')}
</Button>
<Button onClick={handleCancel}>
{t('redemption.edit.buttons.cancel')}
</Button> </Button>
<Button onClick={handleCancel}>取消</Button>
</Form> </Form>
</Card.Content> </Card.Content>
</Card> </Card>

View File

@@ -107,12 +107,12 @@ const EditToken = () => {
useEffect(() => { useEffect(() => {
if (isEdit) { if (isEdit) {
loadToken().catch(error => { loadToken().catch((error) => {
showError(error.message || 'Failed to load token'); showError(error.message || 'Failed to load token');
setLoading(false); setLoading(false);
}); });
} }
loadAvailableModels().catch(error => { loadAvailableModels().catch((error) => {
showError(error.message || 'Failed to load models'); showError(error.message || 'Failed to load models');
}); });
}, []); }, []);
@@ -255,7 +255,10 @@ const EditToken = () => {
<Message>{t('token.edit.quota_notice')}</Message> <Message>{t('token.edit.quota_notice')}</Message>
<Form.Field> <Form.Field>
<Form.Input <Form.Input
label={`${t('token.edit.quota')}${renderQuotaWithPrompt(remain_quota, t)}`} label={`${t('token.edit.quota')}${renderQuotaWithPrompt(
remain_quota,
t
)}`}
name='remain_quota' name='remain_quota'
placeholder={t('token.edit.quota_placeholder')} placeholder={t('token.edit.quota_placeholder')}
onChange={handleInputChange} onChange={handleInputChange}

View File

@@ -131,7 +131,7 @@ const TopUp = () => {
<div style={{ textAlign: 'center', paddingTop: '1em' }}> <div style={{ textAlign: 'center', paddingTop: '1em' }}>
<Statistic> <Statistic>
<Statistic.Value style={{ color: '#2185d0' }}> <Statistic.Value style={{ color: '#2185d0' }}>
{renderQuota(userQuota)} {renderQuota(userQuota, t)}
</Statistic.Value> </Statistic.Value>
<Statistic.Label> <Statistic.Label>
{t('topup.get_code.current_quota')} {t('topup.get_code.current_quota')}