Merge branch 'upstream/main'

This commit is contained in:
Laisky.Cai
2025-02-03 12:25:00 +00:00
70 changed files with 4032 additions and 2781 deletions

View File

@@ -7,8 +7,8 @@
"history": "^5.3.0",
"i18next": "23.2.3",
"i18next-browser-languagedetector": "^8.0.2",
"i18next-http-backend": "^3.0.2",
"marked": "^4.1.1",
"moment": "^2.30.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-dropzone": "^14.2.3",

View File

@@ -1,156 +0,0 @@
{
"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!"
}
},
"channel": {
"title": "Channel Management",
"search": "Search channels by ID, name and key...",
"balance_notice": "OpenAI channels no longer support getting balance via key, so balance shows as 0. For supported channel types, click balance to refresh.",
"test_notice": "Channel testing only supports chat models, preferring gpt-3.5-turbo. If unavailable, uses the first model in your configured list.",
"detail_notice": "Click the detail button below to show balance and set additional test models.",
"table": {
"id": "ID",
"name": "Name",
"group": "Group",
"type": "Type",
"status": "Status",
"response_time": "Response Time",
"balance": "Balance",
"priority": "Priority",
"test_model": "Test Model",
"actions": "Actions",
"no_name": "None",
"status_enabled": "Enabled",
"status_disabled": "Disabled",
"status_auto_disabled": "Disabled",
"status_disabled_tip": "This channel is manually disabled",
"status_auto_disabled_tip": "This channel is automatically disabled",
"status_unknown": "Unknown Status",
"not_tested": "Not Tested",
"priority_tip": "Channel selection priority, higher is preferred",
"select_test_model": "Please select test model",
"click_to_update": "Click to update"
},
"buttons": {
"test": "Test",
"delete": "Delete",
"confirm_delete": "Delete Channel",
"enable": "Enable",
"disable": "Disable",
"edit": "Edit",
"add": "Add New Channel",
"test_all": "Test All Channels",
"test_disabled": "Test Disabled Channels",
"delete_disabled": "Delete Disabled Channels",
"confirm_delete_disabled": "Confirm Delete",
"refresh": "Refresh",
"show_detail": "Details",
"hide_detail": "Hide Details"
},
"messages": {
"test_success": "Channel ${name} test successful, model ${model}, time ${time}s, output: ${message}",
"test_all_started": "Channel testing started successfully, please refresh page to see results.",
"delete_disabled_success": "Deleted all disabled channels, total: ${count}",
"balance_update_success": "Channel ${name} balance updated successfully!",
"all_balance_updated": "All enabled channel balances have been updated!"
},
"edit": {
"title_edit": "Update Channel Information",
"title_create": "Create New Channel",
"type": "Type",
"name": "Name",
"name_placeholder": "Please enter name",
"group": "Group",
"group_placeholder": "Please select groups that can use this channel",
"group_addition": "Please edit group multipliers in system settings to add new group:",
"models": "Models",
"models_placeholder": "Please select models supported by this channel",
"model_mapping": "Model Mapping",
"model_mapping_placeholder": "Optional, used to modify model names in request body. A JSON string where keys are request model names and values are target model names",
"system_prompt": "System Prompt",
"system_prompt_placeholder": "Optional, used to force set system prompt. Use with custom model & model mapping. First create a unique custom model name above, then map it to a natively supported model",
"base_url": "Proxy",
"base_url_placeholder": "Optional, used for API calls through proxy. Enter proxy address in format: https://domain.com",
"key": "Key",
"key_placeholder": "Please enter key",
"batch": "Batch Create",
"batch_placeholder": "Please enter keys, one per line",
"buttons": {
"cancel": "Cancel",
"submit": "Submit",
"fill_models": "Fill Related Models",
"fill_all": "Fill All Models",
"clear": "Clear All Models",
"add_custom": "Add",
"custom_placeholder": "Enter custom model name"
},
"messages": {
"name_required": "Please enter channel name and key!",
"models_required": "Please select at least one model!",
"model_mapping_invalid": "Model mapping must be valid JSON format!",
"update_success": "Channel updated successfully!",
"create_success": "Channel created successfully!"
},
"spark_version": "Model Version",
"spark_version_placeholder": "Please enter Spark model version from API URL, e.g.: v2.1",
"knowledge_id": "Knowledge Base ID",
"knowledge_id_placeholder": "Please enter knowledge base ID, e.g.: 123456",
"plugin_param": "Plugin Parameter",
"plugin_param_placeholder": "Please enter plugin parameter (X-DashScope-Plugin header value)",
"coze_notice": "For Coze, model name is the Bot ID. You can add prefix `bot-`, e.g.: `bot-123456`.",
"douban_notice": "For Douban, you need to go to",
"douban_notice_link": "Model Inference Page",
"douban_notice_2": "to create an inference endpoint, and use the endpoint name as model name, e.g.: `ep-20240608051426-tkxvl`.",
"aws_region_placeholder": "region, e.g.: us-west-2",
"aws_ak_placeholder": "AWS IAM Access Key",
"aws_sk_placeholder": "AWS IAM Secret Key",
"vertex_region_placeholder": "Vertex AI Region, e.g.: us-east5",
"vertex_project_id": "Vertex AI Project ID",
"vertex_project_id_placeholder": "Vertex AI Project ID",
"vertex_credentials": "Google Cloud Application Default Credentials JSON",
"vertex_credentials_placeholder": "Google Cloud Application Default Credentials JSON",
"user_id": "User ID",
"user_id_placeholder": "User ID who generated this key",
"key_prompts": {
"default": "Please enter the authentication key for this channel",
"zhipu": "Enter in format: APIKey|SecretKey",
"spark": "Enter in format: APPID|APISecret|APIKey",
"fastgpt": "Enter in format: APIKey-AppId, e.g.: fastgpt-0sp2gtvfdgyi4k30jwlgwf1i-64f335d84283f05518e9e041",
"tencent": "Enter in format: AppId|SecretId|SecretKey"
}
}
}
}

View File

@@ -1,156 +0,0 @@
{
"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": "超级管理员未设置充值链接!"
}
},
"channel": {
"title": "管理渠道",
"search": "搜索渠道的 ID名称和密钥 ...",
"balance_notice": "OpenAI 渠道已经不再支持通过 key 获取余额,因此余额显示为 0。对于支持的渠道类型请点击余额进行刷新。",
"test_notice": "渠道测试仅支持 chat 模型,优先使用 gpt-3.5-turbo如果该模型不可用则使用你所配置的模型列表中的第一个模型。",
"detail_notice": "点击下方详情按钮可以显示余额以及设置额外的测试模型。",
"table": {
"id": "ID",
"name": "名称",
"group": "分组",
"type": "类型",
"status": "状态",
"response_time": "响应时间",
"balance": "余额",
"priority": "优先级",
"test_model": "测试模型",
"actions": "操作",
"no_name": "无",
"status_enabled": "已启用",
"status_disabled": "已禁用",
"status_auto_disabled": "已禁用",
"status_disabled_tip": "本渠道被手动禁用",
"status_auto_disabled_tip": "本渠道被程序自动禁用",
"status_unknown": "未知状态",
"not_tested": "未测试",
"priority_tip": "渠道选择优先级,越高越优先",
"select_test_model": "请选择测试模型",
"click_to_update": "点击更新"
},
"buttons": {
"test": "测试",
"delete": "删除",
"confirm_delete": "删除渠道",
"enable": "启用",
"disable": "禁用",
"edit": "编辑",
"add": "添加新的渠道",
"test_all": "测试所有渠道",
"test_disabled": "测试禁用渠道",
"delete_disabled": "删除禁用渠道",
"confirm_delete_disabled": "确认删除",
"refresh": "刷新",
"show_detail": "详情",
"hide_detail": "隐藏详情"
},
"messages": {
"test_success": "渠道 ${name} 测试成功,模型 ${model},耗时 ${time} 秒,模型输出:${message}",
"test_all_started": "已成功开始测试渠道,请刷新页面查看结果。",
"delete_disabled_success": "已删除所有禁用渠道,共计 ${count} 个",
"balance_update_success": "渠道 ${name} 余额更新成功!",
"all_balance_updated": "已更新完毕所有已启用渠道余额!"
},
"edit": {
"title_edit": "更新渠道信息",
"title_create": "创建新的渠道",
"type": "类型",
"name": "名称",
"name_placeholder": "请输入名称",
"group": "分组",
"group_placeholder": "请选择可以使用该渠道的分组",
"group_addition": "请在系统设置页面编辑分组倍率以添加新的分组:",
"models": "模型",
"models_placeholder": "请选择该渠道所支持的模型",
"model_mapping": "模型重定向",
"model_mapping_placeholder": "此项可选,用于修改请求体中的模型名称,为一个 JSON 字符串,键为请求中模型名称,值为要替换的模型名称",
"system_prompt": "系统提示词",
"system_prompt_placeholder": "此项可选,用于强制设置给定的系统提示词,请配合自定义模型 & 模型重定向使用,首先创建一个唯一的自定义模型名称并在上面填入,之后将该自定义模型重定向映射到该渠道一个原生支持的模型",
"base_url": "代理",
"base_url_placeholder": "此项可选,用于通过代理站来进行 API 调用请输入代理站地址格式为https://domain.com",
"key": "密钥",
"key_placeholder": "请输入密钥",
"batch": "批量创建",
"batch_placeholder": "请输入密钥,一行一个",
"buttons": {
"cancel": "取消",
"submit": "提交",
"fill_models": "填入相关模型",
"fill_all": "填入所有模型",
"clear": "清除所有模型",
"add_custom": "填入",
"custom_placeholder": "输入自定义模型名称"
},
"messages": {
"name_required": "请填写渠道名称和渠道密钥!",
"models_required": "请至少选择一个模型!",
"model_mapping_invalid": "模型映射必须是合法的 JSON 格式!",
"update_success": "渠道更新成功!",
"create_success": "渠道创建成功!"
},
"spark_version": "模型版本",
"spark_version_placeholder": "请输入星火大模型版本注意是接口地址中的版本号例如v2.1",
"knowledge_id": "知识库 ID",
"knowledge_id_placeholder": "请输入知识库 ID例如123456",
"plugin_param": "插件参数",
"plugin_param_placeholder": "请输入插件参数,即 X-DashScope-Plugin 请求头的取值",
"coze_notice": "对于 Coze 而言,模型名称即 Bot ID你可以添加一个前缀 `bot-`,例如:`bot-123456`。",
"douban_notice": "对于豆包而言,需要手动去",
"douban_notice_link": "模型推理页面",
"douban_notice_2": "创建推理接入点,以接入点名称作为模型名称,例如:`ep-20240608051426-tkxvl`。",
"aws_region_placeholder": "region例如us-west-2",
"aws_ak_placeholder": "AWS IAM Access Key",
"aws_sk_placeholder": "AWS IAM Secret Key",
"vertex_region_placeholder": "Vertex AI Region例如us-east5",
"vertex_project_id": "Vertex AI Project ID",
"vertex_project_id_placeholder": "Vertex AI Project ID",
"vertex_credentials": "Google Cloud Application Default Credentials JSON",
"vertex_credentials_placeholder": "Google Cloud Application Default Credentials JSON",
"user_id": "User ID",
"user_id_placeholder": "生成该密钥的用户 ID",
"key_prompts": {
"default": "请输入渠道对应的鉴权密钥",
"zhipu": "按照如下格式输入APIKey|SecretKey",
"spark": "按照如下格式输入APPID|APISecret|APIKey",
"fastgpt": "按照如下格式输入APIKey-AppId例如fastgpt-0sp2gtvfdgyi4k30jwlgwf1i-64f335d84283f05518e9e041",
"tencent": "按照如下格式输入AppId|SecretId|SecretKey"
}
}
}
}

View File

@@ -42,32 +42,37 @@ 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(
`New version available: ${data.version}, please refresh the page using Shift + F5`
);
}
} else {
localStorage.removeItem('chat_link');
showError(message || 'Unable to connect to the server properly!');
}
if (
data.version !== process.env.REACT_APP_VERSION &&
data.version !== 'v0.0.0' &&
process.env.REACT_APP_VERSION !== ''
) {
showNotice(
`New version available: ${data.version}, please refresh the page using the shortcut key Shift + F5`
);
}
} else {
showError('Unable to connect to the server normally!');
} catch (error) {
showError(error.message || 'Unable to connect to the server properly!');
}
};

View File

@@ -198,7 +198,7 @@ const ChannelsTable = () => {
}
const { success, message } = res.data;
if (success) {
showSuccess('Operation successfully completed!');
showSuccess(t('channel.messages.operation_success'));
let channel = res.data.data;
let newChannels = [...channels];
let realIdx = (activePage - 1) * ITEMS_PER_PAGE + idx;
@@ -325,13 +325,8 @@ const ChannelsTable = () => {
newChannels[realIdx].response_time = time * 1000;
newChannels[realIdx].test_time = Date.now() / 1000;
setChannels(newChannels);
showInfo(
t('channel.messages.test_success', {
name: name,
model: model,
time: time.toFixed(2),
message: message,
})
showSuccess(
t('channel.messages.test_success', { name, model, time, message })
);
} else {
showError(message);
@@ -375,7 +370,7 @@ const ChannelsTable = () => {
newChannels[realIdx].balance = balance;
newChannels[realIdx].balance_updated_time = Date.now() / 1000;
setChannels(newChannels);
showInfo(t('channel.messages.balance_update_success', { name: name }));
showSuccess(t('channel.messages.balance_update_success', { name }));
} else {
showError(message);
}
@@ -594,13 +589,13 @@ const ChannelsTable = () => {
defaultValue={channel.test_model}
onChange={(event, data) => {
switchTestModel(idx, data.value);
}}
/>
</Table.Cell>
<Table.Cell>
<div>
<Button
size={'small'}
}}
/>
</Table.Cell>
<Table.Cell>
<div>
<Button
size={'tiny'}
positive
onClick={() => {
testChannel(
@@ -615,7 +610,7 @@ const ChannelsTable = () => {
</Button>
<Popup
trigger={
<Button size='small' negative>
<Button size='tiny' negative>
{t('channel.buttons.delete')}
</Button>
}
@@ -624,6 +619,7 @@ const ChannelsTable = () => {
hoverable
>
<Button
size={'tiny'}
negative
onClick={() => {
manageChannel(channel.id, 'delete', idx);
@@ -633,7 +629,7 @@ const ChannelsTable = () => {
</Button>
</Popup>
<Button
size={'small'}
size={'tiny'}
onClick={() => {
manageChannel(
channel.id,
@@ -647,7 +643,7 @@ const ChannelsTable = () => {
: t('channel.buttons.enable')}
</Button>
<Button
size={'small'}
size={'tiny'}
as={Link}
to={'/channel/edit/' + channel.id}
>
@@ -663,16 +659,11 @@ const ChannelsTable = () => {
<Table.Footer>
<Table.Row>
<Table.HeaderCell colSpan={showDetail ? '10' : '8'}>
<Button
size='small'
as={Link}
to='/channel/add'
loading={loading}
>
<Button size='tiny' as={Link} to='/channel/add' loading={loading}>
{t('channel.buttons.add')}
</Button>
<Button
size='small'
size='tiny'
loading={loading}
onClick={() => {
testChannels('all');
@@ -681,7 +672,7 @@ const ChannelsTable = () => {
{t('channel.buttons.test_all')}
</Button>
<Button
size='small'
size='tiny'
loading={loading}
onClick={() => {
testChannels('disabled');
@@ -691,7 +682,7 @@ const ChannelsTable = () => {
</Button>
<Popup
trigger={
<Button size='small' loading={loading}>
<Button size='tiny' loading={loading}>
{t('channel.buttons.delete_disabled')}
</Button>
}
@@ -700,7 +691,7 @@ const ChannelsTable = () => {
hoverable
>
<Button
size='small'
size='tiny'
loading={loading}
negative
onClick={deleteAllDisabledChannels}
@@ -712,17 +703,17 @@ const ChannelsTable = () => {
floated='right'
activePage={activePage}
onPageChange={onPaginationChange}
size='small'
size='tiny'
siblingRange={1}
totalPages={
Math.ceil(channels.length / ITEMS_PER_PAGE) +
(channels.length % ITEMS_PER_PAGE === 0 ? 1 : 0)
}
/>
<Button size='small' onClick={refresh} loading={loading}>
<Button size='tiny' onClick={refresh} loading={loading}>
{t('channel.buttons.refresh')}
</Button>
<Button size='small' onClick={toggleShowDetail}>
<Button size='tiny' onClick={toggleShowDetail}>
{showDetail
? t('channel.buttons.hide_detail')
: t('channel.buttons.show_detail')}

View File

@@ -1,9 +1,10 @@
import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Container, Segment } from 'semantic-ui-react';
import { getFooterHTML, getSystemName } from '../helpers';
const Footer = () => {
const { t } = useTranslation();
const systemName = getSystemName();
const [footer, setFooter] = useState(getFooterHTML());
let remainCheckTimes = 5;
@@ -40,6 +41,14 @@ const Footer = () => {
<a href='https://github.com/Laisky/one-api' target='_blank'>
{systemName} {process.env.REACT_APP_VERSION}{' '}
</a>
{t('footer.built_by')}{' '}
<a href='https://github.com/Laisky/one-api' target='_blank'>
{t('footer.built_by_name')}
</a>{' '}
{t('footer.license')}{' '}
<a href='https://opensource.org/licenses/mit-license.php'>
{t('footer.mit')}
</a>
</div>
)}
</Container>

View File

@@ -23,11 +23,6 @@ import '../index.css';
// Header Buttons
let headerButtons = [
{
name: 'header.home',
to: '/',
icon: 'home',
},
{
name: 'header.channel',
to: '/channel',
@@ -170,7 +165,13 @@ const Header = () => {
: { borderTop: 'none', height: '52px' }
}
>
<Container>
<Container
style={{
width: '100%',
maxWidth: isMobile() ? '100%' : '1200px',
padding: isMobile() ? '0 10px' : '0 20px',
}}
>
<Menu.Item as={Link} to='/'>
<img src={logo} alt='logo' style={{ marginRight: '0.75em' }} />
<div style={{ fontSize: '20px' }}>
@@ -191,6 +192,12 @@ const Header = () => {
<Menu.Item>
<Dropdown
selection
trigger={
<Icon
name='language'
style={{ margin: 0, fontSize: '18px' }}
/>
}
options={languageOptions}
value={i18n.language}
onChange={(_, { value }) => changeLanguage(value)}
@@ -241,7 +248,13 @@ const Header = () => {
border: 'none',
}}
>
<Container>
<Container
style={{
width: '100%',
maxWidth: isMobile() ? '100%' : '1200px',
padding: isMobile() ? '0 10px' : '0 20px',
}}
>
<Menu.Item as={Link} to='/' className={'hide-on-mobile'}>
<img src={logo} alt='logo' style={{ marginRight: '0.75em' }} />
<div
@@ -258,13 +271,17 @@ const Header = () => {
<Menu.Menu position='right'>
<Dropdown
item
trigger={
<Icon name='language' style={{ margin: 0, fontSize: '18px' }} />
}
options={languageOptions}
value={i18n.language}
onChange={(_, { value }) => changeLanguage(value)}
style={{
fontSize: '15px',
fontSize: '16px',
fontWeight: '400',
color: '#666',
padding: '0 10px',
}}
/>
{userState.user ? (

View File

@@ -12,12 +12,14 @@ import {
Card,
} from 'semantic-ui-react';
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { UserContext } from '../context/User';
import { API, getLogo, showError, showSuccess, showWarning } from '../helpers';
import { onGitHubOAuthClicked, onLarkOAuthClicked } from './utils';
import larkIcon from '../images/lark.svg';
const LoginForm = () => {
const { t } = useTranslation();
const [inputs, setInputs] = useState({
username: '',
password: '',
@@ -33,7 +35,7 @@ const LoginForm = () => {
useEffect(() => {
if (searchParams.get('expired')) {
showError('Not logged in or login has expired, please log in again!');
showError(t('messages.error.login_expired'));
}
let status = localStorage.getItem('status');
if (status) {
@@ -57,7 +59,7 @@ const LoginForm = () => {
userDispatch({ type: 'login', payload: data });
localStorage.setItem('user', JSON.stringify(data));
navigate('/');
showSuccess('Login successful!');
showSuccess(t('messages.success.login'));
setShowWeChatLoginModal(false);
} else {
showError(message);
@@ -82,11 +84,11 @@ const LoginForm = () => {
localStorage.setItem('user', JSON.stringify(data));
if (username === 'root' && password === '123456') {
navigate('/user/edit');
showSuccess('Login successful!');
showWarning('Please change the default password immediately!');
showSuccess(t('messages.success.login'));
showWarning(t('messages.error.root_password'));
} else {
navigate('/token');
showSuccess('Login successful!');
showSuccess(t('messages.success.login'));
}
} else {
showError(message);
@@ -110,7 +112,7 @@ const LoginForm = () => {
style={{ marginBottom: '1.5em' }}
>
<Image src={logo} style={{ marginBottom: '10px' }} />
<Header.Content>User Login</Header.Content>
<Header.Content>{t('auth.login.title')}</Header.Content>
</Header>
</Card.Header>
<Form size='large'>
@@ -118,7 +120,7 @@ const LoginForm = () => {
fluid
icon='user'
iconPosition='left'
placeholder='Username / Email Address'
placeholder={t('auth.login.username')}
name='username'
value={username}
onChange={handleChange}
@@ -128,7 +130,7 @@ const LoginForm = () => {
fluid
icon='lock'
iconPosition='left'
placeholder='Password'
placeholder={t('auth.login.password')}
name='password'
type='password'
value={password}
@@ -145,7 +147,7 @@ const LoginForm = () => {
}}
onClick={handleSubmit}
>
Log In
{t('auth.login.button')}
</Button>
</Form>
@@ -160,15 +162,21 @@ const LoginForm = () => {
}}
>
<div>
Forgot password?
<Link to='/reset' style={{ color: '#2185d0' }}>
Click to reset
{t('auth.login.forgot_password')}
<Link
to='/reset'
style={{ color: '#2185d0', marginLeft: '2px' }}
>
{t('auth.login.reset_password')}
</Link>
</div>
<div>
No account?
<Link to='/register' style={{ color: '#2185d0' }}>
Click to register
{t('auth.login.no_account')}
<Link
to='/register'
style={{ color: '#2185d0', marginLeft: '2px' }}
>
{t('auth.login.register')}
</Link>
</div>
</div>
@@ -182,7 +190,7 @@ const LoginForm = () => {
horizontal
style={{ color: '#666', fontSize: '0.9em' }}
>
Log in with other methods
{t('auth.login.other_methods')}
</Divider>
<div
style={{
@@ -250,14 +258,12 @@ const LoginForm = () => {
<Modal.Description>
<Image src={status.wechat_qrcode} fluid />
<div style={{ textAlign: 'center' }}>
<p>
Scan the QR code with WeChat, follow the official account and enter 'verification code' to get the verification code (valid within three minutes)
</p>
<p>{t('auth.login.wechat.scan_tip')}</p>
</div>
<Form size='large'>
<Form.Input
fluid
placeholder='Verification code'
placeholder={t('auth.login.wechat.code_placeholder')}
name='wechat_verification_code'
value={inputs.wechat_verification_code}
onChange={handleChange}
@@ -266,13 +272,13 @@ const LoginForm = () => {
fluid
size='large'
style={{
background: '#2F73FF', // Use a more modern blue
background: '#2F73FF',
color: 'white',
marginBottom: '1.5em',
}}
onClick={onSubmitWeChatVerificationCode}
>
Log in
{t('auth.login.button')}
</Button>
</Form>
</Modal.Description>

View File

@@ -8,6 +8,7 @@ import {
Segment,
Select,
Table,
Popup,
} from 'semantic-ui-react';
import {
API,
@@ -18,6 +19,7 @@ import {
showWarning,
timestamp2string,
} from '../helpers';
import { useTranslation } from 'react-i18next';
import { ITEMS_PER_PAGE } from '../constants';
import { renderColorLabel, renderQuota } from '../helpers/render';
@@ -45,15 +47,6 @@ const MODE_OPTIONS = [
{ key: 'self', text: 'Current User', value: 'self' },
];
const LOG_OPTIONS = [
{ key: '0', text: 'All', value: 0 },
{ key: '1', text: 'Recharge', value: 1 },
{ key: '2', text: 'Consumption', value: 2 },
{ key: '3', text: 'Management', value: 3 },
{ key: '4', text: 'System', value: 4 },
{ key: '5', text: 'Test', value: 5 },
];
function renderType(type) {
switch (type) {
case 1:
@@ -137,6 +130,7 @@ function renderDetail(log) {
}
const LogsTable = () => {
const { t } = useTranslation();
const [logs, setLogs] = useState([]);
const [showStat, setShowStat] = useState(false);
const [loading, setLoading] = useState(true);
@@ -168,6 +162,15 @@ const LogsTable = () => {
token: 0,
});
const LOG_OPTIONS = [
{ key: '0', text: t('log.type.all'), value: 0 },
{ key: '1', text: t('log.type.topup'), value: 1 },
{ key: '2', text: t('log.type.usage'), value: 2 },
{ key: '3', text: t('log.type.admin'), value: 3 },
{ key: '4', text: t('log.type.system'), value: 4 },
{ key: '5', text: t('log.type.test'), value: 5 },
];
const handleInputChange = (e, { name, value }) => {
setInputs((inputs) => ({ ...inputs, [name]: value }));
};
@@ -307,296 +310,302 @@ const LogsTable = () => {
return (
<>
<>
<Header as='h3'>
Usages (Total consumption limit:
{showStat && renderQuota(stat.quota)}
{!showStat && (
<span
onClick={handleEyeClick}
style={{ cursor: 'pointer', color: 'gray' }}
>
Click to view
</span>
)}
)
</Header>
<Form>
<Form.Group>
<Form.Input
fluid
label={'Token Name'}
width={3}
value={token_name}
placeholder={'Optional'}
name='token_name'
onChange={handleInputChange}
/>
<Form.Input
fluid
label='Model Name'
width={3}
value={model_name}
placeholder='Optional'
name='model_name'
onChange={handleInputChange}
/>
<Form.Input
fluid
label='Start Time'
width={4}
value={start_timestamp}
type='datetime-local'
name='start_timestamp'
onChange={handleInputChange}
/>
<Form.Input
fluid
label='End Time'
width={4}
value={end_timestamp}
type='datetime-local'
name='end_timestamp'
onChange={handleInputChange}
/>
<Form.Button fluid label='Action' width={2} onClick={refresh}>
Search
</Form.Button>
</Form.Group>
{isAdminUser && (
<>
<Form.Group>
<Form.Input
fluid
label={'Channel ID'}
width={3}
value={channel}
placeholder='Optional'
name='channel'
onChange={handleInputChange}
/>
<Form.Input
fluid
label={'Username'}
width={3}
value={username}
placeholder={'Optional'}
name='username'
onChange={handleInputChange}
/>
</Form.Group>
</>
)}
</Form>
<Table basic={'very'} compact size='small'>
<Table.Header>
<Table.Row>
<Table.HeaderCell
style={{ cursor: 'pointer' }}
onClick={() => {
sortLog('created_time');
}}
<Header as='h3'>
{t('log.usage_details')}{t('log.total_quota')}
{showStat && renderQuota(stat.quota, t)}
{!showStat && (
<span
onClick={handleEyeClick}
style={{ cursor: 'pointer', color: 'gray' }}
>
{t('log.click_to_view')}
</span>
)}
</Header>
<Form>
<Form.Group>
<Form.Input
fluid
label={t('log.table.token_name')}
size={'small'}
width={3}
value={token_name}
placeholder={t('log.table.token_name_placeholder')}
name='token_name'
onChange={handleInputChange}
/>
<Form.Input
fluid
label={t('log.table.model_name')}
size={'small'}
width={3}
value={model_name}
placeholder={t('log.table.model_name_placeholder')}
name='model_name'
onChange={handleInputChange}
/>
<Form.Input
fluid
label={t('log.table.start_time')}
size={'small'}
width={4}
value={start_timestamp}
type='datetime-local'
name='start_timestamp'
onChange={handleInputChange}
/>
<Form.Input
fluid
label={t('log.table.end_time')}
size={'small'}
width={4}
value={end_timestamp}
type='datetime-local'
name='end_timestamp'
onChange={handleInputChange}
/>
<Form.Button
fluid
label={t('log.buttons.query')}
size={'small'}
width={2}
onClick={refresh}
>
{t('log.buttons.submit')}
</Form.Button>
</Form.Group>
{isAdminUser && (
<>
<Form.Group>
<Form.Input
fluid
label={t('log.table.channel_id')}
size={'small'}
width={3}
>
Time
</Table.HeaderCell>
{isAdminUser && (
<Table.HeaderCell
style={{ cursor: 'pointer' }}
onClick={() => {
sortLog('channel');
}}
width={1}
>
Channel
</Table.HeaderCell>
)}
value={channel}
placeholder={t('log.table.channel_id_placeholder')}
name='channel'
onChange={handleInputChange}
/>
<Form.Input
fluid
label={t('log.table.username')}
size={'small'}
width={3}
value={username}
placeholder={t('log.table.username_placeholder')}
name='username'
onChange={handleInputChange}
/>
</Form.Group>
</>
)}
<Form.Input
icon='search'
placeholder={t('log.search')}
value={searchKeyword}
onChange={(e, { value }) => setSearchKeyword(value)}
/>
</Form>
<Table basic={'very'} compact size='small'>
<Table.Header>
<Table.Row>
<Table.HeaderCell
style={{ cursor: 'pointer' }}
onClick={() => {
sortLog('created_time');
}}
width={3}
>
{t('log.table.time')}
</Table.HeaderCell>
{isAdminUser && (
<Table.HeaderCell
style={{ cursor: 'pointer' }}
onClick={() => {
sortLog('type');
sortLog('channel');
}}
width={1}
>
Type
{t('log.table.channel')}
</Table.HeaderCell>
<Table.HeaderCell
style={{ cursor: 'pointer' }}
onClick={() => {
sortLog('model_name');
}}
width={2}
>
Model
</Table.HeaderCell>
{showUserTokenQuota() && (
<>
{isAdminUser && (
<Table.HeaderCell
style={{ cursor: 'pointer' }}
onClick={() => {
sortLog('username');
}}
width={1}
>
User
</Table.HeaderCell>
)}
)}
<Table.HeaderCell
style={{ cursor: 'pointer' }}
onClick={() => {
sortLog('type');
}}
width={1}
>
{t('log.table.type')}
</Table.HeaderCell>
<Table.HeaderCell
style={{ cursor: 'pointer' }}
onClick={() => {
sortLog('model_name');
}}
width={2}
>
{t('log.table.model')}
</Table.HeaderCell>
{showUserTokenQuota() && (
<>
{isAdminUser && (
<Table.HeaderCell
style={{ cursor: 'pointer' }}
onClick={() => {
sortLog('token_name');
sortLog('username');
}}
width={1}
width={2}
>
Token
{t('log.table.username')}
</Table.HeaderCell>
<Table.HeaderCell
style={{ cursor: 'pointer' }}
onClick={() => {
sortLog('prompt_tokens');
}}
width={1}
>
Prompt
</Table.HeaderCell>
<Table.HeaderCell
style={{ cursor: 'pointer' }}
onClick={() => {
sortLog('completion_tokens');
}}
width={1}
>
Completion
</Table.HeaderCell>
<Table.HeaderCell
style={{ cursor: 'pointer' }}
onClick={() => {
sortLog('quota');
}}
width={1}
>
Quota
</Table.HeaderCell>
</>
)}
<Table.HeaderCell
style={{ cursor: 'pointer' }}
onClick={() => {
sortLog('content');
}}
width={isAdminUser ? 4 : 6}
>
Details
</Table.HeaderCell>
</Table.Row>
</Table.Header>
<Table.Body>
{logs
.slice(
(activePage - 1) * ITEMS_PER_PAGE,
activePage * ITEMS_PER_PAGE
)
.map((log, idx) => {
if (log.deleted) return <></>;
return (
<Table.Row key={log.id}>
<Table.Cell>
{renderTimestamp(log.created_at, log.request_id)}
</Table.Cell>
{isAdminUser && (
<Table.Cell>
{log.channel ? (
<Label
basic
as={Link}
to={`/channel/edit/${log.channel}`}
>
{log.channel}
</Label>
) : (
''
)}
</Table.Cell>
)}
<Table.Cell>{renderType(log.type)}</Table.Cell>
<Table.Cell>
{log.model_name ? renderColorLabel(log.model_name) : ''}
</Table.Cell>
{showUserTokenQuota() && (
<>
{isAdminUser && (
<Table.Cell>
{log.username ? (
<Label
basic
as={Link}
to={`/user/edit/${log.user_id}`}
>
{log.username}
</Label>
) : (
''
)}
</Table.Cell>
)}
<Table.Cell>
{log.token_name
? renderColorLabel(log.token_name)
: ''}
</Table.Cell>
<Table.Cell>
{log.prompt_tokens ? log.prompt_tokens : ''}
</Table.Cell>
<Table.Cell>
{log.completion_tokens ? log.completion_tokens : ''}
</Table.Cell>
<Table.Cell>
{log.quota ? renderQuota(log.quota, 6) : ''}
</Table.Cell>
</>
)}
<Table.Cell>{renderDetail(log)}</Table.Cell>
</Table.Row>
);
})}
</Table.Body>
<Table.Footer>
<Table.Row>
<Table.HeaderCell colSpan={'10'}>
<Select
placeholder='Select detail category'
options={LOG_OPTIONS}
style={{ marginRight: '8px' }}
name='logType'
value={logType}
onChange={(e, { name, value }) => {
setLogType(value);
)}
<Table.HeaderCell
style={{ cursor: 'pointer' }}
onClick={() => {
sortLog('token_name');
}}
/>
<Button size='small' onClick={refresh} loading={loading}>
Refresh
</Button>
<Pagination
floated='right'
activePage={activePage}
onPageChange={onPaginationChange}
size='small'
siblingRange={1}
totalPages={
Math.ceil(logs.length / ITEMS_PER_PAGE) +
(logs.length % ITEMS_PER_PAGE === 0 ? 1 : 0)
}
/>
</Table.HeaderCell>
</Table.Row>
</Table.Footer>
</Table>
</>
width={2}
>
{t('log.table.token_name')}
</Table.HeaderCell>
<Table.HeaderCell
style={{ cursor: 'pointer' }}
onClick={() => {
sortLog('prompt_tokens');
}}
width={1}
>
{t('log.table.prompt_tokens')}
</Table.HeaderCell>
<Table.HeaderCell
style={{ cursor: 'pointer' }}
onClick={() => {
sortLog('completion_tokens');
}}
width={1}
>
{t('log.table.completion_tokens')}
</Table.HeaderCell>
<Table.HeaderCell
style={{ cursor: 'pointer' }}
onClick={() => {
sortLog('quota');
}}
width={1}
>
{t('log.table.quota')}
</Table.HeaderCell>
</>
)}
<Table.HeaderCell>{t('log.table.detail')}</Table.HeaderCell>
</Table.Row>
</Table.Header>
<Table.Body>
{logs
.slice(
(activePage - 1) * ITEMS_PER_PAGE,
activePage * ITEMS_PER_PAGE
)
.map((log, idx) => {
if (log.deleted) return <></>;
return (
<Table.Row key={log.id}>
<Table.Cell>
{renderTimestamp(log.created_at, log.request_id)}
</Table.Cell>
{isAdminUser && (
<Table.Cell>
{log.channel ? (
<Label
basic
as={Link}
to={`/channel/edit/${log.channel}`}
>
{log.channel}
</Label>
) : (
''
)}
</Table.Cell>
)}
<Table.Cell>{renderType(log.type)}</Table.Cell>
<Table.Cell>
{log.model_name ? renderColorLabel(log.model_name) : ''}
</Table.Cell>
{showUserTokenQuota() && (
<>
{isAdminUser && (
<Table.Cell>
{log.username ? (
<Label
basic
as={Link}
to={`/user/edit/${log.user_id}`}
>
{log.username}
</Label>
) : (
''
)}
</Table.Cell>
)}
<Table.Cell>
{log.token_name ? renderColorLabel(log.token_name) : ''}
</Table.Cell>
<Table.Cell>
{log.prompt_tokens ? log.prompt_tokens : ''}
</Table.Cell>
<Table.Cell>
{log.completion_tokens ? log.completion_tokens : ''}
</Table.Cell>
<Table.Cell>
{log.quota ? renderQuota(log.quota, t, 6) : ''}
</Table.Cell>
</>
)}
<Table.Cell>{renderDetail(log)}</Table.Cell>
</Table.Row>
);
})}
</Table.Body>
<Table.Footer>
<Table.Row>
<Table.HeaderCell colSpan={'10'}>
<Select
placeholder={t('log.type.select')}
options={LOG_OPTIONS}
style={{ marginRight: '8px' }}
name='logType'
value={logType}
onChange={(e, { name, value }) => {
setLogType(value);
}}
/>
<Button size='small' onClick={refresh} loading={loading}>
{t('log.buttons.refresh')}
</Button>
<Pagination
floated='right'
activePage={activePage}
onPageChange={onPaginationChange}
size='small'
siblingRange={1}
totalPages={
Math.ceil(logs.length / ITEMS_PER_PAGE) +
(logs.length % ITEMS_PER_PAGE === 0 ? 1 : 0)
}
/>
</Table.HeaderCell>
</Table.Row>
</Table.Footer>
</Table>
</>
);
};

View File

@@ -1,8 +1,16 @@
import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Divider, Form, Grid, Header } from 'semantic-ui-react';
import { API, showError, showSuccess, timestamp2string, verifyJSON } from '../helpers';
import {
API,
showError,
showSuccess,
timestamp2string,
verifyJSON,
} from '../helpers';
const OperationSetting = () => {
const { t } = useTranslation();
let now = new Date();
let [inputs, setInputs] = useState({
QuotaForNewUser: 0,
@@ -23,11 +31,13 @@ const OperationSetting = () => {
DisplayInCurrencyEnabled: '',
DisplayTokenStatEnabled: '',
ApproximateTokenEnabled: '',
RetryTimes: 0
RetryTimes: 0,
});
const [originInputs, setOriginInputs] = useState({});
let [loading, setLoading] = useState(false);
let [historyTimestamp, setHistoryTimestamp] = useState(timestamp2string(now.getTime() / 1000 - 30 * 24 * 3600)); // a month ago
let [historyTimestamp, setHistoryTimestamp] = useState(
timestamp2string(now.getTime() / 1000 - 30 * 24 * 3600)
); // a month ago
const getOptions = async () => {
const res = await API.get('/api/option/');
@@ -35,7 +45,11 @@ const OperationSetting = () => {
if (success) {
let newInputs = {};
data.forEach((item) => {
if (item.key === 'ModelRatio' || item.key === 'GroupRatio' || item.key === 'CompletionRatio') {
if (
item.key === 'ModelRatio' ||
item.key === 'GroupRatio' ||
item.key === 'CompletionRatio'
) {
item.value = JSON.stringify(JSON.parse(item.value), null, 2);
}
if (item.value === '{}') {
@@ -61,7 +75,7 @@ const OperationSetting = () => {
}
const res = await API.put('/api/option/', {
key,
value
value,
});
const { success, message } = res.data;
if (success) {
@@ -83,11 +97,22 @@ const OperationSetting = () => {
const submitConfig = async (group) => {
switch (group) {
case 'monitor':
if (originInputs['ChannelDisableThreshold'] !== inputs.ChannelDisableThreshold) {
await updateOption('ChannelDisableThreshold', inputs.ChannelDisableThreshold);
if (
originInputs['ChannelDisableThreshold'] !==
inputs.ChannelDisableThreshold
) {
await updateOption(
'ChannelDisableThreshold',
inputs.ChannelDisableThreshold
);
}
if (originInputs['QuotaRemindThreshold'] !== inputs.QuotaRemindThreshold) {
await updateOption('QuotaRemindThreshold', inputs.QuotaRemindThreshold);
if (
originInputs['QuotaRemindThreshold'] !== inputs.QuotaRemindThreshold
) {
await updateOption(
'QuotaRemindThreshold',
inputs.QuotaRemindThreshold
);
}
break;
case 'ratio':
@@ -146,7 +171,9 @@ const OperationSetting = () => {
const deleteHistoryLogs = async () => {
console.log(inputs);
const res = await API.delete(`/api/log/?target_timestamp=${Date.parse(historyTimestamp) / 1000}`);
const res = await API.delete(
`/api/log/?target_timestamp=${Date.parse(historyTimestamp) / 1000}`
);
const { success, message, data } = res.data;
if (success) {
showSuccess(`$cleared {data} logs!`);
@@ -159,40 +186,218 @@ const OperationSetting = () => {
<Grid columns={1}>
<Grid.Column>
<Form loading={loading}>
<Header as='h3'>
General Settings
</Header>
<Header as='h3'>{t('setting.operation.quota.title')}</Header>
<Form.Group widths='equal'>
<Form.Input
label={t('setting.operation.quota.new_user')}
name='QuotaForNewUser'
onChange={handleInputChange}
autoComplete='new-password'
value={inputs.QuotaForNewUser}
type='number'
min='0'
placeholder={t('setting.operation.quota.new_user_placeholder')}
/>
<Form.Input
label={t('setting.operation.quota.pre_consume')}
name='PreConsumedQuota'
onChange={handleInputChange}
autoComplete='new-password'
value={inputs.PreConsumedQuota}
type='number'
min='0'
placeholder={t('setting.operation.quota.pre_consume_placeholder')}
/>
<Form.Input
label={t('setting.operation.quota.inviter_reward')}
name='QuotaForInviter'
onChange={handleInputChange}
autoComplete='new-password'
value={inputs.QuotaForInviter}
type='number'
min='0'
placeholder={t(
'setting.operation.quota.inviter_reward_placeholder'
)}
/>
<Form.Input
label={t('setting.operation.quota.invitee_reward')}
name='QuotaForInvitee'
onChange={handleInputChange}
autoComplete='new-password'
value={inputs.QuotaForInvitee}
type='number'
min='0'
placeholder={t(
'setting.operation.quota.invitee_reward_placeholder'
)}
/>
</Form.Group>
<Form.Button
onClick={() => {
submitConfig('quota').then();
}}
>
{t('setting.operation.quota.buttons.save')}
</Form.Button>
<Divider />
<Header as='h3'>{t('setting.operation.ratio.title')}</Header>
<Form.Group widths='equal'>
<Form.TextArea
label={t('setting.operation.ratio.model.title')}
name='ModelRatio'
onChange={handleInputChange}
style={{ minHeight: 250, fontFamily: 'JetBrains Mono, Consolas' }}
autoComplete='new-password'
value={inputs.ModelRatio}
placeholder={t('setting.operation.ratio.model.placeholder')}
/>
</Form.Group>
<Form.Group widths='equal'>
<Form.TextArea
label={t('setting.operation.ratio.completion.title')}
name='CompletionRatio'
onChange={handleInputChange}
style={{ minHeight: 250, fontFamily: 'JetBrains Mono, Consolas' }}
autoComplete='new-password'
value={inputs.CompletionRatio}
placeholder={t('setting.operation.ratio.completion.placeholder')}
/>
</Form.Group>
<Form.Group widths='equal'>
<Form.TextArea
label={t('setting.operation.ratio.group.title')}
name='GroupRatio'
onChange={handleInputChange}
style={{ minHeight: 250, fontFamily: 'JetBrains Mono, Consolas' }}
autoComplete='new-password'
value={inputs.GroupRatio}
placeholder={t('setting.operation.ratio.group.placeholder')}
/>
</Form.Group>
<Form.Button
onClick={() => {
submitConfig('ratio').then();
}}
>
{t('setting.operation.ratio.buttons.save')}
</Form.Button>
<Divider />
<Header as='h3'>{t('setting.operation.log.title')}</Header>
<Form.Group inline>
<Form.Checkbox
checked={inputs.LogConsumeEnabled === 'true'}
label={t('setting.operation.log.enable_consume')}
name='LogConsumeEnabled'
onChange={handleInputChange}
/>
</Form.Group>
<Form.Group widths={4}>
<Form.Input
label='Recharge Link'
label={t('setting.operation.log.target_time')}
value={historyTimestamp}
type='datetime-local'
name='history_timestamp'
onChange={(e, { name, value }) => {
setHistoryTimestamp(value);
}}
/>
</Form.Group>
<Form.Button
onClick={() => {
deleteHistoryLogs().then();
}}
>
{t('setting.operation.log.buttons.clean')}
</Form.Button>
<Divider />
<Header as='h3'>{t('setting.operation.monitor.title')}</Header>
<Form.Group widths={3}>
<Form.Input
label={t('setting.operation.monitor.max_response_time')}
name='ChannelDisableThreshold'
onChange={handleInputChange}
autoComplete='new-password'
value={inputs.ChannelDisableThreshold}
type='number'
min='0'
placeholder={t(
'setting.operation.monitor.max_response_time_placeholder'
)}
/>
<Form.Input
label={t('setting.operation.monitor.quota_reminder')}
name='QuotaRemindThreshold'
onChange={handleInputChange}
autoComplete='new-password'
value={inputs.QuotaRemindThreshold}
type='number'
min='0'
placeholder={t(
'setting.operation.monitor.quota_reminder_placeholder'
)}
/>
</Form.Group>
<Form.Group inline>
<Form.Checkbox
checked={inputs.AutomaticDisableChannelEnabled === 'true'}
label={t('setting.operation.monitor.auto_disable')}
name='AutomaticDisableChannelEnabled'
onChange={handleInputChange}
/>
<Form.Checkbox
checked={inputs.AutomaticEnableChannelEnabled === 'true'}
label={t('setting.operation.monitor.auto_enable')}
name='AutomaticEnableChannelEnabled'
onChange={handleInputChange}
/>
</Form.Group>
<Form.Button
onClick={() => {
submitConfig('monitor').then();
}}
>
{t('setting.operation.monitor.buttons.save')}
</Form.Button>
<Divider />
<Header as='h3'>{t('setting.operation.general.title')}</Header>
<Form.Group widths={4}>
<Form.Input
label={t('setting.operation.general.topup_link')}
name='TopUpLink'
onChange={handleInputChange}
autoComplete='new-password'
value={inputs.TopUpLink}
type='link'
placeholder='For example, the purchase link of the card issuing website'
placeholder={t(
'setting.operation.general.topup_link_placeholder'
)}
/>
<Form.Input
label='Chat Page Link'
label={t('setting.operation.general.chat_link')}
name='ChatLink'
onChange={handleInputChange}
autoComplete='new-password'
value={inputs.ChatLink}
type='link'
placeholder='For example, the deployment address of ChatGPT Next Web'
placeholder={t('setting.operation.general.chat_link_placeholder')}
/>
<Form.Input
label='Unit Dollar Quota'
label={t('setting.operation.general.quota_per_unit')}
name='QuotaPerUnit'
onChange={handleInputChange}
autoComplete='new-password'
value={inputs.QuotaPerUnit}
type='number'
step='0.01'
placeholder='Quota that can be exchanged for one unit of currency'
placeholder={t(
'setting.operation.general.quota_per_unit_placeholder'
)}
/>
<Form.Input
label='Retry Times on Failure'
label={t('setting.operation.general.retry_times')}
name='RetryTimes'
type={'number'}
step='1'
@@ -200,186 +405,38 @@ const OperationSetting = () => {
onChange={handleInputChange}
autoComplete='new-password'
value={inputs.RetryTimes}
placeholder='Number of retry attempts on failure'
placeholder={t(
'setting.operation.general.retry_times_placeholder'
)}
/>
</Form.Group>
<Form.Group inline>
<Form.Checkbox
checked={inputs.DisplayInCurrencyEnabled === 'true'}
label='Display quota in the form of currency'
label={t('setting.operation.general.display_in_currency')}
name='DisplayInCurrencyEnabled'
onChange={handleInputChange}
/>
<Form.Checkbox
checked={inputs.DisplayTokenStatEnabled === 'true'}
label='Billing Related API displays token quota instead of user quota'
label={t('setting.operation.general.display_token_stat')}
name='DisplayTokenStatEnabled'
onChange={handleInputChange}
/>
<Form.Checkbox
checked={inputs.ApproximateTokenEnabled === 'true'}
label='Estimate the number of tokens in an approximate way to reduce computational load'
label={t('setting.operation.general.approximate_token')}
name='ApproximateTokenEnabled'
onChange={handleInputChange}
/>
</Form.Group>
<Form.Button onClick={() => {
submitConfig('general').then();
}}>Save General Settings</Form.Button>
<Divider />
<Header as='h3'>
LogsSettings
</Header>
<Form.Group inline>
<Form.Checkbox
checked={inputs.LogConsumeEnabled === 'true'}
label='Enable quota consumption log recording'
name='LogConsumeEnabled'
onChange={handleInputChange}
/>
</Form.Group>
<Form.Group widths={4}>
<Form.Input label='Target Time' value={historyTimestamp} type='datetime-local'
name='history_timestamp'
onChange={(e, { name, value }) => {
setHistoryTimestamp(value);
}} />
</Form.Group>
<Form.Button onClick={() => {
deleteHistoryLogs().then();
}}>Clear History Logs</Form.Button>
<Divider />
<Header as='h3'>
Monitoring Settings
</Header>
<Form.Group widths={3}>
<Form.Input
label='Maximum Response Time'
name='ChannelDisableThreshold'
onChange={handleInputChange}
autoComplete='new-password'
value={inputs.ChannelDisableThreshold}
type='number'
min='0'
placeholder='Unit in seconds. When all operating channels are tested, channels will be automatically disabled if this time is exceeded'
/>
<Form.Input
label='Quota Reminder Threshold'
name='QuotaRemindThreshold'
onChange={handleInputChange}
autoComplete='new-password'
value={inputs.QuotaRemindThreshold}
type='number'
min='0'
placeholder='Email will be sent to remind users when the quota is below this'
/>
</Form.Group>
<Form.Group inline>
<Form.Checkbox
checked={inputs.AutomaticDisableChannelEnabled === 'true'}
label='Automatically disable the channel when it fails'
name='AutomaticDisableChannelEnabled'
onChange={handleInputChange}
/>
<Form.Checkbox
checked={inputs.AutomaticEnableChannelEnabled === 'true'}
label='Automatically enable the channel when it succeeds'
name='AutomaticEnableChannelEnabled'
onChange={handleInputChange}
/>
</Form.Group>
<Form.Button onClick={() => {
submitConfig('monitor').then();
}}>Save Monitoring Settings</Form.Button>
<Divider />
<Header as='h3'>
Quota Settings
</Header>
<Form.Group widths={4}>
<Form.Input
label='Initial quota for new users'
name='QuotaForNewUser'
onChange={handleInputChange}
autoComplete='new-password'
value={inputs.QuotaForNewUser}
type='number'
min='0'
placeholder='For example100'
/>
<Form.Input
label='Request for pre-deducted quota'
name='PreConsumedQuota'
onChange={handleInputChange}
autoComplete='new-password'
value={inputs.PreConsumedQuota}
type='number'
min='0'
placeholder='Refund more or less after the request ends'
/>
<Form.Input
label='Invite new users to reward quota'
name='QuotaForInviter'
onChange={handleInputChange}
autoComplete='new-password'
value={inputs.QuotaForInviter}
type='number'
min='0'
placeholder='For example2000'
/>
<Form.Input
label='New user rewards quota using invitation code'
name='QuotaForInvitee'
onChange={handleInputChange}
autoComplete='new-password'
value={inputs.QuotaForInvitee}
type='number'
min='0'
placeholder='For example1000'
/>
</Form.Group>
<Form.Button onClick={() => {
submitConfig('quota').then();
}}>Save Quota Settings</Form.Button>
<Divider />
<Header as='h3'>
Rate Settings
</Header>
<Form.Group widths='equal'>
<Form.TextArea
label='model rate'
name='ModelRatio'
onChange={handleInputChange}
style={{ minHeight: 250, fontFamily: 'JetBrains Mono, Consolas' }}
autoComplete='new-password'
value={inputs.ModelRatio}
placeholder='Is a JSON textKey is model nameValue is the rate'
/>
</Form.Group>
<Form.Group widths='equal'>
<Form.TextArea
label='Completion Ratio'
name='CompletionRatio'
onChange={handleInputChange}
style={{ minHeight: 250, fontFamily: 'JetBrains Mono, Consolas' }}
autoComplete='new-password'
value={inputs.CompletionRatio}
placeholder='Should be a JSON text, where keys are model names and values are ratios. This ratio setting represents the proportion of ModelCompletion rate to Prompt rate, which can forcefully override One API internal ratios'
/>
</Form.Group>
<Form.Group widths='equal'>
<Form.TextArea
label='group rate'
name='GroupRatio'
onChange={handleInputChange}
style={{ minHeight: 250, fontFamily: 'JetBrains Mono, Consolas' }}
autoComplete='new-password'
value={inputs.GroupRatio}
placeholder='Is a JSON textKey is group nameValue is the rate'
/>
</Form.Group>
<Form.Button onClick={() => {
submitConfig('ratio').then();
}}>Save Rate Settings</Form.Button>
<Form.Button
onClick={() => {
submitConfig('general').then();
}}
>
{t('setting.operation.general.buttons.save')}
</Form.Button>
</Form>
</Grid.Column>
</Grid>

View File

@@ -1,10 +1,20 @@
import React, { useEffect, useState } from 'react';
import { Button, Divider, Form, Grid, Header, Message, Modal } from 'semantic-ui-react';
import { API, showError, showSuccess } from '../helpers';
import { marked } from 'marked';
import { useTranslation } from 'react-i18next';
import {
Button,
Divider,
Form,
Grid,
Header,
Message,
Modal,
} from 'semantic-ui-react';
import { Link } from 'react-router-dom';
import { API, showError, showSuccess, verifyJSON } from '../helpers';
import { marked } from 'marked';
const OtherSetting = () => {
const { t } = useTranslation();
let [inputs, setInputs] = useState({
Footer: '',
Notice: '',
@@ -12,13 +22,13 @@ const OtherSetting = () => {
SystemName: '',
Logo: '',
HomePageContent: '',
Theme: ''
Theme: '',
});
let [loading, setLoading] = useState(false);
const [showUpdateModal, setShowUpdateModal] = useState(false);
const [updateData, setUpdateData] = useState({
tag_name: '',
content: ''
content: '',
});
const getOptions = async () => {
@@ -45,7 +55,7 @@ const OtherSetting = () => {
setLoading(true);
const res = await API.put('/api/option/', {
key,
value
value,
});
const { success, message } = res.data;
if (success) {
@@ -64,10 +74,6 @@ const OtherSetting = () => {
await updateOption('Notice', inputs.Notice);
};
const submitFooter = async () => {
await updateOption('Footer', inputs.Footer);
};
const submitSystemName = async () => {
await updateOption('SystemName', inputs.SystemName);
};
@@ -89,8 +95,7 @@ const OtherSetting = () => {
};
const openGitHubRelease = () => {
window.location =
'https://github.com/songquanpeng/one-api/releases/latest';
window.location = 'https://github.com/songquanpeng/one-api/releases/latest';
};
const checkUpdate = async () => {
@@ -103,7 +108,7 @@ const OtherSetting = () => {
} else {
setUpdateData({
tag_name: tag_name,
content: marked.parse(body)
content: marked.parse(body),
});
setShowUpdateModal(true);
}
@@ -113,86 +118,110 @@ const OtherSetting = () => {
<Grid columns={1}>
<Grid.Column>
<Form loading={loading}>
<Header as='h3'>General Settings</Header>
<Form.Button onClick={checkUpdate}>Check for updates</Form.Button>
<Header as='h3'>{t('setting.other.notice.title')}</Header>
<Form.Group widths='equal'>
<Form.TextArea
label='Announcement'
placeholder='Enter the new announcement content here, supports Markdown & HTML code'
label={t('setting.other.notice.content')}
placeholder={t('setting.other.notice.content_placeholder')}
value={inputs.Notice}
name='Notice'
onChange={handleInputChange}
style={{ minHeight: 150, fontFamily: 'JetBrains Mono, Consolas' }}
style={{ minHeight: 100, fontFamily: 'JetBrains Mono, Consolas' }}
/>
</Form.Group>
<Form.Button onClick={submitNotice}>Save Announcement</Form.Button>
<Form.Button onClick={submitNotice}>
{t('setting.other.notice.buttons.save')}
</Form.Button>
<Divider />
<Header as='h3'>Personalization Settings</Header>
<Header as='h3'>{t('setting.other.system.title')}</Header>
<Form.Group widths='equal'>
<Form.Input
label='System Name'
placeholder='Enter the system name here'
label={t('setting.other.system.name')}
placeholder={t('setting.other.system.name_placeholder')}
value={inputs.SystemName}
name='SystemName'
onChange={handleInputChange}
/>
</Form.Group>
<Form.Button onClick={submitSystemName}>Set system name</Form.Button>
<Form.Button onClick={submitSystemName}>
{t('setting.other.system.buttons.save_name')}
</Form.Button>
<Form.Group widths='equal'>
<Form.Input
label={<label>Theme Name (<Link
to='https://github.com/songquanpeng/one-api/blob/main/web/README.md'>Available Themes</Link>)</label>}
placeholder='Please enter theme name'
label={
<label>
{t('setting.other.system.theme.title')}
<Link to='https://github.com/songquanpeng/one-api/blob/main/web/README.md'>
{t('setting.other.system.theme.link')}
</Link>
</label>
}
placeholder={t('setting.other.system.theme.placeholder')}
value={inputs.Theme}
name='Theme'
onChange={handleInputChange}
/>
</Form.Group>
<Form.Button onClick={submitTheme}>Set Theme (Restart Required)</Form.Button>
<Form.Button onClick={submitTheme}>
{t('setting.other.system.buttons.save_theme')}
</Form.Button>
<Form.Group widths='equal'>
<Form.Input
label='Logo Image URL'
placeholder='Enter the Logo image URL here'
label={t('setting.other.system.logo')}
placeholder={t('setting.other.system.logo_placeholder')}
value={inputs.Logo}
name='Logo'
type='url'
onChange={handleInputChange}
/>
</Form.Group>
<Form.Button onClick={submitLogo}>Settings Logo</Form.Button>
<Form.Button onClick={submitLogo}>
{t('setting.other.system.buttons.save_logo')}
</Form.Button>
<Divider />
<Header as='h3'>{t('setting.other.content.title')}</Header>
<Form.Group widths='equal'>
<Form.TextArea
label='Home Page Content'
placeholder='Enter the homepage content here, supports Markdown & HTML code. Once set, the status information of the homepage will not be displayed. If a link is entered, it will be used as the src attribute of the iframe, allowing you to set any webpage as the homepage.。'
label={t('setting.other.content.homepage.title')}
placeholder={t('setting.other.content.homepage.placeholder')}
value={inputs.HomePageContent}
name='HomePageContent'
onChange={handleInputChange}
style={{ minHeight: 150, fontFamily: 'JetBrains Mono, Consolas' }}
/>
</Form.Group>
<Form.Button onClick={() => submitOption('HomePageContent')}>Save Home Page Content</Form.Button>
<Form.Button onClick={() => submitOption('HomePageContent')}>
{t('setting.other.content.buttons.save_homepage')}
</Form.Button>
<Form.Group widths='equal'>
<Form.TextArea
label='About'
placeholder='Enter new about content here, supports Markdown & HTML code. If a link is entered, it will be used as the src attribute of the iframe, allowing you to set any webpage as the about page.。'
label={t('setting.other.content.about.title')}
placeholder={t('setting.other.content.about.placeholder')}
value={inputs.About}
name='About'
onChange={handleInputChange}
style={{ minHeight: 150, fontFamily: 'JetBrains Mono, Consolas' }}
/>
</Form.Group>
<Form.Button onClick={submitAbout}>Save About</Form.Button>
<Message>Removing One API's copyright notice requires prior authorization. Project maintenance requires significant effort - if this project is meaningful to you, please actively support it.</Message>
<Form.Button onClick={submitAbout}>
{t('setting.other.content.buttons.save_about')}
</Form.Button>
<Message>{t('setting.other.copyright.notice')}</Message>
<Form.Group widths='equal'>
<Form.Input
label='Footer'
placeholder='Enter the new footer here, leave blank to use the default footer, supports HTML code.'
label={t('setting.other.content.footer.title')}
placeholder={t('setting.other.content.footer.placeholder')}
value={inputs.Footer}
name='Footer'
onChange={handleInputChange}
/>
</Form.Group>
<Form.Button onClick={submitFooter}>Set Footer</Form.Button>
<Form.Button onClick={() => submitOption('Footer')}>
{t('setting.other.content.buttons.save_footer')}
</Form.Button>
</Form>
</Grid.Column>
<Modal

View File

@@ -8,29 +8,23 @@ import {
Card,
Message,
} from 'semantic-ui-react';
import {
API,
copy,
showError,
showInfo,
showNotice,
showSuccess,
} from '../helpers';
import { useTranslation } from 'react-i18next';
import { API, copy, getLogo, showError, showNotice } from '../helpers';
import { useSearchParams } from 'react-router-dom';
const PasswordResetConfirm = () => {
const { t } = useTranslation();
const [inputs, setInputs] = useState({
email: '',
token: '',
});
const { email, token } = inputs;
const [loading, setLoading] = useState(false);
const [disableButton, setDisableButton] = useState(false);
const [countdown, setCountdown] = useState(30);
const [newPassword, setNewPassword] = useState('');
const logo = getLogo();
const [countdown, setCountdown] = useState(30);
const [searchParams, setSearchParams] = useSearchParams();
useEffect(() => {
@@ -68,7 +62,7 @@ const PasswordResetConfirm = () => {
let password = res.data.data;
setNewPassword(password);
await copy(password);
showNotice(`New password has been copied to the clipboard:${password}`);
showNotice(t('messages.notice.password_copied', { password }));
} else {
showError(message);
}
@@ -90,8 +84,8 @@ const PasswordResetConfirm = () => {
textAlign='center'
style={{ marginBottom: '1.5em' }}
>
<Image src='/logo.png' style={{ marginBottom: '10px' }} />
<Header.Content>Password Reset Confirmation</Header.Content>
<Image src={logo} style={{ marginBottom: '10px' }} />
<Header.Content>{t('auth.reset.confirm.title')}</Header.Content>
</Header>
</Card.Header>
<Form size='large'>
@@ -99,7 +93,7 @@ const PasswordResetConfirm = () => {
fluid
icon='mail'
iconPosition='left'
placeholder='Email Address'
placeholder={t('auth.reset.email')}
name='email'
value={email}
readOnly
@@ -110,7 +104,7 @@ const PasswordResetConfirm = () => {
fluid
icon='lock'
iconPosition='left'
placeholder='New Password'
placeholder={t('auth.reset.confirm.new_password')}
name='newPassword'
value={newPassword}
readOnly
@@ -122,30 +116,31 @@ const PasswordResetConfirm = () => {
onClick={(e) => {
e.target.select();
navigator.clipboard.writeText(newPassword);
showNotice(`Password has been copied to the clipboard: ${newPassword}`);
showNotice(t('auth.reset.confirm.notice'));
}}
/>
)}
<Button
color='blue'
fluid
size='large'
onClick={handleSubmit}
loading={loading}
disabled={disableButton}
style={{
background: '#2F73FF', // Use a more modern blue
background: '#2F73FF',
color: 'white',
marginBottom: '1.5em',
}}
>
{disableButton ? 'Password Reset Complete' : 'Submit'}
{disableButton
? t('auth.reset.confirm.button_disabled')
: t('auth.reset.confirm.button')}
</Button>
</Form>
{newPassword && (
<Message style={{ background: 'transparent', boxShadow: 'none' }}>
<p style={{ fontSize: '0.9em', color: '#666' }}>
A new password has been generated. Please click the password box or the button above to copy it. Please log in and change your password promptly!
{t('auth.reset.confirm.notice')}
</p>
</Message>
)}

View File

@@ -8,21 +8,23 @@ import {
Card,
Message,
} from 'semantic-ui-react';
import { API, showError, showInfo, showSuccess } from '../helpers';
import { useTranslation } from 'react-i18next';
import { API, getLogo, showError, showInfo, showSuccess } from '../helpers';
import Turnstile from 'react-turnstile';
const PasswordResetForm = () => {
const { t } = useTranslation();
const [inputs, setInputs] = useState({
email: '',
});
const { email } = inputs;
const [loading, setLoading] = useState(false);
const [turnstileEnabled, setTurnstileEnabled] = useState(false);
const [turnstileSiteKey, setTurnstileSiteKey] = useState('');
const [turnstileToken, setTurnstileToken] = useState('');
const [disableButton, setDisableButton] = useState(false);
const [countdown, setCountdown] = useState(30);
const logo = getLogo();
useEffect(() => {
let status = localStorage.getItem('status');
@@ -66,10 +68,12 @@ const PasswordResetForm = () => {
);
const { success, message } = res.data;
if (success) {
showSuccess('Reset email sent successfully, please check your email!');
showSuccess(t('auth.reset.notice'));
setInputs({ ...inputs, email: '' });
} else {
showError(message);
setDisableButton(false);
setCountdown(30);
}
setLoading(false);
}
@@ -89,8 +93,8 @@ const PasswordResetForm = () => {
textAlign='center'
style={{ marginBottom: '1.5em' }}
>
<Image src='/logo.png' style={{ marginBottom: '10px' }} />
<Header.Content>Password Reset</Header.Content>
<Image src={logo} style={{ marginBottom: '10px' }} />
<Header.Content>{t('auth.reset.title')}</Header.Content>
</Header>
</Card.Header>
<Form size='large'>
@@ -98,7 +102,7 @@ const PasswordResetForm = () => {
fluid
icon='mail'
iconPosition='left'
placeholder='Email Address'
placeholder={t('auth.reset.email')}
name='email'
value={email}
onChange={handleChange}
@@ -133,12 +137,14 @@ const PasswordResetForm = () => {
marginBottom: '1.5em',
}}
>
{disableButton ? `Retry (${countdown})` : 'Submit'}
{disableButton
? t('auth.register.get_code_retry', { countdown })
: t('auth.reset.button')}
</Button>
</Form>
<Message style={{ background: 'transparent', boxShadow: 'none' }}>
<p style={{ fontSize: '0.9em', color: '#666' }}>
The system will send an email with a reset link to your email address. Please check your inbox.
{t('auth.reset.notice')}
</p>
</Message>
</Card.Content>

View File

@@ -1,12 +1,29 @@
import React, { useContext, useEffect, useState } from 'react';
import { Button, Divider, Form, Header, Image, Message, Modal } from 'semantic-ui-react';
import { useTranslation } from 'react-i18next';
import {
Button,
Divider,
Form,
Header,
Image,
Message,
Modal,
} from 'semantic-ui-react';
import { Link, useNavigate } from 'react-router-dom';
import { API, copy, showError, showInfo, showNotice, showSuccess } from '../helpers';
import {
API,
copy,
showError,
showInfo,
showNotice,
showSuccess,
} from '../helpers';
import Turnstile from 'react-turnstile';
import { UserContext } from '../context/User';
import { onGitHubOAuthClicked, onLarkOAuthClicked } from './utils';
const PersonalSetting = () => {
const { t } = useTranslation();
const [userState, userDispatch] = useContext(UserContext);
let navigate = useNavigate();
@@ -14,7 +31,7 @@ const PersonalSetting = () => {
wechat_verification_code: '',
email_verification_code: '',
email: '',
self_account_deletion_confirmation: ''
self_account_deletion_confirmation: '',
});
const [status, setStatus] = useState({});
const [showWeChatBindModal, setShowWeChatBindModal] = useState(false);
@@ -26,8 +43,8 @@ const PersonalSetting = () => {
const [loading, setLoading] = useState(false);
const [disableButton, setDisableButton] = useState(false);
const [countdown, setCountdown] = useState(30);
const [affLink, setAffLink] = useState("");
const [systemToken, setSystemToken] = useState("");
const [affLink, setAffLink] = useState('');
const [systemToken, setSystemToken] = useState('');
useEffect(() => {
let status = localStorage.getItem('status');
@@ -63,7 +80,7 @@ const PersonalSetting = () => {
const { success, message, data } = res.data;
if (success) {
setSystemToken(data);
setAffLink("");
setAffLink('');
await copy(data);
showSuccess(`Token has been reset and copied to the clipboard`);
} else {
@@ -77,7 +94,7 @@ const PersonalSetting = () => {
if (success) {
let link = `${window.location.origin}/register?aff=${data}`;
setAffLink(link);
setSystemToken("");
setSystemToken('');
await copy(link);
showSuccess(`Invitation link has been copied to the clipboard`);
} else {
@@ -169,18 +186,24 @@ const PersonalSetting = () => {
return (
<div style={{ lineHeight: '40px' }}>
<Header as='h3'>General Settings</Header>
<Message>
Note that, The token generated here is used for system managementNot for requesting OpenAI related servicesPlease be aware
</Message>
<Header as='h3'>{t('setting.personal.general.title')}</Header>
<Message>{t('setting.personal.general.system_token_notice')}</Message>
<Button as={Link} to={`/user/edit/`}>
Update Personal Information
{t('setting.personal.general.buttons.update_profile')}
</Button>
<Button onClick={generateAccessToken}>
{t('setting.personal.general.buttons.generate_token')}
</Button>
<Button onClick={getAffLink}>
{t('setting.personal.general.buttons.copy_invite')}
</Button>
<Button
onClick={() => {
setShowAccountDeleteModal(true);
}}
>
{t('setting.personal.general.buttons.delete_account')}
</Button>
<Button onClick={generateAccessToken}>Generate system access token</Button>
<Button onClick={getAffLink}>Copy invitation link</Button>
<Button onClick={() => {
setShowAccountDeleteModal(true);
}}>Delete Account</Button>
{systemToken && (
<Form.Input
@@ -201,18 +224,12 @@ const PersonalSetting = () => {
/>
)}
<Divider />
<Header as='h3'>Account binding</Header>
{
status.wechat_login && (
<Button
onClick={() => {
setShowWeChatBindModal(true);
}}
>
Bind WeChat Account
</Button>
)
}
<Header as='h3'>{t('setting.personal.binding.title')}</Header>
{status.wechat_login && (
<Button onClick={() => setShowWeChatBindModal(true)}>
{t('setting.personal.binding.buttons.bind_wechat')}
</Button>
)}
<Modal
onClose={() => setShowWeChatBindModal(false)}
onOpen={() => setShowWeChatBindModal(true)}
@@ -223,41 +240,37 @@ const PersonalSetting = () => {
<Modal.Description>
<Image src={status.wechat_qrcode} fluid />
<div style={{ textAlign: 'center' }}>
<p>
Scan the QR code with WeChat, follow the official account and enter 'verification code' to get the verification code (valid within three minutes)
</p>
<p>{t('setting.personal.binding.wechat.description')}</p>
</div>
<Form size='large'>
<Form.Input
fluid
placeholder='Verification code'
placeholder={t(
'setting.personal.binding.wechat.verification_code'
)}
name='wechat_verification_code'
value={inputs.wechat_verification_code}
onChange={handleInputChange}
/>
<Button color='' fluid size='large' onClick={bindWeChat}>
Bind
{t('setting.personal.binding.wechat.bind')}
</Button>
</Form>
</Modal.Description>
</Modal.Content>
</Modal>
{
status.github_oauth && (
<Button onClick={()=>{onGitHubOAuthClicked(status.github_client_id)}}>Bind GitHub Account</Button>
)
}
{
status.lark_client_id && (
<Button onClick={()=>{onLarkOAuthClicked(status.lark_client_id)}}>Bind Lark Account</Button>
)
}
<Button
onClick={() => {
setShowEmailBindModal(true);
}}
>
Bind email address
{status.github_oauth && (
<Button onClick={() => onGitHubOAuthClicked(status.github_client_id)}>
{t('setting.personal.binding.buttons.bind_github')}
</Button>
)}
{status.lark_client_id && (
<Button onClick={() => onLarkOAuthClicked(status.lark_client_id)}>
{t('setting.personal.binding.buttons.bind_lark')}
</Button>
)}
<Button onClick={() => setShowEmailBindModal(true)}>
{t('setting.personal.binding.buttons.bind_email')}
</Button>
<Modal
onClose={() => setShowEmailBindModal(false)}
@@ -266,57 +279,72 @@ const PersonalSetting = () => {
size={'tiny'}
style={{ maxWidth: '450px' }}
>
<Modal.Header>Bind email address</Modal.Header>
<Modal.Header>{t('setting.personal.binding.email.title')}</Modal.Header>
<Modal.Content>
<Modal.Description>
<Form size='large'>
<Form.Input
fluid
placeholder='Enter email address'
placeholder={t(
'setting.personal.binding.email.email_placeholder'
)}
onChange={handleInputChange}
name='email'
type='email'
action={
<Button onClick={sendVerificationCode} disabled={disableButton || loading}>
{disableButton ? `Resend(${countdown})` : 'Get verification code'}
<Button
onClick={sendVerificationCode}
disabled={disableButton || loading}
>
{disableButton
? t('setting.personal.binding.email.get_code_retry', {
countdown,
})
: t('setting.personal.binding.email.get_code')}
</Button>
}
/>
<Form.Input
fluid
placeholder='Verification code'
placeholder={t(
'setting.personal.binding.email.code_placeholder'
)}
name='email_verification_code'
value={inputs.email_verification_code}
onChange={handleInputChange}
/>
{turnstileEnabled ? (
{turnstileEnabled && (
<Turnstile
sitekey={turnstileSiteKey}
onVerify={(token) => {
setTurnstileToken(token);
}}
/>
) : (
<></>
)}
<div style={{ display: 'flex', justifyContent: 'space-between', marginTop: '1rem' }}>
<Button
color=''
fluid
size='large'
onClick={bindEmail}
loading={loading}
<div
style={{
display: 'flex',
justifyContent: 'space-between',
marginTop: '1rem',
}}
>
Confirm binding
</Button>
<div style={{ width: '1rem' }}></div>
<Button
fluid
size='large'
onClick={() => setShowEmailBindModal(false)}
>
Cancel
</Button>
<Button
color=''
fluid
size='large'
onClick={bindEmail}
loading={loading}
>
{t('setting.personal.binding.email.bind')}
</Button>
<div style={{ width: '1rem' }}></div>
<Button
fluid
size='large'
onClick={() => setShowEmailBindModal(false)}
>
{t('setting.personal.binding.email.cancel')}
</Button>
</div>
</Form>
</Modal.Description>
@@ -329,29 +357,40 @@ const PersonalSetting = () => {
size={'tiny'}
style={{ maxWidth: '450px' }}
>
<Modal.Header>Dangerous operation</Modal.Header>
<Modal.Header>
{t('setting.personal.delete_account.title')}
</Modal.Header>
<Modal.Content>
<Message>You are deleting your own account, all data will be cleared and cannot be recovered</Message>
<Message>{t('setting.personal.delete_account.warning')}</Message>
<Modal.Description>
<Form size='large'>
<Form.Input
fluid
placeholder={`Enter your account name ${userState?.user?.username} To confirm deletion`}
placeholder={t(
'setting.personal.delete_account.confirm_placeholder',
{
username: userState?.user?.username,
}
)}
name='self_account_deletion_confirmation'
value={inputs.self_account_deletion_confirmation}
onChange={handleInputChange}
/>
{turnstileEnabled ? (
{turnstileEnabled && (
<Turnstile
sitekey={turnstileSiteKey}
onVerify={(token) => {
setTurnstileToken(token);
}}
/>
) : (
<></>
)}
<div style={{ display: 'flex', justifyContent: 'space-between', marginTop: '1rem' }}>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
marginTop: '1rem',
}}
>
<Button
color='red'
fluid
@@ -359,7 +398,7 @@ const PersonalSetting = () => {
onClick={deleteAccount}
loading={loading}
>
Confirm deletion
{t('setting.personal.delete_account.buttons.confirm')}
</Button>
<div style={{ width: '1rem' }}></div>
<Button
@@ -367,7 +406,7 @@ const PersonalSetting = () => {
size='large'
onClick={() => setShowAccountDeleteModal(false)}
>
Cancel
{t('setting.personal.delete_account.buttons.cancel')}
</Button>
</div>
</Form>

View File

@@ -1,4 +1,5 @@
import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
Button,
Form,
@@ -25,36 +26,37 @@ function renderTimestamp(timestamp) {
return <>{timestamp2string(timestamp)}</>;
}
function renderStatus(status) {
function renderStatus(status, t) {
switch (status) {
case 1:
return (
<Label basic color='green'>
Unused
{t('redemption.status.unused')}
</Label>
);
case 2:
return (
<Label basic color='red'>
Disabled
{t('redemption.status.disabled')}
</Label>
);
case 3:
return (
<Label basic color='grey'>
Used
{t('redemption.status.used')}
</Label>
);
default:
return (
<Label basic color='black'>
Unknown status
{t('redemption.status.unknown')}
</Label>
);
}
}
const RedemptionsTable = () => {
const { t } = useTranslation();
const [redemptions, setRedemptions] = useState([]);
const [loading, setLoading] = useState(true);
const [activePage, setActivePage] = useState(1);
@@ -114,7 +116,7 @@ const RedemptionsTable = () => {
}
const { success, message } = res.data;
if (success) {
showSuccess('Operation successfully completed!');
showSuccess(t('token.messages.operation_success'));
let redemption = res.data.data;
let newRedemptions = [...redemptions];
let realIdx = (activePage - 1) * ITEMS_PER_PAGE + idx;
@@ -174,6 +176,12 @@ const RedemptionsTable = () => {
setLoading(false);
};
const refresh = async () => {
setLoading(true);
await loadRedemptions(0);
setActivePage(1);
};
return (
<>
<Form onSubmit={searchRedemptions}>
@@ -181,7 +189,7 @@ const RedemptionsTable = () => {
icon='search'
fluid
iconPosition='left'
placeholder='Search for the ID and name of the redemption code ...'
placeholder={t('redemption.search')}
value={searchKeyword}
loading={searching}
onChange={handleKeywordChange}
@@ -197,7 +205,7 @@ const RedemptionsTable = () => {
sortRedemption('id');
}}
>
ID
{t('redemption.table.id')}
</Table.HeaderCell>
<Table.HeaderCell
style={{ cursor: 'pointer' }}
@@ -205,7 +213,7 @@ const RedemptionsTable = () => {
sortRedemption('name');
}}
>
Name
{t('redemption.table.name')}
</Table.HeaderCell>
<Table.HeaderCell
style={{ cursor: 'pointer' }}
@@ -213,7 +221,7 @@ const RedemptionsTable = () => {
sortRedemption('status');
}}
>
Status
{t('redemption.table.status')}
</Table.HeaderCell>
<Table.HeaderCell
style={{ cursor: 'pointer' }}
@@ -221,7 +229,7 @@ const RedemptionsTable = () => {
sortRedemption('quota');
}}
>
Quota
{t('redemption.table.quota')}
</Table.HeaderCell>
<Table.HeaderCell
style={{ cursor: 'pointer' }}
@@ -229,7 +237,7 @@ const RedemptionsTable = () => {
sortRedemption('created_time');
}}
>
Creation time
{t('redemption.table.created_time')}
</Table.HeaderCell>
<Table.HeaderCell
style={{ cursor: 'pointer' }}
@@ -237,9 +245,9 @@ const RedemptionsTable = () => {
sortRedemption('redeemed_time');
}}
>
Redemption time
{t('redemption.table.redeemed_time')}
</Table.HeaderCell>
<Table.HeaderCell>Operation</Table.HeaderCell>
<Table.HeaderCell>{t('redemption.table.actions')}</Table.HeaderCell>
</Table.Row>
</Table.Header>
@@ -255,76 +263,76 @@ const RedemptionsTable = () => {
<Table.Row key={redemption.id}>
<Table.Cell>{redemption.id}</Table.Cell>
<Table.Cell>
{redemption.name ? redemption.name : 'None'}
{redemption.name ? redemption.name : t('redemption.table.no_name')}
</Table.Cell>
<Table.Cell>{renderStatus(redemption.status)}</Table.Cell>
<Table.Cell>{renderQuota(redemption.quota)}</Table.Cell>
<Table.Cell>{renderStatus(redemption.status, t)}</Table.Cell>
<Table.Cell>{renderQuota(redemption.quota, t)}</Table.Cell>
<Table.Cell>
{renderTimestamp(redemption.created_time)}
</Table.Cell>
<Table.Cell>
{redemption.redeemed_time
? renderTimestamp(redemption.redeemed_time)
: 'Not redeemed yet'}{' '}
{redemption.redeemed_time
? renderTimestamp(redemption.redeemed_time)
: t('redemption.table.not_redeemed')}{' '}
</Table.Cell>
<Table.Cell>
<div>
<Button
size={'small'}
positive
onClick={async () => {
if (await copy(redemption.key)) {
showSuccess('Copied to clipboard!');
} else {
showWarning(
'Unable to copy to clipboard, please copy manually. The redemption code has been filled into the search box.'
);
setSearchKeyword(redemption.key);
}
}}
>
Copy
</Button>
<Popup
trigger={
<Button size='small' negative>
Delete
<div>
<Button
size={'tiny'}
positive
onClick={async () => {
if (await copy(redemption.key)) {
showSuccess(t('token.messages.copy_success'));
} else {
showWarning(t('token.messages.copy_failed'));
setSearchKeyword(redemption.key);
}
}}
>
{t('redemption.buttons.copy')}
</Button>
}
on='click'
flowing
hoverable
>
<Button
negative
onClick={() => {
manageRedemption(redemption.id, 'delete', idx);
}}
>
Confirm deletion
</Button>
</Popup>
<Button
size={'small'}
disabled={redemption.status === 3} // used
onClick={() => {
manageRedemption(
redemption.id,
redemption.status === 1 ? 'disable' : 'enable',
idx
);
}}
>
{redemption.status === 1 ? 'Disable' : 'Enable'}
</Button>
<Button
size={'small'}
as={Link}
to={'/redemption/edit/' + redemption.id}
>
Edit
</Button>
</div>
<Popup
trigger={
<Button size='tiny' negative>
{t('redemption.buttons.delete')}
</Button>
}
on='click'
flowing
hoverable
>
<Button
negative
onClick={() => {
manageRedemption(redemption.id, 'delete', idx);
}}
>
{t('redemption.buttons.confirm_delete')}
</Button>
</Popup>
<Button
size={'tiny'}
disabled={redemption.status === 3} // used
onClick={() => {
manageRedemption(
redemption.id,
redemption.status === 1 ? 'disable' : 'enable',
idx
);
}}
>
{redemption.status === 1
? t('redemption.buttons.disable')
: t('redemption.buttons.enable')}
</Button>
<Button
size={'tiny'}
as={Link}
to={'/redemption/edit/' + redemption.id}
>
{t('redemption.buttons.edit')}
</Button>
</div>
</Table.Cell>
</Table.Row>
);
@@ -333,14 +341,17 @@ const RedemptionsTable = () => {
<Table.Footer>
<Table.Row>
<Table.HeaderCell colSpan='8'>
<Table.HeaderCell colSpan='7'>
<Button
size='small'
as={Link}
to='/redemption/add'
loading={loading}
>
Add new redemption code
{t('redemption.buttons.add')}
</Button>
<Button size='small' onClick={refresh} loading={loading}>
{t('redemption.buttons.refresh')}
</Button>
<Pagination
floated='right'

View File

@@ -6,15 +6,16 @@ import {
Header,
Image,
Message,
Segment,
Card,
Divider,
} from 'semantic-ui-react';
import { Link, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { API, getLogo, showError, showInfo, showSuccess } from '../helpers';
import Turnstile from 'react-turnstile';
const RegisterForm = () => {
const { t } = useTranslation();
const [inputs, setInputs] = useState({
username: '',
password: '',
@@ -28,6 +29,8 @@ const RegisterForm = () => {
const [turnstileSiteKey, setTurnstileSiteKey] = useState('');
const [turnstileToken, setTurnstileToken] = useState('');
const [loading, setLoading] = useState(false);
const [disableButton, setDisableButton] = useState(false);
const [countdown, setCountdown] = useState(30);
const logo = getLogo();
let affCode = new URLSearchParams(window.location.search).get('aff');
if (affCode) {
@@ -46,6 +49,19 @@ const RegisterForm = () => {
}
});
useEffect(() => {
let countdownInterval = null;
if (disableButton && countdown > 0) {
countdownInterval = setInterval(() => {
setCountdown(countdown - 1);
}, 1000);
} else if (countdown === 0) {
setDisableButton(false);
setCountdown(30);
}
return () => clearInterval(countdownInterval);
}, [disableButton, countdown]);
let navigate = useNavigate();
function handleChange(e) {
@@ -56,16 +72,16 @@ const RegisterForm = () => {
async function handleSubmit(e) {
if (password.length < 8) {
showInfo('Password length must not be less than 8 characters!');
showInfo(t('messages.error.password_length'));
return;
}
if (password !== password2) {
showInfo('The two passwords entered do not match');
showInfo(t('messages.error.password_mismatch'));
return;
}
if (username && password) {
if (turnstileEnabled && turnstileToken === '') {
showInfo('Please try again in a few seconds, Turnstile is checking the user environment!');
showInfo(t('messages.error.turnstile_wait'));
return;
}
setLoading(true);
@@ -80,7 +96,7 @@ const RegisterForm = () => {
const { success, message } = res.data;
if (success) {
navigate('/login');
showSuccess('Registration successful!');
showSuccess(t('messages.success.register'));
} else {
showError(message);
}
@@ -91,18 +107,21 @@ const RegisterForm = () => {
const sendVerificationCode = async () => {
if (inputs.email === '') return;
if (turnstileEnabled && turnstileToken === '') {
showInfo('Please try again in a few seconds, Turnstile is checking the user environment!');
showInfo(t('messages.error.turnstile_wait'));
return;
}
setDisableButton(true);
setLoading(true);
const res = await API.get(
`/api/verification?email=${inputs.email}&turnstile=${turnstileToken}`
);
const { success, message } = res.data;
if (success) {
showSuccess('Verification code sent successfully, please check your email!');
showSuccess(t('messages.success.verification_code'));
} else {
showError(message);
setDisableButton(false);
setCountdown(30);
}
setLoading(false);
};
@@ -123,7 +142,7 @@ const RegisterForm = () => {
style={{ marginBottom: '1.5em' }}
>
<Image src={logo} style={{ marginBottom: '10px' }} />
<Header.Content>New User Registration</Header.Content>
<Header.Content>{t('auth.register.title')}</Header.Content>
</Header>
</Card.Header>
<Form size='large'>
@@ -131,7 +150,7 @@ const RegisterForm = () => {
fluid
icon='user'
iconPosition='left'
placeholder='Enter username, up to 12 characters'
placeholder={t('auth.register.username')}
onChange={handleChange}
name='username'
style={{ marginBottom: '1em' }}
@@ -140,7 +159,7 @@ const RegisterForm = () => {
fluid
icon='lock'
iconPosition='left'
placeholder='Enter password, minimum 8 characters, maximum 20 characters'
placeholder={t('auth.register.password')}
onChange={handleChange}
name='password'
type='password'
@@ -150,7 +169,7 @@ const RegisterForm = () => {
fluid
icon='lock'
iconPosition='left'
placeholder='Re-enter password'
placeholder={t('auth.register.confirm_password')}
onChange={handleChange}
name='password2'
type='password'
@@ -163,16 +182,15 @@ const RegisterForm = () => {
fluid
icon='mail'
iconPosition='left'
placeholder='Enter email address'
placeholder={t('auth.register.email')}
onChange={handleChange}
name='email'
type='email'
action={
<Button
onClick={sendVerificationCode}
disabled={loading}
>
Get Verification Code
<Button onClick={sendVerificationCode} disabled={loading}>
{disableButton
? t('auth.register.get_code_retry', { countdown })
: t('auth.register.get_code')}
</Button>
}
style={{ marginBottom: '1em' }}
@@ -181,7 +199,7 @@ const RegisterForm = () => {
fluid
icon='lock'
iconPosition='left'
placeholder='Enter verification code'
placeholder={t('auth.register.verification_code')}
onChange={handleChange}
name='verification_code'
style={{ marginBottom: '1em' }}
@@ -217,7 +235,7 @@ const RegisterForm = () => {
}}
loading={loading}
>
Register
{t('auth.register.button')}
</Button>
</Form>
@@ -230,9 +248,12 @@ const RegisterForm = () => {
color: '#666',
}}
>
Already have an account?
<Link to='/login' style={{ color: '#2185d0' }}>
Click to login
{t('auth.register.has_account')}
<Link
to='/login'
style={{ color: '#2185d0', marginLeft: '2px' }}
>
{t('auth.register.login')}
</Link>
</div>
</Message>

View File

@@ -1,8 +1,18 @@
import React, { useEffect, useState } from 'react';
import { Button, Divider, Form, Grid, Header, Modal, Message } from 'semantic-ui-react';
import { useTranslation } from 'react-i18next';
import {
Button,
Divider,
Form,
Grid,
Header,
Modal,
Message,
} from 'semantic-ui-react';
import { API, removeTrailingSlash, showError } from '../helpers';
const SystemSetting = () => {
const { t } = useTranslation();
let [inputs, setInputs] = useState({
PasswordLoginEnabled: '',
PasswordRegisterEnabled: '',
@@ -31,13 +41,14 @@ const SystemSetting = () => {
TurnstileSecretKey: '',
RegisterEnabled: '',
EmailDomainRestrictionEnabled: '',
EmailDomainWhitelist: ''
EmailDomainWhitelist: '',
});
const [originInputs, setOriginInputs] = useState({});
let [loading, setLoading] = useState(false);
const [EmailDomainWhitelist, setEmailDomainWhitelist] = useState([]);
const [restrictedDomainInput, setRestrictedDomainInput] = useState('');
const [showPasswordWarningModal, setShowPasswordWarningModal] = useState(false);
const [showPasswordWarningModal, setShowPasswordWarningModal] =
useState(false);
const getOptions = async () => {
const res = await API.get('/api/option/');
@@ -49,13 +60,15 @@ const SystemSetting = () => {
});
setInputs({
...newInputs,
EmailDomainWhitelist: newInputs.EmailDomainWhitelist.split(',')
EmailDomainWhitelist: newInputs.EmailDomainWhitelist.split(','),
});
setOriginInputs(newInputs);
setEmailDomainWhitelist(newInputs.EmailDomainWhitelist.split(',').map((item) => {
return { key: item, text: item, value: item };
}));
setEmailDomainWhitelist(
newInputs.EmailDomainWhitelist.split(',').map((item) => {
return { key: item, text: item, value: item };
})
);
} else {
showError(message);
}
@@ -83,7 +96,7 @@ const SystemSetting = () => {
}
const res = await API.put('/api/option/', {
key,
value
value,
});
const { success, message } = res.data;
if (success) {
@@ -91,7 +104,8 @@ const SystemSetting = () => {
value = value.split(',');
}
setInputs((inputs) => ({
...inputs, [key]: value
...inputs,
[key]: value,
}));
} else {
showError(message);
@@ -155,13 +169,16 @@ const SystemSetting = () => {
}
};
const submitEmailDomainWhitelist = async () => {
if (
originInputs['EmailDomainWhitelist'] !== inputs.EmailDomainWhitelist.join(',') &&
originInputs['EmailDomainWhitelist'] !==
inputs.EmailDomainWhitelist.join(',') &&
inputs.SMTPToken !== ''
) {
await updateOption('EmailDomainWhitelist', inputs.EmailDomainWhitelist.join(','));
await updateOption(
'EmailDomainWhitelist',
inputs.EmailDomainWhitelist.join(',')
);
}
};
@@ -216,7 +233,7 @@ const SystemSetting = () => {
}
};
const submitLarkOAuth = async () => {
const submitLarkOAuth = async () => {
if (originInputs['LarkClientId'] !== inputs.LarkClientId) {
await updateOption('LarkClientId', inputs.LarkClientId);
}
@@ -242,60 +259,71 @@ const SystemSetting = () => {
const submitNewRestrictedDomain = () => {
const localDomainList = inputs.EmailDomainWhitelist;
if (restrictedDomainInput !== '' && !localDomainList.includes(restrictedDomainInput)) {
if (
restrictedDomainInput !== '' &&
!localDomainList.includes(restrictedDomainInput)
) {
setRestrictedDomainInput('');
setInputs({
...inputs,
EmailDomainWhitelist: [...localDomainList, restrictedDomainInput],
});
setEmailDomainWhitelist([...EmailDomainWhitelist, {
key: restrictedDomainInput,
text: restrictedDomainInput,
value: restrictedDomainInput,
}]);
setEmailDomainWhitelist([
...EmailDomainWhitelist,
{
key: restrictedDomainInput,
text: restrictedDomainInput,
value: restrictedDomainInput,
},
]);
}
}
};
return (
<Grid columns={1}>
<Grid.Column>
<Form loading={loading}>
<Header as='h3'>General Settings</Header>
<Header as='h3'>{t('setting.system.general.title')}</Header>
<Form.Group widths='equal'>
<Form.Input
label='Server Address'
placeholder='For example: https://yourdomain.com'
label={t('setting.system.general.server_address')}
placeholder={t(
'setting.system.general.server_address_placeholder'
)}
value={inputs.ServerAddress}
name='ServerAddress'
onChange={handleInputChange}
/>
</Form.Group>
<Form.Button onClick={submitServerAddress}>
Update Server Address
{t('setting.system.general.buttons.update')}
</Form.Button>
<Divider />
<Header as='h3'>Configure Login/Registration</Header>
<Header as='h3'>{t('setting.system.login.title')}</Header>
<Form.Group inline>
<Form.Checkbox
checked={inputs.PasswordLoginEnabled === 'true'}
label='Allow login via password'
label={t('setting.system.login.password_login')}
name='PasswordLoginEnabled'
onChange={handleInputChange}
/>
{
showPasswordWarningModal &&
{showPasswordWarningModal && (
<Modal
open={showPasswordWarningModal}
onClose={() => setShowPasswordWarningModal(false)}
size={'tiny'}
style={{ maxWidth: '450px' }}
>
<Modal.Header>Warning</Modal.Header>
<Modal.Header>
{t('setting.system.password_login.warning.title')}
</Modal.Header>
<Modal.Content>
<p>Canceling password login will cause all users (including administrators) who have not bound other login methods to be unable to log in via password, confirm cancel?</p>
<p>{t('setting.system.password_login.warning.content')}</p>
</Modal.Content>
<Modal.Actions>
<Button onClick={() => setShowPasswordWarningModal(false)}>Cancel</Button>
<Button onClick={() => setShowPasswordWarningModal(false)}>
{t('setting.system.password_login.warning.buttons.cancel')}
</Button>
<Button
color='yellow'
onClick={async () => {
@@ -303,32 +331,32 @@ const SystemSetting = () => {
await updateOption('PasswordLoginEnabled', 'false');
}}
>
Confirm
{t('setting.system.password_login.warning.buttons.confirm')}
</Button>
</Modal.Actions>
</Modal>
}
)}
<Form.Checkbox
checked={inputs.PasswordRegisterEnabled === 'true'}
label='Allow registration via password'
label={t('setting.system.login.password_register')}
name='PasswordRegisterEnabled'
onChange={handleInputChange}
/>
<Form.Checkbox
checked={inputs.EmailVerificationEnabled === 'true'}
label='Email verification is required when registering via password'
label={t('setting.system.login.email_verification')}
name='EmailVerificationEnabled'
onChange={handleInputChange}
/>
<Form.Checkbox
checked={inputs.GitHubOAuthEnabled === 'true'}
label='Allow login & registration via GitHub account'
label={t('setting.system.login.github_oauth')}
name='GitHubOAuthEnabled'
onChange={handleInputChange}
/>
<Form.Checkbox
checked={inputs.WeChatAuthEnabled === 'true'}
label='Allow login & registration via WeChat'
label={t('setting.system.login.wechat_login')}
name='WeChatAuthEnabled'
onChange={handleInputChange}
/>
@@ -336,304 +364,295 @@ const SystemSetting = () => {
<Form.Group inline>
<Form.Checkbox
checked={inputs.RegisterEnabled === 'true'}
label='Allow new user registration (if this option is off, new users will not be able to register in any way)'
label={t('setting.system.login.registration')}
name='RegisterEnabled'
onChange={handleInputChange}
/>
<Form.Checkbox
checked={inputs.TurnstileCheckEnabled === 'true'}
label='Enable Turnstile user verification'
label={t('setting.system.login.turnstile')}
name='TurnstileCheckEnabled'
onChange={handleInputChange}
/>
</Form.Group>
<Divider />
<Header as='h3'>
Configure Email Domain Whitelist
<Header.Subheader>To prevent malicious users from using temporary emails to sign up in bulk</Header.Subheader>
</Header>
<Form.Group widths={3}>
<Header as='h3'>{t('setting.system.email_restriction.title')}</Header>
<Message>{t('setting.system.email_restriction.subtitle')}</Message>
<Form.Group inline>
<Form.Checkbox
label='Enable Email Domain Whitelist'
checked={inputs.EmailDomainRestrictionEnabled === 'true'}
label={t('setting.system.email_restriction.enable')}
name='EmailDomainRestrictionEnabled'
onChange={handleInputChange}
checked={inputs.EmailDomainRestrictionEnabled === 'true'}
/>
</Form.Group>
<Form.Group widths={2}>
<Form.Dropdown
label='Allowed Email Domains'
placeholder='Allowed Email Domains'
name='EmailDomainWhitelist'
required
fluid
multiple
selection
onChange={handleInputChange}
value={inputs.EmailDomainWhitelist}
autoComplete='new-password'
options={EmailDomainWhitelist}
/>
<Form.Group widths={3}>
<Form.Input
label='Add New Allowed Email Domain'
action={
<Button type='button' onClick={() => {
submitNewRestrictedDomain();
}}>Add</Button>
}
onKeyDown={(e) => {
if (e.key === 'Enter') {
submitNewRestrictedDomain();
}
}}
autoComplete='new-password'
placeholder='Enter new allowed email domain'
label={t('setting.system.email_restriction.add_domain')}
placeholder={t(
'setting.system.email_restriction.add_domain_placeholder'
)}
value={restrictedDomainInput}
onChange={(e, { value }) => {
setRestrictedDomainInput(value);
}}
action={
<Button
onClick={() => {
if (restrictedDomainInput === '') return;
setEmailDomainWhitelist([
...EmailDomainWhitelist,
{
key: restrictedDomainInput,
text: restrictedDomainInput,
value: restrictedDomainInput,
},
]);
setRestrictedDomainInput('');
}}
>
{t('setting.system.email_restriction.buttons.fill')}
</Button>
}
/>
</Form.Group>
<Form.Button onClick={submitEmailDomainWhitelist}>Save Email Domain Whitelist Settings</Form.Button>
<Form.Dropdown
label={t('setting.system.email_restriction.allowed_domains')}
placeholder={t('setting.system.email_restriction.allowed_domains')}
fluid
multiple
search
selection
allowAdditions
value={EmailDomainWhitelist.map((item) => item.value)}
options={EmailDomainWhitelist}
onAddItem={(e, { value }) => {
setEmailDomainWhitelist([
...EmailDomainWhitelist,
{
key: value,
text: value,
value: value,
},
]);
}}
onChange={(e, { value }) => {
let newEmailDomainWhitelist = [];
value.forEach((item) => {
newEmailDomainWhitelist.push({
key: item,
text: item,
value: item,
});
});
setEmailDomainWhitelist(newEmailDomainWhitelist);
}}
/>
<Form.Button onClick={submitEmailDomainWhitelist}>
{t('setting.system.email_restriction.buttons.save')}
</Form.Button>
<Divider />
<Header as='h3'>
Configure SMTP
<Header.Subheader>To support the system email sending</Header.Subheader>
</Header>
<Header as='h3'>{t('setting.system.smtp.title')}</Header>
<Message>{t('setting.system.smtp.subtitle')}</Message>
<Form.Group widths={3}>
<Form.Input
label='SMTP Server Address'
label={t('setting.system.smtp.server')}
placeholder={t('setting.system.smtp.server_placeholder')}
name='SMTPServer'
onChange={handleInputChange}
autoComplete='new-password'
value={inputs.SMTPServer}
placeholder='For example: smtp.qq.com'
/>
<Form.Input
label='SMTP Port'
label={t('setting.system.smtp.port')}
placeholder={t('setting.system.smtp.port_placeholder')}
name='SMTPPort'
onChange={handleInputChange}
autoComplete='new-password'
value={inputs.SMTPPort}
placeholder='Default: 587'
/>
<Form.Input
label='SMTP Account'
label={t('setting.system.smtp.account')}
placeholder={t('setting.system.smtp.account_placeholder')}
name='SMTPAccount'
onChange={handleInputChange}
autoComplete='new-password'
value={inputs.SMTPAccount}
placeholder='Usually an email address'
/>
</Form.Group>
<Form.Group widths={3}>
<Form.Input
label='SMTP Sender Email'
label={t('setting.system.smtp.from')}
placeholder={t('setting.system.smtp.from_placeholder')}
name='SMTPFrom'
onChange={handleInputChange}
autoComplete='new-password'
value={inputs.SMTPFrom}
placeholder='Usually consistent with the email address'
/>
<Form.Input
label='SMTP Access Credential'
label={t('setting.system.smtp.token')}
placeholder={t('setting.system.smtp.token_placeholder')}
name='SMTPToken'
onChange={handleInputChange}
type='password'
autoComplete='new-password'
checked={inputs.RegisterEnabled === 'true'}
placeholder='Sensitive information will not be displayed in the frontend'
value={inputs.SMTPToken}
/>
</Form.Group>
<Form.Button onClick={submitSMTP}>Save SMTP Settings</Form.Button>
<Form.Button onClick={submitSMTP}>
{t('setting.system.smtp.buttons.save')}
</Form.Button>
<Divider />
<Header as='h3'>
Configure GitHub OAuth App
<Header.Subheader>
To support login & registration via GitHub,
<a href='https://github.com/settings/developers' target='_blank'>
Click here
</a>
Manage your GitHub OAuth App
</Header.Subheader>
</Header>
<Header as='h3'>{t('setting.system.github.title')}</Header>
<Message>
Fill in the Homepage URL <code>{inputs.ServerAddress}</code>
, Fill in the Authorization callback URL{' '}
<code>{`${inputs.ServerAddress}/oauth/github`}</code>
{t('setting.system.github.subtitle')}
<a href='https://github.com/settings/developers' target='_blank'>
{t('setting.system.github.manage_link')}
</a>
{t('setting.system.github.manage_text')}
</Message>
<Message>
{t('setting.system.github.url_notice', {
server_url: originInputs.ServerAddress,
callback_url: `${originInputs.ServerAddress}/oauth/github`,
})}
</Message>
<Form.Group widths={3}>
<Form.Input
label='GitHub Client ID'
label={t('setting.system.github.client_id')}
placeholder={t('setting.system.github.client_id_placeholder')}
name='GitHubClientId'
onChange={handleInputChange}
autoComplete='new-password'
value={inputs.GitHubClientId}
placeholder='Enter your registered GitHub OAuth APP ID'
/>
<Form.Input
label='GitHub Client Secret'
label={t('setting.system.github.client_secret')}
placeholder={t('setting.system.github.client_secret_placeholder')}
name='GitHubClientSecret'
onChange={handleInputChange}
type='password'
autoComplete='new-password'
value={inputs.GitHubClientSecret}
placeholder='Sensitive information will not be displayed in the frontend'
/>
</Form.Group>
<Form.Button onClick={submitGitHubOAuth}>
Save GitHub OAuth Settings
{t('setting.system.github.buttons.save')}
</Form.Button>
<Divider />
<Header as='h3'>
Configure Lark OAuth
{t('setting.system.lark.title')}
<Header.Subheader>
To support login & registration via Lark,
{t('setting.system.lark.subtitle')}
<a href='https://open.feishu.cn/app' target='_blank'>
Click here
{t('setting.system.lark.manage_link')}
</a>
Manage your Lark App
{t('setting.system.lark.manage_text')}
</Header.Subheader>
</Header>
<Message>
Fill in the Homepage URL <code>{inputs.ServerAddress}</code>
, Fill in the Redirect URL{' '}
<code>{`${inputs.ServerAddress}/oauth/lark`}</code>
{t('setting.system.lark.url_notice', {
server_url: inputs.ServerAddress,
callback_url: `${inputs.ServerAddress}/oauth/lark`,
})}
</Message>
<Form.Group widths={3}>
<Form.Input
label='App ID'
label={t('setting.system.lark.client_id')}
name='LarkClientId'
onChange={handleInputChange}
autoComplete='new-password'
value={inputs.LarkClientId}
placeholder='Enter App ID'
placeholder={t('setting.system.lark.client_id_placeholder')}
/>
<Form.Input
label='App Secret'
label={t('setting.system.lark.client_secret')}
name='LarkClientSecret'
onChange={handleInputChange}
type='password'
autoComplete='new-password'
value={inputs.LarkClientSecret}
placeholder='Sensitive information will not be displayed in the frontend'
placeholder={t('setting.system.lark.client_secret_placeholder')}
/>
</Form.Group>
<Form.Button onClick={submitLarkOAuth}>
Save Lark OAuth Settings
{t('setting.system.lark.buttons.save')}
</Form.Button>
<Divider />
<Header as='h3'>
Configure WeChat Server
{t('setting.system.wechat.title')}
<Header.Subheader>
To support login & registration via WeChat,
{t('setting.system.wechat.subtitle')}
<a
href='https://github.com/songquanpeng/wechat-server'
target='_blank'
>
Click here
{t('setting.system.wechat.learn_more')}
</a>
Learn about WeChat Server
</Header.Subheader>
</Header>
<Form.Group widths={3}>
<Form.Input
label='WeChat Server Address'
label={t('setting.system.wechat.server_address')}
name='WeChatServerAddress'
placeholder='For example: https://yourdomain.com'
onChange={handleInputChange}
autoComplete='new-password'
value={inputs.WeChatServerAddress}
placeholder={t(
'setting.system.wechat.server_address_placeholder'
)}
/>
<Form.Input
label='WeChat Server Access Credential'
label={t('setting.system.wechat.token')}
name='WeChatServerToken'
type='password'
onChange={handleInputChange}
type='password'
autoComplete='new-password'
value={inputs.WeChatServerToken}
placeholder='Sensitive information will not be displayed in the frontend'
placeholder={t('setting.system.wechat.token_placeholder')}
/>
<Form.Input
label='WeChat Public Account QR Code Image Link'
label={t('setting.system.wechat.qrcode')}
name='WeChatAccountQRCodeImageURL'
onChange={handleInputChange}
autoComplete='new-password'
value={inputs.WeChatAccountQRCodeImageURL}
placeholder='Enter an image link'
placeholder={t('setting.system.wechat.qrcode_placeholder')}
/>
</Form.Group>
<Form.Button onClick={submitWeChat}>
Save WeChat Server Settings
{t('setting.system.wechat.buttons.save')}
</Form.Button>
<Divider />
<Header as='h3'>
Configure Message Pusher
{t('setting.system.turnstile.title')}
<Header.Subheader>
To push alert messages,
<a
href='https://github.com/songquanpeng/message-pusher'
target='_blank'
>
Click here
</a>
Learn about Message Pusher
</Header.Subheader>
</Header>
<Form.Group widths={3}>
<Form.Input
label='Message Pusher Address'
name='MessagePusherAddress'
placeholder='For example: https://msgpusher.com/push/your_username'
onChange={handleInputChange}
autoComplete='new-password'
value={inputs.MessagePusherAddress}
/>
<Form.Input
label='Message Pusher Access Credential'
name='MessagePusherToken'
type='password'
onChange={handleInputChange}
autoComplete='new-password'
value={inputs.MessagePusherToken}
placeholder='Sensitive information will not be displayed in the frontend'
/>
</Form.Group>
<Form.Button onClick={submitMessagePusher}>
Save Message Pusher Settings
</Form.Button>
<Divider />
<Header as='h3'>
Configure Turnstile
<Header.Subheader>
To support user verification,
{t('setting.system.turnstile.subtitle')}
<a href='https://dash.cloudflare.com/' target='_blank'>
Click here
{t('setting.system.turnstile.manage_link')}
</a>
Manage your Turnstile Sites, recommend selecting Invisible Widget Type
{t('setting.system.turnstile.manage_text')}
</Header.Subheader>
</Header>
<Form.Group widths={3}>
<Form.Input
label='Turnstile Site Key'
label={t('setting.system.turnstile.site_key')}
name='TurnstileSiteKey'
onChange={handleInputChange}
autoComplete='new-password'
value={inputs.TurnstileSiteKey}
placeholder='Enter your registered Turnstile Site Key'
placeholder={t('setting.system.turnstile.site_key_placeholder')}
/>
<Form.Input
label='Turnstile Secret Key'
label={t('setting.system.turnstile.secret_key')}
name='TurnstileSecretKey'
onChange={handleInputChange}
type='password'
autoComplete='new-password'
value={inputs.TurnstileSecretKey}
placeholder='Sensitive information will not be displayed in the frontend'
placeholder={t('setting.system.turnstile.secret_key_placeholder')}
/>
</Form.Group>
<Form.Button onClick={submitTurnstile}>
Save Turnstile Settings
{t('setting.system.turnstile.buttons.save')}
</Form.Button>
</Form>
</Grid.Column>

View File

@@ -1,4 +1,5 @@
import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
Button,
Dropdown,
@@ -21,59 +22,63 @@ import {
import { ITEMS_PER_PAGE } from '../constants';
import { renderQuota } from '../helpers/render';
const COPY_OPTIONS = [
{ key: 'web', text: 'Web', value: 'web' },
{ 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 (
<Label basic color='green'>
Enabled
{t('token.table.status_enabled')}
</Label>
);
case 2:
return (
<Label basic color='red'>
Disabled
{t('token.table.status_disabled')}
</Label>
);
case 3:
return (
<Label basic color='yellow'>
Expired
{t('token.table.status_expired')}
</Label>
);
case 4:
return (
<Label basic color='grey'>
Exhausted
{t('token.table.status_depleted')}
</Label>
);
default:
return (
<Label basic color='black'>
Unknown Status
{t('token.table.status_unknown')}
</Label>
);
}
}
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);
@@ -156,9 +161,9 @@ const TokensTable = () => {
url = `sk-${key}`;
}
if (await copy(url)) {
showSuccess('Copied to clipboard!');
showSuccess(t('token.messages.copy_success'));
} else {
showWarning('Unable to copy to clipboard, please copy manually, the token has been entered into the search box。');
showWarning(t('token.messages.copy_failed'));
setSearchKeyword(url);
}
};
@@ -232,7 +237,7 @@ const TokensTable = () => {
}
const { success, message } = res.data;
if (success) {
showSuccess('Operation successfully completed!');
showSuccess(t('token.messages.operation_success'));
let token = res.data.data;
let newTokens = [...tokens];
let realIdx = (activePage - 1) * ITEMS_PER_PAGE + idx;
@@ -303,7 +308,7 @@ const TokensTable = () => {
icon='search'
fluid
iconPosition='left'
placeholder='Search for the name of the token...'
placeholder={t('token.search')}
value={searchKeyword}
loading={searching}
onChange={handleKeywordChange}
@@ -319,7 +324,7 @@ const TokensTable = () => {
sortToken('name');
}}
>
Name
{t('token.table.name')}
</Table.HeaderCell>
<Table.HeaderCell
style={{ cursor: 'pointer' }}
@@ -327,7 +332,7 @@ const TokensTable = () => {
sortToken('status');
}}
>
Status
{t('token.table.status')}
</Table.HeaderCell>
<Table.HeaderCell
style={{ cursor: 'pointer' }}
@@ -335,7 +340,7 @@ const TokensTable = () => {
sortToken('used_quota');
}}
>
Used quota
{t('token.table.used_quota')}
</Table.HeaderCell>
<Table.HeaderCell
style={{ cursor: 'pointer' }}
@@ -343,7 +348,7 @@ const TokensTable = () => {
sortToken('remain_quota');
}}
>
Remaining quota
{t('token.table.remain_quota')}
</Table.HeaderCell>
<Table.HeaderCell
style={{ cursor: 'pointer' }}
@@ -351,7 +356,7 @@ const TokensTable = () => {
sortToken('created_time');
}}
>
Creation time
{t('token.table.created_time')}
</Table.HeaderCell>
<Table.HeaderCell
style={{ cursor: 'pointer' }}
@@ -359,9 +364,9 @@ const TokensTable = () => {
sortToken('expired_time');
}}
>
Expiration time
{t('token.table.expired_time')}
</Table.HeaderCell>
<Table.HeaderCell>Operation</Table.HeaderCell>
<Table.HeaderCell>{t('token.table.actions')}</Table.HeaderCell>
</Table.Row>
</Table.Header>
@@ -373,107 +378,115 @@ const TokensTable = () => {
)
.map((token, idx) => {
if (token.deleted) return <></>;
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 (
<Table.Row key={token.id}>
<Table.Cell>{token.name ? token.name : 'None'}</Table.Cell>
<Table.Cell>{renderStatus(token.status)}</Table.Cell>
<Table.Cell>{renderQuota(token.used_quota)}</Table.Cell>
<Table.Cell>
{token.unlimited_quota
? 'Unlimited'
: renderQuota(token.remain_quota, 2)}
{token.name ? token.name : t('token.table.no_name')}
</Table.Cell>
<Table.Cell>{renderStatus(token.status, t)}</Table.Cell>
<Table.Cell>{renderQuota(token.used_quota, t)}</Table.Cell>
<Table.Cell>
{token.unlimited_quota
? t('token.table.unlimited')
: renderQuota(token.remain_quota, t, 2)}
</Table.Cell>
<Table.Cell>{renderTimestamp(token.created_time)}</Table.Cell>
<Table.Cell>
{token.expired_time === -1
? 'Never Expires'
: renderTimestamp(token.expired_time)}
{token.expired_time === -1
? t('token.table.never_expire')
: renderTimestamp(token.expired_time)}
</Table.Cell>
<Table.Cell>
<div>
<Button.Group color='green' size={'small'}>
<Button
size={'small'}
positive
onClick={async () => {
await onCopy('', token.key);
}}
>
Copy
</Button>
<Dropdown
className='button icon'
floating
options={COPY_OPTIONS.map((option) => ({
...option,
onClick: async () => {
await onCopy(option.value, token.key);
},
}))}
trigger={<></>}
/>
</Button.Group>{' '}
<Button.Group color='blue' size={'small'}>
<Button
size={'small'}
positive
onClick={() => {
onOpenLink('', token.key);
}}
>
Chat
</Button>
<Dropdown
className='button icon'
floating
options={OPEN_LINK_OPTIONS.map((option) => ({
...option,
onClick: async () => {
await onOpenLink(option.value, token.key);
},
}))}
trigger={<></>}
/>
</Button.Group>{' '}
<Popup
trigger={
<Button size='small' negative>
Delete
<div>
<Button.Group color='green' size={'tiny'}>
<Button
size={'tiny'}
positive
onClick={async () => await onCopy('', token.key)}
>
{t('token.buttons.copy')}
</Button>
<Dropdown
className='button icon'
floating
options={copyOptionsWithHandlers}
trigger={<></>}
/>
</Button.Group>{' '}
<Button.Group color='olive' size={'tiny'}>
<Button
size={'tiny'}
positive
onClick={() => onOpenLink('', token.key)}
>
{t('token.buttons.chat')}
</Button>
<Dropdown
className='button icon'
floating
options={openLinkOptionsWithHandlers}
trigger={<></>}
/>
</Button.Group>{' '}
<Popup
trigger={
<Button size='mini' negative>
{t('token.buttons.delete')}
</Button>
}
on='click'
flowing
hoverable
>
<Button
size={'tiny'}
negative
onClick={() => {
manageToken(token.id, 'delete', idx);
}}
>
{t('token.buttons.confirm_delete')} {token.name}
</Button>
</Popup>
<Button
size={'tiny'}
onClick={() => {
manageToken(
token.id,
token.status === 1 ? 'disable' : 'enable',
idx
);
}}
>
{token.status === 1
? t('token.buttons.disable')
: t('token.buttons.enable')}
</Button>
}
on='click'
flowing
hoverable
>
<Button
negative
onClick={() => {
manageToken(token.id, 'delete', idx);
}}
>
Delete Token {token.name}
</Button>
</Popup>
<Button
size={'small'}
onClick={() => {
manageToken(
token.id,
token.status === 1 ? 'disable' : 'enable',
idx
);
}}
>
{token.status === 1 ? 'Disable' : 'Enable'}
</Button>
<Button
size={'small'}
as={Link}
to={'/token/edit/' + token.id}
>
Edit
</Button>
</div>
<Button
size={'tiny'}
as={Link}
to={'/token/edit/' + token.id}
>
{t('token.buttons.edit')}
</Button>
</div>
</Table.Cell>
</Table.Row>
);
@@ -484,24 +497,24 @@ const TokensTable = () => {
<Table.Row>
<Table.HeaderCell colSpan='7'>
<Button size='small' as={Link} to='/token/add' loading={loading}>
Add New Token
{t('token.buttons.add')}
</Button>
<Button size='small' onClick={refresh} loading={loading}>
Refresh
{t('token.buttons.refresh')}
</Button>
<Dropdown
placeholder='Sort By'
placeholder={t('token.sort.placeholder')}
selection
options={[
{ key: '', text: 'Default Order', value: '' },
{ key: '', text: t('token.sort.default'), value: '' },
{
key: 'remain_quota',
text: 'Sort by Remaining Quota',
text: t('token.sort.by_remain'),
value: 'remain_quota',
},
{
key: 'used_quota',
text: 'Sort by Used Quota',
text: t('token.sort.by_used'),
value: 'used_quota',
},
]}

View File

@@ -10,6 +10,7 @@ import {
} from 'semantic-ui-react';
import { Link } from 'react-router-dom';
import { API, showError, showSuccess } from '../helpers';
import { useTranslation } from 'react-i18next';
import { ITEMS_PER_PAGE } from '../constants';
import {
@@ -19,20 +20,23 @@ import {
renderText,
} from '../helpers/render';
function renderRole(role) {
function renderRole(role, t) {
switch (role) {
case 1:
return <Label>Regular user</Label>;
return <Label>{t('user.table.role_types.normal')}</Label>;
case 10:
return <Label color='yellow'>Administrator</Label>;
return <Label color='yellow'>{t('user.table.role_types.admin')}</Label>;
case 100:
return <Label color='orange'>Super administrator</Label>;
return (
<Label color='orange'>{t('user.table.role_types.super_admin')}</Label>
);
default:
return <Label color='red'>Unknown Identity</Label>;
return <Label color='red'>{t('user.table.role_types.unknown')}</Label>;
}
}
const UsersTable = () => {
const { t } = useTranslation();
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(true);
const [activePage, setActivePage] = useState(1);
@@ -83,7 +87,7 @@ const UsersTable = () => {
});
const { success, message } = res.data;
if (success) {
showSuccess('Operation successfully completed!');
showSuccess(t('user.messages.operation_success'));
let user = res.data.data;
let newUsers = [...users];
let realIdx = (activePage - 1) * ITEMS_PER_PAGE + idx;
@@ -103,17 +107,17 @@ const UsersTable = () => {
const renderStatus = (status) => {
switch (status) {
case 1:
return <Label basic>Activated</Label>;
return <Label basic>{t('user.table.status_types.activated')}</Label>;
case 2:
return (
<Label basic color='red'>
Banned
{t('user.table.status_types.banned')}
</Label>
);
default:
return (
<Label basic color='grey'>
Unknown status
{t('user.table.status_types.unknown')}
</Label>
);
}
@@ -175,7 +179,7 @@ const UsersTable = () => {
icon='search'
fluid
iconPosition='left'
placeholder='Search user ID, username, display name, and email address...'
placeholder={t('user.search')}
value={searchKeyword}
loading={searching}
onChange={handleKeywordChange}
@@ -191,7 +195,7 @@ const UsersTable = () => {
sortUser('id');
}}
>
ID
{t('user.table.id')}
</Table.HeaderCell>
<Table.HeaderCell
style={{ cursor: 'pointer' }}
@@ -199,7 +203,7 @@ const UsersTable = () => {
sortUser('username');
}}
>
Username
{t('user.table.username')}
</Table.HeaderCell>
<Table.HeaderCell
style={{ cursor: 'pointer' }}
@@ -207,7 +211,7 @@ const UsersTable = () => {
sortUser('group');
}}
>
Group
{t('user.table.group')}
</Table.HeaderCell>
<Table.HeaderCell
style={{ cursor: 'pointer' }}
@@ -215,7 +219,7 @@ const UsersTable = () => {
sortUser('quota');
}}
>
Statistics
{t('user.table.quota')}
</Table.HeaderCell>
<Table.HeaderCell
style={{ cursor: 'pointer' }}
@@ -223,7 +227,7 @@ const UsersTable = () => {
sortUser('role');
}}
>
User Role
{t('user.table.role_text')}
</Table.HeaderCell>
<Table.HeaderCell
style={{ cursor: 'pointer' }}
@@ -231,9 +235,9 @@ const UsersTable = () => {
sortUser('status');
}}
>
Status
{t('user.table.status_text')}
</Table.HeaderCell>
<Table.HeaderCell>Operation</Table.HeaderCell>
<Table.HeaderCell>{t('user.table.actions')}</Table.HeaderCell>
</Table.Row>
</Table.Header>
@@ -265,54 +269,56 @@ const UsersTable = () => {
{/*</Table.Cell>*/}
<Table.Cell>
<Popup
content='Remaining Quota'
trigger={<Label basic>{renderQuota(user.quota)}</Label>}
/>
<Popup
content='Used Quota'
content={t('user.table.remaining_quota')}
trigger={
<Label basic>{renderQuota(user.used_quota)}</Label>
<Label basic>{renderQuota(user.quota, t)}</Label>
}
/>
<Popup
content='Request Count'
content={t('user.table.used_quota')}
trigger={
<Label basic>{renderQuota(user.used_quota, t)}</Label>
}
/>
<Popup
content={t('user.table.request_count')}
trigger={
<Label basic>{renderNumber(user.request_count)}</Label>
}
/>
</Table.Cell>
<Table.Cell>{renderRole(user.role)}</Table.Cell>
<Table.Cell>{renderRole(user.role, t)}</Table.Cell>
<Table.Cell>{renderStatus(user.status)}</Table.Cell>
<Table.Cell>
<div>
<Button
size={'small'}
size={'tiny'}
positive
onClick={() => {
manageUser(user.username, 'promote', idx);
}}
disabled={user.role === 100}
>
Promote
{t('user.buttons.promote')}
</Button>
<Button
size={'small'}
size={'tiny'}
color={'yellow'}
onClick={() => {
manageUser(user.username, 'demote', idx);
}}
disabled={user.role === 100}
>
Demote
{t('user.buttons.demote')}
</Button>
<Popup
trigger={
<Button
size='small'
size='tiny'
negative
disabled={user.role === 100}
>
Delete
{t('user.buttons.delete')}
</Button>
}
on='click'
@@ -321,15 +327,16 @@ const UsersTable = () => {
>
<Button
negative
size={'tiny'}
onClick={() => {
manageUser(user.username, 'delete', idx);
}}
>
Delete User {user.username}
{t('user.buttons.delete_user')} {user.username}
</Button>
</Popup>
<Button
size={'small'}
size={'tiny'}
onClick={() => {
manageUser(
user.username,
@@ -339,14 +346,16 @@ const UsersTable = () => {
}}
disabled={user.role === 100}
>
{user.status === 1 ? 'Disable' : 'Enable'}
{user.status === 1
? t('user.buttons.disable')
: t('user.buttons.enable')}
</Button>
<Button
size={'small'}
size={'tiny'}
as={Link}
to={'/user/edit/' + user.id}
>
Edit
{t('user.buttons.edit')}
</Button>
</div>
</Table.Cell>
@@ -359,22 +368,26 @@ const UsersTable = () => {
<Table.Row>
<Table.HeaderCell colSpan='7'>
<Button size='small' as={Link} to='/user/add' loading={loading}>
Add New User
{t('user.buttons.add')}
</Button>
<Dropdown
placeholder='Sort By'
placeholder={t('user.table.sort_by')}
selection
options={[
{ key: '', text: 'Default Order', value: '' },
{ key: 'quota', text: 'Sort by Remaining Quota', value: 'quota' },
{ key: '', text: t('user.table.sort.default'), value: '' },
{
key: 'quota',
text: t('user.table.sort.by_quota'),
value: 'quota',
},
{
key: 'used_quota',
text: 'Sort by Used Quota',
text: t('user.table.sort.by_used_quota'),
value: 'used_quota',
},
{
key: 'request_count',
text: 'Sort by Request Count',
text: t('user.table.sort.by_request_count'),
value: 'request_count',
},
]}

View File

@@ -1,7 +1,7 @@
export const toastConstants = {
SUCCESS_TIMEOUT: 1500,
INFO_TIMEOUT: 3000,
ERROR_TIMEOUT: 5000,
SUCCESS_TIMEOUT: 5000,
INFO_TIMEOUT: 8000,
ERROR_TIMEOUT: 10000,
WARNING_TIMEOUT: 10000,
NOTICE_TIMEOUT: 20000
NOTICE_TIMEOUT: 20000,
};

View File

@@ -1,4 +1,5 @@
import { Label } from 'semantic-ui-react';
import { useTranslation } from 'react-i18next';
export function renderText(text, limit) {
if (text.length > limit) {
@@ -39,23 +40,33 @@ 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 `Equivalent Amount${renderQuota(quota, digits)}`;
const amount = (quota / quotaPerUnit).toFixed(2);
return ` (${t('common.quota.display', { amount })})`;
}
return '';
}

View File

@@ -1,10 +1,10 @@
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import Backend from 'i18next-http-backend';
import {initReactI18next} from 'react-i18next';
import LanguageDetector from 'i18next-browser-languagedetector';
import zhTranslation from './locales/zh/translation.json';
import enTranslation from './locales/en/translation.json';
i18n
.use(Backend)
.use(LanguageDetector)
.use(initReactI18next)
.init({
@@ -15,9 +15,14 @@ i18n
escapeValue: false,
},
backend: {
loadPath: '/locales/{{lng}}/{{ns}}.json',
},
resources: {
zh: {
translation: zhTranslation
},
en: {
translation: enTranslation
}
}
});
export default i18n;

View File

@@ -33,3 +33,85 @@ code {
display: none !important;
}
}
@media screen and (max-width: 768px) {
.ui.container {
width: 100% !important;
margin-left: 0 !important;
margin-right: 0 !important;
padding: 0 10px !important;
}
.ui.card,
.ui.cards,
.ui.segment {
margin-left: 0 !important;
margin-right: 0 !important;
}
.ui.table {
padding-left: 0 !important;
padding-right: 0 !important;
}
}
/* 小屏笔记本 (13-14寸) */
@media screen and (min-width: 769px) and (max-width: 1366px) {
.ui.container {
width: auto !important;
max-width: 100% !important;
margin-left: auto !important;
margin-right: auto !important;
padding: 0 24px !important;
}
/* 调整表格显示 */
.ui.table {
font-size: 0.9em;
}
/* 调整卡片布局 */
.ui.cards {
margin-left: -0.5em !important;
margin-right: -0.5em !important;
}
.ui.cards > .card {
margin: 0.5em !important;
width: calc(50% - 1em) !important;
}
}
/* 大屏幕 */
@media screen and (min-width: 1367px) {
.ui.container {
width: 1200px !important;
margin-left: auto !important;
margin-right: auto !important;
padding: 0 !important;
}
}
/* 优化 Dashboard 网格布局 */
@media screen and (max-width: 1366px) {
.charts-grid {
margin: 0 -0.5em !important;
}
.charts-grid .column {
padding: 0.5em !important;
}
.chart-card {
margin: 0 !important;
}
/* 调整字体大小 */
.ui.header {
font-size: 1.1em !important;
}
.stat-value {
font-size: 0.9em !important;
}
}

View File

@@ -0,0 +1,829 @@
{
"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!"
}
},
"channel": {
"title": "Channel Management",
"search": "Search channels by ID, name and key...",
"balance_notice": "OpenAI channels no longer support getting balance via key, so balance shows as 0. For supported channel types, click balance to refresh.",
"test_notice": "Channel testing only supports chat models, preferring gpt-3.5-turbo. If unavailable, uses the first model in your configured list.",
"detail_notice": "Click the detail button below to show balance and set additional test models.",
"table": {
"id": "ID",
"name": "Name",
"group": "Group",
"type": "Type",
"status": "Status",
"response_time": "Response Time",
"balance": "Balance",
"priority": "Priority",
"test_model": "Test Model",
"actions": "Actions",
"no_name": "None",
"status_enabled": "Enabled",
"status_disabled": "Disabled",
"status_auto_disabled": "Disabled",
"status_disabled_tip": "This channel is manually disabled",
"status_auto_disabled_tip": "This channel is automatically disabled",
"status_unknown": "Unknown Status",
"not_tested": "Not Tested",
"priority_tip": "Channel selection priority, higher is preferred",
"select_test_model": "Please select test model",
"click_to_update": "Click to update",
"balance_not_supported": "-"
},
"buttons": {
"test": "Test",
"delete": "Delete",
"confirm_delete": "Delete Channel",
"enable": "Enable",
"disable": "Disable",
"edit": "Edit",
"add": "Add New Channel",
"test_all": "Test All Channels",
"test_disabled": "Test Disabled Channels",
"delete_disabled": "Delete Disabled Channels",
"confirm_delete_disabled": "Confirm Delete",
"refresh": "Refresh",
"show_detail": "Details",
"hide_detail": "Hide Details"
},
"messages": {
"test_success": "Channel {{name}} test successful, model {{model}}, time {{time}}s, output: {{message}}",
"test_all_started": "Channel testing started successfully, please refresh page to see results.",
"delete_disabled_success": "Deleted all disabled channels, total: {{count}}",
"balance_update_success": "Channel {{name}} balance updated successfully!",
"all_balance_updated": "All enabled channel balances have been updated!",
"operation_success": "Operation completed successfully!"
},
"edit": {
"title_edit": "Update Channel Information",
"title_create": "Create New Channel",
"type": "Type",
"name": "Name",
"name_placeholder": "Please enter name",
"group": "Group",
"group_placeholder": "Please select groups that can use this channel",
"group_addition": "Please edit group multipliers in system settings to add new group:",
"models": "Models",
"models_placeholder": "Please select models supported by this channel",
"model_mapping": "Model Mapping",
"model_mapping_placeholder": "Optional, used to modify model names in request body. A JSON string where keys are request model names and values are target model names",
"system_prompt": "System Prompt",
"system_prompt_placeholder": "Optional, used to force set system prompt. Use with custom model & model mapping. First create a unique custom model name above, then map it to a natively supported model",
"base_url": "Proxy",
"base_url_placeholder": "Optional, used for API calls through proxy. Enter proxy address in format: https://domain.com",
"key": "Key",
"key_placeholder": "Please enter key",
"batch": "Batch Create",
"batch_placeholder": "Please enter keys, one per line",
"buttons": {
"cancel": "Cancel",
"submit": "Submit",
"fill_models": "Fill Related Models",
"fill_all": "Fill All Models",
"clear": "Clear All Models",
"add_custom": "Add",
"custom_placeholder": "Enter custom model name"
},
"messages": {
"name_required": "Please enter channel name and key!",
"models_required": "Please select at least one model!",
"model_mapping_invalid": "Model mapping must be valid JSON format!",
"update_success": "Channel updated successfully!",
"create_success": "Channel created successfully!"
},
"spark_version": "Model Version",
"spark_version_placeholder": "Please enter Spark model version from API URL, e.g.: v2.1",
"knowledge_id": "Knowledge Base ID",
"knowledge_id_placeholder": "Please enter knowledge base ID, e.g.: 123456",
"plugin_param": "Plugin Parameter",
"plugin_param_placeholder": "Please enter plugin parameter (X-DashScope-Plugin header value)",
"coze_notice": "For Coze, model name is the Bot ID. You can add prefix `bot-`, e.g.: `bot-123456`.",
"douban_notice": "For Douban, you need to go to",
"douban_notice_link": "Model Inference Page",
"douban_notice_2": "to create an inference endpoint, and use the endpoint name as model name, e.g.: `ep-20240608051426-tkxvl`.",
"aws_region_placeholder": "region, e.g.: us-west-2",
"aws_ak_placeholder": "AWS IAM Access Key",
"aws_sk_placeholder": "AWS IAM Secret Key",
"vertex_region_placeholder": "Vertex AI Region, e.g.: us-east5",
"vertex_project_id": "Vertex AI Project ID",
"vertex_project_id_placeholder": "Vertex AI Project ID",
"vertex_credentials": "Google Cloud Application Default Credentials JSON",
"vertex_credentials_placeholder": "Google Cloud Application Default Credentials JSON",
"user_id": "User ID",
"user_id_placeholder": "User ID who generated this key",
"key_prompts": {
"default": "Please enter the authentication key for this channel",
"zhipu": "Enter in format: APIKey|SecretKey",
"spark": "Enter in format: APPID|APISecret|APIKey",
"fastgpt": "Enter in format: APIKey-AppId, e.g.: fastgpt-0sp2gtvfdgyi4k30jwlgwf1i-64f335d84283f05518e9e041",
"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",
"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": "$"
}
},
"redemption": {
"title": "Redemption Management",
"search": "Search redemption codes by ID and name ...",
"table": {
"id": "ID",
"name": "Name",
"status": "Status",
"quota": "Quota",
"created_time": "Created Time",
"redeemed_time": "Redeemed Time",
"actions": "Actions",
"no_name": "None",
"not_redeemed": "Not Redeemed"
},
"buttons": {
"copy": "Copy",
"delete": "Delete",
"confirm_delete": "Confirm Delete",
"enable": "Enable",
"disable": "Disable",
"edit": "Edit",
"add": "Add New Code",
"refresh": "Refresh"
},
"status": {
"unused": "Unused",
"disabled": "Disabled",
"used": "Used",
"unknown": "Unknown"
},
"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"
}
},
"messages": {
"update_success": "Redemption code updated successfully!",
"create_success": "Redemption code created successfully!"
}
},
"log": {
"title": "Operation Log",
"search": "Search logs...",
"usage_details": "Usage Details",
"total_quota": "Total Quota Used",
"click_to_view": "Click to View",
"type": {
"select": "Select Log Type",
"all": "All",
"topup": "Top Up",
"usage": "Usage",
"admin": "Admin",
"system": "System",
"test": "Test"
},
"table": {
"time": "Time",
"channel": "Channel",
"type": "Type",
"model": "Model",
"username": "Username",
"token_name": "Token Name",
"token_name_placeholder": "Optional",
"model_name": "Model Name",
"model_name_placeholder": "Optional",
"start_time": "Start Time",
"end_time": "End Time",
"channel_id": "Channel ID",
"channel_id_placeholder": "Optional",
"username_placeholder": "Optional",
"prompt_tokens": "Prompt Tokens",
"completion_tokens": "Completion Tokens",
"quota": "Quota",
"detail": "Detail"
},
"buttons": {
"query": "Action",
"submit": "Query",
"refresh": "Refresh"
}
},
"user": {
"title": "User Management",
"edit": {
"title": "Update User Information",
"username": "Username",
"username_placeholder": "Please enter new username",
"password": "Password",
"password_placeholder": "Please enter new password, minimum 8 characters",
"display_name": "Display Name",
"display_name_placeholder": "Please enter new display name",
"group": "Group",
"group_placeholder": "Please select group",
"group_addition": "Please edit group multipliers in system settings to add new group:",
"quota": "Remaining Quota",
"quota_placeholder": "Please enter new remaining quota",
"github_id": "Linked GitHub Account",
"github_id_placeholder": "Read-only, user must link through personal settings page, cannot be modified directly",
"wechat_id": "Linked WeChat Account",
"wechat_id_placeholder": "Read-only, user must link through personal settings page, cannot be modified directly",
"email": "Linked Email Account",
"email_placeholder": "Read-only, user must link through personal settings page, cannot be modified directly",
"buttons": {
"submit": "Submit",
"cancel": "Cancel"
}
},
"add": {
"title": "Create New User Account"
},
"messages": {
"update_success": "User information updated successfully!",
"create_success": "User account created successfully!",
"operation_success": "Operation completed successfully!"
},
"search": "Search users...",
"table": {
"id": "ID",
"username": "Username",
"group": "Group",
"quota": "Quota",
"role_text": "Role",
"status_text": "Status",
"actions": "Actions",
"remaining_quota": "Remaining Quota",
"used_quota": "Used Quota",
"request_count": "Request Count",
"role_types": {
"normal": "Normal User",
"admin": "Admin",
"super_admin": "Super Admin",
"unknown": "Unknown Role"
},
"status_types": {
"activated": "Activated",
"banned": "Banned",
"unknown": "Unknown Status"
},
"sort": {
"default": "Default Order",
"by_quota": "Sort by Remaining Quota",
"by_used_quota": "Sort by Used Quota",
"by_request_count": "Sort by Request Count"
},
"sort_by": "Sort By"
},
"buttons": {
"add": "Add New User",
"delete": "Delete",
"delete_user": "Delete User",
"enable": "Enable",
"disable": "Disable",
"edit": "Edit",
"promote": "Promote",
"demote": "Demote"
}
},
"dashboard": {
"charts": {
"requests": {
"title": "Model Request Trend",
"tooltip": "Request Count"
},
"quota": {
"title": "Quota Usage Trend",
"tooltip": "Quota Used"
},
"tokens": {
"title": "Token Usage Trend",
"tooltip": "Token Count"
}
},
"statistics": {
"title": "Statistics",
"tooltip": {
"date": "Date",
"value": "Value"
}
}
},
"setting": {
"title": "System Settings",
"tabs": {
"personal": "Personal Settings",
"operation": "Operation Settings",
"system": "System Settings",
"other": "Other Settings"
},
"personal": {
"general": {
"title": "General Settings",
"system_token_notice": "Note: The token generated here is for system management, not for requesting OpenAI related services.",
"buttons": {
"update_profile": "Update Profile",
"generate_token": "Generate System Token",
"copy_invite": "Copy Invite Link",
"delete_account": "Delete Account"
}
},
"binding": {
"title": "Account Binding",
"buttons": {
"bind_wechat": "Bind WeChat Account",
"bind_github": "Bind GitHub Account",
"bind_email": "Bind Email Address",
"bind_lark": "Bind Lark Account"
},
"wechat": {
"title": "WeChat Binding",
"description": "Scan QR code to follow the official account, enter 'verification code' to get the code (valid for 3 minutes)",
"verification_code": "Verification Code",
"bind": "Bind"
},
"email": {
"title": "Bind Email Address",
"email_placeholder": "Enter email address",
"code_placeholder": "Verification code",
"get_code": "Get Code",
"get_code_retry": "Resend({{countdown}})",
"bind": "Confirm Binding",
"cancel": "Cancel"
}
},
"delete_account": {
"title": "Dangerous Operation",
"warning": "You are deleting your account. All data will be cleared and cannot be recovered",
"confirm_placeholder": "Enter your username {{username}} to confirm deletion",
"buttons": {
"confirm": "Confirm Delete",
"cancel": "Cancel"
}
}
},
"system": {
"general": {
"title": "General Settings",
"server_address": "Server Address",
"server_address_placeholder": "e.g.: https://yourdomain.com",
"buttons": {
"update": "Update Server Address"
}
},
"login": {
"title": "Login & Registration Settings",
"password_login": "Allow Password Login",
"password_register": "Allow Password Registration",
"email_verification": "Require Email Verification for Password Registration",
"github_oauth": "Allow GitHub OAuth Login & Registration",
"wechat_login": "Allow WeChat Login & Registration",
"registration": "Allow New User Registration (When disabled, new users cannot register by any means)",
"turnstile": "Enable Turnstile User Verification"
},
"email_restriction": {
"title": "Email Domain Whitelist",
"subtitle": "Used to prevent malicious users from batch registering using temporary emails",
"enable": "Enable Email Domain Whitelist",
"allowed_domains": "Allowed Email Domains",
"add_domain": "Add New Allowed Email Domain",
"add_domain_placeholder": "Enter new allowed email domain",
"buttons": {
"fill": "Fill",
"save": "Save Email Domain Whitelist Settings"
}
},
"smtp": {
"title": "SMTP Configuration",
"subtitle": "Used to support system email sending",
"server": "SMTP Server Address",
"server_placeholder": "e.g.: smtp.gmail.com",
"port": "SMTP Port",
"port_placeholder": "Default: 587",
"account": "SMTP Account",
"account_placeholder": "Usually your email address",
"from": "SMTP Sender Email",
"from_placeholder": "Usually same as email address",
"token": "SMTP Access Token",
"token_placeholder": "Sensitive information will not be sent to frontend",
"buttons": {
"save": "Save SMTP Settings"
}
},
"github": {
"title": "GitHub OAuth App Configuration",
"subtitle": "Used to support GitHub login and registration",
"manage_link": "Click here",
"manage_text": "to manage your GitHub OAuth Apps",
"url_notice": "Set Homepage URL to {{server_url}}, and Authorization callback URL to {{callback_url}}",
"client_id": "GitHub Client ID",
"client_id_placeholder": "Enter your registered GitHub OAuth APP ID",
"client_secret": "GitHub Client Secret",
"client_secret_placeholder": "Sensitive information will not be sent to frontend",
"buttons": {
"save": "Save GitHub OAuth Settings"
}
},
"lark": {
"title": "Lark OAuth Configuration",
"subtitle": "Used to support Lark login and registration",
"manage_link": "Click here",
"manage_text": "to manage your Lark applications",
"url_notice": "Set Homepage URL to {{server_url}}, and Redirect URL to {{callback_url}}",
"client_id": "App ID",
"client_id_placeholder": "Enter App ID",
"client_secret": "App Secret",
"client_secret_placeholder": "Sensitive information will not be sent to frontend",
"buttons": {
"save": "Save Lark OAuth Settings"
}
},
"wechat": {
"title": "WeChat Server Configuration",
"subtitle": "Used to support WeChat login and registration",
"learn_more": "Learn about WeChat Server",
"server_address": "WeChat Server Address",
"server_address_placeholder": "e.g.: https://yourdomain.com",
"token": "WeChat Server Access Token",
"token_placeholder": "Sensitive information will not be sent to frontend",
"qrcode": "WeChat Official Account QR Code Image URL",
"qrcode_placeholder": "Enter an image URL",
"buttons": {
"save": "Save WeChat Server Settings"
},
"scan_tip": "Scan QR code to follow WeChat Official Account, enter 'code' to get verification code (valid for 3 minutes)",
"code_placeholder": "Verification code"
},
"turnstile": {
"title": "Turnstile Configuration",
"subtitle": "Used to support user verification",
"manage_link": "Click here",
"manage_text": "to manage your Turnstile Sites, Invisible Widget Type recommended",
"site_key": "Turnstile Site Key",
"site_key_placeholder": "Enter your registered Turnstile Site Key",
"secret_key": "Turnstile Secret Key",
"secret_key_placeholder": "Sensitive information will not be sent to frontend",
"buttons": {
"save": "Save Turnstile Settings"
}
},
"password_login": {
"warning": {
"title": "Warning",
"content": "Disabling password login will prevent all users (including administrators) who haven't bound other login methods from logging in via password. Confirm disable?",
"buttons": {
"confirm": "Confirm",
"cancel": "Cancel"
}
}
}
},
"operation": {
"quota": {
"title": "Quota Settings",
"new_user": "Initial Quota for New Users",
"new_user_placeholder": "e.g.: 100",
"pre_consume": "Pre-consumed Quota per Request",
"pre_consume_placeholder": "Refund or charge difference after request",
"inviter_reward": "Reward Quota for Inviter",
"inviter_reward_placeholder": "e.g.: 2000",
"invitee_reward": "Reward Quota for Using Invite Code",
"invitee_reward_placeholder": "e.g.: 1000",
"buttons": {
"save": "Save Quota Settings"
}
},
"ratio": {
"title": "Ratio Settings",
"model": {
"title": "Model Ratio",
"placeholder": "A JSON text where keys are model names and values are ratios"
},
"completion": {
"title": "Completion Ratio",
"placeholder": "A JSON text where keys are model names and values are ratios. These ratios are the proportion of completion to prompt ratio, which can override One API's internal ratios"
},
"group": {
"title": "Group Ratio",
"placeholder": "A JSON text where keys are group names and values are ratios"
},
"buttons": {
"save": "Save Ratio Settings"
}
},
"log": {
"title": "Log Settings",
"enable_consume": "Enable Quota Consumption Logging",
"target_time": "Target Time",
"buttons": {
"clean": "Clean Historical Logs"
}
},
"monitor": {
"title": "Monitor Settings",
"max_response_time": "Maximum Response Time",
"max_response_time_placeholder": "In seconds, channels exceeding this time during testing will be automatically disabled",
"quota_reminder": "Quota Reminder Threshold",
"quota_reminder_placeholder": "Users will receive email reminders when quota falls below this value",
"auto_disable": "Automatically Disable Channel on Failure",
"auto_enable": "Automatically Enable Channel on Success",
"buttons": {
"save": "Save Monitor Settings"
}
},
"general": {
"title": "General Settings",
"topup_link": "Top-up Link",
"topup_link_placeholder": "e.g.: Card selling website purchase link",
"chat_link": "Chat Page Link",
"chat_link_placeholder": "e.g.: ChatGPT Next Web deployment address",
"quota_per_unit": "Quota per Dollar",
"quota_per_unit_placeholder": "Quota exchangeable per unit of currency",
"retry_times": "Retry Times on Failure",
"retry_times_placeholder": "Number of retry attempts on failure",
"display_in_currency": "Display Quota in Currency Format",
"display_token_stat": "Show Token Quota Instead of User Quota in Billing APIs",
"approximate_token": "Use Approximate Method to Estimate Token Count",
"buttons": {
"save": "Save General Settings"
}
}
},
"other": {
"notice": {
"title": "Notice Settings",
"content": "Notice Content",
"content_placeholder": "Enter new notice content here, supports Markdown & HTML code",
"buttons": {
"save": "Save Notice"
}
},
"system": {
"title": "System Settings",
"name": "System Name",
"name_placeholder": "Please enter system name",
"logo": "Logo Image URL",
"logo_placeholder": "Enter Logo image URL here",
"theme": {
"title": "Theme Name",
"link": "Available Themes",
"placeholder": "Please enter theme name"
},
"buttons": {
"save_name": "Set System Name",
"save_logo": "Set Logo",
"save_theme": "Set Theme (Restart Required)"
}
},
"content": {
"title": "Content Settings",
"homepage": {
"title": "Homepage Content",
"placeholder": "Enter homepage content here, supports Markdown & HTML code. Status information will not be shown after setting. If a link is entered, it will be used as the src attribute of an iframe, allowing you to set any webpage as homepage."
},
"about": {
"title": "About System",
"description": "You can set about content in settings page, supports HTML & Markdown",
"repository": "Project Repository:",
"loading_failed": "Failed to load about content..."
},
"footer": {
"title": "Footer",
"placeholder": "Enter new footer here, leave empty to use default footer, supports HTML code"
},
"buttons": {
"save_homepage": "Save Homepage Content",
"save_about": "Save About",
"save_footer": "Set Footer"
}
},
"copyright": {
"notice": "Removing One API's copyright notice requires authorization. Project maintenance requires significant effort, if this project is meaningful to you, please actively support it."
}
}
},
"footer": {
"built_by": "built by",
"built_by_name": "JustSong",
"license": ", source code is licensed under the",
"mit": "MIT License"
},
"home": {
"welcome": {
"title": "Welcome to One API",
"description": "One API is a LLM API management and distribution system that helps you better manage and use LLM APIs from various providers.",
"login_notice": "To use the service, please login or register first."
},
"system_status": {
"title": "System Status",
"info": {
"title": "System Information",
"name": "Name: ",
"version": "Version: ",
"source": "Source: ",
"source_link": "GitHub Repository",
"start_time": "Start Time: "
},
"config": {
"title": "System Configuration",
"email_verify": "Email Verification: ",
"github_oauth": "GitHub OAuth: ",
"wechat_login": "WeChat Login: ",
"turnstile": "Turnstile Check: ",
"enabled": "Enabled",
"disabled": "Disabled"
}
},
"loading_failed": "Failed to load homepage content..."
},
"auth": {
"login": {
"title": "User Login",
"username": "Username / Email",
"password": "Password",
"button": "Login",
"forgot_password": "Forgot password?",
"reset_password": "Reset",
"no_account": "No account?",
"register": "Register",
"other_methods": "Other login methods",
"wechat": {
"scan_tip": "Scan QR code to follow WeChat Official Account, enter 'code' to get verification code (valid for 3 minutes)",
"code_placeholder": "Verification code"
}
},
"register": {
"title": "New User Registration",
"username": "Username (max 12 characters)",
"password": "Password (8-20 characters)",
"confirm_password": "Confirm password",
"email": "Email address",
"verification_code": "Verification code",
"get_code": "Get code",
"get_code_retry": "Retry ({{countdown}})",
"button": "Register",
"has_account": "Have an account?",
"login": "Login"
},
"reset": {
"title": "Password Reset",
"email": "Email address",
"button": "Submit",
"notice": "The system will send an email containing a reset link to your mailbox. Please check your email.",
"confirm": {
"title": "Password Reset Confirmation",
"new_password": "New password",
"button": "Submit",
"button_disabled": "Password reset completed",
"notice": "New password has been generated, please click the password field or button above to copy. Please login and change your password as soon as possible!"
}
}
},
"about": {
"title": "About",
"description": "One API is an open-source API management and proxy platform.",
"repository": "Repository: ",
"loading_failed": "Loading failed"
},
"messages": {
"success": {
"login": "Login successful!",
"register": "Registration successful!",
"verification_code": "Verification code sent, please check your email!",
"password_reset": "Reset email sent, please check your inbox!"
},
"error": {
"login_expired": "Not logged in or session expired, please login again!",
"password_length": "Password must be at least 8 characters!",
"password_mismatch": "Passwords do not match",
"turnstile_wait": "Please wait a few seconds, Turnstile is checking the environment!",
"root_password": "Please change the default password immediately!"
},
"notice": {
"password_copied": "New password copied to clipboard: {{password}}"
}
}
}

View File

@@ -0,0 +1,825 @@
{
"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": "超级管理员未设置充值链接!"
}
},
"channel": {
"title": "管理渠道",
"search": "搜索渠道的 ID名称和密钥 ...",
"balance_notice": "OpenAI 渠道已经不再支持通过 key 获取余额,因此余额显示为 0。对于支持的渠道类型请点击余额进行刷新。",
"test_notice": "渠道测试仅支持 chat 模型,优先使用 gpt-3.5-turbo如果该模型不可用则使用你所配置的模型列表中的第一个模型。",
"detail_notice": "点击下方详情按钮可以显示余额以及设置额外的测试模型。",
"table": {
"id": "ID",
"name": "名称",
"group": "分组",
"type": "类型",
"status": "状态",
"response_time": "响应时间",
"balance": "余额",
"priority": "优先级",
"test_model": "测试模型",
"actions": "操作",
"no_name": "无",
"status_enabled": "已启用",
"status_disabled": "已禁用",
"status_auto_disabled": "已禁用",
"status_disabled_tip": "本渠道被手动禁用",
"status_auto_disabled_tip": "本渠道被程序自动禁用",
"status_unknown": "未知状态",
"not_tested": "未测试",
"priority_tip": "渠道选择优先级,越高越优先",
"select_test_model": "请选择测试模型",
"click_to_update": "点击更新",
"balance_not_supported": "-"
},
"buttons": {
"test": "测试",
"delete": "删除",
"confirm_delete": "删除渠道",
"enable": "启用",
"disable": "禁用",
"edit": "编辑",
"add": "添加新的渠道",
"test_all": "测试所有渠道",
"test_disabled": "测试禁用渠道",
"delete_disabled": "删除禁用渠道",
"confirm_delete_disabled": "确认删除",
"refresh": "刷新",
"show_detail": "详情",
"hide_detail": "隐藏详情"
},
"messages": {
"test_success": "渠道 {{name}} 测试成功,模型 {{model}},耗时 {{time}} 秒,模型输出:{{message}}",
"test_all_started": "已成功开始测试渠道,请刷新页面查看结果。",
"delete_disabled_success": "已删除所有禁用渠道,共计 {{count}} 个",
"balance_update_success": "渠道 {{name}} 余额更新成功!",
"all_balance_updated": "已更新完毕所有已启用渠道余额!",
"operation_success": "操作成功完成!"
},
"edit": {
"title_edit": "更新渠道信息",
"title_create": "创建新的渠道",
"type": "类型",
"name": "名称",
"name_placeholder": "请输入名称",
"group": "分组",
"group_placeholder": "请选择可以使用该渠道的分组",
"group_addition": "请在系统设置页面编辑分组倍率以添加新的分组:",
"models": "模型",
"models_placeholder": "请选择该渠道所支持的模型",
"model_mapping": "模型重定向",
"model_mapping_placeholder": "此项可选,用于修改请求体中的模型名称,为一个 JSON 字符串,键为请求中模型名称,值为要替换的模型名称",
"system_prompt": "系统提示词",
"system_prompt_placeholder": "此项可选,用于强制设置给定的系统提示词,请配合自定义模型 & 模型重定向使用,首先创建一个唯一的自定义模型名称并在上面填入,之后将该自定义模型重定向映射到该渠道一个原生支持的模型",
"base_url": "代理",
"base_url_placeholder": "此项可选,用于通过代理站来进行 API 调用请输入代理站地址格式为https://domain.com",
"key": "密钥",
"key_placeholder": "请输入密钥",
"batch": "批量创建",
"batch_placeholder": "请输入密钥,一行一个",
"buttons": {
"cancel": "取消",
"submit": "提交",
"fill_models": "填入相关模型",
"fill_all": "填入所有模型",
"clear": "清除所有模型",
"add_custom": "填入",
"custom_placeholder": "输入自定义模型名称"
},
"messages": {
"name_required": "请填写渠道名称和渠道密钥!",
"models_required": "请至少选择一个模型!",
"model_mapping_invalid": "模型映射必须是合法的 JSON 格式!",
"update_success": "渠道更新成功!",
"create_success": "渠道创建成功!"
},
"spark_version": "模型版本",
"spark_version_placeholder": "请输入星火大模型版本注意是接口地址中的版本号例如v2.1",
"knowledge_id": "知识库 ID",
"knowledge_id_placeholder": "请输入知识库 ID例如123456",
"plugin_param": "插件参数",
"plugin_param_placeholder": "请输入插件参数,即 X-DashScope-Plugin 请求头的取值",
"coze_notice": "对于 Coze 而言,模型名称即 Bot ID你可以添加一个前缀 `bot-`,例如:`bot-123456`。",
"douban_notice": "对于豆包而言,需要手动去",
"douban_notice_link": "模型推理页面",
"douban_notice_2": "创建推理接入点,以接入点名称作为模型名称,例如:`ep-20240608051426-tkxvl`。你可以结合模型重定向功能将其转换为常规的模型名称例如doubao-lite-4k -> ep-20240608051426-tkxvl前者作为 JSON 的 key后者作为 value。注意doubao-lite-4k 和 ep-20240608051426-tkxvl 都需要通过自定义模型的方式填入到本渠道的模型列表中。",
"aws_region_placeholder": "region例如us-west-2",
"aws_ak_placeholder": "AWS IAM Access Key",
"aws_sk_placeholder": "AWS IAM Secret Key",
"vertex_region_placeholder": "Vertex AI Region例如us-east5",
"vertex_project_id": "Vertex AI Project ID",
"vertex_project_id_placeholder": "Vertex AI Project ID",
"vertex_credentials": "Google Cloud Application Default Credentials JSON",
"vertex_credentials_placeholder": "Google Cloud Application Default Credentials JSON",
"user_id": "User ID",
"user_id_placeholder": "生成该密钥的用户 ID",
"key_prompts": {
"default": "请输入渠道对应的鉴权密钥",
"zhipu": "按照如下格式输入APIKey|SecretKey",
"spark": "按照如下格式输入APPID|APISecret|APIKey",
"fastgpt": "按照如下格式输入APIKey-AppId例如fastgpt-0sp2gtvfdgyi4k30jwlgwf1i-64f335d84283f05518e9e041",
"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": "$"
}
},
"redemption": {
"title": "兑换管理",
"search": "搜索兑换码的 ID 和名称 ...",
"table": {
"id": "ID",
"name": "名称",
"status": "状态",
"quota": "额度",
"created_time": "创建时间",
"redeemed_time": "兑换时间",
"actions": "操作",
"no_name": "无",
"not_redeemed": "尚未兑换"
},
"buttons": {
"copy": "复制",
"delete": "删除",
"confirm_delete": "确认删除",
"enable": "启用",
"disable": "禁用",
"edit": "编辑",
"add": "添加新的兑换码",
"refresh": "刷新"
},
"status": {
"unused": "未使用",
"disabled": "已禁用",
"used": "已使用",
"unknown": "未知状态"
},
"edit": {
"title_edit": "更新兑换码信息",
"title_create": "创建新的兑换码",
"name": "名称",
"name_placeholder": "请输入名称",
"quota": "额度",
"quota_placeholder": "请输入单个兑换码中包含的额度",
"count": "生成数量",
"count_placeholder": "请输入生成数量",
"buttons": {
"submit": "提交",
"cancel": "取消"
}
},
"messages": {
"update_success": "兑换码更新成功!",
"create_success": "兑换码创建成功!"
}
},
"log": {
"title": "操作日志",
"search": "搜索日志...",
"usage_details": "使用明细",
"total_quota": "总消耗额度",
"click_to_view": "点击查看",
"type": {
"select": "选择明细分类",
"all": "全部",
"topup": "充值",
"usage": "消费",
"admin": "管理",
"system": "系统",
"test": "测试"
},
"table": {
"time": "时间",
"channel": "渠道",
"type": "类型",
"model": "模型",
"username": "用户名",
"token_name": "令牌名称",
"token_name_placeholder": "可选值",
"model_name": "模型名称",
"model_name_placeholder": "可选值",
"start_time": "起始时间",
"end_time": "结束时间",
"channel_id": "渠道 ID",
"channel_id_placeholder": "可选值",
"username_placeholder": "可选值",
"prompt_tokens": "提示词消耗",
"completion_tokens": "补全消耗",
"quota": "额度",
"detail": "详情"
},
"buttons": {
"query": "操作",
"submit": "查询",
"refresh": "刷新"
}
},
"user": {
"title": "用户管理",
"edit": {
"title": "更新用户信息",
"username": "用户名",
"username_placeholder": "请输入新的用户名",
"password": "密码",
"password_placeholder": "请输入新的密码,最短 8 位",
"display_name": "显示名称",
"display_name_placeholder": "请输入新的显示名称",
"group": "分组",
"group_placeholder": "请选择分组",
"group_addition": "请在系统设置页面编辑分组倍率以添加新的分组:",
"quota": "剩余额度",
"quota_placeholder": "请输入新的剩余额度",
"github_id": "已绑定的 GitHub 账户",
"github_id_placeholder": "此项只读,需要用户通过个人设置页面的相关绑定按钮进行绑定,不可直接修改",
"wechat_id": "已绑定的微信账户",
"wechat_id_placeholder": "此项只读,需要用户通过个人设置页面的相关绑定按钮进行绑定,不可直接修改",
"email": "已绑定的邮箱账户",
"email_placeholder": "此项只读,需要用户通过个人设置页面的相关绑定按钮进行绑定,不可直接修改",
"buttons": {
"submit": "提交",
"cancel": "取消"
}
},
"add": {
"title": "创建新用户账户"
},
"messages": {
"update_success": "用户信息更新成功!",
"create_success": "用户账户创建成功!",
"operation_success": "操作成功完成!"
},
"search": "搜索用户...",
"table": {
"id": "ID",
"username": "用户名",
"group": "分组",
"quota": "额度",
"role_text": "角色",
"status_text": "状态",
"actions": "操作",
"remaining_quota": "剩余额度",
"used_quota": "已用额度",
"request_count": "请求次数",
"role_types": {
"normal": "普通用户",
"admin": "管理员",
"super_admin": "超级管理员",
"unknown": "未知身份"
},
"status_types": {
"activated": "已激活",
"banned": "已封禁",
"unknown": "未知状态"
},
"sort": {
"default": "默认排序",
"by_quota": "按剩余额度排序",
"by_used_quota": "按已用额度排序",
"by_request_count": "按请求次数排序"
},
"sort_by": "排序方式"
},
"buttons": {
"add": "添加新的用户",
"delete": "删除",
"delete_user": "删除用户",
"enable": "启用",
"disable": "禁用",
"edit": "编辑",
"promote": "提升",
"demote": "降级"
}
},
"dashboard": {
"charts": {
"requests": {
"title": "模型请求趋势",
"tooltip": "请求次数"
},
"quota": {
"title": "额度消费趋势",
"tooltip": "消费额度"
},
"tokens": {
"title": "Token 消费趋势",
"tooltip": "Token 数量"
}
},
"statistics": {
"title": "统计",
"tooltip": {
"date": "日期",
"value": "数值"
}
}
},
"setting": {
"title": "系统设置",
"tabs": {
"personal": "个人设置",
"operation": "运营设置",
"system": "系统设置",
"other": "其他设置"
},
"personal": {
"general": {
"title": "通用设置",
"system_token_notice": "注意,此处生成的令牌用于系统管理,而非用于请求 OpenAI 相关的服务,请知悉。",
"buttons": {
"update_profile": "更新个人信息",
"generate_token": "生成系统访问令牌",
"copy_invite": "复制邀请链接",
"delete_account": "删除个人账户"
}
},
"binding": {
"title": "账号绑定",
"buttons": {
"bind_wechat": "绑定微信账号",
"bind_github": "绑定 GitHub 账号",
"bind_email": "绑定邮箱地址",
"bind_lark": "绑定飞书账号"
},
"wechat": {
"title": "微信绑定",
"description": "微信扫码关注公众号,输入「验证码」获取验证码(三分钟内有效)",
"verification_code": "验证码",
"bind": "绑定"
},
"email": {
"title": "绑定邮箱地址",
"email_placeholder": "输入邮箱地址",
"code_placeholder": "验证码",
"get_code": "获取验证码",
"get_code_retry": "重新发送({{countdown}})",
"bind": "确认绑定",
"cancel": "取消"
}
},
"delete_account": {
"title": "危险操作",
"warning": "您正在删除自己的帐户,将清空所有数据且不可恢复",
"confirm_placeholder": "输入你的账户名 {{username}} 以确认删除",
"buttons": {
"confirm": "确认删除",
"cancel": "取消"
}
}
},
"system": {
"general": {
"title": "通用设置",
"server_address": "服务器地址",
"server_address_placeholder": "例如https://yourdomain.com",
"buttons": {
"update": "更新服务器地址"
}
},
"login": {
"title": "配置登录注册",
"password_login": "允许通过密码进行登录",
"password_register": "允许通过密码进行注册",
"email_verification": "通过密码注册时需要进行邮箱验证",
"github_oauth": "允许通过 GitHub 账户登录 & 注册",
"wechat_login": "允许通过微信登录 & 注册",
"registration": "允许新用户注册(此项为否时,新用户将无法以任何方式进行注册)",
"turnstile": "启用 Turnstile 用户校验"
},
"email_restriction": {
"title": "配置邮箱域名白名单",
"subtitle": "用以防止恶意用户利用临时邮箱批量注册",
"enable": "启用邮箱域名白名单",
"allowed_domains": "允许的邮箱域名",
"add_domain": "添加新的允许的邮箱域名",
"add_domain_placeholder": "输入新的允许的邮箱域名",
"buttons": {
"fill": "填入",
"save": "保存邮箱域名白名单设置"
}
},
"smtp": {
"title": "配置 SMTP",
"subtitle": "用以支持系统的邮件发送",
"server": "SMTP 服务器地址",
"server_placeholder": "例如smtp.qq.com",
"port": "SMTP 端口",
"port_placeholder": "默认: 587",
"account": "SMTP 账户",
"account_placeholder": "通常是邮箱地址",
"from": "SMTP 发送者邮箱",
"from_placeholder": "通常和邮箱地址保持一致",
"token": "SMTP 访问凭证",
"token_placeholder": "敏感信息不会发送到前端显示",
"buttons": {
"save": "保存 SMTP 设置"
}
},
"github": {
"title": "配置 GitHub OAuth App",
"subtitle": "用以支持通过 GitHub 进行登录注册",
"manage_link": "点击此处",
"manage_text": "管理你的 GitHub OAuth App",
"url_notice": "Homepage URL 填 {{server_url}}Authorization callback URL 填 {{callback_url}}",
"client_id": "GitHub Client ID",
"client_id_placeholder": "输入你注册的 GitHub OAuth APP 的 ID",
"client_secret": "GitHub Client Secret",
"client_secret_placeholder": "敏感信息不会发送到前端显示",
"buttons": {
"save": "保存 GitHub OAuth 设置"
}
},
"lark": {
"title": "配置飞书授权登录",
"subtitle": "用以支持通过飞书进行登录注册",
"manage_link": "点击此处",
"manage_text": "管理你的飞书应用",
"url_notice": "主页链接填 {{server_url}},重定向 URL 填 {{callback_url}}",
"client_id": "App ID",
"client_id_placeholder": "输入 App ID",
"client_secret": "App Secret",
"client_secret_placeholder": "敏感信息不会发送到前端显示",
"buttons": {
"save": "保存飞书 OAuth 设置"
}
},
"wechat": {
"title": "配置 WeChat Server",
"subtitle": "用以支持通过微信进行登录注册",
"learn_more": "了解 WeChat Server",
"server_address": "WeChat Server 服务器地址",
"server_address_placeholder": "例如https://yourdomain.com",
"token": "WeChat Server 访问凭证",
"token_placeholder": "敏感信息不会发送到前端显示",
"qrcode": "微信公众号二维码图片链接",
"qrcode_placeholder": "输入一个图片链接",
"buttons": {
"save": "保存 WeChat Server 设置"
}
},
"turnstile": {
"title": "配置 Turnstile",
"subtitle": "用以支持用户校验",
"manage_link": "点击此处",
"manage_text": "管理你的 Turnstile Sites推荐选择 Invisible Widget Type",
"site_key": "Turnstile Site Key",
"site_key_placeholder": "输入你注册的 Turnstile Site Key",
"secret_key": "Turnstile Secret Key",
"secret_key_placeholder": "敏感信息不会发送到前端显示",
"buttons": {
"save": "保存 Turnstile 设置"
}
},
"password_login": {
"warning": {
"title": "警告",
"content": "取消密码登录将导致所有未绑定其他登录方式的用户(包括管理员)无法通过密码登录,确认取消?",
"buttons": {
"confirm": "确定",
"cancel": "取消"
}
}
}
},
"operation": {
"quota": {
"title": "额度设置",
"new_user": "新用户初始额度",
"new_user_placeholder": "例如100",
"pre_consume": "请求预扣费额度",
"pre_consume_placeholder": "请求结束后多退少补",
"inviter_reward": "邀请新用户奖励额度",
"inviter_reward_placeholder": "例如2000",
"invitee_reward": "新用户使用邀请码奖励额度",
"invitee_reward_placeholder": "例如1000",
"buttons": {
"save": "保存额度设置"
}
},
"ratio": {
"title": "倍率设置",
"model": {
"title": "模型倍率",
"placeholder": "为一个 JSON 文本,键为模型名称,值为倍率"
},
"completion": {
"title": "补全倍率",
"placeholder": "为一个 JSON 文本,键为模型名称,值为倍率,此处的倍率设置是模型补全倍率相较于提示倍率的比例,使用该设置可强制覆盖 One API 的内部比例"
},
"group": {
"title": "分组倍率",
"placeholder": "为一个 JSON 文本,键为分组名称,值为倍率"
},
"buttons": {
"save": "保存倍率设置"
}
},
"log": {
"title": "日志设置",
"enable_consume": "启用额度消费日志记录",
"target_time": "目标时间",
"buttons": {
"clean": "清理历史日志"
}
},
"monitor": {
"title": "监控设置",
"max_response_time": "最长响应时间",
"max_response_time_placeholder": "单位秒,当运行渠道全部测试时,超过此时间将自动禁用渠道",
"quota_reminder": "额度提醒阈值",
"quota_reminder_placeholder": "低于此额度时将发送邮件提醒用户",
"auto_disable": "失败时自动禁用渠道",
"auto_enable": "成功时自动启用渠道",
"buttons": {
"save": "保存监控设置"
}
},
"general": {
"title": "通用设置",
"topup_link": "充值链接",
"topup_link_placeholder": "例如发卡网站的购买链接",
"chat_link": "聊天页面链接",
"chat_link_placeholder": "例如 ChatGPT Next Web 的部署地址",
"quota_per_unit": "单位美元额度",
"quota_per_unit_placeholder": "一单位货币能兑换的额度",
"retry_times": "失败重试次数",
"retry_times_placeholder": "失败重试次数",
"display_in_currency": "以货币形式显示额度",
"display_token_stat": "Billing 相关 API 显示令牌额度而非用户额度",
"approximate_token": "使用近似的方式估算 token 数以减少计算量",
"buttons": {
"save": "保存通用设置"
}
}
},
"other": {
"notice": {
"title": "公告设置",
"content": "公告内容",
"content_placeholder": "在此输入新的公告内容,支持 Markdown & HTML 代码",
"buttons": {
"save": "保存公告"
}
},
"system": {
"title": "系统设置",
"name": "系统名称",
"name_placeholder": "请输入系统名称",
"logo": "Logo 图片地址",
"logo_placeholder": "在此输入 Logo 图片地址",
"theme": {
"title": "主题名称",
"link": "当前可用主题",
"placeholder": "请输入主题名称"
},
"buttons": {
"save_name": "设置系统名称",
"save_logo": "设置 Logo",
"save_theme": "设置主题(重启生效)"
}
},
"content": {
"title": "内容设置",
"homepage": {
"title": "首页内容",
"placeholder": "在此输入首页内容,支持 Markdown & HTML 代码,设置后首页的状态信息将不再显示。如果输入的是一个链接,则会使用该链接作为 iframe 的 src 属性,这允许你设置任意网页作为首页。"
},
"about": {
"title": "关于",
"placeholder": "在此输入新的关于内容,支持 Markdown & HTML 代码。如果输入的是一个链接,则会使用该链接作为 iframe 的 src 属性,这允许你设置任意网页作为关于页面。"
},
"footer": {
"title": "页脚",
"placeholder": "在此输入新的页脚,留空则使用默认页脚,支持 HTML 代码"
},
"buttons": {
"save_homepage": "保存首页内容",
"save_about": "保存关于",
"save_footer": "设置页脚"
}
},
"copyright": {
"notice": "移除 One API 的版权标识必须首先获得授权,项目维护需要花费大量精力,如果本项目对你有意义,请主动支持本项目。"
}
}
},
"about": {
"title": "关于",
"description": "One API 是一个开源的接口管理和代理平台。",
"repository": "项目地址:",
"loading_failed": "加载失败"
},
"footer": {
"built_by": "由",
"built_by_name": "JustSong",
"license": "构建,源代码遵循",
"mit": "MIT 协议"
},
"home": {
"welcome": {
"title": "欢迎使用 One API",
"description": "One API 是一个 LLM API 接口管理和分发系统,可以帮助您更好地管理和使用各大厂商的 LLM API。",
"login_notice": "如需使用,请先登录或注册。"
},
"system_status": {
"title": "系统状况",
"info": {
"title": "系统信息",
"name": "名称:",
"version": "版本:",
"source": "源码:",
"source_link": "GitHub 仓库",
"start_time": "启动时间:"
},
"config": {
"title": "系统配置",
"email_verify": "邮箱验证:",
"github_oauth": "GitHub 身份验证:",
"wechat_login": "微信身份验证:",
"turnstile": "Turnstile 校验:",
"enabled": "已启用",
"disabled": "未启用"
}
},
"loading_failed": "加载首页内容失败..."
},
"auth": {
"login": {
"title": "用户登录",
"username": "用户名 / 邮箱地址",
"password": "密码",
"button": "登录",
"forgot_password": "忘记密码?",
"reset_password": "点击重置",
"no_account": "没有账户?",
"register": "点击注册",
"other_methods": "使用其他方式登录",
"wechat": {
"scan_tip": "微信扫码关注公众号,输入「验证码」获取验证码(三分钟内有效)",
"code_placeholder": "验证码"
}
},
"register": {
"title": "新用户注册",
"username": "输入用户名,最长 12 位",
"password": "输入密码,最短 8 位,最长 20 位",
"confirm_password": "再次输入密码",
"email": "输入邮箱地址",
"verification_code": "输入验证码",
"get_code": "获取验证码",
"get_code_retry": "重试 ({{countdown}})",
"button": "注册",
"has_account": "已有账户?",
"login": "点击登录"
},
"reset": {
"title": "密码重置",
"email": "邮箱地址",
"button": "提交",
"notice": "系统将向您的邮箱发送一封包含重置链接的邮件,请注意查收。",
"confirm": {
"title": "密码重置确认",
"new_password": "新密码",
"button": "提交",
"button_disabled": "密码重置完成",
"notice": "新密码已生成,请点击密码框或上方按钮复制。请及时登录并修改密码!"
}
}
},
"messages": {
"success": {
"login": "登录成功!",
"register": "注册成功!",
"verification_code": "验证码发送成功,请检查你的邮箱!",
"password_reset": "重置邮件发送成功,请检查邮箱!"
},
"error": {
"login_expired": "未登录或登录已过期,请重新登录!",
"password_length": "密码长度不得小于 8 位!",
"password_mismatch": "两次输入的密码不一致",
"turnstile_wait": "请稍后几秒重试Turnstile 正在检查用户环境!",
"root_password": "请立刻修改默认密码!"
},
"notice": {
"password_copied": "新密码已复制到剪贴板:{{password}}"
}
}
}

View File

@@ -1,9 +1,11 @@
import React, { useEffect, useState } from 'react';
import { Card, Header, Segment } from 'semantic-ui-react';
import { useTranslation } from 'react-i18next';
import { Card } from 'semantic-ui-react';
import { API, showError } from '../../helpers';
import { marked } from 'marked';
const About = () => {
const { t } = useTranslation();
const [about, setAbout] = useState('');
const [aboutLoaded, setAboutLoaded] = useState(false);
@@ -20,7 +22,7 @@ const About = () => {
localStorage.setItem('about', aboutContent);
} else {
showError(message);
setAbout('Failed to load the About content...');
setAbout(t('about.loading_failed'));
}
setAboutLoaded(true);
};
@@ -28,17 +30,18 @@ const About = () => {
useEffect(() => {
displayAbout().then();
}, []);
return (
<>
{aboutLoaded && about === '' ? (
<div className='dashboard-container'>
<Card fluid className='chart-card'>
<Card.Content>
<Card.Header className='header'>About the System</Card.Header>
<p>You can set the about content on the settings page, supporting HTML & Markdown</p>
Project repository address:
<a href='https://github.com/Laisky/one-api'>
https://github.com/Laisky/one-api
<Card.Header className='header'>{t('about.title')}</Card.Header>
<p>{t('about.description')}</p>
{t('about.repository')}
<a href='https://github.com/songquanpeng/one-api'>
https://github.com/songquanpeng/one-api
</a>
</Card.Content>
</Card>
@@ -51,10 +54,16 @@ const About = () => {
style={{ width: '100%', height: '100vh', border: 'none' }}
/>
) : (
<div
style={{ fontSize: 'larger' }}
dangerouslySetInnerHTML={{ __html: about }}
></div>
<div className='dashboard-container'>
<Card fluid className='chart-card'>
<Card.Content>
<div
style={{ fontSize: 'larger' }}
dangerouslySetInnerHTML={{ __html: about }}
></div>
</Card.Content>
</Card>
</div>
)}
</>
)}

View File

@@ -207,6 +207,9 @@ const EditChannel = () => {
return;
}
let localInputs = { ...inputs };
if (localInputs.key === 'undefined|undefined|undefined') {
localInputs.key = ''; // prevent potential bug
}
if (localInputs.base_url && localInputs.base_url.endsWith('/')) {
localInputs.base_url = localInputs.base_url.slice(
0,
@@ -621,6 +624,21 @@ const EditChannel = () => {
/>
</Form.Field>
))}
{inputs.type === 37 && (
<Form.Field>
<Form.Input
label='Account ID'
name='user_id'
required
placeholder={
'请输入 Account ID例如d8d7c61dbc334c32d3ced580e4bf42b4'
}
onChange={handleConfigChange}
value={config.user_id}
autoComplete=''
/>
</Form.Field>
)}
{inputs.type !== 33 && !isEdit && (
<Form.Checkbox
checked={batch}

View File

@@ -36,7 +36,7 @@
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04) !important;
border: none !important;
border-radius: 16px !important;
padding-top: 8px!important;
padding: 8px!important;
}
.chart-container {
@@ -91,15 +91,15 @@
}
.settings-tab .item {
color: #2B3674 !important;
color: #000 !important;
font-weight: 500 !important;
padding: 0.8rem 1.2rem !important;
}
.settings-tab .active.item {
color: #4318FF !important;
color: #000 !important;
font-weight: 600 !important;
border-color: #4318FF !important;
border-color: #000 !important;
}
.ui.tab.segment {

View File

@@ -1,16 +1,17 @@
import React, { useEffect, useState } from 'react';
import { Card, Grid, Statistic } from 'semantic-ui-react';
import React, {useEffect, useState} from 'react';
import {useTranslation} from 'react-i18next';
import {Card, Grid} from 'semantic-ui-react';
import {
LineChart,
Bar,
BarChart,
CartesianGrid,
Legend,
Line,
LineChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
BarChart,
Bar,
Legend,
} from 'recharts';
import axios from 'axios';
import './Dashboard.css';
@@ -53,6 +54,7 @@ const chartConfig = {
};
const Dashboard = () => {
const { t } = useTranslation();
const [data, setData] = useState([]);
const [summaryData, setSummaryData] = useState({
todayRequests: 0,
@@ -84,7 +86,7 @@ const Dashboard = () => {
setSummaryData({
todayRequests: 0,
todayQuota: 0,
todayTokens: 0
todayTokens: 0,
});
return;
}
@@ -114,8 +116,18 @@ const Dashboard = () => {
// 获取日期范围
const dates = data.map((item) => item.Day);
const minDate = new Date(Math.min(...dates.map((d) => new Date(d))));
const maxDate = new Date(Math.max(...dates.map((d) => new Date(d))));
const maxDate = new Date(); // 总是使用今天作为最后一天
let minDate =
dates.length > 0
? new Date(Math.min(...dates.map((d) => new Date(d))))
: new Date();
// 确保至少显示5天的数据
const fiveDaysAgo = new Date();
fiveDaysAgo.setDate(fiveDaysAgo.getDate() - 4); // -4是因为包含今天
if (minDate > fiveDaysAgo) {
minDate = fiveDaysAgo;
}
// 生成所有日期
for (let d = new Date(minDate); d <= maxDate; d.setDate(d.getDate() + 1)) {
@@ -146,8 +158,18 @@ const Dashboard = () => {
// 获取日期范围
const dates = data.map((item) => item.Day);
const minDate = new Date(Math.min(...dates.map((d) => new Date(d))));
const maxDate = new Date(Math.max(...dates.map((d) => new Date(d))));
const maxDate = new Date(); // 总是使用今天作为最后一天
let minDate =
dates.length > 0
? new Date(Math.min(...dates.map((d) => new Date(d))))
: new Date();
// 确保至少显示5天的数据
const fiveDaysAgo = new Date();
fiveDaysAgo.setDate(fiveDaysAgo.getDate() - 4); // -4是因为包含今天
if (minDate > fiveDaysAgo) {
minDate = fiveDaysAgo;
}
// 生成所有日期
for (let d = new Date(minDate); d <= maxDate; d.setDate(d.getDate() + 1)) {
@@ -186,6 +208,31 @@ const Dashboard = () => {
return chartConfig.barColors[index % chartConfig.barColors.length];
};
// 添加一个日期格式化函数
const formatDate = (dateStr) => {
const date = new Date(dateStr);
return date.toLocaleDateString('zh-CN', {
month: 'numeric',
day: 'numeric',
});
};
// 修改所有 XAxis 配置
const xAxisConfig = {
dataKey: 'date',
axisLine: false,
tickLine: false,
tick: {
fontSize: 12,
fill: '#A3AED0',
textAnchor: 'middle', // 文本居中对齐
},
tickFormatter: formatDate,
interval: 0,
minTickGap: 5,
padding: { left: 30, right: 30 }, // 增加两侧的内边距,确保首尾标签完整显示
};
return (
<div className='dashboard-container'>
{/* 三个并排的折线图 */}
@@ -194,11 +241,15 @@ const Dashboard = () => {
<Card fluid className='chart-card'>
<Card.Content>
<Card.Header>
模型请求趋势
<span className='stat-value'>{summaryData.todayRequests}</span>
{t('dashboard.charts.requests.title')}
{/* <span className='stat-value'>{summaryData.todayRequests}</span> */}
</Card.Header>
<div className='chart-container'>
<ResponsiveContainer width='100%' height={120}>
<ResponsiveContainer
width='100%'
height={120}
margin={{ left: 10, right: 10 }} // 调整容器边距
>
<LineChart data={timeSeriesData}>
<CartesianGrid
strokeDasharray='3 3'
@@ -206,12 +257,7 @@ const Dashboard = () => {
horizontal={chartConfig.lineChart.grid.horizontal}
opacity={chartConfig.lineChart.grid.opacity}
/>
<XAxis
dataKey='date'
axisLine={false}
tickLine={false}
tick={{ fontSize: 12, fill: '#A3AED0' }}
/>
<XAxis {...xAxisConfig} />
<YAxis hide={true} />
<Tooltip
contentStyle={{
@@ -220,6 +266,13 @@ const Dashboard = () => {
borderRadius: '4px',
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
}}
formatter={(value) => [
value,
t('dashboard.charts.requests.tooltip'),
]}
labelFormatter={(label) =>
`${t('dashboard.statistics.tooltip.date')}: ${formatDate(label)}`
}
/>
<Line
type='monotone'
@@ -240,13 +293,17 @@ const Dashboard = () => {
<Card fluid className='chart-card'>
<Card.Content>
<Card.Header>
额度消费趋势
<span className='stat-value'>
{t('dashboard.charts.quota.title')}
{/* <span className='stat-value'>
${summaryData.todayQuota.toFixed(3)}
</span>
</span> */}
</Card.Header>
<div className='chart-container'>
<ResponsiveContainer width='100%' height={120}>
<ResponsiveContainer
width='100%'
height={120}
margin={{ left: 10, right: 10 }} // 调整容器边距
>
<LineChart data={timeSeriesData}>
<CartesianGrid
strokeDasharray='3 3'
@@ -254,12 +311,7 @@ const Dashboard = () => {
horizontal={chartConfig.lineChart.grid.horizontal}
opacity={chartConfig.lineChart.grid.opacity}
/>
<XAxis
dataKey='date'
axisLine={false}
tickLine={false}
tick={{ fontSize: 12, fill: '#A3AED0' }}
/>
<XAxis {...xAxisConfig} />
<YAxis hide={true} />
<Tooltip
contentStyle={{
@@ -268,6 +320,13 @@ const Dashboard = () => {
borderRadius: '4px',
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
}}
formatter={(value) => [
value,
t('dashboard.charts.quota.tooltip'),
]}
labelFormatter={(label) =>
`${t('dashboard.statistics.tooltip.date')}: ${formatDate(label)}`
}
/>
<Line
type='monotone'
@@ -288,11 +347,15 @@ const Dashboard = () => {
<Card fluid className='chart-card'>
<Card.Content>
<Card.Header>
Token 消费趋势
<span className='stat-value'>{summaryData.todayTokens}</span>
{t('dashboard.charts.tokens.title')}
{/* <span className='stat-value'>{summaryData.todayTokens}</span> */}
</Card.Header>
<div className='chart-container'>
<ResponsiveContainer width='100%' height={120}>
<ResponsiveContainer
width='100%'
height={120}
margin={{ left: 10, right: 10 }} // 调整容器边距
>
<LineChart data={timeSeriesData}>
<CartesianGrid
strokeDasharray='3 3'
@@ -300,12 +363,7 @@ const Dashboard = () => {
horizontal={chartConfig.lineChart.grid.horizontal}
opacity={chartConfig.lineChart.grid.opacity}
/>
<XAxis
dataKey='date'
axisLine={false}
tickLine={false}
tick={{ fontSize: 12, fill: '#A3AED0' }}
/>
<XAxis {...xAxisConfig} />
<YAxis hide={true} />
<Tooltip
contentStyle={{
@@ -314,6 +372,13 @@ const Dashboard = () => {
borderRadius: '4px',
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
}}
formatter={(value) => [
value,
t('dashboard.charts.tokens.tooltip'),
]}
labelFormatter={(label) =>
`${t('dashboard.statistics.tooltip.date')}: ${formatDate(label)}`
}
/>
<Line
type='monotone'
@@ -334,7 +399,7 @@ const Dashboard = () => {
{/* 模型使用统计 */}
<Card fluid className='chart-card'>
<Card.Content>
<Card.Header>统计</Card.Header>
<Card.Header>{t('dashboard.statistics.title')}</Card.Header>
<div className='chart-container'>
<ResponsiveContainer width='100%' height={300}>
<BarChart data={modelData}>
@@ -343,12 +408,7 @@ const Dashboard = () => {
vertical={false}
opacity={0.1}
/>
<XAxis
dataKey='date'
axisLine={false}
tickLine={false}
tick={{ fontSize: 12, fill: '#A3AED0' }}
/>
<XAxis {...xAxisConfig} />
<YAxis
axisLine={false}
tickLine={false}
@@ -361,6 +421,9 @@ const Dashboard = () => {
borderRadius: '4px',
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
}}
labelFormatter={(label) =>
`${t('dashboard.statistics.tooltip.date')}: ${formatDate(label)}`
}
/>
<Legend
wrapperStyle={{

View File

@@ -1,5 +1,6 @@
import React, { useContext, useEffect, useState } from 'react';
import { Card, Grid, Header, Segment } from 'semantic-ui-react';
import { useTranslation } from 'react-i18next';
import { Card, Grid, Header } from 'semantic-ui-react';
import { API, showError, showNotice, timestamp2string } from '../../helpers';
import { StatusContext } from '../../context/Status';
import { marked } from 'marked';
@@ -7,6 +8,7 @@ import { UserContext } from '../../context/User';
import { Link } from 'react-router-dom';
const Home = () => {
const { t } = useTranslation();
const [statusState, statusDispatch] = useContext(StatusContext);
const [homePageContentLoaded, setHomePageContentLoaded] = useState(false);
const [homePageContent, setHomePageContent] = useState('');
@@ -40,7 +42,7 @@ const Home = () => {
localStorage.setItem('home_page_content', content);
} else {
showError(message);
setHomePageContent('Failed to load homepage content...');
setHomePageContent(t('home.loading_failed'));
}
setHomePageContentLoaded(true);
};
@@ -61,23 +63,19 @@ const Home = () => {
<div className='dashboard-container'>
<Card fluid className='chart-card'>
<Card.Content>
<Card.Header className='header'>Welcome to One API</Card.Header>
<Card.Header className='header'>
{t('home.welcome.title')}
</Card.Header>
<Card.Description style={{ lineHeight: '1.6' }}>
<p>
One API is an LLM API interface management and distribution system that helps you better manage and use LLM APIs from various vendors.
</p>
{!userState.user && (
<p>
To use, please <Link to='/login'>log in</Link> or <Link to='/register'>register</Link>.
</p>
)}
<p>{t('home.welcome.description')}</p>
{!userState.user && <p>{t('home.welcome.login_notice')}</p>}
</Card.Description>
</Card.Content>
</Card>
<Card fluid className='chart-card'>
<Card.Content>
<Card.Header>
<Header as='h3'>System Status</Header>
<Header as='h3'>{t('home.system_status.title')}</Header>
</Card.Header>
<Grid columns={2} stackable>
<Grid.Column>
@@ -89,7 +87,7 @@ const Home = () => {
<Card.Content>
<Card.Header>
<Header as='h3' style={{ color: '#444' }}>
System Information
{t('home.system_status.info.title')}
</Header>
</Card.Header>
<Card.Description
@@ -103,7 +101,9 @@ const Home = () => {
}}
>
<i className='info circle icon'></i>
<span style={{ fontWeight: 'bold' }}>Name:</span>
<span style={{ fontWeight: 'bold' }}>
{t('home.system_status.info.name')}
</span>
<span>{statusState?.status?.system_name}</span>
</p>
<p
@@ -114,7 +114,9 @@ const Home = () => {
}}
>
<i className='code branch icon'></i>
<span style={{ fontWeight: 'bold' }}>Version:</span>
<span style={{ fontWeight: 'bold' }}>
{t('home.system_status.info.version')}
</span>
<span>
{statusState?.status?.version || 'unknown'}
</span>
@@ -127,13 +129,15 @@ const Home = () => {
}}
>
<i className='github icon'></i>
<span style={{ fontWeight: 'bold' }}>Source Code:</span>
<span style={{ fontWeight: 'bold' }}>
{t('home.system_status.info.source')}
</span>
<a
href='https://github.com/songquanpeng/one-api'
target='_blank'
style={{ color: '#2185d0' }}
>
GitHub Repository
{t('home.system_status.info.source_link')}
</a>
</p>
<p
@@ -144,7 +148,9 @@ const Home = () => {
}}
>
<i className='clock outline icon'></i>
<span style={{ fontWeight: 'bold' }}>Start Time:</span>
<span style={{ fontWeight: 'bold' }}>
{t('home.system_status.info.start_time')}
</span>
<span>{getStartTimeString()}</span>
</p>
</Card.Description>
@@ -161,7 +167,7 @@ const Home = () => {
<Card.Content>
<Card.Header>
<Header as='h3' style={{ color: '#444' }}>
System Configuration
{t('home.system_status.config.title')}
</Header>
</Card.Header>
<Card.Description
@@ -175,7 +181,9 @@ const Home = () => {
}}
>
<i className='envelope icon'></i>
<span style={{ fontWeight: 'bold' }}>Email Verification:</span>
<span style={{ fontWeight: 'bold' }}>
{t('home.system_status.config.email_verify')}
</span>
<span
style={{
color: statusState?.status?.email_verification
@@ -185,8 +193,8 @@ const Home = () => {
}}
>
{statusState?.status?.email_verification
? 'Enabled'
: 'Disabled'}
? t('home.system_status.config.enabled')
: t('home.system_status.config.disabled')}
</span>
</p>
<p
@@ -198,7 +206,7 @@ const Home = () => {
>
<i className='github icon'></i>
<span style={{ fontWeight: 'bold' }}>
GitHub Authentication:
{t('home.system_status.config.github_oauth')}
</span>
<span
style={{
@@ -209,8 +217,8 @@ const Home = () => {
}}
>
{statusState?.status?.github_oauth
? 'Enabled'
: 'Disabled'}
? t('home.system_status.config.enabled')
: t('home.system_status.config.disabled')}
</span>
</p>
<p
@@ -222,7 +230,7 @@ const Home = () => {
>
<i className='wechat icon'></i>
<span style={{ fontWeight: 'bold' }}>
WeChat Authentication:
{t('home.system_status.config.wechat_login')}
</span>
<span
style={{
@@ -233,8 +241,8 @@ const Home = () => {
}}
>
{statusState?.status?.wechat_login
? 'Enabled'
: 'Disabled'}
? t('home.system_status.config.enabled')
: t('home.system_status.config.disabled')}
</span>
</p>
<p
@@ -246,7 +254,7 @@ const Home = () => {
>
<i className='shield alternate icon'></i>
<span style={{ fontWeight: 'bold' }}>
Turnstile Check:
{t('home.system_status.config.turnstile')}
</span>
<span
style={{
@@ -257,8 +265,8 @@ const Home = () => {
}}
>
{statusState?.status?.turnstile_check
? 'Enabled'
: 'Disabled'}
? t('home.system_status.config.enabled')
: t('home.system_status.config.disabled')}
</span>
</p>
</Card.Description>
@@ -267,7 +275,7 @@ const Home = () => {
</Grid.Column>
</Grid>
</Card.Content>
</Card>{' '}
</Card>
</div>
) : (
<>

View File

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

View File

@@ -1,10 +1,12 @@
import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Form, Card } from 'semantic-ui-react';
import { useParams, useNavigate } from 'react-router-dom';
import { API, downloadTextAsFile, showError, showSuccess } from '../../helpers';
import { renderQuota, renderQuotaWithPrompt } from '../../helpers/render';
const EditRedemption = () => {
const { t } = useTranslation();
const params = useParams();
const navigate = useNavigate();
const redemptionId = params.id;
@@ -61,9 +63,9 @@ const EditRedemption = () => {
const { success, message, data } = res.data;
if (success) {
if (isEdit) {
showSuccess('Redemption code updated successfully!');
showSuccess(t('redemption.messages.update_success'));
} else {
showSuccess('Redemption code created successfully!');
showSuccess(t('redemption.messages.create_success'));
setInputs(originInputs);
}
} else {
@@ -83,14 +85,14 @@ const EditRedemption = () => {
<Card fluid className='chart-card'>
<Card.Content>
<Card.Header className='header'>
{isEdit ? 'Update Redemption Code Information' : 'Create New Redemption Code'}
{isEdit ? t('redemption.edit.title_edit') : t('redemption.edit.title_create')}
</Card.Header>
<Form loading={loading} autoComplete='new-password'>
<Form.Field>
<Form.Input
label='Name'
label={t('redemption.edit.name')}
name='name'
placeholder={'Please enter name'}
placeholder={t('redemption.edit.name_placeholder')}
onChange={handleInputChange}
value={name}
autoComplete='new-password'
@@ -99,9 +101,9 @@ const EditRedemption = () => {
</Form.Field>
<Form.Field>
<Form.Input
label={`Quota ${renderQuotaWithPrompt(quota)}`}
label={`${t('redemption.edit.quota')}${renderQuotaWithPrompt(quota, t)}`}
name='quota'
placeholder={'Please enter the quota included in each redemption code'}
placeholder={t('redemption.edit.quota_placeholder')}
onChange={handleInputChange}
value={quota}
autoComplete='new-password'
@@ -112,9 +114,9 @@ const EditRedemption = () => {
<>
<Form.Field>
<Form.Input
label='Quantity'
label={t('redemption.edit.count')}
name='count'
placeholder={'Please enter the quantity to generate'}
placeholder={t('redemption.edit.count_placeholder')}
onChange={handleInputChange}
value={count}
autoComplete='new-password'
@@ -124,9 +126,11 @@ const EditRedemption = () => {
</>
)}
<Button positive onClick={submit}>
Submit
{t('redemption.edit.buttons.submit')}
</Button>
<Button onClick={handleCancel}>
{t('redemption.edit.buttons.cancel')}
</Button>
<Button onClick={handleCancel}>Cancel</Button>
</Form>
</Card.Content>
</Card>

View File

@@ -1,16 +1,21 @@
import React from 'react';
import { Card } from 'semantic-ui-react';
import { useTranslation } from 'react-i18next';
import RedemptionsTable from '../../components/RedemptionsTable';
const Redemption = () => (
<div className='dashboard-container'>
<Card fluid className='chart-card'>
<Card.Content>
<Card.Header className='header'>Manage Redeem Code</Card.Header>
<RedemptionsTable />
</Card.Content>
</Card>
</div>
);
const Redemption = () => {
const { t } = useTranslation();
return (
<div className='dashboard-container'>
<Card fluid className='chart-card'>
<Card.Content>
<Card.Header className='header'>{t('redemption.title')}</Card.Header>
<RedemptionsTable />
</Card.Content>
</Card>
</div>
);
};
export default Redemption;

View File

@@ -1,4 +1,5 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Card, Tab } from 'semantic-ui-react';
import SystemSetting from '../../components/SystemSetting';
import { isRoot } from '../../helpers';
@@ -7,9 +8,11 @@ import PersonalSetting from '../../components/PersonalSetting';
import OperationSetting from '../../components/OperationSetting';
const Setting = () => {
const { t } = useTranslation();
let panes = [
{
menuItem: 'Personal settings',
menuItem: t('setting.tabs.personal'),
render: () => (
<Tab.Pane attached={false}>
<PersonalSetting />
@@ -20,7 +23,7 @@ const Setting = () => {
if (isRoot()) {
panes.push({
menuItem: 'Operations settings',
menuItem: t('setting.tabs.operation'),
render: () => (
<Tab.Pane attached={false}>
<OperationSetting />
@@ -28,7 +31,7 @@ const Setting = () => {
),
});
panes.push({
menuItem: 'System settings',
menuItem: t('setting.tabs.system'),
render: () => (
<Tab.Pane attached={false}>
<SystemSetting />
@@ -36,7 +39,7 @@ const Setting = () => {
),
});
panes.push({
menuItem: 'Other settings',
menuItem: t('setting.tabs.other'),
render: () => (
<Tab.Pane attached={false}>
<OtherSetting />
@@ -49,12 +52,12 @@ const Setting = () => {
<div className='dashboard-container'>
<Card fluid className='chart-card'>
<Card.Content>
<Card.Header className='header'>系统设置</Card.Header>
<Card.Header className='header'>{t('setting.title')}</Card.Header>
<Tab
menu={{
secondary: true,
pointing: true,
className: 'settings-tab', // 添加自定义类名以便样式化
className: 'settings-tab',
}}
panes={panes}
/>

View File

@@ -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('Expiration time format error!');
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('Token updated successfully');
showSuccess(t('token.edit.messages.update_success'));
} else {
showSuccess('Token created successfully, please click copy on the list page to get the token!');
showSuccess(t('token.edit.messages.create_success'));
setInputs(originInputs);
}
} else {
@@ -141,14 +157,14 @@ const EditToken = () => {
<Card fluid className='chart-card'>
<Card.Content>
<Card.Header className='header'>
{isEdit ? 'Update Token Information' : 'Create New Token'}
{isEdit ? t('token.edit.title_edit') : t('token.edit.title_create')}
</Card.Header>
<Form loading={loading} autoComplete='new-password'>
<Form.Field>
<Form.Input
label='Name'
label={t('token.edit.name')}
name='name'
placeholder={'Please enter name'}
placeholder={t('token.edit.name_placeholder')}
onChange={handleInputChange}
value={name}
autoComplete='new-password'
@@ -157,8 +173,8 @@ const EditToken = () => {
</Form.Field>
<Form.Field>
<Form.Dropdown
label='Model Scope'
placeholder={'Please select allowed models, leave blank for no restriction'}
label={t('token.edit.models')}
placeholder={t('token.edit.models_placeholder')}
name='models'
fluid
multiple
@@ -175,11 +191,9 @@ const EditToken = () => {
</Form.Field>
<Form.Field>
<Form.Input
label='IP Restriction'
label={t('token.edit.ip_limit')}
name='subnet'
placeholder={
'Please enter allowed subnets, e.g., 192.168.0.0/24, use commas to separate multiple subnets'
}
placeholder={t('token.edit.ip_limit_placeholder')}
onChange={handleInputChange}
value={inputs.subnet}
autoComplete='new-password'
@@ -187,11 +201,9 @@ const EditToken = () => {
</Form.Field>
<Form.Field>
<Form.Input
label='Expiration Time'
label={t('token.edit.expire_time')}
name='expired_time'
placeholder={
'Please enter expiration time, format: yyyy-MM-dd HH:mm:ss, -1 means no restriction'
}
placeholder={t('token.edit.expire_time_placeholder')}
onChange={handleInputChange}
value={expired_time}
autoComplete='new-password'
@@ -205,7 +217,7 @@ const EditToken = () => {
setExpiredTime(0, 0, 0, 0);
}}
>
Never Expires
{t('token.edit.buttons.never_expire')}
</Button>
<Button
type={'button'}
@@ -213,7 +225,7 @@ const EditToken = () => {
setExpiredTime(1, 0, 0, 0);
}}
>
Expires in One Month
{t('token.edit.buttons.expire_1_month')}
</Button>
<Button
type={'button'}
@@ -221,7 +233,7 @@ const EditToken = () => {
setExpiredTime(0, 1, 0, 0);
}}
>
Expires in One Day
{t('token.edit.buttons.expire_1_day')}
</Button>
<Button
type={'button'}
@@ -229,7 +241,7 @@ const EditToken = () => {
setExpiredTime(0, 0, 1, 0);
}}
>
Expires in One Hour
{t('token.edit.buttons.expire_1_hour')}
</Button>
<Button
type={'button'}
@@ -237,17 +249,18 @@ const EditToken = () => {
setExpiredTime(0, 0, 0, 1);
}}
>
Expires in One Minute
{t('token.edit.buttons.expire_1_minute')}
</Button>
</div>
<Message>
Note, the token quota is only used to limit the maximum usage of the token itself, actual usage is subject to the account's remaining quota.
</Message>
<Message>{t('token.edit.quota_notice')}</Message>
<Form.Field>
<Form.Input
label={`Quota ${renderQuotaWithPrompt(remain_quota)}`}
label={`${t('token.edit.quota')}${renderQuotaWithPrompt(
remain_quota,
t
)}`}
name='remain_quota'
placeholder={'Please enter quota'}
placeholder={t('token.edit.quota_placeholder')}
onChange={handleInputChange}
value={remain_quota}
autoComplete='new-password'
@@ -261,13 +274,15 @@ const EditToken = () => {
setUnlimitedQuota();
}}
>
{unlimited_quota ? 'Cancel Unlimited Quota' : 'Set as Unlimited Quota'}
{unlimited_quota
? t('token.edit.buttons.cancel_unlimited')
: t('token.edit.buttons.unlimited_quota')}
</Button>
<Button floated='right' positive onClick={submit}>
Submit
{t('token.edit.buttons.submit')}
</Button>
<Button floated='right' onClick={handleCancel}>
Cancel
{t('token.edit.buttons.cancel')}
</Button>
</Form>
</Card.Content>

View File

@@ -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 = () => (
<div className='dashboard-container'>
<Card fluid className='chart-card'>
<Card.Content>
<Card.Header className='header'>Apikeys</Card.Header>
<TokensTable />
</Card.Content>
</Card>
</div>
);
const Token = () => {
const { t } = useTranslation();
return (
<div className='dashboard-container'>
<Card fluid className='chart-card'>
<Card.Content>
<Card.Header className='header'>{t('token.title')}</Card.Header>
<TokensTable />
</Card.Content>
</Card>
</div>
);
};
export default Token;

View File

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

View File

@@ -1,8 +1,10 @@
import React, { useState } from 'react';
import { Button, Form, Header, Segment } from 'semantic-ui-react';
import { useTranslation } from 'react-i18next';
import { Button, Form, Card } from 'semantic-ui-react';
import { API, showError, showSuccess } from '../../helpers';
const AddUser = () => {
const { t } = useTranslation();
const originInputs = {
username: '',
display_name: '',
@@ -20,7 +22,7 @@ const AddUser = () => {
const res = await API.post(`/api/user/`, inputs);
const { success, message } = res.data;
if (success) {
showSuccess('User account created successfully!');
showSuccess(t('user.messages.create_success'));
setInputs(originInputs);
} else {
showError(message);
@@ -28,49 +30,51 @@ const AddUser = () => {
};
return (
<>
<Segment>
<Header as="h3">Create new user account</Header>
<Form autoComplete="off">
<Form.Field>
<Form.Input
label="Username"
name="username"
placeholder={'Please enter username'}
onChange={handleInputChange}
value={username}
autoComplete="off"
required
/>
</Form.Field>
<Form.Field>
<Form.Input
label="Display name"
name="display_name"
placeholder={'Please enter display name'}
onChange={handleInputChange}
value={display_name}
autoComplete="off"
/>
</Form.Field>
<Form.Field>
<Form.Input
label="Password"
name="password"
type={'password'}
placeholder={'Please enter password'}
onChange={handleInputChange}
value={password}
autoComplete="off"
required
/>
</Form.Field>
<Button positive type={'submit'} onClick={submit}>
Submit
</Button>
</Form>
</Segment>
</>
<div className='dashboard-container'>
<Card fluid className='chart-card'>
<Card.Content>
<Card.Header className='header'>{t('user.add.title')}</Card.Header>
<Form autoComplete='off'>
<Form.Field>
<Form.Input
label={t('user.edit.username')}
name='username'
placeholder={t('user.edit.username_placeholder')}
onChange={handleInputChange}
value={username}
autoComplete='off'
required
/>
</Form.Field>
<Form.Field>
<Form.Input
label={t('user.edit.display_name')}
name='display_name'
placeholder={t('user.edit.display_name_placeholder')}
onChange={handleInputChange}
value={display_name}
autoComplete='off'
/>
</Form.Field>
<Form.Field>
<Form.Input
label={t('user.edit.password')}
name='password'
type='password'
placeholder={t('user.edit.password_placeholder')}
onChange={handleInputChange}
value={password}
autoComplete='off'
required
/>
</Form.Field>
<Button positive type='submit' onClick={submit}>
{t('user.edit.buttons.submit')}
</Button>
</Form>
</Card.Content>
</Card>
</div>
);
};

View File

@@ -1,10 +1,12 @@
import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Form, Card } from 'semantic-ui-react';
import { useParams, useNavigate } from 'react-router-dom';
import { API, showError, showSuccess } from '../../helpers';
import { renderQuota, renderQuotaWithPrompt } from '../../helpers/render';
const EditUser = () => {
const { t } = useTranslation();
const params = useParams();
const userId = params.id;
const [loading, setLoading] = useState(true);
@@ -86,7 +88,7 @@ const EditUser = () => {
}
const { success, message } = res.data;
if (success) {
showSuccess('User information updated successfully!');
showSuccess(t('user.messages.update_success'));
} else {
showError(message);
}
@@ -96,13 +98,13 @@ const EditUser = () => {
<div className='dashboard-container'>
<Card fluid className='chart-card'>
<Card.Content>
<Card.Header className='header'>Update User Information</Card.Header>
<Card.Header className='header'>{t('user.edit.title')}</Card.Header>
<Form loading={loading} autoComplete='new-password'>
<Form.Field>
<Form.Input
label='Username'
label={t('user.edit.username')}
name='username'
placeholder={'Please enter new username'}
placeholder={t('user.edit.username_placeholder')}
onChange={handleInputChange}
value={username}
autoComplete='new-password'
@@ -110,10 +112,10 @@ const EditUser = () => {
</Form.Field>
<Form.Field>
<Form.Input
label='Password'
label={t('user.edit.password')}
name='password'
type={'password'}
placeholder={'Please enter new password, minimum 8 characters'}
placeholder={t('user.edit.password_placeholder')}
onChange={handleInputChange}
value={password}
autoComplete='new-password'
@@ -121,9 +123,9 @@ const EditUser = () => {
</Form.Field>
<Form.Field>
<Form.Input
label='Display Name'
label={t('user.edit.display_name')}
name='display_name'
placeholder={'Please enter new display name'}
placeholder={t('user.edit.display_name_placeholder')}
onChange={handleInputChange}
value={display_name}
autoComplete='new-password'
@@ -133,16 +135,14 @@ const EditUser = () => {
<>
<Form.Field>
<Form.Dropdown
label='Group'
placeholder={'Please select group'}
label={t('user.edit.group')}
placeholder={t('user.edit.group_placeholder')}
name='group'
fluid
search
selection
allowAdditions
additionLabel={
'Please edit group ratios in system settings to add new groups:'
}
additionLabel={t('user.edit.group_addition')}
onChange={handleInputChange}
value={inputs.group}
autoComplete='new-password'
@@ -151,9 +151,12 @@ const EditUser = () => {
</Form.Field>
<Form.Field>
<Form.Input
label={`Remaining Quota ${renderQuotaWithPrompt(quota)}`}
label={`${t('user.edit.quota')}${renderQuotaWithPrompt(
quota,
t
)}`}
name='quota'
placeholder={'Please enter new remaining quota'}
placeholder={t('user.edit.quota_placeholder')}
onChange={handleInputChange}
value={quota}
type={'number'}
@@ -164,37 +167,39 @@ const EditUser = () => {
)}
<Form.Field>
<Form.Input
label='Bound GitHub Account'
label={t('user.edit.github_id')}
name='github_id'
value={github_id}
autoComplete='new-password'
placeholder='This field is read-only. Users need to bind through the relevant button on the personal settings page, cannot be modified directly'
placeholder={t('user.edit.github_id_placeholder')}
readOnly
/>
</Form.Field>
<Form.Field>
<Form.Input
label='Bound WeChat Account'
label={t('user.edit.wechat_id')}
name='wechat_id'
value={wechat_id}
autoComplete='new-password'
placeholder='This field is read-only. Users need to bind through the relevant button on the personal settings page, cannot be modified directly'
placeholder={t('user.edit.wechat_id_placeholder')}
readOnly
/>
</Form.Field>
<Form.Field>
<Form.Input
label='Bound Email Account'
label={t('user.edit.email')}
name='email'
value={email}
autoComplete='new-password'
placeholder='This field is read-only. Users need to bind through the relevant button on the personal settings page, cannot be modified directly'
placeholder={t('user.edit.email_placeholder')}
readOnly
/>
</Form.Field>
<Button onClick={handleCancel}>Cancel</Button>
<Button onClick={handleCancel}>
{t('user.edit.buttons.cancel')}
</Button>
<Button positive onClick={submit}>
Submit
{t('user.edit.buttons.submit')}
</Button>
</Form>
</Card.Content>

View File

@@ -1,16 +1,21 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Card } from 'semantic-ui-react';
import UsersTable from '../../components/UsersTable';
const User = () => (
<div className='dashboard-container'>
<Card fluid className='chart-card'>
<Card.Content>
<Card.Header className='header'>Manage Users</Card.Header>
<UsersTable />
</Card.Content>
</Card>
</div>
);
const User = () => {
const { t } = useTranslation();
return (
<div className='dashboard-container'>
<Card fluid className='chart-card'>
<Card.Content>
<Card.Header className='header'>{t('user.title')}</Card.Header>
<UsersTable />
</Card.Content>
</Card>
</div>
);
};
export default User;