first commit: one-api base code + SAAS plan document
Some checks failed
CI / Unit tests (push) Has been cancelled
CI / commit_lint (push) Has been cancelled

This commit is contained in:
huangzhenpc
2025-12-29 22:52:27 +08:00
commit cb7c48bfa7
564 changed files with 61468 additions and 0 deletions

26
web/default/.gitignore vendored Normal file
View File

@@ -0,0 +1,26 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# production
/build
# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.idea
package-lock.json
yarn.lock

21
web/default/README.md Normal file
View File

@@ -0,0 +1,21 @@
# React Template
## Basic Usages
```shell
# Runs the app in the development mode
npm start
# Builds the app for production to the `build` folder
npm run build
```
If you want to change the default server, please set `REACT_APP_SERVER` environment variables before build,
for example: `REACT_APP_SERVER=http://your.domain.com`.
Before you start editing, make sure your `Actions on Save` options have `Optimize imports` & `Run Prettier` enabled.
## Reference
1. https://github.com/OIerDb-ng/OIerDb
2. https://github.com/cornflourblue/react-hooks-redux-registration-login-example

56
web/default/package.json Normal file
View File

@@ -0,0 +1,56 @@
{
"name": "react-template",
"version": "0.1.0",
"private": true,
"dependencies": {
"axios": "^0.27.2",
"history": "^5.3.0",
"i18next": "^24.2.2",
"i18next-browser-languagedetector": "^8.0.2",
"marked": "^4.1.1",
"moment": "^2.30.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-dropzone": "^14.2.3",
"react-i18next": "^15.4.0",
"react-router-dom": "^6.3.0",
"react-scripts": "5.0.1",
"react-toastify": "^9.0.8",
"react-turnstile": "^1.0.5",
"recharts": "^2.15.1",
"semantic-ui-css": "^2.5.0",
"semantic-ui-react": "^2.1.3"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build && rm -rf ../build/default && mv -f build ../build/default",
"test": "react-scripts test",
"eject": "react-scripts eject"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
]
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
},
"devDependencies": {
"prettier": "^2.7.1"
},
"prettier": {
"singleQuote": true,
"jsxSingleQuote": true
},
"proxy": "http://localhost:3000"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

View File

@@ -0,0 +1,18 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<link rel="icon" href="logo.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#ffffff" />
<meta
name="description"
content="OpenAI 接口聚合管理,支持多种渠道包括 Azure可用于二次分发管理 key仅单可执行文件已打包好 Docker 镜像,一键部署,开箱即用"
/>
<title>One API</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
</body>
</html>

BIN
web/default/public/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

View File

@@ -0,0 +1,3 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:

314
web/default/src/App.js Normal file
View File

@@ -0,0 +1,314 @@
import React, { lazy, Suspense, useContext, useEffect } from 'react';
import { Route, Routes } from 'react-router-dom';
import Loading from './components/Loading';
import User from './pages/User';
import { PrivateRoute } from './components/PrivateRoute';
import RegisterForm from './components/RegisterForm';
import LoginForm from './components/LoginForm';
import NotFound from './pages/NotFound';
import Setting from './pages/Setting';
import EditUser from './pages/User/EditUser';
import AddUser from './pages/User/AddUser';
import { API, getLogo, getSystemName, showError, showNotice } from './helpers';
import PasswordResetForm from './components/PasswordResetForm';
import GitHubOAuth from './components/GitHubOAuth';
import PasswordResetConfirm from './components/PasswordResetConfirm';
import { UserContext } from './context/User';
import { StatusContext } from './context/Status';
import Channel from './pages/Channel';
import Token from './pages/Token';
import EditToken from './pages/Token/EditToken';
import EditChannel from './pages/Channel/EditChannel';
import Redemption from './pages/Redemption';
import EditRedemption from './pages/Redemption/EditRedemption';
import TopUp from './pages/TopUp';
import Log from './pages/Log';
import Chat from './pages/Chat';
import LarkOAuth from './components/LarkOAuth';
import Dashboard from './pages/Dashboard';
const Home = lazy(() => import('./pages/Home'));
const About = lazy(() => import('./pages/About'));
function App() {
const [userState, userDispatch] = useContext(UserContext);
const [statusState, statusDispatch] = useContext(StatusContext);
const loadUser = () => {
let user = localStorage.getItem('user');
if (user) {
let data = JSON.parse(user);
userDispatch({ type: 'login', payload: data });
}
};
const loadStatus = async () => {
try {
const res = await API.get('/api/status');
const { success, message, data } = res.data || {}; // Add default empty object
if (success && data) {
// Check data exists
localStorage.setItem('status', JSON.stringify(data));
statusDispatch({ type: 'set', payload: data });
localStorage.setItem('system_name', data.system_name);
localStorage.setItem('logo', data.logo);
localStorage.setItem('footer_html', data.footer_html);
localStorage.setItem('quota_per_unit', data.quota_per_unit);
localStorage.setItem('display_in_currency', data.display_in_currency);
if (data.chat_link) {
localStorage.setItem('chat_link', data.chat_link);
} else {
localStorage.removeItem('chat_link');
}
if (
data.version !== process.env.REACT_APP_VERSION &&
data.version !== 'v0.0.0' &&
process.env.REACT_APP_VERSION !== ''
) {
showNotice(
`新版本可用:${data.version},请使用快捷键 Shift + F5 刷新页面`
);
}
} else {
showError(message || '无法正常连接至服务器!');
}
} catch (error) {
showError(error.message || '无法正常连接至服务器!');
}
};
useEffect(() => {
loadUser();
loadStatus().then();
let systemName = getSystemName();
if (systemName) {
document.title = systemName;
}
let logo = getLogo();
if (logo) {
let linkElement = document.querySelector("link[rel~='icon']");
if (linkElement) {
linkElement.href = logo;
}
}
}, []);
return (
<Routes>
<Route
path='/'
element={
<Suspense fallback={<Loading></Loading>}>
<Home />
</Suspense>
}
/>
<Route
path='/channel'
element={
<PrivateRoute>
<Channel />
</PrivateRoute>
}
/>
<Route
path='/channel/edit/:id'
element={
<Suspense fallback={<Loading></Loading>}>
<EditChannel />
</Suspense>
}
/>
<Route
path='/channel/add'
element={
<Suspense fallback={<Loading></Loading>}>
<EditChannel />
</Suspense>
}
/>
<Route
path='/token'
element={
<PrivateRoute>
<Token />
</PrivateRoute>
}
/>
<Route
path='/token/edit/:id'
element={
<Suspense fallback={<Loading></Loading>}>
<EditToken />
</Suspense>
}
/>
<Route
path='/token/add'
element={
<Suspense fallback={<Loading></Loading>}>
<EditToken />
</Suspense>
}
/>
<Route
path='/redemption'
element={
<PrivateRoute>
<Redemption />
</PrivateRoute>
}
/>
<Route
path='/redemption/edit/:id'
element={
<Suspense fallback={<Loading></Loading>}>
<EditRedemption />
</Suspense>
}
/>
<Route
path='/redemption/add'
element={
<Suspense fallback={<Loading></Loading>}>
<EditRedemption />
</Suspense>
}
/>
<Route
path='/user'
element={
<PrivateRoute>
<User />
</PrivateRoute>
}
/>
<Route
path='/user/edit/:id'
element={
<Suspense fallback={<Loading></Loading>}>
<EditUser />
</Suspense>
}
/>
<Route
path='/user/edit'
element={
<Suspense fallback={<Loading></Loading>}>
<EditUser />
</Suspense>
}
/>
<Route
path='/user/add'
element={
<Suspense fallback={<Loading></Loading>}>
<AddUser />
</Suspense>
}
/>
<Route
path='/user/reset'
element={
<Suspense fallback={<Loading></Loading>}>
<PasswordResetConfirm />
</Suspense>
}
/>
<Route
path='/login'
element={
<Suspense fallback={<Loading></Loading>}>
<LoginForm />
</Suspense>
}
/>
<Route
path='/register'
element={
<Suspense fallback={<Loading></Loading>}>
<RegisterForm />
</Suspense>
}
/>
<Route
path='/reset'
element={
<Suspense fallback={<Loading></Loading>}>
<PasswordResetForm />
</Suspense>
}
/>
<Route
path='/oauth/github'
element={
<Suspense fallback={<Loading></Loading>}>
<GitHubOAuth />
</Suspense>
}
/>
<Route
path='/oauth/lark'
element={
<Suspense fallback={<Loading></Loading>}>
<LarkOAuth />
</Suspense>
}
/>
<Route
path='/setting'
element={
<PrivateRoute>
<Suspense fallback={<Loading></Loading>}>
<Setting />
</Suspense>
</PrivateRoute>
}
/>
<Route
path='/topup'
element={
<PrivateRoute>
<Suspense fallback={<Loading></Loading>}>
<TopUp />
</Suspense>
</PrivateRoute>
}
/>
<Route
path='/log'
element={
<PrivateRoute>
<Log />
</PrivateRoute>
}
/>
<Route
path='/about'
element={
<Suspense fallback={<Loading></Loading>}>
<About />
</Suspense>
}
/>
<Route
path='/chat'
element={
<Suspense fallback={<Loading></Loading>}>
<Chat />
</Suspense>
}
/>
<Route
path='/dashboard'
element={
<PrivateRoute>
<Dashboard />
</PrivateRoute>
}
/>
<Route path='*' element={<NotFound />} />
</Routes>
);
}
export default App;

View File

@@ -0,0 +1,735 @@
import React, {useEffect, useState} from 'react';
import {useTranslation} from 'react-i18next';
import {Button, Dropdown, Form, Input, Label, Message, Pagination, Popup, Table,} from 'semantic-ui-react';
import {Link} from 'react-router-dom';
import {
API,
loadChannelModels,
setPromptShown,
shouldShowPrompt,
showError,
showInfo,
showSuccess,
timestamp2string,
} from '../helpers';
import {CHANNEL_OPTIONS, ITEMS_PER_PAGE} from '../constants';
import {renderGroup, renderNumber} from '../helpers/render';
function renderTimestamp(timestamp) {
return <>{timestamp2string(timestamp)}</>;
}
let type2label = undefined;
function renderType(type, t) {
if (!type2label) {
type2label = new Map();
for (let i = 0; i < CHANNEL_OPTIONS.length; i++) {
type2label[CHANNEL_OPTIONS[i].value] = CHANNEL_OPTIONS[i];
}
type2label[0] = {
value: 0,
text: t('channel.table.status_unknown'),
color: 'grey',
};
}
return (
<Label basic color={type2label[type]?.color}>
{type2label[type] ? type2label[type].text : type}
</Label>
);
}
function renderBalance(type, balance, t) {
switch (type) {
case 1: // OpenAI
if (balance === 0) {
return <span>{t('channel.table.balance_not_supported')}</span>;
}
return <span>${balance.toFixed(2)}</span>;
case 4: // CloseAI
return <span>¥{balance.toFixed(2)}</span>;
case 8: // 自定义
return <span>${balance.toFixed(2)}</span>;
case 5: // OpenAI-SB
return <span>¥{(balance / 10000).toFixed(2)}</span>;
case 10: // AI Proxy
return <span>{renderNumber(balance)}</span>;
case 12: // API2GPT
return <span>¥{balance.toFixed(2)}</span>;
case 13: // AIGC2D
return <span>{renderNumber(balance)}</span>;
case 20: // OpenRouter
return <span>${balance.toFixed(2)}</span>;
case 36: // DeepSeek
return <span>¥{balance.toFixed(2)}</span>;
case 44: // SiliconFlow
return <span>¥{balance.toFixed(2)}</span>;
default:
return <span>{t('channel.table.balance_not_supported')}</span>;
}
}
function isShowDetail() {
return localStorage.getItem('show_detail') === 'true';
}
const promptID = 'detail';
const ChannelsTable = () => {
const { t } = useTranslation();
const [channels, setChannels] = useState([]);
const [loading, setLoading] = useState(true);
const [activePage, setActivePage] = useState(1);
const [searchKeyword, setSearchKeyword] = useState('');
const [searching, setSearching] = useState(false);
const [updatingBalance, setUpdatingBalance] = useState(false);
const [showPrompt, setShowPrompt] = useState(shouldShowPrompt(promptID));
const [showDetail, setShowDetail] = useState(isShowDetail());
const processChannelData = (channel) => {
if (channel.models === '') {
channel.models = [];
channel.test_model = '';
} else {
channel.models = channel.models.split(',');
if (channel.models.length > 0) {
channel.test_model = channel.models[0];
}
channel.model_options = channel.models.map((model) => {
return {
key: model,
text: model,
value: model,
};
});
console.log('channel', channel);
}
return channel;
};
const loadChannels = async (startIdx) => {
const res = await API.get(`/api/channel/?p=${startIdx}`);
const { success, message, data } = res.data;
if (success) {
let localChannels = data.map(processChannelData);
if (startIdx === 0) {
setChannels(localChannels);
} else {
let newChannels = [...channels];
newChannels.splice(
startIdx * ITEMS_PER_PAGE,
data.length,
...localChannels
);
setChannels(newChannels);
}
} else {
showError(message);
}
setLoading(false);
};
const onPaginationChange = (e, { activePage }) => {
(async () => {
if (activePage === Math.ceil(channels.length / ITEMS_PER_PAGE) + 1) {
// In this case we have to load more data and then append them.
await loadChannels(activePage - 1);
}
setActivePage(activePage);
})();
};
const refresh = async () => {
setLoading(true);
await loadChannels(activePage - 1);
};
const toggleShowDetail = () => {
setShowDetail(!showDetail);
localStorage.setItem('show_detail', (!showDetail).toString());
};
useEffect(() => {
loadChannels(0)
.then()
.catch((reason) => {
showError(reason);
});
loadChannelModels().then();
}, []);
const manageChannel = async (id, action, idx, value) => {
let data = { id };
let res;
switch (action) {
case 'delete':
res = await API.delete(`/api/channel/${id}/`);
break;
case 'enable':
data.status = 1;
res = await API.put('/api/channel/', data);
break;
case 'disable':
data.status = 2;
res = await API.put('/api/channel/', data);
break;
case 'priority':
if (value === '') {
return;
}
data.priority = parseInt(value);
res = await API.put('/api/channel/', data);
break;
case 'weight':
if (value === '') {
return;
}
data.weight = parseInt(value);
if (data.weight < 0) {
data.weight = 0;
}
res = await API.put('/api/channel/', data);
break;
}
const { success, message } = res.data;
if (success) {
showSuccess(t('channel.messages.operation_success'));
let channel = res.data.data;
let newChannels = [...channels];
let realIdx = (activePage - 1) * ITEMS_PER_PAGE + idx;
if (action === 'delete') {
newChannels[realIdx].deleted = true;
} else {
newChannels[realIdx].status = channel.status;
}
setChannels(newChannels);
} else {
showError(message);
}
};
const renderStatus = (status, t) => {
switch (status) {
case 1:
return (
<Label basic color='green'>
{t('channel.table.status_enabled')}
</Label>
);
case 2:
return (
<Popup
trigger={
<Label basic color='red'>
{t('channel.table.status_disabled')}
</Label>
}
content={t('channel.table.status_disabled_tip')}
basic
/>
);
case 3:
return (
<Popup
trigger={
<Label basic color='yellow'>
{t('channel.table.status_auto_disabled')}
</Label>
}
content={t('channel.table.status_auto_disabled_tip')}
basic
/>
);
default:
return (
<Label basic color='grey'>
{t('channel.table.status_unknown')}
</Label>
);
}
};
const renderResponseTime = (responseTime, t) => {
let time = responseTime / 1000;
time = time.toFixed(2) + 's';
if (responseTime === 0) {
return (
<Label basic color='grey'>
{t('channel.table.not_tested')}
</Label>
);
} else if (responseTime <= 1000) {
return (
<Label basic color='green'>
{time}
</Label>
);
} else if (responseTime <= 3000) {
return (
<Label basic color='olive'>
{time}
</Label>
);
} else if (responseTime <= 5000) {
return (
<Label basic color='yellow'>
{time}
</Label>
);
} else {
return (
<Label basic color='red'>
{time}
</Label>
);
}
};
const searchChannels = async () => {
if (searchKeyword === '') {
// if keyword is blank, load files instead.
await loadChannels(0);
setActivePage(1);
return;
}
setSearching(true);
const res = await API.get(`/api/channel/search?keyword=${searchKeyword}`);
const { success, message, data } = res.data;
if (success) {
let localChannels = data.map(processChannelData);
setChannels(localChannels);
setActivePage(1);
} else {
showError(message);
}
setSearching(false);
};
const switchTestModel = async (idx, model) => {
let newChannels = [...channels];
let realIdx = (activePage - 1) * ITEMS_PER_PAGE + idx;
newChannels[realIdx].test_model = model;
setChannels(newChannels);
};
const testChannel = async (id, name, idx, m) => {
const res = await API.get(`/api/channel/test/${id}?model=${m}`);
const { success, message, time, model } = res.data;
if (success) {
let newChannels = [...channels];
let realIdx = (activePage - 1) * ITEMS_PER_PAGE + idx;
newChannels[realIdx].response_time = time * 1000;
newChannels[realIdx].test_time = Date.now() / 1000;
setChannels(newChannels);
showSuccess(
t('channel.messages.test_success', { name, model, time, message })
);
} else {
showError(message);
}
let newChannels = [...channels];
let realIdx = (activePage - 1) * ITEMS_PER_PAGE + idx;
newChannels[realIdx].response_time = time * 1000;
newChannels[realIdx].test_time = Date.now() / 1000;
setChannels(newChannels);
};
const testChannels = async (scope) => {
const res = await API.get(`/api/channel/test?scope=${scope}`);
const { success, message } = res.data;
if (success) {
showInfo(t('channel.messages.test_all_started'));
} else {
showError(message);
}
};
const deleteAllDisabledChannels = async () => {
const res = await API.delete(`/api/channel/disabled`);
const { success, message, data } = res.data;
if (success) {
showSuccess(
t('channel.messages.delete_disabled_success', { count: data })
);
await refresh();
} else {
showError(message);
}
};
const updateChannelBalance = async (id, name, idx) => {
const res = await API.get(`/api/channel/update_balance/${id}/`);
const { success, message, balance } = res.data;
if (success) {
let newChannels = [...channels];
let realIdx = (activePage - 1) * ITEMS_PER_PAGE + idx;
newChannels[realIdx].balance = balance;
newChannels[realIdx].balance_updated_time = Date.now() / 1000;
setChannels(newChannels);
showSuccess(t('channel.messages.balance_update_success', { name }));
} else {
showError(message);
}
};
const updateAllChannelsBalance = async () => {
setUpdatingBalance(true);
const res = await API.get(`/api/channel/update_balance`);
const { success, message } = res.data;
if (success) {
showInfo(t('channel.messages.all_balance_updated'));
} else {
showError(message);
}
setUpdatingBalance(false);
};
const handleKeywordChange = async (e, { value }) => {
setSearchKeyword(value.trim());
};
const sortChannel = (key) => {
if (channels.length === 0) return;
setLoading(true);
let sortedChannels = [...channels];
sortedChannels.sort((a, b) => {
if (!isNaN(a[key])) {
// If the value is numeric, subtract to sort
return a[key] - b[key];
} else {
// If the value is not numeric, sort as strings
return ('' + a[key]).localeCompare(b[key]);
}
});
if (sortedChannels[0].id === channels[0].id) {
sortedChannels.reverse();
}
setChannels(sortedChannels);
setLoading(false);
};
return (
<>
<Form onSubmit={searchChannels}>
<Form.Input
icon='search'
fluid
iconPosition='left'
placeholder={t('channel.search')}
value={searchKeyword}
loading={searching}
onChange={handleKeywordChange}
/>
</Form>
{showPrompt && (
<Message
onDismiss={() => {
setShowPrompt(false);
setPromptShown(promptID);
}}
>
{t('channel.balance_notice')}
<br />
{t('channel.test_notice')}
<br />
{t('channel.detail_notice')}
</Message>
)}
<Table basic={'very'} compact size='small'>
<Table.Header>
<Table.Row>
<Table.HeaderCell
style={{ cursor: 'pointer' }}
onClick={() => {
sortChannel('id');
}}
>
{t('channel.table.id')}
</Table.HeaderCell>
<Table.HeaderCell
style={{ cursor: 'pointer' }}
onClick={() => {
sortChannel('name');
}}
>
{t('channel.table.name')}
</Table.HeaderCell>
<Table.HeaderCell
style={{ cursor: 'pointer' }}
onClick={() => {
sortChannel('group');
}}
>
{t('channel.table.group')}
</Table.HeaderCell>
<Table.HeaderCell
style={{ cursor: 'pointer' }}
onClick={() => {
sortChannel('type');
}}
>
{t('channel.table.type')}
</Table.HeaderCell>
<Table.HeaderCell
style={{ cursor: 'pointer' }}
onClick={() => {
sortChannel('status');
}}
>
{t('channel.table.status')}
</Table.HeaderCell>
<Table.HeaderCell
style={{ cursor: 'pointer' }}
onClick={() => {
sortChannel('response_time');
}}
>
{t('channel.table.response_time')}
</Table.HeaderCell>
<Table.HeaderCell
style={{ cursor: 'pointer' }}
onClick={() => {
sortChannel('balance');
}}
>
{t('channel.table.balance')}
</Table.HeaderCell>
<Table.HeaderCell
style={{ cursor: 'pointer' }}
onClick={() => {
sortChannel('priority');
}}
hidden={!showDetail}
>
{t('channel.table.priority')}
</Table.HeaderCell>
<Table.HeaderCell hidden={!showDetail}>
{t('channel.table.test_model')}
</Table.HeaderCell>
<Table.HeaderCell>{t('channel.table.actions')}</Table.HeaderCell>
</Table.Row>
</Table.Header>
<Table.Body>
{channels
.slice(
(activePage - 1) * ITEMS_PER_PAGE,
activePage * ITEMS_PER_PAGE
)
.map((channel, idx) => {
if (channel.deleted) return <></>;
return (
<Table.Row key={channel.id}>
<Table.Cell>{channel.id}</Table.Cell>
<Table.Cell>
{channel.name ? channel.name : t('channel.table.no_name')}
</Table.Cell>
<Table.Cell>{renderGroup(channel.group)}</Table.Cell>
<Table.Cell>{renderType(channel.type, t)}</Table.Cell>
<Table.Cell>{renderStatus(channel.status, t)}</Table.Cell>
<Table.Cell>
<Popup
content={
channel.test_time
? renderTimestamp(channel.test_time)
: t('channel.table.not_tested')
}
key={channel.id}
trigger={renderResponseTime(channel.response_time, t)}
basic
/>
</Table.Cell>
<Table.Cell>
<Popup
trigger={
<span
onClick={() => {
updateChannelBalance(channel.id, channel.name, idx);
}}
style={{ cursor: 'pointer' }}
>
{renderBalance(channel.type, channel.balance, t)}
</span>
}
content={t('channel.table.click_to_update')}
basic
/>
</Table.Cell>
<Table.Cell hidden={!showDetail}>
<Popup
trigger={
<Input
type='number'
defaultValue={channel.priority}
onBlur={(event) => {
manageChannel(
channel.id,
'priority',
idx,
event.target.value
);
}}
>
<input style={{ maxWidth: '60px' }} />
</Input>
}
content={t('channel.table.priority_tip')}
basic
/>
</Table.Cell>
<Table.Cell hidden={!showDetail}>
<Dropdown
placeholder={t('channel.table.select_test_model')}
selection
options={channel.model_options}
defaultValue={channel.test_model}
onChange={(event, data) => {
switchTestModel(idx, data.value);
}}
/>
</Table.Cell>
<Table.Cell>
<div
style={{
display: 'flex',
alignItems: 'center',
flexWrap: 'wrap',
gap: '2px',
rowGap: '6px',
}}
>
<Button
size={'tiny'}
positive
onClick={() => {
testChannel(
channel.id,
channel.name,
idx,
channel.test_model
);
}}
>
{t('channel.buttons.test')}
</Button>
<Popup
trigger={
<Button size='tiny' negative>
{t('channel.buttons.delete')}
</Button>
}
on='click'
flowing
hoverable
>
<Button
size={'tiny'}
negative
onClick={() => {
manageChannel(channel.id, 'delete', idx);
}}
>
{t('channel.buttons.confirm_delete')} {channel.name}
</Button>
</Popup>
<Button
size={'tiny'}
onClick={() => {
manageChannel(
channel.id,
channel.status === 1 ? 'disable' : 'enable',
idx
);
}}
>
{channel.status === 1
? t('channel.buttons.disable')
: t('channel.buttons.enable')}
</Button>
<Button
size={'tiny'}
as={Link}
to={'/channel/edit/' + channel.id}
>
{t('channel.buttons.edit')}
</Button>
</div>
</Table.Cell>
</Table.Row>
);
})}
</Table.Body>
<Table.Footer>
<Table.Row>
<Table.HeaderCell colSpan={showDetail ? '10' : '8'}>
<Button size='tiny' as={Link} to='/channel/add' loading={loading}>
{t('channel.buttons.add')}
</Button>
<Button
size='tiny'
loading={loading}
onClick={() => {
testChannels('all');
}}
>
{t('channel.buttons.test_all')}
</Button>
<Button
size='tiny'
loading={loading}
onClick={() => {
testChannels('disabled');
}}
>
{t('channel.buttons.test_disabled')}
</Button>
<Popup
trigger={
<Button size='tiny' loading={loading}>
{t('channel.buttons.delete_disabled')}
</Button>
}
on='click'
flowing
hoverable
>
<Button
size='tiny'
loading={loading}
negative
onClick={deleteAllDisabledChannels}
>
{t('channel.buttons.confirm_delete_disabled')}
</Button>
</Popup>
<Pagination
floated='right'
activePage={activePage}
onPageChange={onPaginationChange}
size='tiny'
siblingRange={1}
totalPages={
Math.ceil(channels.length / ITEMS_PER_PAGE) +
(channels.length % ITEMS_PER_PAGE === 0 ? 1 : 0)
}
/>
<Button size='tiny' onClick={refresh} loading={loading}>
{t('channel.buttons.refresh')}
</Button>
<Button size='tiny' onClick={toggleShowDetail}>
{showDetail
? t('channel.buttons.hide_detail')
: t('channel.buttons.show_detail')}
</Button>
</Table.HeaderCell>
</Table.Row>
</Table.Footer>
</Table>
</>
);
};
export default ChannelsTable;

View File

@@ -0,0 +1,59 @@
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;
const loadFooter = () => {
let footer_html = localStorage.getItem('footer_html');
if (footer_html) {
setFooter(footer_html);
}
};
useEffect(() => {
const timer = setInterval(() => {
if (remainCheckTimes <= 0) {
clearInterval(timer);
return;
}
remainCheckTimes--;
loadFooter();
}, 200);
return () => clearTimeout(timer);
}, []);
return (
<Segment vertical>
<Container textAlign='center' style={{ color: '#666666' }}>
{footer ? (
<div
className='custom-footer'
dangerouslySetInnerHTML={{ __html: footer }}
></div>
) : (
<div className='custom-footer'>
<a href='https://github.com/songquanpeng/one-api' target='_blank'>
{systemName} {process.env.REACT_APP_VERSION}{' '}
</a>
{t('footer.built_by')}{' '}
<a href='https://github.com/songquanpeng' 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>
</Segment>
);
};
export default Footer;

View File

@@ -0,0 +1,58 @@
import React, { useContext, useEffect, useState } from 'react';
import { Dimmer, Loader, Segment } from 'semantic-ui-react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { API, showError, showSuccess } from '../helpers';
import { UserContext } from '../context/User';
const GitHubOAuth = () => {
const [searchParams, setSearchParams] = useSearchParams();
const [userState, userDispatch] = useContext(UserContext);
const [prompt, setPrompt] = useState('处理中...');
const [processing, setProcessing] = useState(true);
let navigate = useNavigate();
const sendCode = async (code, state, count) => {
const res = await API.get(`/api/oauth/github?code=${code}&state=${state}`);
const { success, message, data } = res.data;
if (success) {
if (message === 'bind') {
showSuccess('绑定成功!');
navigate('/setting');
} else {
userDispatch({ type: 'login', payload: data });
localStorage.setItem('user', JSON.stringify(data));
showSuccess('登录成功!');
navigate('/');
}
} else {
showError(message);
if (count === 0) {
setPrompt(`操作失败,重定向至登录界面中...`);
navigate('/setting'); // in case this is failed to bind GitHub
return;
}
count++;
setPrompt(`出现错误,第 ${count} 次重试中...`);
await new Promise((resolve) => setTimeout(resolve, count * 2000));
await sendCode(code, state, count);
}
};
useEffect(() => {
let code = searchParams.get('code');
let state = searchParams.get('state');
sendCode(code, state, 0).then();
}, []);
return (
<Segment style={{ minHeight: '300px' }}>
<Dimmer active inverted>
<Loader size='large'>{prompt}</Loader>
</Dimmer>
</Segment>
);
};
export default GitHubOAuth;

View File

@@ -0,0 +1,331 @@
import React, { useContext, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { UserContext } from '../context/User';
import { useTranslation } from 'react-i18next';
import {
Button,
Container,
Dropdown,
Icon,
Menu,
Segment,
} from 'semantic-ui-react';
import {
API,
getLogo,
getSystemName,
isAdmin,
isMobile,
showSuccess,
} from '../helpers';
import '../index.css';
// Header Buttons
let headerButtons = [
{
name: 'header.channel',
to: '/channel',
icon: 'sitemap',
admin: true,
},
{
name: 'header.token',
to: '/token',
icon: 'key',
},
{
name: 'header.redemption',
to: '/redemption',
icon: 'dollar sign',
admin: true,
},
{
name: 'header.topup',
to: '/topup',
icon: 'cart',
},
{
name: 'header.user',
to: '/user',
icon: 'user',
admin: true,
},
{
name: 'header.dashboard',
to: '/dashboard',
icon: 'chart bar',
},
{
name: 'header.log',
to: '/log',
icon: 'book',
},
{
name: 'header.setting',
to: '/setting',
icon: 'setting',
},
{
name: 'header.about',
to: '/about',
icon: 'info circle',
},
];
if (localStorage.getItem('chat_link')) {
headerButtons.splice(1, 0, {
name: 'header.chat',
to: '/chat',
icon: 'comments',
});
}
const Header = () => {
const { t, i18n } = useTranslation();
const [userState, userDispatch] = useContext(UserContext);
let navigate = useNavigate();
const [showSidebar, setShowSidebar] = useState(false);
const systemName = getSystemName();
const logo = getLogo();
async function logout() {
setShowSidebar(false);
await API.get('/api/user/logout');
showSuccess('注销成功!');
userDispatch({ type: 'logout' });
localStorage.removeItem('user');
navigate('/login');
}
const toggleSidebar = () => {
setShowSidebar(!showSidebar);
};
const renderButtons = (isMobile) => {
return headerButtons.map((button) => {
if (button.admin && !isAdmin()) return <></>;
if (isMobile) {
return (
<Menu.Item
key={button.name}
onClick={() => {
navigate(button.to);
setShowSidebar(false);
}}
style={{ fontSize: '15px' }}
>
{t(button.name)}
</Menu.Item>
);
}
return (
<Menu.Item
key={button.name}
as={Link}
to={button.to}
style={{
fontSize: '15px',
fontWeight: '400',
color: '#666',
}}
>
<Icon name={button.icon} style={{ marginRight: '4px' }} />
{t(button.name)}
</Menu.Item>
);
});
};
// Add language switcher dropdown
const languageOptions = [
{ key: 'zh', text: '中文', value: 'zh' },
{ key: 'en', text: 'English', value: 'en' },
];
const changeLanguage = (language) => {
i18n.changeLanguage(language);
};
if (isMobile()) {
return (
<>
<Menu
borderless
size='large'
style={
showSidebar
? {
borderBottom: 'none',
marginBottom: '0',
borderTop: 'none',
height: '51px',
}
: { borderTop: 'none', height: '52px' }
}
>
<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' }}>
<b>{systemName}</b>
</div>
</Menu.Item>
<Menu.Menu position='right'>
<Menu.Item onClick={toggleSidebar}>
<Icon name={showSidebar ? 'close' : 'sidebar'} />
</Menu.Item>
</Menu.Menu>
</Container>
</Menu>
{showSidebar ? (
<Segment style={{ marginTop: 0, borderTop: '0' }}>
<Menu secondary vertical style={{ width: '100%', margin: 0 }}>
{renderButtons(true)}
<Menu.Item>
<Dropdown
selection
trigger={
<Icon
name='language'
style={{ margin: 0, fontSize: '18px' }}
/>
}
options={languageOptions}
value={i18n.language}
onChange={(_, { value }) => changeLanguage(value)}
/>
</Menu.Item>
<Menu.Item>
{userState.user ? (
<Button onClick={logout} style={{ color: '#666666' }}>
{t('header.logout')}
</Button>
) : (
<>
<Button
onClick={() => {
setShowSidebar(false);
navigate('/login');
}}
>
{t('header.login')}
</Button>
<Button
onClick={() => {
setShowSidebar(false);
navigate('/register');
}}
>
{t('header.register')}
</Button>
</>
)}
</Menu.Item>
</Menu>
</Segment>
) : (
<></>
)}
</>
);
}
return (
<>
<Menu
borderless
style={{
borderTop: 'none',
boxShadow: 'rgba(0, 0, 0, 0.04) 0px 2px 12px 0px',
border: 'none',
}}
>
<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
style={{
fontSize: '18px',
fontWeight: '500',
color: '#333',
}}
>
{systemName}
</div>
</Menu.Item>
{renderButtons(false)}
<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: '16px',
fontWeight: '400',
color: '#666',
padding: '0 10px',
}}
/>
{userState.user ? (
<Dropdown
text={userState.user.username}
pointing
className='link item'
style={{
fontSize: '15px',
fontWeight: '400',
color: '#666',
}}
>
<Dropdown.Menu>
<Dropdown.Item
onClick={logout}
style={{
fontSize: '15px',
fontWeight: '400',
color: '#666',
}}
>
{t('header.logout')}
</Dropdown.Item>
</Dropdown.Menu>
</Dropdown>
) : (
<Menu.Item
name={t('header.login')}
as={Link}
to='/login'
className='btn btn-link'
style={{
fontSize: '15px',
fontWeight: '400',
color: '#666',
}}
/>
)}
</Menu.Menu>
</Container>
</Menu>
</>
);
};
export default Header;

View File

@@ -0,0 +1,58 @@
import React, { useContext, useEffect, useState } from 'react';
import { Dimmer, Loader, Segment } from 'semantic-ui-react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { API, showError, showSuccess } from '../helpers';
import { UserContext } from '../context/User';
const LarkOAuth = () => {
const [searchParams, setSearchParams] = useSearchParams();
const [userState, userDispatch] = useContext(UserContext);
const [prompt, setPrompt] = useState('处理中...');
const [processing, setProcessing] = useState(true);
let navigate = useNavigate();
const sendCode = async (code, state, count) => {
const res = await API.get(`/api/oauth/lark?code=${code}&state=${state}`);
const { success, message, data } = res.data;
if (success) {
if (message === 'bind') {
showSuccess('绑定成功!');
navigate('/setting');
} else {
userDispatch({ type: 'login', payload: data });
localStorage.setItem('user', JSON.stringify(data));
showSuccess('登录成功!');
navigate('/');
}
} else {
showError(message);
if (count === 0) {
setPrompt(`操作失败,重定向至登录界面中...`);
navigate('/setting'); // in case this is failed to bind lark
return;
}
count++;
setPrompt(`出现错误,第 ${count} 次重试中...`);
await new Promise((resolve) => setTimeout(resolve, count * 2000));
await sendCode(code, state, count);
}
};
useEffect(() => {
let code = searchParams.get('code');
let state = searchParams.get('state');
sendCode(code, state, 0).then();
}, []);
return (
<Segment style={{ minHeight: '300px' }}>
<Dimmer active inverted>
<Loader size='large'>{prompt}</Loader>
</Dimmer>
</Segment>
);
};
export default LarkOAuth;

View File

@@ -0,0 +1,14 @@
import React from 'react';
import { Segment, Dimmer, Loader } from 'semantic-ui-react';
const Loading = ({ prompt: name = 'page' }) => {
return (
<Segment style={{ height: 100 }}>
<Dimmer active inverted>
<Loader indeterminate>加载{name}...</Loader>
</Dimmer>
</Segment>
);
};
export default Loading;

View File

@@ -0,0 +1,292 @@
import React, { useContext, useEffect, useState } from 'react';
import {
Button,
Divider,
Form,
Grid,
Header,
Image,
Message,
Modal,
Segment,
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: '',
wechat_verification_code: '',
});
const [searchParams, setSearchParams] = useSearchParams();
const [submitted, setSubmitted] = useState(false);
const { username, password } = inputs;
const [userState, userDispatch] = useContext(UserContext);
let navigate = useNavigate();
const [status, setStatus] = useState({});
const logo = getLogo();
useEffect(() => {
if (searchParams.get('expired')) {
showError(t('messages.error.login_expired'));
}
let status = localStorage.getItem('status');
if (status) {
status = JSON.parse(status);
setStatus(status);
}
}, []);
const [showWeChatLoginModal, setShowWeChatLoginModal] = useState(false);
const onWeChatLoginClicked = () => {
setShowWeChatLoginModal(true);
};
const onSubmitWeChatVerificationCode = async () => {
const res = await API.get(
`/api/oauth/wechat?code=${inputs.wechat_verification_code}`
);
const { success, message, data } = res.data;
if (success) {
userDispatch({ type: 'login', payload: data });
localStorage.setItem('user', JSON.stringify(data));
navigate('/');
showSuccess(t('messages.success.login'));
setShowWeChatLoginModal(false);
} else {
showError(message);
}
};
function handleChange(e) {
const { name, value } = e.target;
setInputs((inputs) => ({ ...inputs, [name]: value }));
}
async function handleSubmit(e) {
setSubmitted(true);
if (username && password) {
const res = await API.post(`/api/user/login`, {
username,
password,
});
const { success, message, data } = res.data;
if (success) {
userDispatch({ type: 'login', payload: data });
localStorage.setItem('user', JSON.stringify(data));
if (username === 'root' && password === '123456') {
navigate('/user/edit');
showSuccess(t('messages.success.login'));
showWarning(t('messages.error.root_password'));
} else {
navigate('/token');
showSuccess(t('messages.success.login'));
}
} else {
showError(message);
}
}
}
return (
<Grid textAlign='center' style={{ marginTop: '48px' }}>
<Grid.Column style={{ maxWidth: 450 }}>
<Card
fluid
className='chart-card'
style={{ boxShadow: '0 1px 3px rgba(0,0,0,0.12)' }}
>
<Card.Content>
<Card.Header>
<Header
as='h2'
textAlign='center'
style={{ marginBottom: '1.5em' }}
>
<Image src={logo} style={{ marginBottom: '10px' }} />
<Header.Content>{t('auth.login.title')}</Header.Content>
</Header>
</Card.Header>
<Form size='large'>
<Form.Input
fluid
icon='user'
iconPosition='left'
placeholder={t('auth.login.username')}
name='username'
value={username}
onChange={handleChange}
style={{ marginBottom: '1em' }}
/>
<Form.Input
fluid
icon='lock'
iconPosition='left'
placeholder={t('auth.login.password')}
name='password'
type='password'
value={password}
onChange={handleChange}
style={{ marginBottom: '1.5em' }}
/>
<Button
fluid
size='large'
style={{
background: '#2F73FF', // 使用更现代的蓝色
color: 'white',
marginBottom: '1.5em',
}}
onClick={handleSubmit}
>
{t('auth.login.button')}
</Button>
</Form>
<Divider />
<Message style={{ background: 'transparent', boxShadow: 'none' }}>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
fontSize: '0.9em',
color: '#666',
}}
>
<div>
{t('auth.login.forgot_password')}
<Link
to='/reset'
style={{ color: '#2185d0', marginLeft: '2px' }}
>
{t('auth.login.reset_password')}
</Link>
</div>
<div>
{t('auth.login.no_account')}
<Link
to='/register'
style={{ color: '#2185d0', marginLeft: '2px' }}
>
{t('auth.login.register')}
</Link>
</div>
</div>
</Message>
{(status.github_oauth ||
status.wechat_login ||
status.lark_client_id) && (
<>
<Divider
horizontal
style={{ color: '#666', fontSize: '0.9em' }}
>
{t('auth.login.other_methods')}
</Divider>
<div
style={{
display: 'flex',
justifyContent: 'center',
gap: '1em',
marginTop: '1em',
}}
>
{status.github_oauth && (
<Button
circular
color='black'
icon='github'
onClick={() =>
onGitHubOAuthClicked(status.github_client_id)
}
/>
)}
{status.wechat_login && (
<Button
circular
color='green'
icon='wechat'
onClick={onWeChatLoginClicked}
/>
)}
{status.lark_client_id && (
<div
style={{
background:
'radial-gradient(circle, #FFFFFF, #FFFFFF, #FFFFFF, #FFFFFF, #FFFFFF)',
width: '36px',
height: '36px',
borderRadius: '10em',
display: 'flex',
cursor: 'pointer',
}}
onClick={() => onLarkOAuthClicked(status.lark_client_id)}
>
<Image
src={larkIcon}
avatar
style={{
width: '36px',
height: '36px',
cursor: 'pointer',
margin: 'auto',
}}
/>
</div>
)}
</div>
</>
)}
</Card.Content>
</Card>
<Modal
onClose={() => setShowWeChatLoginModal(false)}
onOpen={() => setShowWeChatLoginModal(true)}
open={showWeChatLoginModal}
size={'mini'}
>
<Modal.Content>
<Modal.Description>
<Image src={status.wechat_qrcode} fluid />
<div style={{ textAlign: 'center' }}>
<p>{t('auth.login.wechat.scan_tip')}</p>
</div>
<Form size='large'>
<Form.Input
fluid
placeholder={t('auth.login.wechat.code_placeholder')}
name='wechat_verification_code'
value={inputs.wechat_verification_code}
onChange={handleChange}
/>
<Button
fluid
size='large'
style={{
background: '#2F73FF',
color: 'white',
marginBottom: '1.5em',
}}
onClick={onSubmitWeChatVerificationCode}
>
{t('auth.login.button')}
</Button>
</Form>
</Modal.Description>
</Modal.Content>
</Modal>
</Grid.Column>
</Grid>
);
};
export default LoginForm;

View File

@@ -0,0 +1,613 @@
import React, { useEffect, useState } from 'react';
import {
Button,
Form,
Header,
Label,
Pagination,
Segment,
Select,
Table,
Popup,
} from 'semantic-ui-react';
import {
API,
copy,
isAdmin,
showError,
showSuccess,
showWarning,
timestamp2string,
} from '../helpers';
import { useTranslation } from 'react-i18next';
import { ITEMS_PER_PAGE } from '../constants';
import { renderColorLabel, renderQuota } from '../helpers/render';
import { Link } from 'react-router-dom';
function renderTimestamp(timestamp, request_id) {
return (
<code
onClick={async () => {
if (await copy(request_id)) {
showSuccess(`已复制请求 ID${request_id}`);
} else {
showWarning(`请求 ID 复制失败:${request_id}`);
}
}}
style={{ cursor: 'pointer' }}
>
{timestamp2string(timestamp)}
</code>
);
}
const MODE_OPTIONS = [
{ key: 'all', text: '全部用户', value: 'all' },
{ key: 'self', text: '当前用户', value: 'self' },
];
function renderType(type) {
switch (type) {
case 1:
return (
<Label basic color='green'>
充值
</Label>
);
case 2:
return (
<Label basic color='olive'>
消费
</Label>
);
case 3:
return (
<Label basic color='orange'>
管理
</Label>
);
case 4:
return (
<Label basic color='purple'>
系统
</Label>
);
case 5:
return (
<Label basic color='violet'>
测试
</Label>
);
default:
return (
<Label basic color='black'>
未知
</Label>
);
}
}
function getColorByElapsedTime(elapsedTime) {
if (elapsedTime === undefined || 0) return 'black';
if (elapsedTime < 1000) return 'green';
if (elapsedTime < 3000) return 'olive';
if (elapsedTime < 5000) return 'yellow';
if (elapsedTime < 10000) return 'orange';
return 'red';
}
function renderDetail(log) {
return (
<>
{log.content}
<br />
{log.elapsed_time && (
<Label
basic
size={'mini'}
color={getColorByElapsedTime(log.elapsed_time)}
>
{log.elapsed_time} ms
</Label>
)}
{log.is_stream && (
<>
<Label size={'mini'} color='pink'>
Stream
</Label>
</>
)}
{log.system_prompt_reset && (
<>
<Label basic size={'mini'} color='red'>
System Prompt Reset
</Label>
</>
)}
</>
);
}
const LogsTable = () => {
const { t } = useTranslation();
const [logs, setLogs] = useState([]);
const [showStat, setShowStat] = useState(false);
const [loading, setLoading] = useState(true);
const [activePage, setActivePage] = useState(1);
const [searchKeyword, setSearchKeyword] = useState('');
const [searching, setSearching] = useState(false);
const [logType, setLogType] = useState(0);
const isAdminUser = isAdmin();
let now = new Date();
const [inputs, setInputs] = useState({
username: '',
token_name: '',
model_name: '',
start_timestamp: timestamp2string(0),
end_timestamp: timestamp2string(now.getTime() / 1000 + 3600),
channel: '',
});
const {
username,
token_name,
model_name,
start_timestamp,
end_timestamp,
channel,
} = inputs;
const [stat, setStat] = useState({
quota: 0,
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 }));
};
const getLogSelfStat = async () => {
let localStartTimestamp = Date.parse(start_timestamp) / 1000;
let localEndTimestamp = Date.parse(end_timestamp) / 1000;
let res = await API.get(
`/api/log/self/stat?type=${logType}&token_name=${token_name}&model_name=${model_name}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}`
);
const { success, message, data } = res.data;
if (success) {
setStat(data);
} else {
showError(message);
}
};
const getLogStat = async () => {
let localStartTimestamp = Date.parse(start_timestamp) / 1000;
let localEndTimestamp = Date.parse(end_timestamp) / 1000;
let res = await API.get(
`/api/log/stat?type=${logType}&username=${username}&token_name=${token_name}&model_name=${model_name}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}&channel=${channel}`
);
const { success, message, data } = res.data;
if (success) {
setStat(data);
} else {
showError(message);
}
};
const handleEyeClick = async () => {
if (!showStat) {
if (isAdminUser) {
await getLogStat();
} else {
await getLogSelfStat();
}
}
setShowStat(!showStat);
};
const showUserTokenQuota = () => {
return logType !== 5;
};
const loadLogs = async (startIdx) => {
let url = '';
let localStartTimestamp = Date.parse(start_timestamp) / 1000;
let localEndTimestamp = Date.parse(end_timestamp) / 1000;
if (isAdminUser) {
url = `/api/log/?p=${startIdx}&type=${logType}&username=${username}&token_name=${token_name}&model_name=${model_name}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}&channel=${channel}`;
} else {
url = `/api/log/self/?p=${startIdx}&type=${logType}&token_name=${token_name}&model_name=${model_name}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}`;
}
const res = await API.get(url);
const { success, message, data } = res.data;
if (success) {
if (startIdx === 0) {
setLogs(data);
} else {
let newLogs = [...logs];
newLogs.splice(startIdx * ITEMS_PER_PAGE, data.length, ...data);
setLogs(newLogs);
}
} else {
showError(message);
}
setLoading(false);
};
const onPaginationChange = (e, { activePage }) => {
(async () => {
if (activePage === Math.ceil(logs.length / ITEMS_PER_PAGE) + 1) {
// In this case we have to load more data and then append them.
await loadLogs(activePage - 1);
}
setActivePage(activePage);
})();
};
const refresh = async () => {
setLoading(true);
setActivePage(1);
await loadLogs(0);
};
useEffect(() => {
refresh().then();
}, [logType]);
const searchLogs = async () => {
if (searchKeyword === '') {
// if keyword is blank, load files instead.
await loadLogs(0);
setActivePage(1);
return;
}
setSearching(true);
const res = await API.get(`/api/log/self/search?keyword=${searchKeyword}`);
const { success, message, data } = res.data;
if (success) {
setLogs(data);
setActivePage(1);
} else {
showError(message);
}
setSearching(false);
};
const handleKeywordChange = async (e, { value }) => {
setSearchKeyword(value.trim());
};
const sortLog = (key) => {
if (logs.length === 0) return;
setLoading(true);
let sortedLogs = [...logs];
if (typeof sortedLogs[0][key] === 'string') {
sortedLogs.sort((a, b) => {
return ('' + a[key]).localeCompare(b[key]);
});
} else {
sortedLogs.sort((a, b) => {
if (a[key] === b[key]) return 0;
if (a[key] > b[key]) return -1;
if (a[key] < b[key]) return 1;
});
}
if (sortedLogs[0].id === logs[0].id) {
sortedLogs.reverse();
}
setLogs(sortedLogs);
setLoading(false);
};
return (
<>
<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}
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('channel');
}}
width={1}
>
{t('log.table.channel')}
</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('username');
}}
width={2}
>
{t('log.table.username')}
</Table.HeaderCell>
)}
<Table.HeaderCell
style={{ cursor: 'pointer' }}
onClick={() => {
sortLog('token_name');
}}
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>
</>
);
};
export default LogsTable;

View File

@@ -0,0 +1,446 @@
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';
const OperationSetting = () => {
const { t } = useTranslation();
let now = new Date();
let [inputs, setInputs] = useState({
QuotaForNewUser: 0,
QuotaForInviter: 0,
QuotaForInvitee: 0,
QuotaRemindThreshold: 0,
PreConsumedQuota: 0,
ModelRatio: '',
CompletionRatio: '',
GroupRatio: '',
TopUpLink: '',
ChatLink: '',
QuotaPerUnit: 0,
AutomaticDisableChannelEnabled: '',
AutomaticEnableChannelEnabled: '',
ChannelDisableThreshold: 0,
LogConsumeEnabled: '',
DisplayInCurrencyEnabled: '',
DisplayTokenStatEnabled: '',
ApproximateTokenEnabled: '',
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
const getOptions = async () => {
const res = await API.get('/api/option/');
const { success, message, data } = res.data;
if (success) {
let newInputs = {};
data.forEach((item) => {
if (
item.key === 'ModelRatio' ||
item.key === 'GroupRatio' ||
item.key === 'CompletionRatio'
) {
item.value = JSON.stringify(JSON.parse(item.value), null, 2);
}
if (item.value === '{}') {
item.value = '';
}
newInputs[item.key] = item.value;
});
setInputs(newInputs);
setOriginInputs(newInputs);
} else {
showError(message);
}
};
useEffect(() => {
getOptions().then();
}, []);
const updateOption = async (key, value) => {
setLoading(true);
if (key.endsWith('Enabled')) {
value = inputs[key] === 'true' ? 'false' : 'true';
}
const res = await API.put('/api/option/', {
key,
value,
});
const { success, message } = res.data;
if (success) {
setInputs((inputs) => ({ ...inputs, [key]: value }));
} else {
showError(message);
}
setLoading(false);
};
const handleInputChange = async (e, { name, value }) => {
if (name.endsWith('Enabled')) {
await updateOption(name, value);
} else {
setInputs((inputs) => ({ ...inputs, [name]: value }));
}
};
const submitConfig = async (group) => {
switch (group) {
case 'monitor':
if (
originInputs['ChannelDisableThreshold'] !==
inputs.ChannelDisableThreshold
) {
await updateOption(
'ChannelDisableThreshold',
inputs.ChannelDisableThreshold
);
}
if (
originInputs['QuotaRemindThreshold'] !== inputs.QuotaRemindThreshold
) {
await updateOption(
'QuotaRemindThreshold',
inputs.QuotaRemindThreshold
);
}
break;
case 'ratio':
if (originInputs['ModelRatio'] !== inputs.ModelRatio) {
if (!verifyJSON(inputs.ModelRatio)) {
showError('模型倍率不是合法的 JSON 字符串');
return;
}
await updateOption('ModelRatio', inputs.ModelRatio);
}
if (originInputs['GroupRatio'] !== inputs.GroupRatio) {
if (!verifyJSON(inputs.GroupRatio)) {
showError('分组倍率不是合法的 JSON 字符串');
return;
}
await updateOption('GroupRatio', inputs.GroupRatio);
}
if (originInputs['CompletionRatio'] !== inputs.CompletionRatio) {
if (!verifyJSON(inputs.CompletionRatio)) {
showError('补全倍率不是合法的 JSON 字符串');
return;
}
await updateOption('CompletionRatio', inputs.CompletionRatio);
}
break;
case 'quota':
if (originInputs['QuotaForNewUser'] !== inputs.QuotaForNewUser) {
await updateOption('QuotaForNewUser', inputs.QuotaForNewUser);
}
if (originInputs['QuotaForInvitee'] !== inputs.QuotaForInvitee) {
await updateOption('QuotaForInvitee', inputs.QuotaForInvitee);
}
if (originInputs['QuotaForInviter'] !== inputs.QuotaForInviter) {
await updateOption('QuotaForInviter', inputs.QuotaForInviter);
}
if (originInputs['PreConsumedQuota'] !== inputs.PreConsumedQuota) {
await updateOption('PreConsumedQuota', inputs.PreConsumedQuota);
}
break;
case 'general':
if (originInputs['TopUpLink'] !== inputs.TopUpLink) {
await updateOption('TopUpLink', inputs.TopUpLink);
}
if (originInputs['ChatLink'] !== inputs.ChatLink) {
await updateOption('ChatLink', inputs.ChatLink);
}
if (originInputs['QuotaPerUnit'] !== inputs.QuotaPerUnit) {
await updateOption('QuotaPerUnit', inputs.QuotaPerUnit);
}
if (originInputs['RetryTimes'] !== inputs.RetryTimes) {
await updateOption('RetryTimes', inputs.RetryTimes);
}
break;
}
};
const deleteHistoryLogs = async () => {
console.log(inputs);
const res = await API.delete(
`/api/log/?target_timestamp=${Date.parse(historyTimestamp) / 1000}`
);
const { success, message, data } = res.data;
if (success) {
showSuccess(`${data} 条日志已清理!`);
return;
}
showError('日志清理失败:' + message);
};
return (
<Grid columns={1}>
<Grid.Column>
<Form loading={loading}>
<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={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={t(
'setting.operation.general.topup_link_placeholder'
)}
/>
<Form.Input
label={t('setting.operation.general.chat_link')}
name='ChatLink'
onChange={handleInputChange}
autoComplete='new-password'
value={inputs.ChatLink}
type='link'
placeholder={t('setting.operation.general.chat_link_placeholder')}
/>
<Form.Input
label={t('setting.operation.general.quota_per_unit')}
name='QuotaPerUnit'
onChange={handleInputChange}
autoComplete='new-password'
value={inputs.QuotaPerUnit}
type='number'
step='0.01'
placeholder={t(
'setting.operation.general.quota_per_unit_placeholder'
)}
/>
<Form.Input
label={t('setting.operation.general.retry_times')}
name='RetryTimes'
type={'number'}
step='1'
min='0'
onChange={handleInputChange}
autoComplete='new-password'
value={inputs.RetryTimes}
placeholder={t(
'setting.operation.general.retry_times_placeholder'
)}
/>
</Form.Group>
<Form.Group inline>
<Form.Checkbox
checked={inputs.DisplayInCurrencyEnabled === 'true'}
label={t('setting.operation.general.display_in_currency')}
name='DisplayInCurrencyEnabled'
onChange={handleInputChange}
/>
<Form.Checkbox
checked={inputs.DisplayTokenStatEnabled === 'true'}
label={t('setting.operation.general.display_token_stat')}
name='DisplayTokenStatEnabled'
onChange={handleInputChange}
/>
<Form.Checkbox
checked={inputs.ApproximateTokenEnabled === 'true'}
label={t('setting.operation.general.approximate_token')}
name='ApproximateTokenEnabled'
onChange={handleInputChange}
/>
</Form.Group>
<Form.Button
onClick={() => {
submitConfig('general').then();
}}
>
{t('setting.operation.general.buttons.save')}
</Form.Button>
</Form>
</Grid.Column>
</Grid>
);
};
export default OperationSetting;

View File

@@ -0,0 +1,253 @@
import React, { useEffect, useState } from 'react';
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: '',
About: '',
SystemName: '',
Logo: '',
HomePageContent: '',
Theme: '',
});
let [loading, setLoading] = useState(false);
const [showUpdateModal, setShowUpdateModal] = useState(false);
const [updateData, setUpdateData] = useState({
tag_name: '',
content: '',
});
const getOptions = async () => {
const res = await API.get('/api/option/');
const { success, message, data } = res.data;
if (success) {
let newInputs = {};
data.forEach((item) => {
if (item.key in inputs) {
newInputs[item.key] = item.value;
}
});
setInputs(newInputs);
} else {
showError(message);
}
};
useEffect(() => {
getOptions().then();
}, []);
const updateOption = async (key, value) => {
setLoading(true);
const res = await API.put('/api/option/', {
key,
value,
});
const { success, message } = res.data;
if (success) {
setInputs((inputs) => ({ ...inputs, [key]: value }));
} else {
showError(message);
}
setLoading(false);
};
const handleInputChange = async (e, { name, value }) => {
setInputs((inputs) => ({ ...inputs, [name]: value }));
};
const submitNotice = async () => {
await updateOption('Notice', inputs.Notice);
};
const submitSystemName = async () => {
await updateOption('SystemName', inputs.SystemName);
};
const submitTheme = async () => {
await updateOption('Theme', inputs.Theme);
};
const submitLogo = async () => {
await updateOption('Logo', inputs.Logo);
};
const submitAbout = async () => {
await updateOption('About', inputs.About);
};
const submitOption = async (key) => {
await updateOption(key, inputs[key]);
};
const openGitHubRelease = () => {
window.location = 'https://github.com/songquanpeng/one-api/releases/latest';
};
const checkUpdate = async () => {
const res = await API.get(
'https://api.github.com/repos/songquanpeng/one-api/releases/latest'
);
const { tag_name, body } = res.data;
if (tag_name === process.env.REACT_APP_VERSION) {
showSuccess(`已是最新版本:${tag_name}`);
} else {
setUpdateData({
tag_name: tag_name,
content: marked.parse(body),
});
setShowUpdateModal(true);
}
};
return (
<Grid columns={1}>
<Grid.Column>
<Form loading={loading}>
<Header as='h3'>{t('setting.other.notice.title')}</Header>
<Form.Group widths='equal'>
<Form.TextArea
label={t('setting.other.notice.content')}
placeholder={t('setting.other.notice.content_placeholder')}
value={inputs.Notice}
name='Notice'
onChange={handleInputChange}
style={{ minHeight: 100, fontFamily: 'JetBrains Mono, Consolas' }}
/>
</Form.Group>
<Form.Button onClick={submitNotice}>
{t('setting.other.notice.buttons.save')}
</Form.Button>
<Divider />
<Header as='h3'>{t('setting.other.system.title')}</Header>
<Form.Group widths='equal'>
<Form.Input
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}>
{t('setting.other.system.buttons.save_name')}
</Form.Button>
<Form.Group widths='equal'>
<Form.Input
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}>
{t('setting.other.system.buttons.save_theme')}
</Form.Button>
<Form.Group widths='equal'>
<Form.Input
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}>
{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={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')}>
{t('setting.other.content.buttons.save_homepage')}
</Form.Button>
<Form.Group widths='equal'>
<Form.TextArea
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}>
{t('setting.other.content.buttons.save_about')}
</Form.Button>
<Message>{t('setting.other.copyright.notice')}</Message>
<Form.Group widths='equal'>
<Form.Input
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={() => submitOption('Footer')}>
{t('setting.other.content.buttons.save_footer')}
</Form.Button>
</Form>
</Grid.Column>
<Modal
onClose={() => setShowUpdateModal(false)}
onOpen={() => setShowUpdateModal(true)}
open={showUpdateModal}
>
<Modal.Header>新版本{updateData.tag_name}</Modal.Header>
<Modal.Content>
<Modal.Description>
<div dangerouslySetInnerHTML={{ __html: updateData.content }}></div>
</Modal.Description>
</Modal.Content>
<Modal.Actions>
<Button onClick={() => setShowUpdateModal(false)}>关闭</Button>
<Button
content='详情'
onClick={() => {
setShowUpdateModal(false);
openGitHubRelease();
}}
/>
</Modal.Actions>
</Modal>
</Grid>
);
};
export default OtherSetting;

View File

@@ -0,0 +1,154 @@
import React, { useEffect, useState } from 'react';
import {
Button,
Form,
Grid,
Header,
Image,
Card,
Message,
} from 'semantic-ui-react';
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 [newPassword, setNewPassword] = useState('');
const logo = getLogo();
const [countdown, setCountdown] = useState(30);
const [searchParams, setSearchParams] = useSearchParams();
useEffect(() => {
let token = searchParams.get('token');
let email = searchParams.get('email');
setInputs({
token,
email,
});
}, []);
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]);
async function handleSubmit(e) {
setDisableButton(true);
if (!email) return;
setLoading(true);
const res = await API.post(`/api/user/reset`, {
email,
token,
});
const { success, message } = res.data;
if (success) {
let password = res.data.data;
setNewPassword(password);
await copy(password);
showNotice(t('messages.notice.password_copied', { password }));
} else {
showError(message);
}
setLoading(false);
}
return (
<Grid textAlign='center' style={{ marginTop: '48px' }}>
<Grid.Column style={{ maxWidth: 450 }}>
<Card
fluid
className='chart-card'
style={{ boxShadow: '0 1px 3px rgba(0,0,0,0.12)' }}
>
<Card.Content>
<Card.Header>
<Header
as='h2'
textAlign='center'
style={{ marginBottom: '1.5em' }}
>
<Image src={logo} style={{ marginBottom: '10px' }} />
<Header.Content>{t('auth.reset.confirm.title')}</Header.Content>
</Header>
</Card.Header>
<Form size='large'>
<Form.Input
fluid
icon='mail'
iconPosition='left'
placeholder={t('auth.reset.email')}
name='email'
value={email}
readOnly
style={{ marginBottom: '1em' }}
/>
{newPassword && (
<Form.Input
fluid
icon='lock'
iconPosition='left'
placeholder={t('auth.reset.confirm.new_password')}
name='newPassword'
value={newPassword}
readOnly
style={{
marginBottom: '1em',
cursor: 'pointer',
backgroundColor: '#f8f9fa',
}}
onClick={(e) => {
e.target.select();
navigator.clipboard.writeText(newPassword);
showNotice(t('auth.reset.confirm.notice'));
}}
/>
)}
<Button
fluid
size='large'
onClick={handleSubmit}
loading={loading}
disabled={disableButton}
style={{
background: '#2F73FF',
color: 'white',
marginBottom: '1.5em',
}}
>
{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' }}>
{t('auth.reset.confirm.notice')}
</p>
</Message>
)}
</Card.Content>
</Card>
</Grid.Column>
</Grid>
);
};
export default PasswordResetConfirm;

View File

@@ -0,0 +1,157 @@
import React, { useEffect, useState } from 'react';
import {
Button,
Form,
Grid,
Header,
Image,
Card,
Message,
} from 'semantic-ui-react';
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');
if (status) {
status = JSON.parse(status);
if (status.turnstile_check) {
setTurnstileEnabled(true);
setTurnstileSiteKey(status.turnstile_site_key);
}
}
}, []);
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]);
function handleChange(e) {
const { name, value } = e.target;
setInputs((inputs) => ({ ...inputs, [name]: value }));
}
async function handleSubmit(e) {
setDisableButton(true);
if (!email) return;
if (turnstileEnabled && turnstileToken === '') {
showInfo('请稍后几秒重试Turnstile 正在检查用户环境!');
return;
}
setLoading(true);
const res = await API.get(
`/api/reset_password?email=${email}&turnstile=${turnstileToken}`
);
const { success, message } = res.data;
if (success) {
showSuccess(t('auth.reset.notice'));
setInputs({ ...inputs, email: '' });
} else {
showError(message);
setDisableButton(false);
setCountdown(30);
}
setLoading(false);
}
return (
<Grid textAlign='center' style={{ marginTop: '48px' }}>
<Grid.Column style={{ maxWidth: 450 }}>
<Card
fluid
className='chart-card'
style={{ boxShadow: '0 1px 3px rgba(0,0,0,0.12)' }}
>
<Card.Content>
<Card.Header>
<Header
as='h2'
textAlign='center'
style={{ marginBottom: '1.5em' }}
>
<Image src={logo} style={{ marginBottom: '10px' }} />
<Header.Content>{t('auth.reset.title')}</Header.Content>
</Header>
</Card.Header>
<Form size='large'>
<Form.Input
fluid
icon='mail'
iconPosition='left'
placeholder={t('auth.reset.email')}
name='email'
value={email}
onChange={handleChange}
style={{ marginBottom: '1em' }}
/>
{turnstileEnabled && (
<div
style={{
marginBottom: '1em',
display: 'flex',
justifyContent: 'center',
}}
>
<Turnstile
sitekey={turnstileSiteKey}
onVerify={(token) => {
setTurnstileToken(token);
}}
/>
</div>
)}
<Button
color='blue'
fluid
size='large'
onClick={handleSubmit}
loading={loading}
disabled={disableButton}
style={{
background: '#2F73FF', // 使用更现代的蓝色
color: 'white',
marginBottom: '1.5em',
}}
>
{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' }}>
{t('auth.reset.notice')}
</p>
</Message>
</Card.Content>
</Card>
</Grid.Column>
</Grid>
);
};
export default PasswordResetForm;

View File

@@ -0,0 +1,420 @@
import React, { useContext, useEffect, useState } from '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 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();
const [inputs, setInputs] = useState({
wechat_verification_code: '',
email_verification_code: '',
email: '',
self_account_deletion_confirmation: '',
});
const [status, setStatus] = useState({});
const [showWeChatBindModal, setShowWeChatBindModal] = useState(false);
const [showEmailBindModal, setShowEmailBindModal] = useState(false);
const [showAccountDeleteModal, setShowAccountDeleteModal] = useState(false);
const [turnstileEnabled, setTurnstileEnabled] = useState(false);
const [turnstileSiteKey, setTurnstileSiteKey] = useState('');
const [turnstileToken, setTurnstileToken] = useState('');
const [loading, setLoading] = useState(false);
const [disableButton, setDisableButton] = useState(false);
const [countdown, setCountdown] = useState(30);
const [affLink, setAffLink] = useState('');
const [systemToken, setSystemToken] = useState('');
useEffect(() => {
let status = localStorage.getItem('status');
if (status) {
status = JSON.parse(status);
setStatus(status);
if (status.turnstile_check) {
setTurnstileEnabled(true);
setTurnstileSiteKey(status.turnstile_site_key);
}
}
}, []);
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); // Clean up on unmount
}, [disableButton, countdown]);
const handleInputChange = (e, { name, value }) => {
setInputs((inputs) => ({ ...inputs, [name]: value }));
};
const generateAccessToken = async () => {
const res = await API.get('/api/user/token');
const { success, message, data } = res.data;
if (success) {
setSystemToken(data);
setAffLink('');
await copy(data);
showSuccess(`令牌已重置并已复制到剪贴板`);
} else {
showError(message);
}
};
const getAffLink = async () => {
const res = await API.get('/api/user/aff');
const { success, message, data } = res.data;
if (success) {
let link = `${window.location.origin}/register?aff=${data}`;
setAffLink(link);
setSystemToken('');
await copy(link);
showSuccess(`邀请链接已复制到剪切板`);
} else {
showError(message);
}
};
const handleAffLinkClick = async (e) => {
e.target.select();
await copy(e.target.value);
showSuccess(`邀请链接已复制到剪切板`);
};
const handleSystemTokenClick = async (e) => {
e.target.select();
await copy(e.target.value);
showSuccess(`系统令牌已复制到剪切板`);
};
const deleteAccount = async () => {
if (inputs.self_account_deletion_confirmation !== userState.user.username) {
showError('请输入你的账户名以确认删除!');
return;
}
const res = await API.delete('/api/user/self');
const { success, message } = res.data;
if (success) {
showSuccess('账户已删除!');
await API.get('/api/user/logout');
userDispatch({ type: 'logout' });
localStorage.removeItem('user');
navigate('/login');
} else {
showError(message);
}
};
const bindWeChat = async () => {
if (inputs.wechat_verification_code === '') return;
const res = await API.get(
`/api/oauth/wechat/bind?code=${inputs.wechat_verification_code}`
);
const { success, message } = res.data;
if (success) {
showSuccess('微信账户绑定成功!');
setShowWeChatBindModal(false);
} else {
showError(message);
}
};
const sendVerificationCode = async () => {
setDisableButton(true);
if (inputs.email === '') return;
if (turnstileEnabled && turnstileToken === '') {
showInfo('请稍后几秒重试Turnstile 正在检查用户环境!');
return;
}
setLoading(true);
const res = await API.get(
`/api/verification?email=${inputs.email}&turnstile=${turnstileToken}`
);
const { success, message } = res.data;
if (success) {
showSuccess('验证码发送成功,请检查邮箱!');
} else {
showError(message);
}
setLoading(false);
};
const bindEmail = async () => {
if (inputs.email_verification_code === '') return;
setLoading(true);
const res = await API.get(
`/api/oauth/email/bind?email=${inputs.email}&code=${inputs.email_verification_code}`
);
const { success, message } = res.data;
if (success) {
showSuccess('邮箱账户绑定成功!');
setShowEmailBindModal(false);
} else {
showError(message);
}
setLoading(false);
};
return (
<div style={{ lineHeight: '40px' }}>
<Header as='h3'>{t('setting.personal.general.title')}</Header>
<Message>{t('setting.personal.general.system_token_notice')}</Message>
<Button as={Link} to={`/user/edit/`}>
{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>
{systemToken && (
<Form.Input
fluid
readOnly
value={systemToken}
onClick={handleSystemTokenClick}
style={{ marginTop: '10px' }}
/>
)}
{affLink && (
<Form.Input
fluid
readOnly
value={affLink}
onClick={handleAffLinkClick}
style={{ marginTop: '10px' }}
/>
)}
<Divider />
<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)}
open={showWeChatBindModal}
size={'mini'}
>
<Modal.Content>
<Modal.Description>
<Image src={status.wechat_qrcode} fluid />
<div style={{ textAlign: 'center' }}>
<p>{t('setting.personal.binding.wechat.description')}</p>
</div>
<Form size='large'>
<Form.Input
fluid
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}>
{t('setting.personal.binding.wechat.bind')}
</Button>
</Form>
</Modal.Description>
</Modal.Content>
</Modal>
{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)}
onOpen={() => setShowEmailBindModal(true)}
open={showEmailBindModal}
size={'tiny'}
style={{ maxWidth: '450px' }}
>
<Modal.Header>{t('setting.personal.binding.email.title')}</Modal.Header>
<Modal.Content>
<Modal.Description>
<Form size='large'>
<Form.Input
fluid
placeholder={t(
'setting.personal.binding.email.email_placeholder'
)}
onChange={handleInputChange}
name='email'
type='email'
action={
<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={t(
'setting.personal.binding.email.code_placeholder'
)}
name='email_verification_code'
value={inputs.email_verification_code}
onChange={handleInputChange}
/>
{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}
>
{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>
</Modal.Content>
</Modal>
<Modal
onClose={() => setShowAccountDeleteModal(false)}
onOpen={() => setShowAccountDeleteModal(true)}
open={showAccountDeleteModal}
size={'tiny'}
style={{ maxWidth: '450px' }}
>
<Modal.Header>
{t('setting.personal.delete_account.title')}
</Modal.Header>
<Modal.Content>
<Message>{t('setting.personal.delete_account.warning')}</Message>
<Modal.Description>
<Form size='large'>
<Form.Input
fluid
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 && (
<Turnstile
sitekey={turnstileSiteKey}
onVerify={(token) => {
setTurnstileToken(token);
}}
/>
)}
<div
style={{
display: 'flex',
justifyContent: 'space-between',
marginTop: '1rem',
}}
>
<Button
color='red'
fluid
size='large'
onClick={deleteAccount}
loading={loading}
>
{t('setting.personal.delete_account.buttons.confirm')}
</Button>
<div style={{ width: '1rem' }}></div>
<Button
fluid
size='large'
onClick={() => setShowAccountDeleteModal(false)}
>
{t('setting.personal.delete_account.buttons.cancel')}
</Button>
</div>
</Form>
</Modal.Description>
</Modal.Content>
</Modal>
</div>
);
};
export default PersonalSetting;

View File

@@ -0,0 +1,13 @@
import { Navigate } from 'react-router-dom';
import { history } from '../helpers';
function PrivateRoute({ children }) {
if (!localStorage.getItem('user')) {
return <Navigate to='/login' state={{ from: history.location }} />;
}
return children;
}
export { PrivateRoute };

View File

@@ -0,0 +1,375 @@
import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
Button,
Form,
Label,
Popup,
Pagination,
Table,
} from 'semantic-ui-react';
import { Link } from 'react-router-dom';
import {
API,
copy,
showError,
showInfo,
showSuccess,
showWarning,
timestamp2string,
} from '../helpers';
import { ITEMS_PER_PAGE } from '../constants';
import { renderQuota } from '../helpers/render';
function renderTimestamp(timestamp) {
return <>{timestamp2string(timestamp)}</>;
}
function renderStatus(status, t) {
switch (status) {
case 1:
return (
<Label basic color='green'>
{t('redemption.status.unused')}
</Label>
);
case 2:
return (
<Label basic color='red'>
{t('redemption.status.disabled')}
</Label>
);
case 3:
return (
<Label basic color='grey'>
{t('redemption.status.used')}
</Label>
);
default:
return (
<Label basic color='black'>
{t('redemption.status.unknown')}
</Label>
);
}
}
const RedemptionsTable = () => {
const { t } = useTranslation();
const [redemptions, setRedemptions] = useState([]);
const [loading, setLoading] = useState(true);
const [activePage, setActivePage] = useState(1);
const [searchKeyword, setSearchKeyword] = useState('');
const [searching, setSearching] = useState(false);
const loadRedemptions = async (startIdx) => {
const res = await API.get(`/api/redemption/?p=${startIdx}`);
const { success, message, data } = res.data;
if (success) {
if (startIdx === 0) {
setRedemptions(data);
} else {
let newRedemptions = redemptions;
newRedemptions.push(...data);
setRedemptions(newRedemptions);
}
} else {
showError(message);
}
setLoading(false);
};
const onPaginationChange = (e, { activePage }) => {
(async () => {
if (activePage === Math.ceil(redemptions.length / ITEMS_PER_PAGE) + 1) {
// In this case we have to load more data and then append them.
await loadRedemptions(activePage - 1);
}
setActivePage(activePage);
})();
};
useEffect(() => {
loadRedemptions(0)
.then()
.catch((reason) => {
showError(reason);
});
}, []);
const manageRedemption = async (id, action, idx) => {
let data = { id };
let res;
switch (action) {
case 'delete':
res = await API.delete(`/api/redemption/${id}/`);
break;
case 'enable':
data.status = 1;
res = await API.put('/api/redemption/?status_only=true', data);
break;
case 'disable':
data.status = 2;
res = await API.put('/api/redemption/?status_only=true', data);
break;
}
const { success, message } = res.data;
if (success) {
showSuccess(t('token.messages.operation_success'));
let redemption = res.data.data;
let newRedemptions = [...redemptions];
let realIdx = (activePage - 1) * ITEMS_PER_PAGE + idx;
if (action === 'delete') {
newRedemptions[realIdx].deleted = true;
} else {
newRedemptions[realIdx].status = redemption.status;
}
setRedemptions(newRedemptions);
} else {
showError(message);
}
};
const searchRedemptions = async () => {
if (searchKeyword === '') {
// if keyword is blank, load files instead.
await loadRedemptions(0);
setActivePage(1);
return;
}
setSearching(true);
const res = await API.get(
`/api/redemption/search?keyword=${searchKeyword}`
);
const { success, message, data } = res.data;
if (success) {
setRedemptions(data);
setActivePage(1);
} else {
showError(message);
}
setSearching(false);
};
const handleKeywordChange = async (e, { value }) => {
setSearchKeyword(value.trim());
};
const sortRedemption = (key) => {
if (redemptions.length === 0) return;
setLoading(true);
let sortedRedemptions = [...redemptions];
sortedRedemptions.sort((a, b) => {
if (!isNaN(a[key])) {
// If the value is numeric, subtract to sort
return a[key] - b[key];
} else {
// If the value is not numeric, sort as strings
return ('' + a[key]).localeCompare(b[key]);
}
});
if (sortedRedemptions[0].id === redemptions[0].id) {
sortedRedemptions.reverse();
}
setRedemptions(sortedRedemptions);
setLoading(false);
};
const refresh = async () => {
setLoading(true);
await loadRedemptions(0);
setActivePage(1);
};
return (
<>
<Form onSubmit={searchRedemptions}>
<Form.Input
icon='search'
fluid
iconPosition='left'
placeholder={t('redemption.search')}
value={searchKeyword}
loading={searching}
onChange={handleKeywordChange}
/>
</Form>
<Table basic={'very'} compact size='small'>
<Table.Header>
<Table.Row>
<Table.HeaderCell
style={{ cursor: 'pointer' }}
onClick={() => {
sortRedemption('id');
}}
>
{t('redemption.table.id')}
</Table.HeaderCell>
<Table.HeaderCell
style={{ cursor: 'pointer' }}
onClick={() => {
sortRedemption('name');
}}
>
{t('redemption.table.name')}
</Table.HeaderCell>
<Table.HeaderCell
style={{ cursor: 'pointer' }}
onClick={() => {
sortRedemption('status');
}}
>
{t('redemption.table.status')}
</Table.HeaderCell>
<Table.HeaderCell
style={{ cursor: 'pointer' }}
onClick={() => {
sortRedemption('quota');
}}
>
{t('redemption.table.quota')}
</Table.HeaderCell>
<Table.HeaderCell
style={{ cursor: 'pointer' }}
onClick={() => {
sortRedemption('created_time');
}}
>
{t('redemption.table.created_time')}
</Table.HeaderCell>
<Table.HeaderCell
style={{ cursor: 'pointer' }}
onClick={() => {
sortRedemption('redeemed_time');
}}
>
{t('redemption.table.redeemed_time')}
</Table.HeaderCell>
<Table.HeaderCell>{t('redemption.table.actions')}</Table.HeaderCell>
</Table.Row>
</Table.Header>
<Table.Body>
{redemptions
.slice(
(activePage - 1) * ITEMS_PER_PAGE,
activePage * ITEMS_PER_PAGE
)
.map((redemption, idx) => {
if (redemption.deleted) return <></>;
return (
<Table.Row key={redemption.id}>
<Table.Cell>{redemption.id}</Table.Cell>
<Table.Cell>
{redemption.name ? redemption.name : t('redemption.table.no_name')}
</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)
: t('redemption.table.not_redeemed')}{' '}
</Table.Cell>
<Table.Cell>
<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>
<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>
);
})}
</Table.Body>
<Table.Footer>
<Table.Row>
<Table.HeaderCell colSpan='7'>
<Button
size='small'
as={Link}
to='/redemption/add'
loading={loading}
>
{t('redemption.buttons.add')}
</Button>
<Button size='small' onClick={refresh} loading={loading}>
{t('redemption.buttons.refresh')}
</Button>
<Pagination
floated='right'
activePage={activePage}
onPageChange={onPaginationChange}
size='small'
siblingRange={1}
totalPages={
Math.ceil(redemptions.length / ITEMS_PER_PAGE) +
(redemptions.length % ITEMS_PER_PAGE === 0 ? 1 : 0)
}
/>
</Table.HeaderCell>
</Table.Row>
</Table.Footer>
</Table>
</>
);
};
export default RedemptionsTable;

View File

@@ -0,0 +1,267 @@
import React, { useEffect, useState } from 'react';
import {
Button,
Form,
Grid,
Header,
Image,
Message,
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: '',
password2: '',
email: '',
verification_code: '',
});
const { username, password, password2 } = inputs;
const [showEmailVerification, setShowEmailVerification] = useState(false);
const [turnstileEnabled, setTurnstileEnabled] = useState(false);
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) {
localStorage.setItem('aff', affCode);
}
useEffect(() => {
let status = localStorage.getItem('status');
if (status) {
status = JSON.parse(status);
setShowEmailVerification(status.email_verification);
if (status.turnstile_check) {
setTurnstileEnabled(true);
setTurnstileSiteKey(status.turnstile_site_key);
}
}
});
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) {
const { name, value } = e.target;
console.log(name, value);
setInputs((inputs) => ({ ...inputs, [name]: value }));
}
async function handleSubmit(e) {
if (password.length < 8) {
showInfo(t('messages.error.password_length'));
return;
}
if (password !== password2) {
showInfo(t('messages.error.password_mismatch'));
return;
}
if (username && password) {
if (turnstileEnabled && turnstileToken === '') {
showInfo(t('messages.error.turnstile_wait'));
return;
}
setLoading(true);
if (!affCode) {
affCode = localStorage.getItem('aff');
}
inputs.aff_code = affCode;
const res = await API.post(
`/api/user/register?turnstile=${turnstileToken}`,
inputs
);
const { success, message } = res.data;
if (success) {
navigate('/login');
showSuccess(t('messages.success.register'));
} else {
showError(message);
}
setLoading(false);
}
}
const sendVerificationCode = async () => {
if (inputs.email === '') return;
if (turnstileEnabled && turnstileToken === '') {
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(t('messages.success.verification_code'));
} else {
showError(message);
setDisableButton(false);
setCountdown(30);
}
setLoading(false);
};
return (
<Grid textAlign='center' style={{ marginTop: '48px' }}>
<Grid.Column style={{ maxWidth: 450 }}>
<Card
fluid
className='chart-card'
style={{ boxShadow: '0 1px 3px rgba(0,0,0,0.12)' }}
>
<Card.Content>
<Card.Header>
<Header
as='h2'
textAlign='center'
style={{ marginBottom: '1.5em' }}
>
<Image src={logo} style={{ marginBottom: '10px' }} />
<Header.Content>{t('auth.register.title')}</Header.Content>
</Header>
</Card.Header>
<Form size='large'>
<Form.Input
fluid
icon='user'
iconPosition='left'
placeholder={t('auth.register.username')}
onChange={handleChange}
name='username'
style={{ marginBottom: '1em' }}
/>
<Form.Input
fluid
icon='lock'
iconPosition='left'
placeholder={t('auth.register.password')}
onChange={handleChange}
name='password'
type='password'
style={{ marginBottom: '1em' }}
/>
<Form.Input
fluid
icon='lock'
iconPosition='left'
placeholder={t('auth.register.confirm_password')}
onChange={handleChange}
name='password2'
type='password'
style={{ marginBottom: '1em' }}
/>
{showEmailVerification && (
<>
<Form.Input
fluid
icon='mail'
iconPosition='left'
placeholder={t('auth.register.email')}
onChange={handleChange}
name='email'
type='email'
action={
<Button onClick={sendVerificationCode} disabled={loading}>
{disableButton
? t('auth.register.get_code_retry', { countdown })
: t('auth.register.get_code')}
</Button>
}
style={{ marginBottom: '1em' }}
/>
<Form.Input
fluid
icon='lock'
iconPosition='left'
placeholder={t('auth.register.verification_code')}
onChange={handleChange}
name='verification_code'
style={{ marginBottom: '1em' }}
/>
</>
)}
{turnstileEnabled && (
<div
style={{
marginBottom: '1em',
display: 'flex',
justifyContent: 'center',
}}
>
<Turnstile
sitekey={turnstileSiteKey}
onVerify={(token) => {
setTurnstileToken(token);
}}
/>
</div>
)}
<Button
fluid
size='large'
onClick={handleSubmit}
style={{
background: '#2F73FF', // 使用更现代的蓝色
color: 'white',
marginBottom: '1.5em',
}}
loading={loading}
>
{t('auth.register.button')}
</Button>
</Form>
<Divider />
<Message style={{ background: 'transparent', boxShadow: 'none' }}>
<div
style={{
textAlign: 'center',
fontSize: '0.9em',
color: '#666',
}}
>
{t('auth.register.has_account')}
<Link
to='/login'
style={{ color: '#2185d0', marginLeft: '2px' }}
>
{t('auth.register.login')}
</Link>
</div>
</Message>
</Card.Content>
</Card>
</Grid.Column>
</Grid>
);
};
export default RegisterForm;

View File

@@ -0,0 +1,663 @@
import React, { useEffect, useState } from '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: '',
EmailVerificationEnabled: '',
GitHubOAuthEnabled: '',
GitHubClientId: '',
GitHubClientSecret: '',
LarkClientId: '',
LarkClientSecret: '',
Notice: '',
SMTPServer: '',
SMTPPort: '',
SMTPAccount: '',
SMTPFrom: '',
SMTPToken: '',
ServerAddress: '',
Footer: '',
WeChatAuthEnabled: '',
WeChatServerAddress: '',
WeChatServerToken: '',
WeChatAccountQRCodeImageURL: '',
MessagePusherAddress: '',
MessagePusherToken: '',
TurnstileCheckEnabled: '',
TurnstileSiteKey: '',
TurnstileSecretKey: '',
RegisterEnabled: '',
EmailDomainRestrictionEnabled: '',
EmailDomainWhitelist: '',
});
const [originInputs, setOriginInputs] = useState({});
let [loading, setLoading] = useState(false);
const [EmailDomainWhitelist, setEmailDomainWhitelist] = useState([]);
const [restrictedDomainInput, setRestrictedDomainInput] = useState('');
const [showPasswordWarningModal, setShowPasswordWarningModal] =
useState(false);
const getOptions = async () => {
const res = await API.get('/api/option/');
const { success, message, data } = res.data;
if (success) {
let newInputs = {};
data.forEach((item) => {
newInputs[item.key] = item.value;
});
setInputs({
...newInputs,
EmailDomainWhitelist: newInputs.EmailDomainWhitelist.split(','),
});
setOriginInputs(newInputs);
setEmailDomainWhitelist(
newInputs.EmailDomainWhitelist.split(',').map((item) => {
return { key: item, text: item, value: item };
})
);
} else {
showError(message);
}
};
useEffect(() => {
getOptions().then();
}, []);
const updateOption = async (key, value) => {
setLoading(true);
switch (key) {
case 'PasswordLoginEnabled':
case 'PasswordRegisterEnabled':
case 'EmailVerificationEnabled':
case 'GitHubOAuthEnabled':
case 'WeChatAuthEnabled':
case 'TurnstileCheckEnabled':
case 'EmailDomainRestrictionEnabled':
case 'RegisterEnabled':
value = inputs[key] === 'true' ? 'false' : 'true';
break;
default:
break;
}
const res = await API.put('/api/option/', {
key,
value,
});
const { success, message } = res.data;
if (success) {
if (key === 'EmailDomainWhitelist') {
value = value.split(',');
}
setInputs((inputs) => ({
...inputs,
[key]: value,
}));
} else {
showError(message);
}
setLoading(false);
};
const handleInputChange = async (e, { name, value }) => {
if (name === 'PasswordLoginEnabled' && inputs[name] === 'true') {
// block disabling password login
setShowPasswordWarningModal(true);
return;
}
if (
name === 'Notice' ||
name.startsWith('SMTP') ||
name === 'ServerAddress' ||
name === 'GitHubClientId' ||
name === 'GitHubClientSecret' ||
name === 'LarkClientId' ||
name === 'LarkClientSecret' ||
name === 'WeChatServerAddress' ||
name === 'WeChatServerToken' ||
name === 'WeChatAccountQRCodeImageURL' ||
name === 'TurnstileSiteKey' ||
name === 'TurnstileSecretKey' ||
name === 'EmailDomainWhitelist'
) {
setInputs((inputs) => ({ ...inputs, [name]: value }));
} else {
await updateOption(name, value);
}
};
const submitServerAddress = async () => {
let ServerAddress = removeTrailingSlash(inputs.ServerAddress);
await updateOption('ServerAddress', ServerAddress);
};
const submitSMTP = async () => {
if (originInputs['SMTPServer'] !== inputs.SMTPServer) {
await updateOption('SMTPServer', inputs.SMTPServer);
}
if (originInputs['SMTPAccount'] !== inputs.SMTPAccount) {
await updateOption('SMTPAccount', inputs.SMTPAccount);
}
if (originInputs['SMTPFrom'] !== inputs.SMTPFrom) {
await updateOption('SMTPFrom', inputs.SMTPFrom);
}
if (
originInputs['SMTPPort'] !== inputs.SMTPPort &&
inputs.SMTPPort !== ''
) {
await updateOption('SMTPPort', inputs.SMTPPort);
}
if (
originInputs['SMTPToken'] !== inputs.SMTPToken &&
inputs.SMTPToken !== ''
) {
await updateOption('SMTPToken', inputs.SMTPToken);
}
};
const submitEmailDomainWhitelist = async () => {
if (
originInputs['EmailDomainWhitelist'] !==
inputs.EmailDomainWhitelist.join(',') &&
inputs.SMTPToken !== ''
) {
await updateOption(
'EmailDomainWhitelist',
inputs.EmailDomainWhitelist.join(',')
);
}
};
const submitWeChat = async () => {
if (originInputs['WeChatServerAddress'] !== inputs.WeChatServerAddress) {
await updateOption(
'WeChatServerAddress',
removeTrailingSlash(inputs.WeChatServerAddress)
);
}
if (
originInputs['WeChatAccountQRCodeImageURL'] !==
inputs.WeChatAccountQRCodeImageURL
) {
await updateOption(
'WeChatAccountQRCodeImageURL',
inputs.WeChatAccountQRCodeImageURL
);
}
if (
originInputs['WeChatServerToken'] !== inputs.WeChatServerToken &&
inputs.WeChatServerToken !== ''
) {
await updateOption('WeChatServerToken', inputs.WeChatServerToken);
}
};
const submitMessagePusher = async () => {
if (originInputs['MessagePusherAddress'] !== inputs.MessagePusherAddress) {
await updateOption(
'MessagePusherAddress',
removeTrailingSlash(inputs.MessagePusherAddress)
);
}
if (
originInputs['MessagePusherToken'] !== inputs.MessagePusherToken &&
inputs.MessagePusherToken !== ''
) {
await updateOption('MessagePusherToken', inputs.MessagePusherToken);
}
};
const submitGitHubOAuth = async () => {
if (originInputs['GitHubClientId'] !== inputs.GitHubClientId) {
await updateOption('GitHubClientId', inputs.GitHubClientId);
}
if (
originInputs['GitHubClientSecret'] !== inputs.GitHubClientSecret &&
inputs.GitHubClientSecret !== ''
) {
await updateOption('GitHubClientSecret', inputs.GitHubClientSecret);
}
};
const submitLarkOAuth = async () => {
if (originInputs['LarkClientId'] !== inputs.LarkClientId) {
await updateOption('LarkClientId', inputs.LarkClientId);
}
if (
originInputs['LarkClientSecret'] !== inputs.LarkClientSecret &&
inputs.LarkClientSecret !== ''
) {
await updateOption('LarkClientSecret', inputs.LarkClientSecret);
}
};
const submitTurnstile = async () => {
if (originInputs['TurnstileSiteKey'] !== inputs.TurnstileSiteKey) {
await updateOption('TurnstileSiteKey', inputs.TurnstileSiteKey);
}
if (
originInputs['TurnstileSecretKey'] !== inputs.TurnstileSecretKey &&
inputs.TurnstileSecretKey !== ''
) {
await updateOption('TurnstileSecretKey', inputs.TurnstileSecretKey);
}
};
const submitNewRestrictedDomain = () => {
const localDomainList = inputs.EmailDomainWhitelist;
if (
restrictedDomainInput !== '' &&
!localDomainList.includes(restrictedDomainInput)
) {
setRestrictedDomainInput('');
setInputs({
...inputs,
EmailDomainWhitelist: [...localDomainList, restrictedDomainInput],
});
setEmailDomainWhitelist([
...EmailDomainWhitelist,
{
key: restrictedDomainInput,
text: restrictedDomainInput,
value: restrictedDomainInput,
},
]);
}
};
return (
<Grid columns={1}>
<Grid.Column>
<Form loading={loading}>
<Header as='h3'>{t('setting.system.general.title')}</Header>
<Form.Group widths='equal'>
<Form.Input
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}>
{t('setting.system.general.buttons.update')}
</Form.Button>
<Divider />
<Header as='h3'>{t('setting.system.login.title')}</Header>
<Form.Group inline>
<Form.Checkbox
checked={inputs.PasswordLoginEnabled === 'true'}
label={t('setting.system.login.password_login')}
name='PasswordLoginEnabled'
onChange={handleInputChange}
/>
{showPasswordWarningModal && (
<Modal
open={showPasswordWarningModal}
onClose={() => setShowPasswordWarningModal(false)}
size={'tiny'}
style={{ maxWidth: '450px' }}
>
<Modal.Header>
{t('setting.system.password_login.warning.title')}
</Modal.Header>
<Modal.Content>
<p>{t('setting.system.password_login.warning.content')}</p>
</Modal.Content>
<Modal.Actions>
<Button onClick={() => setShowPasswordWarningModal(false)}>
{t('setting.system.password_login.warning.buttons.cancel')}
</Button>
<Button
color='yellow'
onClick={async () => {
setShowPasswordWarningModal(false);
await updateOption('PasswordLoginEnabled', 'false');
}}
>
{t('setting.system.password_login.warning.buttons.confirm')}
</Button>
</Modal.Actions>
</Modal>
)}
<Form.Checkbox
checked={inputs.PasswordRegisterEnabled === 'true'}
label={t('setting.system.login.password_register')}
name='PasswordRegisterEnabled'
onChange={handleInputChange}
/>
<Form.Checkbox
checked={inputs.EmailVerificationEnabled === 'true'}
label={t('setting.system.login.email_verification')}
name='EmailVerificationEnabled'
onChange={handleInputChange}
/>
<Form.Checkbox
checked={inputs.GitHubOAuthEnabled === 'true'}
label={t('setting.system.login.github_oauth')}
name='GitHubOAuthEnabled'
onChange={handleInputChange}
/>
<Form.Checkbox
checked={inputs.WeChatAuthEnabled === 'true'}
label={t('setting.system.login.wechat_login')}
name='WeChatAuthEnabled'
onChange={handleInputChange}
/>
</Form.Group>
<Form.Group inline>
<Form.Checkbox
checked={inputs.RegisterEnabled === 'true'}
label={t('setting.system.login.registration')}
name='RegisterEnabled'
onChange={handleInputChange}
/>
<Form.Checkbox
checked={inputs.TurnstileCheckEnabled === 'true'}
label={t('setting.system.login.turnstile')}
name='TurnstileCheckEnabled'
onChange={handleInputChange}
/>
</Form.Group>
<Divider />
<Header as='h3'>{t('setting.system.email_restriction.title')}</Header>
<Message>{t('setting.system.email_restriction.subtitle')}</Message>
<Form.Group inline>
<Form.Checkbox
checked={inputs.EmailDomainRestrictionEnabled === 'true'}
label={t('setting.system.email_restriction.enable')}
name='EmailDomainRestrictionEnabled'
onChange={handleInputChange}
/>
</Form.Group>
<Form.Group widths={3}>
<Form.Input
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.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'>{t('setting.system.smtp.title')}</Header>
<Message>{t('setting.system.smtp.subtitle')}</Message>
<Form.Group widths={3}>
<Form.Input
label={t('setting.system.smtp.server')}
placeholder={t('setting.system.smtp.server_placeholder')}
name='SMTPServer'
onChange={handleInputChange}
value={inputs.SMTPServer}
/>
<Form.Input
label={t('setting.system.smtp.port')}
placeholder={t('setting.system.smtp.port_placeholder')}
name='SMTPPort'
onChange={handleInputChange}
value={inputs.SMTPPort}
/>
<Form.Input
label={t('setting.system.smtp.account')}
placeholder={t('setting.system.smtp.account_placeholder')}
name='SMTPAccount'
onChange={handleInputChange}
value={inputs.SMTPAccount}
/>
</Form.Group>
<Form.Group widths={3}>
<Form.Input
label={t('setting.system.smtp.from')}
placeholder={t('setting.system.smtp.from_placeholder')}
name='SMTPFrom'
onChange={handleInputChange}
value={inputs.SMTPFrom}
/>
<Form.Input
label={t('setting.system.smtp.token')}
placeholder={t('setting.system.smtp.token_placeholder')}
name='SMTPToken'
onChange={handleInputChange}
type='password'
value={inputs.SMTPToken}
/>
</Form.Group>
<Form.Button onClick={submitSMTP}>
{t('setting.system.smtp.buttons.save')}
</Form.Button>
<Divider />
<Header as='h3'>{t('setting.system.github.title')}</Header>
<Message>
{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={t('setting.system.github.client_id')}
placeholder={t('setting.system.github.client_id_placeholder')}
name='GitHubClientId'
onChange={handleInputChange}
value={inputs.GitHubClientId}
/>
<Form.Input
label={t('setting.system.github.client_secret')}
placeholder={t('setting.system.github.client_secret_placeholder')}
name='GitHubClientSecret'
onChange={handleInputChange}
type='password'
value={inputs.GitHubClientSecret}
/>
</Form.Group>
<Form.Button onClick={submitGitHubOAuth}>
{t('setting.system.github.buttons.save')}
</Form.Button>
<Divider />
<Header as='h3'>
{t('setting.system.lark.title')}
<Header.Subheader>
{t('setting.system.lark.subtitle')}
<a href='https://open.feishu.cn/app' target='_blank'>
{t('setting.system.lark.manage_link')}
</a>
{t('setting.system.lark.manage_text')}
</Header.Subheader>
</Header>
<Message>
{t('setting.system.lark.url_notice', {
server_url: inputs.ServerAddress,
callback_url: `${inputs.ServerAddress}/oauth/lark`,
})}
</Message>
<Form.Group widths={3}>
<Form.Input
label={t('setting.system.lark.client_id')}
name='LarkClientId'
onChange={handleInputChange}
autoComplete='new-password'
value={inputs.LarkClientId}
placeholder={t('setting.system.lark.client_id_placeholder')}
/>
<Form.Input
label={t('setting.system.lark.client_secret')}
name='LarkClientSecret'
onChange={handleInputChange}
type='password'
autoComplete='new-password'
value={inputs.LarkClientSecret}
placeholder={t('setting.system.lark.client_secret_placeholder')}
/>
</Form.Group>
<Form.Button onClick={submitLarkOAuth}>
{t('setting.system.lark.buttons.save')}
</Form.Button>
<Divider />
<Header as='h3'>
{t('setting.system.wechat.title')}
<Header.Subheader>
{t('setting.system.wechat.subtitle')}
<a
href='https://github.com/songquanpeng/wechat-server'
target='_blank'
>
{t('setting.system.wechat.learn_more')}
</a>
</Header.Subheader>
</Header>
<Form.Group widths={3}>
<Form.Input
label={t('setting.system.wechat.server_address')}
name='WeChatServerAddress'
onChange={handleInputChange}
autoComplete='new-password'
value={inputs.WeChatServerAddress}
placeholder={t(
'setting.system.wechat.server_address_placeholder'
)}
/>
<Form.Input
label={t('setting.system.wechat.token')}
name='WeChatServerToken'
onChange={handleInputChange}
type='password'
autoComplete='new-password'
value={inputs.WeChatServerToken}
placeholder={t('setting.system.wechat.token_placeholder')}
/>
<Form.Input
label={t('setting.system.wechat.qrcode')}
name='WeChatAccountQRCodeImageURL'
onChange={handleInputChange}
autoComplete='new-password'
value={inputs.WeChatAccountQRCodeImageURL}
placeholder={t('setting.system.wechat.qrcode_placeholder')}
/>
</Form.Group>
<Form.Button onClick={submitWeChat}>
{t('setting.system.wechat.buttons.save')}
</Form.Button>
<Divider />
<Header as='h3'>
{t('setting.system.turnstile.title')}
<Header.Subheader>
{t('setting.system.turnstile.subtitle')}
<a href='https://dash.cloudflare.com/' target='_blank'>
{t('setting.system.turnstile.manage_link')}
</a>
{t('setting.system.turnstile.manage_text')}
</Header.Subheader>
</Header>
<Form.Group widths={3}>
<Form.Input
label={t('setting.system.turnstile.site_key')}
name='TurnstileSiteKey'
onChange={handleInputChange}
autoComplete='new-password'
value={inputs.TurnstileSiteKey}
placeholder={t('setting.system.turnstile.site_key_placeholder')}
/>
<Form.Input
label={t('setting.system.turnstile.secret_key')}
name='TurnstileSecretKey'
onChange={handleInputChange}
type='password'
autoComplete='new-password'
value={inputs.TurnstileSecretKey}
placeholder={t('setting.system.turnstile.secret_key_placeholder')}
/>
</Form.Group>
<Form.Button onClick={submitTurnstile}>
{t('setting.system.turnstile.buttons.save')}
</Form.Button>
</Form>
</Grid.Column>
</Grid>
);
};
export default SystemSetting;

View File

@@ -0,0 +1,544 @@
import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
Button,
Dropdown,
Form,
Label,
Pagination,
Popup,
Table,
} from 'semantic-ui-react';
import { Link } from 'react-router-dom';
import {
API,
copy,
showError,
showSuccess,
showWarning,
timestamp2string,
} from '../helpers';
import { ITEMS_PER_PAGE } from '../constants';
import { renderQuota } from '../helpers/render';
function renderTimestamp(timestamp) {
return <>{timestamp2string(timestamp)}</>;
}
function renderStatus(status, t) {
switch (status) {
case 1:
return (
<Label basic color='green'>
{t('token.table.status_enabled')}
</Label>
);
case 2:
return (
<Label basic color='red'>
{t('token.table.status_disabled')}
</Label>
);
case 3:
return (
<Label basic color='yellow'>
{t('token.table.status_expired')}
</Label>
);
case 4:
return (
<Label basic color='grey'>
{t('token.table.status_depleted')}
</Label>
);
default:
return (
<Label basic color='black'>
{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);
const [searchKeyword, setSearchKeyword] = useState('');
const [searching, setSearching] = useState(false);
const [showTopUpModal, setShowTopUpModal] = useState(false);
const [targetTokenIdx, setTargetTokenIdx] = useState(0);
const [orderBy, setOrderBy] = useState('');
const loadTokens = async (startIdx) => {
const res = await API.get(`/api/token/?p=${startIdx}&order=${orderBy}`);
const { success, message, data } = res.data;
if (success) {
if (startIdx === 0) {
setTokens(data);
} else {
let newTokens = [...tokens];
newTokens.splice(startIdx * ITEMS_PER_PAGE, data.length, ...data);
setTokens(newTokens);
}
} else {
showError(message);
}
setLoading(false);
};
const onPaginationChange = (e, { activePage }) => {
(async () => {
if (activePage === Math.ceil(tokens.length / ITEMS_PER_PAGE) + 1) {
// In this case we have to load more data and then append them.
await loadTokens(activePage - 1, orderBy);
}
setActivePage(activePage);
})();
};
const refresh = async () => {
setLoading(true);
await loadTokens(activePage - 1);
};
const onCopy = async (type, key) => {
let status = localStorage.getItem('status');
let serverAddress = '';
if (status) {
status = JSON.parse(status);
serverAddress = status.server_address;
}
if (serverAddress === '') {
serverAddress = window.location.origin;
}
let encodedServerAddress = encodeURIComponent(serverAddress);
const nextLink = localStorage.getItem('chat_link');
let nextUrl;
if (nextLink) {
nextUrl =
nextLink + `/#/?settings={"key":"sk-${key}","url":"${serverAddress}"}`;
} else {
nextUrl = `https://app.nextchat.dev/#/?settings={"key":"sk-${key}","url":"${serverAddress}"}`;
}
let url;
switch (type) {
case 'ama':
url = `ama://set-api-key?server=${encodedServerAddress}&key=sk-${key}`;
break;
case 'opencat':
url = `opencat://team/join?domain=${encodedServerAddress}&token=sk-${key}`;
break;
case 'next':
url = nextUrl;
break;
case 'lobechat':
url =
nextLink +
`/?settings={"keyVaults":{"openai":{"apiKey":"sk-${key}","baseURL":"${serverAddress}/v1"}}}`;
break;
default:
url = `sk-${key}`;
}
if (await copy(url)) {
showSuccess(t('token.messages.copy_success'));
} else {
showWarning(t('token.messages.copy_failed'));
setSearchKeyword(url);
}
};
const onOpenLink = async (type, key) => {
let status = localStorage.getItem('status');
let serverAddress = '';
if (status) {
status = JSON.parse(status);
serverAddress = status.server_address;
}
if (serverAddress === '') {
serverAddress = window.location.origin;
}
let encodedServerAddress = encodeURIComponent(serverAddress);
const chatLink = localStorage.getItem('chat_link');
let defaultUrl;
if (chatLink) {
defaultUrl =
chatLink + `/#/?settings={"key":"sk-${key}","url":"${serverAddress}"}`;
} else {
defaultUrl = `https://app.nextchat.dev/#/?settings={"key":"sk-${key}","url":"${serverAddress}"}`;
}
let url;
switch (type) {
case 'ama':
url = `ama://set-api-key?server=${encodedServerAddress}&key=sk-${key}`;
break;
case 'opencat':
url = `opencat://team/join?domain=${encodedServerAddress}&token=sk-${key}`;
break;
case 'lobechat':
url =
chatLink +
`/?settings={"keyVaults":{"openai":{"apiKey":"sk-${key}","baseURL":"${serverAddress}/v1"}}}`;
break;
default:
url = defaultUrl;
}
window.open(url, '_blank');
};
useEffect(() => {
loadTokens(0, orderBy)
.then()
.catch((reason) => {
showError(reason);
});
}, [orderBy]);
const manageToken = async (id, action, idx) => {
let data = { id };
let res;
switch (action) {
case 'delete':
res = await API.delete(`/api/token/${id}/`);
break;
case 'enable':
data.status = 1;
res = await API.put('/api/token/?status_only=true', data);
break;
case 'disable':
data.status = 2;
res = await API.put('/api/token/?status_only=true', data);
break;
}
const { success, message } = res.data;
if (success) {
showSuccess(t('token.messages.operation_success'));
let token = res.data.data;
let newTokens = [...tokens];
let realIdx = (activePage - 1) * ITEMS_PER_PAGE + idx;
if (action === 'delete') {
newTokens[realIdx].deleted = true;
} else {
newTokens[realIdx].status = token.status;
}
setTokens(newTokens);
} else {
showError(message);
}
};
const searchTokens = async () => {
if (searchKeyword === '') {
// if keyword is blank, load files instead.
await loadTokens(0);
setActivePage(1);
setOrderBy('');
return;
}
setSearching(true);
const res = await API.get(`/api/token/search?keyword=${searchKeyword}`);
const { success, message, data } = res.data;
if (success) {
setTokens(data);
setActivePage(1);
} else {
showError(message);
}
setSearching(false);
};
const handleKeywordChange = async (e, { value }) => {
setSearchKeyword(value.trim());
};
const sortToken = (key) => {
if (tokens.length === 0) return;
setLoading(true);
let sortedTokens = [...tokens];
sortedTokens.sort((a, b) => {
if (!isNaN(a[key])) {
// If the value is numeric, subtract to sort
return a[key] - b[key];
} else {
// If the value is not numeric, sort as strings
return ('' + a[key]).localeCompare(b[key]);
}
});
if (sortedTokens[0].id === tokens[0].id) {
sortedTokens.reverse();
}
setTokens(sortedTokens);
setLoading(false);
};
const handleOrderByChange = (e, { value }) => {
setOrderBy(value);
setActivePage(1);
};
return (
<>
<Form onSubmit={searchTokens}>
<Form.Input
icon='search'
fluid
iconPosition='left'
placeholder={t('token.search')}
value={searchKeyword}
loading={searching}
onChange={handleKeywordChange}
/>
</Form>
<Table basic={'very'} compact size='small'>
<Table.Header>
<Table.Row>
<Table.HeaderCell
style={{ cursor: 'pointer' }}
onClick={() => {
sortToken('name');
}}
>
{t('token.table.name')}
</Table.HeaderCell>
<Table.HeaderCell
style={{ cursor: 'pointer' }}
onClick={() => {
sortToken('status');
}}
>
{t('token.table.status')}
</Table.HeaderCell>
<Table.HeaderCell
style={{ cursor: 'pointer' }}
onClick={() => {
sortToken('used_quota');
}}
>
{t('token.table.used_quota')}
</Table.HeaderCell>
<Table.HeaderCell
style={{ cursor: 'pointer' }}
onClick={() => {
sortToken('remain_quota');
}}
>
{t('token.table.remain_quota')}
</Table.HeaderCell>
<Table.HeaderCell
style={{ cursor: 'pointer' }}
onClick={() => {
sortToken('created_time');
}}
>
{t('token.table.created_time')}
</Table.HeaderCell>
<Table.HeaderCell
style={{ cursor: 'pointer' }}
onClick={() => {
sortToken('expired_time');
}}
>
{t('token.table.expired_time')}
</Table.HeaderCell>
<Table.HeaderCell>{t('token.table.actions')}</Table.HeaderCell>
</Table.Row>
</Table.Header>
<Table.Body>
{tokens
.slice(
(activePage - 1) * ITEMS_PER_PAGE,
activePage * ITEMS_PER_PAGE
)
.map((token, idx) => {
if (token.deleted) return <></>;
const copyOptionsWithHandlers = COPY_OPTIONS.map((option) => ({
...option,
onClick: async () => {
await onCopy(option.value, token.key);
},
}));
const openLinkOptionsWithHandlers = OPEN_LINK_OPTIONS.map(
(option) => ({
...option,
onClick: async () => {
await onOpenLink(option.value, token.key);
},
})
);
return (
<Table.Row key={token.id}>
<Table.Cell>
{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
? t('token.table.never_expire')
: renderTimestamp(token.expired_time)}
</Table.Cell>
<Table.Cell>
<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>
<Button
size={'tiny'}
as={Link}
to={'/token/edit/' + token.id}
>
{t('token.buttons.edit')}
</Button>
</div>
</Table.Cell>
</Table.Row>
);
})}
</Table.Body>
<Table.Footer>
<Table.Row>
<Table.HeaderCell colSpan='7'>
<Button size='small' as={Link} to='/token/add' loading={loading}>
{t('token.buttons.add')}
</Button>
<Button size='small' onClick={refresh} loading={loading}>
{t('token.buttons.refresh')}
</Button>
<Dropdown
placeholder={t('token.sort.placeholder')}
selection
options={[
{ key: '', text: t('token.sort.default'), value: '' },
{
key: 'remain_quota',
text: t('token.sort.by_remain'),
value: 'remain_quota',
},
{
key: 'used_quota',
text: t('token.sort.by_used'),
value: 'used_quota',
},
]}
value={orderBy}
onChange={handleOrderByChange}
style={{ marginLeft: '10px' }}
/>
<Pagination
floated='right'
activePage={activePage}
onPageChange={onPaginationChange}
size='small'
siblingRange={1}
totalPages={
Math.ceil(tokens.length / ITEMS_PER_PAGE) +
(tokens.length % ITEMS_PER_PAGE === 0 ? 1 : 0)
}
/>
</Table.HeaderCell>
</Table.Row>
</Table.Footer>
</Table>
</>
);
};
export default TokensTable;

View File

@@ -0,0 +1,417 @@
import React, { useEffect, useState } from 'react';
import {
Button,
Form,
Label,
Pagination,
Popup,
Table,
Dropdown,
} 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 {
renderGroup,
renderNumber,
renderQuota,
renderText,
} from '../helpers/render';
function renderRole(role, t) {
switch (role) {
case 1:
return <Label>{t('user.table.role_types.normal')}</Label>;
case 10:
return <Label color='yellow'>{t('user.table.role_types.admin')}</Label>;
case 100:
return (
<Label color='orange'>{t('user.table.role_types.super_admin')}</Label>
);
default:
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);
const [searchKeyword, setSearchKeyword] = useState('');
const [searching, setSearching] = useState(false);
const [orderBy, setOrderBy] = useState('');
const loadUsers = async (startIdx) => {
const res = await API.get(`/api/user/?p=${startIdx}&order=${orderBy}`);
const { success, message, data } = res.data;
if (success) {
if (startIdx === 0) {
setUsers(data);
} else {
let newUsers = users;
newUsers.push(...data);
setUsers(newUsers);
}
} else {
showError(message);
}
setLoading(false);
};
const onPaginationChange = (e, { activePage }) => {
(async () => {
if (activePage === Math.ceil(users.length / ITEMS_PER_PAGE) + 1) {
// In this case we have to load more data and then append them.
await loadUsers(activePage - 1, orderBy);
}
setActivePage(activePage);
})();
};
useEffect(() => {
loadUsers(0, orderBy)
.then()
.catch((reason) => {
showError(reason);
});
}, [orderBy]);
const manageUser = (username, action, idx) => {
(async () => {
const res = await API.post('/api/user/manage', {
username,
action,
});
const { success, message } = res.data;
if (success) {
showSuccess(t('user.messages.operation_success'));
let user = res.data.data;
let newUsers = [...users];
let realIdx = (activePage - 1) * ITEMS_PER_PAGE + idx;
if (action === 'delete') {
newUsers[realIdx].deleted = true;
} else {
newUsers[realIdx].status = user.status;
newUsers[realIdx].role = user.role;
}
setUsers(newUsers);
} else {
showError(message);
}
})();
};
const renderStatus = (status) => {
switch (status) {
case 1:
return <Label basic>{t('user.table.status_types.activated')}</Label>;
case 2:
return (
<Label basic color='red'>
{t('user.table.status_types.banned')}
</Label>
);
default:
return (
<Label basic color='grey'>
{t('user.table.status_types.unknown')}
</Label>
);
}
};
const searchUsers = async () => {
if (searchKeyword === '') {
// if keyword is blank, load files instead.
await loadUsers(0);
setActivePage(1);
setOrderBy('');
return;
}
setSearching(true);
const res = await API.get(`/api/user/search?keyword=${searchKeyword}`);
const { success, message, data } = res.data;
if (success) {
setUsers(data);
setActivePage(1);
} else {
showError(message);
}
setSearching(false);
};
const handleKeywordChange = async (e, { value }) => {
setSearchKeyword(value.trim());
};
const sortUser = (key) => {
if (users.length === 0) return;
setLoading(true);
let sortedUsers = [...users];
sortedUsers.sort((a, b) => {
if (!isNaN(a[key])) {
// If the value is numeric, subtract to sort
return a[key] - b[key];
} else {
// If the value is not numeric, sort as strings
return ('' + a[key]).localeCompare(b[key]);
}
});
if (sortedUsers[0].id === users[0].id) {
sortedUsers.reverse();
}
setUsers(sortedUsers);
setLoading(false);
};
const handleOrderByChange = (e, { value }) => {
setOrderBy(value);
setActivePage(1);
};
return (
<>
<Form onSubmit={searchUsers}>
<Form.Input
icon='search'
fluid
iconPosition='left'
placeholder={t('user.search')}
value={searchKeyword}
loading={searching}
onChange={handleKeywordChange}
/>
</Form>
<Table basic={'very'} compact size='small'>
<Table.Header>
<Table.Row>
<Table.HeaderCell
style={{ cursor: 'pointer' }}
onClick={() => {
sortUser('id');
}}
>
{t('user.table.id')}
</Table.HeaderCell>
<Table.HeaderCell
style={{ cursor: 'pointer' }}
onClick={() => {
sortUser('username');
}}
>
{t('user.table.username')}
</Table.HeaderCell>
<Table.HeaderCell
style={{ cursor: 'pointer' }}
onClick={() => {
sortUser('group');
}}
>
{t('user.table.group')}
</Table.HeaderCell>
<Table.HeaderCell
style={{ cursor: 'pointer' }}
onClick={() => {
sortUser('quota');
}}
>
{t('user.table.quota')}
</Table.HeaderCell>
<Table.HeaderCell
style={{ cursor: 'pointer' }}
onClick={() => {
sortUser('role');
}}
>
{t('user.table.role_text')}
</Table.HeaderCell>
<Table.HeaderCell
style={{ cursor: 'pointer' }}
onClick={() => {
sortUser('status');
}}
>
{t('user.table.status_text')}
</Table.HeaderCell>
<Table.HeaderCell>{t('user.table.actions')}</Table.HeaderCell>
</Table.Row>
</Table.Header>
<Table.Body>
{users
.slice(
(activePage - 1) * ITEMS_PER_PAGE,
activePage * ITEMS_PER_PAGE
)
.map((user, idx) => {
if (user.deleted) return <></>;
return (
<Table.Row key={user.id}>
<Table.Cell>{user.id}</Table.Cell>
<Table.Cell>
<Popup
content={user.email ? user.email : '未绑定邮箱地址'}
key={user.username}
header={
user.display_name ? user.display_name : user.username
}
trigger={<span>{renderText(user.username, 15)}</span>}
hoverable
/>
</Table.Cell>
<Table.Cell>{renderGroup(user.group)}</Table.Cell>
{/*<Table.Cell>*/}
{/* {user.email ? <Popup hoverable content={user.email} trigger={<span>{renderText(user.email, 24)}</span>} /> : '无'}*/}
{/*</Table.Cell>*/}
<Table.Cell>
<Popup
content={t('user.table.remaining_quota')}
trigger={
<Label basic>{renderQuota(user.quota, t)}</Label>
}
/>
<Popup
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, t)}</Table.Cell>
<Table.Cell>{renderStatus(user.status)}</Table.Cell>
<Table.Cell>
<div>
<Button
size={'tiny'}
positive
onClick={() => {
manageUser(user.username, 'promote', idx);
}}
disabled={user.role === 100}
>
{t('user.buttons.promote')}
</Button>
<Button
size={'tiny'}
color={'yellow'}
onClick={() => {
manageUser(user.username, 'demote', idx);
}}
disabled={user.role === 100}
>
{t('user.buttons.demote')}
</Button>
<Popup
trigger={
<Button
size='tiny'
negative
disabled={user.role === 100}
>
{t('user.buttons.delete')}
</Button>
}
on='click'
flowing
hoverable
>
<Button
negative
size={'tiny'}
onClick={() => {
manageUser(user.username, 'delete', idx);
}}
>
{t('user.buttons.delete_user')} {user.username}
</Button>
</Popup>
<Button
size={'tiny'}
onClick={() => {
manageUser(
user.username,
user.status === 1 ? 'disable' : 'enable',
idx
);
}}
disabled={user.role === 100}
>
{user.status === 1
? t('user.buttons.disable')
: t('user.buttons.enable')}
</Button>
<Button
size={'tiny'}
as={Link}
to={'/user/edit/' + user.id}
>
{t('user.buttons.edit')}
</Button>
</div>
</Table.Cell>
</Table.Row>
);
})}
</Table.Body>
<Table.Footer>
<Table.Row>
<Table.HeaderCell colSpan='7'>
<Button size='small' as={Link} to='/user/add' loading={loading}>
{t('user.buttons.add')}
</Button>
<Dropdown
placeholder={t('user.table.sort_by')}
selection
options={[
{ key: '', text: t('user.table.sort.default'), value: '' },
{
key: 'quota',
text: t('user.table.sort.by_quota'),
value: 'quota',
},
{
key: 'used_quota',
text: t('user.table.sort.by_used_quota'),
value: 'used_quota',
},
{
key: 'request_count',
text: t('user.table.sort.by_request_count'),
value: 'request_count',
},
]}
value={orderBy}
onChange={handleOrderByChange}
style={{ marginLeft: '10px' }}
/>
<Pagination
floated='right'
activePage={activePage}
onPageChange={onPaginationChange}
size='small'
siblingRange={1}
totalPages={
Math.ceil(users.length / ITEMS_PER_PAGE) +
(users.length % ITEMS_PER_PAGE === 0 ? 1 : 0)
}
/>
</Table.HeaderCell>
</Table.Row>
</Table.Footer>
</Table>
</>
);
};
export default UsersTable;

View File

@@ -0,0 +1,29 @@
import { API, showError } from '../helpers';
export async function getOAuthState() {
const res = await API.get('/api/oauth/state');
const { success, message, data } = res.data;
if (success) {
return data;
} else {
showError(message);
return '';
}
}
export async function onGitHubOAuthClicked(github_client_id) {
const state = await getOAuthState();
if (!state) return;
window.open(
`https://github.com/login/oauth/authorize?client_id=${github_client_id}&state=${state}&scope=user:email`
);
}
export async function onLarkOAuthClicked(lark_client_id) {
const state = await getOAuthState();
if (!state) return;
let redirect_uri = `${window.location.origin}/oauth/lark`;
window.open(
`https://open.feishu.cn/open-apis/authen/v1/index?redirect_uri=${redirect_uri}&app_id=${lark_client_id}&state=${state}`
);
}

View File

@@ -0,0 +1,108 @@
export const CHANNEL_OPTIONS = [
{ key: 1, text: 'OpenAI', value: 1, color: 'green' },
{
key: 50,
text: 'OpenAI 兼容',
value: 50,
color: 'olive',
description: 'OpenAI 兼容渠道,支持设置 Base URL',
},
{key: 14, text: 'Anthropic', value: 14, color: 'black'},
{ key: 33, text: 'AWS', value: 33, color: 'black' },
{key: 3, text: 'Azure', value: 3, color: 'olive'},
{key: 11, text: 'PaLM2', value: 11, color: 'orange'},
{key: 24, text: 'Gemini', value: 24, color: 'orange'},
{
key: 51,
text: 'Gemini (OpenAI)',
value: 51,
color: 'orange',
description: 'Gemini OpenAI 兼容格式',
},
{ key: 28, text: 'Mistral AI', value: 28, color: 'orange' },
{ key: 41, text: 'Novita', value: 41, color: 'purple' },
{
key: 40,
text: '字节火山引擎',
value: 40,
color: 'blue',
description: '原字节跳动豆包',
},
{
key: 15,
text: '百度文心千帆',
value: 15,
color: 'blue',
tip: '请前往<a href="https://console.bce.baidu.com/qianfan/ais/console/applicationConsole/application/v1" target="_blank">此处</a>获取 AKAPI Key以及 SKSecret Key注意V2 版本接口请使用 <strong>百度文心千帆 V2 </strong>渠道类型',
},
{
key: 47,
text: '百度文心千帆 V2',
value: 47,
color: 'blue',
tip: '请前往<a href="https://console.bce.baidu.com/iam/#/iam/apikey/list" target="_blank">此处</a>获取 API Key注意本渠道仅支持<a target="_blank" href="https://cloud.baidu.com/doc/WENXINWORKSHOP/s/em4tsqo3v">推理服务 V2</a>相关模型',
},
{
key: 17,
text: '阿里通义千问',
value: 17,
color: 'orange',
tip: '如需使用阿里云百炼,请使用<strong>阿里云百炼</strong>渠道',
},
{ key: 49, text: '阿里云百炼', value: 49, color: 'orange' },
{
key: 18,
text: '讯飞星火认知',
value: 18,
color: 'blue',
tip: '本渠道基于讯飞 WebSocket 版本 API如需 HTTP 版本,请使用<strong>讯飞星火认知 V2</strong>渠道',
},
{
key: 48,
text: '讯飞星火认知 V2',
value: 48,
color: 'blue',
tip: 'HTTP 版本的讯飞接口,前往<a href="https://console.xfyun.cn/services/cbm" target="_blank">此处</a>获取 HTTP 服务接口认证密钥',
},
{ key: 16, text: '智谱 ChatGLM', value: 16, color: 'violet' },
{ key: 19, text: '360 智脑', value: 19, color: 'blue' },
{ key: 25, text: 'Moonshot AI', value: 25, color: 'black' },
{ key: 23, text: '腾讯混元', value: 23, color: 'teal' },
{ key: 26, text: '百川大模型', value: 26, color: 'orange' },
{ key: 27, text: 'MiniMax', value: 27, color: 'red' },
{ key: 29, text: 'Groq', value: 29, color: 'orange' },
{ key: 30, text: 'Ollama', value: 30, color: 'black' },
{ key: 31, text: '零一万物', value: 31, color: 'green' },
{ key: 32, text: '阶跃星辰', value: 32, color: 'blue' },
{ key: 34, text: 'Coze', value: 34, color: 'blue' },
{ key: 35, text: 'Cohere', value: 35, color: 'blue' },
{ key: 36, text: 'DeepSeek', value: 36, color: 'black' },
{ key: 37, text: 'Cloudflare', value: 37, color: 'orange' },
{ key: 38, text: 'DeepL', value: 38, color: 'black' },
{ key: 39, text: 'together.ai', value: 39, color: 'blue' },
{ key: 42, text: 'VertexAI', value: 42, color: 'blue' },
{ key: 43, text: 'Proxy', value: 43, color: 'blue' },
{ key: 44, text: 'SiliconFlow', value: 44, color: 'blue' },
{ key: 45, text: 'xAI', value: 45, color: 'blue' },
{ key: 46, text: 'Replicate', value: 46, color: 'blue' },
{
key: 8,
text: '自定义渠道',
value: 8,
color: 'pink',
tip: '不推荐使用,请使用 <strong>OpenAI 兼容</strong>渠道类型。注意,这里所需要填入的代理地址仅会在实际请求时替换域名部分,如果你想填入 OpenAI SDK 中所要求的 Base URL请使用 OpenAI 兼容渠道类型',
description: '不推荐使用,请使用 OpenAI 兼容渠道类型',
},
{ key: 22, text: '知识库FastGPT', value: 22, color: 'blue' },
{ key: 21, text: '知识库AI Proxy', value: 21, color: 'purple' },
{ key: 20, text: 'OpenRouter', value: 20, color: 'black' },
{ key: 2, text: '代理API2D', value: 2, color: 'blue' },
{ key: 5, text: '代理OpenAI-SB', value: 5, color: 'brown' },
{ key: 7, text: '代理OhMyGPT', value: 7, color: 'purple' },
{ key: 10, text: '代理AI Proxy', value: 10, color: 'purple' },
{ key: 4, text: '代理CloseAI', value: 4, color: 'teal' },
{ key: 6, text: '代理OpenAI Max', value: 6, color: 'violet' },
{ key: 9, text: '代理AI.LS', value: 9, color: 'yellow' },
{ key: 12, text: '代理API2GPT', value: 12, color: 'blue' },
{ key: 13, text: '代理AIGC2D', value: 13, color: 'purple' },
];

View File

@@ -0,0 +1 @@
export const ITEMS_PER_PAGE = 10; // this value must keep same as the one defined in backend!

View File

@@ -0,0 +1,4 @@
export * from './toast.constants';
export * from './user.constants';
export * from './common.constant';
export * from './channel.constants';

View File

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

View File

@@ -0,0 +1,19 @@
export const userConstants = {
REGISTER_REQUEST: 'USERS_REGISTER_REQUEST',
REGISTER_SUCCESS: 'USERS_REGISTER_SUCCESS',
REGISTER_FAILURE: 'USERS_REGISTER_FAILURE',
LOGIN_REQUEST: 'USERS_LOGIN_REQUEST',
LOGIN_SUCCESS: 'USERS_LOGIN_SUCCESS',
LOGIN_FAILURE: 'USERS_LOGIN_FAILURE',
LOGOUT: 'USERS_LOGOUT',
GETALL_REQUEST: 'USERS_GETALL_REQUEST',
GETALL_SUCCESS: 'USERS_GETALL_SUCCESS',
GETALL_FAILURE: 'USERS_GETALL_FAILURE',
DELETE_REQUEST: 'USERS_DELETE_REQUEST',
DELETE_SUCCESS: 'USERS_DELETE_SUCCESS',
DELETE_FAILURE: 'USERS_DELETE_FAILURE'
};

View File

@@ -0,0 +1,19 @@
// contexts/User/index.jsx
import React from 'react';
import { initialState, reducer } from './reducer';
export const StatusContext = React.createContext({
state: initialState,
dispatch: () => null,
});
export const StatusProvider = ({ children }) => {
const [state, dispatch] = React.useReducer(reducer, initialState);
return (
<StatusContext.Provider value={[state, dispatch]}>
{children}
</StatusContext.Provider>
);
};

View File

@@ -0,0 +1,20 @@
export const reducer = (state, action) => {
switch (action.type) {
case 'set':
return {
...state,
status: action.payload,
};
case 'unset':
return {
...state,
status: undefined,
};
default:
return state;
}
};
export const initialState = {
status: undefined,
};

View File

@@ -0,0 +1,19 @@
// contexts/User/index.jsx
import React from "react"
import { reducer, initialState } from "./reducer"
export const UserContext = React.createContext({
state: initialState,
dispatch: () => null
})
export const UserProvider = ({ children }) => {
const [state, dispatch] = React.useReducer(reducer, initialState)
return (
<UserContext.Provider value={[ state, dispatch ]}>
{ children }
</UserContext.Provider>
)
}

View File

@@ -0,0 +1,21 @@
export const reducer = (state, action) => {
switch (action.type) {
case 'login':
return {
...state,
user: action.payload
};
case 'logout':
return {
...state,
user: undefined
};
default:
return state;
}
};
export const initialState = {
user: undefined
};

View File

@@ -0,0 +1,13 @@
import { showError } from './utils';
import axios from 'axios';
export const API = axios.create({
baseURL: process.env.REACT_APP_SERVER ? process.env.REACT_APP_SERVER : '',
});
API.interceptors.response.use(
(response) => response,
(error) => {
showError(error);
}
);

View File

@@ -0,0 +1,10 @@
export function authHeader() {
// return authorization header with jwt token
let user = JSON.parse(localStorage.getItem('user'));
if (user && user.token) {
return { 'Authorization': 'Bearer ' + user.token };
} else {
return {};
}
}

View File

@@ -0,0 +1,13 @@
import {CHANNEL_OPTIONS} from '../constants';
let channelMap = undefined;
export function getChannelOption(channelId) {
if (channelMap === undefined) {
channelMap = {};
CHANNEL_OPTIONS.forEach((option) => {
channelMap[option.key] = option;
});
}
return channelMap[channelId];
}

View File

@@ -0,0 +1,3 @@
import { createBrowserHistory } from 'history';
export const history = createBrowserHistory();

View File

@@ -0,0 +1,4 @@
export * from './history';
export * from './auth-header';
export * from './utils';
export * from './api';

View File

@@ -0,0 +1,121 @@
import { Label, Message } from 'semantic-ui-react';
import { getChannelOption } from './helper';
import React from 'react';
export function renderText(text, limit) {
if (text.length > limit) {
return text.slice(0, limit - 3) + '...';
}
return text;
}
export function renderGroup(group) {
if (group === '') {
return <Label>default</Label>;
}
let groups = group.split(',');
groups.sort();
return (
<div
style={{
display: 'flex',
alignItems: 'center',
flexWrap: 'wrap',
gap: '2px',
rowGap: '6px',
}}
>
{groups.map((group) => {
if (group === 'vip' || group === 'pro') {
return <Label color='yellow'>{group}</Label>;
} else if (group === 'svip' || group === 'premium') {
return <Label color='red'>{group}</Label>;
}
return <Label>{group}</Label>;
})}
</div>
);
}
export function renderNumber(num) {
if (num >= 1000000000) {
return (num / 1000000000).toFixed(1) + 'B';
} else if (num >= 1000000) {
return (num / 1000000).toFixed(1) + 'M';
} else if (num >= 10000) {
return (num / 1000).toFixed(1) + 'k';
} else {
return num;
}
}
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) {
const amount = (quota / quotaPerUnit).toFixed(precision);
return t('common.quota.display_short', { amount });
}
return renderNumber(quota);
}
export function renderQuotaWithPrompt(quota, t) {
const displayInCurrency =
localStorage.getItem('display_in_currency') === 'true';
const quotaPerUnit = parseFloat(
localStorage.getItem('quota_per_unit') || '1'
);
if (displayInCurrency) {
const amount = (quota / quotaPerUnit).toFixed(2);
return ` (${t('common.quota.display', { amount })})`;
}
return '';
}
const colors = [
'red',
'orange',
'yellow',
'olive',
'green',
'teal',
'blue',
'violet',
'purple',
'pink',
'brown',
'grey',
'black',
];
export function renderColorLabel(text) {
let hash = 0;
for (let i = 0; i < text.length; i++) {
hash = text.charCodeAt(i) + ((hash << 5) - hash);
}
let index = Math.abs(hash % colors.length);
return (
<Label basic color={colors[index]}>
{text}
</Label>
);
}
export function renderChannelTip(channelId) {
let channel = getChannelOption(channelId);
if (channel === undefined || channel.tip === undefined) {
return <></>;
}
return (
<Message>
<div dangerouslySetInnerHTML={{ __html: channel.tip }}></div>
</Message>
);
}

View File

@@ -0,0 +1,217 @@
import {toast} from 'react-toastify';
import {toastConstants} from '../constants';
import React from 'react';
import {API} from './api';
const HTMLToastContent = ({ htmlContent }) => {
return <div dangerouslySetInnerHTML={{ __html: htmlContent }} />;
};
export default HTMLToastContent;
export function isAdmin() {
let user = localStorage.getItem('user');
if (!user) return false;
user = JSON.parse(user);
return user.role >= 10;
}
export function isRoot() {
let user = localStorage.getItem('user');
if (!user) return false;
user = JSON.parse(user);
return user.role >= 100;
}
export function getSystemName() {
let system_name = localStorage.getItem('system_name');
if (!system_name) return 'One API';
return system_name;
}
export function getLogo() {
let logo = localStorage.getItem('logo');
if (!logo) return '/logo.png';
return logo;
}
export function getFooterHTML() {
return localStorage.getItem('footer_html');
}
export async function copy(text) {
let okay = true;
try {
await navigator.clipboard.writeText(text);
} catch (e) {
okay = false;
console.error(e);
}
return okay;
}
export function isMobile() {
return window.innerWidth <= 600;
}
let showErrorOptions = { autoClose: toastConstants.ERROR_TIMEOUT };
let showWarningOptions = { autoClose: toastConstants.WARNING_TIMEOUT };
let showSuccessOptions = { autoClose: toastConstants.SUCCESS_TIMEOUT };
let showInfoOptions = { autoClose: toastConstants.INFO_TIMEOUT };
let showNoticeOptions = { autoClose: false };
if (isMobile()) {
showErrorOptions.position = 'top-center';
// showErrorOptions.transition = 'flip';
showSuccessOptions.position = 'top-center';
// showSuccessOptions.transition = 'flip';
showInfoOptions.position = 'top-center';
// showInfoOptions.transition = 'flip';
showNoticeOptions.position = 'top-center';
// showNoticeOptions.transition = 'flip';
}
export function showError(error) {
if (!error) return;
console.error(error);
if (error.message) {
if (error.name === 'AxiosError') {
switch (error.response.status) {
case 401:
// toast.error('错误:未登录或登录已过期,请重新登录!', showErrorOptions);
window.location.href = '/login?expired=true';
break;
case 429:
toast.error('错误:请求次数过多,请稍后再试!', showErrorOptions);
break;
case 500:
toast.error('错误:服务器内部错误,请联系管理员!', showErrorOptions);
break;
case 405:
toast.info('本站仅作演示之用,无服务端!');
break;
default:
toast.error('错误:' + error.message, showErrorOptions);
}
return;
}
toast.error('错误:' + error.message, showErrorOptions);
} else {
toast.error('错误:' + error, showErrorOptions);
}
}
export function showWarning(message) {
toast.warn(message, showWarningOptions);
}
export function showSuccess(message) {
toast.success(message, showSuccessOptions);
}
export function showInfo(message) {
toast.info(message, showInfoOptions);
}
export function showNotice(message, isHTML = false) {
if (isHTML) {
toast(<HTMLToastContent htmlContent={message} />, showNoticeOptions);
} else {
toast.info(message, showNoticeOptions);
}
}
export function openPage(url) {
window.open(url);
}
export function removeTrailingSlash(url) {
if (url.endsWith('/')) {
return url.slice(0, -1);
} else {
return url;
}
}
export function timestamp2string(timestamp) {
let date = new Date(timestamp * 1000);
let year = date.getFullYear().toString();
let month = (date.getMonth() + 1).toString();
let day = date.getDate().toString();
let hour = date.getHours().toString();
let minute = date.getMinutes().toString();
let second = date.getSeconds().toString();
if (month.length === 1) {
month = '0' + month;
}
if (day.length === 1) {
day = '0' + day;
}
if (hour.length === 1) {
hour = '0' + hour;
}
if (minute.length === 1) {
minute = '0' + minute;
}
if (second.length === 1) {
second = '0' + second;
}
return (
year + '-' + month + '-' + day + ' ' + hour + ':' + minute + ':' + second
);
}
export function downloadTextAsFile(text, filename) {
let blob = new Blob([text], { type: 'text/plain;charset=utf-8' });
let url = URL.createObjectURL(blob);
let a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
}
export const verifyJSON = (str) => {
try {
JSON.parse(str);
} catch (e) {
return false;
}
return true;
};
export function shouldShowPrompt(id) {
let prompt = localStorage.getItem(`prompt-${id}`);
return !prompt;
}
export function setPromptShown(id) {
localStorage.setItem(`prompt-${id}`, 'true');
}
let channelModels = undefined;
export async function loadChannelModels() {
const res = await API.get('/api/models');
const { success, data } = res.data;
if (!success) {
return;
}
channelModels = data;
localStorage.setItem('channel_models', JSON.stringify(data));
}
export function getChannelModels(type) {
if (channelModels !== undefined && type in channelModels) {
return channelModels[type];
}
let models = localStorage.getItem('channel_models');
if (!models) {
return [];
}
channelModels = JSON.parse(models);
if (type in channelModels) {
return channelModels[type];
}
return [];
}

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

@@ -0,0 +1,28 @@
import i18n from 'i18next';
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(LanguageDetector)
.use(initReactI18next)
.init({
fallbackLng: 'zh',
debug: process.env.NODE_ENV === 'development',
interpolation: {
escapeValue: false,
},
resources: {
zh: {
translation: zhTranslation
},
en: {
translation: enTranslation
}
}
});
export default i18n;

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.4 KiB

117
web/default/src/index.css Normal file
View File

@@ -0,0 +1,117 @@
body {
margin: 0;
padding-top: 55px;
overflow-y: scroll;
font-family: Lato, 'Helvetica Neue', Arial, Helvetica, "Microsoft YaHei", sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
scrollbar-width: none;
}
body::-webkit-scrollbar {
display: none;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace;
}
.main-content {
padding: 4px;
}
.small-icon .icon {
font-size: 1em !important;
}
.custom-footer {
font-size: 1.1em;
}
@media only screen and (max-width: 600px) {
.hide-on-mobile {
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;
}
}

32
web/default/src/index.js Normal file
View File

@@ -0,0 +1,32 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import { Container } from 'semantic-ui-react';
import App from './App';
import Header from './components/Header';
import Footer from './components/Footer';
import 'semantic-ui-css/semantic.min.css';
import './index.css';
import { UserProvider } from './context/User';
import { ToastContainer } from 'react-toastify';
import 'react-toastify/dist/ReactToastify.css';
import { StatusProvider } from './context/Status';
import './i18n';
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
<React.StrictMode>
<StatusProvider>
<UserProvider>
<BrowserRouter>
<Header />
<Container className={'main-content'}>
<App />
</Container>
<ToastContainer />
<Footer />
</BrowserRouter>
</UserProvider>
</StatusProvider>
</React.StrictMode>
);

View File

@@ -0,0 +1,831 @@
{
"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",
"proxy_url": "Proxy",
"proxy_url_placeholder": "This is optional and used for API calls via a proxy. Please enter the proxy URL, formatted as: https://domain.com",
"base_url": "Base URL",
"base_url_placeholder": "The Base URL required by the OpenAPI SDK",
"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,827 @@
{
"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": "此项可选,用于强制设置给定的系统提示词,请配合自定义模型 & 模型重定向使用,首先创建一个唯一的自定义模型名称并在上面填入,之后将该自定义模型重定向映射到该渠道一个原生支持的模型",
"proxy_url": "代理",
"proxy_url_placeholder": "此项可选,用于通过代理站来进行 API 调用请输入代理站地址格式为https://domain.com。注意这里所需要填入的代理地址仅会在实际请求时替换域名部分如果你想填入 OpenAI SDK 中所要求的 Base URL请使用 OpenAI 兼容渠道类型",
"base_url": "Base URL",
"base_url_placeholder": "OpenAPI SDK 中所要求的 Base URL",
"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

@@ -0,0 +1,74 @@
import React, { useEffect, useState } from '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);
const displayAbout = async () => {
setAbout(localStorage.getItem('about') || '');
const res = await API.get('/api/about');
const { success, message, data } = res.data;
if (success) {
let aboutContent = data;
if (!data.startsWith('https://')) {
aboutContent = marked.parse(data);
}
setAbout(aboutContent);
localStorage.setItem('about', aboutContent);
} else {
showError(message);
setAbout(t('about.loading_failed'));
}
setAboutLoaded(true);
};
useEffect(() => {
displayAbout().then();
}, []);
return (
<>
{aboutLoaded && about === '' ? (
<div className='dashboard-container'>
<Card fluid className='chart-card'>
<Card.Content>
<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>
</div>
) : (
<>
{about.startsWith('https://') ? (
<iframe
src={about}
style={{ width: '100%', height: '100vh', border: 'none' }}
/>
) : (
<div className='dashboard-container'>
<Card fluid className='chart-card'>
<Card.Content>
<div
style={{ fontSize: 'larger' }}
dangerouslySetInnerHTML={{ __html: about }}
></div>
</Card.Content>
</Card>
</div>
)}
</>
)}
</>
);
};
export default About;

View File

@@ -0,0 +1,698 @@
import React, {useEffect, useState} from 'react';
import {useTranslation} from 'react-i18next';
import {Button, Card, Form, Input, Message} from 'semantic-ui-react';
import {useNavigate, useParams} from 'react-router-dom';
import {API, copy, getChannelModels, showError, showInfo, showSuccess, verifyJSON,} from '../../helpers';
import {CHANNEL_OPTIONS} from '../../constants';
import {renderChannelTip} from '../../helpers/render';
const MODEL_MAPPING_EXAMPLE = {
'gpt-3.5-turbo-0301': 'gpt-3.5-turbo',
'gpt-4-0314': 'gpt-4',
'gpt-4-32k-0314': 'gpt-4-32k',
};
function type2secretPrompt(type, t) {
switch (type) {
case 15:
return t('channel.edit.key_prompts.zhipu');
case 18:
return t('channel.edit.key_prompts.spark');
case 22:
return t('channel.edit.key_prompts.fastgpt');
case 23:
return t('channel.edit.key_prompts.tencent');
default:
return t('channel.edit.key_prompts.default');
}
}
const EditChannel = () => {
const { t } = useTranslation();
const params = useParams();
const navigate = useNavigate();
const channelId = params.id;
const isEdit = channelId !== undefined;
const [loading, setLoading] = useState(isEdit);
const handleCancel = () => {
navigate('/channel');
};
const originInputs = {
name: '',
type: 1,
key: '',
base_url: '',
other: '',
model_mapping: '',
system_prompt: '',
models: [],
groups: ['default'],
};
const [batch, setBatch] = useState(false);
const [inputs, setInputs] = useState(originInputs);
const [originModelOptions, setOriginModelOptions] = useState([]);
const [modelOptions, setModelOptions] = useState([]);
const [groupOptions, setGroupOptions] = useState([]);
const [basicModels, setBasicModels] = useState([]);
const [fullModels, setFullModels] = useState([]);
const [customModel, setCustomModel] = useState('');
const [config, setConfig] = useState({
region: '',
sk: '',
ak: '',
user_id: '',
vertex_ai_project_id: '',
vertex_ai_adc: '',
});
const handleInputChange = (e, { name, value }) => {
setInputs((inputs) => ({ ...inputs, [name]: value }));
if (name === 'type') {
let localModels = getChannelModels(value);
if (inputs.models.length === 0) {
setInputs((inputs) => ({ ...inputs, models: localModels }));
}
setBasicModels(localModels);
}
};
const handleConfigChange = (e, { name, value }) => {
setConfig((inputs) => ({ ...inputs, [name]: value }));
};
const loadChannel = async () => {
let res = await API.get(`/api/channel/${channelId}`);
const { success, message, data } = res.data;
if (success) {
if (data.models === '') {
data.models = [];
} else {
data.models = data.models.split(',');
}
if (data.group === '') {
data.groups = [];
} else {
data.groups = data.group.split(',');
}
if (data.model_mapping !== '') {
data.model_mapping = JSON.stringify(
JSON.parse(data.model_mapping),
null,
2
);
}
setInputs(data);
if (data.config !== '') {
setConfig(JSON.parse(data.config));
}
setBasicModels(getChannelModels(data.type));
} else {
showError(message);
}
setLoading(false);
};
const fetchModels = async () => {
try {
let res = await API.get(`/api/channel/models`);
let localModelOptions = res.data.data.map((model) => ({
key: model.id,
text: model.id,
value: model.id,
}));
setOriginModelOptions(localModelOptions);
setFullModels(res.data.data.map((model) => model.id));
} catch (error) {
showError(error.message);
}
};
const fetchGroups = async () => {
try {
let res = await API.get(`/api/group/`);
setGroupOptions(
res.data.data.map((group) => ({
key: group,
text: group,
value: group,
}))
);
} catch (error) {
showError(error.message);
}
};
useEffect(() => {
let localModelOptions = [...originModelOptions];
inputs.models.forEach((model) => {
if (!localModelOptions.find((option) => option.key === model)) {
localModelOptions.push({
key: model,
text: model,
value: model,
});
}
});
setModelOptions(localModelOptions);
}, [originModelOptions, inputs.models]);
useEffect(() => {
if (isEdit) {
loadChannel().then();
} else {
let localModels = getChannelModels(inputs.type);
setBasicModels(localModels);
}
fetchModels().then();
fetchGroups().then();
}, []);
const submit = async () => {
if (inputs.key === '') {
if (config.ak !== '' && config.sk !== '' && config.region !== '') {
inputs.key = `${config.ak}|${config.sk}|${config.region}`;
} else if (
config.region !== '' &&
config.vertex_ai_project_id !== '' &&
config.vertex_ai_adc !== ''
) {
inputs.key = `${config.region}|${config.vertex_ai_project_id}|${config.vertex_ai_adc}`;
}
}
if (!isEdit && (inputs.name === '' || inputs.key === '')) {
showInfo(t('channel.edit.messages.name_required'));
return;
}
if (inputs.type !== 43 && inputs.models.length === 0) {
showInfo(t('channel.edit.messages.models_required'));
return;
}
if (inputs.model_mapping !== '' && !verifyJSON(inputs.model_mapping)) {
showInfo(t('channel.edit.messages.model_mapping_invalid'));
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,
localInputs.base_url.length - 1
);
}
if (localInputs.type === 3 && localInputs.other === '') {
localInputs.other = '2024-03-01-preview';
}
let res;
localInputs.models = localInputs.models.join(',');
localInputs.group = localInputs.groups.join(',');
localInputs.config = JSON.stringify(config);
if (isEdit) {
res = await API.put(`/api/channel/`, {
...localInputs,
id: parseInt(channelId),
});
} else {
res = await API.post(`/api/channel/`, localInputs);
}
const { success, message } = res.data;
if (success) {
if (isEdit) {
showSuccess(t('channel.edit.messages.update_success'));
} else {
showSuccess(t('channel.edit.messages.create_success'));
setInputs(originInputs);
}
} else {
showError(message);
}
};
const addCustomModel = () => {
if (customModel.trim() === '') return;
if (inputs.models.includes(customModel)) return;
let localModels = [...inputs.models];
localModels.push(customModel);
let localModelOptions = [];
localModelOptions.push({
key: customModel,
text: customModel,
value: customModel,
});
setModelOptions((modelOptions) => {
return [...modelOptions, ...localModelOptions];
});
setCustomModel('');
handleInputChange(null, { name: 'models', value: localModels });
};
return (
<div className='dashboard-container'>
<Card fluid className='chart-card'>
<Card.Content>
<Card.Header className='header'>
{isEdit
? t('channel.edit.title_edit')
: t('channel.edit.title_create')}
</Card.Header>
<Form loading={loading} autoComplete='new-password'>
<Form.Field>
<Form.Select
label={t('channel.edit.type')}
name='type'
required
search
options={CHANNEL_OPTIONS}
value={inputs.type}
onChange={handleInputChange}
/>
</Form.Field>
<Form.Field>
<Form.Input
label={t('channel.edit.name')}
name='name'
placeholder={t('channel.edit.name_placeholder')}
onChange={handleInputChange}
value={inputs.name}
required
/>
</Form.Field>
<Form.Field>
<Form.Dropdown
label={t('channel.edit.group')}
placeholder={t('channel.edit.group_placeholder')}
name='groups'
required
fluid
multiple
selection
allowAdditions
additionLabel={t('channel.edit.group_addition')}
onChange={handleInputChange}
value={inputs.groups}
autoComplete='new-password'
options={groupOptions}
/>
</Form.Field>
{renderChannelTip(inputs.type)}
{/* Azure OpenAI specific fields */}
{inputs.type === 3 && (
<>
<Message>
注意<strong>模型部署名称必须和模型名称保持一致</strong>
因为 One API 会把请求体中的 model
参数替换为你的部署名称模型名称中的点会被剔除
<a
target='_blank'
href='https://github.com/songquanpeng/one-api/issues/133?notification_referrer_id=NT_kwDOAmJSYrM2NjIwMzI3NDgyOjM5OTk4MDUw#issuecomment-1571602271'
>
图片演示
</a>
</Message>
<Form.Field>
<Form.Input
label='AZURE_OPENAI_ENDPOINT'
name='base_url'
placeholder='请输入 AZURE_OPENAI_ENDPOINT例如https://docs-test-001.openai.azure.com'
onChange={handleInputChange}
value={inputs.base_url}
autoComplete='new-password'
/>
</Form.Field>
<Form.Field>
<Form.Input
label='默认 API 版本'
name='other'
placeholder='请输入默认 API 版本例如2024-03-01-preview该配置可以被实际的请求查询参数所覆盖'
onChange={handleInputChange}
value={inputs.other}
autoComplete='new-password'
/>
</Form.Field>
</>
)}
{/* Custom base URL field */}
{inputs.type === 8 && (
<Form.Field>
<Form.Input
required
label={t('channel.edit.proxy_url')}
name='base_url'
placeholder={t('channel.edit.proxy_url_placeholder')}
onChange={handleInputChange}
value={inputs.base_url}
autoComplete='new-password'
/>
</Form.Field>
)}
{inputs.type === 50 && (
<Form.Field>
<Form.Input
required
label={t('channel.edit.base_url')}
name='base_url'
placeholder={t('channel.edit.base_url_placeholder')}
onChange={handleInputChange}
value={inputs.base_url}
autoComplete='new-password'
/>
</Form.Field>
)}
{inputs.type === 18 && (
<Form.Field>
<Form.Input
label={t('channel.edit.spark_version')}
name='other'
placeholder={t('channel.edit.spark_version_placeholder')}
onChange={handleInputChange}
value={inputs.other}
autoComplete='new-password'
/>
</Form.Field>
)}
{inputs.type === 21 && (
<Form.Field>
<Form.Input
label={t('channel.edit.knowledge_id')}
name='other'
placeholder={t('channel.edit.knowledge_id_placeholder')}
onChange={handleInputChange}
value={inputs.other}
autoComplete='new-password'
/>
</Form.Field>
)}
{inputs.type === 17 && (
<Form.Field>
<Form.Input
label={t('channel.edit.plugin_param')}
name='other'
placeholder={t('channel.edit.plugin_param_placeholder')}
onChange={handleInputChange}
value={inputs.other}
autoComplete='new-password'
/>
</Form.Field>
)}
{inputs.type === 34 && (
<Message>{t('channel.edit.coze_notice')}</Message>
)}
{inputs.type === 40 && (
<Message>
{t('channel.edit.douban_notice')}
<a
target='_blank'
href='https://console.volcengine.com/ark/region:ark+cn-beijing/endpoint'
>
{t('channel.edit.douban_notice_link')}
</a>
{t('channel.edit.douban_notice_2')}
</Message>
)}
{inputs.type !== 43 && (
<Form.Field>
<Form.Dropdown
label={t('channel.edit.models')}
placeholder={t('channel.edit.models_placeholder')}
name='models'
required
fluid
multiple
search
onLabelClick={(e, { value }) => {
copy(value).then();
}}
selection
onChange={handleInputChange}
value={inputs.models}
autoComplete='new-password'
options={modelOptions}
/>
</Form.Field>
)}
{inputs.type !== 43 && (
<div style={{ lineHeight: '40px', marginBottom: '12px' }}>
<Button
type={'button'}
onClick={() => {
handleInputChange(null, {
name: 'models',
value: basicModels,
});
}}
>
{t('channel.edit.buttons.fill_models')}
</Button>
<Button
type={'button'}
onClick={() => {
handleInputChange(null, {
name: 'models',
value: fullModels,
});
}}
>
{t('channel.edit.buttons.fill_all')}
</Button>
<Button
type={'button'}
onClick={() => {
handleInputChange(null, { name: 'models', value: [] });
}}
>
{t('channel.edit.buttons.clear')}
</Button>
<Input
action={
<Button type={'button'} onClick={addCustomModel}>
{t('channel.edit.buttons.add_custom')}
</Button>
}
placeholder={t('channel.edit.buttons.custom_placeholder')}
value={customModel}
onChange={(e, { value }) => {
setCustomModel(value);
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
addCustomModel();
e.preventDefault();
}
}}
/>
</div>
)}
{inputs.type !== 43 && (
<>
<Form.Field>
<Form.TextArea
label={t('channel.edit.model_mapping')}
placeholder={`${t(
'channel.edit.model_mapping_placeholder'
)}\n${JSON.stringify(MODEL_MAPPING_EXAMPLE, null, 2)}`}
name='model_mapping'
onChange={handleInputChange}
value={inputs.model_mapping}
style={{
minHeight: 150,
fontFamily: 'JetBrains Mono, Consolas',
}}
autoComplete='new-password'
/>
</Form.Field>
<Form.Field>
<Form.TextArea
label={t('channel.edit.system_prompt')}
placeholder={t('channel.edit.system_prompt_placeholder')}
name='system_prompt'
onChange={handleInputChange}
value={inputs.system_prompt}
style={{
minHeight: 150,
fontFamily: 'JetBrains Mono, Consolas',
}}
autoComplete='new-password'
/>
</Form.Field>
</>
)}
{inputs.type === 33 && (
<Form.Field>
<Form.Input
label='Region'
name='region'
required
placeholder={t('channel.edit.aws_region_placeholder')}
onChange={handleConfigChange}
value={config.region}
autoComplete=''
/>
<Form.Input
label='AK'
name='ak'
required
placeholder={t('channel.edit.aws_ak_placeholder')}
onChange={handleConfigChange}
value={config.ak}
autoComplete=''
/>
<Form.Input
label='SK'
name='sk'
required
placeholder={t('channel.edit.aws_sk_placeholder')}
onChange={handleConfigChange}
value={config.sk}
autoComplete=''
/>
</Form.Field>
)}
{inputs.type === 42 && (
<Form.Field>
<Form.Input
label='Region'
name='region'
required
placeholder={t('channel.edit.vertex_region_placeholder')}
onChange={handleConfigChange}
value={config.region}
autoComplete=''
/>
<Form.Input
label={t('channel.edit.vertex_project_id')}
name='vertex_ai_project_id'
required
placeholder={t('channel.edit.vertex_project_id_placeholder')}
onChange={handleConfigChange}
value={config.vertex_ai_project_id}
autoComplete=''
/>
<Form.Input
label={t('channel.edit.vertex_credentials')}
name='vertex_ai_adc'
required
placeholder={t('channel.edit.vertex_credentials_placeholder')}
onChange={handleConfigChange}
value={config.vertex_ai_adc}
autoComplete=''
/>
</Form.Field>
)}
{inputs.type === 34 && (
<Form.Input
label={t('channel.edit.user_id')}
name='user_id'
required
placeholder={t('channel.edit.user_id_placeholder')}
onChange={handleConfigChange}
value={config.user_id}
autoComplete=''
/>
)}
{inputs.type !== 33 &&
inputs.type !== 42 &&
(batch ? (
<Form.Field>
<Form.TextArea
label={t('channel.edit.key')}
name='key'
required
placeholder={t('channel.edit.batch_placeholder')}
onChange={handleInputChange}
value={inputs.key}
style={{
minHeight: 150,
fontFamily: 'JetBrains Mono, Consolas',
}}
autoComplete='new-password'
/>
</Form.Field>
) : (
<Form.Field>
<Form.Input
label={t('channel.edit.key')}
name='key'
required
placeholder={type2secretPrompt(inputs.type, t)}
onChange={handleInputChange}
value={inputs.key}
autoComplete='new-password'
/>
</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}
label={t('channel.edit.batch')}
name='batch'
onChange={() => setBatch(!batch)}
/>
)}
{inputs.type !== 3 &&
inputs.type !== 33 &&
inputs.type !== 8 &&
inputs.type !== 50 &&
inputs.type !== 22 && (
<Form.Field>
<Form.Input
label={t('channel.edit.proxy_url')}
name='base_url'
placeholder={t('channel.edit.proxy_url_placeholder')}
onChange={handleInputChange}
value={inputs.base_url}
autoComplete='new-password'
/>
</Form.Field>
)}
{inputs.type === 22 && (
<Form.Field>
<Form.Input
label='私有部署地址'
name='base_url'
placeholder={
'请输入私有部署地址格式为https://fastgpt.run/api/openapi'
}
onChange={handleInputChange}
value={inputs.base_url}
autoComplete='new-password'
/>
</Form.Field>
)}
<Button onClick={handleCancel}>
{t('channel.edit.buttons.cancel')}
</Button>
<Button
type={isEdit ? 'button' : 'submit'}
positive
onClick={submit}
>
{t('channel.edit.buttons.submit')}
</Button>
</Form>
</Card.Content>
</Card>
</div>
);
};
export default EditChannel;

View File

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

View File

@@ -0,0 +1,15 @@
import React from 'react';
const Chat = () => {
const chatLink = localStorage.getItem('chat_link');
return (
<iframe
src={chatLink}
style={{ width: '100%', height: '85vh', border: 'none' }}
/>
);
};
export default Chat;

View File

@@ -0,0 +1,109 @@
.dashboard-container {
padding: 20px 24px 40px;
background-color: #ffffff;
margin-top: -15px; /* 减小与导航栏的间距 */
max-width: 1600px; /* 设置最大宽度 */
margin-left: auto; /* 水平居中 */
margin-right: auto;
}
.stat-card {
background: linear-gradient(135deg, #2185d0 0%, #1678c2 100%) !important;
color: white !important;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1) !important;
transition: transform 0.2s ease !important;
margin-bottom: 1rem !important;
}
.stat-card:hover {
transform: translateY(-5px);
}
.stat-card .statistic {
color: white !important;
}
.charts-grid {
margin-bottom: 1rem !important;
}
.charts-grid .column {
padding: 0.5rem !important;
}
.chart-card {
height: 100%;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04) !important;
border: none !important;
border-radius: 16px !important;
padding: 8px!important;
}
.chart-container {
margin-top: 2px;
padding: 16px;
background-color: white;
border-radius: 12px;
}
.ui.card > .content > .header {
color: #2B3674;
font-size: 1.2em;
margin-bottom: 15px;
display: flex;
justify-content: space-between;
align-items: center;
font-weight: 600;
gap: 12px; /* 增加标题和数值之间的间距 */
}
.stat-value {
color: #4318FF;
font-weight: bold;
font-size: 1.1em;
background: rgba(67, 24, 255, 0.1);
padding: 4px 12px;
border-radius: 8px;
white-space: nowrap; /* 防止数值换行 */
margin-left: 16px;
}
/* 优化图表响应式布局 */
@media (max-width: 768px) {
.dashboard-container {
padding: 10px 16px; /* 移动端也相应减小内边距 */
max-width: 100%; /* 移动端占满全宽 */
}
.chart-container {
padding: 12px;
}
.charts-grid .column {
padding: 0.25rem !important;
}
}
/* 设置页面的 Tab 样式 */
.settings-tab {
margin-top: 1rem !important;
border-bottom: none !important;
}
.settings-tab .item {
color: #000 !important;
font-weight: 500 !important;
padding: 0.8rem 1.2rem !important;
}
.settings-tab .active.item {
color: #000 !important;
font-weight: 600 !important;
border-color: #000 !important;
}
.ui.tab.segment {
border: none !important;
box-shadow: none !important;
padding: 1rem 0 !important;
}

View File

@@ -0,0 +1,460 @@
import React, {useEffect, useState} from 'react';
import {useTranslation} from 'react-i18next';
import {Card, Grid} from 'semantic-ui-react';
import {
Bar,
BarChart,
CartesianGrid,
Legend,
Line,
LineChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
import axios from 'axios';
import './Dashboard.css';
// 在 Dashboard 组件内添加自定义配置
const chartConfig = {
lineChart: {
style: {
background: '#fff',
borderRadius: '8px',
},
line: {
strokeWidth: 2,
dot: false,
activeDot: { r: 4 },
},
grid: {
vertical: false,
horizontal: true,
opacity: 0.1,
},
},
colors: {
requests: '#4318FF',
quota: '#00B5D8',
tokens: '#6C63FF',
},
barColors: [
'#4318FF', // 深紫色
'#00B5D8', // 青色
'#6C63FF', // 紫色
'#05CD99', // 绿色
'#FFB547', // 橙色
'#FF5E7D', // 粉色
'#41B883', // 翠绿
'#7983FF', // 淡紫
'#FF8F6B', // 珊瑚色
'#49BEFF', // 天蓝
],
};
const Dashboard = () => {
const { t } = useTranslation();
const [data, setData] = useState([]);
const [summaryData, setSummaryData] = useState({
todayRequests: 0,
todayQuota: 0,
todayTokens: 0,
});
useEffect(() => {
fetchDashboardData();
}, []);
const fetchDashboardData = async () => {
try {
const response = await axios.get('/api/user/dashboard');
if (response.data.success) {
const dashboardData = response.data.data || [];
setData(dashboardData);
calculateSummary(dashboardData);
}
} catch (error) {
console.error('Failed to fetch dashboard data:', error);
setData([]);
calculateSummary([]);
}
};
const calculateSummary = (dashboardData) => {
if (!Array.isArray(dashboardData) || dashboardData.length === 0) {
setSummaryData({
todayRequests: 0,
todayQuota: 0,
todayTokens: 0,
});
return;
}
const today = new Date().toISOString().split('T')[0];
const todayData = dashboardData.filter((item) => item.Day === today);
const summary = {
todayRequests: todayData.reduce(
(sum, item) => sum + item.RequestCount,
0
),
todayQuota:
todayData.reduce((sum, item) => sum + item.Quota, 0) / 1000000,
todayTokens: todayData.reduce(
(sum, item) => sum + item.PromptTokens + item.CompletionTokens,
0
),
};
setSummaryData(summary);
};
// 处理数据以供折线图使用,补充缺失的日期
const processTimeSeriesData = () => {
const dailyData = {};
// 获取日期范围
const dates = data.map((item) => item.Day);
const maxDate = new Date(); // 总是使用今天作为最后一天
let minDate =
dates.length > 0
? new Date(Math.min(...dates.map((d) => new Date(d))))
: new Date();
// 确保至少显示7天的数据
const sevenDaysAgo = new Date();
sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 6); // -6是因为包含今天
if (minDate > sevenDaysAgo) {
minDate = sevenDaysAgo;
}
// 生成所有日期
for (let d = new Date(minDate); d <= maxDate; d.setDate(d.getDate() + 1)) {
const dateStr = d.toISOString().split('T')[0];
dailyData[dateStr] = {
date: dateStr,
requests: 0,
quota: 0,
tokens: 0,
};
}
// 填充实际数据
data.forEach((item) => {
dailyData[item.Day].requests += item.RequestCount;
dailyData[item.Day].quota += item.Quota / 1000000;
dailyData[item.Day].tokens += item.PromptTokens + item.CompletionTokens;
});
return Object.values(dailyData).sort((a, b) =>
a.date.localeCompare(b.date)
);
};
// 处理数据以供堆叠柱状图使用
const processModelData = () => {
const timeData = {};
// 获取日期范围
const dates = data.map((item) => item.Day);
const maxDate = new Date(); // 总是使用今天作为最后一天
let minDate =
dates.length > 0
? new Date(Math.min(...dates.map((d) => new Date(d))))
: new Date();
// 确保至少显示7天的数据
const sevenDaysAgo = new Date();
sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 6); // -6是因为包含今天
if (minDate > sevenDaysAgo) {
minDate = sevenDaysAgo;
}
// 生成所有日期
for (let d = new Date(minDate); d <= maxDate; d.setDate(d.getDate() + 1)) {
const dateStr = d.toISOString().split('T')[0];
timeData[dateStr] = {
date: dateStr,
};
// 初始化所有模型的数据为0
const models = [...new Set(data.map((item) => item.ModelName))];
models.forEach((model) => {
timeData[dateStr][model] = 0;
});
}
// 填充实际数据
data.forEach((item) => {
timeData[item.Day][item.ModelName] =
item.PromptTokens + item.CompletionTokens;
});
return Object.values(timeData).sort((a, b) => a.date.localeCompare(b.date));
};
// 获取所有唯一的模型名称
const getUniqueModels = () => {
return [...new Set(data.map((item) => item.ModelName))];
};
const timeSeriesData = processTimeSeriesData();
const modelData = processModelData();
const models = getUniqueModels();
// 生成随机颜色
const getRandomColor = (index) => {
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'>
{/* 三个并排的折线图 */}
<Grid columns={3} stackable className='charts-grid'>
<Grid.Column>
<Card fluid className='chart-card'>
<Card.Content>
<Card.Header>
{t('dashboard.charts.requests.title')}
{/* <span className='stat-value'>{summaryData.todayRequests}</span> */}
</Card.Header>
<div className='chart-container'>
<ResponsiveContainer
width='100%'
height={120}
margin={{ left: 10, right: 10 }} // 调整容器边距
>
<LineChart data={timeSeriesData}>
<CartesianGrid
strokeDasharray='3 3'
vertical={chartConfig.lineChart.grid.vertical}
horizontal={chartConfig.lineChart.grid.horizontal}
opacity={chartConfig.lineChart.grid.opacity}
/>
<XAxis {...xAxisConfig} />
<YAxis hide={true} />
<Tooltip
contentStyle={{
background: '#fff',
border: 'none',
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'
dataKey='requests'
stroke={chartConfig.colors.requests}
strokeWidth={chartConfig.lineChart.line.strokeWidth}
dot={chartConfig.lineChart.line.dot}
activeDot={chartConfig.lineChart.line.activeDot}
/>
</LineChart>
</ResponsiveContainer>
</div>
</Card.Content>
</Card>
</Grid.Column>
<Grid.Column>
<Card fluid className='chart-card'>
<Card.Content>
<Card.Header>
{t('dashboard.charts.quota.title')}
{/* <span className='stat-value'>
${summaryData.todayQuota.toFixed(3)}
</span> */}
</Card.Header>
<div className='chart-container'>
<ResponsiveContainer
width='100%'
height={120}
margin={{ left: 10, right: 10 }} // 调整容器边距
>
<LineChart data={timeSeriesData}>
<CartesianGrid
strokeDasharray='3 3'
vertical={chartConfig.lineChart.grid.vertical}
horizontal={chartConfig.lineChart.grid.horizontal}
opacity={chartConfig.lineChart.grid.opacity}
/>
<XAxis {...xAxisConfig} />
<YAxis hide={true} />
<Tooltip
contentStyle={{
background: '#fff',
border: 'none',
borderRadius: '4px',
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
}}
formatter={(value) => [
value.toFixed(6),
t('dashboard.charts.quota.tooltip'),
]}
labelFormatter={(label) =>
`${t(
'dashboard.statistics.tooltip.date'
)}: ${formatDate(label)}`
}
/>
<Line
type='monotone'
dataKey='quota'
stroke={chartConfig.colors.quota}
strokeWidth={chartConfig.lineChart.line.strokeWidth}
dot={chartConfig.lineChart.line.dot}
activeDot={chartConfig.lineChart.line.activeDot}
/>
</LineChart>
</ResponsiveContainer>
</div>
</Card.Content>
</Card>
</Grid.Column>
<Grid.Column>
<Card fluid className='chart-card'>
<Card.Content>
<Card.Header>
{t('dashboard.charts.tokens.title')}
{/* <span className='stat-value'>{summaryData.todayTokens}</span> */}
</Card.Header>
<div className='chart-container'>
<ResponsiveContainer
width='100%'
height={120}
margin={{ left: 10, right: 10 }} // 调整容器边距
>
<LineChart data={timeSeriesData}>
<CartesianGrid
strokeDasharray='3 3'
vertical={chartConfig.lineChart.grid.vertical}
horizontal={chartConfig.lineChart.grid.horizontal}
opacity={chartConfig.lineChart.grid.opacity}
/>
<XAxis {...xAxisConfig} />
<YAxis hide={true} />
<Tooltip
contentStyle={{
background: '#fff',
border: 'none',
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'
dataKey='tokens'
stroke={chartConfig.colors.tokens}
strokeWidth={chartConfig.lineChart.line.strokeWidth}
dot={chartConfig.lineChart.line.dot}
activeDot={chartConfig.lineChart.line.activeDot}
/>
</LineChart>
</ResponsiveContainer>
</div>
</Card.Content>
</Card>
</Grid.Column>
</Grid>
{/* 模型使用统计 */}
<Card fluid className='chart-card'>
<Card.Content>
<Card.Header>{t('dashboard.statistics.title')}</Card.Header>
<div className='chart-container'>
<ResponsiveContainer width='100%' height={300}>
<BarChart data={modelData}>
<CartesianGrid
strokeDasharray='3 3'
vertical={false}
opacity={0.1}
/>
<XAxis {...xAxisConfig} />
<YAxis
axisLine={false}
tickLine={false}
tick={{ fontSize: 12, fill: '#A3AED0' }}
/>
<Tooltip
contentStyle={{
background: '#fff',
border: 'none',
borderRadius: '4px',
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
}}
labelFormatter={(label) =>
`${t('dashboard.statistics.tooltip.date')}: ${formatDate(
label
)}`
}
/>
<Legend
wrapperStyle={{
paddingTop: '20px',
}}
/>
{models.map((model, index) => (
<Bar
key={model}
dataKey={model}
stackId='a'
fill={getRandomColor(index)}
name={model}
radius={[4, 4, 0, 0]}
/>
))}
</BarChart>
</ResponsiveContainer>
</div>
</Card.Content>
</Card>
</div>
);
};
export default Dashboard;

View File

@@ -0,0 +1,299 @@
import React, { useContext, useEffect, useState } from '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';
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('');
const [userState] = useContext(UserContext);
const displayNotice = async () => {
const res = await API.get('/api/notice');
const { success, message, data } = res.data;
if (success) {
let oldNotice = localStorage.getItem('notice');
if (data !== oldNotice && data !== '') {
const htmlNotice = marked(data);
showNotice(htmlNotice, true);
localStorage.setItem('notice', data);
}
} else {
showError(message);
}
};
const displayHomePageContent = async () => {
setHomePageContent(localStorage.getItem('home_page_content') || '');
const res = await API.get('/api/home_page_content');
const { success, message, data } = res.data;
if (success) {
let content = data;
if (!data.startsWith('https://')) {
content = marked.parse(data);
}
setHomePageContent(content);
localStorage.setItem('home_page_content', content);
} else {
showError(message);
setHomePageContent(t('home.loading_failed'));
}
setHomePageContentLoaded(true);
};
const getStartTimeString = () => {
const timestamp = statusState?.status?.start_time;
return timestamp2string(timestamp);
};
useEffect(() => {
displayNotice().then();
displayHomePageContent().then();
}, []);
return (
<>
{homePageContentLoaded && homePageContent === '' ? (
<div className='dashboard-container'>
<Card fluid className='chart-card'>
<Card.Content>
<Card.Header className='header'>
{t('home.welcome.title')}
</Card.Header>
<Card.Description style={{ lineHeight: '1.6' }}>
<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'>{t('home.system_status.title')}</Header>
</Card.Header>
<Grid columns={2} stackable>
<Grid.Column>
<Card
fluid
className='chart-card'
style={{ boxShadow: '0 1px 3px rgba(0,0,0,0.12)' }}
>
<Card.Content>
<Card.Header>
<Header as='h3' style={{ color: '#444' }}>
{t('home.system_status.info.title')}
</Header>
</Card.Header>
<Card.Description
style={{ lineHeight: '2', marginTop: '1em' }}
>
<p
style={{
display: 'flex',
alignItems: 'center',
gap: '0.5em',
}}
>
<i className='info circle icon'></i>
<span style={{ fontWeight: 'bold' }}>
{t('home.system_status.info.name')}
</span>
<span>{statusState?.status?.system_name}</span>
</p>
<p
style={{
display: 'flex',
alignItems: 'center',
gap: '0.5em',
}}
>
<i className='code branch icon'></i>
<span style={{ fontWeight: 'bold' }}>
{t('home.system_status.info.version')}
</span>
<span>
{statusState?.status?.version || 'unknown'}
</span>
</p>
<p
style={{
display: 'flex',
alignItems: 'center',
gap: '0.5em',
}}
>
<i className='github icon'></i>
<span style={{ fontWeight: 'bold' }}>
{t('home.system_status.info.source')}
</span>
<a
href='https://github.com/songquanpeng/one-api'
target='_blank'
style={{ color: '#2185d0' }}
>
{t('home.system_status.info.source_link')}
</a>
</p>
<p
style={{
display: 'flex',
alignItems: 'center',
gap: '0.5em',
}}
>
<i className='clock outline icon'></i>
<span style={{ fontWeight: 'bold' }}>
{t('home.system_status.info.start_time')}
</span>
<span>{getStartTimeString()}</span>
</p>
</Card.Description>
</Card.Content>
</Card>
</Grid.Column>
<Grid.Column>
<Card
fluid
className='chart-card'
style={{ boxShadow: '0 1px 3px rgba(0,0,0,0.12)' }}
>
<Card.Content>
<Card.Header>
<Header as='h3' style={{ color: '#444' }}>
{t('home.system_status.config.title')}
</Header>
</Card.Header>
<Card.Description
style={{ lineHeight: '2', marginTop: '1em' }}
>
<p
style={{
display: 'flex',
alignItems: 'center',
gap: '0.5em',
}}
>
<i className='envelope icon'></i>
<span style={{ fontWeight: 'bold' }}>
{t('home.system_status.config.email_verify')}
</span>
<span
style={{
color: statusState?.status?.email_verification
? '#21ba45'
: '#db2828',
fontWeight: '500',
}}
>
{statusState?.status?.email_verification
? t('home.system_status.config.enabled')
: t('home.system_status.config.disabled')}
</span>
</p>
<p
style={{
display: 'flex',
alignItems: 'center',
gap: '0.5em',
}}
>
<i className='github icon'></i>
<span style={{ fontWeight: 'bold' }}>
{t('home.system_status.config.github_oauth')}
</span>
<span
style={{
color: statusState?.status?.github_oauth
? '#21ba45'
: '#db2828',
fontWeight: '500',
}}
>
{statusState?.status?.github_oauth
? t('home.system_status.config.enabled')
: t('home.system_status.config.disabled')}
</span>
</p>
<p
style={{
display: 'flex',
alignItems: 'center',
gap: '0.5em',
}}
>
<i className='wechat icon'></i>
<span style={{ fontWeight: 'bold' }}>
{t('home.system_status.config.wechat_login')}
</span>
<span
style={{
color: statusState?.status?.wechat_login
? '#21ba45'
: '#db2828',
fontWeight: '500',
}}
>
{statusState?.status?.wechat_login
? t('home.system_status.config.enabled')
: t('home.system_status.config.disabled')}
</span>
</p>
<p
style={{
display: 'flex',
alignItems: 'center',
gap: '0.5em',
}}
>
<i className='shield alternate icon'></i>
<span style={{ fontWeight: 'bold' }}>
{t('home.system_status.config.turnstile')}
</span>
<span
style={{
color: statusState?.status?.turnstile_check
? '#21ba45'
: '#db2828',
fontWeight: '500',
}}
>
{statusState?.status?.turnstile_check
? t('home.system_status.config.enabled')
: t('home.system_status.config.disabled')}
</span>
</p>
</Card.Description>
</Card.Content>
</Card>
</Grid.Column>
</Grid>
</Card.Content>
</Card>
</div>
) : (
<>
{homePageContent.startsWith('https://') ? (
<iframe
src={homePageContent}
style={{ width: '100%', height: '100vh', border: 'none' }}
/>
) : (
<div
style={{ fontSize: 'larger' }}
dangerouslySetInnerHTML={{ __html: homePageContent }}
></div>
)}
</>
)}
</>
);
};
export default Home;

View File

@@ -0,0 +1,21 @@
import React from 'react';
import { Card } from 'semantic-ui-react';
import { useTranslation } from 'react-i18next';
import LogsTable from '../../components/LogsTable';
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

@@ -0,0 +1,13 @@
import React from 'react';
import { Message } from 'semantic-ui-react';
const NotFound = () => (
<>
<Message negative>
<Message.Header>页面不存在</Message.Header>
<p>请检查你的浏览器地址是否正确</p>
</Message>
</>
);
export default NotFound;

View File

@@ -0,0 +1,141 @@
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;
const isEdit = redemptionId !== undefined;
const [loading, setLoading] = useState(isEdit);
const originInputs = {
name: '',
quota: 100000,
count: 1,
};
const [inputs, setInputs] = useState(originInputs);
const { name, quota, count } = inputs;
const handleCancel = () => {
navigate('/redemption');
};
const handleInputChange = (e, { name, value }) => {
setInputs((inputs) => ({ ...inputs, [name]: value }));
};
const loadRedemption = async () => {
let res = await API.get(`/api/redemption/${redemptionId}`);
const { success, message, data } = res.data;
if (success) {
setInputs(data);
} else {
showError(message);
}
setLoading(false);
};
useEffect(() => {
if (isEdit) {
loadRedemption().then();
}
}, []);
const submit = async () => {
if (!isEdit && inputs.name === '') return;
let localInputs = inputs;
localInputs.count = parseInt(localInputs.count);
localInputs.quota = parseInt(localInputs.quota);
let res;
if (isEdit) {
res = await API.put(`/api/redemption/`, {
...localInputs,
id: parseInt(redemptionId),
});
} else {
res = await API.post(`/api/redemption/`, {
...localInputs,
});
}
const { success, message, data } = res.data;
if (success) {
if (isEdit) {
showSuccess(t('redemption.messages.update_success'));
} else {
showSuccess(t('redemption.messages.create_success'));
setInputs(originInputs);
}
} else {
showError(message);
}
if (!isEdit && data) {
let text = '';
for (let i = 0; i < data.length; i++) {
text += data[i] + '\n';
}
downloadTextAsFile(text, `${inputs.name}.txt`);
}
};
return (
<div className='dashboard-container'>
<Card fluid className='chart-card'>
<Card.Content>
<Card.Header className='header'>
{isEdit ? t('redemption.edit.title_edit') : t('redemption.edit.title_create')}
</Card.Header>
<Form loading={loading} autoComplete='new-password'>
<Form.Field>
<Form.Input
label={t('redemption.edit.name')}
name='name'
placeholder={t('redemption.edit.name_placeholder')}
onChange={handleInputChange}
value={name}
autoComplete='new-password'
required={!isEdit}
/>
</Form.Field>
<Form.Field>
<Form.Input
label={`${t('redemption.edit.quota')}${renderQuotaWithPrompt(quota, t)}`}
name='quota'
placeholder={t('redemption.edit.quota_placeholder')}
onChange={handleInputChange}
value={quota}
autoComplete='new-password'
type='number'
/>
</Form.Field>
{!isEdit && (
<>
<Form.Field>
<Form.Input
label={t('redemption.edit.count')}
name='count'
placeholder={t('redemption.edit.count_placeholder')}
onChange={handleInputChange}
value={count}
autoComplete='new-password'
type='number'
/>
</Form.Field>
</>
)}
<Button positive onClick={submit}>
{t('redemption.edit.buttons.submit')}
</Button>
<Button onClick={handleCancel}>
{t('redemption.edit.buttons.cancel')}
</Button>
</Form>
</Card.Content>
</Card>
</div>
);
};
export default EditRedemption;

View File

@@ -0,0 +1,21 @@
import React from 'react';
import { Card } from 'semantic-ui-react';
import { useTranslation } from 'react-i18next';
import RedemptionsTable from '../../components/RedemptionsTable';
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

@@ -0,0 +1,70 @@
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';
import OtherSetting from '../../components/OtherSetting';
import PersonalSetting from '../../components/PersonalSetting';
import OperationSetting from '../../components/OperationSetting';
const Setting = () => {
const { t } = useTranslation();
let panes = [
{
menuItem: t('setting.tabs.personal'),
render: () => (
<Tab.Pane attached={false}>
<PersonalSetting />
</Tab.Pane>
),
},
];
if (isRoot()) {
panes.push({
menuItem: t('setting.tabs.operation'),
render: () => (
<Tab.Pane attached={false}>
<OperationSetting />
</Tab.Pane>
),
});
panes.push({
menuItem: t('setting.tabs.system'),
render: () => (
<Tab.Pane attached={false}>
<SystemSetting />
</Tab.Pane>
),
});
panes.push({
menuItem: t('setting.tabs.other'),
render: () => (
<Tab.Pane attached={false}>
<OtherSetting />
</Tab.Pane>
),
});
}
return (
<div className='dashboard-container'>
<Card fluid className='chart-card'>
<Card.Content>
<Card.Header className='header'>{t('setting.title')}</Card.Header>
<Tab
menu={{
secondary: true,
pointing: true,
className: 'settings-tab',
}}
panes={panes}
/>
</Card.Content>
</Card>
</div>
);
};
export default Setting;

View File

@@ -0,0 +1,294 @@
import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
Button,
Form,
Header,
Message,
Segment,
Card,
} from 'semantic-ui-react';
import { useNavigate, useParams } from 'react-router-dom';
import {
API,
copy,
showError,
showSuccess,
timestamp2string,
} from '../../helpers';
import { renderQuotaWithPrompt } from '../../helpers/render';
const EditToken = () => {
const { t } = useTranslation();
const params = useParams();
const tokenId = params.id;
const isEdit = tokenId !== undefined;
const [loading, setLoading] = useState(isEdit);
const [modelOptions, setModelOptions] = useState([]);
const originInputs = {
name: '',
remain_quota: isEdit ? 0 : 500000,
expired_time: -1,
unlimited_quota: false,
models: [],
subnet: '',
};
const [inputs, setInputs] = useState(originInputs);
const { name, remain_quota, expired_time, unlimited_quota } = inputs;
const navigate = useNavigate();
const handleInputChange = (e, { name, value }) => {
setInputs((inputs) => ({ ...inputs, [name]: value }));
};
const handleCancel = () => {
navigate('/token');
};
const setExpiredTime = (month, day, hour, minute) => {
let now = new Date();
let timestamp = now.getTime() / 1000;
let seconds = month * 30 * 24 * 60 * 60;
seconds += day * 24 * 60 * 60;
seconds += hour * 60 * 60;
seconds += minute * 60;
if (seconds !== 0) {
timestamp += seconds;
setInputs({ ...inputs, expired_time: timestamp2string(timestamp) });
} else {
setInputs({ ...inputs, expired_time: -1 });
}
};
const setUnlimitedQuota = () => {
setInputs({ ...inputs, unlimited_quota: !unlimited_quota });
};
const loadToken = async () => {
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 {
showError(message || 'Failed to load token');
}
} catch (error) {
showError(error.message || 'Network error');
}
setLoading(false);
};
const loadAvailableModels = async () => {
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;
localInputs.remain_quota = parseInt(localInputs.remain_quota);
if (localInputs.expired_time !== -1) {
let time = Date.parse(localInputs.expired_time);
if (isNaN(time)) {
showError(t('token.edit.messages.expire_time_invalid'));
return;
}
localInputs.expired_time = Math.ceil(time / 1000);
}
localInputs.models = localInputs.models.join(',');
let res;
if (isEdit) {
res = await API.put(`/api/token/`, {
...localInputs,
id: parseInt(tokenId),
});
} else {
res = await API.post(`/api/token/`, localInputs);
}
const { success, message } = res.data;
if (success) {
if (isEdit) {
showSuccess(t('token.edit.messages.update_success'));
} else {
showSuccess(t('token.edit.messages.create_success'));
setInputs(originInputs);
}
} else {
showError(message);
}
};
return (
<div className='dashboard-container'>
<Card fluid className='chart-card'>
<Card.Content>
<Card.Header className='header'>
{isEdit ? t('token.edit.title_edit') : t('token.edit.title_create')}
</Card.Header>
<Form loading={loading} autoComplete='new-password'>
<Form.Field>
<Form.Input
label={t('token.edit.name')}
name='name'
placeholder={t('token.edit.name_placeholder')}
onChange={handleInputChange}
value={name}
autoComplete='new-password'
required={!isEdit}
/>
</Form.Field>
<Form.Field>
<Form.Dropdown
label={t('token.edit.models')}
placeholder={t('token.edit.models_placeholder')}
name='models'
fluid
multiple
search
onLabelClick={(e, { value }) => {
copy(value).then();
}}
selection
onChange={handleInputChange}
value={inputs.models}
autoComplete='new-password'
options={modelOptions}
/>
</Form.Field>
<Form.Field>
<Form.Input
label={t('token.edit.ip_limit')}
name='subnet'
placeholder={t('token.edit.ip_limit_placeholder')}
onChange={handleInputChange}
value={inputs.subnet}
autoComplete='new-password'
/>
</Form.Field>
<Form.Field>
<Form.Input
label={t('token.edit.expire_time')}
name='expired_time'
placeholder={t('token.edit.expire_time_placeholder')}
onChange={handleInputChange}
value={expired_time}
autoComplete='new-password'
type='datetime-local'
/>
</Form.Field>
<div style={{ lineHeight: '40px' }}>
<Button
type={'button'}
onClick={() => {
setExpiredTime(0, 0, 0, 0);
}}
>
{t('token.edit.buttons.never_expire')}
</Button>
<Button
type={'button'}
onClick={() => {
setExpiredTime(1, 0, 0, 0);
}}
>
{t('token.edit.buttons.expire_1_month')}
</Button>
<Button
type={'button'}
onClick={() => {
setExpiredTime(0, 1, 0, 0);
}}
>
{t('token.edit.buttons.expire_1_day')}
</Button>
<Button
type={'button'}
onClick={() => {
setExpiredTime(0, 0, 1, 0);
}}
>
{t('token.edit.buttons.expire_1_hour')}
</Button>
<Button
type={'button'}
onClick={() => {
setExpiredTime(0, 0, 0, 1);
}}
>
{t('token.edit.buttons.expire_1_minute')}
</Button>
</div>
<Message>{t('token.edit.quota_notice')}</Message>
<Form.Field>
<Form.Input
label={`${t('token.edit.quota')}${renderQuotaWithPrompt(
remain_quota,
t
)}`}
name='remain_quota'
placeholder={t('token.edit.quota_placeholder')}
onChange={handleInputChange}
value={remain_quota}
autoComplete='new-password'
type='number'
disabled={unlimited_quota}
/>
</Form.Field>
<Button
type={'button'}
onClick={() => {
setUnlimitedQuota();
}}
>
{unlimited_quota
? t('token.edit.buttons.cancel_unlimited')
: t('token.edit.buttons.unlimited_quota')}
</Button>
<Button floated='right' positive onClick={submit}>
{t('token.edit.buttons.submit')}
</Button>
<Button floated='right' onClick={handleCancel}>
{t('token.edit.buttons.cancel')}
</Button>
</Form>
</Card.Content>
</Card>
</div>
);
};
export default EditToken;

View File

@@ -0,0 +1,21 @@
import React from 'react';
import { Card } from 'semantic-ui-react';
import TokensTable from '../../components/TokensTable';
import { useTranslation } from 'react-i18next';
const Token = () => {
const { 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

@@ -0,0 +1,253 @@
import React, { useEffect, useState } from 'react';
import {
Button,
Form,
Grid,
Header,
Card,
Statistic,
Divider,
} from 'semantic-ui-react';
import { API, showError, showInfo, showSuccess } from '../../helpers';
import { renderQuota } from '../../helpers/render';
import { useTranslation } from 'react-i18next';
const TopUp = () => {
const { t } = useTranslation();
const [redemptionCode, setRedemptionCode] = useState('');
const [topUpLink, setTopUpLink] = useState('');
const [userQuota, setUserQuota] = useState(0);
const [isSubmitting, setIsSubmitting] = useState(false);
const [user, setUser] = useState({});
const topUp = async () => {
if (redemptionCode === '') {
showInfo(t('topup.redeem_code.empty_code'));
return;
}
setIsSubmitting(true);
try {
const res = await API.post('/api/user/topup', {
key: redemptionCode,
});
const { success, message, data } = res.data;
if (success) {
showSuccess(t('topup.redeem_code.success'));
setUserQuota((quota) => {
return quota + data;
});
setRedemptionCode('');
} else {
showError(message);
}
} catch (err) {
showError(t('topup.redeem_code.request_failed'));
} finally {
setIsSubmitting(false);
}
};
const openTopUpLink = () => {
if (!topUpLink) {
showError(t('topup.redeem_code.no_link'));
return;
}
let url = new URL(topUpLink);
let username = user.username;
let user_id = user.id;
url.searchParams.append('username', username);
url.searchParams.append('user_id', user_id);
url.searchParams.append('transaction_id', crypto.randomUUID());
window.open(url.toString(), '_blank');
};
const getUserQuota = async () => {
let res = await API.get(`/api/user/self`);
const { success, message, data } = res.data;
if (success) {
setUserQuota(data.quota);
setUser(data);
} else {
showError(message);
}
};
useEffect(() => {
let status = localStorage.getItem('status');
if (status) {
status = JSON.parse(status);
if (status.top_up_link) {
setTopUpLink(status.top_up_link);
}
}
getUserQuota().then();
}, []);
return (
<div className='dashboard-container'>
<Card fluid className='chart-card'>
<Card.Content>
<Card.Header>
<Header as='h2'>{t('topup.title')}</Header>
</Card.Header>
<Grid columns={2} stackable>
<Grid.Column>
<Card
fluid
style={{
height: '100%',
boxShadow: '0 1px 3px rgba(0,0,0,0.1)',
}}
>
<Card.Content
style={{
height: '100%',
display: 'flex',
flexDirection: 'column',
}}
>
<Card.Header>
<Header as='h3' style={{ color: '#2185d0', margin: '1em' }}>
<i className='credit card icon'></i>
{t('topup.get_code.title')}
</Header>
</Card.Header>
<Card.Description
style={{
flex: 1,
display: 'flex',
flexDirection: 'column',
}}
>
<div
style={{
flex: 1,
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
}}
>
<div style={{ textAlign: 'center', paddingTop: '1em' }}>
<Statistic>
<Statistic.Value style={{ color: '#2185d0' }}>
{renderQuota(userQuota, t)}
</Statistic.Value>
<Statistic.Label>
{t('topup.get_code.current_quota')}
</Statistic.Label>
</Statistic>
</div>
<div
style={{ textAlign: 'center', paddingBottom: '1em' }}
>
<Button
primary
size='large'
onClick={openTopUpLink}
style={{ width: '80%' }}
>
{t('topup.get_code.button')}
</Button>
</div>
</div>
</Card.Description>
</Card.Content>
</Card>
</Grid.Column>
<Grid.Column>
<Card
fluid
style={{
height: '100%',
boxShadow: '0 1px 3px rgba(0,0,0,0.1)',
}}
>
<Card.Content
style={{
height: '100%',
display: 'flex',
flexDirection: 'column',
}}
>
<Card.Header>
<Header as='h3' style={{ color: '#21ba45', margin: '1em' }}>
<i className='ticket alternate icon'></i>
{t('topup.redeem_code.title')}
</Header>
</Card.Header>
<Card.Description
style={{
flex: 1,
display: 'flex',
flexDirection: 'column',
}}
>
<div
style={{
flex: 1,
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
}}
>
<Form.Input
fluid
icon='key'
iconPosition='left'
placeholder={t('topup.redeem_code.placeholder')}
value={redemptionCode}
onChange={(e) => {
setRedemptionCode(e.target.value);
}}
onPaste={(e) => {
e.preventDefault();
const pastedText = e.clipboardData.getData('text');
setRedemptionCode(pastedText.trim());
}}
action={
<Button
icon='paste'
content={t('topup.redeem_code.paste')}
onClick={async () => {
try {
const text =
await navigator.clipboard.readText();
setRedemptionCode(text.trim());
} catch (err) {
showError(t('topup.redeem_code.paste_error'));
}
}}
/>
}
/>
<div style={{ paddingBottom: '1em' }}>
<Button
color='green'
fluid
size='large'
onClick={topUp}
loading={isSubmitting}
disabled={isSubmitting}
>
{isSubmitting
? t('topup.redeem_code.submitting')
: t('topup.redeem_code.submit')}
</Button>
</div>
</div>
</Card.Description>
</Card.Content>
</Card>
</Grid.Column>
</Grid>
</Card.Content>
</Card>
</div>
);
};
export default TopUp;

View File

@@ -0,0 +1,81 @@
import React, { useState } from '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: '',
password: '',
};
const [inputs, setInputs] = useState(originInputs);
const { username, display_name, password } = inputs;
const handleInputChange = (e, { name, value }) => {
setInputs((inputs) => ({ ...inputs, [name]: value }));
};
const submit = async () => {
if (inputs.username === '' || inputs.password === '') return;
const res = await API.post(`/api/user/`, inputs);
const { success, message } = res.data;
if (success) {
showSuccess(t('user.messages.create_success'));
setInputs(originInputs);
} else {
showError(message);
}
};
return (
<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>
);
};
export default AddUser;

View File

@@ -0,0 +1,211 @@
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);
const [inputs, setInputs] = useState({
username: '',
display_name: '',
password: '',
github_id: '',
wechat_id: '',
email: '',
quota: 0,
group: 'default',
});
const [groupOptions, setGroupOptions] = useState([]);
const {
username,
display_name,
password,
github_id,
wechat_id,
email,
quota,
group,
} = inputs;
const handleInputChange = (e, { name, value }) => {
setInputs((inputs) => ({ ...inputs, [name]: value }));
};
const fetchGroups = async () => {
try {
let res = await API.get(`/api/group/`);
setGroupOptions(
res.data.data.map((group) => ({
key: group,
text: group,
value: group,
}))
);
} catch (error) {
showError(error.message);
}
};
const navigate = useNavigate();
const handleCancel = () => {
navigate('/setting');
};
const loadUser = async () => {
let res = undefined;
if (userId) {
res = await API.get(`/api/user/${userId}`);
} else {
res = await API.get(`/api/user/self`);
}
const { success, message, data } = res.data;
if (success) {
data.password = '';
setInputs(data);
} else {
showError(message);
}
setLoading(false);
};
useEffect(() => {
loadUser().then();
if (userId) {
fetchGroups().then();
}
}, []);
const submit = async () => {
let res = undefined;
if (userId) {
let data = { ...inputs, id: parseInt(userId) };
if (typeof data.quota === 'string') {
data.quota = parseInt(data.quota);
}
res = await API.put(`/api/user/`, data);
} else {
res = await API.put(`/api/user/self`, inputs);
}
const { success, message } = res.data;
if (success) {
showSuccess(t('user.messages.update_success'));
} else {
showError(message);
}
};
return (
<div className='dashboard-container'>
<Card fluid className='chart-card'>
<Card.Content>
<Card.Header className='header'>{t('user.edit.title')}</Card.Header>
<Form loading={loading} autoComplete='new-password'>
<Form.Field>
<Form.Input
label={t('user.edit.username')}
name='username'
placeholder={t('user.edit.username_placeholder')}
onChange={handleInputChange}
value={username}
autoComplete='new-password'
/>
</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='new-password'
/>
</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='new-password'
/>
</Form.Field>
{userId && (
<>
<Form.Field>
<Form.Dropdown
label={t('user.edit.group')}
placeholder={t('user.edit.group_placeholder')}
name='group'
fluid
search
selection
allowAdditions
additionLabel={t('user.edit.group_addition')}
onChange={handleInputChange}
value={inputs.group}
autoComplete='new-password'
options={groupOptions}
/>
</Form.Field>
<Form.Field>
<Form.Input
label={`${t('user.edit.quota')}${renderQuotaWithPrompt(
quota,
t
)}`}
name='quota'
placeholder={t('user.edit.quota_placeholder')}
onChange={handleInputChange}
value={quota}
type={'number'}
autoComplete='new-password'
/>
</Form.Field>
</>
)}
<Form.Field>
<Form.Input
label={t('user.edit.github_id')}
name='github_id'
value={github_id}
autoComplete='new-password'
placeholder={t('user.edit.github_id_placeholder')}
readOnly
/>
</Form.Field>
<Form.Field>
<Form.Input
label={t('user.edit.wechat_id')}
name='wechat_id'
value={wechat_id}
autoComplete='new-password'
placeholder={t('user.edit.wechat_id_placeholder')}
readOnly
/>
</Form.Field>
<Form.Field>
<Form.Input
label={t('user.edit.email')}
name='email'
value={email}
autoComplete='new-password'
placeholder={t('user.edit.email_placeholder')}
readOnly
/>
</Form.Field>
<Button onClick={handleCancel}>
{t('user.edit.buttons.cancel')}
</Button>
<Button positive onClick={submit}>
{t('user.edit.buttons.submit')}
</Button>
</Form>
</Card.Content>
</Card>
</div>
);
};
export default EditUser;

View File

@@ -0,0 +1,21 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Card } from 'semantic-ui-react';
import UsersTable from '../../components/UsersTable';
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;

5
web/default/vercel.json Normal file
View File

@@ -0,0 +1,5 @@
{
"github": {
"silent": true
}
}