feat: add new theme air (#1167)
* chore: add theme air with new-api main branch v0.2.0.3-alpha.1(first step) * feat: 完成渠道界面 * chore: 优化渠道界面样式问题 * feat: 完成兑换码界面 * feat: 完成充值(钱包)界面 * chore: 初代air主题将使用default主题的运营设置界面、系统设置界面、其他设置界面 * feat: 完成日志界面 * feat: 完成用户管理界面 * feat: 完成个人设置界面 * feat: 完成令牌界面 * chore: 优化令牌界面逻辑 * feat: 修改版权信息 * chore: make necessary changes --------- Co-authored-by: Calon <1808837298@qq.com> Co-authored-by: Apple\Apple <zeraturing@foxmail.com> Co-authored-by: JustSong <songquanpeng@foxmail.com>
This commit is contained in:
parent
205aba694f
commit
11af81eb39
@ -12,6 +12,10 @@ WORKDIR /web/berry
|
|||||||
RUN npm install
|
RUN npm install
|
||||||
RUN DISABLE_ESLINT_PLUGIN='true' REACT_APP_VERSION=$(cat VERSION) npm run build
|
RUN DISABLE_ESLINT_PLUGIN='true' REACT_APP_VERSION=$(cat VERSION) npm run build
|
||||||
|
|
||||||
|
WORKDIR /web/air
|
||||||
|
RUN npm install
|
||||||
|
RUN DISABLE_ESLINT_PLUGIN='true' REACT_APP_VERSION=$(cat VERSION) npm run build
|
||||||
|
|
||||||
FROM golang AS builder2
|
FROM golang AS builder2
|
||||||
|
|
||||||
ENV GO111MODULE=on \
|
ENV GO111MODULE=on \
|
||||||
|
@ -107,6 +107,7 @@ var Theme = env.String("THEME", "default")
|
|||||||
var ValidThemes = map[string]bool{
|
var ValidThemes = map[string]bool{
|
||||||
"default": true,
|
"default": true,
|
||||||
"berry": true,
|
"berry": true,
|
||||||
|
"air": true,
|
||||||
}
|
}
|
||||||
|
|
||||||
// All duration's unit is seconds
|
// All duration's unit is seconds
|
||||||
|
@ -33,6 +33,12 @@
|
|||||||
|||
|
|||
|
||||||
|||
|
|||
|
||||||
|
|
||||||
|
### 主题:air
|
||||||
|
由 [Calon](https://github.com/Calcium-Ion) 开发。
|
||||||
|
|||
|
||||||
|
|:---:|:---:|
|
||||||
|
|
||||||
|
|
||||||
#### 开发说明
|
#### 开发说明
|
||||||
|
|
||||||
请查看 [web/berry/README.md](https://github.com/songquanpeng/one-api/tree/main/web/berry/README.md)
|
请查看 [web/berry/README.md](https://github.com/songquanpeng/one-api/tree/main/web/berry/README.md)
|
||||||
|
@ -1,2 +1,3 @@
|
|||||||
default
|
default
|
||||||
berry
|
berry
|
||||||
|
air
|
||||||
|
26
web/air/.gitignore
vendored
Normal file
26
web/air/.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/air/README.md
Normal file
21
web/air/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
|
60
web/air/package.json
Normal file
60
web/air/package.json
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
{
|
||||||
|
"name": "react-template",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@douyinfe/semi-icons": "^2.46.1",
|
||||||
|
"@douyinfe/semi-ui": "^2.46.1",
|
||||||
|
"@visactor/react-vchart": "~1.8.8",
|
||||||
|
"@visactor/vchart": "~1.8.8",
|
||||||
|
"@visactor/vchart-semi-theme": "~1.8.8",
|
||||||
|
"axios": "^0.27.2",
|
||||||
|
"history": "^5.3.0",
|
||||||
|
"marked": "^4.1.1",
|
||||||
|
"react": "^18.2.0",
|
||||||
|
"react-dom": "^18.2.0",
|
||||||
|
"react-dropzone": "^14.2.3",
|
||||||
|
"react-fireworks": "^1.0.4",
|
||||||
|
"react-router-dom": "^6.3.0",
|
||||||
|
"react-scripts": "5.0.1",
|
||||||
|
"react-telegram-login": "^1.1.2",
|
||||||
|
"react-toastify": "^9.0.8",
|
||||||
|
"react-turnstile": "^1.0.5",
|
||||||
|
"semantic-ui-css": "^2.5.0",
|
||||||
|
"semantic-ui-react": "^2.1.3",
|
||||||
|
"usehooks-ts": "^2.9.1"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"start": "react-scripts start",
|
||||||
|
"build": "react-scripts build && mv -f build ../build/air",
|
||||||
|
"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.8.8",
|
||||||
|
"typescript": "4.4.2"
|
||||||
|
},
|
||||||
|
"prettier": {
|
||||||
|
"singleQuote": true,
|
||||||
|
"jsxSingleQuote": true
|
||||||
|
},
|
||||||
|
"proxy": "http://localhost:3000"
|
||||||
|
}
|
BIN
web/air/public/favicon.ico
Normal file
BIN
web/air/public/favicon.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 4.2 KiB |
18
web/air/public/index.html
Normal file
18
web/air/public/index.html
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<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>New API</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||||
|
<div id="root"></div>
|
||||||
|
</body>
|
||||||
|
</html>
|
BIN
web/air/public/logo.png
Normal file
BIN
web/air/public/logo.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 7.9 KiB |
3
web/air/public/robots.txt
Normal file
3
web/air/public/robots.txt
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
# https://www.robotstxt.org/robotstxt.html
|
||||||
|
User-agent: *
|
||||||
|
Disallow:
|
242
web/air/src/App.js
Normal file
242
web/air/src/App.js
Normal file
@ -0,0 +1,242 @@
|
|||||||
|
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 { getLogo, getSystemName } from './helpers';
|
||||||
|
import PasswordResetForm from './components/PasswordResetForm';
|
||||||
|
import GitHubOAuth from './components/GitHubOAuth';
|
||||||
|
import PasswordResetConfirm from './components/PasswordResetConfirm';
|
||||||
|
import { UserContext } from './context/User';
|
||||||
|
import Channel from './pages/Channel';
|
||||||
|
import Token from './pages/Token';
|
||||||
|
import EditChannel from './pages/Channel/EditChannel';
|
||||||
|
import Redemption from './pages/Redemption';
|
||||||
|
import TopUp from './pages/TopUp';
|
||||||
|
import Log from './pages/Log';
|
||||||
|
import Chat from './pages/Chat';
|
||||||
|
import { Layout } from '@douyinfe/semi-ui';
|
||||||
|
import Midjourney from './pages/Midjourney';
|
||||||
|
import Detail from './pages/Detail';
|
||||||
|
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadUser();
|
||||||
|
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 (
|
||||||
|
<Layout>
|
||||||
|
<Layout.Content>
|
||||||
|
<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="/redemption"
|
||||||
|
element={
|
||||||
|
<PrivateRoute>
|
||||||
|
<Redemption />
|
||||||
|
</PrivateRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<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/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="/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="/detail"
|
||||||
|
element={
|
||||||
|
<PrivateRoute>
|
||||||
|
<Detail />
|
||||||
|
</PrivateRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/midjourney"
|
||||||
|
element={
|
||||||
|
<PrivateRoute>
|
||||||
|
<Midjourney />
|
||||||
|
</PrivateRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/about"
|
||||||
|
element={
|
||||||
|
<Suspense fallback={<Loading></Loading>}>
|
||||||
|
<About />
|
||||||
|
</Suspense>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/chat"
|
||||||
|
element={
|
||||||
|
<Suspense fallback={<Loading></Loading>}>
|
||||||
|
<Chat />
|
||||||
|
</Suspense>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route path="*" element={
|
||||||
|
<NotFound />
|
||||||
|
} />
|
||||||
|
</Routes>
|
||||||
|
</Layout.Content>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
738
web/air/src/components/ChannelsTable.js
Normal file
738
web/air/src/components/ChannelsTable.js
Normal file
@ -0,0 +1,738 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { API, isMobile, shouldShowPrompt, showError, showInfo, showSuccess, timestamp2string } from '../helpers';
|
||||||
|
|
||||||
|
import { CHANNEL_OPTIONS, ITEMS_PER_PAGE } from '../constants';
|
||||||
|
import { renderGroup, renderNumberWithPoint, renderQuota } from '../helpers/render';
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Dropdown,
|
||||||
|
Form,
|
||||||
|
InputNumber,
|
||||||
|
Popconfirm,
|
||||||
|
Space,
|
||||||
|
SplitButtonGroup,
|
||||||
|
Switch,
|
||||||
|
Table,
|
||||||
|
Tag,
|
||||||
|
Tooltip,
|
||||||
|
Typography
|
||||||
|
} from '@douyinfe/semi-ui';
|
||||||
|
import EditChannel from '../pages/Channel/EditChannel';
|
||||||
|
import { IconTreeTriangleDown } from '@douyinfe/semi-icons';
|
||||||
|
|
||||||
|
function renderTimestamp(timestamp) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{timestamp2string(timestamp)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let type2label = undefined;
|
||||||
|
|
||||||
|
function renderType(type) {
|
||||||
|
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: '未知类型', color: 'grey' };
|
||||||
|
}
|
||||||
|
return <Tag size="large" color={type2label[type]?.color}>{type2label[type]?.text}</Tag>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ChannelsTable = () => {
|
||||||
|
const columns = [
|
||||||
|
// {
|
||||||
|
// title: '',
|
||||||
|
// dataIndex: 'checkbox',
|
||||||
|
// className: 'checkbox',
|
||||||
|
// },
|
||||||
|
{
|
||||||
|
title: 'ID',
|
||||||
|
dataIndex: 'id'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '名称',
|
||||||
|
dataIndex: 'name'
|
||||||
|
},
|
||||||
|
// {
|
||||||
|
// title: '分组',
|
||||||
|
// dataIndex: 'group',
|
||||||
|
// render: (text, record, index) => {
|
||||||
|
// return (
|
||||||
|
// <div>
|
||||||
|
// <Space spacing={2}>
|
||||||
|
// {
|
||||||
|
// text.split(',').map((item, index) => {
|
||||||
|
// return (renderGroup(item));
|
||||||
|
// })
|
||||||
|
// }
|
||||||
|
// </Space>
|
||||||
|
// </div>
|
||||||
|
// );
|
||||||
|
// }
|
||||||
|
// },
|
||||||
|
{
|
||||||
|
title: '类型',
|
||||||
|
dataIndex: 'type',
|
||||||
|
render: (text, record, index) => {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{renderType(text)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '状态',
|
||||||
|
dataIndex: 'status',
|
||||||
|
render: (text, record, index) => {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{renderStatus(text)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '响应时间',
|
||||||
|
dataIndex: 'response_time',
|
||||||
|
render: (text, record, index) => {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{renderResponseTime(text)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '已用/剩余',
|
||||||
|
dataIndex: 'expired_time',
|
||||||
|
render: (text, record, index) => {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Space spacing={1}>
|
||||||
|
<Tooltip content={'已用额度'}>
|
||||||
|
<Tag color="white" type="ghost" size="large">{renderQuota(record.used_quota)}</Tag>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip content={'剩余额度' + record.balance + ',点击更新'}>
|
||||||
|
<Tag color="white" type="ghost" size="large" onClick={() => {
|
||||||
|
updateChannelBalance(record);
|
||||||
|
}}>${renderNumberWithPoint(record.balance)}</Tag>
|
||||||
|
</Tooltip>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '优先级',
|
||||||
|
dataIndex: 'priority',
|
||||||
|
render: (text, record, index) => {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<InputNumber
|
||||||
|
style={{ width: 70 }}
|
||||||
|
name="priority"
|
||||||
|
onBlur={e => {
|
||||||
|
manageChannel(record.id, 'priority', record, e.target.value);
|
||||||
|
}}
|
||||||
|
keepFocus={true}
|
||||||
|
innerButtons
|
||||||
|
defaultValue={record.priority}
|
||||||
|
min={-999}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// {
|
||||||
|
// title: '权重',
|
||||||
|
// dataIndex: 'weight',
|
||||||
|
// render: (text, record, index) => {
|
||||||
|
// return (
|
||||||
|
// <div>
|
||||||
|
// <InputNumber
|
||||||
|
// style={{ width: 70 }}
|
||||||
|
// name="weight"
|
||||||
|
// onBlur={e => {
|
||||||
|
// manageChannel(record.id, 'weight', record, e.target.value);
|
||||||
|
// }}
|
||||||
|
// keepFocus={true}
|
||||||
|
// innerButtons
|
||||||
|
// defaultValue={record.weight}
|
||||||
|
// min={0}
|
||||||
|
// />
|
||||||
|
// </div>
|
||||||
|
// );
|
||||||
|
// }
|
||||||
|
// },
|
||||||
|
{
|
||||||
|
title: '',
|
||||||
|
dataIndex: 'operate',
|
||||||
|
render: (text, record, index) => (
|
||||||
|
<div>
|
||||||
|
{/* <SplitButtonGroup style={{ marginRight: 1 }} aria-label="测试操作项目组">
|
||||||
|
<Button theme="light" onClick={() => {
|
||||||
|
testChannel(record, '');
|
||||||
|
}}>测试</Button>
|
||||||
|
<Dropdown trigger="click" position="bottomRight" menu={record.test_models}
|
||||||
|
>
|
||||||
|
<Button style={{ padding: '8px 4px' }} type="primary" icon={<IconTreeTriangleDown />}></Button>
|
||||||
|
</Dropdown>
|
||||||
|
</SplitButtonGroup> */}
|
||||||
|
<Button theme='light' type='primary' style={{ marginRight: 1 }} onClick={() => testChannel(record)}>测试</Button>
|
||||||
|
<Popconfirm
|
||||||
|
title="确定是否要删除此渠道?"
|
||||||
|
content="此修改将不可逆"
|
||||||
|
okType={'danger'}
|
||||||
|
position={'left'}
|
||||||
|
onConfirm={() => {
|
||||||
|
manageChannel(record.id, 'delete', record).then(
|
||||||
|
() => {
|
||||||
|
removeRecord(record.id);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button theme="light" type="danger" style={{ marginRight: 1 }}>删除</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
{
|
||||||
|
record.status === 1 ?
|
||||||
|
<Button theme="light" type="warning" style={{ marginRight: 1 }} onClick={
|
||||||
|
async () => {
|
||||||
|
manageChannel(
|
||||||
|
record.id,
|
||||||
|
'disable',
|
||||||
|
record
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}>禁用</Button> :
|
||||||
|
<Button theme="light" type="secondary" style={{ marginRight: 1 }} onClick={
|
||||||
|
async () => {
|
||||||
|
manageChannel(
|
||||||
|
record.id,
|
||||||
|
'enable',
|
||||||
|
record
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}>启用</Button>
|
||||||
|
}
|
||||||
|
<Button theme="light" type="tertiary" style={{ marginRight: 1 }} onClick={
|
||||||
|
() => {
|
||||||
|
setEditingChannel(record);
|
||||||
|
setShowEdit(true);
|
||||||
|
}
|
||||||
|
}>编辑</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const [channels, setChannels] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [activePage, setActivePage] = useState(1);
|
||||||
|
const [idSort, setIdSort] = useState(false);
|
||||||
|
const [searchKeyword, setSearchKeyword] = useState('');
|
||||||
|
const [searchGroup, setSearchGroup] = useState('');
|
||||||
|
const [searchModel, setSearchModel] = useState('');
|
||||||
|
const [searching, setSearching] = useState(false);
|
||||||
|
const [updatingBalance, setUpdatingBalance] = useState(false);
|
||||||
|
const [pageSize, setPageSize] = useState(ITEMS_PER_PAGE);
|
||||||
|
const [showPrompt, setShowPrompt] = useState(shouldShowPrompt('channel-test'));
|
||||||
|
const [channelCount, setChannelCount] = useState(pageSize);
|
||||||
|
const [groupOptions, setGroupOptions] = useState([]);
|
||||||
|
const [showEdit, setShowEdit] = useState(false);
|
||||||
|
const [enableBatchDelete, setEnableBatchDelete] = useState(false);
|
||||||
|
const [editingChannel, setEditingChannel] = useState({
|
||||||
|
id: undefined
|
||||||
|
});
|
||||||
|
const [selectedChannels, setSelectedChannels] = useState([]);
|
||||||
|
|
||||||
|
const removeRecord = id => {
|
||||||
|
let newDataSource = [...channels];
|
||||||
|
if (id != null) {
|
||||||
|
let idx = newDataSource.findIndex(data => data.id === id);
|
||||||
|
|
||||||
|
if (idx > -1) {
|
||||||
|
newDataSource.splice(idx, 1);
|
||||||
|
setChannels(newDataSource);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const setChannelFormat = (channels) => {
|
||||||
|
for (let i = 0; i < channels.length; i++) {
|
||||||
|
channels[i].key = '' + channels[i].id;
|
||||||
|
let test_models = [];
|
||||||
|
channels[i].models.split(',').forEach((item, index) => {
|
||||||
|
test_models.push({
|
||||||
|
node: 'item',
|
||||||
|
name: item,
|
||||||
|
onClick: () => {
|
||||||
|
testChannel(channels[i], item);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
channels[i].test_models = test_models;
|
||||||
|
}
|
||||||
|
// data.key = '' + data.id
|
||||||
|
setChannels(channels);
|
||||||
|
if (channels.length >= pageSize) {
|
||||||
|
setChannelCount(channels.length + pageSize);
|
||||||
|
} else {
|
||||||
|
setChannelCount(channels.length);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadChannels = async (startIdx, pageSize, idSort) => {
|
||||||
|
setLoading(true);
|
||||||
|
const res = await API.get(`/api/channel/?p=${startIdx}&page_size=${pageSize}&id_sort=${idSort}`);
|
||||||
|
const { success, message, data } = res.data;
|
||||||
|
if (success) {
|
||||||
|
if (startIdx === 0) {
|
||||||
|
setChannelFormat(data);
|
||||||
|
} else {
|
||||||
|
let newChannels = [...channels];
|
||||||
|
newChannels.splice(startIdx * pageSize, data.length, ...data);
|
||||||
|
setChannelFormat(newChannels);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
showError(message);
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const refresh = async () => {
|
||||||
|
await loadChannels(activePage - 1, pageSize, idSort);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// console.log('default effect')
|
||||||
|
const localIdSort = localStorage.getItem('id-sort') === 'true';
|
||||||
|
const localPageSize = parseInt(localStorage.getItem('page-size')) || ITEMS_PER_PAGE;
|
||||||
|
setIdSort(localIdSort);
|
||||||
|
setPageSize(localPageSize);
|
||||||
|
loadChannels(0, localPageSize, localIdSort)
|
||||||
|
.then()
|
||||||
|
.catch((reason) => {
|
||||||
|
showError(reason);
|
||||||
|
});
|
||||||
|
fetchGroups().then();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const manageChannel = async (id, action, record, 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('操作成功完成!');
|
||||||
|
let channel = res.data.data;
|
||||||
|
let newChannels = [...channels];
|
||||||
|
if (action === 'delete') {
|
||||||
|
|
||||||
|
} else {
|
||||||
|
record.status = channel.status;
|
||||||
|
}
|
||||||
|
setChannels(newChannels);
|
||||||
|
} else {
|
||||||
|
showError(message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderStatus = (status) => {
|
||||||
|
switch (status) {
|
||||||
|
case 1:
|
||||||
|
return <Tag size="large" color="green">已启用</Tag>;
|
||||||
|
case 2:
|
||||||
|
return (
|
||||||
|
<Tag size="large" color="yellow">
|
||||||
|
已禁用
|
||||||
|
</Tag>
|
||||||
|
);
|
||||||
|
case 3:
|
||||||
|
return (
|
||||||
|
<Tag size="large" color="yellow">
|
||||||
|
自动禁用
|
||||||
|
</Tag>
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return (
|
||||||
|
<Tag size="large" color="grey">
|
||||||
|
未知状态
|
||||||
|
</Tag>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderResponseTime = (responseTime) => {
|
||||||
|
let time = responseTime / 1000;
|
||||||
|
time = time.toFixed(2) + ' 秒';
|
||||||
|
if (responseTime === 0) {
|
||||||
|
return <Tag size="large" color="grey">未测试</Tag>;
|
||||||
|
} else if (responseTime <= 1000) {
|
||||||
|
return <Tag size="large" color="green">{time}</Tag>;
|
||||||
|
} else if (responseTime <= 3000) {
|
||||||
|
return <Tag size="large" color="lime">{time}</Tag>;
|
||||||
|
} else if (responseTime <= 5000) {
|
||||||
|
return <Tag size="large" color="yellow">{time}</Tag>;
|
||||||
|
} else {
|
||||||
|
return <Tag size="large" color="red">{time}</Tag>;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const searchChannels = async (searchKeyword, searchGroup, searchModel) => {
|
||||||
|
if (searchKeyword === '' && searchGroup === '' && searchModel === '') {
|
||||||
|
// if keyword is blank, load files instead.
|
||||||
|
await loadChannels(0, pageSize, idSort);
|
||||||
|
setActivePage(1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSearching(true);
|
||||||
|
const res = await API.get(`/api/channel/search?keyword=${searchKeyword}&group=${searchGroup}&model=${searchModel}`);
|
||||||
|
const { success, message, data } = res.data;
|
||||||
|
if (success) {
|
||||||
|
setChannels(data);
|
||||||
|
setActivePage(1);
|
||||||
|
} else {
|
||||||
|
showError(message);
|
||||||
|
}
|
||||||
|
setSearching(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const testChannel = async (record, model) => {
|
||||||
|
const res = await API.get(`/api/channel/test/${record.id}?model=${model}`);
|
||||||
|
const { success, message, time } = res.data;
|
||||||
|
if (success) {
|
||||||
|
record.response_time = time * 1000;
|
||||||
|
record.test_time = Date.now() / 1000;
|
||||||
|
showInfo(`通道 ${record.name} 测试成功,耗时 ${time.toFixed(2)} 秒。`);
|
||||||
|
} else {
|
||||||
|
showError(message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const testChannels = async (scope) => {
|
||||||
|
const res = await API.get(`/api/channel/test?scope=${scope}`);
|
||||||
|
const { success, message } = res.data;
|
||||||
|
if (success) {
|
||||||
|
showInfo('已成功开始测试通道,请刷新页面查看结果。');
|
||||||
|
} else {
|
||||||
|
showError(message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteAllDisabledChannels = async () => {
|
||||||
|
const res = await API.delete(`/api/channel/disabled`);
|
||||||
|
const { success, message, data } = res.data;
|
||||||
|
if (success) {
|
||||||
|
showSuccess(`已删除所有禁用渠道,共计 ${data} 个`);
|
||||||
|
await refresh();
|
||||||
|
} else {
|
||||||
|
showError(message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateChannelBalance = async (record) => {
|
||||||
|
const res = await API.get(`/api/channel/update_balance/${record.id}/`);
|
||||||
|
const { success, message, balance } = res.data;
|
||||||
|
if (success) {
|
||||||
|
record.balance = balance;
|
||||||
|
record.balance_updated_time = Date.now() / 1000;
|
||||||
|
showInfo(`通道 ${record.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('已更新完毕所有已启用通道余额!');
|
||||||
|
} else {
|
||||||
|
showError(message);
|
||||||
|
}
|
||||||
|
setUpdatingBalance(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const batchDeleteChannels = async () => {
|
||||||
|
if (selectedChannels.length === 0) {
|
||||||
|
showError('请先选择要删除的通道!');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoading(true);
|
||||||
|
let ids = [];
|
||||||
|
selectedChannels.forEach((channel) => {
|
||||||
|
ids.push(channel.id);
|
||||||
|
});
|
||||||
|
const res = await API.post(`/api/channel/batch`, { ids: ids });
|
||||||
|
const { success, message, data } = res.data;
|
||||||
|
if (success) {
|
||||||
|
showSuccess(`已删除 ${data} 个通道!`);
|
||||||
|
await refresh();
|
||||||
|
} else {
|
||||||
|
showError(message);
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const fixChannelsAbilities = async () => {
|
||||||
|
const res = await API.post(`/api/channel/fix`);
|
||||||
|
const { success, message, data } = res.data;
|
||||||
|
if (success) {
|
||||||
|
showSuccess(`已修复 ${data} 个通道!`);
|
||||||
|
await refresh();
|
||||||
|
} else {
|
||||||
|
showError(message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let pageData = channels.slice((activePage - 1) * pageSize, activePage * pageSize);
|
||||||
|
|
||||||
|
const handlePageChange = page => {
|
||||||
|
setActivePage(page);
|
||||||
|
if (page === Math.ceil(channels.length / pageSize) + 1) {
|
||||||
|
// In this case we have to load more data and then append them.
|
||||||
|
loadChannels(page - 1, pageSize, idSort).then(r => {
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePageSizeChange = async (size) => {
|
||||||
|
localStorage.setItem('page-size', size + '');
|
||||||
|
setPageSize(size);
|
||||||
|
setActivePage(1);
|
||||||
|
loadChannels(0, size, idSort)
|
||||||
|
.then()
|
||||||
|
.catch((reason) => {
|
||||||
|
showError(reason);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchGroups = async () => {
|
||||||
|
try {
|
||||||
|
let res = await API.get(`/api/group/`);
|
||||||
|
// add 'all' option
|
||||||
|
// res.data.data.unshift('all');
|
||||||
|
setGroupOptions(res.data.data.map((group) => ({
|
||||||
|
label: group,
|
||||||
|
value: group
|
||||||
|
})));
|
||||||
|
} catch (error) {
|
||||||
|
showError(error.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeEdit = () => {
|
||||||
|
setShowEdit(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRow = (record, index) => {
|
||||||
|
if (record.status !== 1) {
|
||||||
|
return {
|
||||||
|
style: {
|
||||||
|
background: 'var(--semi-color-disabled-border)'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<EditChannel refresh={refresh} visible={showEdit} handleClose={closeEdit} editingChannel={editingChannel} />
|
||||||
|
<div style={{ display: "flex", placeItems: "center", justifyContent: "space-between" }}>
|
||||||
|
<Form onSubmit={() => {
|
||||||
|
searchChannels(searchKeyword, searchGroup, searchModel);
|
||||||
|
}} labelPosition="left">
|
||||||
|
<div style={{ display: 'flex' }}>
|
||||||
|
<Space>
|
||||||
|
<Form.Input
|
||||||
|
field="search_keyword"
|
||||||
|
label="搜索"
|
||||||
|
placeholder="ID,名称和密钥 ..."
|
||||||
|
value={searchKeyword}
|
||||||
|
loading={searching}
|
||||||
|
onChange={(v) => {
|
||||||
|
setSearchKeyword(v.trim());
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{/* <Form.Input
|
||||||
|
field="search_model"
|
||||||
|
label="模型"
|
||||||
|
placeholder="模型关键字"
|
||||||
|
value={searchModel}
|
||||||
|
loading={searching}
|
||||||
|
onChange={(v) => {
|
||||||
|
setSearchModel(v.trim());
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Form.Select field="group" label="分组" optionList={groupOptions} onChange={(v) => {
|
||||||
|
setSearchGroup(v);
|
||||||
|
searchChannels(searchKeyword, v, searchModel);
|
||||||
|
}} /> */}
|
||||||
|
<Button label="查询" type="primary" htmlType="submit" className="btn-margin-right"
|
||||||
|
style={{ marginRight: 8 }}>查询</Button>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
<div style={{
|
||||||
|
display: isMobile() ? '' : 'flex',
|
||||||
|
marginTop: isMobile() ? 0 : -45,
|
||||||
|
zIndex: 999,
|
||||||
|
position: 'relative',
|
||||||
|
pointerEvents: 'none'
|
||||||
|
}}>
|
||||||
|
<Space style={{ pointerEvents: 'auto', marginTop: isMobile() ? 0 : 45 }}>
|
||||||
|
<Button theme="light" type="primary" style={{ marginRight: 8 }} onClick={
|
||||||
|
() => {
|
||||||
|
setEditingChannel({
|
||||||
|
id: undefined
|
||||||
|
});
|
||||||
|
setShowEdit(true);
|
||||||
|
}
|
||||||
|
}>添加新的渠道</Button>
|
||||||
|
<Popconfirm
|
||||||
|
title="确定?"
|
||||||
|
okType={'warning'}
|
||||||
|
onConfirm={() => { testChannels("all") }}
|
||||||
|
position={isMobile() ? 'top' : 'left'}
|
||||||
|
>
|
||||||
|
<Button theme="light" type="warning" style={{ marginRight: 8 }}>测试所有通道</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
<Popconfirm
|
||||||
|
title="确定?"
|
||||||
|
okType={'warning'}
|
||||||
|
onConfirm={() => { testChannels("disabled") }}
|
||||||
|
position={isMobile() ? 'top' : 'left'}
|
||||||
|
>
|
||||||
|
<Button theme="light" type="warning" style={{ marginRight: 8 }}>测试禁用渠道</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
{/* <Popconfirm
|
||||||
|
title="确定?"
|
||||||
|
okType={'secondary'}
|
||||||
|
onConfirm={updateAllChannelsBalance}
|
||||||
|
>
|
||||||
|
<Button theme="light" type="secondary" style={{ marginRight: 8 }}>更新所有已启用通道余额</Button>
|
||||||
|
</Popconfirm> */}
|
||||||
|
<Popconfirm
|
||||||
|
title="确定是否要删除禁用通道?"
|
||||||
|
content="此修改将不可逆"
|
||||||
|
okType={'danger'}
|
||||||
|
onConfirm={deleteAllDisabledChannels}
|
||||||
|
position={isMobile() ? 'top' : 'left'}
|
||||||
|
>
|
||||||
|
<Button theme="light" type="danger" style={{ marginRight: 8 }}>删除禁用通道</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
|
||||||
|
<Button theme="light" type="primary" style={{ marginRight: 8 }} onClick={refresh}>刷新</Button>
|
||||||
|
</Space>
|
||||||
|
{/*<div style={{width: '100%', pointerEvents: 'none', position: 'absolute'}}>*/}
|
||||||
|
|
||||||
|
{/*</div>*/}
|
||||||
|
</div>
|
||||||
|
{/* <div style={{ marginTop: 20 }}>
|
||||||
|
<Space>
|
||||||
|
<Typography.Text strong>开启批量删除</Typography.Text>
|
||||||
|
<Switch label="开启批量删除" uncheckedText="关" aria-label="是否开启批量删除" onChange={(v) => {
|
||||||
|
setEnableBatchDelete(v);
|
||||||
|
}}></Switch>
|
||||||
|
<Popconfirm
|
||||||
|
title="确定是否要删除所选通道?"
|
||||||
|
content="此修改将不可逆"
|
||||||
|
okType={'danger'}
|
||||||
|
onConfirm={batchDeleteChannels}
|
||||||
|
disabled={!enableBatchDelete}
|
||||||
|
position={'top'}
|
||||||
|
>
|
||||||
|
<Button disabled={!enableBatchDelete} theme="light" type="danger"
|
||||||
|
style={{ marginRight: 8 }}>删除所选通道</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
<Popconfirm
|
||||||
|
title="确定是否要修复数据库一致性?"
|
||||||
|
content="进行该操作时,可能导致渠道访问错误,请仅在数据库出现问题时使用"
|
||||||
|
okType={'warning'}
|
||||||
|
onConfirm={fixChannelsAbilities}
|
||||||
|
position={'top'}
|
||||||
|
>
|
||||||
|
<Button theme="light" type="secondary" style={{ marginRight: 8 }}>修复数据库一致性</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: 10, display: 'flex' }}>
|
||||||
|
<Space>
|
||||||
|
<Space>
|
||||||
|
<Typography.Text strong>使用ID排序</Typography.Text>
|
||||||
|
<Switch checked={idSort} label="使用ID排序" uncheckedText="关" aria-label="是否用ID排序" onChange={(v) => {
|
||||||
|
localStorage.setItem('id-sort', v + '');
|
||||||
|
setIdSort(v);
|
||||||
|
loadChannels(0, pageSize, v)
|
||||||
|
.then()
|
||||||
|
.catch((reason) => {
|
||||||
|
showError(reason);
|
||||||
|
});
|
||||||
|
}}></Switch>
|
||||||
|
</Space>
|
||||||
|
</Space>
|
||||||
|
</div> */}
|
||||||
|
</div>
|
||||||
|
<Table className={'channel-table'} style={{ marginTop: 15 }} columns={columns} dataSource={pageData} pagination={{
|
||||||
|
currentPage: activePage,
|
||||||
|
pageSize: pageSize,
|
||||||
|
total: channelCount,
|
||||||
|
pageSizeOpts: [10, 20, 50, 100],
|
||||||
|
showSizeChanger: true,
|
||||||
|
formatPageText: (page) => '',
|
||||||
|
onPageSizeChange: (size) => {
|
||||||
|
handlePageSizeChange(size).then();
|
||||||
|
},
|
||||||
|
onPageChange: handlePageChange
|
||||||
|
}} loading={loading} onRow={handleRow} rowSelection={
|
||||||
|
enableBatchDelete ?
|
||||||
|
{
|
||||||
|
onChange: (selectedRowKeys, selectedRows) => {
|
||||||
|
// console.log(`selectedRowKeys: ${selectedRowKeys}`, 'selectedRows: ', selectedRows);
|
||||||
|
setSelectedChannels(selectedRows);
|
||||||
|
}
|
||||||
|
} : null
|
||||||
|
} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ChannelsTable;
|
64
web/air/src/components/Footer.js
Normal file
64
web/air/src/components/Footer.js
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import { Container, Segment } from 'semantic-ui-react';
|
||||||
|
import { getFooterHTML, getSystemName } from '../helpers';
|
||||||
|
|
||||||
|
const Footer = () => {
|
||||||
|
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'>
|
||||||
|
{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>
|
||||||
|
由{' '}
|
||||||
|
<a href='https://github.com/songquanpeng' target='_blank'>
|
||||||
|
JustSong
|
||||||
|
</a>{' '}
|
||||||
|
构建,主题 air 来自{' '}
|
||||||
|
<a href='https://github.com/Calcium-Ion' target='_blank'>
|
||||||
|
Calon
|
||||||
|
</a>{' '},源代码遵循{' '}
|
||||||
|
<a href='https://opensource.org/licenses/mit-license.php'>
|
||||||
|
MIT 协议
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Container>
|
||||||
|
</Segment>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Footer;
|
58
web/air/src/components/GitHubOAuth.js
Normal file
58
web/air/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;
|
161
web/air/src/components/HeaderBar.js
Normal file
161
web/air/src/components/HeaderBar.js
Normal file
@ -0,0 +1,161 @@
|
|||||||
|
import React, { useContext, useEffect, useState } from 'react';
|
||||||
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
|
import { UserContext } from '../context/User';
|
||||||
|
|
||||||
|
import { API, getLogo, getSystemName, showSuccess } from '../helpers';
|
||||||
|
import '../index.css';
|
||||||
|
|
||||||
|
import fireworks from 'react-fireworks';
|
||||||
|
|
||||||
|
import { IconHelpCircle, IconKey, IconUser } from '@douyinfe/semi-icons';
|
||||||
|
import { Avatar, Dropdown, Layout, Nav, Switch } from '@douyinfe/semi-ui';
|
||||||
|
import { stringToColor } from '../helpers/render';
|
||||||
|
|
||||||
|
// HeaderBar Buttons
|
||||||
|
let headerButtons = [
|
||||||
|
{
|
||||||
|
text: '关于',
|
||||||
|
itemKey: 'about',
|
||||||
|
to: '/about',
|
||||||
|
icon: <IconHelpCircle />
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
if (localStorage.getItem('chat_link')) {
|
||||||
|
headerButtons.splice(1, 0, {
|
||||||
|
name: '聊天',
|
||||||
|
to: '/chat',
|
||||||
|
icon: 'comments'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const HeaderBar = () => {
|
||||||
|
const [userState, userDispatch] = useContext(UserContext);
|
||||||
|
let navigate = useNavigate();
|
||||||
|
|
||||||
|
const [showSidebar, setShowSidebar] = useState(false);
|
||||||
|
const [dark, setDark] = useState(false);
|
||||||
|
const systemName = getSystemName();
|
||||||
|
const logo = getLogo();
|
||||||
|
var themeMode = localStorage.getItem('theme-mode');
|
||||||
|
const currentDate = new Date();
|
||||||
|
// enable fireworks on new year(1.1 and 2.9-2.24)
|
||||||
|
const isNewYear = (currentDate.getMonth() === 0 && currentDate.getDate() === 1) || (currentDate.getMonth() === 1 && currentDate.getDate() >= 9 && currentDate.getDate() <= 24);
|
||||||
|
|
||||||
|
async function logout() {
|
||||||
|
setShowSidebar(false);
|
||||||
|
await API.get('/api/user/logout');
|
||||||
|
showSuccess('注销成功!');
|
||||||
|
userDispatch({ type: 'logout' });
|
||||||
|
localStorage.removeItem('user');
|
||||||
|
navigate('/login');
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleNewYearClick = () => {
|
||||||
|
fireworks.init('root', {});
|
||||||
|
fireworks.start();
|
||||||
|
setTimeout(() => {
|
||||||
|
fireworks.stop();
|
||||||
|
setTimeout(() => {
|
||||||
|
window.location.reload();
|
||||||
|
}, 10000);
|
||||||
|
}, 3000);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (themeMode === 'dark') {
|
||||||
|
switchMode(true);
|
||||||
|
}
|
||||||
|
if (isNewYear) {
|
||||||
|
console.log('Happy New Year!');
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const switchMode = (model) => {
|
||||||
|
const body = document.body;
|
||||||
|
if (!model) {
|
||||||
|
body.removeAttribute('theme-mode');
|
||||||
|
localStorage.setItem('theme-mode', 'light');
|
||||||
|
} else {
|
||||||
|
body.setAttribute('theme-mode', 'dark');
|
||||||
|
localStorage.setItem('theme-mode', 'dark');
|
||||||
|
}
|
||||||
|
setDark(model);
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Layout>
|
||||||
|
<div style={{ width: '100%' }}>
|
||||||
|
<Nav
|
||||||
|
mode={'horizontal'}
|
||||||
|
// bodyStyle={{ height: 100 }}
|
||||||
|
renderWrapper={({ itemElement, isSubNav, isInSubNav, props }) => {
|
||||||
|
const routerMap = {
|
||||||
|
about: '/about',
|
||||||
|
login: '/login',
|
||||||
|
register: '/register'
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
style={{ textDecoration: 'none' }}
|
||||||
|
to={routerMap[props.itemKey]}
|
||||||
|
>
|
||||||
|
{itemElement}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
selectedKeys={[]}
|
||||||
|
// items={headerButtons}
|
||||||
|
onSelect={key => {
|
||||||
|
|
||||||
|
}}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
{isNewYear &&
|
||||||
|
// happy new year
|
||||||
|
<Dropdown
|
||||||
|
position="bottomRight"
|
||||||
|
render={
|
||||||
|
<Dropdown.Menu>
|
||||||
|
<Dropdown.Item onClick={handleNewYearClick}>Happy New Year!!!</Dropdown.Item>
|
||||||
|
</Dropdown.Menu>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Nav.Item itemKey={'new-year'} text={'🏮'} />
|
||||||
|
</Dropdown>
|
||||||
|
}
|
||||||
|
<Nav.Item itemKey={'about'} icon={<IconHelpCircle />} />
|
||||||
|
<Switch checkedText="🌞" size={'large'} checked={dark} uncheckedText="🌙" onChange={switchMode} />
|
||||||
|
{userState.user ?
|
||||||
|
<>
|
||||||
|
<Dropdown
|
||||||
|
position="bottomRight"
|
||||||
|
render={
|
||||||
|
<Dropdown.Menu>
|
||||||
|
<Dropdown.Item onClick={logout}>退出</Dropdown.Item>
|
||||||
|
</Dropdown.Menu>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Avatar size="small" color={stringToColor(userState.user.username)} style={{ margin: 4 }}>
|
||||||
|
{userState.user.username[0]}
|
||||||
|
</Avatar>
|
||||||
|
<span>{userState.user.username}</span>
|
||||||
|
</Dropdown>
|
||||||
|
</>
|
||||||
|
:
|
||||||
|
<>
|
||||||
|
<Nav.Item itemKey={'login'} text={'登录'} icon={<IconKey />} />
|
||||||
|
<Nav.Item itemKey={'register'} text={'注册'} icon={<IconUser />} />
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
</Nav>
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default HeaderBar;
|
14
web/air/src/components/Loading.js
Normal file
14
web/air/src/components/Loading.js
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Dimmer, Loader, Segment } 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;
|
254
web/air/src/components/LoginForm.js
Normal file
254
web/air/src/components/LoginForm.js
Normal file
@ -0,0 +1,254 @@
|
|||||||
|
import React, { useContext, useEffect, useState } from 'react';
|
||||||
|
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
|
import { UserContext } from '../context/User';
|
||||||
|
import { API, getLogo, showError, showInfo, showSuccess } from '../helpers';
|
||||||
|
import { onGitHubOAuthClicked } from './utils';
|
||||||
|
import Turnstile from 'react-turnstile';
|
||||||
|
import { Button, Card, Divider, Form, Icon, Layout, Modal } from '@douyinfe/semi-ui';
|
||||||
|
import Title from '@douyinfe/semi-ui/lib/es/typography/title';
|
||||||
|
import Text from '@douyinfe/semi-ui/lib/es/typography/text';
|
||||||
|
import TelegramLoginButton from 'react-telegram-login';
|
||||||
|
|
||||||
|
import { IconGithubLogo } from '@douyinfe/semi-icons';
|
||||||
|
import WeChatIcon from './WeChatIcon';
|
||||||
|
|
||||||
|
const LoginForm = () => {
|
||||||
|
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);
|
||||||
|
const [turnstileEnabled, setTurnstileEnabled] = useState(false);
|
||||||
|
const [turnstileSiteKey, setTurnstileSiteKey] = useState('');
|
||||||
|
const [turnstileToken, setTurnstileToken] = useState('');
|
||||||
|
let navigate = useNavigate();
|
||||||
|
const [status, setStatus] = useState({});
|
||||||
|
const logo = getLogo();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (searchParams.get('expired')) {
|
||||||
|
showError('未登录或登录已过期,请重新登录!');
|
||||||
|
}
|
||||||
|
let status = localStorage.getItem('status');
|
||||||
|
if (status) {
|
||||||
|
status = JSON.parse(status);
|
||||||
|
setStatus(status);
|
||||||
|
if (status.turnstile_check) {
|
||||||
|
setTurnstileEnabled(true);
|
||||||
|
setTurnstileSiteKey(status.turnstile_site_key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const [showWeChatLoginModal, setShowWeChatLoginModal] = useState(false);
|
||||||
|
|
||||||
|
const onWeChatLoginClicked = () => {
|
||||||
|
setShowWeChatLoginModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSubmitWeChatVerificationCode = async () => {
|
||||||
|
if (turnstileEnabled && turnstileToken === '') {
|
||||||
|
showInfo('请稍后几秒重试,Turnstile 正在检查用户环境!');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
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('登录成功!');
|
||||||
|
setShowWeChatLoginModal(false);
|
||||||
|
} else {
|
||||||
|
showError(message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function handleChange(name, value) {
|
||||||
|
setInputs((inputs) => ({ ...inputs, [name]: value }));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit(e) {
|
||||||
|
if (turnstileEnabled && turnstileToken === '') {
|
||||||
|
showInfo('请稍后几秒重试,Turnstile 正在检查用户环境!');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSubmitted(true);
|
||||||
|
if (username && password) {
|
||||||
|
const res = await API.post(`/api/user/login?turnstile=${turnstileToken}`, {
|
||||||
|
username,
|
||||||
|
password
|
||||||
|
});
|
||||||
|
const { success, message, data } = res.data;
|
||||||
|
if (success) {
|
||||||
|
userDispatch({ type: 'login', payload: data });
|
||||||
|
localStorage.setItem('user', JSON.stringify(data));
|
||||||
|
showSuccess('登录成功!');
|
||||||
|
if (username === 'root' && password === '123456') {
|
||||||
|
Modal.error({ title: '您正在使用默认密码!', content: '请立刻修改默认密码!', centered: true });
|
||||||
|
}
|
||||||
|
navigate('/token');
|
||||||
|
} else {
|
||||||
|
showError(message);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
showError('请输入用户名和密码!');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加Telegram登录处理函数
|
||||||
|
const onTelegramLoginClicked = async (response) => {
|
||||||
|
const fields = ['id', 'first_name', 'last_name', 'username', 'photo_url', 'auth_date', 'hash', 'lang'];
|
||||||
|
const params = {};
|
||||||
|
fields.forEach((field) => {
|
||||||
|
if (response[field]) {
|
||||||
|
params[field] = response[field];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const res = await API.get(`/api/oauth/telegram/login`, { params });
|
||||||
|
const { success, message, data } = res.data;
|
||||||
|
if (success) {
|
||||||
|
userDispatch({ type: 'login', payload: data });
|
||||||
|
localStorage.setItem('user', JSON.stringify(data));
|
||||||
|
showSuccess('登录成功!');
|
||||||
|
navigate('/');
|
||||||
|
} else {
|
||||||
|
showError(message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Layout>
|
||||||
|
<Layout.Header>
|
||||||
|
</Layout.Header>
|
||||||
|
<Layout.Content>
|
||||||
|
<div style={{ justifyContent: 'center', display: 'flex', marginTop: 120 }}>
|
||||||
|
<div style={{ width: 500 }}>
|
||||||
|
<Card>
|
||||||
|
<Title heading={2} style={{ textAlign: 'center' }}>
|
||||||
|
用户登录
|
||||||
|
</Title>
|
||||||
|
<Form>
|
||||||
|
<Form.Input
|
||||||
|
field={'username'}
|
||||||
|
label={'用户名'}
|
||||||
|
placeholder="用户名"
|
||||||
|
name="username"
|
||||||
|
onChange={(value) => handleChange('username', value)}
|
||||||
|
/>
|
||||||
|
<Form.Input
|
||||||
|
field={'password'}
|
||||||
|
label={'密码'}
|
||||||
|
placeholder="密码"
|
||||||
|
name="password"
|
||||||
|
type="password"
|
||||||
|
onChange={(value) => handleChange('password', value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button theme="solid" style={{ width: '100%' }} type={'primary'} size="large"
|
||||||
|
htmlType={'submit'} onClick={handleSubmit}>
|
||||||
|
登录
|
||||||
|
</Button>
|
||||||
|
</Form>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', marginTop: 20 }}>
|
||||||
|
<Text>
|
||||||
|
没有账号请先 <Link to="/register">注册账号</Link>
|
||||||
|
</Text>
|
||||||
|
<Text>
|
||||||
|
忘记密码 <Link to="/reset">点击重置</Link>
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
{status.github_oauth || status.wechat_login || status.telegram_oauth ? (
|
||||||
|
<>
|
||||||
|
<Divider margin="12px" align="center">
|
||||||
|
第三方登录
|
||||||
|
</Divider>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'center', marginTop: 20 }}>
|
||||||
|
{status.github_oauth ? (
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<IconGithubLogo />}
|
||||||
|
onClick={() => onGitHubOAuthClicked(status.github_client_id)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<></>
|
||||||
|
)}
|
||||||
|
{status.wechat_login ? (
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
style={{ color: 'rgba(var(--semi-green-5), 1)' }}
|
||||||
|
icon={<Icon svg={<WeChatIcon />} />}
|
||||||
|
onClick={onWeChatLoginClicked}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<></>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status.telegram_oauth ? (
|
||||||
|
<TelegramLoginButton dataOnauth={onTelegramLoginClicked} botName={status.telegram_bot_name} />
|
||||||
|
) : (
|
||||||
|
<></>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<></>
|
||||||
|
)}
|
||||||
|
<Modal
|
||||||
|
title="微信扫码登录"
|
||||||
|
visible={showWeChatLoginModal}
|
||||||
|
maskClosable={true}
|
||||||
|
onOk={onSubmitWeChatVerificationCode}
|
||||||
|
onCancel={() => setShowWeChatLoginModal(false)}
|
||||||
|
okText={'登录'}
|
||||||
|
size={'small'}
|
||||||
|
centered={true}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', alignItem: 'center', flexDirection: 'column' }}>
|
||||||
|
<img src={status.wechat_qrcode} />
|
||||||
|
</div>
|
||||||
|
<div style={{ textAlign: 'center' }}>
|
||||||
|
<p>
|
||||||
|
微信扫码关注公众号,输入「验证码」获取验证码(三分钟内有效)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Form size="large">
|
||||||
|
<Form.Input
|
||||||
|
field={'wechat_verification_code'}
|
||||||
|
placeholder="验证码"
|
||||||
|
label={'验证码'}
|
||||||
|
value={inputs.wechat_verification_code}
|
||||||
|
onChange={(value) => handleChange('wechat_verification_code', value)}
|
||||||
|
/>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
</Card>
|
||||||
|
{turnstileEnabled ? (
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'center', marginTop: 20 }}>
|
||||||
|
<Turnstile
|
||||||
|
sitekey={turnstileSiteKey}
|
||||||
|
onVerify={(token) => {
|
||||||
|
setTurnstileToken(token);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<></>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</Layout.Content>
|
||||||
|
</Layout>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LoginForm;
|
401
web/air/src/components/LogsTable.js
Normal file
401
web/air/src/components/LogsTable.js
Normal file
@ -0,0 +1,401 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { API, copy, isAdmin, showError, showSuccess, timestamp2string } from '../helpers';
|
||||||
|
|
||||||
|
import { Avatar, Button, Form, Layout, Modal, Select, Space, Spin, Table, Tag } from '@douyinfe/semi-ui';
|
||||||
|
import { ITEMS_PER_PAGE } from '../constants';
|
||||||
|
import { renderNumber, renderQuota, stringToColor } from '../helpers/render';
|
||||||
|
import Paragraph from '@douyinfe/semi-ui/lib/es/typography/paragraph';
|
||||||
|
|
||||||
|
const { Header } = Layout;
|
||||||
|
|
||||||
|
function renderTimestamp(timestamp) {
|
||||||
|
return (<>
|
||||||
|
{timestamp2string(timestamp)}
|
||||||
|
</>);
|
||||||
|
}
|
||||||
|
|
||||||
|
const MODE_OPTIONS = [{ key: 'all', text: '全部用户', value: 'all' }, { key: 'self', text: '当前用户', value: 'self' }];
|
||||||
|
|
||||||
|
const colors = ['amber', 'blue', 'cyan', 'green', 'grey', 'indigo', 'light-blue', 'lime', 'orange', 'pink', 'purple', 'red', 'teal', 'violet', 'yellow'];
|
||||||
|
|
||||||
|
function renderType(type) {
|
||||||
|
switch (type) {
|
||||||
|
case 1:
|
||||||
|
return <Tag color="cyan" size="large"> 充值 </Tag>;
|
||||||
|
case 2:
|
||||||
|
return <Tag color="lime" size="large"> 消费 </Tag>;
|
||||||
|
case 3:
|
||||||
|
return <Tag color="orange" size="large"> 管理 </Tag>;
|
||||||
|
case 4:
|
||||||
|
return <Tag color="purple" size="large"> 系统 </Tag>;
|
||||||
|
default:
|
||||||
|
return <Tag color="black" size="large"> 未知 </Tag>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderIsStream(bool) {
|
||||||
|
if (bool) {
|
||||||
|
return <Tag color="blue" size="large">流</Tag>;
|
||||||
|
} else {
|
||||||
|
return <Tag color="purple" size="large">非流</Tag>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderUseTime(type) {
|
||||||
|
const time = parseInt(type);
|
||||||
|
if (time < 101) {
|
||||||
|
return <Tag color="green" size="large"> {time} s </Tag>;
|
||||||
|
} else if (time < 300) {
|
||||||
|
return <Tag color="orange" size="large"> {time} s </Tag>;
|
||||||
|
} else {
|
||||||
|
return <Tag color="red" size="large"> {time} s </Tag>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const LogsTable = () => {
|
||||||
|
const columns = [{
|
||||||
|
title: '时间', dataIndex: 'timestamp2string'
|
||||||
|
}, {
|
||||||
|
title: '渠道',
|
||||||
|
dataIndex: 'channel',
|
||||||
|
className: isAdmin() ? 'tableShow' : 'tableHiddle',
|
||||||
|
render: (text, record, index) => {
|
||||||
|
return (isAdminUser ? record.type === 0 || record.type === 2 ? <div>
|
||||||
|
{<Tag color={colors[parseInt(text) % colors.length]} size="large"> {text} </Tag>}
|
||||||
|
</div> : <></> : <></>);
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
title: '用户',
|
||||||
|
dataIndex: 'username',
|
||||||
|
className: isAdmin() ? 'tableShow' : 'tableHiddle',
|
||||||
|
render: (text, record, index) => {
|
||||||
|
return (isAdminUser ? <div>
|
||||||
|
<Avatar size="small" color={stringToColor(text)} style={{ marginRight: 4 }}
|
||||||
|
onClick={() => showUserInfo(record.user_id)}>
|
||||||
|
{typeof text === 'string' && text.slice(0, 1)}
|
||||||
|
</Avatar>
|
||||||
|
{text}
|
||||||
|
</div> : <></>);
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
title: '令牌', dataIndex: 'token_name', render: (text, record, index) => {
|
||||||
|
return (record.type === 0 || record.type === 2 ? <div>
|
||||||
|
<Tag color="grey" size="large" onClick={() => {
|
||||||
|
copyText(text);
|
||||||
|
}}> {text} </Tag>
|
||||||
|
</div> : <></>);
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
title: '类型', dataIndex: 'type', render: (text, record, index) => {
|
||||||
|
return (<div>
|
||||||
|
{renderType(text)}
|
||||||
|
</div>);
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
title: '模型', dataIndex: 'model_name', render: (text, record, index) => {
|
||||||
|
return (record.type === 0 || record.type === 2 ? <div>
|
||||||
|
<Tag color={stringToColor(text)} size="large" onClick={() => {
|
||||||
|
copyText(text);
|
||||||
|
}}> {text} </Tag>
|
||||||
|
</div> : <></>);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// {
|
||||||
|
// title: '用时', dataIndex: 'use_time', render: (text, record, index) => {
|
||||||
|
// return (<div>
|
||||||
|
// <Space>
|
||||||
|
// {renderUseTime(text)}
|
||||||
|
// {renderIsStream(record.is_stream)}
|
||||||
|
// </Space>
|
||||||
|
// </div>);
|
||||||
|
// }
|
||||||
|
// },
|
||||||
|
{
|
||||||
|
title: '提示', dataIndex: 'prompt_tokens', render: (text, record, index) => {
|
||||||
|
return (record.type === 0 || record.type === 2 ? <div>
|
||||||
|
{<span> {text} </span>}
|
||||||
|
</div> : <></>);
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
title: '补全', dataIndex: 'completion_tokens', render: (text, record, index) => {
|
||||||
|
return (parseInt(text) > 0 && (record.type === 0 || record.type === 2) ? <div>
|
||||||
|
{<span> {text} </span>}
|
||||||
|
</div> : <></>);
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
title: '花费', dataIndex: 'quota', render: (text, record, index) => {
|
||||||
|
return (record.type === 0 || record.type === 2 ? <div>
|
||||||
|
{renderQuota(text, 6)}
|
||||||
|
</div> : <></>);
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
title: '详情', dataIndex: 'content', render: (text, record, index) => {
|
||||||
|
return <Paragraph ellipsis={{ rows: 2, showTooltip: { type: 'popover', opts: { style: { width: 240 } } } }}
|
||||||
|
style={{ maxWidth: 240 }}>
|
||||||
|
{text}
|
||||||
|
</Paragraph>;
|
||||||
|
}
|
||||||
|
}];
|
||||||
|
|
||||||
|
const [logs, setLogs] = useState([]);
|
||||||
|
const [showStat, setShowStat] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [loadingStat, setLoadingStat] = useState(false);
|
||||||
|
const [activePage, setActivePage] = useState(1);
|
||||||
|
const [logCount, setLogCount] = useState(ITEMS_PER_PAGE);
|
||||||
|
const [pageSize, setPageSize] = useState(ITEMS_PER_PAGE);
|
||||||
|
const [searchKeyword, setSearchKeyword] = useState('');
|
||||||
|
const [searching, setSearching] = useState(false);
|
||||||
|
const [logType, setLogType] = useState(0);
|
||||||
|
const isAdminUser = isAdmin();
|
||||||
|
let now = new Date();
|
||||||
|
// 初始化start_timestamp为前一天
|
||||||
|
const [inputs, setInputs] = useState({
|
||||||
|
username: '',
|
||||||
|
token_name: '',
|
||||||
|
model_name: '',
|
||||||
|
start_timestamp: timestamp2string(now.getTime() / 1000 - 86400),
|
||||||
|
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 handleInputChange = (value, name) => {
|
||||||
|
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 () => {
|
||||||
|
setLoadingStat(true);
|
||||||
|
if (isAdminUser) {
|
||||||
|
await getLogStat();
|
||||||
|
} else {
|
||||||
|
await getLogSelfStat();
|
||||||
|
}
|
||||||
|
setShowStat(true);
|
||||||
|
setLoadingStat(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const showUserInfo = async (userId) => {
|
||||||
|
if (!isAdminUser) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const res = await API.get(`/api/user/${userId}`);
|
||||||
|
const { success, message, data } = res.data;
|
||||||
|
if (success) {
|
||||||
|
Modal.info({
|
||||||
|
title: '用户信息', content: <div style={{ padding: 12 }}>
|
||||||
|
<p>用户名: {data.username}</p>
|
||||||
|
<p>余额: {renderQuota(data.quota)}</p>
|
||||||
|
<p>已用额度:{renderQuota(data.used_quota)}</p>
|
||||||
|
<p>请求次数:{renderNumber(data.request_count)}</p>
|
||||||
|
</div>, centered: true
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
showError(message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const setLogsFormat = (logs) => {
|
||||||
|
for (let i = 0; i < logs.length; i++) {
|
||||||
|
logs[i].timestamp2string = timestamp2string(logs[i].created_at);
|
||||||
|
logs[i].key = '' + logs[i].id;
|
||||||
|
}
|
||||||
|
// data.key = '' + data.id
|
||||||
|
setLogs(logs);
|
||||||
|
setLogCount(logs.length + ITEMS_PER_PAGE);
|
||||||
|
// console.log(logCount);
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadLogs = async (startIdx, pageSize, logType = 0) => {
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
let url = '';
|
||||||
|
let localStartTimestamp = Date.parse(start_timestamp) / 1000;
|
||||||
|
let localEndTimestamp = Date.parse(end_timestamp) / 1000;
|
||||||
|
if (isAdminUser) {
|
||||||
|
url = `/api/log/?p=${startIdx}&page_size=${pageSize}&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}&page_size=${pageSize}&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) {
|
||||||
|
setLogsFormat(data);
|
||||||
|
} else {
|
||||||
|
let newLogs = [...logs];
|
||||||
|
newLogs.splice(startIdx * pageSize, data.length, ...data);
|
||||||
|
setLogsFormat(newLogs);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
showError(message);
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const pageData = logs.slice((activePage - 1) * pageSize, activePage * pageSize);
|
||||||
|
|
||||||
|
const handlePageChange = page => {
|
||||||
|
setActivePage(page);
|
||||||
|
if (page === Math.ceil(logs.length / pageSize) + 1) {
|
||||||
|
// In this case we have to load more data and then append them.
|
||||||
|
loadLogs(page - 1, pageSize).then(r => {
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePageSizeChange = async (size) => {
|
||||||
|
localStorage.setItem('page-size', size + '');
|
||||||
|
setPageSize(size);
|
||||||
|
setActivePage(1);
|
||||||
|
loadLogs(0, size)
|
||||||
|
.then()
|
||||||
|
.catch((reason) => {
|
||||||
|
showError(reason);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const refresh = async (localLogType) => {
|
||||||
|
// setLoading(true);
|
||||||
|
setActivePage(1);
|
||||||
|
await loadLogs(0, pageSize, localLogType);
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyText = async (text) => {
|
||||||
|
if (await copy(text)) {
|
||||||
|
showSuccess('已复制:' + text);
|
||||||
|
} else {
|
||||||
|
// setSearchKeyword(text);
|
||||||
|
Modal.error({ title: '无法复制到剪贴板,请手动复制', content: text });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// console.log('default effect')
|
||||||
|
const localPageSize = parseInt(localStorage.getItem('page-size')) || ITEMS_PER_PAGE;
|
||||||
|
setPageSize(localPageSize);
|
||||||
|
loadLogs(0, localPageSize)
|
||||||
|
.then()
|
||||||
|
.catch((reason) => {
|
||||||
|
showError(reason);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const searchLogs = async () => {
|
||||||
|
if (searchKeyword === '') {
|
||||||
|
// if keyword is blank, load files instead.
|
||||||
|
await loadLogs(0, pageSize);
|
||||||
|
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);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (<>
|
||||||
|
<Layout>
|
||||||
|
<Header>
|
||||||
|
<Spin spinning={loadingStat}>
|
||||||
|
<h3>使用明细(总消耗额度:
|
||||||
|
<span onClick={handleEyeClick} style={{
|
||||||
|
cursor: 'pointer', color: 'gray'
|
||||||
|
}}>{showStat ? renderQuota(stat.quota) : '点击查看'}</span>
|
||||||
|
)
|
||||||
|
</h3>
|
||||||
|
</Spin>
|
||||||
|
</Header>
|
||||||
|
<Form layout="horizontal" style={{ marginTop: 10 }}>
|
||||||
|
<>
|
||||||
|
<Form.Input field="token_name" label="令牌名称" style={{ width: 176 }} value={token_name}
|
||||||
|
placeholder={'可选值'} name="token_name"
|
||||||
|
onChange={value => handleInputChange(value, 'token_name')} />
|
||||||
|
<Form.Input field="model_name" label="模型名称" style={{ width: 176 }} value={model_name}
|
||||||
|
placeholder="可选值"
|
||||||
|
name="model_name"
|
||||||
|
onChange={value => handleInputChange(value, 'model_name')} />
|
||||||
|
<Form.DatePicker field="start_timestamp" label="起始时间" style={{ width: 272 }}
|
||||||
|
initValue={start_timestamp}
|
||||||
|
value={start_timestamp} type="dateTime"
|
||||||
|
name="start_timestamp"
|
||||||
|
onChange={value => handleInputChange(value, 'start_timestamp')} />
|
||||||
|
<Form.DatePicker field="end_timestamp" fluid label="结束时间" style={{ width: 272 }}
|
||||||
|
initValue={end_timestamp}
|
||||||
|
value={end_timestamp} type="dateTime"
|
||||||
|
name="end_timestamp"
|
||||||
|
onChange={value => handleInputChange(value, 'end_timestamp')} />
|
||||||
|
{isAdminUser && <>
|
||||||
|
<Form.Input field="channel" label="渠道 ID" style={{ width: 176 }} value={channel}
|
||||||
|
placeholder="可选值" name="channel"
|
||||||
|
onChange={value => handleInputChange(value, 'channel')} />
|
||||||
|
<Form.Input field="username" label="用户名称" style={{ width: 176 }} value={username}
|
||||||
|
placeholder={'可选值'} name="username"
|
||||||
|
onChange={value => handleInputChange(value, 'username')} />
|
||||||
|
</>}
|
||||||
|
<Form.Section>
|
||||||
|
<Button label="查询" type="primary" htmlType="submit" className="btn-margin-right"
|
||||||
|
onClick={refresh} loading={loading}>查询</Button>
|
||||||
|
</Form.Section>
|
||||||
|
</>
|
||||||
|
</Form>
|
||||||
|
<Table style={{ marginTop: 5 }} columns={columns} dataSource={pageData} pagination={{
|
||||||
|
currentPage: activePage,
|
||||||
|
pageSize: pageSize,
|
||||||
|
total: logCount,
|
||||||
|
pageSizeOpts: [10, 20, 50, 100],
|
||||||
|
showSizeChanger: true,
|
||||||
|
onPageSizeChange: (size) => {
|
||||||
|
handlePageSizeChange(size).then();
|
||||||
|
},
|
||||||
|
onPageChange: handlePageChange
|
||||||
|
}} />
|
||||||
|
<Select defaultValue="0" style={{ width: 120 }} onChange={(value) => {
|
||||||
|
setLogType(parseInt(value));
|
||||||
|
refresh(parseInt(value)).then();
|
||||||
|
}}>
|
||||||
|
<Select.Option value="0">全部</Select.Option>
|
||||||
|
<Select.Option value="1">充值</Select.Option>
|
||||||
|
<Select.Option value="2">消费</Select.Option>
|
||||||
|
<Select.Option value="3">管理</Select.Option>
|
||||||
|
<Select.Option value="4">系统</Select.Option>
|
||||||
|
</Select>
|
||||||
|
</Layout>
|
||||||
|
</>);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LogsTable;
|
454
web/air/src/components/MjLogsTable.js
Normal file
454
web/air/src/components/MjLogsTable.js
Normal file
@ -0,0 +1,454 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { API, copy, isAdmin, showError, showSuccess, timestamp2string } from '../helpers';
|
||||||
|
|
||||||
|
import { Banner, Button, Form, ImagePreview, Layout, Modal, Progress, Table, Tag, Typography } from '@douyinfe/semi-ui';
|
||||||
|
import { ITEMS_PER_PAGE } from '../constants';
|
||||||
|
|
||||||
|
|
||||||
|
const colors = ['amber', 'blue', 'cyan', 'green', 'grey', 'indigo',
|
||||||
|
'light-blue', 'lime', 'orange', 'pink',
|
||||||
|
'purple', 'red', 'teal', 'violet', 'yellow'
|
||||||
|
];
|
||||||
|
|
||||||
|
function renderType(type) {
|
||||||
|
switch (type) {
|
||||||
|
case 'IMAGINE':
|
||||||
|
return <Tag color="blue" size="large">绘图</Tag>;
|
||||||
|
case 'UPSCALE':
|
||||||
|
return <Tag color="orange" size="large">放大</Tag>;
|
||||||
|
case 'VARIATION':
|
||||||
|
return <Tag color="purple" size="large">变换</Tag>;
|
||||||
|
case 'HIGH_VARIATION':
|
||||||
|
return <Tag color="purple" size="large">强变换</Tag>;
|
||||||
|
case 'LOW_VARIATION':
|
||||||
|
return <Tag color="purple" size="large">弱变换</Tag>;
|
||||||
|
case 'PAN':
|
||||||
|
return <Tag color="cyan" size="large">平移</Tag>;
|
||||||
|
case 'DESCRIBE':
|
||||||
|
return <Tag color="yellow" size="large">图生文</Tag>;
|
||||||
|
case 'BLEND':
|
||||||
|
return <Tag color="lime" size="large">图混合</Tag>;
|
||||||
|
case 'SHORTEN':
|
||||||
|
return <Tag color="pink" size="large">缩词</Tag>;
|
||||||
|
case 'REROLL':
|
||||||
|
return <Tag color="indigo" size="large">重绘</Tag>;
|
||||||
|
case 'INPAINT':
|
||||||
|
return <Tag color="violet" size="large">局部重绘-提交</Tag>;
|
||||||
|
case 'ZOOM':
|
||||||
|
return <Tag color="teal" size="large">变焦</Tag>;
|
||||||
|
case 'CUSTOM_ZOOM':
|
||||||
|
return <Tag color="teal" size="large">自定义变焦-提交</Tag>;
|
||||||
|
case 'MODAL':
|
||||||
|
return <Tag color="green" size="large">窗口处理</Tag>;
|
||||||
|
case 'SWAP_FACE':
|
||||||
|
return <Tag color="light-green" size="large">换脸</Tag>;
|
||||||
|
default:
|
||||||
|
return <Tag color="white" size="large">未知</Tag>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function renderCode(code) {
|
||||||
|
switch (code) {
|
||||||
|
case 1:
|
||||||
|
return <Tag color="green" size="large">已提交</Tag>;
|
||||||
|
case 21:
|
||||||
|
return <Tag color="lime" size="large">等待中</Tag>;
|
||||||
|
case 22:
|
||||||
|
return <Tag color="orange" size="large">重复提交</Tag>;
|
||||||
|
case 0:
|
||||||
|
return <Tag color="yellow" size="large">未提交</Tag>;
|
||||||
|
default:
|
||||||
|
return <Tag color="white" size="large">未知</Tag>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function renderStatus(type) {
|
||||||
|
// Ensure all cases are string literals by adding quotes.
|
||||||
|
switch (type) {
|
||||||
|
case 'SUCCESS':
|
||||||
|
return <Tag color="green" size="large">成功</Tag>;
|
||||||
|
case 'NOT_START':
|
||||||
|
return <Tag color="grey" size="large">未启动</Tag>;
|
||||||
|
case 'SUBMITTED':
|
||||||
|
return <Tag color="yellow" size="large">队列中</Tag>;
|
||||||
|
case 'IN_PROGRESS':
|
||||||
|
return <Tag color="blue" size="large">执行中</Tag>;
|
||||||
|
case 'FAILURE':
|
||||||
|
return <Tag color="red" size="large">失败</Tag>;
|
||||||
|
case 'MODAL':
|
||||||
|
return <Tag color="yellow" size="large">窗口等待</Tag>;
|
||||||
|
default:
|
||||||
|
return <Tag color="white" size="large">未知</Tag>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderTimestamp = (timestampInSeconds) => {
|
||||||
|
const date = new Date(timestampInSeconds * 1000); // 从秒转换为毫秒
|
||||||
|
|
||||||
|
const year = date.getFullYear(); // 获取年份
|
||||||
|
const month = ('0' + (date.getMonth() + 1)).slice(-2); // 获取月份,从0开始需要+1,并保证两位数
|
||||||
|
const day = ('0' + date.getDate()).slice(-2); // 获取日期,并保证两位数
|
||||||
|
const hours = ('0' + date.getHours()).slice(-2); // 获取小时,并保证两位数
|
||||||
|
const minutes = ('0' + date.getMinutes()).slice(-2); // 获取分钟,并保证两位数
|
||||||
|
const seconds = ('0' + date.getSeconds()).slice(-2); // 获取秒钟,并保证两位数
|
||||||
|
|
||||||
|
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`; // 格式化输出
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
const LogsTable = () => {
|
||||||
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
|
const [modalContent, setModalContent] = useState('');
|
||||||
|
const columns = [
|
||||||
|
{
|
||||||
|
title: '提交时间',
|
||||||
|
dataIndex: 'submit_time',
|
||||||
|
render: (text, record, index) => {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{renderTimestamp(text / 1000)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '渠道',
|
||||||
|
dataIndex: 'channel_id',
|
||||||
|
className: isAdmin() ? 'tableShow' : 'tableHiddle',
|
||||||
|
render: (text, record, index) => {
|
||||||
|
return (
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Tag color={colors[parseInt(text) % colors.length]} size="large" onClick={() => {
|
||||||
|
copyText(text); // 假设copyText是用于文本复制的函数
|
||||||
|
}}> {text} </Tag>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '类型',
|
||||||
|
dataIndex: 'action',
|
||||||
|
render: (text, record, index) => {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{renderType(text)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '任务ID',
|
||||||
|
dataIndex: 'mj_id',
|
||||||
|
render: (text, record, index) => {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{text}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '提交结果',
|
||||||
|
dataIndex: 'code',
|
||||||
|
className: isAdmin() ? 'tableShow' : 'tableHiddle',
|
||||||
|
render: (text, record, index) => {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{renderCode(text)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '任务状态',
|
||||||
|
dataIndex: 'status',
|
||||||
|
className: isAdmin() ? 'tableShow' : 'tableHiddle',
|
||||||
|
render: (text, record, index) => {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{renderStatus(text)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '进度',
|
||||||
|
dataIndex: 'progress',
|
||||||
|
render: (text, record, index) => {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{
|
||||||
|
// 转换例如100%为数字100,如果text未定义,返回0
|
||||||
|
<Progress stroke={record.status === 'FAILURE' ? 'var(--semi-color-warning)' : null}
|
||||||
|
percent={text ? parseInt(text.replace('%', '')) : 0} showInfo={true}
|
||||||
|
aria-label="drawing progress" />
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '结果图片',
|
||||||
|
dataIndex: 'image_url',
|
||||||
|
render: (text, record, index) => {
|
||||||
|
if (!text) {
|
||||||
|
return '无';
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
setModalImageUrl(text); // 更新图片URL状态
|
||||||
|
setIsModalOpenurl(true); // 打开模态框
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
查看图片
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Prompt',
|
||||||
|
dataIndex: 'prompt',
|
||||||
|
render: (text, record, index) => {
|
||||||
|
// 如果text未定义,返回替代文本,例如空字符串''或其他
|
||||||
|
if (!text) {
|
||||||
|
return '无';
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Typography.Text
|
||||||
|
ellipsis={{ showTooltip: true }}
|
||||||
|
style={{ width: 100 }}
|
||||||
|
onClick={() => {
|
||||||
|
setModalContent(text);
|
||||||
|
setIsModalOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{text}
|
||||||
|
</Typography.Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'PromptEn',
|
||||||
|
dataIndex: 'prompt_en',
|
||||||
|
render: (text, record, index) => {
|
||||||
|
// 如果text未定义,返回替代文本,例如空字符串''或其他
|
||||||
|
if (!text) {
|
||||||
|
return '无';
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Typography.Text
|
||||||
|
ellipsis={{ showTooltip: true }}
|
||||||
|
style={{ width: 100 }}
|
||||||
|
onClick={() => {
|
||||||
|
setModalContent(text);
|
||||||
|
setIsModalOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{text}
|
||||||
|
</Typography.Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '失败原因',
|
||||||
|
dataIndex: 'fail_reason',
|
||||||
|
render: (text, record, index) => {
|
||||||
|
// 如果text未定义,返回替代文本,例如空字符串''或其他
|
||||||
|
if (!text) {
|
||||||
|
return '无';
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Typography.Text
|
||||||
|
ellipsis={{ showTooltip: true }}
|
||||||
|
style={{ width: 100 }}
|
||||||
|
onClick={() => {
|
||||||
|
setModalContent(text);
|
||||||
|
setIsModalOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{text}
|
||||||
|
</Typography.Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
];
|
||||||
|
|
||||||
|
const [logs, setLogs] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [activePage, setActivePage] = useState(1);
|
||||||
|
const [logCount, setLogCount] = useState(ITEMS_PER_PAGE);
|
||||||
|
const [logType, setLogType] = useState(0);
|
||||||
|
const isAdminUser = isAdmin();
|
||||||
|
const [isModalOpenurl, setIsModalOpenurl] = useState(false);
|
||||||
|
const [showBanner, setShowBanner] = useState(false);
|
||||||
|
|
||||||
|
// 定义模态框图片URL的状态和更新函数
|
||||||
|
const [modalImageUrl, setModalImageUrl] = useState('');
|
||||||
|
let now = new Date();
|
||||||
|
// 初始化start_timestamp为前一天
|
||||||
|
const [inputs, setInputs] = useState({
|
||||||
|
channel_id: '',
|
||||||
|
mj_id: '',
|
||||||
|
start_timestamp: timestamp2string(now.getTime() / 1000 - 2592000),
|
||||||
|
end_timestamp: timestamp2string(now.getTime() / 1000 + 3600)
|
||||||
|
});
|
||||||
|
const { channel_id, mj_id, start_timestamp, end_timestamp } = inputs;
|
||||||
|
|
||||||
|
const [stat, setStat] = useState({
|
||||||
|
quota: 0,
|
||||||
|
token: 0
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleInputChange = (value, name) => {
|
||||||
|
setInputs((inputs) => ({ ...inputs, [name]: value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
const setLogsFormat = (logs) => {
|
||||||
|
for (let i = 0; i < logs.length; i++) {
|
||||||
|
logs[i].timestamp2string = timestamp2string(logs[i].created_at);
|
||||||
|
logs[i].key = '' + logs[i].id;
|
||||||
|
}
|
||||||
|
// data.key = '' + data.id
|
||||||
|
setLogs(logs);
|
||||||
|
setLogCount(logs.length + ITEMS_PER_PAGE);
|
||||||
|
// console.log(logCount);
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadLogs = async (startIdx) => {
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
let url = '';
|
||||||
|
let localStartTimestamp = Date.parse(start_timestamp);
|
||||||
|
let localEndTimestamp = Date.parse(end_timestamp);
|
||||||
|
if (isAdminUser) {
|
||||||
|
url = `/api/mj/?p=${startIdx}&channel_id=${channel_id}&mj_id=${mj_id}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}`;
|
||||||
|
} else {
|
||||||
|
url = `/api/mj/self/?p=${startIdx}&mj_id=${mj_id}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}`;
|
||||||
|
}
|
||||||
|
const res = await API.get(url);
|
||||||
|
const { success, message, data } = res.data;
|
||||||
|
if (success) {
|
||||||
|
if (startIdx === 0) {
|
||||||
|
setLogsFormat(data);
|
||||||
|
} else {
|
||||||
|
let newLogs = [...logs];
|
||||||
|
newLogs.splice(startIdx * ITEMS_PER_PAGE, data.length, ...data);
|
||||||
|
setLogsFormat(newLogs);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
showError(message);
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const pageData = logs.slice((activePage - 1) * ITEMS_PER_PAGE, activePage * ITEMS_PER_PAGE);
|
||||||
|
|
||||||
|
const handlePageChange = page => {
|
||||||
|
setActivePage(page);
|
||||||
|
if (page === Math.ceil(logs.length / ITEMS_PER_PAGE) + 1) {
|
||||||
|
// In this case we have to load more data and then append them.
|
||||||
|
loadLogs(page - 1).then(r => {
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const refresh = async () => {
|
||||||
|
// setLoading(true);
|
||||||
|
setActivePage(1);
|
||||||
|
await loadLogs(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyText = async (text) => {
|
||||||
|
if (await copy(text)) {
|
||||||
|
showSuccess('已复制:' + text);
|
||||||
|
} else {
|
||||||
|
// setSearchKeyword(text);
|
||||||
|
Modal.error({ title: '无法复制到剪贴板,请手动复制', content: text });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
refresh().then();
|
||||||
|
}, [logType]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const mjNotifyEnabled = localStorage.getItem('mj_notify_enabled');
|
||||||
|
if (mjNotifyEnabled !== 'true') {
|
||||||
|
setShowBanner(true);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
|
||||||
|
<Layout>
|
||||||
|
{isAdminUser && showBanner ? <Banner
|
||||||
|
type="info"
|
||||||
|
description="当前未开启Midjourney回调,部分项目可能无法获得绘图结果,可在运营设置中开启。"
|
||||||
|
/> : <></>
|
||||||
|
}
|
||||||
|
<Form layout="horizontal" style={{ marginTop: 10 }}>
|
||||||
|
<>
|
||||||
|
<Form.Input field="channel_id" label="渠道 ID" style={{ width: 176 }} value={channel_id}
|
||||||
|
placeholder={'可选值'} name="channel_id"
|
||||||
|
onChange={value => handleInputChange(value, 'channel_id')} />
|
||||||
|
<Form.Input field="mj_id" label="任务 ID" style={{ width: 176 }} value={mj_id}
|
||||||
|
placeholder="可选值"
|
||||||
|
name="mj_id"
|
||||||
|
onChange={value => handleInputChange(value, 'mj_id')} />
|
||||||
|
<Form.DatePicker field="start_timestamp" label="起始时间" style={{ width: 272 }}
|
||||||
|
initValue={start_timestamp}
|
||||||
|
value={start_timestamp} type="dateTime"
|
||||||
|
name="start_timestamp"
|
||||||
|
onChange={value => handleInputChange(value, 'start_timestamp')} />
|
||||||
|
<Form.DatePicker field="end_timestamp" fluid label="结束时间" style={{ width: 272 }}
|
||||||
|
initValue={end_timestamp}
|
||||||
|
value={end_timestamp} type="dateTime"
|
||||||
|
name="end_timestamp"
|
||||||
|
onChange={value => handleInputChange(value, 'end_timestamp')} />
|
||||||
|
|
||||||
|
<Form.Section>
|
||||||
|
<Button label="查询" type="primary" htmlType="submit" className="btn-margin-right"
|
||||||
|
onClick={refresh}>查询</Button>
|
||||||
|
</Form.Section>
|
||||||
|
</>
|
||||||
|
</Form>
|
||||||
|
<Table style={{ marginTop: 5 }} columns={columns} dataSource={pageData} pagination={{
|
||||||
|
currentPage: activePage,
|
||||||
|
pageSize: ITEMS_PER_PAGE,
|
||||||
|
total: logCount,
|
||||||
|
pageSizeOpts: [10, 20, 50, 100],
|
||||||
|
onPageChange: handlePageChange
|
||||||
|
}} loading={loading} />
|
||||||
|
<Modal
|
||||||
|
visible={isModalOpen}
|
||||||
|
onOk={() => setIsModalOpen(false)}
|
||||||
|
onCancel={() => setIsModalOpen(false)}
|
||||||
|
closable={null}
|
||||||
|
bodyStyle={{ height: '400px', overflow: 'auto' }} // 设置模态框内容区域样式
|
||||||
|
width={800} // 设置模态框宽度
|
||||||
|
>
|
||||||
|
<p style={{ whiteSpace: 'pre-line' }}>{modalContent}</p>
|
||||||
|
</Modal>
|
||||||
|
<ImagePreview
|
||||||
|
src={modalImageUrl}
|
||||||
|
visible={isModalOpenurl}
|
||||||
|
onVisibleChange={(visible) => setIsModalOpenurl(visible)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
</Layout>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LogsTable;
|
389
web/air/src/components/OperationSetting.js
Normal file
389
web/air/src/components/OperationSetting.js
Normal file
@ -0,0 +1,389 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { Divider, Form, Grid, Header } from 'semantic-ui-react';
|
||||||
|
import { API, showError, showSuccess, timestamp2string, verifyJSON } from '../helpers';
|
||||||
|
|
||||||
|
const OperationSetting = () => {
|
||||||
|
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'>
|
||||||
|
通用设置
|
||||||
|
</Header>
|
||||||
|
<Form.Group widths={4}>
|
||||||
|
<Form.Input
|
||||||
|
label='充值链接'
|
||||||
|
name='TopUpLink'
|
||||||
|
onChange={handleInputChange}
|
||||||
|
autoComplete='new-password'
|
||||||
|
value={inputs.TopUpLink}
|
||||||
|
type='link'
|
||||||
|
placeholder='例如发卡网站的购买链接'
|
||||||
|
/>
|
||||||
|
<Form.Input
|
||||||
|
label='聊天页面链接'
|
||||||
|
name='ChatLink'
|
||||||
|
onChange={handleInputChange}
|
||||||
|
autoComplete='new-password'
|
||||||
|
value={inputs.ChatLink}
|
||||||
|
type='link'
|
||||||
|
placeholder='例如 ChatGPT Next Web 的部署地址'
|
||||||
|
/>
|
||||||
|
<Form.Input
|
||||||
|
label='单位美元额度'
|
||||||
|
name='QuotaPerUnit'
|
||||||
|
onChange={handleInputChange}
|
||||||
|
autoComplete='new-password'
|
||||||
|
value={inputs.QuotaPerUnit}
|
||||||
|
type='number'
|
||||||
|
step='0.01'
|
||||||
|
placeholder='一单位货币能兑换的额度'
|
||||||
|
/>
|
||||||
|
<Form.Input
|
||||||
|
label='失败重试次数'
|
||||||
|
name='RetryTimes'
|
||||||
|
type={'number'}
|
||||||
|
step='1'
|
||||||
|
min='0'
|
||||||
|
onChange={handleInputChange}
|
||||||
|
autoComplete='new-password'
|
||||||
|
value={inputs.RetryTimes}
|
||||||
|
placeholder='失败重试次数'
|
||||||
|
/>
|
||||||
|
</Form.Group>
|
||||||
|
<Form.Group inline>
|
||||||
|
<Form.Checkbox
|
||||||
|
checked={inputs.DisplayInCurrencyEnabled === 'true'}
|
||||||
|
label='以货币形式显示额度'
|
||||||
|
name='DisplayInCurrencyEnabled'
|
||||||
|
onChange={handleInputChange}
|
||||||
|
/>
|
||||||
|
<Form.Checkbox
|
||||||
|
checked={inputs.DisplayTokenStatEnabled === 'true'}
|
||||||
|
label='Billing 相关 API 显示令牌额度而非用户额度'
|
||||||
|
name='DisplayTokenStatEnabled'
|
||||||
|
onChange={handleInputChange}
|
||||||
|
/>
|
||||||
|
<Form.Checkbox
|
||||||
|
checked={inputs.ApproximateTokenEnabled === 'true'}
|
||||||
|
label='使用近似的方式估算 token 数以减少计算量'
|
||||||
|
name='ApproximateTokenEnabled'
|
||||||
|
onChange={handleInputChange}
|
||||||
|
/>
|
||||||
|
</Form.Group>
|
||||||
|
<Form.Button onClick={() => {
|
||||||
|
submitConfig('general').then();
|
||||||
|
}}>保存通用设置</Form.Button>
|
||||||
|
<Divider />
|
||||||
|
<Header as='h3'>
|
||||||
|
日志设置
|
||||||
|
</Header>
|
||||||
|
<Form.Group inline>
|
||||||
|
<Form.Checkbox
|
||||||
|
checked={inputs.LogConsumeEnabled === 'true'}
|
||||||
|
label='启用额度消费日志记录'
|
||||||
|
name='LogConsumeEnabled'
|
||||||
|
onChange={handleInputChange}
|
||||||
|
/>
|
||||||
|
</Form.Group>
|
||||||
|
<Form.Group widths={4}>
|
||||||
|
<Form.Input label='目标时间' value={historyTimestamp} type='datetime-local'
|
||||||
|
name='history_timestamp'
|
||||||
|
onChange={(e, { name, value }) => {
|
||||||
|
setHistoryTimestamp(value);
|
||||||
|
}} />
|
||||||
|
</Form.Group>
|
||||||
|
<Form.Button onClick={() => {
|
||||||
|
deleteHistoryLogs().then();
|
||||||
|
}}>清理历史日志</Form.Button>
|
||||||
|
<Divider />
|
||||||
|
<Header as='h3'>
|
||||||
|
监控设置
|
||||||
|
</Header>
|
||||||
|
<Form.Group widths={3}>
|
||||||
|
<Form.Input
|
||||||
|
label='最长响应时间'
|
||||||
|
name='ChannelDisableThreshold'
|
||||||
|
onChange={handleInputChange}
|
||||||
|
autoComplete='new-password'
|
||||||
|
value={inputs.ChannelDisableThreshold}
|
||||||
|
type='number'
|
||||||
|
min='0'
|
||||||
|
placeholder='单位秒,当运行通道全部测试时,超过此时间将自动禁用通道'
|
||||||
|
/>
|
||||||
|
<Form.Input
|
||||||
|
label='额度提醒阈值'
|
||||||
|
name='QuotaRemindThreshold'
|
||||||
|
onChange={handleInputChange}
|
||||||
|
autoComplete='new-password'
|
||||||
|
value={inputs.QuotaRemindThreshold}
|
||||||
|
type='number'
|
||||||
|
min='0'
|
||||||
|
placeholder='低于此额度时将发送邮件提醒用户'
|
||||||
|
/>
|
||||||
|
</Form.Group>
|
||||||
|
<Form.Group inline>
|
||||||
|
<Form.Checkbox
|
||||||
|
checked={inputs.AutomaticDisableChannelEnabled === 'true'}
|
||||||
|
label='失败时自动禁用通道'
|
||||||
|
name='AutomaticDisableChannelEnabled'
|
||||||
|
onChange={handleInputChange}
|
||||||
|
/>
|
||||||
|
<Form.Checkbox
|
||||||
|
checked={inputs.AutomaticEnableChannelEnabled === 'true'}
|
||||||
|
label='成功时自动启用通道'
|
||||||
|
name='AutomaticEnableChannelEnabled'
|
||||||
|
onChange={handleInputChange}
|
||||||
|
/>
|
||||||
|
</Form.Group>
|
||||||
|
<Form.Button onClick={() => {
|
||||||
|
submitConfig('monitor').then();
|
||||||
|
}}>保存监控设置</Form.Button>
|
||||||
|
<Divider />
|
||||||
|
<Header as='h3'>
|
||||||
|
额度设置
|
||||||
|
</Header>
|
||||||
|
<Form.Group widths={4}>
|
||||||
|
<Form.Input
|
||||||
|
label='新用户初始额度'
|
||||||
|
name='QuotaForNewUser'
|
||||||
|
onChange={handleInputChange}
|
||||||
|
autoComplete='new-password'
|
||||||
|
value={inputs.QuotaForNewUser}
|
||||||
|
type='number'
|
||||||
|
min='0'
|
||||||
|
placeholder='例如:100'
|
||||||
|
/>
|
||||||
|
<Form.Input
|
||||||
|
label='请求预扣费额度'
|
||||||
|
name='PreConsumedQuota'
|
||||||
|
onChange={handleInputChange}
|
||||||
|
autoComplete='new-password'
|
||||||
|
value={inputs.PreConsumedQuota}
|
||||||
|
type='number'
|
||||||
|
min='0'
|
||||||
|
placeholder='请求结束后多退少补'
|
||||||
|
/>
|
||||||
|
<Form.Input
|
||||||
|
label='邀请新用户奖励额度'
|
||||||
|
name='QuotaForInviter'
|
||||||
|
onChange={handleInputChange}
|
||||||
|
autoComplete='new-password'
|
||||||
|
value={inputs.QuotaForInviter}
|
||||||
|
type='number'
|
||||||
|
min='0'
|
||||||
|
placeholder='例如:2000'
|
||||||
|
/>
|
||||||
|
<Form.Input
|
||||||
|
label='新用户使用邀请码奖励额度'
|
||||||
|
name='QuotaForInvitee'
|
||||||
|
onChange={handleInputChange}
|
||||||
|
autoComplete='new-password'
|
||||||
|
value={inputs.QuotaForInvitee}
|
||||||
|
type='number'
|
||||||
|
min='0'
|
||||||
|
placeholder='例如:1000'
|
||||||
|
/>
|
||||||
|
</Form.Group>
|
||||||
|
<Form.Button onClick={() => {
|
||||||
|
submitConfig('quota').then();
|
||||||
|
}}>保存额度设置</Form.Button>
|
||||||
|
<Divider />
|
||||||
|
<Header as='h3'>
|
||||||
|
倍率设置
|
||||||
|
</Header>
|
||||||
|
<Form.Group widths='equal'>
|
||||||
|
<Form.TextArea
|
||||||
|
label='模型倍率'
|
||||||
|
name='ModelRatio'
|
||||||
|
onChange={handleInputChange}
|
||||||
|
style={{ minHeight: 250, fontFamily: 'JetBrains Mono, Consolas' }}
|
||||||
|
autoComplete='new-password'
|
||||||
|
value={inputs.ModelRatio}
|
||||||
|
placeholder='为一个 JSON 文本,键为模型名称,值为倍率'
|
||||||
|
/>
|
||||||
|
</Form.Group>
|
||||||
|
<Form.Group widths='equal'>
|
||||||
|
<Form.TextArea
|
||||||
|
label='补全倍率'
|
||||||
|
name='CompletionRatio'
|
||||||
|
onChange={handleInputChange}
|
||||||
|
style={{ minHeight: 250, fontFamily: 'JetBrains Mono, Consolas' }}
|
||||||
|
autoComplete='new-password'
|
||||||
|
value={inputs.CompletionRatio}
|
||||||
|
placeholder='为一个 JSON 文本,键为模型名称,值为倍率,此处的倍率设置是模型补全倍率相较于提示倍率的比例,使用该设置可强制覆盖 One API 的内部比例'
|
||||||
|
/>
|
||||||
|
</Form.Group>
|
||||||
|
<Form.Group widths='equal'>
|
||||||
|
<Form.TextArea
|
||||||
|
label='分组倍率'
|
||||||
|
name='GroupRatio'
|
||||||
|
onChange={handleInputChange}
|
||||||
|
style={{ minHeight: 250, fontFamily: 'JetBrains Mono, Consolas' }}
|
||||||
|
autoComplete='new-password'
|
||||||
|
value={inputs.GroupRatio}
|
||||||
|
placeholder='为一个 JSON 文本,键为分组名称,值为倍率'
|
||||||
|
/>
|
||||||
|
</Form.Group>
|
||||||
|
<Form.Button onClick={() => {
|
||||||
|
submitConfig('ratio').then();
|
||||||
|
}}>保存倍率设置</Form.Button>
|
||||||
|
</Form>
|
||||||
|
</Grid.Column>
|
||||||
|
</Grid>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default OperationSetting;
|
225
web/air/src/components/OtherSetting.js
Normal file
225
web/air/src/components/OtherSetting.js
Normal file
@ -0,0 +1,225 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { Button, Divider, Form, Grid, Header, Message, Modal } from 'semantic-ui-react';
|
||||||
|
import { API, showError, showSuccess } from '../helpers';
|
||||||
|
import { marked } from 'marked';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
|
const OtherSetting = () => {
|
||||||
|
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 submitFooter = async () => {
|
||||||
|
await updateOption('Footer', inputs.Footer);
|
||||||
|
};
|
||||||
|
|
||||||
|
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'>通用设置</Header>
|
||||||
|
<Form.Button onClick={checkUpdate}>检查更新</Form.Button>
|
||||||
|
<Form.Group widths='equal'>
|
||||||
|
<Form.TextArea
|
||||||
|
label='公告'
|
||||||
|
placeholder='在此输入新的公告内容,支持 Markdown & HTML 代码'
|
||||||
|
value={inputs.Notice}
|
||||||
|
name='Notice'
|
||||||
|
onChange={handleInputChange}
|
||||||
|
style={{ minHeight: 150, fontFamily: 'JetBrains Mono, Consolas' }}
|
||||||
|
/>
|
||||||
|
</Form.Group>
|
||||||
|
<Form.Button onClick={submitNotice}>保存公告</Form.Button>
|
||||||
|
<Divider />
|
||||||
|
<Header as='h3'>个性化设置</Header>
|
||||||
|
<Form.Group widths='equal'>
|
||||||
|
<Form.Input
|
||||||
|
label='系统名称'
|
||||||
|
placeholder='在此输入系统名称'
|
||||||
|
value={inputs.SystemName}
|
||||||
|
name='SystemName'
|
||||||
|
onChange={handleInputChange}
|
||||||
|
/>
|
||||||
|
</Form.Group>
|
||||||
|
<Form.Button onClick={submitSystemName}>设置系统名称</Form.Button>
|
||||||
|
<Form.Group widths='equal'>
|
||||||
|
<Form.Input
|
||||||
|
label={<label>主题名称(<Link
|
||||||
|
to='https://github.com/songquanpeng/one-api/blob/main/web/README.md'>当前可用主题</Link>)</label>}
|
||||||
|
placeholder='请输入主题名称'
|
||||||
|
value={inputs.Theme}
|
||||||
|
name='Theme'
|
||||||
|
onChange={handleInputChange}
|
||||||
|
/>
|
||||||
|
</Form.Group>
|
||||||
|
<Form.Button onClick={submitTheme}>设置主题(重启生效)</Form.Button>
|
||||||
|
<Form.Group widths='equal'>
|
||||||
|
<Form.Input
|
||||||
|
label='Logo 图片地址'
|
||||||
|
placeholder='在此输入 Logo 图片地址'
|
||||||
|
value={inputs.Logo}
|
||||||
|
name='Logo'
|
||||||
|
type='url'
|
||||||
|
onChange={handleInputChange}
|
||||||
|
/>
|
||||||
|
</Form.Group>
|
||||||
|
<Form.Button onClick={submitLogo}>设置 Logo</Form.Button>
|
||||||
|
<Form.Group widths='equal'>
|
||||||
|
<Form.TextArea
|
||||||
|
label='首页内容'
|
||||||
|
placeholder='在此输入首页内容,支持 Markdown & HTML 代码,设置后首页的状态信息将不再显示。如果输入的是一个链接,则会使用该链接作为 iframe 的 src 属性,这允许你设置任意网页作为首页。'
|
||||||
|
value={inputs.HomePageContent}
|
||||||
|
name='HomePageContent'
|
||||||
|
onChange={handleInputChange}
|
||||||
|
style={{ minHeight: 150, fontFamily: 'JetBrains Mono, Consolas' }}
|
||||||
|
/>
|
||||||
|
</Form.Group>
|
||||||
|
<Form.Button onClick={() => submitOption('HomePageContent')}>保存首页内容</Form.Button>
|
||||||
|
<Form.Group widths='equal'>
|
||||||
|
<Form.TextArea
|
||||||
|
label='关于'
|
||||||
|
placeholder='在此输入新的关于内容,支持 Markdown & HTML 代码。如果输入的是一个链接,则会使用该链接作为 iframe 的 src 属性,这允许你设置任意网页作为关于页面。'
|
||||||
|
value={inputs.About}
|
||||||
|
name='About'
|
||||||
|
onChange={handleInputChange}
|
||||||
|
style={{ minHeight: 150, fontFamily: 'JetBrains Mono, Consolas' }}
|
||||||
|
/>
|
||||||
|
</Form.Group>
|
||||||
|
<Form.Button onClick={submitAbout}>保存关于</Form.Button>
|
||||||
|
<Message>移除 One API
|
||||||
|
的版权标识必须首先获得授权,项目维护需要花费大量精力,如果本项目对你有意义,请主动支持本项目。</Message>
|
||||||
|
<Form.Group widths='equal'>
|
||||||
|
<Form.Input
|
||||||
|
label='页脚'
|
||||||
|
placeholder='在此输入新的页脚,留空则使用默认页脚,支持 HTML 代码'
|
||||||
|
value={inputs.Footer}
|
||||||
|
name='Footer'
|
||||||
|
onChange={handleInputChange}
|
||||||
|
/>
|
||||||
|
</Form.Group>
|
||||||
|
<Form.Button onClick={submitFooter}>设置页脚</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;
|
113
web/air/src/components/PasswordResetConfirm.js
Normal file
113
web/air/src/components/PasswordResetConfirm.js
Normal file
@ -0,0 +1,113 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { Button, Form, Grid, Header, Image, Segment } from 'semantic-ui-react';
|
||||||
|
import { API, copy, showError, showNotice } from '../helpers';
|
||||||
|
import { useSearchParams } from 'react-router-dom';
|
||||||
|
|
||||||
|
const PasswordResetConfirm = () => {
|
||||||
|
const [inputs, setInputs] = useState({
|
||||||
|
email: '',
|
||||||
|
token: ''
|
||||||
|
});
|
||||||
|
const { email, token } = inputs;
|
||||||
|
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const [disableButton, setDisableButton] = useState(false);
|
||||||
|
const [countdown, setCountdown] = useState(30);
|
||||||
|
|
||||||
|
const [newPassword, setNewPassword] = useState('');
|
||||||
|
|
||||||
|
const [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(`新密码已复制到剪贴板:${password}`);
|
||||||
|
} else {
|
||||||
|
showError(message);
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Grid textAlign="center" style={{ marginTop: '48px' }}>
|
||||||
|
<Grid.Column style={{ maxWidth: 450 }}>
|
||||||
|
<Header as="h2" color="" textAlign="center">
|
||||||
|
<Image src="/logo.png" /> 密码重置确认
|
||||||
|
</Header>
|
||||||
|
<Form size="large">
|
||||||
|
<Segment>
|
||||||
|
<Form.Input
|
||||||
|
fluid
|
||||||
|
icon="mail"
|
||||||
|
iconPosition="left"
|
||||||
|
placeholder="邮箱地址"
|
||||||
|
name="email"
|
||||||
|
value={email}
|
||||||
|
readOnly
|
||||||
|
/>
|
||||||
|
{newPassword && (
|
||||||
|
<Form.Input
|
||||||
|
fluid
|
||||||
|
icon="lock"
|
||||||
|
iconPosition="left"
|
||||||
|
placeholder="新密码"
|
||||||
|
name="newPassword"
|
||||||
|
value={newPassword}
|
||||||
|
readOnly
|
||||||
|
onClick={(e) => {
|
||||||
|
e.target.select();
|
||||||
|
navigator.clipboard.writeText(newPassword);
|
||||||
|
showNotice(`密码已复制到剪贴板:${newPassword}`);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
color="green"
|
||||||
|
fluid
|
||||||
|
size="large"
|
||||||
|
onClick={handleSubmit}
|
||||||
|
loading={loading}
|
||||||
|
disabled={disableButton}
|
||||||
|
>
|
||||||
|
{disableButton ? `密码重置完成` : '提交'}
|
||||||
|
</Button>
|
||||||
|
</Segment>
|
||||||
|
</Form>
|
||||||
|
</Grid.Column>
|
||||||
|
</Grid>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PasswordResetConfirm;
|
102
web/air/src/components/PasswordResetForm.js
Normal file
102
web/air/src/components/PasswordResetForm.js
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { Button, Form, Grid, Header, Image, Segment } from 'semantic-ui-react';
|
||||||
|
import { API, showError, showInfo, showSuccess } from '../helpers';
|
||||||
|
import Turnstile from 'react-turnstile';
|
||||||
|
|
||||||
|
const PasswordResetForm = () => {
|
||||||
|
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);
|
||||||
|
|
||||||
|
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('重置邮件发送成功,请检查邮箱!');
|
||||||
|
setInputs({ ...inputs, email: '' });
|
||||||
|
} else {
|
||||||
|
showError(message);
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Grid textAlign="center" style={{ marginTop: '48px' }}>
|
||||||
|
<Grid.Column style={{ maxWidth: 450 }}>
|
||||||
|
<Header as="h2" color="" textAlign="center">
|
||||||
|
<Image src="/logo.png" /> 密码重置
|
||||||
|
</Header>
|
||||||
|
<Form size="large">
|
||||||
|
<Segment>
|
||||||
|
<Form.Input
|
||||||
|
fluid
|
||||||
|
icon="mail"
|
||||||
|
iconPosition="left"
|
||||||
|
placeholder="邮箱地址"
|
||||||
|
name="email"
|
||||||
|
value={email}
|
||||||
|
onChange={handleChange}
|
||||||
|
/>
|
||||||
|
{turnstileEnabled ? (
|
||||||
|
<Turnstile
|
||||||
|
sitekey={turnstileSiteKey}
|
||||||
|
onVerify={(token) => {
|
||||||
|
setTurnstileToken(token);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<></>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
color="green"
|
||||||
|
fluid
|
||||||
|
size="large"
|
||||||
|
onClick={handleSubmit}
|
||||||
|
loading={loading}
|
||||||
|
disabled={disableButton}
|
||||||
|
>
|
||||||
|
{disableButton ? `重试 (${countdown})` : '提交'}
|
||||||
|
</Button>
|
||||||
|
</Segment>
|
||||||
|
</Form>
|
||||||
|
</Grid.Column>
|
||||||
|
</Grid>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PasswordResetForm;
|
653
web/air/src/components/PersonalSetting.js
Normal file
653
web/air/src/components/PersonalSetting.js
Normal file
@ -0,0 +1,653 @@
|
|||||||
|
import React, { useContext, useEffect, useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { API, copy, isRoot, showError, showInfo, showSuccess } from '../helpers';
|
||||||
|
import Turnstile from 'react-turnstile';
|
||||||
|
import { UserContext } from '../context/User';
|
||||||
|
import { onGitHubOAuthClicked } from './utils';
|
||||||
|
import {
|
||||||
|
Avatar,
|
||||||
|
Banner,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Descriptions,
|
||||||
|
Image,
|
||||||
|
Input,
|
||||||
|
InputNumber,
|
||||||
|
Layout,
|
||||||
|
Modal,
|
||||||
|
Space,
|
||||||
|
Tag,
|
||||||
|
Typography
|
||||||
|
} from '@douyinfe/semi-ui';
|
||||||
|
import { getQuotaPerUnit, renderQuota, renderQuotaWithPrompt, stringToColor } from '../helpers/render';
|
||||||
|
import TelegramLoginButton from 'react-telegram-login';
|
||||||
|
|
||||||
|
const PersonalSetting = () => {
|
||||||
|
const [userState, userDispatch] = useContext(UserContext);
|
||||||
|
let navigate = useNavigate();
|
||||||
|
|
||||||
|
const [inputs, setInputs] = useState({
|
||||||
|
wechat_verification_code: '',
|
||||||
|
email_verification_code: '',
|
||||||
|
email: '',
|
||||||
|
self_account_deletion_confirmation: '',
|
||||||
|
set_new_password: '',
|
||||||
|
set_new_password_confirmation: ''
|
||||||
|
});
|
||||||
|
const [status, setStatus] = useState({});
|
||||||
|
const [showChangePasswordModal, setShowChangePasswordModal] = useState(false);
|
||||||
|
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('');
|
||||||
|
// const [models, setModels] = useState([]);
|
||||||
|
const [openTransfer, setOpenTransfer] = useState(false);
|
||||||
|
const [transferAmount, setTransferAmount] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// let user = localStorage.getItem('user');
|
||||||
|
// if (user) {
|
||||||
|
// userDispatch({ type: 'login', payload: user });
|
||||||
|
// }
|
||||||
|
// console.log(localStorage.getItem('user'))
|
||||||
|
|
||||||
|
let status = localStorage.getItem('status');
|
||||||
|
if (status) {
|
||||||
|
status = JSON.parse(status);
|
||||||
|
setStatus(status);
|
||||||
|
if (status.turnstile_check) {
|
||||||
|
setTurnstileEnabled(true);
|
||||||
|
setTurnstileSiteKey(status.turnstile_site_key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
getUserData().then(
|
||||||
|
(res) => {
|
||||||
|
console.log(userState);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
// loadModels().then();
|
||||||
|
getAffLink().then();
|
||||||
|
setTransferAmount(getQuotaPerUnit());
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
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 = (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);
|
||||||
|
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);
|
||||||
|
} else {
|
||||||
|
showError(message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getUserData = async () => {
|
||||||
|
let res = await API.get(`/api/user/self`);
|
||||||
|
const { success, message, data } = res.data;
|
||||||
|
if (success) {
|
||||||
|
userDispatch({ type: 'login', payload: data });
|
||||||
|
} else {
|
||||||
|
showError(message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// const loadModels = async () => {
|
||||||
|
// let res = await API.get(`/api/user/models`);
|
||||||
|
// const { success, message, data } = res.data;
|
||||||
|
// if (success) {
|
||||||
|
// setModels(data);
|
||||||
|
// console.log(data);
|
||||||
|
// } 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 changePassword = async () => {
|
||||||
|
if (inputs.set_new_password !== inputs.set_new_password_confirmation) {
|
||||||
|
showError('两次输入的密码不一致!');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const res = await API.put(
|
||||||
|
`/api/user/self`,
|
||||||
|
{
|
||||||
|
password: inputs.set_new_password
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const { success, message } = res.data;
|
||||||
|
if (success) {
|
||||||
|
showSuccess('密码修改成功!');
|
||||||
|
setShowWeChatBindModal(false);
|
||||||
|
} else {
|
||||||
|
showError(message);
|
||||||
|
}
|
||||||
|
setShowChangePasswordModal(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const transfer = async () => {
|
||||||
|
if (transferAmount < getQuotaPerUnit()) {
|
||||||
|
showError('划转金额最低为' + renderQuota(getQuotaPerUnit()));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const res = await API.post(
|
||||||
|
`/api/user/aff_transfer`,
|
||||||
|
{
|
||||||
|
quota: transferAmount
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const { success, message } = res.data;
|
||||||
|
if (success) {
|
||||||
|
showSuccess(message);
|
||||||
|
setOpenTransfer(false);
|
||||||
|
getUserData().then();
|
||||||
|
} else {
|
||||||
|
showError(message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const sendVerificationCode = async () => {
|
||||||
|
if (inputs.email === '') {
|
||||||
|
showError('请输入邮箱!');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setDisableButton(true);
|
||||||
|
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 === '') {
|
||||||
|
showError('请输入邮箱验证码!');
|
||||||
|
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);
|
||||||
|
userState.user.email = inputs.email;
|
||||||
|
} else {
|
||||||
|
showError(message);
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getUsername = () => {
|
||||||
|
if (userState.user) {
|
||||||
|
return userState.user.username;
|
||||||
|
} else {
|
||||||
|
return 'null';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
setOpenTransfer(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyText = async (text) => {
|
||||||
|
if (await copy(text)) {
|
||||||
|
showSuccess('已复制:' + text);
|
||||||
|
} else {
|
||||||
|
// setSearchKeyword(text);
|
||||||
|
Modal.error({ title: '无法复制到剪贴板,请手动复制', content: text });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Layout>
|
||||||
|
<Layout.Content>
|
||||||
|
<Modal
|
||||||
|
title="请输入要划转的数量"
|
||||||
|
visible={openTransfer}
|
||||||
|
onOk={transfer}
|
||||||
|
onCancel={handleCancel}
|
||||||
|
maskClosable={false}
|
||||||
|
size={'small'}
|
||||||
|
centered={true}
|
||||||
|
>
|
||||||
|
<div style={{ marginTop: 20 }}>
|
||||||
|
<Typography.Text>{`可用额度${renderQuotaWithPrompt(userState?.user?.aff_quota)}`}</Typography.Text>
|
||||||
|
<Input style={{ marginTop: 5 }} value={userState?.user?.aff_quota} disabled={true}></Input>
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: 20 }}>
|
||||||
|
<Typography.Text>{`划转额度${renderQuotaWithPrompt(transferAmount)} 最低` + renderQuota(getQuotaPerUnit())}</Typography.Text>
|
||||||
|
<div>
|
||||||
|
<InputNumber min={0} style={{ marginTop: 5 }} value={transferAmount}
|
||||||
|
onChange={(value) => setTransferAmount(value)} disabled={false}></InputNumber>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
<div style={{ marginTop: 20 }}>
|
||||||
|
<Card
|
||||||
|
title={
|
||||||
|
<Card.Meta
|
||||||
|
avatar={<Avatar size="default" color={stringToColor(getUsername())}
|
||||||
|
style={{ marginRight: 4 }}>
|
||||||
|
{typeof getUsername() === 'string' && getUsername().slice(0, 1)}
|
||||||
|
</Avatar>}
|
||||||
|
title={<Typography.Text>{getUsername()}</Typography.Text>}
|
||||||
|
description={isRoot() ? <Tag color="red">管理员</Tag> : <Tag color="blue">普通用户</Tag>}
|
||||||
|
></Card.Meta>
|
||||||
|
}
|
||||||
|
headerExtraContent={
|
||||||
|
<>
|
||||||
|
<Space vertical align="start">
|
||||||
|
<Tag color="green">{'ID: ' + userState?.user?.id}</Tag>
|
||||||
|
<Tag color="blue">{userState?.user?.group}</Tag>
|
||||||
|
</Space>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
footer={
|
||||||
|
<Descriptions row>
|
||||||
|
<Descriptions.Item itemKey="当前余额">{renderQuota(userState?.user?.quota)}</Descriptions.Item>
|
||||||
|
<Descriptions.Item itemKey="历史消耗">{renderQuota(userState?.user?.used_quota)}</Descriptions.Item>
|
||||||
|
<Descriptions.Item itemKey="请求次数">{userState.user?.request_count}</Descriptions.Item>
|
||||||
|
</Descriptions>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Typography.Title heading={6}>调用信息</Typography.Title>
|
||||||
|
{/* <Typography.Title heading={6}>可用模型</Typography.Title>
|
||||||
|
<div style={{ marginTop: 10 }}>
|
||||||
|
<Space wrap>
|
||||||
|
{models.map((model) => (
|
||||||
|
<Tag key={model} color="cyan" onClick={() => {
|
||||||
|
copyText(model);
|
||||||
|
}}>
|
||||||
|
{model}
|
||||||
|
</Tag>
|
||||||
|
))}
|
||||||
|
</Space>
|
||||||
|
</div> */}
|
||||||
|
</Card>
|
||||||
|
{/* <Card
|
||||||
|
footer={
|
||||||
|
<div>
|
||||||
|
<Typography.Text>邀请链接</Typography.Text>
|
||||||
|
<Input
|
||||||
|
style={{ marginTop: 10 }}
|
||||||
|
value={affLink}
|
||||||
|
onClick={handleAffLinkClick}
|
||||||
|
readOnly
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Typography.Title heading={6}>邀请信息</Typography.Title>
|
||||||
|
<div style={{ marginTop: 10 }}>
|
||||||
|
<Descriptions row>
|
||||||
|
<Descriptions.Item itemKey="待使用收益">
|
||||||
|
<span style={{ color: 'rgba(var(--semi-red-5), 1)' }}>
|
||||||
|
{
|
||||||
|
renderQuota(userState?.user?.aff_quota)
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
<Button type={'secondary'} onClick={() => setOpenTransfer(true)} size={'small'}
|
||||||
|
style={{ marginLeft: 10 }}>划转</Button>
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item
|
||||||
|
itemKey="总收益">{renderQuota(userState?.user?.aff_history_quota)}</Descriptions.Item>
|
||||||
|
<Descriptions.Item itemKey="邀请人数">{userState?.user?.aff_count}</Descriptions.Item>
|
||||||
|
</Descriptions>
|
||||||
|
</div>
|
||||||
|
</Card> */}
|
||||||
|
<Card>
|
||||||
|
<Typography.Title heading={6}>邀请链接</Typography.Title>
|
||||||
|
<Input
|
||||||
|
style={{ marginTop: 10 }}
|
||||||
|
value={affLink}
|
||||||
|
onClick={handleAffLinkClick}
|
||||||
|
readOnly
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<Typography.Title heading={6}>个人信息</Typography.Title>
|
||||||
|
<div style={{ marginTop: 20 }}>
|
||||||
|
<Typography.Text strong>邮箱</Typography.Text>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||||
|
<div>
|
||||||
|
<Input
|
||||||
|
value={userState.user && userState.user.email !== '' ? userState.user.email : '未绑定'}
|
||||||
|
readonly={true}
|
||||||
|
></Input>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Button onClick={() => {
|
||||||
|
setShowEmailBindModal(true);
|
||||||
|
}}>{
|
||||||
|
userState.user && userState.user.email !== '' ? '修改绑定' : '绑定邮箱'
|
||||||
|
}</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: 10 }}>
|
||||||
|
<Typography.Text strong>微信</Typography.Text>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||||
|
<div>
|
||||||
|
<Input
|
||||||
|
value={userState.user && userState.user.wechat_id !== '' ? '已绑定' : '未绑定'}
|
||||||
|
readonly={true}
|
||||||
|
></Input>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Button disabled={(userState.user && userState.user.wechat_id !== '') || !status.wechat_login}>
|
||||||
|
{
|
||||||
|
status.wechat_login ? '绑定' : '未启用'
|
||||||
|
}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: 10 }}>
|
||||||
|
<Typography.Text strong>GitHub</Typography.Text>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||||
|
<div>
|
||||||
|
<Input
|
||||||
|
value={userState.user && userState.user.github_id !== '' ? userState.user.github_id : '未绑定'}
|
||||||
|
readonly={true}
|
||||||
|
></Input>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
onGitHubOAuthClicked(status.github_client_id);
|
||||||
|
}}
|
||||||
|
disabled={(userState.user && userState.user.github_id !== '') || !status.github_oauth}
|
||||||
|
>
|
||||||
|
{
|
||||||
|
status.github_oauth ? '绑定' : '未启用'
|
||||||
|
}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* <div style={{ marginTop: 10 }}>
|
||||||
|
<Typography.Text strong>Telegram</Typography.Text>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||||
|
<div>
|
||||||
|
<Input
|
||||||
|
value={userState.user && userState.user.telegram_id !== '' ? userState.user.telegram_id : '未绑定'}
|
||||||
|
readonly={true}
|
||||||
|
></Input>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{status.telegram_oauth ?
|
||||||
|
userState.user.telegram_id !== '' ? <Button disabled={true}>已绑定</Button>
|
||||||
|
: <TelegramLoginButton dataAuthUrl="/api/oauth/telegram/bind"
|
||||||
|
botName={status.telegram_bot_name} />
|
||||||
|
: <Button disabled={true}>未启用</Button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div> */}
|
||||||
|
|
||||||
|
<div style={{ marginTop: 10 }}>
|
||||||
|
<Space>
|
||||||
|
<Button onClick={generateAccessToken}>生成系统访问令牌</Button>
|
||||||
|
<Button onClick={() => {
|
||||||
|
setShowChangePasswordModal(true);
|
||||||
|
}}>修改密码</Button>
|
||||||
|
<Button type={'danger'} onClick={() => {
|
||||||
|
setShowAccountDeleteModal(true);
|
||||||
|
}}>删除个人账户</Button>
|
||||||
|
</Space>
|
||||||
|
|
||||||
|
{systemToken && (
|
||||||
|
<Input
|
||||||
|
readOnly
|
||||||
|
value={systemToken}
|
||||||
|
onClick={handleSystemTokenClick}
|
||||||
|
style={{ marginTop: '10px' }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{
|
||||||
|
status.wechat_login && (
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
setShowWeChatBindModal(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
绑定微信账号
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
<Modal
|
||||||
|
onCancel={() => setShowWeChatBindModal(false)}
|
||||||
|
// onOpen={() => setShowWeChatBindModal(true)}
|
||||||
|
visible={showWeChatBindModal}
|
||||||
|
size={'mini'}
|
||||||
|
>
|
||||||
|
<Image src={status.wechat_qrcode} />
|
||||||
|
<div style={{ textAlign: 'center' }}>
|
||||||
|
<p>
|
||||||
|
微信扫码关注公众号,输入「验证码」获取验证码(三分钟内有效)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
placeholder="验证码"
|
||||||
|
name="wechat_verification_code"
|
||||||
|
value={inputs.wechat_verification_code}
|
||||||
|
onChange={(v) => handleInputChange('wechat_verification_code', v)}
|
||||||
|
/>
|
||||||
|
<Button color="" fluid size="large" onClick={bindWeChat}>
|
||||||
|
绑定
|
||||||
|
</Button>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
<Modal
|
||||||
|
onCancel={() => setShowEmailBindModal(false)}
|
||||||
|
// onOpen={() => setShowEmailBindModal(true)}
|
||||||
|
onOk={bindEmail}
|
||||||
|
visible={showEmailBindModal}
|
||||||
|
size={'small'}
|
||||||
|
centered={true}
|
||||||
|
maskClosable={false}
|
||||||
|
>
|
||||||
|
<Typography.Title heading={6}>绑定邮箱地址</Typography.Title>
|
||||||
|
<div style={{ marginTop: 20, display: 'flex', justifyContent: 'space-between' }}>
|
||||||
|
<Input
|
||||||
|
fluid
|
||||||
|
placeholder="输入邮箱地址"
|
||||||
|
onChange={(value) => handleInputChange('email', value)}
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
/>
|
||||||
|
<Button onClick={sendVerificationCode}
|
||||||
|
disabled={disableButton || loading}>
|
||||||
|
{disableButton ? `重新发送(${countdown})` : '获取验证码'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: 10 }}>
|
||||||
|
<Input
|
||||||
|
fluid
|
||||||
|
placeholder="验证码"
|
||||||
|
name="email_verification_code"
|
||||||
|
value={inputs.email_verification_code}
|
||||||
|
onChange={(value) => handleInputChange('email_verification_code', value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{turnstileEnabled ? (
|
||||||
|
<Turnstile
|
||||||
|
sitekey={turnstileSiteKey}
|
||||||
|
onVerify={(token) => {
|
||||||
|
setTurnstileToken(token);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<></>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
<Modal
|
||||||
|
onCancel={() => setShowAccountDeleteModal(false)}
|
||||||
|
visible={showAccountDeleteModal}
|
||||||
|
size={'small'}
|
||||||
|
centered={true}
|
||||||
|
onOk={deleteAccount}
|
||||||
|
>
|
||||||
|
<div style={{ marginTop: 20 }}>
|
||||||
|
<Banner
|
||||||
|
type="danger"
|
||||||
|
description="您正在删除自己的帐户,将清空所有数据且不可恢复"
|
||||||
|
closeIcon={null}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: 20 }}>
|
||||||
|
<Input
|
||||||
|
placeholder={`输入你的账户名 ${userState?.user?.username} 以确认删除`}
|
||||||
|
name="self_account_deletion_confirmation"
|
||||||
|
value={inputs.self_account_deletion_confirmation}
|
||||||
|
onChange={(value) => handleInputChange('self_account_deletion_confirmation', value)}
|
||||||
|
/>
|
||||||
|
{turnstileEnabled ? (
|
||||||
|
<Turnstile
|
||||||
|
sitekey={turnstileSiteKey}
|
||||||
|
onVerify={(token) => {
|
||||||
|
setTurnstileToken(token);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<></>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
<Modal
|
||||||
|
onCancel={() => setShowChangePasswordModal(false)}
|
||||||
|
visible={showChangePasswordModal}
|
||||||
|
size={'small'}
|
||||||
|
centered={true}
|
||||||
|
onOk={changePassword}
|
||||||
|
>
|
||||||
|
<div style={{ marginTop: 20 }}>
|
||||||
|
<Input
|
||||||
|
name="set_new_password"
|
||||||
|
placeholder="新密码"
|
||||||
|
value={inputs.set_new_password}
|
||||||
|
onChange={(value) => handleInputChange('set_new_password', value)}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
style={{ marginTop: 20 }}
|
||||||
|
name="set_new_password_confirmation"
|
||||||
|
placeholder="确认新密码"
|
||||||
|
value={inputs.set_new_password_confirmation}
|
||||||
|
onChange={(value) => handleInputChange('set_new_password_confirmation', value)}
|
||||||
|
/>
|
||||||
|
{turnstileEnabled ? (
|
||||||
|
<Turnstile
|
||||||
|
sitekey={turnstileSiteKey}
|
||||||
|
onVerify={(token) => {
|
||||||
|
setTurnstileToken(token);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<></>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</Layout.Content>
|
||||||
|
</Layout>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PersonalSetting;
|
13
web/air/src/components/PrivateRoute.js
Normal file
13
web/air/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 };
|
406
web/air/src/components/RedemptionsTable.js
Normal file
406
web/air/src/components/RedemptionsTable.js
Normal file
@ -0,0 +1,406 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { API, copy, showError, showSuccess, timestamp2string } from '../helpers';
|
||||||
|
|
||||||
|
import { ITEMS_PER_PAGE } from '../constants';
|
||||||
|
import { renderQuota } from '../helpers/render';
|
||||||
|
import { Button, Form, Modal, Popconfirm, Popover, Table, Tag } from '@douyinfe/semi-ui';
|
||||||
|
import EditRedemption from '../pages/Redemption/EditRedemption';
|
||||||
|
|
||||||
|
function renderTimestamp(timestamp) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{timestamp2string(timestamp)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderStatus(status) {
|
||||||
|
switch (status) {
|
||||||
|
case 1:
|
||||||
|
return <Tag color="green" size="large">未使用</Tag>;
|
||||||
|
case 2:
|
||||||
|
return <Tag color="red" size="large"> 已禁用 </Tag>;
|
||||||
|
case 3:
|
||||||
|
return <Tag color="grey" size="large"> 已使用 </Tag>;
|
||||||
|
default:
|
||||||
|
return <Tag color="black" size="large"> 未知状态 </Tag>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const RedemptionsTable = () => {
|
||||||
|
const columns = [
|
||||||
|
{
|
||||||
|
title: 'ID',
|
||||||
|
dataIndex: 'id'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '名称',
|
||||||
|
dataIndex: 'name'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '状态',
|
||||||
|
dataIndex: 'status',
|
||||||
|
key: 'status',
|
||||||
|
render: (text, record, index) => {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{renderStatus(text)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '额度',
|
||||||
|
dataIndex: 'quota',
|
||||||
|
render: (text, record, index) => {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{renderQuota(parseInt(text))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '创建时间',
|
||||||
|
dataIndex: 'created_time',
|
||||||
|
render: (text, record, index) => {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{renderTimestamp(text)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// {
|
||||||
|
// title: '兑换人ID',
|
||||||
|
// dataIndex: 'used_user_id',
|
||||||
|
// render: (text, record, index) => {
|
||||||
|
// return (
|
||||||
|
// <div>
|
||||||
|
// {text === 0 ? '无' : text}
|
||||||
|
// </div>
|
||||||
|
// );
|
||||||
|
// }
|
||||||
|
// },
|
||||||
|
{
|
||||||
|
title: '',
|
||||||
|
dataIndex: 'operate',
|
||||||
|
render: (text, record, index) => (
|
||||||
|
<div>
|
||||||
|
<Popover
|
||||||
|
content={
|
||||||
|
record.key
|
||||||
|
}
|
||||||
|
style={{ padding: 20 }}
|
||||||
|
position="top"
|
||||||
|
>
|
||||||
|
<Button theme="light" type="tertiary" style={{ marginRight: 1 }}>查看</Button>
|
||||||
|
</Popover>
|
||||||
|
<Button theme="light" type="secondary" style={{ marginRight: 1 }}
|
||||||
|
onClick={async (text) => {
|
||||||
|
await copyText(record.key);
|
||||||
|
}}
|
||||||
|
>复制</Button>
|
||||||
|
<Popconfirm
|
||||||
|
title="确定是否要删除此兑换码?"
|
||||||
|
content="此修改将不可逆"
|
||||||
|
okType={'danger'}
|
||||||
|
position={'left'}
|
||||||
|
onConfirm={() => {
|
||||||
|
manageRedemption(record.id, 'delete', record).then(
|
||||||
|
() => {
|
||||||
|
removeRecord(record.key);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button theme="light" type="danger" style={{ marginRight: 1 }}>删除</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
{
|
||||||
|
record.status === 1 ?
|
||||||
|
<Button theme="light" type="warning" style={{ marginRight: 1 }} onClick={
|
||||||
|
async () => {
|
||||||
|
manageRedemption(
|
||||||
|
record.id,
|
||||||
|
'disable',
|
||||||
|
record
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}>禁用</Button> :
|
||||||
|
<Button theme="light" type="secondary" style={{ marginRight: 1 }} onClick={
|
||||||
|
async () => {
|
||||||
|
manageRedemption(
|
||||||
|
record.id,
|
||||||
|
'enable',
|
||||||
|
record
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} disabled={record.status === 3}>启用</Button>
|
||||||
|
}
|
||||||
|
<Button theme="light" type="tertiary" style={{ marginRight: 1 }} onClick={
|
||||||
|
() => {
|
||||||
|
setEditingRedemption(record);
|
||||||
|
setShowEdit(true);
|
||||||
|
}
|
||||||
|
} disabled={record.status !== 1}>编辑</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const [redemptions, setRedemptions] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [activePage, setActivePage] = useState(1);
|
||||||
|
const [searchKeyword, setSearchKeyword] = useState('');
|
||||||
|
const [searching, setSearching] = useState(false);
|
||||||
|
const [tokenCount, setTokenCount] = useState(ITEMS_PER_PAGE);
|
||||||
|
const [selectedKeys, setSelectedKeys] = useState([]);
|
||||||
|
const [editingRedemption, setEditingRedemption] = useState({
|
||||||
|
id: undefined
|
||||||
|
});
|
||||||
|
const [showEdit, setShowEdit] = useState(false);
|
||||||
|
|
||||||
|
const closeEdit = () => {
|
||||||
|
setShowEdit(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// const setCount = (data) => {
|
||||||
|
// if (data.length >= (activePage) * ITEMS_PER_PAGE) {
|
||||||
|
// setTokenCount(data.length + 1);
|
||||||
|
// } else {
|
||||||
|
// setTokenCount(data.length);
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
const setRedemptionFormat = (redeptions) => {
|
||||||
|
// for (let i = 0; i < redeptions.length; i++) {
|
||||||
|
// redeptions[i].key = '' + redeptions[i].id;
|
||||||
|
// }
|
||||||
|
// data.key = '' + data.id
|
||||||
|
setRedemptions(redeptions);
|
||||||
|
if (redeptions.length >= (activePage) * ITEMS_PER_PAGE) {
|
||||||
|
setTokenCount(redeptions.length + 1);
|
||||||
|
} else {
|
||||||
|
setTokenCount(redeptions.length);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadRedemptions = async (startIdx) => {
|
||||||
|
const res = await API.get(`/api/redemption/?p=${startIdx}`);
|
||||||
|
const { success, message, data } = res.data;
|
||||||
|
if (success) {
|
||||||
|
if (startIdx === 0) {
|
||||||
|
setRedemptionFormat(data);
|
||||||
|
} else {
|
||||||
|
let newRedemptions = redemptions;
|
||||||
|
newRedemptions.push(...data);
|
||||||
|
setRedemptionFormat(newRedemptions);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
showError(message);
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeRecord = key => {
|
||||||
|
let newDataSource = [...redemptions];
|
||||||
|
if (key != null) {
|
||||||
|
let idx = newDataSource.findIndex(data => data.key === key);
|
||||||
|
|
||||||
|
if (idx > -1) {
|
||||||
|
newDataSource.splice(idx, 1);
|
||||||
|
setRedemptions(newDataSource);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyText = async (text) => {
|
||||||
|
if (await copy(text)) {
|
||||||
|
showSuccess('已复制到剪贴板!');
|
||||||
|
} else {
|
||||||
|
// setSearchKeyword(text);
|
||||||
|
Modal.error({ title: '无法复制到剪贴板,请手动复制', content: text });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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 refresh = async () => {
|
||||||
|
await loadRedemptions(activePage - 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const manageRedemption = async (id, action, record) => {
|
||||||
|
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('操作成功完成!');
|
||||||
|
let redemption = res.data.data;
|
||||||
|
let newRedemptions = [...redemptions];
|
||||||
|
// let realIdx = (activePage - 1) * ITEMS_PER_PAGE + idx;
|
||||||
|
if (action === 'delete') {
|
||||||
|
|
||||||
|
} else {
|
||||||
|
record.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 (value) => {
|
||||||
|
setSearchKeyword(value.trim());
|
||||||
|
};
|
||||||
|
|
||||||
|
const sortRedemption = (key) => {
|
||||||
|
if (redemptions.length === 0) return;
|
||||||
|
setLoading(true);
|
||||||
|
let sortedRedemptions = [...redemptions];
|
||||||
|
sortedRedemptions.sort((a, b) => {
|
||||||
|
return ('' + a[key]).localeCompare(b[key]);
|
||||||
|
});
|
||||||
|
if (sortedRedemptions[0].id === redemptions[0].id) {
|
||||||
|
sortedRedemptions.reverse();
|
||||||
|
}
|
||||||
|
setRedemptions(sortedRedemptions);
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePageChange = page => {
|
||||||
|
setActivePage(page);
|
||||||
|
if (page === Math.ceil(redemptions.length / ITEMS_PER_PAGE) + 1) {
|
||||||
|
// In this case we have to load more data and then append them.
|
||||||
|
loadRedemptions(page - 1).then(r => {
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let pageData = redemptions.slice((activePage - 1) * ITEMS_PER_PAGE, activePage * ITEMS_PER_PAGE);
|
||||||
|
const rowSelection = {
|
||||||
|
onSelect: (record, selected) => {
|
||||||
|
},
|
||||||
|
onSelectAll: (selected, selectedRows) => {
|
||||||
|
},
|
||||||
|
onChange: (selectedRowKeys, selectedRows) => {
|
||||||
|
setSelectedKeys(selectedRows);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRow = (record, index) => {
|
||||||
|
if (record.status !== 1) {
|
||||||
|
return {
|
||||||
|
style: {
|
||||||
|
background: 'var(--semi-color-disabled-border)'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<EditRedemption refresh={refresh} editingRedemption={editingRedemption} visiable={showEdit}
|
||||||
|
handleClose={closeEdit}></EditRedemption>
|
||||||
|
<Form onSubmit={searchRedemptions}>
|
||||||
|
<Form.Input
|
||||||
|
label="搜索关键字"
|
||||||
|
field="keyword"
|
||||||
|
icon="search"
|
||||||
|
iconPosition="left"
|
||||||
|
placeholder="关键字(id或者名称)"
|
||||||
|
value={searchKeyword}
|
||||||
|
loading={searching}
|
||||||
|
onChange={handleKeywordChange}
|
||||||
|
/>
|
||||||
|
</Form>
|
||||||
|
|
||||||
|
<Table style={{ marginTop: 20 }} columns={columns} dataSource={pageData} pagination={{
|
||||||
|
currentPage: activePage,
|
||||||
|
pageSize: ITEMS_PER_PAGE,
|
||||||
|
total: tokenCount,
|
||||||
|
// showSizeChanger: true,
|
||||||
|
// pageSizeOptions: [10, 20, 50, 100],
|
||||||
|
formatPageText: (page) => `第 ${page.currentStart} - ${page.currentEnd} 条,共 ${redemptions.length} 条`,
|
||||||
|
// onPageSizeChange: (size) => {
|
||||||
|
// setPageSize(size);
|
||||||
|
// setActivePage(1);
|
||||||
|
// },
|
||||||
|
onPageChange: handlePageChange
|
||||||
|
}} loading={loading} rowSelection={rowSelection} onRow={handleRow}>
|
||||||
|
</Table>
|
||||||
|
<Button theme="light" type="primary" style={{ marginRight: 8 }} onClick={
|
||||||
|
() => {
|
||||||
|
setEditingRedemption({
|
||||||
|
id: undefined
|
||||||
|
});
|
||||||
|
setShowEdit(true);
|
||||||
|
}
|
||||||
|
}>添加兑换码</Button>
|
||||||
|
<Button label="复制所选兑换码" type="warning" onClick={
|
||||||
|
async () => {
|
||||||
|
if (selectedKeys.length === 0) {
|
||||||
|
showError('请至少选择一个兑换码!');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let keys = '';
|
||||||
|
for (let i = 0; i < selectedKeys.length; i++) {
|
||||||
|
keys += selectedKeys[i].name + ' ' + selectedKeys[i].key + '\n';
|
||||||
|
}
|
||||||
|
await copyText(keys);
|
||||||
|
}
|
||||||
|
}>复制所选兑换码到剪贴板</Button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default RedemptionsTable;
|
194
web/air/src/components/RegisterForm.js
Normal file
194
web/air/src/components/RegisterForm.js
Normal file
@ -0,0 +1,194 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { Button, Form, Grid, Header, Image, Message, Segment } from 'semantic-ui-react';
|
||||||
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
|
import { API, getLogo, showError, showInfo, showSuccess } from '../helpers';
|
||||||
|
import Turnstile from 'react-turnstile';
|
||||||
|
|
||||||
|
const RegisterForm = () => {
|
||||||
|
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 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
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('密码长度不得小于 8 位!');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (password !== password2) {
|
||||||
|
showInfo('两次输入的密码不一致');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (username && password) {
|
||||||
|
if (turnstileEnabled && turnstileToken === '') {
|
||||||
|
showInfo('请稍后几秒重试,Turnstile 正在检查用户环境!');
|
||||||
|
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('注册成功!');
|
||||||
|
} else {
|
||||||
|
showError(message);
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sendVerificationCode = async () => {
|
||||||
|
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);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Grid textAlign="center" style={{ marginTop: '48px' }}>
|
||||||
|
<Grid.Column style={{ maxWidth: 450 }}>
|
||||||
|
<Header as="h2" color="" textAlign="center">
|
||||||
|
<Image src={logo} /> 新用户注册
|
||||||
|
</Header>
|
||||||
|
<Form size="large">
|
||||||
|
<Segment>
|
||||||
|
<Form.Input
|
||||||
|
fluid
|
||||||
|
icon="user"
|
||||||
|
iconPosition="left"
|
||||||
|
placeholder="输入用户名,最长 12 位"
|
||||||
|
onChange={handleChange}
|
||||||
|
name="username"
|
||||||
|
/>
|
||||||
|
<Form.Input
|
||||||
|
fluid
|
||||||
|
icon="lock"
|
||||||
|
iconPosition="left"
|
||||||
|
placeholder="输入密码,最短 8 位,最长 20 位"
|
||||||
|
onChange={handleChange}
|
||||||
|
name="password"
|
||||||
|
type="password"
|
||||||
|
/>
|
||||||
|
<Form.Input
|
||||||
|
fluid
|
||||||
|
icon="lock"
|
||||||
|
iconPosition="left"
|
||||||
|
placeholder="输入密码,最短 8 位,最长 20 位"
|
||||||
|
onChange={handleChange}
|
||||||
|
name="password2"
|
||||||
|
type="password"
|
||||||
|
/>
|
||||||
|
{showEmailVerification ? (
|
||||||
|
<>
|
||||||
|
<Form.Input
|
||||||
|
fluid
|
||||||
|
icon="mail"
|
||||||
|
iconPosition="left"
|
||||||
|
placeholder="输入邮箱地址"
|
||||||
|
onChange={handleChange}
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
action={
|
||||||
|
<Button onClick={sendVerificationCode} disabled={loading}>
|
||||||
|
获取验证码
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Form.Input
|
||||||
|
fluid
|
||||||
|
icon="lock"
|
||||||
|
iconPosition="left"
|
||||||
|
placeholder="输入验证码"
|
||||||
|
onChange={handleChange}
|
||||||
|
name="verification_code"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<></>
|
||||||
|
)}
|
||||||
|
{turnstileEnabled ? (
|
||||||
|
<Turnstile
|
||||||
|
sitekey={turnstileSiteKey}
|
||||||
|
onVerify={(token) => {
|
||||||
|
setTurnstileToken(token);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<></>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
color="green"
|
||||||
|
fluid
|
||||||
|
size="large"
|
||||||
|
onClick={handleSubmit}
|
||||||
|
loading={loading}
|
||||||
|
>
|
||||||
|
注册
|
||||||
|
</Button>
|
||||||
|
</Segment>
|
||||||
|
</Form>
|
||||||
|
<Message>
|
||||||
|
已有账户?
|
||||||
|
<Link to="/login" className="btn btn-link">
|
||||||
|
点击登录
|
||||||
|
</Link>
|
||||||
|
</Message>
|
||||||
|
</Grid.Column>
|
||||||
|
</Grid>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default RegisterForm;
|
214
web/air/src/components/SiderBar.js
Normal file
214
web/air/src/components/SiderBar.js
Normal file
@ -0,0 +1,214 @@
|
|||||||
|
import React, { useContext, useEffect, useMemo, useState } from 'react';
|
||||||
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
|
import { UserContext } from '../context/User';
|
||||||
|
import { StatusContext } from '../context/Status';
|
||||||
|
|
||||||
|
import { API, getLogo, getSystemName, isAdmin, isMobile, showError } from '../helpers';
|
||||||
|
import '../index.css';
|
||||||
|
|
||||||
|
import {
|
||||||
|
IconCalendarClock,
|
||||||
|
IconComment,
|
||||||
|
IconCreditCard,
|
||||||
|
IconGift,
|
||||||
|
IconHistogram,
|
||||||
|
IconHome,
|
||||||
|
IconImage,
|
||||||
|
IconKey,
|
||||||
|
IconLayers,
|
||||||
|
IconSetting,
|
||||||
|
IconUser
|
||||||
|
} from '@douyinfe/semi-icons';
|
||||||
|
import { Layout, Nav } from '@douyinfe/semi-ui';
|
||||||
|
|
||||||
|
// HeaderBar Buttons
|
||||||
|
|
||||||
|
const SiderBar = () => {
|
||||||
|
const [userState, userDispatch] = useContext(UserContext);
|
||||||
|
const [statusState, statusDispatch] = useContext(StatusContext);
|
||||||
|
const defaultIsCollapsed = isMobile() || localStorage.getItem('default_collapse_sidebar') === 'true';
|
||||||
|
|
||||||
|
let navigate = useNavigate();
|
||||||
|
const [selectedKeys, setSelectedKeys] = useState(['home']);
|
||||||
|
const systemName = getSystemName();
|
||||||
|
const logo = getLogo();
|
||||||
|
const [isCollapsed, setIsCollapsed] = useState(defaultIsCollapsed);
|
||||||
|
|
||||||
|
const headerButtons = useMemo(() => [
|
||||||
|
{
|
||||||
|
text: '首页',
|
||||||
|
itemKey: 'home',
|
||||||
|
to: '/',
|
||||||
|
icon: <IconHome />
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: '渠道',
|
||||||
|
itemKey: 'channel',
|
||||||
|
to: '/channel',
|
||||||
|
icon: <IconLayers />,
|
||||||
|
className: isAdmin() ? 'semi-navigation-item-normal' : 'tableHiddle'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: '聊天',
|
||||||
|
itemKey: 'chat',
|
||||||
|
to: '/chat',
|
||||||
|
icon: <IconComment />,
|
||||||
|
className: localStorage.getItem('chat_link') ? 'semi-navigation-item-normal' : 'tableHiddle'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: '令牌',
|
||||||
|
itemKey: 'token',
|
||||||
|
to: '/token',
|
||||||
|
icon: <IconKey />
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: '兑换',
|
||||||
|
itemKey: 'redemption',
|
||||||
|
to: '/redemption',
|
||||||
|
icon: <IconGift />,
|
||||||
|
className: isAdmin() ? 'semi-navigation-item-normal' : 'tableHiddle'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: '充值',
|
||||||
|
itemKey: 'topup',
|
||||||
|
to: '/topup',
|
||||||
|
icon: <IconCreditCard />
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: '用户',
|
||||||
|
itemKey: 'user',
|
||||||
|
to: '/user',
|
||||||
|
icon: <IconUser />,
|
||||||
|
className: isAdmin() ? 'semi-navigation-item-normal' : 'tableHiddle'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: '日志',
|
||||||
|
itemKey: 'log',
|
||||||
|
to: '/log',
|
||||||
|
icon: <IconHistogram />
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: '数据看板',
|
||||||
|
itemKey: 'detail',
|
||||||
|
to: '/detail',
|
||||||
|
icon: <IconCalendarClock />,
|
||||||
|
className: localStorage.getItem('enable_data_export') === 'true' ? 'semi-navigation-item-normal' : 'tableHiddle'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: '绘图',
|
||||||
|
itemKey: 'midjourney',
|
||||||
|
to: '/midjourney',
|
||||||
|
icon: <IconImage />,
|
||||||
|
className: localStorage.getItem('enable_drawing') === 'true' ? 'semi-navigation-item-normal' : 'tableHiddle'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: '设置',
|
||||||
|
itemKey: 'setting',
|
||||||
|
to: '/setting',
|
||||||
|
icon: <IconSetting />
|
||||||
|
}
|
||||||
|
// {
|
||||||
|
// text: '关于',
|
||||||
|
// itemKey: 'about',
|
||||||
|
// to: '/about',
|
||||||
|
// icon: <IconAt/>
|
||||||
|
// }
|
||||||
|
], [localStorage.getItem('enable_data_export'), localStorage.getItem('enable_drawing'), localStorage.getItem('chat_link'), isAdmin()]);
|
||||||
|
|
||||||
|
const loadStatus = async () => {
|
||||||
|
const res = await API.get('/api/status');
|
||||||
|
const { success, data } = res.data;
|
||||||
|
if (success) {
|
||||||
|
localStorage.setItem('status', JSON.stringify(data));
|
||||||
|
statusDispatch({ type: 'set', payload: data });
|
||||||
|
localStorage.setItem('system_name', data.system_name);
|
||||||
|
localStorage.setItem('logo', data.logo);
|
||||||
|
localStorage.setItem('footer_html', data.footer_html);
|
||||||
|
localStorage.setItem('quota_per_unit', data.quota_per_unit);
|
||||||
|
localStorage.setItem('display_in_currency', data.display_in_currency);
|
||||||
|
localStorage.setItem('enable_drawing', data.enable_drawing);
|
||||||
|
localStorage.setItem('enable_data_export', data.enable_data_export);
|
||||||
|
localStorage.setItem('data_export_default_time', data.data_export_default_time);
|
||||||
|
localStorage.setItem('default_collapse_sidebar', data.default_collapse_sidebar);
|
||||||
|
localStorage.setItem('mj_notify_enabled', data.mj_notify_enabled);
|
||||||
|
if (data.chat_link) {
|
||||||
|
localStorage.setItem('chat_link', data.chat_link);
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem('chat_link');
|
||||||
|
}
|
||||||
|
if (data.chat_link2) {
|
||||||
|
localStorage.setItem('chat_link2', data.chat_link2);
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem('chat_link2');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
showError('无法正常连接至服务器!');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadStatus().then(() => {
|
||||||
|
setIsCollapsed(isMobile() || localStorage.getItem('default_collapse_sidebar') === 'true');
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Layout>
|
||||||
|
<div style={{ height: '100%' }}>
|
||||||
|
<Nav
|
||||||
|
// bodyStyle={{ maxWidth: 200 }}
|
||||||
|
style={{ maxWidth: 200 }}
|
||||||
|
defaultIsCollapsed={isMobile() || localStorage.getItem('default_collapse_sidebar') === 'true'}
|
||||||
|
isCollapsed={isCollapsed}
|
||||||
|
onCollapseChange={collapsed => {
|
||||||
|
setIsCollapsed(collapsed);
|
||||||
|
}}
|
||||||
|
selectedKeys={selectedKeys}
|
||||||
|
renderWrapper={({ itemElement, isSubNav, isInSubNav, props }) => {
|
||||||
|
const routerMap = {
|
||||||
|
home: '/',
|
||||||
|
channel: '/channel',
|
||||||
|
token: '/token',
|
||||||
|
redemption: '/redemption',
|
||||||
|
topup: '/topup',
|
||||||
|
user: '/user',
|
||||||
|
log: '/log',
|
||||||
|
midjourney: '/midjourney',
|
||||||
|
setting: '/setting',
|
||||||
|
about: '/about',
|
||||||
|
chat: '/chat',
|
||||||
|
detail: '/detail'
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
style={{ textDecoration: 'none' }}
|
||||||
|
to={routerMap[props.itemKey]}
|
||||||
|
>
|
||||||
|
{itemElement}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
items={headerButtons}
|
||||||
|
onSelect={key => {
|
||||||
|
setSelectedKeys([key.itemKey]);
|
||||||
|
}}
|
||||||
|
header={{
|
||||||
|
logo: <img src={logo} alt="logo" style={{ marginRight: '0.75em' }} />,
|
||||||
|
text: systemName
|
||||||
|
}}
|
||||||
|
// footer={{
|
||||||
|
// text: '© 2021 NekoAPI',
|
||||||
|
// }}
|
||||||
|
>
|
||||||
|
|
||||||
|
<Nav.Footer collapseButton={true}>
|
||||||
|
</Nav.Footer>
|
||||||
|
</Nav>
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SiderBar;
|
590
web/air/src/components/SystemSetting.js
Normal file
590
web/air/src/components/SystemSetting.js
Normal file
@ -0,0 +1,590 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { Button, Divider, Form, Grid, Header, Modal, Message } from 'semantic-ui-react';
|
||||||
|
import { API, removeTrailingSlash, showError } from '../helpers';
|
||||||
|
|
||||||
|
const SystemSetting = () => {
|
||||||
|
let [inputs, setInputs] = useState({
|
||||||
|
PasswordLoginEnabled: '',
|
||||||
|
PasswordRegisterEnabled: '',
|
||||||
|
EmailVerificationEnabled: '',
|
||||||
|
GitHubOAuthEnabled: '',
|
||||||
|
GitHubClientId: '',
|
||||||
|
GitHubClientSecret: '',
|
||||||
|
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 === '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 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'>通用设置</Header>
|
||||||
|
<Form.Group widths='equal'>
|
||||||
|
<Form.Input
|
||||||
|
label='服务器地址'
|
||||||
|
placeholder='例如:https://yourdomain.com'
|
||||||
|
value={inputs.ServerAddress}
|
||||||
|
name='ServerAddress'
|
||||||
|
onChange={handleInputChange}
|
||||||
|
/>
|
||||||
|
</Form.Group>
|
||||||
|
<Form.Button onClick={submitServerAddress}>
|
||||||
|
更新服务器地址
|
||||||
|
</Form.Button>
|
||||||
|
<Divider />
|
||||||
|
<Header as='h3'>配置登录注册</Header>
|
||||||
|
<Form.Group inline>
|
||||||
|
<Form.Checkbox
|
||||||
|
checked={inputs.PasswordLoginEnabled === 'true'}
|
||||||
|
label='允许通过密码进行登录'
|
||||||
|
name='PasswordLoginEnabled'
|
||||||
|
onChange={handleInputChange}
|
||||||
|
/>
|
||||||
|
{
|
||||||
|
showPasswordWarningModal &&
|
||||||
|
<Modal
|
||||||
|
open={showPasswordWarningModal}
|
||||||
|
onClose={() => setShowPasswordWarningModal(false)}
|
||||||
|
size={'tiny'}
|
||||||
|
style={{ maxWidth: '450px' }}
|
||||||
|
>
|
||||||
|
<Modal.Header>警告</Modal.Header>
|
||||||
|
<Modal.Content>
|
||||||
|
<p>取消密码登录将导致所有未绑定其他登录方式的用户(包括管理员)无法通过密码登录,确认取消?</p>
|
||||||
|
</Modal.Content>
|
||||||
|
<Modal.Actions>
|
||||||
|
<Button onClick={() => setShowPasswordWarningModal(false)}>取消</Button>
|
||||||
|
<Button
|
||||||
|
color='yellow'
|
||||||
|
onClick={async () => {
|
||||||
|
setShowPasswordWarningModal(false);
|
||||||
|
await updateOption('PasswordLoginEnabled', 'false');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
确定
|
||||||
|
</Button>
|
||||||
|
</Modal.Actions>
|
||||||
|
</Modal>
|
||||||
|
}
|
||||||
|
<Form.Checkbox
|
||||||
|
checked={inputs.PasswordRegisterEnabled === 'true'}
|
||||||
|
label='允许通过密码进行注册'
|
||||||
|
name='PasswordRegisterEnabled'
|
||||||
|
onChange={handleInputChange}
|
||||||
|
/>
|
||||||
|
<Form.Checkbox
|
||||||
|
checked={inputs.EmailVerificationEnabled === 'true'}
|
||||||
|
label='通过密码注册时需要进行邮箱验证'
|
||||||
|
name='EmailVerificationEnabled'
|
||||||
|
onChange={handleInputChange}
|
||||||
|
/>
|
||||||
|
<Form.Checkbox
|
||||||
|
checked={inputs.GitHubOAuthEnabled === 'true'}
|
||||||
|
label='允许通过 GitHub 账户登录 & 注册'
|
||||||
|
name='GitHubOAuthEnabled'
|
||||||
|
onChange={handleInputChange}
|
||||||
|
/>
|
||||||
|
<Form.Checkbox
|
||||||
|
checked={inputs.WeChatAuthEnabled === 'true'}
|
||||||
|
label='允许通过微信登录 & 注册'
|
||||||
|
name='WeChatAuthEnabled'
|
||||||
|
onChange={handleInputChange}
|
||||||
|
/>
|
||||||
|
</Form.Group>
|
||||||
|
<Form.Group inline>
|
||||||
|
<Form.Checkbox
|
||||||
|
checked={inputs.RegisterEnabled === 'true'}
|
||||||
|
label='允许新用户注册(此项为否时,新用户将无法以任何方式进行注册)'
|
||||||
|
name='RegisterEnabled'
|
||||||
|
onChange={handleInputChange}
|
||||||
|
/>
|
||||||
|
<Form.Checkbox
|
||||||
|
checked={inputs.TurnstileCheckEnabled === 'true'}
|
||||||
|
label='启用 Turnstile 用户校验'
|
||||||
|
name='TurnstileCheckEnabled'
|
||||||
|
onChange={handleInputChange}
|
||||||
|
/>
|
||||||
|
</Form.Group>
|
||||||
|
<Divider />
|
||||||
|
<Header as='h3'>
|
||||||
|
配置邮箱域名白名单
|
||||||
|
<Header.Subheader>用以防止恶意用户利用临时邮箱批量注册</Header.Subheader>
|
||||||
|
</Header>
|
||||||
|
<Form.Group widths={3}>
|
||||||
|
<Form.Checkbox
|
||||||
|
label='启用邮箱域名白名单'
|
||||||
|
name='EmailDomainRestrictionEnabled'
|
||||||
|
onChange={handleInputChange}
|
||||||
|
checked={inputs.EmailDomainRestrictionEnabled === 'true'}
|
||||||
|
/>
|
||||||
|
</Form.Group>
|
||||||
|
<Form.Group widths={2}>
|
||||||
|
<Form.Dropdown
|
||||||
|
label='允许的邮箱域名'
|
||||||
|
placeholder='允许的邮箱域名'
|
||||||
|
name='EmailDomainWhitelist'
|
||||||
|
required
|
||||||
|
fluid
|
||||||
|
multiple
|
||||||
|
selection
|
||||||
|
onChange={handleInputChange}
|
||||||
|
value={inputs.EmailDomainWhitelist}
|
||||||
|
autoComplete='new-password'
|
||||||
|
options={EmailDomainWhitelist}
|
||||||
|
/>
|
||||||
|
<Form.Input
|
||||||
|
label='添加新的允许的邮箱域名'
|
||||||
|
action={
|
||||||
|
<Button type='button' onClick={() => {
|
||||||
|
submitNewRestrictedDomain();
|
||||||
|
}}>填入</Button>
|
||||||
|
}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
submitNewRestrictedDomain();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
autoComplete='new-password'
|
||||||
|
placeholder='输入新的允许的邮箱域名'
|
||||||
|
value={restrictedDomainInput}
|
||||||
|
onChange={(e, { value }) => {
|
||||||
|
setRestrictedDomainInput(value);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Form.Group>
|
||||||
|
<Form.Button onClick={submitEmailDomainWhitelist}>保存邮箱域名白名单设置</Form.Button>
|
||||||
|
<Divider />
|
||||||
|
<Header as='h3'>
|
||||||
|
配置 SMTP
|
||||||
|
<Header.Subheader>用以支持系统的邮件发送</Header.Subheader>
|
||||||
|
</Header>
|
||||||
|
<Form.Group widths={3}>
|
||||||
|
<Form.Input
|
||||||
|
label='SMTP 服务器地址'
|
||||||
|
name='SMTPServer'
|
||||||
|
onChange={handleInputChange}
|
||||||
|
autoComplete='new-password'
|
||||||
|
value={inputs.SMTPServer}
|
||||||
|
placeholder='例如:smtp.qq.com'
|
||||||
|
/>
|
||||||
|
<Form.Input
|
||||||
|
label='SMTP 端口'
|
||||||
|
name='SMTPPort'
|
||||||
|
onChange={handleInputChange}
|
||||||
|
autoComplete='new-password'
|
||||||
|
value={inputs.SMTPPort}
|
||||||
|
placeholder='默认: 587'
|
||||||
|
/>
|
||||||
|
<Form.Input
|
||||||
|
label='SMTP 账户'
|
||||||
|
name='SMTPAccount'
|
||||||
|
onChange={handleInputChange}
|
||||||
|
autoComplete='new-password'
|
||||||
|
value={inputs.SMTPAccount}
|
||||||
|
placeholder='通常是邮箱地址'
|
||||||
|
/>
|
||||||
|
</Form.Group>
|
||||||
|
<Form.Group widths={3}>
|
||||||
|
<Form.Input
|
||||||
|
label='SMTP 发送者邮箱'
|
||||||
|
name='SMTPFrom'
|
||||||
|
onChange={handleInputChange}
|
||||||
|
autoComplete='new-password'
|
||||||
|
value={inputs.SMTPFrom}
|
||||||
|
placeholder='通常和邮箱地址保持一致'
|
||||||
|
/>
|
||||||
|
<Form.Input
|
||||||
|
label='SMTP 访问凭证'
|
||||||
|
name='SMTPToken'
|
||||||
|
onChange={handleInputChange}
|
||||||
|
type='password'
|
||||||
|
autoComplete='new-password'
|
||||||
|
checked={inputs.RegisterEnabled === 'true'}
|
||||||
|
placeholder='敏感信息不会发送到前端显示'
|
||||||
|
/>
|
||||||
|
</Form.Group>
|
||||||
|
<Form.Button onClick={submitSMTP}>保存 SMTP 设置</Form.Button>
|
||||||
|
<Divider />
|
||||||
|
<Header as='h3'>
|
||||||
|
配置 GitHub OAuth App
|
||||||
|
<Header.Subheader>
|
||||||
|
用以支持通过 GitHub 进行登录注册,
|
||||||
|
<a href='https://github.com/settings/developers' target='_blank'>
|
||||||
|
点击此处
|
||||||
|
</a>
|
||||||
|
管理你的 GitHub OAuth App
|
||||||
|
</Header.Subheader>
|
||||||
|
</Header>
|
||||||
|
<Message>
|
||||||
|
Homepage URL 填 <code>{inputs.ServerAddress}</code>
|
||||||
|
,Authorization callback URL 填{' '}
|
||||||
|
<code>{`${inputs.ServerAddress}/oauth/github`}</code>
|
||||||
|
</Message>
|
||||||
|
<Form.Group widths={3}>
|
||||||
|
<Form.Input
|
||||||
|
label='GitHub Client ID'
|
||||||
|
name='GitHubClientId'
|
||||||
|
onChange={handleInputChange}
|
||||||
|
autoComplete='new-password'
|
||||||
|
value={inputs.GitHubClientId}
|
||||||
|
placeholder='输入你注册的 GitHub OAuth APP 的 ID'
|
||||||
|
/>
|
||||||
|
<Form.Input
|
||||||
|
label='GitHub Client Secret'
|
||||||
|
name='GitHubClientSecret'
|
||||||
|
onChange={handleInputChange}
|
||||||
|
type='password'
|
||||||
|
autoComplete='new-password'
|
||||||
|
value={inputs.GitHubClientSecret}
|
||||||
|
placeholder='敏感信息不会发送到前端显示'
|
||||||
|
/>
|
||||||
|
</Form.Group>
|
||||||
|
<Form.Button onClick={submitGitHubOAuth}>
|
||||||
|
保存 GitHub OAuth 设置
|
||||||
|
</Form.Button>
|
||||||
|
<Divider />
|
||||||
|
<Header as='h3'>
|
||||||
|
配置 WeChat Server
|
||||||
|
<Header.Subheader>
|
||||||
|
用以支持通过微信进行登录注册,
|
||||||
|
<a
|
||||||
|
href='https://github.com/songquanpeng/wechat-server'
|
||||||
|
target='_blank'
|
||||||
|
>
|
||||||
|
点击此处
|
||||||
|
</a>
|
||||||
|
了解 WeChat Server
|
||||||
|
</Header.Subheader>
|
||||||
|
</Header>
|
||||||
|
<Form.Group widths={3}>
|
||||||
|
<Form.Input
|
||||||
|
label='WeChat Server 服务器地址'
|
||||||
|
name='WeChatServerAddress'
|
||||||
|
placeholder='例如:https://yourdomain.com'
|
||||||
|
onChange={handleInputChange}
|
||||||
|
autoComplete='new-password'
|
||||||
|
value={inputs.WeChatServerAddress}
|
||||||
|
/>
|
||||||
|
<Form.Input
|
||||||
|
label='WeChat Server 访问凭证'
|
||||||
|
name='WeChatServerToken'
|
||||||
|
type='password'
|
||||||
|
onChange={handleInputChange}
|
||||||
|
autoComplete='new-password'
|
||||||
|
value={inputs.WeChatServerToken}
|
||||||
|
placeholder='敏感信息不会发送到前端显示'
|
||||||
|
/>
|
||||||
|
<Form.Input
|
||||||
|
label='微信公众号二维码图片链接'
|
||||||
|
name='WeChatAccountQRCodeImageURL'
|
||||||
|
onChange={handleInputChange}
|
||||||
|
autoComplete='new-password'
|
||||||
|
value={inputs.WeChatAccountQRCodeImageURL}
|
||||||
|
placeholder='输入一个图片链接'
|
||||||
|
/>
|
||||||
|
</Form.Group>
|
||||||
|
<Form.Button onClick={submitWeChat}>
|
||||||
|
保存 WeChat Server 设置
|
||||||
|
</Form.Button>
|
||||||
|
<Divider />
|
||||||
|
<Header as='h3'>
|
||||||
|
配置 Message Pusher
|
||||||
|
<Header.Subheader>
|
||||||
|
用以推送报警信息,
|
||||||
|
<a
|
||||||
|
href='https://github.com/songquanpeng/message-pusher'
|
||||||
|
target='_blank'
|
||||||
|
>
|
||||||
|
点击此处
|
||||||
|
</a>
|
||||||
|
了解 Message Pusher
|
||||||
|
</Header.Subheader>
|
||||||
|
</Header>
|
||||||
|
<Form.Group widths={3}>
|
||||||
|
<Form.Input
|
||||||
|
label='Message Pusher 推送地址'
|
||||||
|
name='MessagePusherAddress'
|
||||||
|
placeholder='例如:https://msgpusher.com/push/your_username'
|
||||||
|
onChange={handleInputChange}
|
||||||
|
autoComplete='new-password'
|
||||||
|
value={inputs.MessagePusherAddress}
|
||||||
|
/>
|
||||||
|
<Form.Input
|
||||||
|
label='Message Pusher 访问凭证'
|
||||||
|
name='MessagePusherToken'
|
||||||
|
type='password'
|
||||||
|
onChange={handleInputChange}
|
||||||
|
autoComplete='new-password'
|
||||||
|
value={inputs.MessagePusherToken}
|
||||||
|
placeholder='敏感信息不会发送到前端显示'
|
||||||
|
/>
|
||||||
|
</Form.Group>
|
||||||
|
<Form.Button onClick={submitMessagePusher}>
|
||||||
|
保存 Message Pusher 设置
|
||||||
|
</Form.Button>
|
||||||
|
<Divider />
|
||||||
|
<Header as='h3'>
|
||||||
|
配置 Turnstile
|
||||||
|
<Header.Subheader>
|
||||||
|
用以支持用户校验,
|
||||||
|
<a href='https://dash.cloudflare.com/' target='_blank'>
|
||||||
|
点击此处
|
||||||
|
</a>
|
||||||
|
管理你的 Turnstile Sites,推荐选择 Invisible Widget Type
|
||||||
|
</Header.Subheader>
|
||||||
|
</Header>
|
||||||
|
<Form.Group widths={3}>
|
||||||
|
<Form.Input
|
||||||
|
label='Turnstile Site Key'
|
||||||
|
name='TurnstileSiteKey'
|
||||||
|
onChange={handleInputChange}
|
||||||
|
autoComplete='new-password'
|
||||||
|
value={inputs.TurnstileSiteKey}
|
||||||
|
placeholder='输入你注册的 Turnstile Site Key'
|
||||||
|
/>
|
||||||
|
<Form.Input
|
||||||
|
label='Turnstile Secret Key'
|
||||||
|
name='TurnstileSecretKey'
|
||||||
|
onChange={handleInputChange}
|
||||||
|
type='password'
|
||||||
|
autoComplete='new-password'
|
||||||
|
value={inputs.TurnstileSecretKey}
|
||||||
|
placeholder='敏感信息不会发送到前端显示'
|
||||||
|
/>
|
||||||
|
</Form.Group>
|
||||||
|
<Form.Button onClick={submitTurnstile}>
|
||||||
|
保存 Turnstile 设置
|
||||||
|
</Form.Button>
|
||||||
|
</Form>
|
||||||
|
</Grid.Column>
|
||||||
|
</Grid>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SystemSetting;
|
586
web/air/src/components/TokensTable.js
Normal file
586
web/air/src/components/TokensTable.js
Normal file
@ -0,0 +1,586 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { API, copy, showError, showSuccess, timestamp2string } from '../helpers';
|
||||||
|
|
||||||
|
import { ITEMS_PER_PAGE } from '../constants';
|
||||||
|
import { renderQuota } from '../helpers/render';
|
||||||
|
import { Button, Dropdown, Form, Modal, Popconfirm, Popover, SplitButtonGroup, Table, Tag } from '@douyinfe/semi-ui';
|
||||||
|
|
||||||
|
import { IconTreeTriangleDown } from '@douyinfe/semi-icons';
|
||||||
|
import EditToken from '../pages/Token/EditToken';
|
||||||
|
|
||||||
|
const COPY_OPTIONS = [
|
||||||
|
{ key: 'next', text: 'ChatGPT Next Web', value: 'next' },
|
||||||
|
{ key: 'ama', text: 'ChatGPT Web & Midjourney', value: 'ama' },
|
||||||
|
{ key: 'opencat', text: 'OpenCat', value: 'opencat' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const OPEN_LINK_OPTIONS = [
|
||||||
|
{ key: 'ama', text: 'ChatGPT Web & Midjourney', value: 'ama' },
|
||||||
|
{ key: 'opencat', text: 'OpenCat', value: 'opencat' }
|
||||||
|
];
|
||||||
|
|
||||||
|
function renderTimestamp(timestamp) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{timestamp2string(timestamp)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderStatus(status, model_limits_enabled = false) {
|
||||||
|
switch (status) {
|
||||||
|
case 1:
|
||||||
|
if (model_limits_enabled) {
|
||||||
|
return <Tag color="green" size="large">已启用:限制模型</Tag>;
|
||||||
|
} else {
|
||||||
|
return <Tag color="green" size="large">已启用</Tag>;
|
||||||
|
}
|
||||||
|
case 2:
|
||||||
|
return <Tag color="red" size="large"> 已禁用 </Tag>;
|
||||||
|
case 3:
|
||||||
|
return <Tag color="yellow" size="large"> 已过期 </Tag>;
|
||||||
|
case 4:
|
||||||
|
return <Tag color="grey" size="large"> 已耗尽 </Tag>;
|
||||||
|
default:
|
||||||
|
return <Tag color="black" size="large"> 未知状态 </Tag>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const TokensTable = () => {
|
||||||
|
|
||||||
|
const link_menu = [
|
||||||
|
{
|
||||||
|
node: 'item', key: 'next', name: 'ChatGPT Next Web', onClick: () => {
|
||||||
|
onOpenLink('next');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ node: 'item', key: 'ama', name: 'AMA 问天', value: 'ama' },
|
||||||
|
{
|
||||||
|
node: 'item', key: 'next-mj', name: 'ChatGPT Web & Midjourney', value: 'next-mj', onClick: () => {
|
||||||
|
onOpenLink('next-mj');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ node: 'item', key: 'opencat', name: 'OpenCat', value: 'opencat' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{
|
||||||
|
title: '名称',
|
||||||
|
dataIndex: 'name'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '状态',
|
||||||
|
dataIndex: 'status',
|
||||||
|
key: 'status',
|
||||||
|
render: (text, record, index) => {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{renderStatus(text, record.model_limits_enabled)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '已用额度',
|
||||||
|
dataIndex: 'used_quota',
|
||||||
|
render: (text, record, index) => {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{renderQuota(parseInt(text))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '剩余额度',
|
||||||
|
dataIndex: 'remain_quota',
|
||||||
|
render: (text, record, index) => {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{record.unlimited_quota ? <Tag size={'large'} color={'white'}>无限制</Tag> :
|
||||||
|
<Tag size={'large'} color={'light-blue'}>{renderQuota(parseInt(text))}</Tag>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '创建时间',
|
||||||
|
dataIndex: 'created_time',
|
||||||
|
render: (text, record, index) => {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{renderTimestamp(text)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '过期时间',
|
||||||
|
dataIndex: 'expired_time',
|
||||||
|
render: (text, record, index) => {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{record.expired_time === -1 ? '永不过期' : renderTimestamp(text)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '',
|
||||||
|
dataIndex: 'operate',
|
||||||
|
render: (text, record, index) => (
|
||||||
|
<div>
|
||||||
|
<Popover
|
||||||
|
content={
|
||||||
|
'sk-' + record.key
|
||||||
|
}
|
||||||
|
style={{ padding: 20 }}
|
||||||
|
position="top"
|
||||||
|
>
|
||||||
|
<Button theme="light" type="tertiary" style={{ marginRight: 1 }}>查看</Button>
|
||||||
|
</Popover>
|
||||||
|
<Button theme="light" type="secondary" style={{ marginRight: 1 }}
|
||||||
|
onClick={async (text) => {
|
||||||
|
await copyText('sk-' + record.key);
|
||||||
|
}}
|
||||||
|
>复制</Button>
|
||||||
|
<SplitButtonGroup style={{ marginRight: 1 }} aria-label="项目操作按钮组">
|
||||||
|
<Button theme="light" style={{ color: 'rgba(var(--semi-teal-7), 1)' }} onClick={() => {
|
||||||
|
onOpenLink('next', record.key);
|
||||||
|
}}>聊天</Button>
|
||||||
|
<Dropdown trigger="click" position="bottomRight" menu={
|
||||||
|
[
|
||||||
|
{
|
||||||
|
node: 'item',
|
||||||
|
key: 'next',
|
||||||
|
disabled: !localStorage.getItem('chat_link'),
|
||||||
|
name: 'ChatGPT Next Web',
|
||||||
|
onClick: () => {
|
||||||
|
onOpenLink('next', record.key);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
node: 'item',
|
||||||
|
key: 'next-mj',
|
||||||
|
disabled: !localStorage.getItem('chat_link2'),
|
||||||
|
name: 'ChatGPT Web & Midjourney',
|
||||||
|
onClick: () => {
|
||||||
|
onOpenLink('next-mj', record.key);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
node: 'item', key: 'ama', name: 'AMA 问天(BotGem)', onClick: () => {
|
||||||
|
onOpenLink('ama', record.key);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
node: 'item', key: 'opencat', name: 'OpenCat', onClick: () => {
|
||||||
|
onOpenLink('opencat', record.key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Button style={{ padding: '8px 4px', color: 'rgba(var(--semi-teal-7), 1)' }} type="primary"
|
||||||
|
icon={<IconTreeTriangleDown />}></Button>
|
||||||
|
</Dropdown>
|
||||||
|
</SplitButtonGroup>
|
||||||
|
<Popconfirm
|
||||||
|
title="确定是否要删除此令牌?"
|
||||||
|
content="此修改将不可逆"
|
||||||
|
okType={'danger'}
|
||||||
|
position={'left'}
|
||||||
|
onConfirm={() => {
|
||||||
|
manageToken(record.id, 'delete', record).then(
|
||||||
|
() => {
|
||||||
|
removeRecord(record.key);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button theme="light" type="danger" style={{ marginRight: 1 }}>删除</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
{
|
||||||
|
record.status === 1 ?
|
||||||
|
<Button theme="light" type="warning" style={{ marginRight: 1 }} onClick={
|
||||||
|
async () => {
|
||||||
|
manageToken(
|
||||||
|
record.id,
|
||||||
|
'disable',
|
||||||
|
record
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}>禁用</Button> :
|
||||||
|
<Button theme="light" type="secondary" style={{ marginRight: 1 }} onClick={
|
||||||
|
async () => {
|
||||||
|
manageToken(
|
||||||
|
record.id,
|
||||||
|
'enable',
|
||||||
|
record
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}>启用</Button>
|
||||||
|
}
|
||||||
|
<Button theme="light" type="tertiary" style={{ marginRight: 1 }} onClick={
|
||||||
|
() => {
|
||||||
|
setEditingToken(record);
|
||||||
|
setShowEdit(true);
|
||||||
|
}
|
||||||
|
}>编辑</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const [pageSize, setPageSize] = useState(ITEMS_PER_PAGE);
|
||||||
|
const [showEdit, setShowEdit] = useState(false);
|
||||||
|
const [tokens, setTokens] = useState([]);
|
||||||
|
const [selectedKeys, setSelectedKeys] = useState([]);
|
||||||
|
const [tokenCount, setTokenCount] = useState(pageSize);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [activePage, setActivePage] = useState(1);
|
||||||
|
const [searchKeyword, setSearchKeyword] = useState('');
|
||||||
|
const [searchToken, setSearchToken] = useState('');
|
||||||
|
const [searching, setSearching] = useState(false);
|
||||||
|
const [showTopUpModal, setShowTopUpModal] = useState(false);
|
||||||
|
const [targetTokenIdx, setTargetTokenIdx] = useState(0);
|
||||||
|
const [editingToken, setEditingToken] = useState({
|
||||||
|
id: undefined
|
||||||
|
});
|
||||||
|
|
||||||
|
const closeEdit = () => {
|
||||||
|
setShowEdit(false);
|
||||||
|
setTimeout(() => {
|
||||||
|
setEditingToken({
|
||||||
|
id: undefined
|
||||||
|
});
|
||||||
|
}, 500);
|
||||||
|
};
|
||||||
|
|
||||||
|
const setTokensFormat = (tokens) => {
|
||||||
|
setTokens(tokens);
|
||||||
|
if (tokens.length >= pageSize) {
|
||||||
|
setTokenCount(tokens.length + pageSize);
|
||||||
|
} else {
|
||||||
|
setTokenCount(tokens.length);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let pageData = tokens.slice((activePage - 1) * pageSize, activePage * pageSize);
|
||||||
|
const loadTokens = async (startIdx) => {
|
||||||
|
setLoading(true);
|
||||||
|
const res = await API.get(`/api/token/?p=${startIdx}&size=${pageSize}`);
|
||||||
|
const { success, message, data } = res.data;
|
||||||
|
if (success) {
|
||||||
|
if (startIdx === 0) {
|
||||||
|
setTokensFormat(data);
|
||||||
|
} else {
|
||||||
|
let newTokens = [...tokens];
|
||||||
|
newTokens.splice(startIdx * pageSize, data.length, ...data);
|
||||||
|
setTokensFormat(newTokens);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
showError(message);
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onPaginationChange = (e, { activePage }) => {
|
||||||
|
(async () => {
|
||||||
|
if (activePage === Math.ceil(tokens.length / pageSize) + 1) {
|
||||||
|
// In this case we have to load more data and then append them.
|
||||||
|
await loadTokens(activePage - 1);
|
||||||
|
}
|
||||||
|
setActivePage(activePage);
|
||||||
|
})();
|
||||||
|
};
|
||||||
|
|
||||||
|
const refresh = async () => {
|
||||||
|
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');
|
||||||
|
const mjLink = localStorage.getItem('chat_link2');
|
||||||
|
let nextUrl;
|
||||||
|
|
||||||
|
if (nextLink) {
|
||||||
|
nextUrl = nextLink + `/#/?settings={"key":"sk-${key}","url":"${serverAddress}"}`;
|
||||||
|
} else {
|
||||||
|
nextUrl = `https://chat.oneapi.pro/#/?settings={"key":"sk-${key}","url":"${serverAddress}"}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
let url;
|
||||||
|
switch (type) {
|
||||||
|
case 'ama':
|
||||||
|
url = mjLink + `/#/?settings={"key":"sk-${key}","url":"${serverAddress}"}`;
|
||||||
|
break;
|
||||||
|
case 'opencat':
|
||||||
|
url = `opencat://team/join?domain=${encodedServerAddress}&token=sk-${key}`;
|
||||||
|
break;
|
||||||
|
case 'next':
|
||||||
|
url = nextUrl;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
url = `sk-${key}`;
|
||||||
|
}
|
||||||
|
// if (await copy(url)) {
|
||||||
|
// showSuccess('已复制到剪贴板!');
|
||||||
|
// } else {
|
||||||
|
// showWarning('无法复制到剪贴板,请手动复制,已将令牌填入搜索框。');
|
||||||
|
// setSearchKeyword(url);
|
||||||
|
// }
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyText = async (text) => {
|
||||||
|
if (await copy(text)) {
|
||||||
|
showSuccess('已复制到剪贴板!');
|
||||||
|
} else {
|
||||||
|
// setSearchKeyword(text);
|
||||||
|
Modal.error({ title: '无法复制到剪贴板,请手动复制', content: text });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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');
|
||||||
|
const mjLink = localStorage.getItem('chat_link2');
|
||||||
|
let defaultUrl;
|
||||||
|
|
||||||
|
if (chatLink) {
|
||||||
|
defaultUrl = chatLink + `/#/?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-mj':
|
||||||
|
url = mjLink + `/#/?settings={"key":"sk-${key}","url":"${serverAddress}"}`;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
if (!chatLink) {
|
||||||
|
showError('管理员未设置聊天链接');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
url = defaultUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.open(url, '_blank');
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadTokens(0)
|
||||||
|
.then()
|
||||||
|
.catch((reason) => {
|
||||||
|
showError(reason);
|
||||||
|
});
|
||||||
|
}, [pageSize]);
|
||||||
|
|
||||||
|
const removeRecord = key => {
|
||||||
|
let newDataSource = [...tokens];
|
||||||
|
if (key != null) {
|
||||||
|
let idx = newDataSource.findIndex(data => data.key === key);
|
||||||
|
|
||||||
|
if (idx > -1) {
|
||||||
|
newDataSource.splice(idx, 1);
|
||||||
|
setTokensFormat(newDataSource);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const manageToken = async (id, action, record) => {
|
||||||
|
setLoading(true);
|
||||||
|
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('操作成功完成!');
|
||||||
|
let token = res.data.data;
|
||||||
|
let newTokens = [...tokens];
|
||||||
|
// let realIdx = (activePage - 1) * ITEMS_PER_PAGE + idx;
|
||||||
|
if (action === 'delete') {
|
||||||
|
|
||||||
|
} else {
|
||||||
|
record.status = token.status;
|
||||||
|
// newTokens[realIdx].status = token.status;
|
||||||
|
}
|
||||||
|
setTokensFormat(newTokens);
|
||||||
|
} else {
|
||||||
|
showError(message);
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const searchTokens = async () => {
|
||||||
|
if (searchKeyword === '' && searchToken === '') {
|
||||||
|
// if keyword is blank, load files instead.
|
||||||
|
await loadTokens(0);
|
||||||
|
setActivePage(1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSearching(true);
|
||||||
|
const res = await API.get(`/api/token/search?keyword=${searchKeyword}&token=${searchToken}`);
|
||||||
|
const { success, message, data } = res.data;
|
||||||
|
if (success) {
|
||||||
|
setTokensFormat(data);
|
||||||
|
setActivePage(1);
|
||||||
|
} else {
|
||||||
|
showError(message);
|
||||||
|
}
|
||||||
|
setSearching(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeywordChange = async (value) => {
|
||||||
|
setSearchKeyword(value.trim());
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSearchTokenChange = async (value) => {
|
||||||
|
setSearchToken(value.trim());
|
||||||
|
};
|
||||||
|
|
||||||
|
const sortToken = (key) => {
|
||||||
|
if (tokens.length === 0) return;
|
||||||
|
setLoading(true);
|
||||||
|
let sortedTokens = [...tokens];
|
||||||
|
sortedTokens.sort((a, b) => {
|
||||||
|
return ('' + a[key]).localeCompare(b[key]);
|
||||||
|
});
|
||||||
|
if (sortedTokens[0].id === tokens[0].id) {
|
||||||
|
sortedTokens.reverse();
|
||||||
|
}
|
||||||
|
setTokens(sortedTokens);
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
const handlePageChange = page => {
|
||||||
|
setActivePage(page);
|
||||||
|
if (page === Math.ceil(tokens.length / pageSize) + 1) {
|
||||||
|
// In this case we have to load more data and then append them.
|
||||||
|
loadTokens(page - 1).then(r => {
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const rowSelection = {
|
||||||
|
onSelect: (record, selected) => {
|
||||||
|
},
|
||||||
|
onSelectAll: (selected, selectedRows) => {
|
||||||
|
},
|
||||||
|
onChange: (selectedRowKeys, selectedRows) => {
|
||||||
|
setSelectedKeys(selectedRows);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRow = (record, index) => {
|
||||||
|
if (record.status !== 1) {
|
||||||
|
return {
|
||||||
|
style: {
|
||||||
|
background: 'var(--semi-color-disabled-border)'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<EditToken refresh={refresh} editingToken={editingToken} visiable={showEdit} handleClose={closeEdit}></EditToken>
|
||||||
|
<Form layout="horizontal" style={{ marginTop: 10 }} labelPosition={'left'}>
|
||||||
|
<Form.Input
|
||||||
|
field="keyword"
|
||||||
|
label="搜索关键字"
|
||||||
|
placeholder="令牌名称"
|
||||||
|
value={searchKeyword}
|
||||||
|
loading={searching}
|
||||||
|
onChange={handleKeywordChange}
|
||||||
|
/>
|
||||||
|
{/* <Form.Input
|
||||||
|
field="token"
|
||||||
|
label="Key"
|
||||||
|
placeholder="密钥"
|
||||||
|
value={searchToken}
|
||||||
|
loading={searching}
|
||||||
|
onChange={handleSearchTokenChange}
|
||||||
|
/> */}
|
||||||
|
<Button label="查询" type="primary" htmlType="submit" className="btn-margin-right"
|
||||||
|
onClick={searchTokens} style={{ marginRight: 8 }}>查询</Button>
|
||||||
|
</Form>
|
||||||
|
|
||||||
|
<Table style={{ marginTop: 20 }} columns={columns} dataSource={pageData} pagination={{
|
||||||
|
currentPage: activePage,
|
||||||
|
pageSize: pageSize,
|
||||||
|
total: tokenCount,
|
||||||
|
showSizeChanger: true,
|
||||||
|
pageSizeOptions: [10, 20, 50, 100],
|
||||||
|
formatPageText: (page) => `第 ${page.currentStart} - ${page.currentEnd} 条,共 ${tokens.length} 条`,
|
||||||
|
onPageSizeChange: (size) => {
|
||||||
|
setPageSize(size);
|
||||||
|
setActivePage(1);
|
||||||
|
},
|
||||||
|
onPageChange: handlePageChange
|
||||||
|
}} loading={loading} rowSelection={rowSelection} onRow={handleRow}>
|
||||||
|
</Table>
|
||||||
|
<Button theme="light" type="primary" style={{ marginRight: 8 }} onClick={
|
||||||
|
() => {
|
||||||
|
setEditingToken({
|
||||||
|
id: undefined
|
||||||
|
});
|
||||||
|
setShowEdit(true);
|
||||||
|
}
|
||||||
|
}>添加令牌</Button>
|
||||||
|
<Button label="复制所选令牌" type="warning" onClick={
|
||||||
|
async () => {
|
||||||
|
if (selectedKeys.length === 0) {
|
||||||
|
showError('请至少选择一个令牌!');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let keys = '';
|
||||||
|
for (let i = 0; i < selectedKeys.length; i++) {
|
||||||
|
keys += selectedKeys[i].name + ' sk-' + selectedKeys[i].key + '\n';
|
||||||
|
}
|
||||||
|
await copyText(keys);
|
||||||
|
}
|
||||||
|
}>复制所选令牌到剪贴板</Button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TokensTable;
|
338
web/air/src/components/UsersTable.js
Normal file
338
web/air/src/components/UsersTable.js
Normal file
@ -0,0 +1,338 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { API, showError, showSuccess } from '../helpers';
|
||||||
|
import { Button, Form, Popconfirm, Space, Table, Tag, Tooltip } from '@douyinfe/semi-ui';
|
||||||
|
import { ITEMS_PER_PAGE } from '../constants';
|
||||||
|
import { renderGroup, renderNumber, renderQuota } from '../helpers/render';
|
||||||
|
import AddUser from '../pages/User/AddUser';
|
||||||
|
import EditUser from '../pages/User/EditUser';
|
||||||
|
|
||||||
|
function renderRole(role) {
|
||||||
|
switch (role) {
|
||||||
|
case 1:
|
||||||
|
return <Tag size="large">普通用户</Tag>;
|
||||||
|
case 10:
|
||||||
|
return <Tag color="yellow" size="large">管理员</Tag>;
|
||||||
|
case 100:
|
||||||
|
return <Tag color="orange" size="large">超级管理员</Tag>;
|
||||||
|
default:
|
||||||
|
return <Tag color="red" size="large">未知身份</Tag>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const UsersTable = () => {
|
||||||
|
const columns = [{
|
||||||
|
title: 'ID', dataIndex: 'id'
|
||||||
|
}, {
|
||||||
|
title: '用户名', dataIndex: 'username'
|
||||||
|
}, {
|
||||||
|
title: '分组', dataIndex: 'group', render: (text, record, index) => {
|
||||||
|
return (<div>
|
||||||
|
{renderGroup(text)}
|
||||||
|
</div>);
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
title: '统计信息', dataIndex: 'info', render: (text, record, index) => {
|
||||||
|
return (<div>
|
||||||
|
<Space spacing={1}>
|
||||||
|
<Tooltip content={'剩余额度'}>
|
||||||
|
<Tag color="white" size="large">{renderQuota(record.quota)}</Tag>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip content={'已用额度'}>
|
||||||
|
<Tag color="white" size="large">{renderQuota(record.used_quota)}</Tag>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip content={'调用次数'}>
|
||||||
|
<Tag color="white" size="large">{renderNumber(record.request_count)}</Tag>
|
||||||
|
</Tooltip>
|
||||||
|
</Space>
|
||||||
|
</div>);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// {
|
||||||
|
// title: '邀请信息', dataIndex: 'invite', render: (text, record, index) => {
|
||||||
|
// return (<div>
|
||||||
|
// <Space spacing={1}>
|
||||||
|
// <Tooltip content={'邀请人数'}>
|
||||||
|
// <Tag color="white" size="large">{renderNumber(record.aff_count)}</Tag>
|
||||||
|
// </Tooltip>
|
||||||
|
// <Tooltip content={'邀请总收益'}>
|
||||||
|
// <Tag color="white" size="large">{renderQuota(record.aff_history_quota)}</Tag>
|
||||||
|
// </Tooltip>
|
||||||
|
// <Tooltip content={'邀请人ID'}>
|
||||||
|
// {record.inviter_id === 0 ? <Tag color="white" size="large">无</Tag> :
|
||||||
|
// <Tag color="white" size="large">{record.inviter_id}</Tag>}
|
||||||
|
// </Tooltip>
|
||||||
|
// </Space>
|
||||||
|
// </div>);
|
||||||
|
// }
|
||||||
|
// },
|
||||||
|
{
|
||||||
|
title: '角色', dataIndex: 'role', render: (text, record, index) => {
|
||||||
|
return (<div>
|
||||||
|
{renderRole(text)}
|
||||||
|
</div>);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '状态', dataIndex: 'status', render: (text, record, index) => {
|
||||||
|
return (<div>
|
||||||
|
{renderStatus(text)}
|
||||||
|
</div>);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '', dataIndex: 'operate', render: (text, record, index) => (<div>
|
||||||
|
<>
|
||||||
|
<Popconfirm
|
||||||
|
title="确定?"
|
||||||
|
okType={'warning'}
|
||||||
|
onConfirm={() => {
|
||||||
|
manageUser(record.username, 'promote', record);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button theme="light" type="warning" style={{ marginRight: 1 }}>提升</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
<Popconfirm
|
||||||
|
title="确定?"
|
||||||
|
okType={'warning'}
|
||||||
|
onConfirm={() => {
|
||||||
|
manageUser(record.username, 'demote', record);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button theme="light" type="secondary" style={{ marginRight: 1 }}>降级</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
{record.status === 1 ?
|
||||||
|
<Button theme="light" type="warning" style={{ marginRight: 1 }} onClick={async () => {
|
||||||
|
manageUser(record.username, 'disable', record);
|
||||||
|
}}>禁用</Button> :
|
||||||
|
<Button theme="light" type="secondary" style={{ marginRight: 1 }} onClick={async () => {
|
||||||
|
manageUser(record.username, 'enable', record);
|
||||||
|
}} disabled={record.status === 3}>启用</Button>}
|
||||||
|
<Button theme="light" type="tertiary" style={{ marginRight: 1 }} onClick={() => {
|
||||||
|
setEditingUser(record);
|
||||||
|
setShowEditUser(true);
|
||||||
|
}}>编辑</Button>
|
||||||
|
</>
|
||||||
|
<Popconfirm
|
||||||
|
title="确定是否要删除此用户?"
|
||||||
|
content="硬删除,此修改将不可逆"
|
||||||
|
okType={'danger'}
|
||||||
|
position={'left'}
|
||||||
|
onConfirm={() => {
|
||||||
|
manageUser(record.username, 'delete', record).then(() => {
|
||||||
|
removeRecord(record.id);
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button theme="light" type="danger" style={{ marginRight: 1 }}>删除</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
</div>)
|
||||||
|
}];
|
||||||
|
|
||||||
|
const [users, setUsers] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [activePage, setActivePage] = useState(1);
|
||||||
|
const [searchKeyword, setSearchKeyword] = useState('');
|
||||||
|
const [searching, setSearching] = useState(false);
|
||||||
|
const [userCount, setUserCount] = useState(ITEMS_PER_PAGE);
|
||||||
|
const [showAddUser, setShowAddUser] = useState(false);
|
||||||
|
const [showEditUser, setShowEditUser] = useState(false);
|
||||||
|
const [editingUser, setEditingUser] = useState({
|
||||||
|
id: undefined
|
||||||
|
});
|
||||||
|
|
||||||
|
const setCount = (data) => {
|
||||||
|
if (data.length >= (activePage) * ITEMS_PER_PAGE) {
|
||||||
|
setUserCount(data.length + 1);
|
||||||
|
} else {
|
||||||
|
setUserCount(data.length);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeRecord = key => {
|
||||||
|
console.log(key);
|
||||||
|
let newDataSource = [...users];
|
||||||
|
if (key != null) {
|
||||||
|
let idx = newDataSource.findIndex(data => data.id === key);
|
||||||
|
|
||||||
|
if (idx > -1) {
|
||||||
|
newDataSource.splice(idx, 1);
|
||||||
|
setUsers(newDataSource);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadUsers = async (startIdx) => {
|
||||||
|
const res = await API.get(`/api/user/?p=${startIdx}`);
|
||||||
|
const { success, message, data } = res.data;
|
||||||
|
if (success) {
|
||||||
|
if (startIdx === 0) {
|
||||||
|
setUsers(data);
|
||||||
|
setCount(data);
|
||||||
|
} else {
|
||||||
|
let newUsers = users;
|
||||||
|
newUsers.push(...data);
|
||||||
|
setUsers(newUsers);
|
||||||
|
setCount(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);
|
||||||
|
}
|
||||||
|
setActivePage(activePage);
|
||||||
|
})();
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadUsers(0)
|
||||||
|
.then()
|
||||||
|
.catch((reason) => {
|
||||||
|
showError(reason);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const manageUser = async (username, action, record) => {
|
||||||
|
const res = await API.post('/api/user/manage', {
|
||||||
|
username, action
|
||||||
|
});
|
||||||
|
const { success, message } = res.data;
|
||||||
|
if (success) {
|
||||||
|
showSuccess('操作成功完成!');
|
||||||
|
let user = res.data.data;
|
||||||
|
let newUsers = [...users];
|
||||||
|
if (action === 'delete') {
|
||||||
|
|
||||||
|
} else {
|
||||||
|
record.status = user.status;
|
||||||
|
record.role = user.role;
|
||||||
|
}
|
||||||
|
setUsers(newUsers);
|
||||||
|
} else {
|
||||||
|
showError(message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderStatus = (status) => {
|
||||||
|
switch (status) {
|
||||||
|
case 1:
|
||||||
|
return <Tag size="large">已激活</Tag>;
|
||||||
|
case 2:
|
||||||
|
return (<Tag size="large" color="red">
|
||||||
|
已封禁
|
||||||
|
</Tag>);
|
||||||
|
default:
|
||||||
|
return (<Tag size="large" color="grey">
|
||||||
|
未知状态
|
||||||
|
</Tag>);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const searchUsers = async () => {
|
||||||
|
if (searchKeyword === '') {
|
||||||
|
// if keyword is blank, load files instead.
|
||||||
|
await loadUsers(0);
|
||||||
|
setActivePage(1);
|
||||||
|
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 (value) => {
|
||||||
|
setSearchKeyword(value.trim());
|
||||||
|
};
|
||||||
|
|
||||||
|
const sortUser = (key) => {
|
||||||
|
if (users.length === 0) return;
|
||||||
|
setLoading(true);
|
||||||
|
let sortedUsers = [...users];
|
||||||
|
sortedUsers.sort((a, b) => {
|
||||||
|
return ('' + a[key]).localeCompare(b[key]);
|
||||||
|
});
|
||||||
|
if (sortedUsers[0].id === users[0].id) {
|
||||||
|
sortedUsers.reverse();
|
||||||
|
}
|
||||||
|
setUsers(sortedUsers);
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePageChange = page => {
|
||||||
|
setActivePage(page);
|
||||||
|
if (page === Math.ceil(users.length / ITEMS_PER_PAGE) + 1) {
|
||||||
|
// In this case we have to load more data and then append them.
|
||||||
|
loadUsers(page - 1).then(r => {
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const pageData = users.slice((activePage - 1) * ITEMS_PER_PAGE, activePage * ITEMS_PER_PAGE);
|
||||||
|
|
||||||
|
const closeAddUser = () => {
|
||||||
|
setShowAddUser(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeEditUser = () => {
|
||||||
|
setShowEditUser(false);
|
||||||
|
setEditingUser({
|
||||||
|
id: undefined
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const refresh = async () => {
|
||||||
|
if (searchKeyword === '') {
|
||||||
|
await loadUsers(activePage - 1);
|
||||||
|
} else {
|
||||||
|
await searchUsers();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<AddUser refresh={refresh} visible={showAddUser} handleClose={closeAddUser}></AddUser>
|
||||||
|
<EditUser refresh={refresh} visible={showEditUser} handleClose={closeEditUser}
|
||||||
|
editingUser={editingUser}></EditUser>
|
||||||
|
<Form onSubmit={searchUsers}>
|
||||||
|
<Form.Input
|
||||||
|
label="搜索关键字"
|
||||||
|
icon="search"
|
||||||
|
field="keyword"
|
||||||
|
iconPosition="left"
|
||||||
|
placeholder="搜索用户的 ID,用户名,显示名称,以及邮箱地址 ..."
|
||||||
|
value={searchKeyword}
|
||||||
|
loading={searching}
|
||||||
|
onChange={value => handleKeywordChange(value)}
|
||||||
|
/>
|
||||||
|
</Form>
|
||||||
|
|
||||||
|
<Table columns={columns} dataSource={pageData} pagination={{
|
||||||
|
currentPage: activePage,
|
||||||
|
pageSize: ITEMS_PER_PAGE,
|
||||||
|
total: userCount,
|
||||||
|
pageSizeOpts: [10, 20, 50, 100],
|
||||||
|
onPageChange: handlePageChange
|
||||||
|
}} loading={loading} />
|
||||||
|
<Button theme="light" type="primary" style={{ marginRight: 8 }} onClick={
|
||||||
|
() => {
|
||||||
|
setShowAddUser(true);
|
||||||
|
}
|
||||||
|
}>添加用户</Button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default UsersTable;
|
24
web/air/src/components/WeChatIcon.js
Normal file
24
web/air/src/components/WeChatIcon.js
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Icon } from '@douyinfe/semi-ui';
|
||||||
|
|
||||||
|
const WeChatIcon = () => {
|
||||||
|
function CustomIcon() {
|
||||||
|
return <svg t="1709714447384" className="icon" viewBox="0 0 1024 1024" version="1.1"
|
||||||
|
xmlns="http://www.w3.org/2000/svg" p-id="5091" width="16" height="16">
|
||||||
|
<path
|
||||||
|
d="M690.1 377.4c5.9 0 11.8 0.2 17.6 0.5-24.4-128.7-158.3-227.1-319.9-227.1C209 150.8 64 271.4 64 420.2c0 81.1 43.6 154.2 111.9 203.6 5.5 3.9 9.1 10.3 9.1 17.6 0 2.4-0.5 4.6-1.1 6.9-5.5 20.3-14.2 52.8-14.6 54.3-0.7 2.6-1.7 5.2-1.7 7.9 0 5.9 4.8 10.8 10.8 10.8 2.3 0 4.2-0.9 6.2-2l70.9-40.9c5.3-3.1 11-5 17.2-5 3.2 0 6.4 0.5 9.5 1.4 33.1 9.5 68.8 14.8 105.7 14.8 6 0 11.9-0.1 17.8-0.4-7.1-21-10.9-43.1-10.9-66 0-135.8 132.2-245.8 295.3-245.8z m-194.3-86.5c23.8 0 43.2 19.3 43.2 43.1s-19.3 43.1-43.2 43.1c-23.8 0-43.2-19.3-43.2-43.1s19.4-43.1 43.2-43.1z m-215.9 86.2c-23.8 0-43.2-19.3-43.2-43.1s19.3-43.1 43.2-43.1 43.2 19.3 43.2 43.1-19.4 43.1-43.2 43.1z"
|
||||||
|
p-id="5092"></path>
|
||||||
|
<path
|
||||||
|
d="M866.7 792.7c56.9-41.2 93.2-102 93.2-169.7 0-124-120.8-224.5-269.9-224.5-149 0-269.9 100.5-269.9 224.5S540.9 847.5 690 847.5c30.8 0 60.6-4.4 88.1-12.3 2.6-0.8 5.2-1.2 7.9-1.2 5.2 0 9.9 1.6 14.3 4.1l59.1 34c1.7 1 3.3 1.7 5.2 1.7 2.4 0 4.7-0.9 6.4-2.6 1.7-1.7 2.6-4 2.6-6.4 0-2.2-0.9-4.4-1.4-6.6-0.3-1.2-7.6-28.3-12.2-45.3-0.5-1.9-0.9-3.8-0.9-5.7 0.1-5.9 3.1-11.2 7.6-14.5zM600.2 587.2c-19.9 0-36-16.1-36-35.9 0-19.8 16.1-35.9 36-35.9s36 16.1 36 35.9c0 19.8-16.2 35.9-36 35.9z m179.9 0c-19.9 0-36-16.1-36-35.9 0-19.8 16.1-35.9 36-35.9s36 16.1 36 35.9c-0.1 19.8-16.2 35.9-36 35.9z"
|
||||||
|
p-id="5093"></path>
|
||||||
|
</svg>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Icon svg={<CustomIcon />} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default WeChatIcon;
|
20
web/air/src/components/utils.js
Normal file
20
web/air/src/components/utils.js
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
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`
|
||||||
|
);
|
||||||
|
}
|
37
web/air/src/constants/channel.constants.js
Normal file
37
web/air/src/constants/channel.constants.js
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
export const CHANNEL_OPTIONS = [
|
||||||
|
{ key: 1, text: 'OpenAI', value: 1, color: 'green' },
|
||||||
|
{ key: 14, text: 'Anthropic Claude', value: 14, color: 'black' },
|
||||||
|
{ key: 3, text: 'Azure OpenAI', value: 3, color: 'olive' },
|
||||||
|
{ key: 11, text: 'Google PaLM2', value: 11, color: 'orange' },
|
||||||
|
{ key: 24, text: 'Google Gemini', value: 24, color: 'orange' },
|
||||||
|
{ key: 28, text: 'Mistral AI', value: 28, color: 'orange' },
|
||||||
|
{ key: 15, text: '百度文心千帆', value: 15, color: 'blue' },
|
||||||
|
{ key: 17, text: '阿里通义千问', value: 17, color: 'orange' },
|
||||||
|
{ key: 18, text: '讯飞星火认知', value: 18, color: 'blue' },
|
||||||
|
{ 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: 8, text: '自定义渠道', value: 8, color: 'pink' },
|
||||||
|
{ 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' }
|
||||||
|
];
|
||||||
|
|
||||||
|
for (let i = 0; i < CHANNEL_OPTIONS.length; i++) {
|
||||||
|
CHANNEL_OPTIONS[i].label = CHANNEL_OPTIONS[i].text;
|
||||||
|
}
|
1
web/air/src/constants/common.constant.js
Normal file
1
web/air/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/air/src/constants/index.js
Normal file
4
web/air/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/air/src/constants/toast.constants.js
Normal file
7
web/air/src/constants/toast.constants.js
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
export const toastConstants = {
|
||||||
|
SUCCESS_TIMEOUT: 1500,
|
||||||
|
INFO_TIMEOUT: 3000,
|
||||||
|
ERROR_TIMEOUT: 5000,
|
||||||
|
WARNING_TIMEOUT: 10000,
|
||||||
|
NOTICE_TIMEOUT: 20000
|
||||||
|
};
|
19
web/air/src/constants/user.constants.js
Normal file
19
web/air/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/air/src/context/Status/index.js
Normal file
19
web/air/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/air/src/context/Status/reducer.js
Normal file
20
web/air/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/air/src/context/User/index.js
Normal file
19
web/air/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/air/src/context/User/reducer.js
Normal file
21
web/air/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/air/src/helpers/api.js
Normal file
13
web/air/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/air/src/helpers/auth-header.js
Normal file
10
web/air/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 {};
|
||||||
|
}
|
||||||
|
}
|
3
web/air/src/helpers/history.js
Normal file
3
web/air/src/helpers/history.js
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
import { createBrowserHistory } from 'history';
|
||||||
|
|
||||||
|
export const history = createBrowserHistory();
|
4
web/air/src/helpers/index.js
Normal file
4
web/air/src/helpers/index.js
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
export * from './history';
|
||||||
|
export * from './auth-header';
|
||||||
|
export * from './utils';
|
||||||
|
export * from './api';
|
170
web/air/src/helpers/render.js
Normal file
170
web/air/src/helpers/render.js
Normal file
@ -0,0 +1,170 @@
|
|||||||
|
import {Label} from 'semantic-ui-react';
|
||||||
|
import {Tag} from "@douyinfe/semi-ui";
|
||||||
|
|
||||||
|
export function renderText(text, limit) {
|
||||||
|
if (text.length > limit) {
|
||||||
|
return text.slice(0, limit - 3) + '...';
|
||||||
|
}
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderGroup(group) {
|
||||||
|
if (group === '') {
|
||||||
|
return <Tag size='large'>default</Tag>;
|
||||||
|
}
|
||||||
|
let groups = group.split(',');
|
||||||
|
groups.sort();
|
||||||
|
return <>
|
||||||
|
{groups.map((group) => {
|
||||||
|
if (group === 'vip' || group === 'pro') {
|
||||||
|
return <Tag size='large' color='yellow'>{group}</Tag>;
|
||||||
|
} else if (group === 'svip' || group === 'premium') {
|
||||||
|
return <Tag size='large' color='red'>{group}</Tag>;
|
||||||
|
}
|
||||||
|
if (group === 'default') {
|
||||||
|
return <Tag size='large'>{group}</Tag>;
|
||||||
|
} else {
|
||||||
|
return <Tag size='large' color={stringToColor(group)}>{group}</Tag>;
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 renderQuotaNumberWithDigit(num, digits = 2) {
|
||||||
|
let displayInCurrency = localStorage.getItem('display_in_currency');
|
||||||
|
num = num.toFixed(digits);
|
||||||
|
if (displayInCurrency) {
|
||||||
|
return '$' + num;
|
||||||
|
}
|
||||||
|
return num;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderNumberWithPoint(num) {
|
||||||
|
num = num.toFixed(2);
|
||||||
|
if (num >= 100000) {
|
||||||
|
// Convert number to string to manipulate it
|
||||||
|
let numStr = num.toString();
|
||||||
|
// Find the position of the decimal point
|
||||||
|
let decimalPointIndex = numStr.indexOf('.');
|
||||||
|
|
||||||
|
let wholePart = numStr;
|
||||||
|
let decimalPart = '';
|
||||||
|
|
||||||
|
// If there is a decimal point, split the number into whole and decimal parts
|
||||||
|
if (decimalPointIndex !== -1) {
|
||||||
|
wholePart = numStr.slice(0, decimalPointIndex);
|
||||||
|
decimalPart = numStr.slice(decimalPointIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Take the first two and last two digits of the whole number part
|
||||||
|
let shortenedWholePart = wholePart.slice(0, 2) + '..' + wholePart.slice(-2);
|
||||||
|
|
||||||
|
// Return the formatted number
|
||||||
|
return shortenedWholePart + decimalPart;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the number is less than 100,000, return it unmodified
|
||||||
|
return num;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getQuotaPerUnit() {
|
||||||
|
let quotaPerUnit = localStorage.getItem('quota_per_unit');
|
||||||
|
quotaPerUnit = parseFloat(quotaPerUnit);
|
||||||
|
return quotaPerUnit;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getQuotaWithUnit(quota, digits = 6) {
|
||||||
|
let quotaPerUnit = localStorage.getItem('quota_per_unit');
|
||||||
|
quotaPerUnit = parseFloat(quotaPerUnit);
|
||||||
|
return (quota / quotaPerUnit).toFixed(digits);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderQuota(quota, digits = 2) {
|
||||||
|
let quotaPerUnit = localStorage.getItem('quota_per_unit');
|
||||||
|
let displayInCurrency = localStorage.getItem('display_in_currency');
|
||||||
|
quotaPerUnit = parseFloat(quotaPerUnit);
|
||||||
|
displayInCurrency = displayInCurrency === 'true';
|
||||||
|
if (displayInCurrency) {
|
||||||
|
return '$' + (quota / quotaPerUnit).toFixed(digits);
|
||||||
|
}
|
||||||
|
return renderNumber(quota);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderQuotaWithPrompt(quota, digits) {
|
||||||
|
let displayInCurrency = localStorage.getItem('display_in_currency');
|
||||||
|
displayInCurrency = displayInCurrency === 'true';
|
||||||
|
if (displayInCurrency) {
|
||||||
|
return `(等价金额:${renderQuota(quota, digits)})`;
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const colors = ['amber', 'blue', 'cyan', 'green', 'grey', 'indigo',
|
||||||
|
'light-blue', 'lime', 'orange', 'pink',
|
||||||
|
'purple', 'red', 'teal', 'violet', 'yellow'
|
||||||
|
]
|
||||||
|
|
||||||
|
export const modelColorMap = {
|
||||||
|
'dall-e': 'rgb(147,112,219)', // 深紫色
|
||||||
|
'dall-e-2': 'rgb(147,112,219)', // 介于紫色和蓝色之间的色调
|
||||||
|
'dall-e-3': 'rgb(153,50,204)', // 介于紫罗兰和洋红之间的色调
|
||||||
|
'midjourney': 'rgb(136,43,180)', // 介于紫罗兰和洋红之间的色调
|
||||||
|
'gpt-3.5-turbo': 'rgb(184,227,167)', // 浅绿色
|
||||||
|
'gpt-3.5-turbo-0301': 'rgb(131,220,131)', // 亮绿色
|
||||||
|
'gpt-3.5-turbo-0613': 'rgb(60,179,113)', // 海洋绿
|
||||||
|
'gpt-3.5-turbo-1106': 'rgb(32,178,170)', // 浅海洋绿
|
||||||
|
'gpt-3.5-turbo-16k': 'rgb(252,200,149)', // 淡橙色
|
||||||
|
'gpt-3.5-turbo-16k-0613': 'rgb(255,181,119)', // 淡桃色
|
||||||
|
'gpt-3.5-turbo-instruct': 'rgb(175,238,238)', // 粉蓝色
|
||||||
|
'gpt-4': 'rgb(135,206,235)', // 天蓝色
|
||||||
|
'gpt-4-0314': 'rgb(70,130,180)', // 钢蓝色
|
||||||
|
'gpt-4-0613': 'rgb(100,149,237)', // 矢车菊蓝
|
||||||
|
'gpt-4-1106-preview': 'rgb(30,144,255)', // 道奇蓝
|
||||||
|
'gpt-4-0125-preview': 'rgb(2,177,236)', // 深天蓝
|
||||||
|
'gpt-4-turbo-preview': 'rgb(2,177,255)', // 深天蓝
|
||||||
|
'gpt-4-32k': 'rgb(104,111,238)', // 中紫色
|
||||||
|
'gpt-4-32k-0314': 'rgb(90,105,205)', // 暗灰蓝色
|
||||||
|
'gpt-4-32k-0613': 'rgb(61,71,139)', // 暗蓝灰色
|
||||||
|
'gpt-4-all': 'rgb(65,105,225)', // 皇家蓝
|
||||||
|
'gpt-4-gizmo-*': 'rgb(0,0,255)', // 纯蓝色
|
||||||
|
'gpt-4-vision-preview': 'rgb(25,25,112)', // 午夜蓝
|
||||||
|
'text-ada-001': 'rgb(255,192,203)', // 粉红色
|
||||||
|
'text-babbage-001': 'rgb(255,160,122)', // 浅珊瑚色
|
||||||
|
'text-curie-001': 'rgb(219,112,147)', // 苍紫罗兰色
|
||||||
|
'text-davinci-002': 'rgb(199,21,133)', // 中紫罗兰红色
|
||||||
|
'text-davinci-003': 'rgb(219,112,147)', // 苍紫罗兰色(与Curie相同,表示同一个系列)
|
||||||
|
'text-davinci-edit-001': 'rgb(255,105,180)', // 热粉色
|
||||||
|
'text-embedding-ada-002': 'rgb(255,182,193)', // 浅粉红
|
||||||
|
'text-embedding-v1': 'rgb(255,174,185)', // 浅粉红色(略有区别)
|
||||||
|
'text-moderation-latest': 'rgb(255,130,171)', // 强粉色
|
||||||
|
'text-moderation-stable': 'rgb(255,160,122)', // 浅珊瑚色(与Babbage相同,表示同一类功能)
|
||||||
|
'tts-1': 'rgb(255,140,0)', // 深橙色
|
||||||
|
'tts-1-1106': 'rgb(255,165,0)', // 橙色
|
||||||
|
'tts-1-hd': 'rgb(255,215,0)', // 金色
|
||||||
|
'tts-1-hd-1106': 'rgb(255,223,0)', // 金黄色(略有区别)
|
||||||
|
'whisper-1': 'rgb(245,245,220)' // 米色
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stringToColor(str) {
|
||||||
|
let sum = 0;
|
||||||
|
// 对字符串中的每个字符进行操作
|
||||||
|
for (let i = 0; i < str.length; i++) {
|
||||||
|
// 将字符的ASCII值加到sum中
|
||||||
|
sum += str.charCodeAt(i);
|
||||||
|
}
|
||||||
|
// 使用模运算得到个位数
|
||||||
|
let i = sum % colors.length;
|
||||||
|
return colors[i];
|
||||||
|
}
|
233
web/air/src/helpers/utils.js
Normal file
233
web/air/src/helpers/utils.js
Normal file
@ -0,0 +1,233 @@
|
|||||||
|
import { Toast } from '@douyinfe/semi-ui';
|
||||||
|
import { toastConstants } from '../constants';
|
||||||
|
import React from 'react';
|
||||||
|
import {toast} from "react-toastify";
|
||||||
|
|
||||||
|
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 'New 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) {
|
||||||
|
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('错误:请求次数过多,请稍后再试!');
|
||||||
|
break;
|
||||||
|
case 500:
|
||||||
|
Toast.error('错误:服务器内部错误,请联系管理员!');
|
||||||
|
break;
|
||||||
|
case 405:
|
||||||
|
Toast.info('本站仅作演示之用,无服务端!');
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
Toast.error('错误:' + error.message);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Toast.error('错误:' + error.message);
|
||||||
|
} else {
|
||||||
|
Toast.error('错误:' + error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function showWarning(message) {
|
||||||
|
Toast.warning(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function showSuccess(message) {
|
||||||
|
Toast.success(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function showInfo(message) {
|
||||||
|
Toast.info(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function showNotice(message, isHTML = false) {
|
||||||
|
if (isHTML) {
|
||||||
|
toast(<HTMLToastContent htmlContent={message} />, showNoticeOptions);
|
||||||
|
} else {
|
||||||
|
Toast.info(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 timestamp2string1(timestamp, dataExportDefaultTime = 'hour') {
|
||||||
|
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();
|
||||||
|
if (month.length === 1) {
|
||||||
|
month = '0' + month;
|
||||||
|
}
|
||||||
|
if (day.length === 1) {
|
||||||
|
day = '0' + day;
|
||||||
|
}
|
||||||
|
if (hour.length === 1) {
|
||||||
|
hour = '0' + hour;
|
||||||
|
}
|
||||||
|
let str = month + '-' + day
|
||||||
|
if (dataExportDefaultTime === 'hour') {
|
||||||
|
str += ' ' + hour + ":00"
|
||||||
|
} else if (dataExportDefaultTime === 'week') {
|
||||||
|
let nextWeek = new Date(timestamp * 1000 + 6 * 24 * 60 * 60 * 1000);
|
||||||
|
let nextMonth = (nextWeek.getMonth() + 1).toString();
|
||||||
|
let nextDay = nextWeek.getDate().toString();
|
||||||
|
if (nextMonth.length === 1) {
|
||||||
|
nextMonth = '0' + nextMonth;
|
||||||
|
}
|
||||||
|
if (nextDay.length === 1) {
|
||||||
|
nextDay = '0' + nextDay;
|
||||||
|
}
|
||||||
|
str += ' - ' + nextMonth + '-' + nextDay
|
||||||
|
}
|
||||||
|
return str;
|
||||||
|
}
|
||||||
|
|
||||||
|
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');
|
||||||
|
}
|
105
web/air/src/index.css
Normal file
105
web/air/src/index.css
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
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;
|
||||||
|
color: var(--semi-color-text-0) !important;
|
||||||
|
background-color: var( --semi-color-bg-0) !important;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
#root {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media only screen and (max-width: 767px) {
|
||||||
|
.semi-table-tbody, .semi-table-row, .semi-table-row-cell {
|
||||||
|
display: block!important;
|
||||||
|
width: auto!important;
|
||||||
|
padding: 2px!important;
|
||||||
|
}
|
||||||
|
.semi-table-row-cell {
|
||||||
|
border-bottom: 0!important;
|
||||||
|
}
|
||||||
|
.semi-table-tbody>.semi-table-row {
|
||||||
|
border-bottom: 1px solid rgba(0,0,0,.1);
|
||||||
|
}
|
||||||
|
.semi-space {
|
||||||
|
/*display: block!important;*/
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
row-gap: 3px;
|
||||||
|
column-gap: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.semi-table-tbody > .semi-table-row > .semi-table-row-cell {
|
||||||
|
padding: 16px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-table {
|
||||||
|
.semi-table-tbody > .semi-table-row > .semi-table-row-cell {
|
||||||
|
padding: 16px 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.semi-layout {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tableShow {
|
||||||
|
display: revert;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tableHiddle {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
body::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.semi-navigation-vertical {
|
||||||
|
/*display: flex;*/
|
||||||
|
/*flex-direction: column;*/
|
||||||
|
}
|
||||||
|
|
||||||
|
.semi-navigation-item {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.semi-navigation-vertical {
|
||||||
|
/*flex: 0 0 auto;*/
|
||||||
|
/*display: flex;*/
|
||||||
|
/*flex-direction: column;*/
|
||||||
|
/*width: 100%;*/
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-content {
|
||||||
|
padding: 4px;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
}
|
54
web/air/src/index.js
Normal file
54
web/air/src/index.js
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
import { initVChartSemiTheme } from '@visactor/vchart-semi-theme';
|
||||||
|
import React from 'react';
|
||||||
|
import ReactDOM from 'react-dom/client';
|
||||||
|
import {BrowserRouter} from 'react-router-dom';
|
||||||
|
import App from './App';
|
||||||
|
import HeaderBar from './components/HeaderBar';
|
||||||
|
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 {Layout} from "@douyinfe/semi-ui";
|
||||||
|
import SiderBar from "./components/SiderBar";
|
||||||
|
|
||||||
|
// initialization
|
||||||
|
initVChartSemiTheme({
|
||||||
|
isWatchingThemeSwitch: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const root = ReactDOM.createRoot(document.getElementById('root'));
|
||||||
|
const {Sider, Content, Header} = Layout;
|
||||||
|
root.render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<StatusProvider>
|
||||||
|
<UserProvider>
|
||||||
|
<BrowserRouter>
|
||||||
|
<Layout>
|
||||||
|
<Sider>
|
||||||
|
<SiderBar/>
|
||||||
|
</Sider>
|
||||||
|
<Layout>
|
||||||
|
<Header>
|
||||||
|
<HeaderBar/>
|
||||||
|
</Header>
|
||||||
|
<Content
|
||||||
|
style={{
|
||||||
|
padding: '24px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<App/>
|
||||||
|
</Content>
|
||||||
|
<Layout.Footer>
|
||||||
|
<Footer></Footer>
|
||||||
|
</Layout.Footer>
|
||||||
|
</Layout>
|
||||||
|
<ToastContainer/>
|
||||||
|
</Layout>
|
||||||
|
</BrowserRouter>
|
||||||
|
</UserProvider>
|
||||||
|
</StatusProvider>
|
||||||
|
</React.StrictMode>
|
||||||
|
);
|
58
web/air/src/pages/About/index.js
Normal file
58
web/air/src/pages/About/index.js
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { Header, Segment } from 'semantic-ui-react';
|
||||||
|
import { API, showError } from '../../helpers';
|
||||||
|
import { marked } from 'marked';
|
||||||
|
|
||||||
|
const About = () => {
|
||||||
|
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('加载关于内容失败...');
|
||||||
|
}
|
||||||
|
setAboutLoaded(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
displayAbout().then();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{
|
||||||
|
aboutLoaded && about === '' ? <>
|
||||||
|
<Segment>
|
||||||
|
<Header as='h3'>关于</Header>
|
||||||
|
<p>可在设置页面设置关于内容,支持 HTML & Markdown</p>
|
||||||
|
项目仓库地址:
|
||||||
|
<a href='https://github.com/songquanpeng/one-api'>
|
||||||
|
https://github.com/songquanpeng/one-api
|
||||||
|
</a>
|
||||||
|
</Segment>
|
||||||
|
</> : <>
|
||||||
|
{
|
||||||
|
about.startsWith('https://') ? <iframe
|
||||||
|
src={about}
|
||||||
|
style={{ width: '100%', height: '100vh', border: 'none' }}
|
||||||
|
/> : <div style={{ fontSize: 'larger' }} dangerouslySetInnerHTML={{ __html: about }}></div>
|
||||||
|
}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
export default About;
|
628
web/air/src/pages/Channel/EditChannel.js
Normal file
628
web/air/src/pages/Channel/EditChannel.js
Normal file
@ -0,0 +1,628 @@
|
|||||||
|
import React, {useEffect, useRef, useState} from 'react';
|
||||||
|
import {useNavigate, useParams} from 'react-router-dom';
|
||||||
|
import {API, isMobile, showError, showInfo, showSuccess, verifyJSON} from '../../helpers';
|
||||||
|
import {CHANNEL_OPTIONS} from '../../constants';
|
||||||
|
import Title from "@douyinfe/semi-ui/lib/es/typography/title";
|
||||||
|
import {SideSheet, Space, Spin, Button, Input, Typography, Select, TextArea, Checkbox, Banner} from "@douyinfe/semi-ui";
|
||||||
|
|
||||||
|
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) {
|
||||||
|
// inputs.type === 15 ? '按照如下格式输入:APIKey|SecretKey' : (inputs.type === 18 ? '按照如下格式输入:APPID|APISecret|APIKey' : '请输入渠道对应的鉴权密钥')
|
||||||
|
switch (type) {
|
||||||
|
case 15:
|
||||||
|
return '按照如下格式输入:APIKey|SecretKey';
|
||||||
|
case 18:
|
||||||
|
return '按照如下格式输入:APPID|APISecret|APIKey';
|
||||||
|
case 22:
|
||||||
|
return '按照如下格式输入:APIKey-AppId,例如:fastgpt-0sp2gtvfdgyi4k30jwlgwf1i-64f335d84283f05518e9e041';
|
||||||
|
case 23:
|
||||||
|
return '按照如下格式输入:AppId|SecretId|SecretKey';
|
||||||
|
default:
|
||||||
|
return '请输入渠道对应的鉴权密钥';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const EditChannel = (props) => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const channelId = props.editingChannel.id;
|
||||||
|
const isEdit = channelId !== undefined;
|
||||||
|
const [loading, setLoading] = useState(isEdit);
|
||||||
|
const handleCancel = () => {
|
||||||
|
props.handleClose()
|
||||||
|
};
|
||||||
|
const originInputs = {
|
||||||
|
name: '',
|
||||||
|
type: 1,
|
||||||
|
key: '',
|
||||||
|
openai_organization: '',
|
||||||
|
base_url: '',
|
||||||
|
other: '',
|
||||||
|
model_mapping: '',
|
||||||
|
models: [],
|
||||||
|
auto_ban: 1,
|
||||||
|
groups: ['default']
|
||||||
|
};
|
||||||
|
const [batch, setBatch] = useState(false);
|
||||||
|
const [autoBan, setAutoBan] = useState(true);
|
||||||
|
// const [autoBan, setAutoBan] = useState(true);
|
||||||
|
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 handleInputChange = (name, value) => {
|
||||||
|
setInputs((inputs) => ({...inputs, [name]: value}));
|
||||||
|
if (name === 'type' && inputs.models.length === 0) {
|
||||||
|
let localModels = [];
|
||||||
|
switch (value) {
|
||||||
|
case 14:
|
||||||
|
localModels = ["claude-instant-1.2", "claude-2", "claude-2.0", "claude-2.1", "claude-3-opus-20240229", "claude-3-sonnet-20240229", "claude-3-haiku-20240307"];
|
||||||
|
break;
|
||||||
|
case 11:
|
||||||
|
localModels = ['PaLM-2'];
|
||||||
|
break;
|
||||||
|
case 15:
|
||||||
|
localModels = ['ERNIE-Bot', 'ERNIE-Bot-turbo', 'ERNIE-Bot-4', 'Embedding-V1'];
|
||||||
|
break;
|
||||||
|
case 17:
|
||||||
|
localModels = ["qwen-turbo", "qwen-plus", "qwen-max", "qwen-max-longcontext", 'text-embedding-v1'];
|
||||||
|
break;
|
||||||
|
case 16:
|
||||||
|
localModels = ['chatglm_pro', 'chatglm_std', 'chatglm_lite'];
|
||||||
|
break;
|
||||||
|
case 18:
|
||||||
|
localModels = ['SparkDesk', 'SparkDesk-v1.1', 'SparkDesk-v2.1', 'SparkDesk-v3.1', 'SparkDesk-v3.5'];
|
||||||
|
break;
|
||||||
|
case 19:
|
||||||
|
localModels = ['360GPT_S2_V9', 'embedding-bert-512-v1', 'embedding_s1_v1', 'semantic_similarity_s1_v1'];
|
||||||
|
break;
|
||||||
|
case 23:
|
||||||
|
localModels = ['hunyuan'];
|
||||||
|
break;
|
||||||
|
case 24:
|
||||||
|
localModels = ['gemini-pro', 'gemini-pro-vision'];
|
||||||
|
break;
|
||||||
|
case 25:
|
||||||
|
localModels = ['moonshot-v1-8k', 'moonshot-v1-32k', 'moonshot-v1-128k'];
|
||||||
|
break;
|
||||||
|
case 26:
|
||||||
|
localModels = ['glm-4', 'glm-4v', 'glm-3-turbo'];
|
||||||
|
break;
|
||||||
|
case 2:
|
||||||
|
localModels = ['mj_imagine', 'mj_variation', 'mj_reroll', 'mj_blend', 'mj_upscale', 'mj_describe'];
|
||||||
|
break;
|
||||||
|
case 5:
|
||||||
|
localModels = [
|
||||||
|
'swap_face',
|
||||||
|
'mj_imagine',
|
||||||
|
'mj_variation',
|
||||||
|
'mj_reroll',
|
||||||
|
'mj_blend',
|
||||||
|
'mj_upscale',
|
||||||
|
'mj_describe',
|
||||||
|
'mj_zoom',
|
||||||
|
'mj_shorten',
|
||||||
|
'mj_modal',
|
||||||
|
'mj_inpaint',
|
||||||
|
'mj_custom_zoom',
|
||||||
|
'mj_high_variation',
|
||||||
|
'mj_low_variation',
|
||||||
|
'mj_pan',
|
||||||
|
];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
setInputs((inputs) => ({...inputs, models: localModels}));
|
||||||
|
}
|
||||||
|
//setAutoBan
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
const loadChannel = async () => {
|
||||||
|
setLoading(true)
|
||||||
|
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.auto_ban === 0) {
|
||||||
|
setAutoBan(false);
|
||||||
|
} else {
|
||||||
|
setAutoBan(true);
|
||||||
|
}
|
||||||
|
// console.log(data);
|
||||||
|
} else {
|
||||||
|
showError(message);
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchModels = async () => {
|
||||||
|
try {
|
||||||
|
let res = await API.get(`/api/channel/models`);
|
||||||
|
let localModelOptions = res.data.data.map((model) => ({
|
||||||
|
label: model.id,
|
||||||
|
value: model.id
|
||||||
|
}));
|
||||||
|
setOriginModelOptions(localModelOptions);
|
||||||
|
setFullModels(res.data.data.map((model) => model.id));
|
||||||
|
setBasicModels(res.data.data.filter((model) => {
|
||||||
|
return model.id.startsWith('gpt-3') || model.id.startsWith('text-');
|
||||||
|
}).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) => ({
|
||||||
|
label: 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({
|
||||||
|
label: model,
|
||||||
|
value: model
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
setModelOptions(localModelOptions);
|
||||||
|
}, [originModelOptions, inputs.models]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchModels().then();
|
||||||
|
fetchGroups().then();
|
||||||
|
if (isEdit) {
|
||||||
|
loadChannel().then(
|
||||||
|
() => {
|
||||||
|
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
setInputs(originInputs)
|
||||||
|
}
|
||||||
|
}, [props.editingChannel.id]);
|
||||||
|
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
if (!isEdit && (inputs.name === '' || inputs.key === '')) {
|
||||||
|
showInfo('请填写渠道名称和渠道密钥!');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (inputs.models.length === 0) {
|
||||||
|
showInfo('请至少选择一个模型!');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (inputs.model_mapping !== '' && !verifyJSON(inputs.model_mapping)) {
|
||||||
|
showInfo('模型映射必须是合法的 JSON 格式!');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let localInputs = {...inputs};
|
||||||
|
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 = '2023-06-01-preview';
|
||||||
|
}
|
||||||
|
if (localInputs.type === 18 && localInputs.other === '') {
|
||||||
|
localInputs.other = 'v2.1';
|
||||||
|
}
|
||||||
|
let res;
|
||||||
|
if (!Array.isArray(localInputs.models)) {
|
||||||
|
showError('提交失败,请勿重复提交!');
|
||||||
|
handleCancel();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
localInputs.auto_ban = autoBan ? 1 : 0;
|
||||||
|
localInputs.models = localInputs.models.join(',');
|
||||||
|
localInputs.group = localInputs.groups.join(',');
|
||||||
|
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('渠道更新成功!');
|
||||||
|
} else {
|
||||||
|
showSuccess('渠道创建成功!');
|
||||||
|
setInputs(originInputs);
|
||||||
|
}
|
||||||
|
props.refresh();
|
||||||
|
props.handleClose();
|
||||||
|
} else {
|
||||||
|
showError(message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const addCustomModel = () => {
|
||||||
|
if (customModel.trim() === '') return;
|
||||||
|
if (inputs.models.includes(customModel)) return showError("该模型已存在!");
|
||||||
|
let localModels = [...inputs.models];
|
||||||
|
localModels.push(customModel);
|
||||||
|
let localModelOptions = [];
|
||||||
|
localModelOptions.push({
|
||||||
|
key: customModel,
|
||||||
|
text: customModel,
|
||||||
|
value: customModel
|
||||||
|
});
|
||||||
|
setModelOptions(modelOptions => {
|
||||||
|
return [...modelOptions, ...localModelOptions];
|
||||||
|
});
|
||||||
|
setCustomModel('');
|
||||||
|
handleInputChange('models', localModels);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SideSheet
|
||||||
|
maskClosable={false}
|
||||||
|
placement={isEdit ? 'right' : 'left'}
|
||||||
|
title={<Title level={3}>{isEdit ? '更新渠道信息' : '创建新的渠道'}</Title>}
|
||||||
|
headerStyle={{borderBottom: '1px solid var(--semi-color-border)'}}
|
||||||
|
bodyStyle={{borderBottom: '1px solid var(--semi-color-border)'}}
|
||||||
|
visible={props.visible}
|
||||||
|
footer={
|
||||||
|
<div style={{display: 'flex', justifyContent: 'flex-end'}}>
|
||||||
|
<Space>
|
||||||
|
<Button theme='solid' size={'large'} onClick={submit}>提交</Button>
|
||||||
|
<Button theme='solid' size={'large'} type={'tertiary'} onClick={handleCancel}>取消</Button>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
closeIcon={null}
|
||||||
|
onCancel={() => handleCancel()}
|
||||||
|
width={isMobile() ? '100%' : 600}
|
||||||
|
>
|
||||||
|
<Spin spinning={loading}>
|
||||||
|
<div style={{marginTop: 10}}>
|
||||||
|
<Typography.Text strong>类型:</Typography.Text>
|
||||||
|
</div>
|
||||||
|
<Select
|
||||||
|
name='type'
|
||||||
|
required
|
||||||
|
optionList={CHANNEL_OPTIONS}
|
||||||
|
value={inputs.type}
|
||||||
|
onChange={value => handleInputChange('type', value)}
|
||||||
|
style={{width: '50%'}}
|
||||||
|
/>
|
||||||
|
{
|
||||||
|
inputs.type === 3 && (
|
||||||
|
<>
|
||||||
|
<div style={{marginTop: 10}}>
|
||||||
|
<Banner type={"warning"} description={
|
||||||
|
<>
|
||||||
|
注意,<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>。
|
||||||
|
</>
|
||||||
|
}>
|
||||||
|
</Banner>
|
||||||
|
</div>
|
||||||
|
<div style={{marginTop: 10}}>
|
||||||
|
<Typography.Text strong>AZURE_OPENAI_ENDPOINT:</Typography.Text>
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
label='AZURE_OPENAI_ENDPOINT'
|
||||||
|
name='azure_base_url'
|
||||||
|
placeholder={'请输入 AZURE_OPENAI_ENDPOINT,例如:https://docs-test-001.openai.azure.com'}
|
||||||
|
onChange={value => {
|
||||||
|
handleInputChange('base_url', value)
|
||||||
|
}}
|
||||||
|
value={inputs.base_url}
|
||||||
|
autoComplete='new-password'
|
||||||
|
/>
|
||||||
|
<div style={{marginTop: 10}}>
|
||||||
|
<Typography.Text strong>默认 API 版本:</Typography.Text>
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
label='默认 API 版本'
|
||||||
|
name='azure_other'
|
||||||
|
placeholder={'请输入默认 API 版本,例如:2023-06-01-preview,该配置可以被实际的请求查询参数所覆盖'}
|
||||||
|
onChange={value => {
|
||||||
|
handleInputChange('other', value)
|
||||||
|
}}
|
||||||
|
value={inputs.other}
|
||||||
|
autoComplete='new-password'
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
{
|
||||||
|
inputs.type === 8 && (
|
||||||
|
<>
|
||||||
|
<div style={{marginTop: 10}}>
|
||||||
|
<Typography.Text strong>Base URL:</Typography.Text>
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
name='base_url'
|
||||||
|
placeholder={'请输入自定义渠道的 Base URL'}
|
||||||
|
onChange={value => {
|
||||||
|
handleInputChange('base_url', value)
|
||||||
|
}}
|
||||||
|
value={inputs.base_url}
|
||||||
|
autoComplete='new-password'
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
<div style={{marginTop: 10}}>
|
||||||
|
<Typography.Text strong>名称:</Typography.Text>
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
required
|
||||||
|
name='name'
|
||||||
|
placeholder={'请为渠道命名'}
|
||||||
|
onChange={value => {
|
||||||
|
handleInputChange('name', value)
|
||||||
|
}}
|
||||||
|
value={inputs.name}
|
||||||
|
autoComplete='new-password'
|
||||||
|
/>
|
||||||
|
<div style={{marginTop: 10}}>
|
||||||
|
<Typography.Text strong>分组:</Typography.Text>
|
||||||
|
</div>
|
||||||
|
<Select
|
||||||
|
placeholder={'请选择可以使用该渠道的分组'}
|
||||||
|
name='groups'
|
||||||
|
required
|
||||||
|
multiple
|
||||||
|
selection
|
||||||
|
allowAdditions
|
||||||
|
additionLabel={'请在系统设置页面编辑分组倍率以添加新的分组:'}
|
||||||
|
onChange={value => {
|
||||||
|
handleInputChange('groups', value)
|
||||||
|
}}
|
||||||
|
value={inputs.groups}
|
||||||
|
autoComplete='new-password'
|
||||||
|
optionList={groupOptions}
|
||||||
|
/>
|
||||||
|
{
|
||||||
|
inputs.type === 18 && (
|
||||||
|
<>
|
||||||
|
<div style={{marginTop: 10}}>
|
||||||
|
<Typography.Text strong>模型版本:</Typography.Text>
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
name='other'
|
||||||
|
placeholder={'请输入星火大模型版本,注意是接口地址中的版本号,例如:v2.1'}
|
||||||
|
onChange={value => {
|
||||||
|
handleInputChange('other', value)
|
||||||
|
}}
|
||||||
|
value={inputs.other}
|
||||||
|
autoComplete='new-password'
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
{
|
||||||
|
inputs.type === 21 && (
|
||||||
|
<>
|
||||||
|
<div style={{marginTop: 10}}>
|
||||||
|
<Typography.Text strong>知识库 ID:</Typography.Text>
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
label='知识库 ID'
|
||||||
|
name='other'
|
||||||
|
placeholder={'请输入知识库 ID,例如:123456'}
|
||||||
|
onChange={value => {
|
||||||
|
handleInputChange('other', value)
|
||||||
|
}}
|
||||||
|
value={inputs.other}
|
||||||
|
autoComplete='new-password'
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
<div style={{marginTop: 10}}>
|
||||||
|
<Typography.Text strong>模型:</Typography.Text>
|
||||||
|
</div>
|
||||||
|
<Select
|
||||||
|
placeholder={'请选择该渠道所支持的模型'}
|
||||||
|
name='models'
|
||||||
|
required
|
||||||
|
multiple
|
||||||
|
selection
|
||||||
|
onChange={value => {
|
||||||
|
handleInputChange('models', value)
|
||||||
|
}}
|
||||||
|
value={inputs.models}
|
||||||
|
autoComplete='new-password'
|
||||||
|
optionList={modelOptions}
|
||||||
|
/>
|
||||||
|
<div style={{lineHeight: '40px', marginBottom: '12px'}}>
|
||||||
|
<Space>
|
||||||
|
<Button type='primary' onClick={() => {
|
||||||
|
handleInputChange('models', basicModels);
|
||||||
|
}}>填入基础模型</Button>
|
||||||
|
<Button type='secondary' onClick={() => {
|
||||||
|
handleInputChange('models', fullModels);
|
||||||
|
}}>填入所有模型</Button>
|
||||||
|
<Button type='warning' onClick={() => {
|
||||||
|
handleInputChange('models', []);
|
||||||
|
}}>清除所有模型</Button>
|
||||||
|
</Space>
|
||||||
|
<Input
|
||||||
|
addonAfter={
|
||||||
|
<Button type='primary' onClick={addCustomModel}>填入</Button>
|
||||||
|
}
|
||||||
|
placeholder='输入自定义模型名称'
|
||||||
|
value={customModel}
|
||||||
|
onChange={(value) => {
|
||||||
|
setCustomModel(value.trim());
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{marginTop: 10}}>
|
||||||
|
<Typography.Text strong>模型重定向:</Typography.Text>
|
||||||
|
</div>
|
||||||
|
<TextArea
|
||||||
|
placeholder={`此项可选,用于修改请求体中的模型名称,为一个 JSON 字符串,键为请求中模型名称,值为要替换的模型名称,例如:\n${JSON.stringify(MODEL_MAPPING_EXAMPLE, null, 2)}`}
|
||||||
|
name='model_mapping'
|
||||||
|
onChange={value => {
|
||||||
|
handleInputChange('model_mapping', value)
|
||||||
|
}}
|
||||||
|
autosize
|
||||||
|
value={inputs.model_mapping}
|
||||||
|
autoComplete='new-password'
|
||||||
|
/>
|
||||||
|
<Typography.Text style={{
|
||||||
|
color: 'rgba(var(--semi-blue-5), 1)',
|
||||||
|
userSelect: 'none',
|
||||||
|
cursor: 'pointer'
|
||||||
|
}} onClick={
|
||||||
|
() => {
|
||||||
|
handleInputChange('model_mapping', JSON.stringify(MODEL_MAPPING_EXAMPLE, null, 2))
|
||||||
|
}
|
||||||
|
}>
|
||||||
|
填入模板
|
||||||
|
</Typography.Text>
|
||||||
|
<div style={{marginTop: 10}}>
|
||||||
|
<Typography.Text strong>密钥:</Typography.Text>
|
||||||
|
</div>
|
||||||
|
{
|
||||||
|
batch ?
|
||||||
|
<TextArea
|
||||||
|
label='密钥'
|
||||||
|
name='key'
|
||||||
|
required
|
||||||
|
placeholder={'请输入密钥,一行一个'}
|
||||||
|
onChange={value => {
|
||||||
|
handleInputChange('key', value)
|
||||||
|
}}
|
||||||
|
value={inputs.key}
|
||||||
|
style={{minHeight: 150, fontFamily: 'JetBrains Mono, Consolas'}}
|
||||||
|
autoComplete='new-password'
|
||||||
|
/>
|
||||||
|
:
|
||||||
|
<Input
|
||||||
|
label='密钥'
|
||||||
|
name='key'
|
||||||
|
required
|
||||||
|
placeholder={type2secretPrompt(inputs.type)}
|
||||||
|
onChange={value => {
|
||||||
|
handleInputChange('key', value)
|
||||||
|
}}
|
||||||
|
value={inputs.key}
|
||||||
|
autoComplete='new-password'
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
<div style={{marginTop: 10}}>
|
||||||
|
<Typography.Text strong>组织:</Typography.Text>
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
label='组织,可选,不填则为默认组织'
|
||||||
|
name='openai_organization'
|
||||||
|
placeholder='请输入组织org-xxx'
|
||||||
|
onChange={value => {
|
||||||
|
handleInputChange('openai_organization', value)
|
||||||
|
}}
|
||||||
|
value={inputs.openai_organization}
|
||||||
|
/>
|
||||||
|
<div style={{marginTop: 10, display: 'flex'}}>
|
||||||
|
<Space>
|
||||||
|
<Checkbox
|
||||||
|
name='auto_ban'
|
||||||
|
checked={autoBan}
|
||||||
|
onChange={
|
||||||
|
() => {
|
||||||
|
setAutoBan(!autoBan);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// onChange={handleInputChange}
|
||||||
|
/>
|
||||||
|
<Typography.Text
|
||||||
|
strong>是否自动禁用(仅当自动禁用开启时有效),关闭后不会自动禁用该渠道:</Typography.Text>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{
|
||||||
|
!isEdit && (
|
||||||
|
<div style={{marginTop: 10, display: 'flex'}}>
|
||||||
|
<Space>
|
||||||
|
<Checkbox
|
||||||
|
checked={batch}
|
||||||
|
label='批量创建'
|
||||||
|
name='batch'
|
||||||
|
onChange={() => setBatch(!batch)}
|
||||||
|
/>
|
||||||
|
<Typography.Text strong>批量创建</Typography.Text>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
{
|
||||||
|
inputs.type !== 3 && inputs.type !== 8 && inputs.type !== 22 && (
|
||||||
|
<>
|
||||||
|
<div style={{marginTop: 10}}>
|
||||||
|
<Typography.Text strong>代理:</Typography.Text>
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
label='代理'
|
||||||
|
name='base_url'
|
||||||
|
placeholder={'此项可选,用于通过代理站来进行 API 调用'}
|
||||||
|
onChange={value => {
|
||||||
|
handleInputChange('base_url', value)
|
||||||
|
}}
|
||||||
|
value={inputs.base_url}
|
||||||
|
autoComplete='new-password'
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
{
|
||||||
|
inputs.type === 22 && (
|
||||||
|
<>
|
||||||
|
<div style={{marginTop: 10}}>
|
||||||
|
<Typography.Text strong>私有部署地址:</Typography.Text>
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
name='base_url'
|
||||||
|
placeholder={'请输入私有部署地址,格式为:https://fastgpt.run/api/openapi'}
|
||||||
|
onChange={value => {
|
||||||
|
handleInputChange('base_url', value)
|
||||||
|
}}
|
||||||
|
value={inputs.base_url}
|
||||||
|
autoComplete='new-password'
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
</Spin>
|
||||||
|
</SideSheet>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default EditChannel;
|
18
web/air/src/pages/Channel/index.js
Normal file
18
web/air/src/pages/Channel/index.js
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import ChannelsTable from '../../components/ChannelsTable';
|
||||||
|
import {Layout} from "@douyinfe/semi-ui";
|
||||||
|
|
||||||
|
const File = () => (
|
||||||
|
<>
|
||||||
|
<Layout>
|
||||||
|
<Layout.Header>
|
||||||
|
<h3>管理渠道</h3>
|
||||||
|
</Layout.Header>
|
||||||
|
<Layout.Content>
|
||||||
|
<ChannelsTable/>
|
||||||
|
</Layout.Content>
|
||||||
|
</Layout>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default File;
|
15
web/air/src/pages/Chat/index.js
Normal file
15
web/air/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;
|
359
web/air/src/pages/Detail/index.js
Normal file
359
web/air/src/pages/Detail/index.js
Normal file
@ -0,0 +1,359 @@
|
|||||||
|
import React, {useEffect, useRef, useState} from 'react';
|
||||||
|
import {Button, Col, Form, Layout, Row, Spin} from "@douyinfe/semi-ui";
|
||||||
|
import VChart from '@visactor/vchart';
|
||||||
|
import {API, isAdmin, showError, timestamp2string, timestamp2string1} from "../../helpers";
|
||||||
|
import {
|
||||||
|
getQuotaWithUnit, modelColorMap,
|
||||||
|
renderNumber,
|
||||||
|
renderQuota,
|
||||||
|
renderQuotaNumberWithDigit,
|
||||||
|
stringToColor
|
||||||
|
} from "../../helpers/render";
|
||||||
|
|
||||||
|
const Detail = (props) => {
|
||||||
|
const formRef = useRef();
|
||||||
|
let now = new Date();
|
||||||
|
const [inputs, setInputs] = useState({
|
||||||
|
username: '',
|
||||||
|
token_name: '',
|
||||||
|
model_name: '',
|
||||||
|
start_timestamp: localStorage.getItem('data_export_default_time') === 'hour' ? timestamp2string(now.getTime() / 1000 - 86400) : (localStorage.getItem('data_export_default_time') === 'week' ? timestamp2string(now.getTime() / 1000 - 86400 * 30) : timestamp2string(now.getTime() / 1000 - 86400 * 7)),
|
||||||
|
end_timestamp: timestamp2string(now.getTime() / 1000 + 3600),
|
||||||
|
channel: '',
|
||||||
|
data_export_default_time: ''
|
||||||
|
});
|
||||||
|
const {username, model_name, start_timestamp, end_timestamp, channel} = inputs;
|
||||||
|
const isAdminUser = isAdmin();
|
||||||
|
const initialized = useRef(false)
|
||||||
|
const [modelDataChart, setModelDataChart] = useState(null);
|
||||||
|
const [modelDataPieChart, setModelDataPieChart] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [quotaData, setQuotaData] = useState([]);
|
||||||
|
const [consumeQuota, setConsumeQuota] = useState(0);
|
||||||
|
const [times, setTimes] = useState(0);
|
||||||
|
const [dataExportDefaultTime, setDataExportDefaultTime] = useState(localStorage.getItem('data_export_default_time') || 'hour');
|
||||||
|
|
||||||
|
const handleInputChange = (value, name) => {
|
||||||
|
if (name === 'data_export_default_time') {
|
||||||
|
setDataExportDefaultTime(value);
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setInputs((inputs) => ({...inputs, [name]: value}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const spec_line = {
|
||||||
|
type: 'bar',
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
id: 'barData',
|
||||||
|
values: []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
xField: 'Time',
|
||||||
|
yField: 'Usage',
|
||||||
|
seriesField: 'Model',
|
||||||
|
stack: true,
|
||||||
|
legends: {
|
||||||
|
visible: true
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
visible: true,
|
||||||
|
text: '模型消耗分布',
|
||||||
|
subtext: '0'
|
||||||
|
},
|
||||||
|
bar: {
|
||||||
|
// The state style of bar
|
||||||
|
state: {
|
||||||
|
hover: {
|
||||||
|
stroke: '#000',
|
||||||
|
lineWidth: 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
mark: {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
key: datum => datum['Model'],
|
||||||
|
value: datum => renderQuotaNumberWithDigit(parseFloat(datum['Usage']), 4)
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
dimension: {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
key: datum => datum['Model'],
|
||||||
|
value: datum => datum['Usage']
|
||||||
|
}
|
||||||
|
],
|
||||||
|
updateContent: array => {
|
||||||
|
// sort by value
|
||||||
|
array.sort((a, b) => b.value - a.value);
|
||||||
|
// add $
|
||||||
|
let sum = 0;
|
||||||
|
for (let i = 0; i < array.length; i++) {
|
||||||
|
sum += parseFloat(array[i].value);
|
||||||
|
array[i].value = renderQuotaNumberWithDigit(parseFloat(array[i].value), 4);
|
||||||
|
}
|
||||||
|
// add to first
|
||||||
|
array.unshift({
|
||||||
|
key: '总计',
|
||||||
|
value: renderQuotaNumberWithDigit(sum, 4)
|
||||||
|
});
|
||||||
|
return array;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
color: {
|
||||||
|
specified: modelColorMap
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const spec_pie = {
|
||||||
|
type: 'pie',
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
id: 'id0',
|
||||||
|
values: [
|
||||||
|
{type: 'null', value: '0'},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
outerRadius: 0.8,
|
||||||
|
innerRadius: 0.5,
|
||||||
|
padAngle: 0.6,
|
||||||
|
valueField: 'value',
|
||||||
|
categoryField: 'type',
|
||||||
|
pie: {
|
||||||
|
style: {
|
||||||
|
cornerRadius: 10
|
||||||
|
},
|
||||||
|
state: {
|
||||||
|
hover: {
|
||||||
|
outerRadius: 0.85,
|
||||||
|
stroke: '#000',
|
||||||
|
lineWidth: 1
|
||||||
|
},
|
||||||
|
selected: {
|
||||||
|
outerRadius: 0.85,
|
||||||
|
stroke: '#000',
|
||||||
|
lineWidth: 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
visible: true,
|
||||||
|
text: '模型调用次数占比'
|
||||||
|
},
|
||||||
|
legends: {
|
||||||
|
visible: true,
|
||||||
|
orient: 'left'
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
visible: true
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
mark: {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
key: datum => datum['type'],
|
||||||
|
value: datum => renderNumber(datum['value'])
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
color: {
|
||||||
|
specified: modelColorMap
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadQuotaData = async (lineChart, pieChart) => {
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
let url = '';
|
||||||
|
let localStartTimestamp = Date.parse(start_timestamp) / 1000;
|
||||||
|
let localEndTimestamp = Date.parse(end_timestamp) / 1000;
|
||||||
|
if (isAdminUser) {
|
||||||
|
url = `/api/data/?username=${username}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}&default_time=${dataExportDefaultTime}`;
|
||||||
|
} else {
|
||||||
|
url = `/api/data/self/?start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}&default_time=${dataExportDefaultTime}`;
|
||||||
|
}
|
||||||
|
const res = await API.get(url);
|
||||||
|
const {success, message, data} = res.data;
|
||||||
|
if (success) {
|
||||||
|
setQuotaData(data);
|
||||||
|
if (data.length === 0) {
|
||||||
|
data.push({
|
||||||
|
'count': 0,
|
||||||
|
'model_name': '无数据',
|
||||||
|
'quota': 0,
|
||||||
|
'created_at': now.getTime() / 1000
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// 根据dataExportDefaultTime重制时间粒度
|
||||||
|
let timeGranularity = 3600;
|
||||||
|
if (dataExportDefaultTime === 'day') {
|
||||||
|
timeGranularity = 86400;
|
||||||
|
} else if (dataExportDefaultTime === 'week') {
|
||||||
|
timeGranularity = 604800;
|
||||||
|
}
|
||||||
|
data.forEach(item => {
|
||||||
|
item['created_at'] = Math.floor(item['created_at'] / timeGranularity) * timeGranularity;
|
||||||
|
});
|
||||||
|
updateChart(lineChart, pieChart, data);
|
||||||
|
} else {
|
||||||
|
showError(message);
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const refresh = async () => {
|
||||||
|
await loadQuotaData(modelDataChart, modelDataPieChart);
|
||||||
|
};
|
||||||
|
|
||||||
|
const initChart = async () => {
|
||||||
|
let lineChart = modelDataChart
|
||||||
|
if (!modelDataChart) {
|
||||||
|
lineChart = new VChart(spec_line, {dom: 'model_data'});
|
||||||
|
setModelDataChart(lineChart);
|
||||||
|
lineChart.renderAsync();
|
||||||
|
}
|
||||||
|
let pieChart = modelDataPieChart
|
||||||
|
if (!modelDataPieChart) {
|
||||||
|
pieChart = new VChart(spec_pie, {dom: 'model_pie'});
|
||||||
|
setModelDataPieChart(pieChart);
|
||||||
|
pieChart.renderAsync();
|
||||||
|
}
|
||||||
|
console.log('init vchart');
|
||||||
|
await loadQuotaData(lineChart, pieChart)
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateChart = (lineChart, pieChart, data) => {
|
||||||
|
if (isAdminUser) {
|
||||||
|
// 将所有用户合并
|
||||||
|
}
|
||||||
|
let pieData = [];
|
||||||
|
let lineData = [];
|
||||||
|
let consumeQuota = 0;
|
||||||
|
let times = 0;
|
||||||
|
for (let i = 0; i < data.length; i++) {
|
||||||
|
const item = data[i];
|
||||||
|
consumeQuota += item.quota;
|
||||||
|
times += item.count;
|
||||||
|
// 合并model_name
|
||||||
|
let pieItem = pieData.find(it => it.type === item.model_name);
|
||||||
|
if (pieItem) {
|
||||||
|
pieItem.value += item.count;
|
||||||
|
} else {
|
||||||
|
pieData.push({
|
||||||
|
"type": item.model_name,
|
||||||
|
"value": item.count
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// 合并created_at和model_name 为 lineData, created_at 数据类型是小时的时间戳
|
||||||
|
// 转换日期格式
|
||||||
|
let createTime = timestamp2string1(item.created_at, dataExportDefaultTime);
|
||||||
|
let lineItem = lineData.find(it => it.Time === createTime && it.Model === item.model_name);
|
||||||
|
if (lineItem) {
|
||||||
|
lineItem.Usage += parseFloat(getQuotaWithUnit(item.quota));
|
||||||
|
} else {
|
||||||
|
lineData.push({
|
||||||
|
"Time": createTime,
|
||||||
|
"Model": item.model_name,
|
||||||
|
"Usage": parseFloat(getQuotaWithUnit(item.quota))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setConsumeQuota(consumeQuota);
|
||||||
|
setTimes(times);
|
||||||
|
|
||||||
|
// sort by count
|
||||||
|
pieData.sort((a, b) => b.value - a.value);
|
||||||
|
spec_pie.title.subtext = `总计:${renderNumber(times)}`;
|
||||||
|
spec_pie.data[0].values = pieData;
|
||||||
|
|
||||||
|
spec_line.title.subtext = `总计:${renderQuota(consumeQuota, 2)}`;
|
||||||
|
spec_line.data[0].values = lineData;
|
||||||
|
pieChart.updateSpec(spec_pie);
|
||||||
|
lineChart.updateSpec(spec_line);
|
||||||
|
|
||||||
|
// pieChart.updateData('id0', pieData);
|
||||||
|
// lineChart.updateData('barData', lineData);
|
||||||
|
pieChart.reLayout();
|
||||||
|
lineChart.reLayout();
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// setDataExportDefaultTime(localStorage.getItem('data_export_default_time'));
|
||||||
|
// if (dataExportDefaultTime === 'day') {
|
||||||
|
// // 设置开始时间为7天前
|
||||||
|
// let st = timestamp2string(now.getTime() / 1000 - 86400 * 7)
|
||||||
|
// inputs.start_timestamp = st;
|
||||||
|
// formRef.current.formApi.setValue('start_timestamp', st);
|
||||||
|
// }
|
||||||
|
if (!initialized.current) {
|
||||||
|
initialized.current = true;
|
||||||
|
initChart();
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Layout>
|
||||||
|
<Layout.Header>
|
||||||
|
<h3>数据看板</h3>
|
||||||
|
</Layout.Header>
|
||||||
|
<Layout.Content>
|
||||||
|
<Form ref={formRef} layout='horizontal' style={{marginTop: 10}}>
|
||||||
|
<>
|
||||||
|
<Form.DatePicker field="start_timestamp" label='起始时间' style={{width: 272}}
|
||||||
|
initValue={start_timestamp}
|
||||||
|
value={start_timestamp} type='dateTime'
|
||||||
|
name='start_timestamp'
|
||||||
|
onChange={value => handleInputChange(value, 'start_timestamp')}/>
|
||||||
|
<Form.DatePicker field="end_timestamp" fluid label='结束时间' style={{width: 272}}
|
||||||
|
initValue={end_timestamp}
|
||||||
|
value={end_timestamp} type='dateTime'
|
||||||
|
name='end_timestamp'
|
||||||
|
onChange={value => handleInputChange(value, 'end_timestamp')}/>
|
||||||
|
<Form.Select field="data_export_default_time" label='时间粒度' style={{width: 176}}
|
||||||
|
initValue={dataExportDefaultTime}
|
||||||
|
placeholder={'时间粒度'} name='data_export_default_time'
|
||||||
|
optionList={
|
||||||
|
[
|
||||||
|
{label: '小时', value: 'hour'},
|
||||||
|
{label: '天', value: 'day'},
|
||||||
|
{label: '周', value: 'week'}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
onChange={value => handleInputChange(value, 'data_export_default_time')}>
|
||||||
|
</Form.Select>
|
||||||
|
{
|
||||||
|
isAdminUser && <>
|
||||||
|
<Form.Input field="username" label='用户名称' style={{width: 176}} value={username}
|
||||||
|
placeholder={'可选值'} name='username'
|
||||||
|
onChange={value => handleInputChange(value, 'username')}/>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
<Form.Section>
|
||||||
|
<Button label='查询' type="primary" htmlType="submit" className="btn-margin-right"
|
||||||
|
onClick={refresh} loading={loading}>查询</Button>
|
||||||
|
</Form.Section>
|
||||||
|
</>
|
||||||
|
</Form>
|
||||||
|
<Spin spinning={loading}>
|
||||||
|
<div style={{height: 500}}>
|
||||||
|
<div id="model_pie" style={{width: '100%', minWidth: 100}}></div>
|
||||||
|
</div>
|
||||||
|
<div style={{height: 500}}>
|
||||||
|
<div id="model_data" style={{width: '100%', minWidth: 100}}></div>
|
||||||
|
</div>
|
||||||
|
</Spin>
|
||||||
|
</Layout.Content>
|
||||||
|
</Layout>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
export default Detail;
|
130
web/air/src/pages/Home/index.js
Normal file
130
web/air/src/pages/Home/index.js
Normal file
@ -0,0 +1,130 @@
|
|||||||
|
import React, { useContext, useEffect, useState } from 'react';
|
||||||
|
import { Card, Col, Row } from '@douyinfe/semi-ui';
|
||||||
|
import { API, showError, showNotice, timestamp2string } from '../../helpers';
|
||||||
|
import { StatusContext } from '../../context/Status';
|
||||||
|
import { marked } from 'marked';
|
||||||
|
|
||||||
|
const Home = () => {
|
||||||
|
const [statusState] = useContext(StatusContext);
|
||||||
|
const [homePageContentLoaded, setHomePageContentLoaded] = useState(false);
|
||||||
|
const [homePageContent, setHomePageContent] = useState('');
|
||||||
|
|
||||||
|
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('加载首页内容失败...');
|
||||||
|
}
|
||||||
|
setHomePageContentLoaded(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStartTimeString = () => {
|
||||||
|
const timestamp = statusState?.status?.start_time;
|
||||||
|
return statusState.status ? timestamp2string(timestamp) : '';
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
displayNotice().then();
|
||||||
|
displayHomePageContent().then();
|
||||||
|
}, []);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{
|
||||||
|
homePageContentLoaded && homePageContent === '' ?
|
||||||
|
<>
|
||||||
|
<Card
|
||||||
|
bordered={false}
|
||||||
|
headerLine={false}
|
||||||
|
title='系统状况'
|
||||||
|
bodyStyle={{ padding: '10px 20px' }}
|
||||||
|
>
|
||||||
|
<Row gutter={16}>
|
||||||
|
<Col span={12}>
|
||||||
|
<Card
|
||||||
|
title='系统信息'
|
||||||
|
headerExtraContent={<span
|
||||||
|
style={{ fontSize: '12px', color: 'var(--semi-color-text-1)' }}>系统信息总览</span>}>
|
||||||
|
<p>名称:{statusState?.status?.system_name}</p>
|
||||||
|
<p>版本:{statusState?.status?.version ? statusState?.status?.version : 'unknown'}</p>
|
||||||
|
<p>
|
||||||
|
源码:
|
||||||
|
<a
|
||||||
|
href='https://github.com/songquanpeng/one-api'
|
||||||
|
target='_blank' rel='noreferrer'
|
||||||
|
>
|
||||||
|
https://github.com/songquanpeng/one-api
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
<p>启动时间:{getStartTimeString()}</p>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
<Col span={12}>
|
||||||
|
<Card
|
||||||
|
title='系统配置'
|
||||||
|
headerExtraContent={<span
|
||||||
|
style={{ fontSize: '12px', color: 'var(--semi-color-text-1)' }}>系统配置总览</span>}>
|
||||||
|
<p>
|
||||||
|
邮箱验证:
|
||||||
|
{statusState?.status?.email_verification === true ? '已启用' : '未启用'}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
GitHub 身份验证:
|
||||||
|
{statusState?.status?.github_oauth === true ? '已启用' : '未启用'}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
微信身份验证:
|
||||||
|
{statusState?.status?.wechat_login === true ? '已启用' : '未启用'}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Turnstile 用户校验:
|
||||||
|
{statusState?.status?.turnstile_check === true ? '已启用' : '未启用'}
|
||||||
|
</p>
|
||||||
|
{/*<p>*/}
|
||||||
|
{/* Telegram 身份验证:*/}
|
||||||
|
{/* {statusState?.status?.telegram_oauth === true*/}
|
||||||
|
{/* ? '已启用' : '未启用'}*/}
|
||||||
|
{/*</p>*/}
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
</>
|
||||||
|
: <>
|
||||||
|
{
|
||||||
|
homePageContent.startsWith('https://') ?
|
||||||
|
<iframe src={homePageContent} style={{ width: '100%', height: '100vh', border: 'none' }} /> :
|
||||||
|
<div style={{ fontSize: 'larger' }} dangerouslySetInnerHTML={{ __html: homePageContent }}></div>
|
||||||
|
}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Home;
|
10
web/air/src/pages/Log/index.js
Normal file
10
web/air/src/pages/Log/index.js
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import LogsTable from '../../components/LogsTable';
|
||||||
|
|
||||||
|
const Token = () => (
|
||||||
|
<>
|
||||||
|
<LogsTable />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default Token;
|
10
web/air/src/pages/Midjourney/index.js
Normal file
10
web/air/src/pages/Midjourney/index.js
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import MjLogsTable from '../../components/MjLogsTable';
|
||||||
|
|
||||||
|
const Midjourney = () => (
|
||||||
|
<>
|
||||||
|
<MjLogsTable />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default Midjourney;
|
13
web/air/src/pages/NotFound/index.js
Normal file
13
web/air/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;
|
181
web/air/src/pages/Redemption/EditRedemption.js
Normal file
181
web/air/src/pages/Redemption/EditRedemption.js
Normal file
@ -0,0 +1,181 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
|
import { API, downloadTextAsFile, isMobile, showError, showSuccess } from '../../helpers';
|
||||||
|
import { renderQuotaWithPrompt } from '../../helpers/render';
|
||||||
|
import { AutoComplete, Button, Input, Modal, SideSheet, Space, Spin, Typography } from '@douyinfe/semi-ui';
|
||||||
|
import Title from '@douyinfe/semi-ui/lib/es/typography/title';
|
||||||
|
import { Divider } from 'semantic-ui-react';
|
||||||
|
|
||||||
|
const EditRedemption = (props) => {
|
||||||
|
const isEdit = props.editingRedemption.id !== undefined;
|
||||||
|
const [loading, setLoading] = useState(isEdit);
|
||||||
|
|
||||||
|
const params = useParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const originInputs = {
|
||||||
|
name: '',
|
||||||
|
quota: 100000,
|
||||||
|
count: 1
|
||||||
|
};
|
||||||
|
const [inputs, setInputs] = useState(originInputs);
|
||||||
|
const { name, quota, count } = inputs;
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
props.handleClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInputChange = (name, value) => {
|
||||||
|
setInputs((inputs) => ({ ...inputs, [name]: value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadRedemption = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
let res = await API.get(`/api/redemption/${props.editingRedemption.id}`);
|
||||||
|
const { success, message, data } = res.data;
|
||||||
|
if (success) {
|
||||||
|
setInputs(data);
|
||||||
|
} else {
|
||||||
|
showError(message);
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isEdit) {
|
||||||
|
loadRedemption().then(
|
||||||
|
() => {
|
||||||
|
// console.log(inputs);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
setInputs(originInputs);
|
||||||
|
}
|
||||||
|
}, [props.editingRedemption.id]);
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
if (!isEdit && inputs.name === '') return;
|
||||||
|
setLoading(true);
|
||||||
|
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(props.editingRedemption.id) });
|
||||||
|
} else {
|
||||||
|
res = await API.post(`/api/redemption/`, {
|
||||||
|
...localInputs
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const { success, message, data } = res.data;
|
||||||
|
if (success) {
|
||||||
|
if (isEdit) {
|
||||||
|
showSuccess('兑换码更新成功!');
|
||||||
|
props.refresh();
|
||||||
|
props.handleClose();
|
||||||
|
} else {
|
||||||
|
showSuccess('兑换码创建成功!');
|
||||||
|
setInputs(originInputs);
|
||||||
|
props.refresh();
|
||||||
|
props.handleClose();
|
||||||
|
}
|
||||||
|
} 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`);
|
||||||
|
Modal.confirm({
|
||||||
|
title: '兑换码创建成功',
|
||||||
|
content: (
|
||||||
|
<div>
|
||||||
|
<p>兑换码创建成功,是否下载兑换码?</p>
|
||||||
|
<p>兑换码将以文本文件的形式下载,文件名为兑换码的名称。</p>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
onOk: () => {
|
||||||
|
downloadTextAsFile(text, `${inputs.name}.txt`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SideSheet
|
||||||
|
placement={isEdit ? 'right' : 'left'}
|
||||||
|
title={<Title level={3}>{isEdit ? '更新兑换码信息' : '创建新的兑换码'}</Title>}
|
||||||
|
headerStyle={{ borderBottom: '1px solid var(--semi-color-border)' }}
|
||||||
|
bodyStyle={{ borderBottom: '1px solid var(--semi-color-border)' }}
|
||||||
|
visible={props.visiable}
|
||||||
|
footer={
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||||
|
<Space>
|
||||||
|
<Button theme="solid" size={'large'} onClick={submit}>提交</Button>
|
||||||
|
<Button theme="solid" size={'large'} type={'tertiary'} onClick={handleCancel}>取消</Button>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
closeIcon={null}
|
||||||
|
onCancel={() => handleCancel()}
|
||||||
|
width={isMobile() ? '100%' : 600}
|
||||||
|
>
|
||||||
|
<Spin spinning={loading}>
|
||||||
|
<Input
|
||||||
|
style={{ marginTop: 20 }}
|
||||||
|
label="名称"
|
||||||
|
name="name"
|
||||||
|
placeholder={'请输入名称'}
|
||||||
|
onChange={value => handleInputChange('name', value)}
|
||||||
|
value={name}
|
||||||
|
autoComplete="new-password"
|
||||||
|
required={!isEdit}
|
||||||
|
/>
|
||||||
|
<Divider />
|
||||||
|
<div style={{ marginTop: 20 }}>
|
||||||
|
<Typography.Text>{`额度${renderQuotaWithPrompt(quota)}`}</Typography.Text>
|
||||||
|
</div>
|
||||||
|
<AutoComplete
|
||||||
|
style={{ marginTop: 8 }}
|
||||||
|
name="quota"
|
||||||
|
placeholder={'请输入额度'}
|
||||||
|
onChange={(value) => handleInputChange('quota', value)}
|
||||||
|
value={quota}
|
||||||
|
autoComplete="new-password"
|
||||||
|
type="number"
|
||||||
|
position={'bottom'}
|
||||||
|
data={[
|
||||||
|
{ value: 500000, label: '1$' },
|
||||||
|
{ value: 5000000, label: '10$' },
|
||||||
|
{ value: 25000000, label: '50$' },
|
||||||
|
{ value: 50000000, label: '100$' },
|
||||||
|
{ value: 250000000, label: '500$' },
|
||||||
|
{ value: 500000000, label: '1000$' }
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
{
|
||||||
|
!isEdit && <>
|
||||||
|
<Divider />
|
||||||
|
<Typography.Text>生成数量</Typography.Text>
|
||||||
|
<Input
|
||||||
|
style={{ marginTop: 8 }}
|
||||||
|
label="生成数量"
|
||||||
|
name="count"
|
||||||
|
placeholder={'请输入生成数量'}
|
||||||
|
onChange={value => handleInputChange('count', value)}
|
||||||
|
value={count}
|
||||||
|
autoComplete="new-password"
|
||||||
|
type="number"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
</Spin>
|
||||||
|
</SideSheet>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default EditRedemption;
|
18
web/air/src/pages/Redemption/index.js
Normal file
18
web/air/src/pages/Redemption/index.js
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import RedemptionsTable from '../../components/RedemptionsTable';
|
||||||
|
import {Layout} from "@douyinfe/semi-ui";
|
||||||
|
|
||||||
|
const Redemption = () => (
|
||||||
|
<>
|
||||||
|
<Layout>
|
||||||
|
<Layout.Header>
|
||||||
|
<h3>管理兑换码</h3>
|
||||||
|
</Layout.Header>
|
||||||
|
<Layout.Content>
|
||||||
|
<RedemptionsTable/>
|
||||||
|
</Layout.Content>
|
||||||
|
</Layout>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default Redemption;
|
53
web/air/src/pages/Setting/index.js
Normal file
53
web/air/src/pages/Setting/index.js
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
import React from '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';
|
||||||
|
import {Layout, TabPane, Tabs} from "@douyinfe/semi-ui";
|
||||||
|
|
||||||
|
const Setting = () => {
|
||||||
|
let panes = [
|
||||||
|
{
|
||||||
|
tab: '个人设置',
|
||||||
|
content: <PersonalSetting/>,
|
||||||
|
itemKey: '1'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
if (isRoot()) {
|
||||||
|
panes.push({
|
||||||
|
tab: '运营设置',
|
||||||
|
content: <OperationSetting/>,
|
||||||
|
itemKey: '2'
|
||||||
|
});
|
||||||
|
panes.push({
|
||||||
|
tab: '系统设置',
|
||||||
|
content: <SystemSetting/>,
|
||||||
|
itemKey: '3'
|
||||||
|
});
|
||||||
|
panes.push({
|
||||||
|
tab: '其他设置',
|
||||||
|
content: <OtherSetting/>,
|
||||||
|
itemKey: '4'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Layout>
|
||||||
|
<Layout.Content>
|
||||||
|
<Tabs type="line" defaultActiveKey="1">
|
||||||
|
{panes.map(pane => (
|
||||||
|
<TabPane itemKey={pane.itemKey} tab={pane.tab}>
|
||||||
|
{pane.content}
|
||||||
|
</TabPane>
|
||||||
|
))}
|
||||||
|
</Tabs>
|
||||||
|
</Layout.Content>
|
||||||
|
</Layout>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Setting;
|
351
web/air/src/pages/Token/EditToken.js
Normal file
351
web/air/src/pages/Token/EditToken.js
Normal file
@ -0,0 +1,351 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { API, isMobile, showError, showSuccess, timestamp2string } from '../../helpers';
|
||||||
|
import { renderQuotaWithPrompt } from '../../helpers/render';
|
||||||
|
import {
|
||||||
|
AutoComplete,
|
||||||
|
Banner,
|
||||||
|
Button,
|
||||||
|
Checkbox,
|
||||||
|
DatePicker,
|
||||||
|
Input,
|
||||||
|
Select,
|
||||||
|
SideSheet,
|
||||||
|
Space,
|
||||||
|
Spin,
|
||||||
|
Typography
|
||||||
|
} from '@douyinfe/semi-ui';
|
||||||
|
import Title from '@douyinfe/semi-ui/lib/es/typography/title';
|
||||||
|
import { Divider } from 'semantic-ui-react';
|
||||||
|
|
||||||
|
const EditToken = (props) => {
|
||||||
|
const [isEdit, setIsEdit] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(isEdit);
|
||||||
|
const originInputs = {
|
||||||
|
name: '',
|
||||||
|
remain_quota: isEdit ? 0 : 500000,
|
||||||
|
expired_time: -1,
|
||||||
|
unlimited_quota: false,
|
||||||
|
model_limits_enabled: false,
|
||||||
|
model_limits: []
|
||||||
|
};
|
||||||
|
const [inputs, setInputs] = useState(originInputs);
|
||||||
|
const { name, remain_quota, expired_time, unlimited_quota, model_limits_enabled, model_limits } = inputs;
|
||||||
|
// const [visible, setVisible] = useState(false);
|
||||||
|
const [models, setModels] = useState({});
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const handleInputChange = (name, value) => {
|
||||||
|
setInputs((inputs) => ({ ...inputs, [name]: value }));
|
||||||
|
};
|
||||||
|
const handleCancel = () => {
|
||||||
|
props.handleClose();
|
||||||
|
};
|
||||||
|
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 loadModels = async () => {
|
||||||
|
// let res = await API.get(`/api/user/models`);
|
||||||
|
// const { success, message, data } = res.data;
|
||||||
|
// if (success) {
|
||||||
|
// let localModelOptions = data.map((model) => ({
|
||||||
|
// label: model,
|
||||||
|
// value: model
|
||||||
|
// }));
|
||||||
|
// setModels(localModelOptions);
|
||||||
|
// } else {
|
||||||
|
// showError(message);
|
||||||
|
// }
|
||||||
|
// };
|
||||||
|
|
||||||
|
const loadToken = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
let res = await API.get(`/api/token/${props.editingToken.id}`);
|
||||||
|
const { success, message, data } = res.data;
|
||||||
|
if (success) {
|
||||||
|
if (data.expired_time !== -1) {
|
||||||
|
data.expired_time = timestamp2string(data.expired_time);
|
||||||
|
}
|
||||||
|
// if (data.model_limits !== '') {
|
||||||
|
// data.model_limits = data.model_limits.split(',');
|
||||||
|
// } else {
|
||||||
|
// data.model_limits = [];
|
||||||
|
// }
|
||||||
|
setInputs(data);
|
||||||
|
} else {
|
||||||
|
showError(message);
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
useEffect(() => {
|
||||||
|
setIsEdit(props.editingToken.id !== undefined);
|
||||||
|
}, [props.editingToken.id]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isEdit) {
|
||||||
|
setInputs(originInputs);
|
||||||
|
} else {
|
||||||
|
loadToken().then(
|
||||||
|
() => {
|
||||||
|
// console.log(inputs);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// loadModels();
|
||||||
|
}, [isEdit]);
|
||||||
|
|
||||||
|
// 新增 state 变量 tokenCount 来记录用户想要创建的令牌数量,默认为 1
|
||||||
|
const [tokenCount, setTokenCount] = useState(1);
|
||||||
|
|
||||||
|
// 新增处理 tokenCount 变化的函数
|
||||||
|
const handleTokenCountChange = (value) => {
|
||||||
|
// 确保用户输入的是正整数
|
||||||
|
const count = parseInt(value, 10);
|
||||||
|
if (!isNaN(count) && count > 0) {
|
||||||
|
setTokenCount(count);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 生成一个随机的四位字母数字字符串
|
||||||
|
const generateRandomSuffix = () => {
|
||||||
|
const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||||||
|
let result = '';
|
||||||
|
for (let i = 0; i < 6; i++) {
|
||||||
|
result += characters.charAt(Math.floor(Math.random() * characters.length));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
if (isEdit) {
|
||||||
|
// 编辑令牌的逻辑保持不变
|
||||||
|
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('过期时间格式错误!');
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
localInputs.expired_time = Math.ceil(time / 1000);
|
||||||
|
}
|
||||||
|
// localInputs.model_limits = localInputs.model_limits.join(',');
|
||||||
|
let res = await API.put(`/api/token/`, { ...localInputs, id: parseInt(props.editingToken.id) });
|
||||||
|
const { success, message } = res.data;
|
||||||
|
if (success) {
|
||||||
|
showSuccess('令牌更新成功!');
|
||||||
|
props.refresh();
|
||||||
|
props.handleClose();
|
||||||
|
} else {
|
||||||
|
showError(message);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 处理新增多个令牌的情况
|
||||||
|
let successCount = 0; // 记录成功创建的令牌数量
|
||||||
|
for (let i = 0; i < tokenCount; i++) {
|
||||||
|
let localInputs = { ...inputs };
|
||||||
|
if (i !== 0) {
|
||||||
|
// 如果用户想要创建多个令牌,则给每个令牌一个序号后缀
|
||||||
|
localInputs.name = `${inputs.name}-${generateRandomSuffix()}`;
|
||||||
|
}
|
||||||
|
localInputs.remain_quota = parseInt(localInputs.remain_quota);
|
||||||
|
|
||||||
|
if (localInputs.expired_time !== -1) {
|
||||||
|
let time = Date.parse(localInputs.expired_time);
|
||||||
|
if (isNaN(time)) {
|
||||||
|
showError('过期时间格式错误!');
|
||||||
|
setLoading(false);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
localInputs.expired_time = Math.ceil(time / 1000);
|
||||||
|
}
|
||||||
|
// localInputs.model_limits = localInputs.model_limits.join(',');
|
||||||
|
let res = await API.post(`/api/token/`, localInputs);
|
||||||
|
const { success, message } = res.data;
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
successCount++;
|
||||||
|
} else {
|
||||||
|
showError(message);
|
||||||
|
break; // 如果创建失败,终止循环
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (successCount > 0) {
|
||||||
|
showSuccess(`${successCount}个令牌创建成功,请在列表页面点击复制获取令牌!`);
|
||||||
|
props.refresh();
|
||||||
|
props.handleClose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
setInputs(originInputs); // 重置表单
|
||||||
|
setTokenCount(1); // 重置数量为默认值
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SideSheet
|
||||||
|
placement={isEdit ? 'right' : 'left'}
|
||||||
|
title={<Title level={3}>{isEdit ? '更新令牌信息' : '创建新的令牌'}</Title>}
|
||||||
|
headerStyle={{ borderBottom: '1px solid var(--semi-color-border)' }}
|
||||||
|
bodyStyle={{ borderBottom: '1px solid var(--semi-color-border)' }}
|
||||||
|
visible={props.visiable}
|
||||||
|
footer={
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||||
|
<Space>
|
||||||
|
<Button theme="solid" size={'large'} onClick={submit}>提交</Button>
|
||||||
|
<Button theme="solid" size={'large'} type={'tertiary'} onClick={handleCancel}>取消</Button>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
closeIcon={null}
|
||||||
|
onCancel={() => handleCancel()}
|
||||||
|
width={isMobile() ? '100%' : 600}
|
||||||
|
>
|
||||||
|
<Spin spinning={loading}>
|
||||||
|
<Input
|
||||||
|
style={{ marginTop: 20 }}
|
||||||
|
label="名称"
|
||||||
|
name="name"
|
||||||
|
placeholder={'请输入名称'}
|
||||||
|
onChange={(value) => handleInputChange('name', value)}
|
||||||
|
value={name}
|
||||||
|
autoComplete="new-password"
|
||||||
|
required={!isEdit}
|
||||||
|
/>
|
||||||
|
<Divider />
|
||||||
|
<DatePicker
|
||||||
|
label="过期时间"
|
||||||
|
name="expired_time"
|
||||||
|
placeholder={'请选择过期时间'}
|
||||||
|
onChange={(value) => handleInputChange('expired_time', value)}
|
||||||
|
value={expired_time}
|
||||||
|
autoComplete="new-password"
|
||||||
|
type="dateTime"
|
||||||
|
/>
|
||||||
|
<div style={{ marginTop: 20 }}>
|
||||||
|
<Space>
|
||||||
|
<Button type={'tertiary'} onClick={() => {
|
||||||
|
setExpiredTime(0, 0, 0, 0);
|
||||||
|
}}>永不过期</Button>
|
||||||
|
<Button type={'tertiary'} onClick={() => {
|
||||||
|
setExpiredTime(0, 0, 1, 0);
|
||||||
|
}}>一小时</Button>
|
||||||
|
<Button type={'tertiary'} onClick={() => {
|
||||||
|
setExpiredTime(1, 0, 0, 0);
|
||||||
|
}}>一个月</Button>
|
||||||
|
<Button type={'tertiary'} onClick={() => {
|
||||||
|
setExpiredTime(0, 1, 0, 0);
|
||||||
|
}}>一天</Button>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
<Banner type={'warning'}
|
||||||
|
description={'注意,令牌的额度仅用于限制令牌本身的最大额度使用量,实际的使用受到账户的剩余额度限制。'}></Banner>
|
||||||
|
<div style={{ marginTop: 20 }}>
|
||||||
|
<Typography.Text>{`额度${renderQuotaWithPrompt(remain_quota)}`}</Typography.Text>
|
||||||
|
</div>
|
||||||
|
<AutoComplete
|
||||||
|
style={{ marginTop: 8 }}
|
||||||
|
name="remain_quota"
|
||||||
|
placeholder={'请输入额度'}
|
||||||
|
onChange={(value) => handleInputChange('remain_quota', value)}
|
||||||
|
value={remain_quota}
|
||||||
|
autoComplete="new-password"
|
||||||
|
type="number"
|
||||||
|
// position={'top'}
|
||||||
|
data={[
|
||||||
|
{ value: 500000, label: '1$' },
|
||||||
|
{ value: 5000000, label: '10$' },
|
||||||
|
{ value: 25000000, label: '50$' },
|
||||||
|
{ value: 50000000, label: '100$' },
|
||||||
|
{ value: 250000000, label: '500$' },
|
||||||
|
{ value: 500000000, label: '1000$' }
|
||||||
|
]}
|
||||||
|
disabled={unlimited_quota}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{!isEdit && (
|
||||||
|
<>
|
||||||
|
<div style={{ marginTop: 20 }}>
|
||||||
|
<Typography.Text>新建数量</Typography.Text>
|
||||||
|
</div>
|
||||||
|
<AutoComplete
|
||||||
|
style={{ marginTop: 8 }}
|
||||||
|
label="数量"
|
||||||
|
placeholder={'请选择或输入创建令牌的数量'}
|
||||||
|
onChange={(value) => handleTokenCountChange(value)}
|
||||||
|
onSelect={(value) => handleTokenCountChange(value)}
|
||||||
|
value={tokenCount.toString()}
|
||||||
|
autoComplete="off"
|
||||||
|
type="number"
|
||||||
|
data={[
|
||||||
|
{ value: 10, label: '10个' },
|
||||||
|
{ value: 20, label: '20个' },
|
||||||
|
{ value: 30, label: '30个' },
|
||||||
|
{ value: 100, label: '100个' }
|
||||||
|
]}
|
||||||
|
disabled={unlimited_quota}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Button style={{ marginTop: 8 }} type={'warning'} onClick={() => {
|
||||||
|
setUnlimitedQuota();
|
||||||
|
}}>{unlimited_quota ? '取消无限额度' : '设为无限额度'}</Button>
|
||||||
|
</div>
|
||||||
|
{/* <Divider />
|
||||||
|
<div style={{ marginTop: 10, display: 'flex' }}>
|
||||||
|
<Space>
|
||||||
|
<Checkbox
|
||||||
|
name="model_limits_enabled"
|
||||||
|
checked={model_limits_enabled}
|
||||||
|
onChange={(e) => handleInputChange('model_limits_enabled', e.target.checked)}
|
||||||
|
>
|
||||||
|
</Checkbox>
|
||||||
|
<Typography.Text>启用模型限制(非必要,不建议启用)</Typography.Text>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
style={{ marginTop: 8 }}
|
||||||
|
placeholder={'请选择该渠道所支持的模型'}
|
||||||
|
name="models"
|
||||||
|
required
|
||||||
|
multiple
|
||||||
|
selection
|
||||||
|
onChange={value => {
|
||||||
|
handleInputChange('model_limits', value);
|
||||||
|
}}
|
||||||
|
value={inputs.model_limits}
|
||||||
|
autoComplete="new-password"
|
||||||
|
optionList={models}
|
||||||
|
disabled={!model_limits_enabled}
|
||||||
|
/> */}
|
||||||
|
</Spin>
|
||||||
|
</SideSheet>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default EditToken;
|
17
web/air/src/pages/Token/index.js
Normal file
17
web/air/src/pages/Token/index.js
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import TokensTable from '../../components/TokensTable';
|
||||||
|
import {Layout} from "@douyinfe/semi-ui";
|
||||||
|
const Token = () => (
|
||||||
|
<>
|
||||||
|
<Layout>
|
||||||
|
<Layout.Header>
|
||||||
|
<h3>我的令牌</h3>
|
||||||
|
</Layout.Header>
|
||||||
|
<Layout.Content>
|
||||||
|
<TokensTable/>
|
||||||
|
</Layout.Content>
|
||||||
|
</Layout>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default Token;
|
314
web/air/src/pages/TopUp/index.js
Normal file
314
web/air/src/pages/TopUp/index.js
Normal file
@ -0,0 +1,314 @@
|
|||||||
|
import React, {useEffect, useState} from 'react';
|
||||||
|
import {API, isMobile, showError, showInfo, showSuccess} from '../../helpers';
|
||||||
|
import {renderNumber, renderQuota} from '../../helpers/render';
|
||||||
|
import {Col, Layout, Row, Typography, Card, Button, Form, Divider, Space, Modal} from "@douyinfe/semi-ui";
|
||||||
|
import Title from "@douyinfe/semi-ui/lib/es/typography/title";
|
||||||
|
import Text from '@douyinfe/semi-ui/lib/es/typography/text';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
|
const TopUp = () => {
|
||||||
|
const [redemptionCode, setRedemptionCode] = useState('');
|
||||||
|
const [topUpCode, setTopUpCode] = useState('');
|
||||||
|
const [topUpCount, setTopUpCount] = useState(10);
|
||||||
|
const [minTopupCount, setMinTopUpCount] = useState(1);
|
||||||
|
const [amount, setAmount] = useState(0.0);
|
||||||
|
const [minTopUp, setMinTopUp] = useState(1);
|
||||||
|
const [topUpLink, setTopUpLink] = useState('');
|
||||||
|
const [enableOnlineTopUp, setEnableOnlineTopUp] = useState(false);
|
||||||
|
const [userQuota, setUserQuota] = useState(0);
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [payWay, setPayWay] = useState('');
|
||||||
|
|
||||||
|
const topUp = async () => {
|
||||||
|
if (redemptionCode === '') {
|
||||||
|
showInfo('请输入兑换码!')
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setIsSubmitting(true);
|
||||||
|
try {
|
||||||
|
const res = await API.post('/api/user/topup', {
|
||||||
|
key: redemptionCode
|
||||||
|
});
|
||||||
|
const {success, message, data} = res.data;
|
||||||
|
if (success) {
|
||||||
|
showSuccess('兑换成功!');
|
||||||
|
Modal.success({title: '兑换成功!', content: '成功兑换额度:' + renderQuota(data), centered: true});
|
||||||
|
setUserQuota((quota) => {
|
||||||
|
return quota + data;
|
||||||
|
});
|
||||||
|
setRedemptionCode('');
|
||||||
|
} else {
|
||||||
|
showError(message);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
showError('请求失败');
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openTopUpLink = () => {
|
||||||
|
if (!topUpLink) {
|
||||||
|
showError('超级管理员未设置充值链接!');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.open(topUpLink, '_blank');
|
||||||
|
};
|
||||||
|
|
||||||
|
const preTopUp = async (payment) => {
|
||||||
|
if (!enableOnlineTopUp) {
|
||||||
|
showError('管理员未开启在线充值!');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (amount === 0) {
|
||||||
|
await getAmount();
|
||||||
|
}
|
||||||
|
if (topUpCount < minTopUp) {
|
||||||
|
showInfo('充值数量不能小于' + minTopUp);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPayWay(payment)
|
||||||
|
setOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
const onlineTopUp = async () => {
|
||||||
|
if (amount === 0) {
|
||||||
|
await getAmount();
|
||||||
|
}
|
||||||
|
if (topUpCount < minTopUp) {
|
||||||
|
showInfo('充值数量不能小于' + minTopUp);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setOpen(false);
|
||||||
|
try {
|
||||||
|
const res = await API.post('/api/user/pay', {
|
||||||
|
amount: parseInt(topUpCount),
|
||||||
|
top_up_code: topUpCode,
|
||||||
|
payment_method: payWay
|
||||||
|
});
|
||||||
|
if (res !== undefined) {
|
||||||
|
const {message, data} = res.data;
|
||||||
|
// showInfo(message);
|
||||||
|
if (message === 'success') {
|
||||||
|
|
||||||
|
let params = data
|
||||||
|
let url = res.data.url
|
||||||
|
let form = document.createElement('form')
|
||||||
|
form.action = url
|
||||||
|
form.method = 'POST'
|
||||||
|
// 判断是否为safari浏览器
|
||||||
|
let isSafari = navigator.userAgent.indexOf("Safari") > -1 && navigator.userAgent.indexOf("Chrome") < 1;
|
||||||
|
if (!isSafari) {
|
||||||
|
form.target = '_blank'
|
||||||
|
}
|
||||||
|
for (let key in params) {
|
||||||
|
let input = document.createElement('input')
|
||||||
|
input.type = 'hidden'
|
||||||
|
input.name = key
|
||||||
|
input.value = params[key]
|
||||||
|
form.appendChild(input)
|
||||||
|
}
|
||||||
|
document.body.appendChild(form)
|
||||||
|
form.submit()
|
||||||
|
document.body.removeChild(form)
|
||||||
|
} else {
|
||||||
|
showError(data);
|
||||||
|
// setTopUpCount(parseInt(res.data.count));
|
||||||
|
// setAmount(parseInt(data));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
showError(res);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.log(err);
|
||||||
|
} finally {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getUserQuota = async () => {
|
||||||
|
let res = await API.get(`/api/user/self`);
|
||||||
|
const {success, message, data} = res.data;
|
||||||
|
if (success) {
|
||||||
|
setUserQuota(data.quota);
|
||||||
|
} 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);
|
||||||
|
}
|
||||||
|
if (status.min_topup) {
|
||||||
|
setMinTopUp(status.min_topup);
|
||||||
|
}
|
||||||
|
if (status.enable_online_topup) {
|
||||||
|
setEnableOnlineTopUp(status.enable_online_topup);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
getUserQuota().then();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const renderAmount = () => {
|
||||||
|
// console.log(amount);
|
||||||
|
return amount + '元';
|
||||||
|
}
|
||||||
|
|
||||||
|
const getAmount = async (value) => {
|
||||||
|
if (value === undefined) {
|
||||||
|
value = topUpCount;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await API.post('/api/user/amount', {
|
||||||
|
amount: parseFloat(value),
|
||||||
|
top_up_code: topUpCode
|
||||||
|
});
|
||||||
|
if (res !== undefined) {
|
||||||
|
const {message, data} = res.data;
|
||||||
|
// showInfo(message);
|
||||||
|
if (message === 'success') {
|
||||||
|
setAmount(parseFloat(data));
|
||||||
|
} else {
|
||||||
|
showError(data);
|
||||||
|
// setTopUpCount(parseInt(res.data.count));
|
||||||
|
// setAmount(parseInt(data));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
showError(res);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.log(err);
|
||||||
|
} finally {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Layout>
|
||||||
|
<Layout.Header>
|
||||||
|
<h3>充值额度</h3>
|
||||||
|
</Layout.Header>
|
||||||
|
<Layout.Content>
|
||||||
|
<Modal
|
||||||
|
title="确定要充值吗"
|
||||||
|
visible={open}
|
||||||
|
onOk={onlineTopUp}
|
||||||
|
onCancel={handleCancel}
|
||||||
|
maskClosable={false}
|
||||||
|
size={'small'}
|
||||||
|
centered={true}
|
||||||
|
>
|
||||||
|
<p>充值数量:{topUpCount}$</p>
|
||||||
|
<p>实付金额:{renderAmount()}</p>
|
||||||
|
<p>是否确认充值?</p>
|
||||||
|
</Modal>
|
||||||
|
<div style={{marginTop: 20, display: 'flex', justifyContent: 'center'}}>
|
||||||
|
<Card
|
||||||
|
style={{width: '500px', padding: '20px'}}
|
||||||
|
>
|
||||||
|
<Title level={3} style={{textAlign: 'center'}}>余额 {renderQuota(userQuota)}</Title>
|
||||||
|
<div style={{marginTop: 20}}>
|
||||||
|
<Divider>
|
||||||
|
兑换余额
|
||||||
|
</Divider>
|
||||||
|
<Form>
|
||||||
|
<Form.Input
|
||||||
|
field={'redemptionCode'}
|
||||||
|
label={'兑换码'}
|
||||||
|
placeholder='兑换码'
|
||||||
|
name='redemptionCode'
|
||||||
|
value={redemptionCode}
|
||||||
|
onChange={(value) => {
|
||||||
|
setRedemptionCode(value);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Space>
|
||||||
|
{
|
||||||
|
topUpLink ?
|
||||||
|
<Button type={'primary'} theme={'solid'} onClick={openTopUpLink}>
|
||||||
|
获取兑换码
|
||||||
|
</Button> : null
|
||||||
|
}
|
||||||
|
<Button type={"warning"} theme={'solid'} onClick={topUp}
|
||||||
|
disabled={isSubmitting}>
|
||||||
|
{isSubmitting ? '兑换中...' : '兑换'}
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
{/* <div style={{marginTop: 20}}>
|
||||||
|
<Divider>
|
||||||
|
在线充值
|
||||||
|
</Divider>
|
||||||
|
<Form>
|
||||||
|
<Form.Input
|
||||||
|
disabled={!enableOnlineTopUp}
|
||||||
|
field={'redemptionCount'}
|
||||||
|
label={'实付金额:' + renderAmount()}
|
||||||
|
placeholder={'充值数量,最低' + minTopUp + '$'}
|
||||||
|
name='redemptionCount'
|
||||||
|
type={'number'}
|
||||||
|
value={topUpCount}
|
||||||
|
suffix={'$'}
|
||||||
|
min={minTopUp}
|
||||||
|
defaultValue={minTopUp}
|
||||||
|
max={100000}
|
||||||
|
onChange={async (value) => {
|
||||||
|
if (value < 1) {
|
||||||
|
value = 1;
|
||||||
|
}
|
||||||
|
if (value > 100000) {
|
||||||
|
value = 100000;
|
||||||
|
}
|
||||||
|
setTopUpCount(value);
|
||||||
|
await getAmount(value);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Space>
|
||||||
|
<Button type={'primary'} theme={'solid'} onClick={
|
||||||
|
async () => {
|
||||||
|
preTopUp('zfb')
|
||||||
|
}
|
||||||
|
}>
|
||||||
|
支付宝
|
||||||
|
</Button>
|
||||||
|
<Button style={{backgroundColor: 'rgba(var(--semi-green-5), 1)'}}
|
||||||
|
type={'primary'}
|
||||||
|
theme={'solid'} onClick={
|
||||||
|
async () => {
|
||||||
|
preTopUp('wx')
|
||||||
|
}
|
||||||
|
}>
|
||||||
|
微信
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</Form>
|
||||||
|
</div> */}
|
||||||
|
{/*<div style={{ display: 'flex', justifyContent: 'right' }}>*/}
|
||||||
|
{/* <Text>*/}
|
||||||
|
{/* <Link onClick={*/}
|
||||||
|
{/* async () => {*/}
|
||||||
|
{/* window.location.href = '/topup/history'*/}
|
||||||
|
{/* }*/}
|
||||||
|
{/* }>充值记录</Link>*/}
|
||||||
|
{/* </Text>*/}
|
||||||
|
{/*</div>*/}
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</Layout.Content>
|
||||||
|
</Layout>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TopUp;
|
98
web/air/src/pages/User/AddUser.js
Normal file
98
web/air/src/pages/User/AddUser.js
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { API, isMobile, showError, showSuccess } from '../../helpers';
|
||||||
|
import Title from '@douyinfe/semi-ui/lib/es/typography/title';
|
||||||
|
import { Button, Input, SideSheet, Space, Spin } from '@douyinfe/semi-ui';
|
||||||
|
|
||||||
|
const AddUser = (props) => {
|
||||||
|
const originInputs = {
|
||||||
|
username: '',
|
||||||
|
display_name: '',
|
||||||
|
password: ''
|
||||||
|
};
|
||||||
|
const [inputs, setInputs] = useState(originInputs);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const { username, display_name, password } = inputs;
|
||||||
|
|
||||||
|
const handleInputChange = (name, value) => {
|
||||||
|
setInputs((inputs) => ({ ...inputs, [name]: value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
if (inputs.username === '' || inputs.password === '') return;
|
||||||
|
const res = await API.post(`/api/user/`, inputs);
|
||||||
|
const { success, message } = res.data;
|
||||||
|
if (success) {
|
||||||
|
showSuccess('用户账户创建成功!');
|
||||||
|
setInputs(originInputs);
|
||||||
|
props.refresh();
|
||||||
|
props.handleClose();
|
||||||
|
} else {
|
||||||
|
showError(message);
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
props.handleClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SideSheet
|
||||||
|
placement={'left'}
|
||||||
|
title={<Title level={3}>{'添加用户'}</Title>}
|
||||||
|
headerStyle={{ borderBottom: '1px solid var(--semi-color-border)' }}
|
||||||
|
bodyStyle={{ borderBottom: '1px solid var(--semi-color-border)' }}
|
||||||
|
visible={props.visible}
|
||||||
|
footer={
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||||
|
<Space>
|
||||||
|
<Button theme="solid" size={'large'} onClick={submit}>提交</Button>
|
||||||
|
<Button theme="solid" size={'large'} type={'tertiary'} onClick={handleCancel}>取消</Button>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
closeIcon={null}
|
||||||
|
onCancel={() => handleCancel()}
|
||||||
|
width={isMobile() ? '100%' : 600}
|
||||||
|
>
|
||||||
|
<Spin spinning={loading}>
|
||||||
|
<Input
|
||||||
|
style={{ marginTop: 20 }}
|
||||||
|
label="用户名"
|
||||||
|
name="username"
|
||||||
|
addonBefore={'用户名'}
|
||||||
|
placeholder={'请输入用户名'}
|
||||||
|
onChange={value => handleInputChange('username', value)}
|
||||||
|
value={username}
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
style={{ marginTop: 20 }}
|
||||||
|
addonBefore={'显示名'}
|
||||||
|
label="显示名称"
|
||||||
|
name="display_name"
|
||||||
|
autoComplete="off"
|
||||||
|
placeholder={'请输入显示名称'}
|
||||||
|
onChange={value => handleInputChange('display_name', value)}
|
||||||
|
value={display_name}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
style={{ marginTop: 20 }}
|
||||||
|
label="密 码"
|
||||||
|
name="password"
|
||||||
|
type={'password'}
|
||||||
|
addonBefore={'密码'}
|
||||||
|
placeholder={'请输入密码'}
|
||||||
|
onChange={value => handleInputChange('password', value)}
|
||||||
|
value={password}
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
</Spin>
|
||||||
|
</SideSheet>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AddUser;
|
220
web/air/src/pages/User/EditUser.js
Normal file
220
web/air/src/pages/User/EditUser.js
Normal file
@ -0,0 +1,220 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { API, isMobile, showError, showSuccess } from '../../helpers';
|
||||||
|
import { renderQuotaWithPrompt } from '../../helpers/render';
|
||||||
|
import Title from '@douyinfe/semi-ui/lib/es/typography/title';
|
||||||
|
import { Button, Divider, Input, Select, SideSheet, Space, Spin, Typography } from '@douyinfe/semi-ui';
|
||||||
|
|
||||||
|
const EditUser = (props) => {
|
||||||
|
const userId = props.editingUser.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, telegram_id, email, quota, group } =
|
||||||
|
inputs;
|
||||||
|
const handleInputChange = (name, value) => {
|
||||||
|
setInputs((inputs) => ({ ...inputs, [name]: value }));
|
||||||
|
};
|
||||||
|
const fetchGroups = async () => {
|
||||||
|
try {
|
||||||
|
let res = await API.get(`/api/group/`);
|
||||||
|
setGroupOptions(res.data.data.map((group) => ({
|
||||||
|
label: group,
|
||||||
|
value: group
|
||||||
|
})));
|
||||||
|
} catch (error) {
|
||||||
|
showError(error.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const handleCancel = () => {
|
||||||
|
props.handleClose();
|
||||||
|
};
|
||||||
|
const loadUser = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}, [props.editingUser.id]);
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
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('用户信息更新成功!');
|
||||||
|
props.refresh();
|
||||||
|
props.handleClose();
|
||||||
|
} else {
|
||||||
|
showError(message);
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SideSheet
|
||||||
|
placement={'right'}
|
||||||
|
title={<Title level={3}>{'编辑用户'}</Title>}
|
||||||
|
headerStyle={{ borderBottom: '1px solid var(--semi-color-border)' }}
|
||||||
|
bodyStyle={{ borderBottom: '1px solid var(--semi-color-border)' }}
|
||||||
|
visible={props.visible}
|
||||||
|
footer={
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||||
|
<Space>
|
||||||
|
<Button theme="solid" size={'large'} onClick={submit}>提交</Button>
|
||||||
|
<Button theme="solid" size={'large'} type={'tertiary'} onClick={handleCancel}>取消</Button>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
closeIcon={null}
|
||||||
|
onCancel={() => handleCancel()}
|
||||||
|
width={isMobile() ? '100%' : 600}
|
||||||
|
>
|
||||||
|
<Spin spinning={loading}>
|
||||||
|
<div style={{ marginTop: 20 }}>
|
||||||
|
<Typography.Text>用户名</Typography.Text>
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
label="用户名"
|
||||||
|
name="username"
|
||||||
|
placeholder={'请输入新的用户名'}
|
||||||
|
onChange={value => handleInputChange('username', value)}
|
||||||
|
value={username}
|
||||||
|
autoComplete="new-password"
|
||||||
|
/>
|
||||||
|
<div style={{ marginTop: 20 }}>
|
||||||
|
<Typography.Text>密码</Typography.Text>
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
label="密码"
|
||||||
|
name="password"
|
||||||
|
type={'password'}
|
||||||
|
placeholder={'请输入新的密码,最短 8 位'}
|
||||||
|
onChange={value => handleInputChange('password', value)}
|
||||||
|
value={password}
|
||||||
|
autoComplete="new-password"
|
||||||
|
/>
|
||||||
|
<div style={{ marginTop: 20 }}>
|
||||||
|
<Typography.Text>显示名称</Typography.Text>
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
label="显示名称"
|
||||||
|
name="display_name"
|
||||||
|
placeholder={'请输入新的显示名称'}
|
||||||
|
onChange={value => handleInputChange('display_name', value)}
|
||||||
|
value={display_name}
|
||||||
|
autoComplete="new-password"
|
||||||
|
/>
|
||||||
|
{
|
||||||
|
userId && <>
|
||||||
|
<div style={{ marginTop: 20 }}>
|
||||||
|
<Typography.Text>分组</Typography.Text>
|
||||||
|
</div>
|
||||||
|
<Select
|
||||||
|
placeholder={'请选择分组'}
|
||||||
|
name="group"
|
||||||
|
fluid
|
||||||
|
search
|
||||||
|
selection
|
||||||
|
allowAdditions
|
||||||
|
additionLabel={'请在系统设置页面编辑分组倍率以添加新的分组:'}
|
||||||
|
onChange={value => handleInputChange('group', value)}
|
||||||
|
value={inputs.group}
|
||||||
|
autoComplete="new-password"
|
||||||
|
optionList={groupOptions}
|
||||||
|
/>
|
||||||
|
<div style={{ marginTop: 20 }}>
|
||||||
|
<Typography.Text>{`剩余额度${renderQuotaWithPrompt(quota)}`}</Typography.Text>
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
name="quota"
|
||||||
|
placeholder={'请输入新的剩余额度'}
|
||||||
|
onChange={value => handleInputChange('quota', value)}
|
||||||
|
value={quota}
|
||||||
|
type={'number'}
|
||||||
|
autoComplete="new-password"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
<Divider style={{ marginTop: 20 }}>以下信息不可修改</Divider>
|
||||||
|
<div style={{ marginTop: 20 }}>
|
||||||
|
<Typography.Text>已绑定的 GitHub 账户</Typography.Text>
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
name="github_id"
|
||||||
|
value={github_id}
|
||||||
|
autoComplete="new-password"
|
||||||
|
placeholder="此项只读,需要用户通过个人设置页面的相关绑定按钮进行绑定,不可直接修改"
|
||||||
|
readonly
|
||||||
|
/>
|
||||||
|
<div style={{ marginTop: 20 }}>
|
||||||
|
<Typography.Text>已绑定的微信账户</Typography.Text>
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
name="wechat_id"
|
||||||
|
value={wechat_id}
|
||||||
|
autoComplete="new-password"
|
||||||
|
placeholder="此项只读,需要用户通过个人设置页面的相关绑定按钮进行绑定,不可直接修改"
|
||||||
|
readonly
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
name="telegram_id"
|
||||||
|
value={telegram_id}
|
||||||
|
autoComplete="new-password"
|
||||||
|
placeholder="此项只读,需要用户通过个人设置页面的相关绑定按钮进行绑定,不可直接修改"
|
||||||
|
readonly
|
||||||
|
/>
|
||||||
|
<div style={{ marginTop: 20 }}>
|
||||||
|
<Typography.Text>已绑定的邮箱账户</Typography.Text>
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
name="email"
|
||||||
|
value={email}
|
||||||
|
autoComplete="new-password"
|
||||||
|
placeholder="此项只读,需要用户通过个人设置页面的相关绑定按钮进行绑定,不可直接修改"
|
||||||
|
readonly
|
||||||
|
/>
|
||||||
|
</Spin>
|
||||||
|
</SideSheet>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default EditUser;
|
18
web/air/src/pages/User/index.js
Normal file
18
web/air/src/pages/User/index.js
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import UsersTable from '../../components/UsersTable';
|
||||||
|
import {Layout} from "@douyinfe/semi-ui";
|
||||||
|
|
||||||
|
const User = () => (
|
||||||
|
<>
|
||||||
|
<Layout>
|
||||||
|
<Layout.Header>
|
||||||
|
<h3>管理用户</h3>
|
||||||
|
</Layout.Header>
|
||||||
|
<Layout.Content>
|
||||||
|
<UsersTable/>
|
||||||
|
</Layout.Content>
|
||||||
|
</Layout>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default User;
|
5
web/air/vercel.json
Normal file
5
web/air/vercel.json
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"github": {
|
||||||
|
"silent": true
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user