From 60f2776795d680650d0aa087ef62314457f9edc0 Mon Sep 17 00:00:00 2001 From: JustSong Date: Sat, 1 Feb 2025 15:11:07 +0800 Subject: [PATCH] feat: i18n for token related pages --- .../public/locales/en/translation.json | 88 ++++++++++ .../public/locales/zh/translation.json | 88 ++++++++++ web/default/src/App.js | 52 +++--- web/default/src/components/TokensTable.js | 160 +++++++++--------- web/default/src/helpers/render.js | 25 +-- web/default/src/pages/Token/EditToken.js | 132 ++++++++------- web/default/src/pages/Token/index.js | 25 +-- 7 files changed, 385 insertions(+), 185 deletions(-) diff --git a/web/default/public/locales/en/translation.json b/web/default/public/locales/en/translation.json index baab810b..3c1b23fa 100644 --- a/web/default/public/locales/en/translation.json +++ b/web/default/public/locales/en/translation.json @@ -152,5 +152,93 @@ "tencent": "Enter in format: AppId|SecretId|SecretKey" } } + }, + "token": { + "title": "Token Management", + "search": "Search tokens by name ...", + "table": { + "name": "Name", + "status": "Status", + "used_quota": "Used Quota", + "remain_quota": "Remaining Quota", + "created_time": "Created Time", + "expired_time": "Expiry Time", + "actions": "Actions", + "no_name": "None", + "never_expire": "Never Expires", + "unlimited": "Unlimited", + "status_enabled": "Enabled", + "status_disabled": "Disabled", + "status_expired": "Expired", + "status_depleted": "Depleted", + "status_unknown": "Unknown Status" + }, + "buttons": { + "copy": "Copy", + "chat": "Chat", + "delete": "Delete", + "confirm_delete": "Delete Token", + "enable": "Enable", + "disable": "Disable", + "edit": "Edit", + "add": "Add New Token", + "refresh": "Refresh" + }, + "edit": { + "title_edit": "Update Token Information", + "title_create": "Create New Token", + "name": "Name", + "name_placeholder": "Please enter name", + "models": "Model Scope", + "models_placeholder": "Please select allowed models, leave empty for no restrictions", + "ip_limit": "IP Restriction", + "ip_limit_placeholder": "Please enter allowed subnets, e.g.: 192.168.0.0/24, use commas to separate multiple subnets", + "expire_time": "Expiry Time", + "expire_time_placeholder": "Please enter expiry time in yyyy-MM-dd HH:mm:ss format, -1 for no limit", + "quota_notice": "Note: Token quota only limits the maximum usage of the token itself, actual usage is subject to account remaining quota.", + "quota": "Quota", + "quota_placeholder": "Please enter quota", + "buttons": { + "never_expire": "Never Expire", + "expire_1_month": "Expire in 1 Month", + "expire_1_day": "Expire in 1 Day", + "expire_1_hour": "Expire in 1 Hour", + "expire_1_minute": "Expire in 1 Minute", + "unlimited_quota": "Set Unlimited Quota", + "cancel_unlimited": "Cancel Unlimited Quota", + "submit": "Submit", + "cancel": "Cancel" + }, + "messages": { + "update_success": "Token updated successfully!", + "create_success": "Token created successfully, please copy it from the list page!", + "expire_time_invalid": "Invalid expiry time format!" + } + }, + "copy_options": { + "raw": "Copy Raw Token", + "ama": "Copy AMA Link", + "opencat": "Copy OpenCat Link", + "next": "Copy NextChat Link", + "lobe": "Copy LobeChat Link" + }, + "messages": { + "copy_success": "Copied to clipboard!", + "copy_failed": "Unable to copy to clipboard, please copy manually. Token has been filled in the search box.", + "operation_success": "Operation completed successfully!" + }, + "sort": { + "placeholder": "Sort By", + "default": "Default Order", + "by_remain": "Sort by Remaining Quota", + "by_used": "Sort by Used Quota" + } + }, + "common": { + "quota": { + "display": "Equivalent: ${{amount}}", + "display_short": "${{amount}}", + "unit": "$" + } } } diff --git a/web/default/public/locales/zh/translation.json b/web/default/public/locales/zh/translation.json index 8b53e005..9cf78f34 100644 --- a/web/default/public/locales/zh/translation.json +++ b/web/default/public/locales/zh/translation.json @@ -152,5 +152,93 @@ "tencent": "按照如下格式输入:AppId|SecretId|SecretKey" } } + }, + "token": { + "title": "令牌管理", + "search": "搜索令牌的名称 ...", + "table": { + "name": "名称", + "status": "状态", + "used_quota": "已用额度", + "remain_quota": "剩余额度", + "created_time": "创建时间", + "expired_time": "过期时间", + "actions": "操作", + "no_name": "无", + "never_expire": "永不过期", + "unlimited": "无限制", + "status_enabled": "已启用", + "status_disabled": "已禁用", + "status_expired": "已过期", + "status_depleted": "已耗尽", + "status_unknown": "未知状态" + }, + "buttons": { + "copy": "复制", + "chat": "聊天", + "delete": "删除", + "confirm_delete": "删除令牌", + "enable": "启用", + "disable": "禁用", + "edit": "编辑", + "add": "添加新的令牌", + "refresh": "刷新" + }, + "edit": { + "title_edit": "更新令牌信息", + "title_create": "创建新的令牌", + "name": "名称", + "name_placeholder": "请输入名称", + "models": "模型范围", + "models_placeholder": "请选择允许使用的模型,留空则不进行限制", + "ip_limit": "IP 限制", + "ip_limit_placeholder": "请输入允许访问的网段,例如:192.168.0.0/24,请使用英文逗号分隔多个网段", + "expire_time": "过期时间", + "expire_time_placeholder": "请输入过期时间,格式为 yyyy-MM-dd HH:mm:ss,-1 表示无限制", + "quota_notice": "注意,令牌的额度仅用于限制令牌本身的最大额度使用量,实际的使用受到账户的剩余额度限制。", + "quota": "额度", + "quota_placeholder": "请输入额度", + "buttons": { + "never_expire": "永不过期", + "expire_1_month": "一个月后过期", + "expire_1_day": "一天后过期", + "expire_1_hour": "一小时后过期", + "expire_1_minute": "一分钟后过期", + "unlimited_quota": "设为无限额度", + "cancel_unlimited": "取消无限额度", + "submit": "提交", + "cancel": "取消" + }, + "messages": { + "update_success": "令牌更新成功!", + "create_success": "令牌创建成功,请在列表页面点击复制获取令牌!", + "expire_time_invalid": "过期时间格式错误!" + } + }, + "copy_options": { + "raw": "复制原始令牌", + "ama": "复制 AMA 链接", + "opencat": "复制 OpenCat 链接", + "next": "复制 NextChat 链接", + "lobe": "复制 LobeChat 链接" + }, + "messages": { + "copy_success": "已复制到剪贴板!", + "copy_failed": "无法复制到剪贴板,请手动复制,已将令牌填入搜索框。", + "operation_success": "操作成功完成!" + }, + "sort": { + "placeholder": "排序方式", + "default": "默认排序", + "by_remain": "按剩余额度排序", + "by_used": "按已用额度排序" + } + }, + "common": { + "quota": { + "display": "等价金额:${{amount}}", + "display_short": "${{amount}}", + "unit": "$" + } } } diff --git a/web/default/src/App.js b/web/default/src/App.js index 3597f71b..0e63a403 100644 --- a/web/default/src/App.js +++ b/web/default/src/App.js @@ -42,32 +42,36 @@ function App() { } }; const loadStatus = async () => { - const res = await API.get('/api/status'); - const { success, data } = res.data; - if (success) { - localStorage.setItem('status', JSON.stringify(data)); - statusDispatch({ type: 'set', payload: data }); - localStorage.setItem('system_name', data.system_name); - localStorage.setItem('logo', data.logo); - localStorage.setItem('footer_html', data.footer_html); - localStorage.setItem('quota_per_unit', data.quota_per_unit); - localStorage.setItem('display_in_currency', data.display_in_currency); - if (data.chat_link) { - localStorage.setItem('chat_link', data.chat_link); + try { + const res = await API.get('/api/status'); + const { success, message, data } = res.data || {}; // Add default empty object + if (success && data) { // Check data exists + localStorage.setItem('status', JSON.stringify(data)); + statusDispatch({ type: 'set', payload: data }); + localStorage.setItem('system_name', data.system_name); + localStorage.setItem('logo', data.logo); + localStorage.setItem('footer_html', data.footer_html); + localStorage.setItem('quota_per_unit', data.quota_per_unit); + localStorage.setItem('display_in_currency', data.display_in_currency); + if (data.chat_link) { + localStorage.setItem('chat_link', data.chat_link); + } else { + localStorage.removeItem('chat_link'); + } + if ( + data.version !== process.env.REACT_APP_VERSION && + data.version !== 'v0.0.0' && + process.env.REACT_APP_VERSION !== '' + ) { + showNotice( + `新版本可用:${data.version},请使用快捷键 Shift + F5 刷新页面` + ); + } } else { - localStorage.removeItem('chat_link'); + showError(message || '无法正常连接至服务器!'); } - if ( - data.version !== process.env.REACT_APP_VERSION && - data.version !== 'v0.0.0' && - process.env.REACT_APP_VERSION !== '' - ) { - showNotice( - `新版本可用:${data.version},请使用快捷键 Shift + F5 刷新页面` - ); - } - } else { - showError('无法正常连接至服务器!'); + } catch (error) { + showError(error.message || '无法正常连接至服务器!'); } }; diff --git a/web/default/src/components/TokensTable.js b/web/default/src/components/TokensTable.js index 4fee9773..401d5fe8 100644 --- a/web/default/src/components/TokensTable.js +++ b/web/default/src/components/TokensTable.js @@ -1,4 +1,5 @@ import React, { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; import { Button, Dropdown, @@ -21,64 +22,63 @@ import { import { ITEMS_PER_PAGE } from '../constants'; import { renderQuota } from '../helpers/render'; -const COPY_OPTIONS = [ - { key: 'next', text: 'ChatGPT Next Web', value: 'next' }, - { key: 'ama', text: 'BotGem', value: 'ama' }, - { key: 'opencat', text: 'OpenCat', value: 'opencat' }, - { key: 'lobechat', text: 'LobeChat', value: 'lobechat' }, -]; - -const OPEN_LINK_OPTIONS = [ - { key: 'next', text: 'ChatGPT Next Web', value: 'next' }, - { key: 'ama', text: 'BotGem', value: 'ama' }, - { key: 'opencat', text: 'OpenCat', value: 'opencat' }, - { key: 'lobechat', text: 'LobeChat', value: 'lobechat' }, -]; - function renderTimestamp(timestamp) { return <>{timestamp2string(timestamp)}; } -function renderStatus(status) { +function renderStatus(status, t) { switch (status) { case 1: return ( ); case 2: return ( ); case 3: return ( ); case 4: return ( ); default: return ( ); } } const TokensTable = () => { + const { t } = useTranslation(); + + const COPY_OPTIONS = [ + { key: 'raw', text: t('token.copy_options.raw'), value: '' }, + { key: 'next', text: t('token.copy_options.next'), value: 'next' }, + { key: 'ama', text: t('token.copy_options.ama'), value: 'ama' }, + { key: 'opencat', text: t('token.copy_options.opencat'), value: 'opencat' }, + { key: 'lobe', text: t('token.copy_options.lobe'), value: 'lobechat' } + ]; + + const OPEN_LINK_OPTIONS = [ + { key: 'next', text: t('token.copy_options.next'), value: 'next' }, + { key: 'ama', text: t('token.copy_options.ama'), value: 'ama' }, + { key: 'opencat', text: t('token.copy_options.opencat'), value: 'opencat' }, + { key: 'lobe', text: t('token.copy_options.lobe'), value: 'lobechat' } + ]; + const [tokens, setTokens] = useState([]); const [loading, setLoading] = useState(true); const [activePage, setActivePage] = useState(1); @@ -135,8 +135,7 @@ const TokensTable = () => { let nextUrl; if (nextLink) { - nextUrl = - nextLink + `/#/?settings={"key":"sk-${key}","url":"${serverAddress}"}`; + nextUrl = nextLink + `/#/?settings={"key":"sk-${key}","url":"${serverAddress}"}`; } else { nextUrl = `https://app.nextchat.dev/#/?settings={"key":"sk-${key}","url":"${serverAddress}"}`; } @@ -153,17 +152,15 @@ const TokensTable = () => { url = nextUrl; break; 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; default: url = `sk-${key}`; } if (await copy(url)) { - showSuccess('已复制到剪贴板!'); + showSuccess(t('token.messages.copy_success')); } else { - showWarning('无法复制到剪贴板,请手动复制,已将令牌填入搜索框。'); + showWarning(t('token.messages.copy_failed')); setSearchKeyword(url); } }; @@ -237,7 +234,7 @@ const TokensTable = () => { } const { success, message } = res.data; if (success) { - showSuccess('操作成功完成!'); + showSuccess(t('token.messages.operation_success')); let token = res.data.data; let newTokens = [...tokens]; let realIdx = (activePage - 1) * ITEMS_PER_PAGE + idx; @@ -308,7 +305,7 @@ const TokensTable = () => { icon='search' fluid iconPosition='left' - placeholder='搜索令牌的名称 ...' + placeholder={t('token.search')} value={searchKeyword} loading={searching} onChange={handleKeywordChange} @@ -324,7 +321,7 @@ const TokensTable = () => { sortToken('name'); }} > - 名称 + {t('token.table.name')} { sortToken('status'); }} > - 状态 + {t('token.table.status')} { sortToken('used_quota'); }} > - 已用额度 + {t('token.table.used_quota')} { sortToken('remain_quota'); }} > - 剩余额度 + {t('token.table.remain_quota')} { sortToken('created_time'); }} > - 创建时间 + {t('token.table.created_time')} { sortToken('expired_time'); }} > - 过期时间 + {t('token.table.expired_time')} - 操作 + {t('token.table.actions')} @@ -378,20 +375,37 @@ const TokensTable = () => { ) .map((token, idx) => { if (token.deleted) return <>; + + const copyOptionsWithHandlers = COPY_OPTIONS.map(option => ({ + ...option, + onClick: async () => { + await onCopy(option.value, token.key); + } + })); + + const openLinkOptionsWithHandlers = OPEN_LINK_OPTIONS.map(option => ({ + ...option, + onClick: async () => { + await onOpenLink(option.value, token.key); + } + })); + return ( - {token.name ? token.name : '无'} - {renderStatus(token.status)} - {renderQuota(token.used_quota)} + + {token.name ? token.name : t('token.table.no_name')} + + {renderStatus(token.status, t)} + {renderQuota(token.used_quota, t)} {token.unlimited_quota - ? '无限制' - : renderQuota(token.remain_quota, 2)} + ? t('token.table.unlimited') + : renderQuota(token.remain_quota, t, 2)} {renderTimestamp(token.created_time)} {token.expired_time === -1 - ? '永不过期' + ? t('token.table.never_expire') : renderTimestamp(token.expired_time)} @@ -400,21 +414,14 @@ const TokensTable = () => { ({ - ...option, - onClick: async () => { - await onCopy(option.value, token.key); - }, - }))} + options={copyOptionsWithHandlers} trigger={<>} /> {' '} @@ -422,28 +429,21 @@ const TokensTable = () => { ({ - ...option, - onClick: async () => { - await onOpenLink(option.value, token.key); - }, - }))} + options={openLinkOptionsWithHandlers} trigger={<>} /> {' '} - 删除 + {t('token.buttons.delete')} } on='click' @@ -456,7 +456,7 @@ const TokensTable = () => { manageToken(token.id, 'delete', idx); }} > - 删除令牌 {token.name} + {t('token.buttons.confirm_delete')} {token.name} - @@ -489,24 +487,24 @@ const TokensTable = () => { limit) { @@ -39,23 +40,27 @@ export function renderNumber(num) { } } -export function renderQuota(quota, digits = 2) { - let quotaPerUnit = localStorage.getItem('quota_per_unit'); - let displayInCurrency = localStorage.getItem('display_in_currency'); - quotaPerUnit = parseFloat(quotaPerUnit); - displayInCurrency = displayInCurrency === 'true'; +export function renderQuota(quota, t, precision = 2) { + const displayInCurrency = localStorage.getItem('display_in_currency') === 'true'; + const quotaPerUnit = parseFloat(localStorage.getItem('quota_per_unit') || '1'); + if (displayInCurrency) { - return '$' + (quota / quotaPerUnit).toFixed(digits); + const amount = (quota / quotaPerUnit).toFixed(precision); + return t('common.quota.display_short', { amount }); } + return renderNumber(quota); } -export function renderQuotaWithPrompt(quota, digits) { - let displayInCurrency = localStorage.getItem('display_in_currency'); - displayInCurrency = displayInCurrency === 'true'; +export function renderQuotaWithPrompt(quota, t) { + const displayInCurrency = localStorage.getItem('display_in_currency') === 'true'; + const quotaPerUnit = parseFloat(localStorage.getItem('quota_per_unit') || '1'); + if (displayInCurrency) { - return `(等价金额:${renderQuota(quota, digits)})`; + const amount = (quota / quotaPerUnit).toFixed(2); + return ` (${t('common.quota.display', { amount })})`; } + return ''; } diff --git a/web/default/src/pages/Token/EditToken.js b/web/default/src/pages/Token/EditToken.js index 3e7517f8..28ae1e59 100644 --- a/web/default/src/pages/Token/EditToken.js +++ b/web/default/src/pages/Token/EditToken.js @@ -1,4 +1,5 @@ import React, { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; import { Button, Form, @@ -18,6 +19,7 @@ import { import { renderQuotaWithPrompt } from '../../helpers/render'; const EditToken = () => { + const { t } = useTranslation(); const params = useParams(); const tokenId = params.id; const isEdit = tokenId !== undefined; @@ -60,47 +62,61 @@ const EditToken = () => { }; const loadToken = async () => { - let res = await API.get(`/api/token/${tokenId}`); - const { success, message, data } = res.data; - if (success) { - if (data.expired_time !== -1) { - data.expired_time = timestamp2string(data.expired_time); - } - if (data.models === '') { - data.models = []; + try { + let res = await API.get(`/api/token/${tokenId}`); + const { success, message, data } = res.data || {}; + if (success && data) { + if (data.expired_time !== -1) { + data.expired_time = timestamp2string(data.expired_time); + } + if (data.models === '') { + data.models = []; + } else { + data.models = data.models.split(','); + } + setInputs(data); } else { - data.models = data.models.split(','); + showError(message || 'Failed to load token'); } - setInputs(data); - } else { - showError(message); + } catch (error) { + showError(error.message || 'Network error'); } setLoading(false); }; - useEffect(() => { - if (isEdit) { - loadToken().then(); - } - loadAvailableModels().then(); - }, []); const loadAvailableModels = async () => { - let res = await API.get(`/api/user/available_models`); - const { success, message, data } = res.data; - if (success) { - let options = data.map((model) => { - return { - key: model, - text: model, - value: model, - }; - }); - setModelOptions(options); - } else { - showError(message); + try { + let res = await API.get(`/api/user/available_models`); + const { success, message, data } = res.data || {}; + if (success && data) { + let options = data.map((model) => { + return { + key: model, + text: model, + value: model, + }; + }); + setModelOptions(options); + } else { + showError(message || 'Failed to load models'); + } + } catch (error) { + showError(error.message || 'Network error'); } }; + useEffect(() => { + if (isEdit) { + loadToken().catch(error => { + showError(error.message || 'Failed to load token'); + setLoading(false); + }); + } + loadAvailableModels().catch(error => { + showError(error.message || 'Failed to load models'); + }); + }, []); + const submit = async () => { if (!isEdit && inputs.name === '') return; let localInputs = inputs; @@ -108,7 +124,7 @@ const EditToken = () => { if (localInputs.expired_time !== -1) { let time = Date.parse(localInputs.expired_time); if (isNaN(time)) { - showError('过期时间格式错误!'); + showError(t('token.edit.messages.expire_time_invalid')); return; } localInputs.expired_time = Math.ceil(time / 1000); @@ -126,9 +142,9 @@ const EditToken = () => { const { success, message } = res.data; if (success) { if (isEdit) { - showSuccess('令牌更新成功!'); + showSuccess(t('token.edit.messages.update_success')); } else { - showSuccess('令牌创建成功,请在列表页面点击复制获取令牌!'); + showSuccess(t('token.edit.messages.create_success')); setInputs(originInputs); } } else { @@ -141,14 +157,14 @@ const EditToken = () => { - {isEdit ? '更新令牌信息' : '创建新的令牌'} + {isEdit ? t('token.edit.title_edit') : t('token.edit.title_create')}
{ { { { setExpiredTime(0, 0, 0, 0); }} > - 永不过期 + {t('token.edit.buttons.never_expire')} - - 注意,令牌的额度仅用于限制令牌本身的最大额度使用量,实际的使用受到账户的剩余额度限制。 - + {t('token.edit.quota_notice')} { setUnlimitedQuota(); }} > - {unlimited_quota ? '取消无限额度' : '设为无限额度'} + {unlimited_quota + ? t('token.edit.buttons.cancel_unlimited') + : t('token.edit.buttons.unlimited_quota')}
diff --git a/web/default/src/pages/Token/index.js b/web/default/src/pages/Token/index.js index 8745e0af..19ef58e8 100644 --- a/web/default/src/pages/Token/index.js +++ b/web/default/src/pages/Token/index.js @@ -1,16 +1,21 @@ import React from 'react'; import { Card } from 'semantic-ui-react'; import TokensTable from '../../components/TokensTable'; +import { useTranslation } from 'react-i18next'; -const Token = () => ( -
- - - 令牌管理 - - - -
-); +const Token = () => { + const { t } = useTranslation(); + + return ( +
+ + + {t('token.title')} + + + +
+ ); +}; export default Token;