first commit: one-api base code + SAAS plan document
This commit is contained in:
26
web/default/.gitignore
vendored
Normal file
26
web/default/.gitignore
vendored
Normal 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
21
web/default/README.md
Normal 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
56
web/default/package.json
Normal 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"
|
||||
}
|
||||
BIN
web/default/public/favicon.ico
Normal file
BIN
web/default/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
18
web/default/public/index.html
Normal file
18
web/default/public/index.html
Normal 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
BIN
web/default/public/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 7.9 KiB |
3
web/default/public/robots.txt
Normal file
3
web/default/public/robots.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
# https://www.robotstxt.org/robotstxt.html
|
||||
User-agent: *
|
||||
Disallow:
|
||||
314
web/default/src/App.js
Normal file
314
web/default/src/App.js
Normal 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;
|
||||
735
web/default/src/components/ChannelsTable.js
Normal file
735
web/default/src/components/ChannelsTable.js
Normal 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;
|
||||
59
web/default/src/components/Footer.js
Normal file
59
web/default/src/components/Footer.js
Normal 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;
|
||||
58
web/default/src/components/GitHubOAuth.js
Normal file
58
web/default/src/components/GitHubOAuth.js
Normal 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;
|
||||
331
web/default/src/components/Header.js
Normal file
331
web/default/src/components/Header.js
Normal 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;
|
||||
58
web/default/src/components/LarkOAuth.js
Normal file
58
web/default/src/components/LarkOAuth.js
Normal 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;
|
||||
14
web/default/src/components/Loading.js
Normal file
14
web/default/src/components/Loading.js
Normal 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;
|
||||
292
web/default/src/components/LoginForm.js
Normal file
292
web/default/src/components/LoginForm.js
Normal 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;
|
||||
613
web/default/src/components/LogsTable.js
Normal file
613
web/default/src/components/LogsTable.js
Normal 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;
|
||||
446
web/default/src/components/OperationSetting.js
Normal file
446
web/default/src/components/OperationSetting.js
Normal 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;
|
||||
253
web/default/src/components/OtherSetting.js
Normal file
253
web/default/src/components/OtherSetting.js
Normal 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;
|
||||
154
web/default/src/components/PasswordResetConfirm.js
Normal file
154
web/default/src/components/PasswordResetConfirm.js
Normal 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;
|
||||
157
web/default/src/components/PasswordResetForm.js
Normal file
157
web/default/src/components/PasswordResetForm.js
Normal 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;
|
||||
420
web/default/src/components/PersonalSetting.js
Normal file
420
web/default/src/components/PersonalSetting.js
Normal 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;
|
||||
13
web/default/src/components/PrivateRoute.js
Normal file
13
web/default/src/components/PrivateRoute.js
Normal 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 };
|
||||
375
web/default/src/components/RedemptionsTable.js
Normal file
375
web/default/src/components/RedemptionsTable.js
Normal 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;
|
||||
267
web/default/src/components/RegisterForm.js
Normal file
267
web/default/src/components/RegisterForm.js
Normal 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;
|
||||
663
web/default/src/components/SystemSetting.js
Normal file
663
web/default/src/components/SystemSetting.js
Normal 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;
|
||||
544
web/default/src/components/TokensTable.js
Normal file
544
web/default/src/components/TokensTable.js
Normal 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;
|
||||
417
web/default/src/components/UsersTable.js
Normal file
417
web/default/src/components/UsersTable.js
Normal 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;
|
||||
29
web/default/src/components/utils.js
Normal file
29
web/default/src/components/utils.js
Normal 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}`
|
||||
);
|
||||
}
|
||||
108
web/default/src/constants/channel.constants.js
Normal file
108
web/default/src/constants/channel.constants.js
Normal 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>获取 AK(API Key)以及 SK(Secret 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' },
|
||||
];
|
||||
1
web/default/src/constants/common.constant.js
Normal file
1
web/default/src/constants/common.constant.js
Normal file
@@ -0,0 +1 @@
|
||||
export const ITEMS_PER_PAGE = 10; // this value must keep same as the one defined in backend!
|
||||
4
web/default/src/constants/index.js
Normal file
4
web/default/src/constants/index.js
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from './toast.constants';
|
||||
export * from './user.constants';
|
||||
export * from './common.constant';
|
||||
export * from './channel.constants';
|
||||
7
web/default/src/constants/toast.constants.js
Normal file
7
web/default/src/constants/toast.constants.js
Normal file
@@ -0,0 +1,7 @@
|
||||
export const toastConstants = {
|
||||
SUCCESS_TIMEOUT: 5000,
|
||||
INFO_TIMEOUT: 8000,
|
||||
ERROR_TIMEOUT: 10000,
|
||||
WARNING_TIMEOUT: 10000,
|
||||
NOTICE_TIMEOUT: 20000,
|
||||
};
|
||||
19
web/default/src/constants/user.constants.js
Normal file
19
web/default/src/constants/user.constants.js
Normal 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'
|
||||
};
|
||||
19
web/default/src/context/Status/index.js
Normal file
19
web/default/src/context/Status/index.js
Normal 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>
|
||||
);
|
||||
};
|
||||
20
web/default/src/context/Status/reducer.js
Normal file
20
web/default/src/context/Status/reducer.js
Normal 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,
|
||||
};
|
||||
19
web/default/src/context/User/index.js
Normal file
19
web/default/src/context/User/index.js
Normal 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>
|
||||
)
|
||||
}
|
||||
21
web/default/src/context/User/reducer.js
Normal file
21
web/default/src/context/User/reducer.js
Normal 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
|
||||
};
|
||||
13
web/default/src/helpers/api.js
Normal file
13
web/default/src/helpers/api.js
Normal 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);
|
||||
}
|
||||
);
|
||||
10
web/default/src/helpers/auth-header.js
Normal file
10
web/default/src/helpers/auth-header.js
Normal 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 {};
|
||||
}
|
||||
}
|
||||
13
web/default/src/helpers/helper.js
Normal file
13
web/default/src/helpers/helper.js
Normal 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];
|
||||
}
|
||||
3
web/default/src/helpers/history.js
Normal file
3
web/default/src/helpers/history.js
Normal file
@@ -0,0 +1,3 @@
|
||||
import { createBrowserHistory } from 'history';
|
||||
|
||||
export const history = createBrowserHistory();
|
||||
4
web/default/src/helpers/index.js
Normal file
4
web/default/src/helpers/index.js
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from './history';
|
||||
export * from './auth-header';
|
||||
export * from './utils';
|
||||
export * from './api';
|
||||
121
web/default/src/helpers/render.js
Normal file
121
web/default/src/helpers/render.js
Normal 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>
|
||||
);
|
||||
}
|
||||
217
web/default/src/helpers/utils.js
Normal file
217
web/default/src/helpers/utils.js
Normal 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
28
web/default/src/i18n.js
Normal 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;
|
||||
1
web/default/src/images/lark.svg
Normal file
1
web/default/src/images/lark.svg
Normal file
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
117
web/default/src/index.css
Normal 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
32
web/default/src/index.js
Normal 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>
|
||||
);
|
||||
831
web/default/src/locales/en/translation.json
Normal file
831
web/default/src/locales/en/translation.json
Normal 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}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
827
web/default/src/locales/zh/translation.json
Normal file
827
web/default/src/locales/zh/translation.json
Normal 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}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
74
web/default/src/pages/About/index.js
Normal file
74
web/default/src/pages/About/index.js
Normal 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;
|
||||
698
web/default/src/pages/Channel/EditChannel.js
Normal file
698
web/default/src/pages/Channel/EditChannel.js
Normal 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;
|
||||
21
web/default/src/pages/Channel/index.js
Normal file
21
web/default/src/pages/Channel/index.js
Normal 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;
|
||||
15
web/default/src/pages/Chat/index.js
Normal file
15
web/default/src/pages/Chat/index.js
Normal 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;
|
||||
109
web/default/src/pages/Dashboard/Dashboard.css
Normal file
109
web/default/src/pages/Dashboard/Dashboard.css
Normal 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;
|
||||
}
|
||||
460
web/default/src/pages/Dashboard/index.js
Normal file
460
web/default/src/pages/Dashboard/index.js
Normal 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;
|
||||
299
web/default/src/pages/Home/index.js
Normal file
299
web/default/src/pages/Home/index.js
Normal 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;
|
||||
21
web/default/src/pages/Log/index.js
Normal file
21
web/default/src/pages/Log/index.js
Normal 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;
|
||||
13
web/default/src/pages/NotFound/index.js
Normal file
13
web/default/src/pages/NotFound/index.js
Normal 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;
|
||||
141
web/default/src/pages/Redemption/EditRedemption.js
Normal file
141
web/default/src/pages/Redemption/EditRedemption.js
Normal 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;
|
||||
21
web/default/src/pages/Redemption/index.js
Normal file
21
web/default/src/pages/Redemption/index.js
Normal 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;
|
||||
70
web/default/src/pages/Setting/index.js
Normal file
70
web/default/src/pages/Setting/index.js
Normal 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;
|
||||
294
web/default/src/pages/Token/EditToken.js
Normal file
294
web/default/src/pages/Token/EditToken.js
Normal 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;
|
||||
21
web/default/src/pages/Token/index.js
Normal file
21
web/default/src/pages/Token/index.js
Normal 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;
|
||||
253
web/default/src/pages/TopUp/index.js
Normal file
253
web/default/src/pages/TopUp/index.js
Normal 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;
|
||||
81
web/default/src/pages/User/AddUser.js
Normal file
81
web/default/src/pages/User/AddUser.js
Normal 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;
|
||||
211
web/default/src/pages/User/EditUser.js
Normal file
211
web/default/src/pages/User/EditUser.js
Normal 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;
|
||||
21
web/default/src/pages/User/index.js
Normal file
21
web/default/src/pages/User/index.js
Normal 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
5
web/default/vercel.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"github": {
|
||||
"silent": true
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user